fix(process): keep lifecycle waits independent of application timers

Use Node promise timers consistently for ownership retries, final verification and capture-journal quiet settlement. Preserve the existing elapsed-time budgets, ownership checks, cancellation and trailing-callback drain.

Adapt the ownership timer correction from gensokyo-dev's PR #5249 and complete the same fix in the adjacent capture-journal owner. Three new controlled regressions fail before the fix; 93 source tests and 94 CLI/MCP/process boundary tests pass afterward, with static checks passing. Global performance replacement and ordinary native Jadx incidence remain unverified.

Fixes #5267

Co-authored-by: gensokyo-dev <[email protected]>
This commit is contained in:
morlutoandgensokyo-dev committed 2026-10-20 00:45:49 +08:00
1 parent c4aaf813e0
commit 5199c672ff
4 files changed
+126 -15

No files matched your search

@@ -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<number, ProcessRunTokenObservation>(
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<number, ProcessRunTokenObservation>(
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");
+6 -14
View File
@@ -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<void>((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<void>((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<void>((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<void>((resolve) =>
setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS),
);
await delay(TOKEN_READ_RETRY_DELAY_MS);
let live: ReadonlySet<number> | undefined;
try {
live = new Set(
@@ -289,7 +289,7 @@ export const settleProcessCaptureJournal = async (
let observed = journal.length;
let lastChangeAt = monotonicProcessCaptureClock();
while (monotonicProcessCaptureClock() < deadline) {
await new Promise<void>((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.
@@ -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;