From 182c8ee5647764160f62ebddb3c44643101a6fa6 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sat, 29 Aug 2026 05:16:42 +0800 Subject: [PATCH] fix(tui): dedupe concurrent bootstraps to prevent first-run freeze During first-time provider setup the disposed server instance fires server.instance.disposed, which triggers sync.bootstrap() from the SSE handler while the provider dialog awaits its own sync.bootstrap() right after instance.dispose(). The two runs overlap and race the global store transitions. Wrap bootstrap in a singleflight helper so concurrent calls share one in-flight promise; the guard clears on settle so later disposed/reload events still re-bootstrap. --- .../src/cli/cmd/tui/context/sync.tsx | 5 +- packages/cyberstrike/src/util/singleflight.ts | 10 +++ .../test/util/singleflight.test.ts | 69 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 packages/cyberstrike/src/util/singleflight.ts create mode 100644 packages/cyberstrike/test/util/singleflight.test.ts diff --git a/packages/cyberstrike/src/cli/cmd/tui/context/sync.tsx b/packages/cyberstrike/src/cli/cmd/tui/context/sync.tsx index ef05b2ad72..76cb559785 100644 --- a/packages/cyberstrike/src/cli/cmd/tui/context/sync.tsx +++ b/packages/cyberstrike/src/cli/cmd/tui/context/sync.tsx @@ -28,6 +28,7 @@ import { useExit } from "./exit" import { useArgs } from "./args" import { batch, onMount } from "solid-js" import { Log } from "@/util/log" +import { singleflight } from "@/util/singleflight" import type { Path } from "@cyberstrike-io/sdk" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ @@ -577,7 +578,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const exit = useExit() const args = useArgs() - async function bootstrap() { + const bootstrap = singleflight(async function () { console.log("bootstrapping") const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session @@ -664,7 +665,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) await exit(e) }) - } + }) onMount(() => { bootstrap() diff --git a/packages/cyberstrike/src/util/singleflight.ts b/packages/cyberstrike/src/util/singleflight.ts new file mode 100644 index 0000000000..2aef38b92d --- /dev/null +++ b/packages/cyberstrike/src/util/singleflight.ts @@ -0,0 +1,10 @@ +export function singleflight(fn: () => Promise) { + let inflight: Promise | undefined + return () => { + if (inflight) return inflight + inflight = fn().finally(() => { + inflight = undefined + }) + return inflight + } +} diff --git a/packages/cyberstrike/test/util/singleflight.test.ts b/packages/cyberstrike/test/util/singleflight.test.ts new file mode 100644 index 0000000000..0f0d2c29ab --- /dev/null +++ b/packages/cyberstrike/test/util/singleflight.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { singleflight } from "../../src/util/singleflight" + +function tick() { + return new Promise((r) => queueMicrotask(r)) +} + +describe("util.singleflight", () => { + test("concurrent calls share one execution", async () => { + let count = 0 + const run = singleflight(async () => { + count++ + await tick() + return { value: Math.random() } + }) + + const [a, b] = await Promise.all([run(), run()]) + expect(count).toBe(1) + expect(a).toBe(b) + }) + + test("executes again after the promise settles", async () => { + let count = 0 + const run = singleflight(async () => { + count++ + await tick() + }) + + await run() + await run() + expect(count).toBe(2) + }) + + test("rejection propagates to all callers and the guard clears", async () => { + let fail = true + let count = 0 + const run = singleflight(async () => { + count++ + await tick() + if (fail) throw new Error("boom") + return "ok" + }) + + const results = await Promise.allSettled([run(), run(), run()]) + expect(count).toBe(1) + for (const result of results) { + expect(result.status).toBe("rejected") + if (result.status === "rejected") expect(result.reason.message).toBe("boom") + } + + fail = false + expect(await run()).toBe("ok") + expect(count).toBe(2) + }) + + test("sequential non-overlapping calls each execute", async () => { + let count = 0 + const run = singleflight(async () => { + count++ + await tick() + return count + }) + + expect(await run()).toBe(1) + expect(await run()).toBe(2) + expect(await run()).toBe(3) + expect(count).toBe(3) + }) +})