fix: serialize session lifecycle transitions

This commit is contained in:
User
2026-07-15 01:37:42 +02:00
parent 08b1f4909e
commit 6747f6f8a0
10 changed files with 1402 additions and 101 deletions
+199 -31
View File
@@ -20,6 +20,12 @@ const LIST = [
const resumeResult = (id: string, alreadyActive = false) =>
HttpResponse.json({ id, alreadyActive });
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((onResolve) => { resolve = onResolve; });
return { promise, resolve };
}
beforeEach(() => {
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource;
@@ -134,58 +140,104 @@ test("an already-active same-session Resume preserves its EventSource and store"
expect(useSessionStore.getState().activityLog).toEqual(before);
});
test("a false then true pair of concurrent Resume completions preserves the cold stream", async () => {
test("concurrent same-id Resume invocations share one cold request and replacement source", async () => {
let resumeCalls = 0;
let releaseCold!: () => void;
let releaseAlreadyActive!: () => void;
let markColdStarted!: () => void;
let markAlreadyActiveStarted!: () => void;
const coldStarted = new Promise<void>((resolve) => { markColdStarted = resolve; });
const alreadyActiveStarted = new Promise<void>((resolve) => { markAlreadyActiveStarted = resolve; });
const coldReleased = new Promise<void>((resolve) => { releaseCold = resolve; });
const alreadyActiveReleased = new Promise<void>((resolve) => { releaseAlreadyActive = resolve; });
const coldGate = deferred();
const coldStarted = deferred();
server.use(
http.post("http://localhost:8787/sessions/:id/resume", async () => {
resumeCalls += 1;
if (resumeCalls === 1) {
markColdStarted();
await coldReleased;
return resumeResult("s1", false);
}
markAlreadyActiveStarted();
await alreadyActiveReleased;
return resumeResult("s1", true);
if (resumeCalls === 1) return resumeResult("s1", false);
if (resumeCalls === 2) coldStarted.resolve();
await coldGate.promise;
return resumeResult("s1", false);
}),
http.get("http://localhost:8787/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
);
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
const resumeButton = await screen.findByRole("button", { name: /resume/i });
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
const oldSource = FakeEventSource.instances[0];
act(() => oldSource.emitNamed("info", { type: "info", text: "Old generation" }, "900"));
await userEvent.click(screen.getByText("Attiva uno"));
const resumeButton = await screen.findByRole("button", { name: /resume/i });
await userEvent.click(resumeButton);
await coldStarted;
await coldStarted.promise;
await userEvent.click(resumeButton);
await alreadyActiveStarted;
await new Promise((resolve) => setImmediate(resolve));
expect(resumeCalls).toBe(2);
releaseCold();
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
const stream = FakeEventSource.instances[0];
act(() => stream.emitNamed(
coldGate.resolve();
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
const replacement = FakeEventSource.instances[1];
expect(oldSource.closed).toBe(true);
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events");
expect(useSessionStore.getState().activityLog).toEqual([
{ kind: "lifecycle", phase: null, text: "Resuming session" },
]);
act(() => replacement.emitNamed(
"ui_request",
{ type: "ui_request", ui_request: { id: "cold-gate", widget: "select" } },
"1",
));
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
releaseAlreadyActive();
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument());
expect(FakeEventSource.instances).toHaveLength(1);
expect(stream.closed).toBe(false);
expect(useSessionStore.getState().pendingWidget?.id).toBe("cold-gate");
expect(useSessionStore.getState().activityLog).toEqual(before);
});
test("a committed Resume releases same-id single-flight before its manifest settles", async () => {
const other = {
...LIST[0], id: "s3", question: "Attiva tre", group: null,
created_at: "2026-01-03T00:00:00Z",
};
let s1ResumeCalls = 0;
let s1ManifestCalls = 0;
const firstS1ManifestGate = deferred();
const firstS1ManifestStarted = deferred();
server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
const id = params.id as string;
if (id === "s1") s1ResumeCalls += 1;
return resumeResult(id);
}),
http.get("http://localhost:8787/sessions/:id", async ({ params }) => {
if (params.id === "s1") {
s1ManifestCalls += 1;
if (s1ManifestCalls === 1) {
firstS1ManifestStarted.resolve();
await firstS1ManifestGate.promise;
return HttpResponse.json({ id: "s1", status: "open", phase: 7 });
}
return HttpResponse.json({ id: "s1", status: "open", phase: 4 });
}
return HttpResponse.json({ id: "s3", status: "open", phase: 3 });
}),
);
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await firstS1ManifestStarted.promise;
expect(s1ResumeCalls).toBe(1);
await userEvent.click(screen.getByText("Attiva tre"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
await userEvent.click(screen.getByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(s1ResumeCalls).toBe(2));
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events");
firstS1ManifestGate.resolve();
await new Promise((resolve) => setImmediate(resolve));
expect(useSessionStore.getState().currentPhase).toBe("F4");
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events");
});
test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => {
@@ -321,6 +373,122 @@ test("resuming a different already-active session binds it only after success",
}));
});
test("competing Resume requests for different ids commit only the latest intent", async () => {
const other = {
...LIST[0], id: "s3", question: "Attiva tre", group: null,
created_at: "2026-01-03T00:00:00Z",
};
const s1Gate = deferred();
const s3Gate = deferred();
const s1Started = deferred();
const s3Started = deferred();
server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => {
const id = params.id as string;
if (id === "s1") {
s1Started.resolve();
await s1Gate.promise;
} else {
s3Started.resolve();
await s3Gate.promise;
}
return resumeResult(id);
}),
http.get("http://localhost:8787/sessions/:id", ({ params }) =>
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
);
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await s1Started.promise;
await userEvent.click(screen.getByText("Attiva tre"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await s3Started.promise;
s3Gate.resolve();
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
expect(FakeEventSource.instances).toHaveLength(1);
const latestSource = FakeEventSource.instances[0];
expect(latestSource.url).toContain("/sessions/s3/events");
act(() => latestSource.emitNamed("info", { type: "info", text: "Latest target" }, "1"));
s1Gate.resolve();
await new Promise((resolve) => setImmediate(resolve));
expect(FakeEventSource.instances).toHaveLength(1);
expect(latestSource.closed).toBe(false);
expect(useSessionStore.getState().currentPhase).toBe("F3");
expect(useSessionStore.getState().activityLog).toContainEqual(expect.objectContaining({
text: "Latest target",
}));
});
test("a stale Resume manifest cannot repaint the latest session phase", async () => {
const other = {
...LIST[0], id: "s3", question: "Attiva tre", group: null,
created_at: "2026-01-03T00:00:00Z",
};
const s1ManifestGate = deferred();
const s1ManifestStarted = deferred();
server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
resumeResult(params.id as string)),
http.get("http://localhost:8787/sessions/:id", async ({ params }) => {
if (params.id === "s1") {
s1ManifestStarted.resolve();
await s1ManifestGate.promise;
return HttpResponse.json({ id: "s1", status: "open", phase: 7 });
}
return HttpResponse.json({ id: "s3", status: "open", phase: 3 });
}),
);
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await s1ManifestStarted.promise;
await userEvent.click(screen.getByText("Attiva tre"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s3/events");
s1ManifestGate.resolve();
await new Promise((resolve) => setImmediate(resolve));
expect(useSessionStore.getState().currentPhase).toBe("F3");
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s3/events");
});
test("starting a new question invalidates a pending Resume intent", async () => {
const resumeGate = deferred();
const resumeStarted = deferred();
server.use(
http.post("http://localhost:8787/sessions/:id/resume", async () => {
resumeStarted.resolve();
await resumeGate.promise;
return resumeResult("s1");
}),
http.post("http://localhost:8787/runtime/prewarm", () =>
HttpResponse.json({ status: "warming" }, { status: 202 })),
);
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await resumeStarted.promise;
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
expect(await screen.findByText(/type your question/i)).toBeInTheDocument();
resumeGate.resolve();
await new Promise((resolve) => setImmediate(resolve));
expect(FakeEventSource.instances).toHaveLength(0);
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
});
test("a failed Resume with no active session preserves the panel and existing activity", async () => {
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 409 })));
useSessionStore.setState({
+64 -1
View File
@@ -33,6 +33,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
export function AppShell() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null);
const resumeInvocationRef = useRef(0);
const latestResumeIntentRef = useRef<{ token: number; id: string } | null>(null);
const resumeInFlightRef = useRef(new Map<string, {
latestToken: number;
promise: Promise<void>;
}>());
const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0);
const [creatingSession, setCreatingSession] = useState(false);
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
@@ -72,6 +78,11 @@ export function AppShell() {
setActiveSessionId(id);
}
function invalidateResumeIntent() {
resumeInvocationRef.current += 1;
latestResumeIntentRef.current = null;
}
// A background refresh can remove a session (for example from another browser).
// Keep the local selection aligned with the authoritative list.
useEffect(() => {
@@ -107,8 +118,37 @@ export function AppShell() {
});
}
async function doResume(id: string) {
const token = ++resumeInvocationRef.current;
latestResumeIntentRef.current = { token, id };
const inFlight = resumeInFlightRef.current.get(id);
if (inFlight) {
// Repeated intent for the same target shares one backend lifecycle operation and one
// commit path. Updating its token still lets s1→s2→s1 make the final s1 intent authoritative.
inFlight.latestToken = token;
return inFlight.promise;
}
const operation = {
latestToken: token,
promise: Promise.resolve(),
};
operation.promise = runResume(id, operation).finally(() => {
if (resumeInFlightRef.current.get(id) === operation) {
resumeInFlightRef.current.delete(id);
}
});
resumeInFlightRef.current.set(id, operation);
return operation.promise;
}
async function runResume(
id: string,
operation: { latestToken: number; promise: Promise<void> },
) {
try {
const result = await resumeSession(id);
const latest = latestResumeIntentRef.current;
if (latest?.token !== operation.latestToken || latest.id !== id) return;
const reconnectSameSession = activeSessionIdRef.current === id;
setPanelSession(null);
setAwaitingQuestion(false);
@@ -129,10 +169,22 @@ export function AppShell() {
setStreamCursorResetEpoch((value) => value + 1);
}
// Single-flight covers only the backend Resume and its local binding commit. A slow
// manifest read must not prevent a later explicit intent from starting a new Resume.
if (resumeInFlightRef.current.get(id) === operation) {
resumeInFlightRef.current.delete(id);
}
// Paint the persisted re-entry phase while the replacement stream starts replaying.
// The manifest's `phase` is the 1-based current phase (1..8).
try {
const m = (await getSession(id)) as { phase?: number };
const latestAfterManifest = latestResumeIntentRef.current;
if (
latestAfterManifest?.token !== operation.latestToken
|| latestAfterManifest.id !== id
|| activeSessionIdRef.current !== id
) return;
if (typeof m.phase === "number" && m.phase >= 1 && m.phase <= 8) {
setPhase(`F${m.phase}`);
}
@@ -140,7 +192,12 @@ export function AppShell() {
/* non-fatal: the first gate will set the phase */
}
} catch {
toast.error("Failed to resume session.");
if (
latestResumeIntentRef.current?.token === operation.latestToken
&& latestResumeIntentRef.current.id === id
) {
toast.error("Failed to resume session.");
}
}
}
async function move(s: SessionSummary, group: string) {
@@ -183,6 +240,9 @@ export function AppShell() {
}
async function deleteSessions(targets: SessionSummary[]) {
if (targets.some((session) => session.id === activeSessionIdRef.current)) {
invalidateResumeIntent();
}
try {
const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id)));
const deletedIds = new Set(
@@ -246,6 +306,7 @@ export function AppShell() {
if (ev === "session_exit") {
// Never let a streamed event terminate the managed Pi child. Only the
// explicit “Stop & save” action is allowed to call /close.
invalidateResumeIntent();
resetSession();
selectActiveSession(null);
setAwaitingQuestion(false);
@@ -258,6 +319,7 @@ export function AppShell() {
}, [lastSystemEvent]);
function startNewSession() {
invalidateResumeIntent();
resetSession();
setAwaitingQuestion(true);
setCreatingSession(false);
@@ -290,6 +352,7 @@ export function AppShell() {
async function stopSession() {
if (!activeSessionId) return;
invalidateResumeIntent();
try {
await closeSession(activeSessionId);
} finally {