diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index b5db919f5..1604de901 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -176,11 +176,11 @@ export const runFirmwareCommand = async (context: { }; let failure: unknown; try { - const deadline = Date.now() + FIRMWARE_LIMITS.timeoutMs; + const deadline = performance.now() + FIRMWARE_LIMITS.timeoutMs; while (!(await supervisor.waitForOutputClose(100))) { checkDiagnostics(); if (signal.aborted) throw new AnalysisCancelledError(operation); - if (Date.now() >= deadline) + if (performance.now() >= deadline) throw new AnalysisTimeoutError(operation, FIRMWARE_LIMITS.timeoutMs); if (context.outputBudget !== undefined) await inventoryFirmwareOutput( diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index 0503a83e1..c64f011b4 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -279,6 +279,6 @@ export const checkRecoveryDeadline = ( signal?: AbortSignal, ): void => { checkRecoveryCancellation(signal); - if (Date.now() >= deadline) + if (performance.now() >= deadline) throw new AnalysisTimeoutError(OPERATION, RECOVERY_LIMITS.timeoutMs); }; diff --git a/src/javascript/recovery/WakaruCommand.test.ts b/src/javascript/recovery/WakaruCommand.test.ts index 08e43e44a..8dcb74be4 100644 --- a/src/javascript/recovery/WakaruCommand.test.ts +++ b/src/javascript/recovery/WakaruCommand.test.ts @@ -33,7 +33,7 @@ it("rejects an exhausted shared execution budget before launching the next comma arguments: ["--version"], cwd: "/tmp/unused", environment: {}, - deadline: Date.now() - 1, + deadline: performance.now() - 1, launcher: async () => { throw new Error("An expired budget must not launch"); }, @@ -52,7 +52,7 @@ it("preserves caller cancellation before acquiring an expired execution budget", arguments: ["--version"], cwd: "/tmp/unused", environment: {}, - deadline: Date.now() - 1, + deadline: performance.now() - 1, signal: AbortSignal.abort(), launcher: async () => { throw new Error("A cancelled request must not launch"); @@ -86,7 +86,7 @@ const commandFixture = ( arguments: ["--version"], cwd: "/tmp/unused", environment: {}, - deadline: Date.now() + 5_000, + deadline: performance.now() + 5_000, launcher, }, }; @@ -140,7 +140,7 @@ it("enforces the shared deadline while exited output streams remain open", async }); try { await expect( - runWakaruCommand({ ...context, deadline: Date.now() + 50 }), + runWakaruCommand({ ...context, deadline: performance.now() + 50 }), ).rejects.toMatchObject({ _tag: "AnalysisTimeoutError", operation: "recover_javascript_sources", diff --git a/src/javascript/recovery/WakaruProvider.ts b/src/javascript/recovery/WakaruProvider.ts index 7a51c8bd5..b1260bcd1 100644 --- a/src/javascript/recovery/WakaruProvider.ts +++ b/src/javascript/recovery/WakaruProvider.ts @@ -100,7 +100,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort { let tree: SafeOutputTree | undefined; let failed: { readonly cause: unknown } | undefined; let execution: AnalysisExecution | undefined; - const deadline = Date.now() + RECOVERY_LIMITS.timeoutMs; + const deadline = performance.now() + RECOVERY_LIMITS.timeoutMs; try { checkRecoveryCancellation(options?.signal); const engine = await resolveWakaruCommand(this.environment); diff --git a/src/process/ProviderDeadline.test.ts b/src/process/ProviderDeadline.test.ts new file mode 100644 index 000000000..b43ce6654 --- /dev/null +++ b/src/process/ProviderDeadline.test.ts @@ -0,0 +1,35 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { ProviderStartupDeadline } from "./ProviderDeadline.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it("keeps remaining time and cancellation classification stable across wall-clock jumps", () => { + const dateNow = vi.spyOn(Date, "now").mockReturnValue(10_000); + const caller = new AbortController(); + const deadline = new ProviderStartupDeadline(10_000, caller.signal); + try { + dateNow.mockReturnValue(9_000_000_000_000); + const remaining = deadline.remainingMs(); + expect(remaining).toBeGreaterThan(0); + expect(remaining).toBeLessThanOrEqual(10_000); + expect(Number.isInteger(remaining)).toBe(true); + expect(deadline.interruption).toBeUndefined(); + caller.abort(new Error("caller stopped startup")); + expect(deadline.interruption).toBe("cancelled"); + } finally { + deadline.dispose(); + } +}); + +it("aborts a bounded wait when its real monotonic deadline expires", async () => { + const deadline = new ProviderStartupDeadline(40); + try { + await expect(deadline.wait(500)).resolves.toBe("aborted"); + expect(deadline.interruption).toBe("timeout"); + expect(deadline.remainingMs()).toBe(0); + } finally { + deadline.dispose(); + } +}); diff --git a/src/process/ProviderDeadline.ts b/src/process/ProviderDeadline.ts index 3081f469f..33cb3a8d0 100644 --- a/src/process/ProviderDeadline.ts +++ b/src/process/ProviderDeadline.ts @@ -42,11 +42,11 @@ export class ProviderStartupDeadline { ) { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new RangeError("Provider startup timeout must be positive"); - this.#deadlineAt = Date.now() + timeoutMs; + this.#deadlineAt = performance.now() + timeoutMs; this.#externalSignal = signal; this.#onExternalAbort = () => { if (this.#controller.signal.aborted) return; - if (Date.now() >= this.#deadlineAt) { + if (performance.now() >= this.#deadlineAt) { this.#interruption = "timeout"; this.#abort( new DOMException("Provider startup deadline elapsed", "TimeoutError"), @@ -79,13 +79,13 @@ export class ProviderStartupDeadline { get interruption(): "cancelled" | "timeout" | undefined { return ( this.#interruption ?? - (Date.now() >= this.#deadlineAt ? "timeout" : undefined) + (performance.now() >= this.#deadlineAt ? "timeout" : undefined) ); } /** Milliseconds still available to the next startup phase. */ remainingMs(): number { - return Math.max(0, this.#deadlineAt - Date.now()); + return Math.max(0, Math.ceil(this.#deadlineAt - performance.now())); } /** Wait for at most one polling interval under the shared deadline signal. */