Skip to content

The handshake reaper stops firing behind the tests' backs - #6

Merged
karngyan merged 1 commit into
mainfrom
worktree-relay-handshake-flake
Aug 8, 2026
Merged

The handshake reaper stops firing behind the tests' backs#6
karngyan merged 1 commit into
mainfrom
worktree-relay-handshake-flake

Conversation

@karngyan

@karngyan karngyan commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The failures

relay/test/hub.test.ts went red twice this week, in two different tests, and neither reproduced locally.

  • main @ 8b7a07ccdaemon disconnect closes every client 1012 "daemon gone": expected { code: 4001, … } to deeply equal { code: 1012, reason: 'daemon gone' }
  • #4closes only the client that sent an oversized frame: expect(f.channel).toBe(2) got 0

Different tests, different assertions. One cause.

Cause

vitest.config.ts bound HANDSHAKE_TIMEOUT_MS: 50 so the reap tests could run in real time. That made the reaper ambient for the entire file. Nearly every test dials a client; most have no reason to send immediately; a loaded runner stretches dial-to-first-byte past 50 ms. alarm() (src/hub.ts:297) then does precisely what it should to a client that has never spoken — ws.close(4001, 'handshake timeout') and retireClient(ws, true), which sends the daemon closed{channel}.

Both failures are that, seen from two angles:

  • 4001 vs 1012 — the reaper's close code arrived before the daemon-loss teardown's. That is hub.ts:308 verbatim.
  • channel 0 vs 2Leg buffers control and data frames in one queue, so the reaper's closed{channel:2} control (channel 0) sat in front of the data frame the test was waiting for and nextFrame() handed it back.

Fix

Invert the default. The config binds ten minutes — past any deadline vitest will let a test reach — and the two tests that are about reaping bind 50 ms for themselves via a new handshakeDeadline() harness helper.

It must be called before anything dials: the deadline is read twice, once to arm the alarm as each client is accepted (hub.ts:102) and once inside alarm() to decide who is overdue. Setting it after the dial arms minutes out and then waits on a reap that never lands. The helper's doc comment says so.

The channel-cap test drops its own inline copy of this workaround — it hit this wall first and had been carrying a bespoke runInDurableObject poke to 600_000 ever since. Its sleep stays, now as the assertion that the default really does outlast a test.

A test that says nothing about the deadline is no longer making a silent bet on how fast the runner is.

Verification — reproduced, not re-run

  • Binding the deadline at 1 ms: 4 failed / 86 passed, including both CI signatures verbatim (4001 vs 1012, and the oversized-frame test). That is the diagnosis confirmed rather than inferred.
  • Binding it at ten minutes: 90/90, five consecutive runs.
  • Mutation check — neutering handshakeDeadline() to a no-op fails exactly the two reap tests and nothing else. That is what establishes both halves: the opt-in is load-bearing where it is used, and the other 19 tests in the file no longer depend on the reaper's timing at all.
  • make lint and make test clean: Go all ok, web 601/601, relay 90/90.

No production code changed — src/ is untouched.

🤖 Generated with Claude Code

`test/hub.test.ts` failed twice in CI this week, in two different tests,
and neither reproduced locally. One root cause.

vitest.config.ts bound HANDSHAKE_TIMEOUT_MS at 50 ms so the reap tests
could run in real time. That made the reaper ambient for the whole file.
Almost every test dials a client, most have no reason to send at once,
and a loaded runner stretches dial-to-first-byte past 50 ms — at which
point alarm() does exactly what it should to a client that looks idle:
close 4001, and tell the daemon `closed{channel}`.

Both failures are that, seen from different angles. `daemon disconnect
closes every client 1012` got 4001, the reaper's code arriving before
the teardown's. The oversized-frame test asked for the frame on channel
2 and got channel 0, because `Leg` queues control and data together and
the reaper's `closed` control had landed in front of it.

So the default is inverted. The config now binds ten minutes — past any
deadline vitest lets a test reach — and the two tests that are *about*
reaping bind 50 ms for themselves through a new `handshakeDeadline()`
helper, before they dial. It has to be before: the deadline is read once
to arm the alarm at accept and once in alarm() to judge who is overdue,
and setting it later arms minutes out and then waits for a reap that
never comes.

The channel-cap test loses its own copy of this workaround, which it had
carried since it hit the same wall first. Its sleep stays: it is now the
assertion that the default really does outlast a test.

Verified by reproducing rather than by re-running. Binding the deadline
at 1 ms fails four tests including both CI signatures verbatim; at ten
minutes the suite is 90/90 five runs over. Neutering the new helper to a
no-op fails the two reap tests and only those, which is what says the
other nineteen no longer depend on the reaper's timing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@karngyan
karngyan merged commit 65c5930 into main Aug 8, 2026
1 check passed
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