源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { expect, vi } from "vitest";
|
||||
import { test } from "@openwork/testkit";
|
||||
import {
|
||||
startSyncStreamLifecycle,
|
||||
type SyncStreamPhase,
|
||||
} from "../../apps/app/src/react-app/domains/session/sync/sync-stream-lifecycle";
|
||||
|
||||
/**
|
||||
* The workspace event stream must never park in a terminal dead state while
|
||||
* a task keeps running on the server. Auth-shaped subscribe failures (401,
|
||||
* 403, 404) can be transient — a restarting local server or a rotating
|
||||
* runtime generation — so they retry on a slower bounded backoff, and a
|
||||
* token/runtime generation change restarts the stream immediately.
|
||||
*/
|
||||
|
||||
function authError(status: number) {
|
||||
return Object.assign(new Error(`subscribe rejected with ${status}`), { status });
|
||||
}
|
||||
|
||||
function isAuthError(error: unknown) {
|
||||
if (!error || typeof error !== "object" || !("status" in error)) return false;
|
||||
const status = (error as { status: unknown }).status;
|
||||
return status === 401 || status === 403 || status === 404;
|
||||
}
|
||||
|
||||
function streamOf(items: unknown[], options: { thenHang: boolean }, signal: AbortSignal): AsyncIterable<unknown> {
|
||||
return (async function* () {
|
||||
for (const item of items) yield item;
|
||||
if (options.thenHang) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
test("a stream killed by a stale token restarts on generation change and resumes the same task's events", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let token = "generation-1";
|
||||
const received: string[] = [];
|
||||
const phases: SyncStreamPhase[] = [];
|
||||
const timeline: string[] = [];
|
||||
let subscribeAttempts = 0;
|
||||
|
||||
const lifecycle = startSyncStreamLifecycle({
|
||||
subscribe: async (signal) => {
|
||||
subscribeAttempts += 1;
|
||||
timeline.push(`subscribe:${subscribeAttempts}:${token}`);
|
||||
if (subscribeAttempts === 1) {
|
||||
// The long task starts streaming, then the server drops the
|
||||
// connection (for example a local server restart).
|
||||
return streamOf(["task-started", "output-1"], { thenHang: false }, signal);
|
||||
}
|
||||
if (token !== "generation-2") throw authError(401);
|
||||
return streamOf(["output-2", "task-completed"], { thenHang: true }, signal);
|
||||
},
|
||||
onEvent: (event) => {
|
||||
received.push(String(event));
|
||||
timeline.push(`event:${String(event)}`);
|
||||
},
|
||||
onConnected: () => {
|
||||
timeline.push("reconcile");
|
||||
},
|
||||
onPhaseChange: (phase) => phases.push(phase),
|
||||
isAuthError,
|
||||
});
|
||||
|
||||
// First connection delivers the first half of the task, then ends.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(received).toEqual(["task-started", "output-1"]);
|
||||
expect(lifecycle.getPhase()).toBe("reconnecting");
|
||||
|
||||
// Reconnects with the stale token are refused with 401 and park the
|
||||
// stream in the auth-blocked lane — visibly, not silently.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(subscribeAttempts).toBe(2);
|
||||
expect(lifecycle.getPhase()).toBe("auth-blocked");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(subscribeAttempts).toBe(3);
|
||||
expect(lifecycle.getPhase()).toBe("auth-blocked");
|
||||
|
||||
// Negative half: while blocked, no events flow and nothing tight-loops.
|
||||
expect(received).toEqual(["task-started", "output-1"]);
|
||||
|
||||
// The token rotates (reattachment) — the restart is immediate, with no
|
||||
// timer to wait out and no remount of the lifecycle.
|
||||
token = "generation-2";
|
||||
lifecycle.notifyGenerationChanged();
|
||||
expect(subscribeAttempts).toBe(4);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// The same task's output resumes and completes on the same lifecycle.
|
||||
expect(received).toEqual(["task-started", "output-1", "output-2", "task-completed"]);
|
||||
expect(lifecycle.getPhase()).toBe("live");
|
||||
|
||||
// Level reconciliation runs on the reconnect before any resumed events,
|
||||
// so cached idle state is corrected before the stream is trusted again.
|
||||
const reconnectSubscribe = timeline.indexOf("subscribe:4:generation-2");
|
||||
const reconnectReconcile = timeline.indexOf("reconcile", reconnectSubscribe);
|
||||
const resumedEvent = timeline.indexOf("event:output-2");
|
||||
expect(reconnectReconcile).toBeGreaterThan(reconnectSubscribe);
|
||||
expect(resumedEvent).toBeGreaterThan(reconnectReconcile);
|
||||
|
||||
// The UI-facing phase history names every state it passed through.
|
||||
expect(phases).toEqual([
|
||||
"connecting",
|
||||
"live",
|
||||
"reconnecting",
|
||||
"connecting",
|
||||
"auth-blocked",
|
||||
"connecting",
|
||||
"auth-blocked",
|
||||
"connecting",
|
||||
"live",
|
||||
]);
|
||||
|
||||
lifecycle.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a permanently invalid token backs off exponentially within bounds instead of tight-looping or dying", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const attemptAt: number[] = [];
|
||||
const lifecycle = startSyncStreamLifecycle({
|
||||
subscribe: async () => {
|
||||
attemptAt.push(Date.now());
|
||||
throw authError(403);
|
||||
},
|
||||
onEvent: () => {},
|
||||
isAuthError,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
||||
|
||||
// Never terminal: the stream keeps probing so a later server-side fix
|
||||
// (or generation change) can be picked up.
|
||||
expect(lifecycle.getPhase()).toBe("auth-blocked");
|
||||
expect(attemptAt.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// Negative proof: no infinite tight reconnect loop. Ten minutes of a
|
||||
// permanently invalid token yields a bounded number of attempts with a
|
||||
// growing, capped spacing.
|
||||
expect(attemptAt.length).toBeLessThanOrEqual(15);
|
||||
const gaps = attemptAt.slice(1).map((at, index) => at - attemptAt[index]);
|
||||
for (const gap of gaps) {
|
||||
expect(gap).toBeGreaterThanOrEqual(5_000);
|
||||
expect(gap).toBeLessThanOrEqual(60_000);
|
||||
}
|
||||
// The schedule is exponential until the cap, then holds the cap.
|
||||
expect(gaps.slice(0, 4)).toEqual([5_000, 10_000, 20_000, 40_000]);
|
||||
expect(gaps.at(-1)).toBe(60_000);
|
||||
|
||||
lifecycle.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a silent live stream is exposed as stale and reconnected by the watchdog", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let attempts = 0;
|
||||
const phases: SyncStreamPhase[] = [];
|
||||
const lifecycle = startSyncStreamLifecycle({
|
||||
subscribe: async (signal) => {
|
||||
attempts += 1;
|
||||
return streamOf([], { thenHang: true }, signal);
|
||||
},
|
||||
onEvent: () => {},
|
||||
onPhaseChange: (phase) => phases.push(phase),
|
||||
isAuthError,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(lifecycle.getPhase()).toBe("live");
|
||||
|
||||
// No events for longer than the stale threshold: the watchdog exposes
|
||||
// the stall as "stale" and replaces the connection.
|
||||
await vi.advanceTimersByTimeAsync(40_000);
|
||||
expect(phases).toContain("stale");
|
||||
expect(attempts).toBeGreaterThanOrEqual(2);
|
||||
expect(lifecycle.getPhase()).toBe("live");
|
||||
|
||||
lifecycle.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user