diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts index 1b8b8eaa8..dca3c0f96 100644 --- a/electron/ipc/cursor/bounds.ts +++ b/electron/ipc/cursor/bounds.ts @@ -16,9 +16,13 @@ import { import type { NativeMacWindowSource, SelectedSource, WindowBounds } from "../types"; import { parseWindowId } from "../utils"; import { resolveWindowsWindowBounds } from "../windowsWindowControl"; +import { createNonOverlappingRunner, createSingleFlight } from "./concurrency"; const execFileAsync = promisify(execFile); +const runNativeMacWindowSources = createSingleFlight(); +const runWindowBoundsRefresh = createNonOverlappingRunner(); +/** Return cached macOS windows or share one native enumeration among concurrent callers. */ export async function getNativeMacWindowSources(options?: { maxAgeMs?: number }) { if (process.platform !== "darwin") { return [] as NativeMacWindowSource[]; @@ -29,34 +33,35 @@ export async function getNativeMacWindowSources(options?: { maxAgeMs?: number }) if (cachedNativeMacWindowSources && now - cachedNativeMacWindowSourcesAtMs < maxAgeMs) { return cachedNativeMacWindowSources; } + return runNativeMacWindowSources(async () => { + try { + const binaryPath = await ensureNativeWindowListBinary(); + const { stdout } = await execFileAsync(binaryPath, [], { + timeout: 30000, + maxBuffer: 10 * 1024 * 1024, + }); - try { - const binaryPath = await ensureNativeWindowListBinary(); - const { stdout } = await execFileAsync(binaryPath, [], { - timeout: 30000, - maxBuffer: 10 * 1024 * 1024, - }); - - const parsed = JSON.parse(stdout); - if (!Array.isArray(parsed)) { - return [] as NativeMacWindowSource[]; - } - - const entries = parsed.filter((entry: unknown): entry is NativeMacWindowSource => { - if (!entry || typeof entry !== "object") { - return false; + const parsed = JSON.parse(stdout); + if (!Array.isArray(parsed)) { + return [] as NativeMacWindowSource[]; } - const candidate = entry as Partial; - return typeof candidate.id === "string" && typeof candidate.name === "string"; - }); + const entries = parsed.filter((entry: unknown): entry is NativeMacWindowSource => { + if (!entry || typeof entry !== "object") { + return false; + } - setCachedNativeMacWindowSources(entries); - setCachedNativeMacWindowSourcesAtMs(now); - return entries; - } catch { - return cachedNativeMacWindowSources ?? ([] as NativeMacWindowSource[]); - } + const candidate = entry as Partial; + return typeof candidate.id === "string" && typeof candidate.name === "string"; + }); + + setCachedNativeMacWindowSources(entries); + setCachedNativeMacWindowSourcesAtMs(now); + return entries; + } catch { + return cachedNativeMacWindowSources ?? ([] as NativeMacWindowSource[]); + } + }); } export function getWindowBoundsFromNativeSource( @@ -172,22 +177,24 @@ export function stopWindowBoundsCapture() { } async function refreshSelectedWindowBounds() { - if (!selectedSource?.id?.startsWith("window:")) { - setSelectedWindowBounds(null); - return; - } + await runWindowBoundsRefresh(async () => { + if (!selectedSource?.id?.startsWith("window:")) { + setSelectedWindowBounds(null); + return; + } - let bounds: WindowBounds | null = null; + let bounds: WindowBounds | null = null; - if (process.platform === "darwin") { - bounds = await resolveMacWindowBounds(selectedSource); - } else if (process.platform === "win32") { - bounds = await resolveWindowsWindowBounds(selectedSource); - } else if (process.platform === "linux") { - bounds = await resolveLinuxWindowBounds(selectedSource); - } + if (process.platform === "darwin") { + bounds = await resolveMacWindowBounds(selectedSource); + } else if (process.platform === "win32") { + bounds = await resolveWindowsWindowBounds(selectedSource); + } else if (process.platform === "linux") { + bounds = await resolveLinuxWindowBounds(selectedSource); + } - setSelectedWindowBounds(bounds); + setSelectedWindowBounds(bounds); + }); } export function startWindowBoundsCapture() { diff --git a/electron/ipc/cursor/concurrency.test.ts b/electron/ipc/cursor/concurrency.test.ts new file mode 100644 index 000000000..1b54ed8b9 --- /dev/null +++ b/electron/ipc/cursor/concurrency.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { createNonOverlappingRunner, createSingleFlight } from "./concurrency"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +describe("cursor task concurrency", () => { + it("shares one operation until it settles, then starts another", async () => { + const run = createSingleFlight(); + const first = deferred(); + const operation = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce("second"); + + const firstResult = run(operation); + const sharedResult = run(operation); + expect(operation).toHaveBeenCalledTimes(1); + + first.resolve("first"); + await expect(Promise.all([firstResult, sharedResult])).resolves.toEqual(["first", "first"]); + await expect(run(operation)).resolves.toBe("second"); + expect(operation).toHaveBeenCalledTimes(2); + }); + + it("skips overlap and runs again after settlement", async () => { + const run = createNonOverlappingRunner(); + const first = deferred(); + const operation = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(undefined); + + const firstResult = run(operation); + await expect(run(operation)).resolves.toBe(false); + expect(operation).toHaveBeenCalledTimes(1); + + first.resolve(); + await expect(firstResult).resolves.toBe(true); + await expect(run(operation)).resolves.toBe(true); + expect(operation).toHaveBeenCalledTimes(2); + }); +}); diff --git a/electron/ipc/cursor/concurrency.ts b/electron/ipc/cursor/concurrency.ts new file mode 100644 index 000000000..c7012a981 --- /dev/null +++ b/electron/ipc/cursor/concurrency.ts @@ -0,0 +1,38 @@ +/** Share one asynchronous operation with every caller until it settles. */ +export function createSingleFlight() { + let inFlight: Promise | null = null; + + return (operation: () => Promise) => { + if (inFlight) { + return inFlight; + } + + inFlight = (async () => { + try { + return await operation(); + } finally { + inFlight = null; + } + })(); + return inFlight; + }; +} + +/** Skip overlapping asynchronous operations and allow another after settlement. */ +export function createNonOverlappingRunner() { + let running = false; + + return async (operation: () => Promise) => { + if (running) { + return false; + } + + running = true; + try { + await operation(); + return true; + } finally { + running = false; + } + }; +}