fix: serialize session lifecycle transitions
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user