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) + }) +})