返回 DeepSeek-Reasonix
clear-session-identity-race.test.ts
根目录 / desktop / frontend / src / __tests__ / clear-session-identity-race.test.ts
1 // Run: npx tsx src/__tests__/clear-session-identity-race.test.ts
2 //
3 // Deterministic race barriers for clear-session identity fencing.
4 // Avoids importing useController (React) so the suite runs without node_modules.
5
6 import assert from "node:assert/strict";
7 import { readFileSync } from "node:fs";
8 import { dirname, join } from "node:path";
9 import { fileURLToPath } from "node:url";
10 import { hydrateIdentityCurrent } from "../lib/sessionIdentity";
11
12 let passed = 0;
13 let failed = 0;
14
15 function ok(value: boolean, label: string) {
16 if (value) {
17 process.stdout.write(` PASS ${label}\n`);
18 passed += 1;
19 } else {
20 process.stdout.write(` FAIL ${label}\n`);
21 failed += 1;
22 }
23 }
24
25 function deferred<T>() {
26 let resolve!: (value: T) => void;
27 let reject!: (err: unknown) => void;
28 const promise = new Promise<T>((done, fail) => {
29 resolve = done;
30 reject = fail;
31 });
32 return { promise, resolve, reject };
33 }
34
35 console.log("\nclear-session identity race");
36
37 // ── hydrate identity fence (used by loadSessionDataForTab.stillCurrent) ─────
38 ok(hydrateIdentityCurrent({ sessionPath: "/a.jsonl", sessionGeneration: 1 }, { sessionPath: "/a.jsonl", sessionGeneration: 1 }), "matching path+generation is current");
39 ok(!hydrateIdentityCurrent({ sessionPath: "/a.jsonl", sessionGeneration: 1 }, { sessionPath: "/b.jsonl", sessionGeneration: 2 }), "path drift after clear is rejected");
40 ok(!hydrateIdentityCurrent({ sessionPath: "/a.jsonl", sessionGeneration: 1 }, { sessionPath: "/a.jsonl", sessionGeneration: 2 }), "generation-only drift after clear is rejected");
41 ok(hydrateIdentityCurrent({}, { sessionPath: "/b.jsonl", sessionGeneration: 2 }), "empty load identity does not false-reject");
42 ok(!hydrateIdentityCurrent(
43 { session: { hostId: "local", sessionId: "canonical-a" }, sessionGeneration: 1 },
44 { session: { hostId: "local", sessionId: "canonical-b" }, sessionGeneration: 1 },
45 ), "canonical identity drift is rejected when session paths are empty");
46
47 // ── deferred A hydrate vs clear→B (barrier interleaving) ───────────────────
48 type LiveMeta = { sessionPath: string; sessionGeneration: number; items: string[] };
49
50 const live: LiveMeta = {
51 sessionPath: "/sessions/a.jsonl",
52 sessionGeneration: 1,
53 items: ["old content from A"],
54 };
55
56 const lateA = deferred<{ path: string; generation: number; items: string[] }>();
57
58 // Hydrate A starts and hangs (simulates HistorySliceForTab in flight).
59 const hydrateA = (async () => {
60 const loadPath = live.sessionPath;
61 const loadGen = live.sessionGeneration;
62 const page = await lateA.promise;
63 // stillCurrent check at apply time — equivalent to useController fence.
64 if (!hydrateIdentityCurrent(
65 { sessionPath: loadPath, sessionGeneration: loadGen },
66 { sessionPath: live.sessionPath, sessionGeneration: live.sessionGeneration },
67 )) {
68 return { applied: false as const, items: live.items.slice() };
69 }
70 live.items = page.items;
71 return { applied: true as const, items: live.items.slice() };
72 })();
73
74 // Clear succeeds: rotate identity to B and wipe transcript (atomic clear path).
75 live.sessionPath = "/sessions/b.jsonl";
76 live.sessionGeneration = 2;
77 live.items = [];
78
79 // Immediate mode switch would start hydrate B; A is still pending.
80 ok(live.items.length === 0, "after clear, transcript is empty before late A returns");
81 ok(live.sessionGeneration === 2, "after clear, generation is B");
82
83 // Late A resolves with old content.
84 lateA.resolve({
85 path: "/sessions/a.jsonl",
86 generation: 1,
87 items: ["old content from A"],
88 });
89
90 const result = await hydrateA;
91 ok(result.applied === false, "late A hydrate is not applied after clear to B");
92 ok(result.items.length === 0, "transcript remains empty after rejected late A");
93 ok(live.sessionPath === "/sessions/b.jsonl", "live identity path stays B");
94 ok(live.sessionGeneration === 2, "live identity generation stays B");
95
96 // Source contract: clearSession still wires the real fence pieces.
97 const root = join(dirname(fileURLToPath(import.meta.url)), "..");
98 const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
99 assert.match(controller, /hydrateIdentityCurrent\(/, "useController uses shared identity fence");
100 assert.match(controller, /evictTab\(tabId\)/, "clearSession evicts TranscriptStore");
101 assert.match(controller, /sessionGeneration:\s*cleared\.sessionGeneration/, "clear applies returned generation");
102 assert.match(
103 controller,
104 /sessionIdentityStableKey\(a\) === sessionIdentityStableKey\(b\)/,
105 "sameMeta compares the complete canonical identity through the shared helper",
106 );
107 assert.match(controller, /a\.sessionGeneration === b\.sessionGeneration/, "sameMeta also fences unbound generations");
108
109 console.log(`\n${passed} passed, ${failed} failed`);
110 if (failed > 0) process.exit(1);
111
111 lines TYPESCRIPT