Skip to content

fix(ssm): wait on the condition, not on 5ms (#61) - #68

Merged
scttfrdmn merged 1 commit into
mainfrom
fix/61-ssm-session-condition-waits
Aug 1, 2026
Merged

fix(ssm): wait on the condition, not on 5ms (#61)#68
scttfrdmn merged 1 commit into
mainfrom
fix/61-ssm-session-condition-waits

Conversation

@scttfrdmn

Copy link
Copy Markdown
Contributor

Closes #61.

Four assertions in src/ssm/session.test.ts slept setTimeout(r, 5) for an async chain whose duration is a property of the machine, not of the code: the inbound handler awaits WebCrypto digests before ACKing, and markReady is invoked with void, so nothing awaits its flushPending. 5 ms was enough on an idle laptop and not enough alongside 29 other test files in parallel CI workers.

What each sleep was actually waiting for

Each is now a wait on that condition instead of on a duration:

was now waits for
:141waitForSends(ws, 3).catch(() => {}) + 5 ms s.ready. HandshakeComplete sends no frame back, so ready is the only observable effect — the .catch(() => {}) on a send-count wait was standing in for a condition it couldn't express, and goes away with it.
makeReady s.ready before clearing ws.sent. A premature clear drops the flushed frames the caller's assertions then read, which is why the helper now takes the session and not just the socket.
the queue-then-flush test a flushed Output frame
the channel-closed test onClose having fired and the socket closed

waitFor(pred, describe) polls on a 1 ms tick with a 2 s ceiling. It takes a describe string because a CI timeout message is the only diagnostic anyone will ever see of a failure here. waitForSends becomes a thin wrapper over it.

sentFrames/inputFrames extract the repeated deserialize-and-filter. Worth noting: the session sends Uint8Arrays, not ArrayBuffers — my first cut of sentFrames filtered on instanceof ArrayBuffer and silently matched nothing. That's a quiet way to write a test that can never fail, so the helper filters out the one string send (the token JSON) rather than filtering in on a binary type.

Why this is worth more than an ordinary flake fix

The flakiest of the four covers input being queued rather than dropped — the terminal's core correctness property. A test everyone learns to re-run is precisely how a real regression in the flush path gets waved through.

Verification

  • The failure mode, demonstrated rather than asserted. I could not reproduce the CI flake locally even under 8-core load, so instead of claiming a repro I made the flush path take 25 ms and ran both versions: the old sleeps fail exactly the test Flaky test: ssm/session.test.ts handshake-flush assertion races a fixed 5ms setTimeout #61 names, the condition waits pass.
  • Four mutations of session.ts, each still caught: no-op flushPending (1 fail), handshakeDone never set (3 fails), no onClose on ChannelClosed (1 fail), socket not closed (1 fail). The waits fail on broken, not merely on slow.
  • Ten full-suite runs under 8 busy cores: 591 passing, no flake.
  • The file's own runtime went from ~2 s of sleeping to 12 ms.

Other setTimeout(r, 0) calls in src/ui/*.test.ts are left alone: a zero-delay yield is a deterministic macrotask boundary, not a bet on how long something takes.

Four assertions in `src/ssm/session.test.ts` slept `setTimeout(r, 5)` for an
async chain whose duration is a property of the machine, not of the code: the
inbound handler awaits WebCrypto digests before ACKing, and `markReady` is
invoked with `void`, so nothing awaits its `flushPending`. 5ms was enough on an
idle laptop and not enough alongside 29 other test files in parallel CI workers.

Each sleep is replaced by a wait on the condition it was actually about:

- `s.ready` after HandshakeComplete — which sends no frame back, so `ready`
  itself is the only observable effect. The `waitForSends(ws, 3).catch(() => {})`
  hack that stood in for it goes away with it.
- `s.ready` in `makeReady` before clearing `ws.sent` — a premature clear would
  drop the flushed frames the *caller's* assertions then read, which is why the
  helper needs the session and not just the socket.
- a flushed `Output` frame in the queue-then-flush test.
- `onClose` having fired and the socket being closed.

`waitFor(pred, describe)` polls on a 1ms tick with a 2s ceiling, so the test's
duration tracks the machine while its verdict tracks only correctness. It takes
a `describe` string because a CI timeout message is the only diagnostic anyone
will see. `waitForSends` is now a thin wrapper over it.

`sentFrames`/`inputFrames` extract the repeated deserialize-and-filter. Note the
session sends `Uint8Array`s, not `ArrayBuffer`s — an `instanceof ArrayBuffer`
filter silently matches nothing, which is a quiet way to write a test that can
never fail.

This matters more than an ordinary flake: the flakiest assertion covers input
being *queued rather than dropped*, which is the terminal's core correctness
property. A test everyone learns to re-run is how a real regression in the flush
path gets waved through.

Verified by slowing the flush path to 25ms — the old sleeps fail exactly the
test #61 names, the waits pass — and by four mutations of `session.ts`
(no-op flush, never-ready, no onClose, no socket close), each still caught.
Ten full-suite runs under 8-core load: 591 passing, no flake.
@scttfrdmn
scttfrdmn merged commit 932d852 into main Aug 1, 2026
1 check passed
@scttfrdmn
scttfrdmn deleted the fix/61-ssm-session-condition-waits branch August 1, 2026 21:40
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.

Flaky test: ssm/session.test.ts handshake-flush assertion races a fixed 5ms setTimeout

1 participant