Skip to content

Stop the readiness probe blacklisting itself, and gate the fleet on wait_for_servers - #356

Open
sibson wants to merge 7 commits into
mainfrom
claude/fix-healthcheck-blacklist
Open

Stop the readiness probe blacklisting itself, and gate the fleet on wait_for_servers#356
sibson wants to merge 7 commits into
mainfrom
claude/fix-healthcheck-blacklist

Conversation

@sibson

@sibson sibson commented Aug 15, 2026

Copy link
Copy Markdown
Owner

This branch was written before #351/#353 and never got a PR, so it sat stranded while the stack moved on — and the problem it fixes kept resurfacing. Rebased onto current main and verified against a real fleet.

The problem

The base HEALTHCHECK opened an RFB connection and hung up without speaking the protocol. Xvnc counts every connection closed before a successful authentication towards BlacklistThreshold (default 5), so the probe blacklisted 127.0.0.1 about ten seconds after start-up and refused every session after that — captures against tigervnc-auth silently produced no PNG.

The workaround at the time was -BlacklistThreshold=1000000, which turns the brute-force protection off rather than fixing the probe.

The fix

Measured against TigerVNC 1.12.0, no RFB-level probe avoids the counter: a bare connect, a version exchange, and a version-plus-security-types exchange each trip it after five attempts, and only a successful authentication clears the mark. So the probe reads the kernel socket table with ss instead, confirming the port is listening without a connection ever existing.

The threshold is then reduced to a targeted 50 rather than removed, because the probe was not the only offender: vncservers.py's port_open() guard leaves about seven unauthenticated closes per run, and test_cli.py adds one deliberate wrong password.

Readiness itself moves out of the container: wait_for_servers.py gates both server families by connecting, capturing, and requiring the content that server is supposed to render — the same assertion the tests make, so the gate and the tests cannot drift. The old marker file was written unconditionally, so its failure mode was a false green: a server that came up blank still reported healthy.

Verification against a real fleet

  • whole fleet rebuilt and started: all five containers reach Healthy on the connectionless probe
  • wait_for_servers.py docker: every server ready on the first attempt
  • full functional suite: 31 tests, OK (4 skipped — OS-hosted servers)
  • after that run, tigervnc's log shows 20 connection entries and 0 blacklist entries, while roughly 35 healthcheck probes had elapsed. Connections track test sessions, not probes.

One integration fix

x11vnc grew its own HEALTHCHECK while this branch sat unmerged, keeping both habits removed from base: an RFB connection via /dev/tcp, and the marker-file gate. The connection is harmless there — x11vnc has no blacklist — but it is the pattern that broke TigerVNC, and no reason to leave an exception lying around to be copied. Its check now uses ss plus the thing genuinely specific to that container, its event sink.

Supersedes #355, which raised the threshold on both entrypoint branches — spreading the workaround instead of deleting it.

🤖 Generated with Claude Code

sibson and others added 5 commits August 14, 2026 23:05
The base HEALTHCHECK opened an RFB connection and hung up without
speaking the protocol. Xvnc counts every connection closed before a
successful authentication towards BlacklistThreshold (default 5), so
the probe blacklisted 127.0.0.1 about ten seconds after start-up and
refused every session after that -- captures against tigervnc-auth
silently produced no PNG. The workaround was -BlacklistThreshold=1000000,
which turned the brute-force protection off rather than fixing the probe.

Measured against TigerVNC 1.12.0, no RFB-level probe avoids the counter:
a bare connect, a version exchange, and a version-plus-security-types
exchange each trip it after five attempts, and only a successful
authentication clears the mark. So the probe now reads the kernel socket
table with ss instead, confirming the port is listening without a
connection ever existing. Over 25 minutes and four full functional runs
the fleet logged 44 connections, all from tests and none from the probe,
with no blacklisting.

The threshold is reduced to a targeted 50 rather than removed, because
the probe was not the only offender: vncservers.py's port_open() guard
leaves about seven unauthenticated closes per run, and test_cli.py adds
one deliberate wrong password. A full run interleaves enough successful
authentications to stay under the default, but running test_cli.py alone
repeatedly does not.

