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:
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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user