fix(input): recover when the seq counter falls behind the server watermark

Browser input is delivered exactly once by (clientId, seq). The server records a
watermark per clientId and discards anything not above it as a duplicate — but
acknowledged it with an ACK indistinguishable from "applied". The client then
dropped the record from its queue, the UI looked perfectly normal, and the
terminal received nothing at all.

The counter is persisted to localStorage through a debounced write. Kill the page
between "sent" and "persisted" and the restored counter is below the server's
watermark, after which every keystroke lands under it, is discarded, and is
ACKed. Reloading does not help: the clientId is restored from localStorage
alongside that stale counter. Measured on a real session — typing into the same
session from a fresh browser (new clientId, no watermark on the server) worked
perfectly, which is what localised the fault to client state.

Three changes:
- on rejection the server replies {"t":"ia",seq,"dup":true,"last":<watermark>}.
  It still ACKs, so the client can drop the record from its queue, but it now
  says the input was not applied and supplies the number needed to climb out.
- on `dup` the client lifts its counter above the watermark and re-queues.
  ⚠️ Only records whose FIRST delivery is being retried are re-sent: a retry
  judged duplicate means the mechanism is working (the original did arrive), and
  re-sending would type the same text twice.
- the counter is now persisted synchronously. The queue payload can stay
  debounced, but the counter is the thing that has to survive a crash, and
  leaving it on the lossiest path cancels the only guarantee there is.

⚠️ Reading the watermark is defensive: the session arrives through a structured
port, and a port missing that method must not take the whole input path down —
a throw inside the handler means the ACK is never sent and the record is stuck in
the client queue forever, which is worse than the ambiguity being fixed. A mock
port's test timeout is what exposed this.
This commit is contained in:
d fei
2026-09-03 02:01:44 -07:00
parent e6dac66a20
commit 05bb7081cc
6 changed files with 198 additions and 8 deletions
+36
View File
@@ -71,3 +71,39 @@ describe('Session.shouldApplyInput (exactly-once input dedup)', () => {
expect(s.shouldApplyInput('recent', 2)).toBe(true);
});
});
describe('Session.lastInputSeq (the watermark a stuck client needs)', () => {
it('reports 0 for a client it has never seen', () => {
expect(makeSession().lastInputSeq('c-new')).toBe(0);
});
it('reports the highest seq applied for that client', () => {
const s = makeSession();
s.shouldApplyInput('c-1', 7);
expect(s.lastInputSeq('c-1')).toBe(7);
});
it('is what a rolled-back client must clear to be heard again', () => {
// The failure this exists for: the client's seq counter persists on a
// DEBOUNCED write, so a tab killed between a send and that write comes back
// counting from below the watermark. Every later keystroke then lands at or
// under it and is rejected — silently, because a rejected frame is ACKed too.
const s = makeSession();
for (let i = 1; i <= 40; i++) s.shouldApplyInput('c-1', i);
// Restored counter starts over at 1: dropped, and every subsequent one too.
expect(s.shouldApplyInput('c-1', 1)).toBe(false);
expect(s.shouldApplyInput('c-1', 2)).toBe(false);
// The watermark it is handed back is exactly what makes it recoverable.
const watermark = s.lastInputSeq('c-1');
expect(watermark).toBe(40);
expect(s.shouldApplyInput('c-1', watermark + 1)).toBe(true);
});
it('does not resurrect a seq that forgetInputSeq rolled back', () => {
const s = makeSession();
s.shouldApplyInput('c-1', 5);
s.forgetInputSeq('c-1', 5);
expect(s.lastInputSeq('c-1')).toBe(4);
expect(s.shouldApplyInput('c-1', 5)).toBe(true);
});
});