Chess on the ATmosphere checkmate.blue

Detect joins at the live tail instead of replaying a minute of firehose #54

Merged checkmate.blue
targeting main
Merged opened by and assigned to checkmate.blue
targeting main

HAD-239, plus a related stuck-game case found while working on it.

The cursor (HAD-239). waitForOpponent subscribed with initialCursor: (Date.now() - 60_000) * 1000. Jetstream does not seek to a cursor and then jump to the live tail; it drains the backlog at close to real time, so the socket stayed roughly a minute behind and a join could take that long to notice. Measured against jetstream2.us-east:

live tail (no cursor)     lag median  -31ms   p90   -22ms   max     59ms
cursor 60s in the past    lag median 55614ms  p90 59556ms   max  60296ms

That socket has no record to read, so the catch-up trick used for the move path did not apply directly. JetstreamConnection now takes an optional onCatchUp that runs on every connect, after the record read when there is one, and waitForOpponent uses it to re-run findJoiner against Constellation's backlink index. Gaps are closed by reading, the socket stays at the tail.

No recursion: loadGame only calls waitForOpponent again when the game is still unpaired, in which case findJoiner returns null and the chain stops.

Directed invites could stick in 'waiting' forever. The discovery added in HAD-149 is gated on (!record.white || !record.black), i.e. an open game with a free slot. CreateGameForm fills both slots up front for a directed invite, so discovery never ran for one. The owner branch then found the opponent's child record via findGameRecordByParent and applied its PGN, but never moved its own status off waiting -- so an inviter who was away when their opponent joined sat on "Waiting for opponent" permanently, with the moves arriving underneath.

The child record is the proof of a join, so the owner branch now activates on it and persists the change. No extra query: findGameRecordByParent was already being called there.

Tests cover onCatchUp firing on open, again on reconnect, with no record to read, ordered after the record read, and not at all after destroy().

Also folded in: five isInCheck regression tests as their own commit. That getter drives the "Check!" banner and had no coverage; a reported stuck banner turned out to be a stale cached bundle rather than a bug, but the gap was real.

This pull request has no versions submitted yet.