diff --git a/src/process/ProcessOwnership.finalSweep.test.ts b/src/process/ProcessOwnership.finalSweep.test.ts index b9c2cd1d5a..6ed2491fc4 100644 --- a/src/process/ProcessOwnership.finalSweep.test.ts +++ b/src/process/ProcessOwnership.finalSweep.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, type ProcessOwnershipHost, + type ProcessRunTokenObservation, type ProcessTableEntry, } from "./ProcessOwnership.js"; import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; @@ -108,6 +109,108 @@ it("waits for native absence after one signal and does not signal again", async expect(fixture.signalGroups).toEqual([100]); }); +it("finishes the public cleanup sweep when the application timer is unavailable", async () => { + const unrelated: ProcessTableEntry = { + pid: 200, + parentPid: 1, + processGroupId: 200, + state: "S", + command: "transient-token-process", + }; + let signaled = false; + let postSignalSnapshots = 0; + let unrelatedTokenReads = 0; + const fixture = makeHost(() => { + signaled = true; + fixture.replaceRows([unrelated]); + }); + fixture.replaceRows([...initialRows, unrelated]); + const host: ProcessOwnershipHost = { + ...fixture.host, + listProcesses: async () => { + if (!signaled) return fixture.rows(); + postSignalSnapshots += 1; + if (postSignalSnapshots === 1) return fixture.rows(); + fixture.replaceRows([]); + return []; + }, + runTokens: async (entries) => + new Map( + entries.map(({ pid }) => { + if (pid !== unrelated.pid) + return [pid, { state: "readable", runId: "sweep-run" }] as const; + if (!signaled) + return [pid, { state: "readable", runId: undefined }] as const; + unrelatedTokenReads += 1; + return [pid, { state: "readable", runId: "sweep-run" }] as const; + }), + ), + }; + const applicationTimer = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(() => { + throw new Error("application timer is unavailable"); + }); + + try { + await expect(cleanupOwnedProcessGroup(ownership(), host)).resolves.toEqual({ + cleaned: true, + signaled: true, + }); + expect(unrelatedTokenReads).toBeGreaterThan(0); + expect(postSignalSnapshots).toBe(2); + expect(fixture.signalGroups).toEqual([100]); + expect(applicationTimer).not.toHaveBeenCalled(); + } finally { + applicationTimer.mockRestore(); + } +}); + +it("keeps an unreadable live group member fail-closed when the application timer is unavailable", async () => { + const fixture = makeHost(() => undefined); + let unreadableReads = 0; + const host: ProcessOwnershipHost = { + ...fixture.host, + runTokens: async (entries) => + new Map( + entries.map(({ pid }) => { + if (pid === 101) { + unreadableReads += 1; + return [ + pid, + { + state: "unavailable", + reason: "EACCES: fixture permission denied", + }, + ] as const; + } + return [pid, { state: "readable", runId: "sweep-run" }] as const; + }), + ), + }; + const applicationTimer = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(() => { + throw new Error("application timer is unavailable"); + }); + + try { + await expect( + cleanupOwnedProcessGroup(ownership(), host), + ).resolves.toMatchObject({ + cleaned: false, + reason: expect.stringContaining( + "process ownership token could not be read", + ), + }); + expect(unreadableReads).toBeGreaterThan(1); + expect(fixture.signalGroups).toEqual([]); + expect(applicationTimer).not.toHaveBeenCalled(); + } finally { + applicationTimer.mockRestore(); + } +}); + it("preserves a moved same-generation helper as unresolved through the final deadline", async () => { const child = initialRows[1]; if (child === undefined) throw new Error("fixture child is missing"); diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 307b20220c..a0c45fb571 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { performance } from "node:perf_hooks"; +import { setTimeout as delay } from "node:timers/promises"; import { promisify } from "node:util"; import { @@ -389,11 +390,8 @@ export const cleanupOwnedProcessGroup = async ( while (!verification.cleaned) { const remaining = deadline - performance.now(); if (remaining <= 0) break; - await new Promise((resolve) => - setTimeout( - resolve, - Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining), - ), + await delay( + Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining), ); verification = await verifyNoTokenOwnedProcesses( ownership.runId, @@ -1089,9 +1087,7 @@ const recheckOwnershipFailures = async ( ); attempt += 1 ) { - await new Promise((resolve) => - setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), - ); + await delay(TOKEN_READ_RETRY_DELAY_MS); processes = await host.listProcesses(); } const live = new Set(liveProcesses(processes).map(({ pid }) => pid)); @@ -1336,9 +1332,7 @@ const scanWithOwnershipEvidence = async ( ) .map(({ process }) => process); if (retryRows.length === 0) break; - await new Promise((resolve) => - setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), - ); + await delay(TOKEN_READ_RETRY_DELAY_MS); const retries = await readProcessOwnershipFacts(retryRows, host); const retryByPid = new Map(retries.map((fact) => [fact.process.pid, fact])); facts = facts.map((fact) => retryByPid.get(fact.process.pid) ?? fact); @@ -1509,9 +1503,7 @@ const settleUnreadableTokenCandidates = async ( attempt < TOKEN_READ_RETRY_ATTEMPTS && remaining.length > 0; attempt += 1 ) { - await new Promise((resolve) => - setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), - ); + await delay(TOKEN_READ_RETRY_DELAY_MS); let live: ReadonlySet | undefined; try { live = new Set( diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index d20e693b28..a2b8e64a86 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -289,7 +289,7 @@ export const settleProcessCaptureJournal = async ( let observed = journal.length; let lastChangeAt = monotonicProcessCaptureClock(); while (monotonicProcessCaptureClock() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 4)); + await delay(4); // A stalled event loop can leave timer and poll callbacks that were already // due queued behind this wake-up. Drain them before judging silence so a // trailing observation is not dropped by an early settle. diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index bd7a3c0c77..a0833e380a 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -257,6 +257,22 @@ it("preserves the actionable Darwin ownership compiler prerequisite", () => { }); }); +it("settles the capture journal when the application timer is unavailable", async () => { + const applicationTimer = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(() => { + throw new Error("application timer is unavailable"); + }); + try { + await expect( + settleProcessCaptureJournal([], 0, 50), + ).resolves.toBeUndefined(); + expect(applicationTimer).not.toHaveBeenCalled(); + } finally { + applicationTimer.mockRestore(); + } +}); + it("waits for terminal observations delivered after the exit callback", async () => { const journal: Array<{ capture_order: number;