- Shipped
- September 4, 2026 at 11:45 PM UTC
- Author
- Kamo
- Commit
- 37c5494
Opening a terminal fails about half the time with "The server refused the terminal connection. Your ticket may have expired", and the log says: Terminal handshake refused: no valid ticket Both terminal hand-offs were kept in a ConcurrentHashMap inside one pod, and both are split across two independent conversations that do not land together: - the TICKET is minted by POST **************** which the browser sends to kamo-internal and kamo-internal proxies onward from one of ITS pods — and is redeemed by a WebSocket handshake the browser opens straight at the edge on /desktop-ws; and collected by the ticket request that follows it. Those arrive over different connections from different sources, so no session affinity can tie them together: a client-IP rule would see kamo-internal's pod for one and the member's browser for the other. The state has to be shared. This was latent for as long as the service ran one pod, and both registries argued in their own comments that in-memory was the right store — which it was. `replicas` became 2 in 4bd25e7, but that commit also broke the context, so no second pod ever started and the two-pod state was never actually reached. Fixing the startup made it real and this surfaced immediately. So: a narrow TerminalHandoffStore over Redis, which this service already requires (@EnableRedisHttpSession will not start without it). Single-use stays atomic — GETDEL in one operation, because a get-then-delete lets two handshakes racing on one ticket both win, and a replayable ticket sits in browser history. There is deliberately NO in-memory fallback bean: one that degraded quietly to per-pod state would reproduce this outage and make it look like flakiness. Two things came along because they are the same defect in the same class: - The per-member terminal CAP counted per pod, so one member could hold twice the limit — eight PTYs and tmux clients on a shared VM that has been taken down by memory pressure before. The counter is shared now, refreshes its TTL on every change so an orphaned count decays instead of locking somebody out, and clamps at zero so a decrement outliving its increment cannot buy headroom. - The dispatch registry had the identical bug and a QUIETER failure: an empty result is the ordinary answer there (nearly every terminal is one somebody opened for themselves), so a lost hand-off refused nothing — it opened a plain Seven new tests spell out "two pods, one store" by sharing one store between two registry instances: a ticket minted on one redeems on the other, is then spent everywhere, carries its hand-off across, and the cap and its release are seen by both. Verified in an isolated worktree — 2137 tests, 0 failures — because the shared working tree currently holds another session's in-flight work. That worktree needed one unrelated main-source fix to compile at all: a shared-library enum gained PROGRESSIVE_LOGIN_LOCKOUT, which makes SuspiciousDetectionService's exhaustive switch non-exhaustive, so origin/main does not currently build. That fix is not in this commit and is not mine; this will not deploy until it lands.