The bug: answering a quiz and navigating away mid-turn, then returning, re-prompted the same question. Root causes: (1) the answer was durable only at onFinish — every mid-turn view read the stale pre-answer record; (2) the server had no notion of a turn in flight; (3) remount treated the stale record as current, with no re-attach path. Worse, the stale dock let the quiz be answered again, racing two turns into a last-writer-wins saveMessages clobber. Fix (four pieces): - Write-ahead persist in chat-handler: the request body IS the client's canonical state — saved to disk BEFORE the stream starts. The answer is durable the instant the request arrives; crashes recover via the existing mount continuation. - Turn liveness registry (src/server/turn-liveness.ts): in-memory chatId → in-flight map with a TTL leak guard; beginTurn rejects concurrent turns (409) so one turn ever owns a chat. endTurn runs in onFinish AFTER persistTurn, so pollers that see not-in-flight always read a finished record; a turn-start failure clears the registry (no lockout). - GET /api/chats/[id] reports turnInFlight; the chat page passes initiallyInFlight to the client. - Client re-attach (components/chat.tsx): while in flight, busy shimmer + composer gated + mount continuation suppressed; poll /api/chats/[id] until the turn finishes, then syncFromServer adopts the completed transcript. Stop stays local-stream only (a server turn has no client request to abort). Verified by scripted-model tests: concurrent turn 409 + registry lifecycle, write-ahead durability mid-turn, failed-turn lockout guard.
25 lines
1019 B
TypeScript
25 lines
1019 B
TypeScript
/**
|
|
* Turn liveness registry unit tests (src/server/turn-liveness.ts): one turn
|
|
* per chat, ever — begin/end round trip, double-begin rejection, restart
|
|
* after end. The 30-minute stale-eviction TTL is not tested (it is a leak
|
|
* guard, not a behavior).
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
|
|
|
|
describe("turn liveness registry", () => {
|
|
it("begin/end round trip; a second begin is rejected until the turn ends", () => {
|
|
const id = "chat_liveness_unit";
|
|
assert.equal(isTurnRunning(id), false, "initially idle");
|
|
assert.equal(beginTurn(id), true, "first turn accepted");
|
|
assert.equal(isTurnRunning(id), true, "in flight");
|
|
assert.equal(beginTurn(id), false, "no two turns for one chat");
|
|
endTurn(id);
|
|
assert.equal(isTurnRunning(id), false, "cleared at finish");
|
|
assert.equal(beginTurn(id), true, "a new turn may start after the last ended");
|
|
endTurn(id);
|
|
});
|
|
});
|