Also installs x11-utils in the base stage. draw-content.sh waits on
xdpyinfo and xwininfo, neither of which existed in the tigervnc image, so
both retry loops ran to exhaustion and the readiness marker was really a
fixed 21.3s sleep -- long enough to pass by luck, and no kind of check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
draw-content.sh waited with `cmd >/dev/null 2>&1 && break`, which cannot
distinguish "not ready yet" from "that program is not installed", and then
touched the readiness marker unconditionally once the loops finished. Both
waits therefore degraded into sleeps on any image missing the tools, and the
marker asserted nothing at all.

That is what happened: x11-utils was absent from the tigervnc image, so the
marker was really a fixed 21.3s sleep. Installing the package fixed that
instance but not the shape -- an xlogo that failed to map would still have
produced a marker, and so a green run against a blank framebuffer.

Missing tools are now a hard error, and a wait that runs out is a hard error
rather than a fall-through to touching the marker.

Measured on an image with xdpyinfo and xwininfo removed: the old script
reported the container healthy with the marker written, the new one leaves
the marker absent, never reports healthy, and logs which tool is missing.
On a correct image the marker now appears 0.76s after start rather than
21.27s, because the waits do real work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Readiness for the Docker fleet was a marker file created by
draw-content.sh and checked by the image HEALTHCHECK -- a weaker,
in-container imitation of the gate the OS-hosted servers already used.
It could only ever approximate what the tests need, and its failure mode
was a false green: the marker was written unconditionally, so a server
that came up blank still reported healthy.

wait_for_servers.py now gates both families. wait_until_ready() asserts
what test_capture asserts -- connect, capture, and require the content
that server is supposed to render -- with the flatness rule extracted so
the gate and the test cannot drift apart. renders_desktop already
distinguished the servers expected to be blank, so vncev and the
OS-hosted servers stop at the handshake as before.

That leaves the HEALTHCHECK doing liveness only: the process bound its
port. It is now identical in every stage, so the vncev and
libvncserver-example overrides are gone, and draw-content.sh no longer
writes a marker at all.

Verified against a tigervnc image with xdpyinfo removed, which comes up
blank: the container still reports healthy, because its port is bound,
and the readiness gate fails it by name -- 'capture is flat' on every
attempt, then exit 1 out of wait_for_servers.py, which fails servers-up.
That is the case the old marker reported ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
x11vnc grew its own HEALTHCHECK while this branch sat unmerged, and it
kept both habits this branch removes from base: it opens an RFB connection
to /dev/tcp, and it gates on draw-content.sh's marker file.

Neither belongs now. The connection is harmless against x11vnc, which has
no blacklist, but it is the pattern that broke TigerVNC and there is no
reason to keep an exception around waiting to be copied. The marker file
is the weaker in-container imitation of readiness that
wait_for_servers.py now replaces for every server.

What is specific to this container is its event sink, so that is what the
check keeps beyond base's liveness probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comments explained a mechanism that is gone by narrating how it
failed. What a reader needs is why the waits here must stay loud, and why
the blacklist threshold is 50 rather than the default -- both of which are
true whatever came before.
Comment thread tests/functional/vncservers.py Outdated
def port_open(host: str, port: int, timeout: float = PORT_PROBE_TIMEOUT) -> bool:
"""Whether ``port`` accepts a connection. Cheap liveness, not readiness.

This hangs up without authenticating, which TigerVNC counts towards

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

over comment

Comment thread .github/workflows/ci.yml
@@ -98,6 +98,14 @@ jobs:
- name: Build and start the VNC test servers
run: docker compose -f tests/servers/docker-compose.yml up -d --build --wait

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

over comment

The blacklist paragraph stays: without it the natural fix for a readiness
probe is to make it speak more of the protocol, which was measured not to
help. Around it went restatement -- where readiness lives, said three
times, and what the check does, which the check says.


def distinct_colours(image: Image.Image) -> Optional[int]:
"""How many colours ``image`` contains, or None above ``MAX_COLOURS``."""

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

over comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted rather than shortened. port_open's blacklist aside, has_expected_content's flat-capture paragraph, wait_until_ready's essay, and the CI step's comment (which restated its own name) are all gone -- 38 lines out, 8 in.

Two of your three flags were docstring paragraphs, which I had never audited because I scoped the rule to # comments. That was the gap.

Docstring second paragraphs counted as comments and had never been
audited; the CI step's comment restated its own name. Gone: the port_open
blacklist aside, the flat-capture explanation, the wait_until_ready essay,
the readiness gate's rationale.

What is left is what a reader cannot recover: that servers accept
connections before they can serve, that Screen Sharing's first connection
is what starts it, and that the reactor thread outlives its clients.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant