Compare commits

..
29 changed files with 425 additions and 993 deletions

No files matched your search

@@ -20,6 +20,11 @@ it("reads a Python 3.9 reference-flagged PYZ table", () => {
]); ]);
}); });
it("reads marshal one-byte string tags as Latin-1", () => {
for (const hex of ["6101000000e9", "4101000000e9", "7a01e9", "5a01e9"])
expect(readMarshalSubset(bytes(hex))).toEqual({ ok: true, value: "é" });
});
it("decodes CPython marshal Unicode with surrogatepass", () => { it("decodes CPython marshal Unicode with surrogatepass", () => {
expect( expect(
readMarshalSubset( readMarshalSubset(
+2 -8
View File
@@ -168,14 +168,8 @@ const readAsciiBytes = (
): Result<string, PyInstallerFailure> => { ): Result<string, PyInstallerFailure> => {
const bytes = readBytes(cursor, length); const bytes = readBytes(cursor, length);
if (!bytes.ok) return bytes; if (!bytes.ok) return bytes;
if (bytes.value.some((byte) => byte > 0x7f)) // These tags use CPython's one-byte Unicode constructor, not an ASCII check.
return err( return ok(bytes.value.toString("latin1"));
pyInstallerFailure(
"format",
"PYZ marshal ASCII string contains a non-ASCII byte",
),
);
return ok(bytes.value.toString("ascii"));
}; };
const readUtf8 = ( const readUtf8 = (
+23 -26
View File
@@ -61,33 +61,30 @@ describe.skipIf(process.platform === "win32")(
processIdentities: (members) => processIdentities: (members) =>
Promise.resolve( Promise.resolve(
new Map( new Map(
members.map(({ pid }) => { members.map((member) => [
const current = rows.find((entry) => entry.pid === pid); member.pid,
return [ withholdToken && member.pid === 100
pid, ? {
current === undefined state: "absent" as const,
? { reason:
state: "absent" as const, process.platform === "darwin"
reason: ? ("not-in-snapshot" as const)
process.platform === "darwin" : ("not-found-in-caller-pid-namespace" as const),
? ("not-in-snapshot" as const) }
: ("not-found-in-caller-pid-namespace" as const), : {
} state: "readable" as const,
: { identity: `start-${String(member.pid)}`,
state: "readable" as const, lifecycle: {
identity: `start-${String(pid)}`, state: "observed" as const,
lifecycle: { lifecycle: "non-zombie" as const,
state: "observed" as const,
lifecycle: "non-zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: current.parentPid,
processGroupId: current.processGroupId,
},
}, },
]; relation: {
}), state: "observed" as const,
parentPid: member.parentPid,
processGroupId: member.processGroupId,
},
},
]),
), ),
), ),
signalGroup: vi.fn(() => { signalGroup: vi.fn(() => {
@@ -1,92 +0,0 @@
import { writeFile, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
const fixture = vi.hoisted(() => ({
owners: [] as OwnedFileHandle[],
next: 0,
}));
vi.mock("../filesystem/OwnedRegularFile.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../filesystem/OwnedRegularFile.js")
>()),
withOwnedRegularFile: async (
_path: string,
options: { retainFailedClose: (owner: OwnedFileHandle) => void },
) => {
const owner = fixture.owners[fixture.next++];
if (owner === undefined) throw new Error("Unexpected cache file read");
try {
await owner.close();
} catch (cause: unknown) {
options.retainFailedClose(owner);
throw cause;
}
throw new Error("Expected controlled close failure");
},
}));
vi.mock("./ExecFileOutput.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./ExecFileOutput.js")>()),
execFileOutput: async (command: string) => {
if (command !== "/bin/ls") throw new Error("Unexpected command");
return { stdout: "", stderr: "", stdoutBytes: 0, stderrBytes: 0 };
},
}));
import { createDarwinHelperCache } from "./DarwinHelperCache.js";
it.skipIf(process.platform === "win32")(
"retains distinct failed-close owners after both descriptors become invalidated",
async () => {
const failures = [
new Error("first native close failed"),
new Error("second native close failed"),
];
fixture.owners = failures.map((failure, index) => {
const handle = {
fd: index + 40,
close: async () => {
handle.fd = -1;
throw failure;
},
};
return new OwnedFileHandle(handle as unknown as FileHandle);
});
fixture.next = 0;
const closes = fixture.owners.map((owner) => vi.spyOn(owner, "close"));
const root = await createTestTempDirectory("rea-darwin-cache-owners-");
const compiled = join(root, "compiled");
await writeFile(compiled, "fixture helper");
const cache = createDarwinHelperCache({
directory: join(root, "cache"),
sources: [],
xcrun: "/unused",
environment: {},
});
expect(await cache.publish("a".repeat(64), compiled)).toBeUndefined();
expect(await cache.publish("b".repeat(64), compiled)).toBeUndefined();
expect(fixture.owners.map(({ handle }) => handle.fd)).toEqual([-1, -1]);
const failure = await cache.close().catch((cause: unknown) => cause);
if (!(failure instanceof ProviderCleanupError))
throw new Error("Expected retained cache cleanup failure");
expect(closes.map(({ mock }) => mock.calls.length)).toEqual([2, 2]);
expect(failure.cleanupIncomplete).toBe(true);
if (!(failure.cause instanceof AggregateError))
throw new Error("Expected exact aggregate cleanup causes");
expect(failure.cause.errors).toEqual(failures);
const retried = await cache.close().catch((cause: unknown) => cause);
expect(closes.map(({ mock }) => mock.calls.length)).toEqual([3, 3]);
expect(retried).toMatchObject({ cleanupIncomplete: true });
if (
!(retried instanceof ProviderCleanupError) ||
!(retried.cause instanceof AggregateError)
)
throw new Error("Expected retained aggregate cleanup causes on retry");
expect(retried.cause.errors).toEqual(failures);
expect(failure.cleanupIncomplete).toBe(true);
},
);
+5 -17
View File
@@ -168,7 +168,7 @@ export const createDarwinHelperCache = (options: {
}) => { }) => {
const uid = process.getuid?.(); const uid = process.getuid?.();
// Failed closes and removals stay owned until close() retries them. // Failed closes and removals stay owned until close() retries them.
const retained = new Map<string | OwnedFileHandle, () => Promise<void>>(); const retained = new Map<string, () => Promise<void>>();
// The first unresolved close failure; later ones depend on it. // The first unresolved close failure; later ones depend on it.
let cleanupAnchor: ProviderCleanupError | undefined; let cleanupAnchor: ProviderCleanupError | undefined;
let key: Promise<string> | undefined; let key: Promise<string> | undefined;
@@ -271,7 +271,7 @@ export const createDarwinHelperCache = (options: {
}; };
const retainClose = (owner: OwnedFileHandle): void => { const retainClose = (owner: OwnedFileHandle): void => {
retained.set(owner, () => owner.close()); retained.set(`file handle ${String(owner.handle.fd)}`, () => owner.close());
}; };
/** Hash the opened descriptor itself; FIFOs and links never block or pass. */ /** Hash the opened descriptor itself; FIFOs and links never block or pass. */
@@ -431,19 +431,13 @@ export const createDarwinHelperCache = (options: {
/** Retry every retained close or removal; unresolved ones stay owned. */ /** Retry every retained close or removal; unresolved ones stay owned. */
const close = async (): Promise<void> => { const close = async (): Promise<void> => {
const failed: string[] = []; const failed: string[] = [];
const causes: unknown[] = [];
// Deleting the visited entry while iterating a Map is well defined. // Deleting the visited entry while iterating a Map is well defined.
for (const [resource, cleanup] of retained) { for (const [resource, cleanup] of retained) {
try { try {
await cleanup(); await cleanup();
retained.delete(resource); retained.delete(resource);
} catch (cause: unknown) { } catch {
failed.push( failed.push(resource);
typeof resource === "string"
? resource
: `file handle ${String(resource.handle.fd)}`,
);
causes.push(cause);
} }
} }
if (failed.length > 0) { if (failed.length > 0) {
@@ -454,13 +448,7 @@ export const createDarwinHelperCache = (options: {
reason: reason:
"Darwin ownership-helper cache resources remain owned until cleanup succeeds.", "Darwin ownership-helper cache resources remain owned until cleanup succeeds.",
}, },
{ { operation: "prepare_owned_process" },
cause: new AggregateError(
causes,
"Darwin helper cache cleanup failed",
),
operation: "prepare_owned_process",
},
); );
// Every returned failure clears once a later retry releases them all. // Every returned failure clears once a later retry releases them all.
if (cleanupAnchor === undefined) cleanupAnchor = failure; if (cleanupAnchor === undefined) cleanupAnchor = failure;
@@ -172,7 +172,7 @@ it("decodes a successful native snapshot absence as a distinct identity fact", a
environment: {}, environment: {},
}); });
try { try {
const processes = [ const operation = reader.identities([
{ {
pid: 123, pid: 123,
parentPid: 1, parentPid: 1,
@@ -180,8 +180,7 @@ it("decodes a successful native snapshot absence as a distinct identity fact", a
state: "S", state: "S",
command: "fixture", command: "fixture",
}, },
]; ]);
const operation = reader.identities(processes);
await command.waitUntilStarted(); await command.waitUntilStarted();
command.release(); command.release();
await expect(operation).resolves.toEqual( await expect(operation).resolves.toEqual(
@@ -212,17 +211,26 @@ it.each(["read", "identities"] as const)(
await Promise.allSettled([operation, closing, reader.close()]); await Promise.allSettled([operation, closing, reader.close()]);
}); });
const processes = [
{
pid: 123,
parentPid: 1,
processGroupId: 123,
state: "S",
command: "fixture",
},
];
operation = operation =
mode === "read" ? reader.read(processes) : reader.identities(processes); mode === "read"
? reader.read([
{
pid: 123,
parentPid: 1,
processGroupId: 123,
state: "S",
command: "fixture",
},
])
: reader.identities([
{
pid: 123,
parentPid: 1,
processGroupId: 123,
state: "S",
command: "fixture",
},
]);
await command.waitUntilStarted(); await command.waitUntilStarted();
let closeSettled = false; let closeSettled = false;
@@ -265,7 +273,25 @@ it.each(["read", "identities"] as const)(
]), ]),
); );
await expect( await expect(
mode === "read" ? reader.read(processes) : reader.identities(processes), mode === "read"
? reader.read([
{
pid: 123,
parentPid: 1,
processGroupId: 123,
state: "S",
command: "fixture",
},
])
: reader.identities([
{
pid: 123,
parentPid: 1,
processGroupId: 123,
state: "S",
command: "fixture",
},
]),
).rejects.toThrow("closed"); ).rejects.toThrow("closed");
}, },
); );
+1 -1
View File
@@ -776,7 +776,7 @@ export const createDarwinProcessRunTokenReader = (
}; };
const identities = async ( const identities = async (
processes: readonly Pick<ProcessTableEntry, "pid">[], processes: readonly ProcessTableEntry[],
signal?: AbortSignal, signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => { ): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
if (processes.length === 0) return new Map(); if (processes.length === 0) return new Map();
-44
View File
@@ -9,7 +9,6 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { HopperStartError } from "../domain/hopperErrors.js"; import { HopperStartError } from "../domain/hopperErrors.js";
import { createDeferred } from "../../tests/fixtures/binarySession.js";
// Unix socket paths have a short platform limit; the canonical system temp path can exceed it. // Unix socket paths have a short platform limit; the canonical system temp path can exceed it.
const createTestTempDirectory = async (prefix: string): Promise<string> => { const createTestTempDirectory = async (prefix: string): Promise<string> => {
const directory = await mkdtemp(join("/tmp", prefix)); const directory = await mkdtemp(join("/tmp", prefix));
@@ -94,49 +93,6 @@ afterEach(() => {
describe.skipIf(process.platform === "win32")( describe.skipIf(process.platform === "win32")(
"accepted lease connections", "accepted lease connections",
() => { () => {
it("joins concurrent release callers until the listening server finishes closing", async () => {
const directory = await createTestTempDirectory("rea-release-join-");
const acquired = await acquireOwnedLease({
identity: { test: "concurrent-release" },
directory,
runId: "concurrent-release",
failure: (failure) => new Error(failure.kind),
});
if (!acquired.acquired) throw new Error("expected acquired lease");
onTestFinished(() => acquired.lease.release());
const started = createDeferred<void>();
const finish = createDeferred<void>();
const close = Server.prototype.close;
vi.spyOn(Server.prototype, "close").mockImplementationOnce(function (
this: Server,
callback,
) {
return close.call(this, (cause) => {
started.resolve();
void finish.promise.then(() => callback?.(cause));
});
});
let firstFinished = false;
let secondFinished = false;
const first = acquired.lease.release().then(() => {
firstFinished = true;
});
await started.promise;
const second = acquired.lease.release().then(() => {
secondFinished = true;
});
try {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(firstFinished).toBe(false);
expect(secondFinished).toBe(false);
} finally {
finish.resolve();
await Promise.all([first, second]);
}
expect(firstFinished).toBe(true);
expect(secondFinished).toBe(true);
});
it.each([false, true])( it.each([false, true])(
"closes a held peer and preserves retry after failure=%s", "closes a held peer and preserves retry after failure=%s",
async (failClose) => { async (failClose) => {
+8 -14
View File
@@ -240,22 +240,16 @@ const tryAcquireLease = async (
: { acquired: false, owner: existingOwner }; : { acquired: false, owner: existingOwner };
} }
let released = false; let released = false;
let releasing: Promise<void> | undefined;
const lease: OwnedLease = { const lease: OwnedLease = {
socketPath, socketPath,
release: () => { release: async () => {
if (released) return Promise.resolve(); if (released) return;
releasing ??= (async () => { // Owner replies do not require peers to close their writable half. End
// Owner replies do not require peers to close their writable half. End // every accepted connection before awaiting the listening server.
// every accepted connection before awaiting the listening server. for (const socket of connections) socket.destroy();
for (const socket of connections) socket.destroy(); // Node removes the bound socket; never unlink a later owner's socket.
// Node removes the bound socket; never unlink a later owner's socket. await closeServer(server);
await closeServer(server); released = true;
released = true;
})().finally(() => {
releasing = undefined;
});
return releasing;
}, },
}; };
if (securePath) if (securePath)
+12 -22
View File
@@ -17,10 +17,6 @@ import {
} from "./ProviderProcess.js"; } from "./ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js"; import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
/** Injectable acquisition seam; production owns a POSIX group or Windows job. */ /** Injectable acquisition seam; production owns a POSIX group or Windows job. */
export type OwnedMcpLauncher = ( export type OwnedMcpLauncher = (
@@ -47,10 +43,7 @@ export class OwnedMcpStdioTransport implements Transport {
#bytes = 0; #bytes = 0;
#started = false; #started = false;
#closed = false; #closed = false;
readonly #cleanup = new AnalysisCleanupLease( #closePromise: Promise<void> | undefined;
() => this.#stop(),
[this.runId],
);
#starting: Promise<void> | undefined; #starting: Promise<void> | undefined;
#failure: OwnedMcpTransportFailure | undefined; #failure: OwnedMcpTransportFailure | undefined;
@@ -146,8 +139,11 @@ export class OwnedMcpStdioTransport implements Transport {
/** Verifies owned process exit before declaring the transport closed. */ /** Verifies owned process exit before declaring the transport closed. */
close(): Promise<void> { close(): Promise<void> {
this.#closed = true; this.#closePromise ??= this.#stop().catch((cause: unknown) => {
return this.#cleanup.release(); this.#closePromise = undefined;
throw cause;
});
return this.#closePromise;
} }
/** Keep the interruption reason distinct from errors produced by its teardown. */ /** Keep the interruption reason distinct from errors produced by its teardown. */
@@ -186,6 +182,7 @@ export class OwnedMcpStdioTransport implements Transport {
} }
async #stop(): Promise<void> { async #stop(): Promise<void> {
this.#closed = true;
this.#frameBuffer = Buffer.alloc(0); this.#frameBuffer = Buffer.alloc(0);
this.#frameLength = 0; this.#frameLength = 0;
// A cancellation may race acquisition. Join it before inspecting ownership. // A cancellation may race acquisition. Join it before inspecting ownership.
@@ -216,18 +213,11 @@ export class OwnedMcpStdioTransport implements Transport {
await inputClosed; await inputClosed;
input?.off("error", this.#error); input?.off("error", this.#error);
this.onclose?.(); this.onclose?.();
if (stopped?.status === "incomplete") { if (stopped?.status === "incomplete")
const failure = new ProviderCleanupError( throw new ProviderCleanupError(this.settings.provider, [this.runId], {
this.settings.provider, reason: stopped.reason,
[this.runId], stderr: this.diagnostics()?.stderr ?? "",
{ });
reason: stopped.reason,
stderr: this.diagnostics()?.stderr ?? "",
},
);
new AnalysisCleanupLeaseGroup(failure, [this.#cleanup]);
throw failure;
}
} }
readonly #error = (error: Error): void => { readonly #error = (error: Error): void => {
+2 -15
View File
@@ -45,21 +45,8 @@ export class WorkerCleanupRegistry {
} }
async retry(except?: RetainedWorkerCleanup): Promise<void> { async retry(except?: RetainedWorkerCleanup): Promise<void> {
const failures: unknown[] = []; for (const owner of [...this.#pending])
for (const owner of [...this.#pending]) { if (owner !== except) await owner.retryRetainedCleanup();
if (owner === except) continue;
try {
await owner.retryRetainedCleanup();
} catch (cause: unknown) {
failures.push(cause);
}
}
if (failures.length === 1) throw failures[0];
if (failures.length > 1)
throw new AggregateError(
failures,
"Owned worker cleanup remains incomplete",
);
} }
} }
+5 -13
View File
@@ -95,10 +95,7 @@ it.each([
), ),
processIdentities: async (selected) => processIdentities: async (selected) =>
new Map<number, ProcessIdentityObservation>( new Map<number, ProcessIdentityObservation>(
selected.map(({ pid }) => { selected.map((process) => {
const process = [parent, child].find((entry) => entry.pid === pid);
if (process === undefined)
return [pid, { state: "absent", reason: "not-in-snapshot" }];
if (process.pid === parent.pid && ++parentReads > 2) { if (process.pid === parent.pid && ++parentReads > 2) {
reparented = true; reparented = true;
return [ return [
@@ -173,15 +170,10 @@ it("shares native exit waiting before signaling healthy groups in one cleanup at
processIdentities: async (selected) => { processIdentities: async (selected) => {
clock += 25; clock += 25;
return new Map<number, ProcessIdentityObservation>( return new Map<number, ProcessIdentityObservation>(
selected.map(({ pid }) => { selected.map((process) => [
const process = rows.find((entry) => entry.pid === pid); process.pid,
return [ identity(process, process.pid !== root.pid),
pid, ]),
process === undefined
? { state: "absent", reason: "not-in-snapshot" }
: identity(process, process.pid !== root.pid),
];
}),
); );
}, },
signalGroup: vi.fn((group) => { signalGroup: vi.fn((group) => {
+14 -137
View File
@@ -20,126 +20,6 @@ const observedIdentity = (entry: ProcessTableEntry, identity: string) => ({
}, },
}); });
it.each([
"live",
"absent",
"unavailable",
"reused-foreign",
"omitted-command",
"listed-command",
"refresh-command",
] as const)(
"revalidates a retained owner with %s observations during non-sweep cleanup",
async (nativeState) => {
const row: ProcessTableEntry = {
pid: 101,
parentPid: 1,
processGroupId: 101,
state: "S",
command: "retained-owner",
};
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const identity = observedIdentity(row, "start-101");
evidenceOwner.reconcile(
[
{
process: row,
state: "same-generation",
identityBefore: identity,
identityAfter: identity,
token: { state: "readable", runId: "run-token" },
},
],
{
runId: "run-token",
listedRows: 1,
listedPids: new Set([row.pid]),
leaderPid: 100,
processGroupIds: new Set([100]),
},
);
expect(evidenceOwner.candidates).toMatchObject([
{ pid: row.pid, applicability: "token-verified" },
]);
let signaled = false;
let tableReads = 0;
const signalGroup = vi.fn(() => {
signaled = true;
});
const host: ProcessOwnershipHost = {
listProcesses: async () => {
tableReads += 1;
return nativeState === "listed-command" ||
(nativeState === "refresh-command" && tableReads === 1)
? [row]
: [];
},
environment: async () => ({
REA_PROCESS_RUN_ID:
nativeState === "reused-foreign" ? "foreign" : "run-token",
}),
processIdentities: async (entries) =>
new Map(
entries.map(({ pid }) => [
pid,
signaled || nativeState === "absent"
? { state: "absent" as const, reason: "not-in-snapshot" as const }
: nativeState === "unavailable"
? {
state: "unavailable" as const,
reason: "native read denied",
}
: observedIdentity(
row,
nativeState === "reused-foreign"
? "replacement"
: "start-101",
),
]),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
evidenceOwner,
...(nativeState === "omitted-command" ||
nativeState === "listed-command" ||
nativeState === "refresh-command"
? {
leaderPid: row.pid,
processGroupId: row.processGroupId,
expectedCommand: row.command,
}
: {}),
},
host,
);
if (nativeState === "live" || nativeState === "listed-command") {
expect(result).toMatchObject({ cleaned: true, signaled: true });
expect(signalGroup).toHaveBeenCalledExactlyOnceWith(101, "SIGKILL");
} else {
expect(signalGroup).not.toHaveBeenCalled();
expect(result).toMatchObject(
nativeState === "unavailable" ||
nativeState === "omitted-command" ||
nativeState === "refresh-command"
? { cleaned: false }
: { cleaned: true, signaled: false },
);
expect(evidenceOwner.candidates).toEqual(
nativeState === "unavailable" ||
nativeState === "omitted-command" ||
nativeState === "refresh-command"
? [expect.objectContaining({ pid: row.pid })]
: [],
);
}
},
);
it("keeps an unresolved earlier generation unknown when a later unrelated process has a foreign marker", async () => { it("keeps an unresolved earlier generation unknown when a later unrelated process has a foreign marker", async () => {
const row: ProcessTableEntry = { const row: ProcessTableEntry = {
pid: 920, pid: 920,
@@ -159,7 +39,7 @@ it("keeps an unresolved earlier generation unknown when a later unrelated proces
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
readable readable
? observedIdentity(row, "later-generation") ? observedIdentity(entry, "later-generation")
: { state: "unavailable" as const, reason: "identity unavailable" }, : { state: "unavailable" as const, reason: "identity unavailable" },
]), ]),
), ),
@@ -207,7 +87,7 @@ it.each([undefined, "run-token"])(
new Map( new Map(
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
observedIdentity(row, "current-start"), observedIdentity(entry, "current-start"),
]), ]),
), ),
), ),
@@ -300,7 +180,7 @@ it("validates and cleans an exact-token owner without a capture baseline", async
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
live live
? observedIdentity(process, "owner-start") ? observedIdentity(entry, "owner-start")
: { : {
state: "absent" as const, state: "absent" as const,
reason: "not-in-snapshot" as const, reason: "not-in-snapshot" as const,
@@ -359,7 +239,7 @@ it
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
{ {
...observedIdentity(row, "reused-current-generation"), ...observedIdentity(entry, "reused-current-generation"),
effectiveUid, effectiveUid,
}, },
]), ]),
@@ -419,7 +299,7 @@ it
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
{ {
...observedIdentity(row, "current-generation"), ...observedIdentity(entry, "current-generation"),
effectiveUid: process.getuid!(), effectiveUid: process.getuid!(),
...(environmentRestriction === undefined ...(environmentRestriction === undefined
? {} ? {}
@@ -485,7 +365,7 @@ it.each(["omitted", "throws"] as const)(
new Map( new Map(
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
observedIdentity(row, "current-generation"), observedIdentity(entry, "current-generation"),
]), ]),
), ),
); );
@@ -535,7 +415,7 @@ it("keeps a matching-token replacement through omission and retries cleanup only
? { state: "absent" as const, reason: "not-in-snapshot" as const } ? { state: "absent" as const, reason: "not-in-snapshot" as const }
: identityUnavailable : identityUnavailable
? { state: "unavailable" as const, reason: "native read denied" } ? { state: "unavailable" as const, reason: "native read denied" }
: observedIdentity(row, replaced ? "new-owner" : "old-owner"), : observedIdentity(entry, replaced ? "new-owner" : "old-owner"),
]), ]),
), ),
runTokens: async (entries) => { runTokens: async (entries) => {
@@ -736,7 +616,7 @@ it("requires a scoped current relation before exempting a withheld token", async
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
nativeRelation nativeRelation
? observedIdentity(row, "opaque-generation") ? observedIdentity(entry, "opaque-generation")
: { : {
state: "readable" as const, state: "readable" as const,
identity: "opaque-generation", identity: "opaque-generation",
@@ -1067,7 +947,7 @@ describe("capture-local process identity baselines", () => {
entry.pid === 902 entry.pid === 902
? { state: "unavailable", reason: "fixture" } ? { state: "unavailable", reason: "fixture" }
: observedIdentity( : observedIdentity(
processes.find(({ pid }) => pid === entry.pid)!, entry,
entry.pid === 899 ? "stable-start" : "reused-start", entry.pid === 899 ? "stable-start" : "reused-start",
), ),
]), ]),
@@ -1131,7 +1011,7 @@ describe("capture-local process identity baselines", () => {
new Map( new Map(
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
observedIdentity(process, identity), observedIdentity(entry, identity),
]), ]),
), ),
); );
@@ -1368,7 +1248,7 @@ it("revalidates identity after the final environment token read", async () => {
new Map( new Map(
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
observedIdentity(process, identity), observedIdentity(entry, identity),
]), ]),
), ),
); );
@@ -1417,10 +1297,7 @@ it("fails closed when candidate PIDs change identity during token reading", asyn
new Map( new Map(
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
observedIdentity( observedIdentity(entry, identity),
processes.find(({ pid }) => pid === entry.pid)!,
identity,
),
]), ]),
), ),
); );
@@ -1484,7 +1361,7 @@ it("uses the launcher's native relation instead of stale process-table fields",
members.map((member) => [ members.map((member) => [
member.pid, member.pid,
observedIdentity( observedIdentity(
{ ...row, parentPid: 1, processGroupId: 100 }, { ...member, parentPid: 1, processGroupId: 100 },
"start-100", "start-100",
), ),
]), ]),
@@ -1574,7 +1451,7 @@ it("refuses to signal when PID identity changes after token validation", async (
new Map( new Map(
entries.map((entry) => [ entries.map((entry) => [
entry.pid, entry.pid,
observedIdentity(process, identity), observedIdentity(entry, identity),
]), ]),
), ),
); );
+83 -163
View File
@@ -58,17 +58,8 @@ it.each(
runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId }, runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId },
...(native ...(native
? { ? {
processIdentities: async ( processIdentities: async (rows: readonly ProcessTableEntry[]) =>
rows: readonly Pick<ProcessTableEntry, "pid">[], new Map(rows.map((row) => [row.pid, nativeIdentity(row)])),
) =>
new Map(
rows.map(({ pid }) => {
const row = [launcher].find(
(candidate) => candidate.pid === pid,
)!;
return [row.pid, nativeIdentity(row)];
}),
),
} }
: {}), : {}),
signalGroup, signalGroup,
@@ -161,12 +152,7 @@ it.each(
), ),
processIdentities: async (rows) => processIdentities: async (rows) =>
new Map( new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => {
const row = [
nativeLauncher,
observing ? foreign : { ...foreign, parentPid: 1 },
independentlyOwned,
].find((candidate) => candidate.pid === pid)!;
if (!observing && row.pid !== foreign.pid) if (!observing && row.pid !== foreign.pid)
return [row.pid, { state: "absent", reason: "not-in-snapshot" }]; return [row.pid, { state: "absent", reason: "not-in-snapshot" }];
if ( if (
@@ -231,14 +217,7 @@ it("preserves independently token-owned ancestry when the launcher identity mism
]), ]),
), ),
processIdentities: async (rows) => processIdentities: async (rows) =>
new Map( new Map(rows.map((row) => [row.pid, nativeIdentity(row)])),
rows.map(({ pid }) => {
const row = [foreignLauncher, ownedParent, unreadableChild].find(
(candidate) => candidate.pid === pid,
)!;
return [row.pid, nativeIdentity(row)];
}),
),
signalGroup: vi.fn(), signalGroup: vi.fn(),
}; };
@@ -288,24 +267,19 @@ it.each(["parent", "group"] as const)(
processIdentities: async (rows) => { processIdentities: async (rows) => {
identityReads += 1; identityReads += 1;
return new Map( return new Map(
rows.map(({ pid }) => { rows.map((row) => [
const row = [launcher, detached].find( row.pid,
(candidate) => candidate.pid === pid, nativeIdentity(
)!; row.pid === launcher.pid && identityReads > 1
return [ ? {
row.pid, ...row,
nativeIdentity( parentPid: mismatch === "parent" ? 999 : row.parentPid,
row.pid === launcher.pid && identityReads > 1 processGroupId:
? { mismatch === "group" ? 999 : row.processGroupId,
...row, }
parentPid: mismatch === "parent" ? 999 : row.parentPid, : row,
processGroupId: ),
mismatch === "group" ? 999 : row.processGroupId, ]),
}
: row,
),
];
}),
); );
}, },
signalGroup: vi.fn(), signalGroup: vi.fn(),
@@ -374,10 +348,7 @@ it.each([
processIdentities: async (rows) => { processIdentities: async (rows) => {
identityReads.push(rows.map(({ pid }) => pid)); identityReads.push(rows.map(({ pid }) => pid));
return new Map( return new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => {
const row = [launcher, child, grandchild].find(
(candidate) => candidate.pid === pid,
)!;
if ( if (
phase === "exited" || phase === "exited" ||
(phase === "omitted" && row.pid !== child.pid) (phase === "omitted" && row.pid !== child.pid)
@@ -468,17 +439,12 @@ it("retains a live native descendant behind an exited table ancestor", async ()
}, },
processIdentities: async (rows) => processIdentities: async (rows) =>
new Map( new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => [
const row = [launcher, child, grandchild].find( row.pid,
(candidate) => candidate.pid === pid, row.pid === child.pid
)!; ? { state: "absent", reason: "not-in-snapshot" }
return [ : nativeIdentity(row),
row.pid, ]),
row.pid === child.pid
? { state: "absent", reason: "not-in-snapshot" }
: nativeIdentity(row),
];
}),
), ),
signalGroup: vi.fn(), signalGroup: vi.fn(),
}; };
@@ -527,26 +493,21 @@ it.each(["unavailable-relation", "absent"] as const)(
), ),
processIdentities: async (rows) => processIdentities: async (rows) =>
new Map( new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => [
const row = [launcher, child, detached].find( row.pid,
(candidate) => candidate.pid === pid, (omitted && row.pid === launcher.pid) ||
)!; (ancestor === "absent" && row.pid === child.pid)
return [ ? { state: "absent", reason: "not-in-snapshot" }
row.pid, : row.pid === child.pid
(omitted && row.pid === launcher.pid) || ? {
(ancestor === "absent" && row.pid === child.pid) ...nativeIdentity(row),
? { state: "absent", reason: "not-in-snapshot" } relation: {
: row.pid === child.pid state: "unavailable",
? { reason: "relation denied",
...nativeIdentity(row), },
relation: { }
state: "unavailable", : nativeIdentity(row),
reason: "relation denied", ]),
},
}
: nativeIdentity(row),
];
}),
), ),
signalGroup: vi.fn(), signalGroup: vi.fn(),
}; };
@@ -586,17 +547,12 @@ it("retains a verified child before rejecting an unreadable launcher identity",
environment: async () => ({ REA_PROCESS_RUN_ID: "run" }), environment: async () => ({ REA_PROCESS_RUN_ID: "run" }),
processIdentities: async (rows) => processIdentities: async (rows) =>
new Map( new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => [
const row = [launcher, child].find( row.pid,
(candidate) => candidate.pid === pid, row.pid === launcher.pid
)!; ? { state: "unavailable", reason: "native identity denied" }
return [ : nativeIdentity(row),
row.pid, ]),
row.pid === launcher.pid
? { state: "unavailable", reason: "native identity denied" }
: nativeIdentity(row),
];
}),
), ),
signalGroup: vi.fn(), signalGroup: vi.fn(),
}; };
@@ -632,19 +588,14 @@ it("uses a readable native relation to exclude a stale table descendant", async
}, },
processIdentities: async (rows) => processIdentities: async (rows) =>
new Map( new Map(
rows.map(({ pid }) => { rows.map((row) => [
const row = [launcher, child].find( row.pid,
(candidate) => candidate.pid === pid, nativeIdentity(
)!; row.pid === child.pid
return [ ? { ...row, parentPid: 1, processGroupId: 200 }
row.pid, : row,
nativeIdentity( ),
row.pid === child.pid ]),
? { ...row, parentPid: 1, processGroupId: 200 }
: row,
),
];
}),
), ),
signalGroup: vi.fn(), signalGroup: vi.fn(),
}; };
@@ -675,25 +626,17 @@ it("does not retain table scope after a native relation resolves as unrelated",
processIdentities: async (rows) => { processIdentities: async (rows) => {
identityReads += 1; identityReads += 1;
return new Map( return new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => [
const row = [launcher, child].find( row.pid,
(candidate) => candidate.pid === pid, row.pid !== child.pid
)!; ? nativeIdentity(row)
return [ : identityReads === 1
row.pid, ? {
row.pid !== child.pid ...nativeIdentity(row),
? nativeIdentity(row) relation: { state: "unavailable", reason: "relation denied" },
: identityReads === 1 }
? { : nativeIdentity({ ...row, parentPid: 1, processGroupId: 200 }),
...nativeIdentity(row), ]),
relation: {
state: "unavailable",
reason: "relation denied",
},
}
: nativeIdentity({ ...row, parentPid: 1, processGroupId: 200 }),
];
}),
); );
}, },
signalGroup: vi.fn(), signalGroup: vi.fn(),
@@ -735,10 +678,7 @@ it.each(["none", "unresolved", "known"] as const)(
processIdentities: async (rows) => { processIdentities: async (rows) => {
identityReads += 1; identityReads += 1;
return new Map( return new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => {
const row = [launcher, child].find(
(candidate) => candidate.pid === pid,
)!;
if (row.pid === launcher.pid) if (row.pid === launcher.pid)
return [ return [
row.pid, row.pid,
@@ -759,10 +699,7 @@ it.each(["none", "unresolved", "known"] as const)(
row.pid, row.pid,
{ {
...nativeIdentity(row), ...nativeIdentity(row),
relation: { relation: { state: "unavailable", reason: "relation denied" },
state: "unavailable",
reason: "relation denied",
},
}, },
]; ];
return [ return [
@@ -837,10 +774,7 @@ it.each(["replaced", "relation-unavailable-replaced", "unavailable"] as const)(
processIdentities: async (rows) => { processIdentities: async (rows) => {
identityReads += 1; identityReads += 1;
return new Map( return new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => {
const row = [launcher, child, detached].find(
(candidate) => candidate.pid === pid,
)!;
const replacedAncestor = const replacedAncestor =
row.pid === child.pid && ancestorGeneration !== "unavailable"; row.pid === child.pid && ancestorGeneration !== "unavailable";
if ( if (
@@ -952,24 +886,19 @@ it("keeps an exiting descendant whose termination cannot be confirmed", async ()
processIdentities: async (rows) => { processIdentities: async (rows) => {
identityReads += 1; identityReads += 1;
return new Map( return new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => [
const row = [launcher, child].find( row.pid,
(candidate) => candidate.pid === pid, row.pid === launcher.pid
)!; ? omitted
return [ ? { state: "absent", reason: "not-in-snapshot" }
row.pid, : nativeIdentity(row)
row.pid === launcher.pid : identityReads === 1
? omitted ? {
? { state: "absent", reason: "not-in-snapshot" } ...nativeIdentity(row),
: nativeIdentity(row) lifecycle: { state: "observed", lifecycle: "exiting" },
: identityReads === 1 }
? { : { state: "unavailable", reason: "identity denied" },
...nativeIdentity(row), ]),
lifecycle: { state: "observed", lifecycle: "exiting" },
}
: { state: "unavailable", reason: "identity denied" },
];
}),
); );
}, },
signalGroup, signalGroup,
@@ -1041,10 +970,7 @@ it.each(
processIdentities: async (rows) => { processIdentities: async (rows) => {
identityReads += 1; identityReads += 1;
return new Map( return new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => {
const row = [launcher, child].find(
(candidate) => candidate.pid === pid,
)!;
if (row.pid !== stale.pid) if (row.pid !== stale.pid)
return [ return [
row.pid, row.pid,
@@ -1134,19 +1060,13 @@ it.each(["absent", "zombie", "dead"] as const)(
processIdentities: async (rows) => { processIdentities: async (rows) => {
identityReads += 1; identityReads += 1;
return new Map( return new Map(
rows.map(({ pid }): [number, ProcessIdentityObservation] => { rows.map((row): [number, ProcessIdentityObservation] => {
const row = [launcher, child, detached].find(
(candidate) => candidate.pid === pid,
)!;
if (row.pid === child.pid) if (row.pid === child.pid)
return [ return [
row.pid, row.pid,
identityReads === 1 identityReads === 1
? exitedIdentity(row, exit) ? exitedIdentity(row, exit)
: { : { ...nativeIdentity(replacement), identity: "replacement" },
...nativeIdentity(replacement),
identity: "replacement",
},
]; ];
if ( if (
phase === "exited" || phase === "exited" ||
+1 -6
View File
@@ -337,12 +337,7 @@ describe("related sweep candidates during cleanup", () => {
let childReads = 0; let childReads = 0;
const adapter: ProcessOwnershipHost = { const adapter: ProcessOwnershipHost = {
listProcesses: async () => processes, listProcesses: async () => processes,
processIdentities: async (members) => processIdentities: async (members) => identitiesWithRelations(members),
identitiesWithRelations(
processes.filter(({ pid }) =>
members.some((member) => member.pid === pid),
),
),
environment: async () => ({}), environment: async () => ({}),
runTokens: async (members) => runTokens: async (members) =>
new Map( new Map(
+12 -78
View File
@@ -195,12 +195,9 @@ export interface ProcessOwnershipHost {
processes: readonly ProcessTableEntry[], processes: readonly ProcessTableEntry[],
signal?: AbortSignal, signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>>; ): Promise<ReadonlyMap<number, ProcessRunTokenObservation>>;
/** /** Read stable identities; omit the signal during cleanup so it can finish. */
* Read stable native identities by PID; caller rows supply no native facts.
* Omit the signal during cleanup so it can finish.
*/
processIdentities?( processIdentities?(
processes: readonly Pick<ProcessTableEntry, "pid">[], processes: readonly ProcessTableEntry[],
signal?: AbortSignal, signal?: AbortSignal,
/** Linux credential enrichment is selective; omitted means UID and environment restriction remain unknown. */ /** Linux credential enrichment is selective; omitted means UID and environment restriction remain unknown. */
credentialPids?: ReadonlySet<number>, credentialPids?: ReadonlySet<number>,
@@ -598,7 +595,7 @@ const readProcessRelationSnapshot = async (
ownership: OwnedProcessGroup, ownership: OwnedProcessGroup,
processes: readonly ProcessTableEntry[], processes: readonly ProcessTableEntry[],
host: ProcessOwnershipHost, host: ProcessOwnershipHost,
): Promise<ProcessRelationSnapshot | ProcessCleanupFailure> => { ): Promise<ProcessRelationSnapshot> => {
if (host.processIdentities === undefined) if (host.processIdentities === undefined)
return { return {
currentRelations: processes, currentRelations: processes,
@@ -606,17 +603,12 @@ const readProcessRelationSnapshot = async (
failures: [], failures: [],
}; };
const retainedPids = const identities = await readNativeProcessIdentityMap(processes, host);
ownership.evidenceOwner?.candidates.map(({ pid }) => pid) ?? [];
const observationRows =
ownership.evidenceOwner?.rowsForObservation(processes) ?? processes;
const identities = await readNativeProcessIdentityMap(observationRows, host);
const possibleDescendants = new Set( const possibleDescendants = new Set(
descendantsOf([ownership.leaderPid], processes).map(({ pid }) => pid), descendantsOf([ownership.leaderPid], processes).map(({ pid }) => pid),
); );
const relevantPids = new Set([ const relevantPids = new Set([
ownership.leaderPid, ownership.leaderPid,
...retainedPids,
...possibleDescendants, ...possibleDescendants,
...processes ...processes
.filter( .filter(
@@ -628,28 +620,13 @@ const readProcessRelationSnapshot = async (
]); ]);
const currentRelations: ProcessTableEntry[] = []; const currentRelations: ProcessTableEntry[] = [];
const failures: ProcessOwnershipValidationFailure[] = []; const failures: ProcessOwnershipValidationFailure[] = [];
const listedPids = new Set(processes.map(({ pid }) => pid)); for (const process of processes) {
// An empty table can still describe a token-verified owner. A table that
// lists other processes and omits a live retained PID is incomplete: it
// cannot authorize a signal or certify cleanup.
const omittedFromListedTable = (pid: number): boolean =>
processes.length > 0 && !listedPids.has(pid);
for (const process of observationRows) {
const identity = identities.get(process.pid); const identity = identities.get(process.pid);
if (processIdentityHasExited(identity)) continue; if (processIdentityHasExited(identity)) continue;
if ( if (
identity?.state === "readable" && identity?.state === "readable" &&
identity.relation?.state === "observed" identity.relation?.state === "observed"
) { ) {
if (omittedFromListedTable(process.pid)) {
failures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"retained process was omitted from the current process table",
});
continue;
}
currentRelations.push({ currentRelations.push({
...process, ...process,
parentPid: identity.relation.parentPid, parentPid: identity.relation.parentPid,
@@ -664,27 +641,6 @@ const readProcessRelationSnapshot = async (
diagnostic: `native process membership could not be read${identity?.state === "unavailable" ? `: ${identity.reason}` : ""}`, diagnostic: `native process membership could not be read${identity?.state === "unavailable" ? `: ${identity.reason}` : ""}`,
}); });
} }
const launcher = currentRelations.find(
({ pid }) => pid === ownership.leaderPid,
);
if (launcher !== undefined) {
const listedLauncher = processes.find(
({ pid }) => pid === ownership.leaderPid,
);
// Retained rows select PIDs, but only this table supplies a current command.
// Native parent/group applicability must hold on every pre-signal refresh.
const reason = launcherIdentityFailure(
{
parentPid: launcher.parentPid,
processGroupId: launcher.processGroupId,
...(listedLauncher === undefined
? {}
: { command: listedLauncher.command }),
},
ownership,
);
if (reason !== null) return { cleaned: false, reason };
}
return { currentRelations, identities, failures }; return { currentRelations, identities, failures };
}; };
@@ -992,7 +948,6 @@ const createOwnedCleanupPlan = async (
processes, processes,
host, host,
); );
if ("cleaned" in relationSnapshot) return relationSnapshot;
const sampledProcessGroupIds = new Set( const sampledProcessGroupIds = new Set(
(ownership.sampledProcessGroupIds ?? []).filter( (ownership.sampledProcessGroupIds ?? []).filter(
(processGroupId) => (processGroupId) =>
@@ -1024,14 +979,8 @@ const createOwnedCleanupPlan = async (
exitSettlement, exitSettlement,
) )
: { owned: [], failures: [], unverified: [], identities: new Map() }; : { owned: [], failures: [], unverified: [], identities: new Map() };
const listedPids = new Set(processes.map(({ pid }) => pid));
const omittedFromListedTable = (pid: number): boolean =>
processes.length > 0 && !listedPids.has(pid);
const signalableOwned = tokenOwned.owned.filter(
({ pid }) => !omittedFromListedTable(pid),
);
const tokenOwnedGroupIds = new Set( const tokenOwnedGroupIds = new Set(
signalableOwned.map(({ processGroupId }) => processGroupId), tokenOwned.owned.map(({ processGroupId }) => processGroupId),
); );
const launcher = relationSnapshot.currentRelations.find( const launcher = relationSnapshot.currentRelations.find(
({ pid }) => pid === ownership.leaderPid, ({ pid }) => pid === ownership.leaderPid,
@@ -1039,36 +988,21 @@ const createOwnedCleanupPlan = async (
const rootMembers = relationSnapshot.currentRelations.filter( const rootMembers = relationSnapshot.currentRelations.filter(
({ processGroupId }) => processGroupId === ownership.processGroupId, ({ processGroupId }) => processGroupId === ownership.processGroupId,
); );
const unresolved: ProcessOwnershipValidationFailure[] = [];
for (const failure of tokenOwned.failures)
mergeProcessOwnershipFailure(unresolved, failure);
// The later bracketed token scan retains the current, more useful failure
// for a PID whose earlier membership read was also unavailable.
for (const failure of relationSnapshot.failures)
if (!unresolved.some(({ pid }) => pid === failure.pid))
mergeProcessOwnershipFailure(unresolved, failure);
for (const process of tokenOwned.owned)
if (omittedFromListedTable(process.pid))
mergeProcessOwnershipFailure(unresolved, {
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"retained process was omitted from the current process table",
});
if ( if (
launcher === undefined && launcher === undefined &&
rootMembers.length === 0 && rootMembers.length === 0 &&
signalableOwned.length === 0 && tokenOwned.owned.length === 0 &&
escapedProcessGroupIds.size === 0 && escapedProcessGroupIds.size === 0 &&
!relationSnapshot.currentRelations.some(({ processGroupId }) => !relationSnapshot.currentRelations.some(({ processGroupId }) =>
sampledProcessGroupIds.has(processGroupId), sampledProcessGroupIds.has(processGroupId),
) )
) )
return unresolved.length > 0 return tokenOwned.failures.length > 0 ||
relationSnapshot.failures.length > 0
? { ? {
signalOrder: [], signalOrder: [],
tokenOwnedIdentities: tokenOwned.identities, tokenOwnedIdentities: tokenOwned.identities,
unresolved, unresolved: [...tokenOwned.failures, ...relationSnapshot.failures],
unverified: tokenOwned.unverified, unverified: tokenOwned.unverified,
} }
: { : {
@@ -1089,13 +1023,14 @@ const createOwnedCleanupPlan = async (
const processGroupIds = new Set<number>([ownership.processGroupId]); const processGroupIds = new Set<number>([ownership.processGroupId]);
for (const descendant of descendants) for (const descendant of descendants)
processGroupIds.add(descendant.processGroupId); processGroupIds.add(descendant.processGroupId);
for (const process of signalableOwned) for (const process of tokenOwned.owned)
processGroupIds.add(process.processGroupId); processGroupIds.add(process.processGroupId);
for (const processGroupId of sampledProcessGroupIds) for (const processGroupId of sampledProcessGroupIds)
processGroupIds.add(processGroupId); processGroupIds.add(processGroupId);
for (const processGroupId of escapedProcessGroupIds) for (const processGroupId of escapedProcessGroupIds)
processGroupIds.add(processGroupId); processGroupIds.add(processGroupId);
const signalableGroups = new Set<number>(); const signalableGroups = new Set<number>();
const unresolved = [...tokenOwned.failures, ...relationSnapshot.failures];
const unverifiableSampledGroups = new Set<number>(); const unverifiableSampledGroups = new Set<number>();
for (const processGroupId of processGroupIds) { for (const processGroupId of processGroupIds) {
if (processGroupId === ownership.processGroupId) continue; if (processGroupId === ownership.processGroupId) continue;
@@ -1238,7 +1173,6 @@ const revalidateOwnedProcessGroup = async (
settled.processes, settled.processes,
host, host,
); );
if ("cleaned" in relationSnapshot) return relationSnapshot;
if (relationSnapshot.failures.length > 0) if (relationSnapshot.failures.length > 0)
return cleanupValidationFailure(relationSnapshot.failures); return cleanupValidationFailure(relationSnapshot.failures);
members = relationSnapshot.currentRelations.filter( members = relationSnapshot.currentRelations.filter(
+6 -11
View File
@@ -6,9 +6,7 @@ import type {
/** Compare a live launcher against the identity committed at process launch. */ /** Compare a live launcher against the identity committed at process launch. */
export const launcherIdentityFailure = ( export const launcherIdentityFailure = (
launcher: Pick<ProcessTableEntry, "parentPid" | "processGroupId"> & { launcher: ProcessTableEntry,
readonly command?: string;
},
ownership: OwnedProcessGroup, ownership: OwnedProcessGroup,
): string | null => { ): string | null => {
if (launcher.processGroupId !== ownership.processGroupId) if (launcher.processGroupId !== ownership.processGroupId)
@@ -18,13 +16,10 @@ export const launcherIdentityFailure = (
launcher.parentPid !== ownership.expectedParentPid launcher.parentPid !== ownership.expectedParentPid
) )
return "owned launcher parent identity did not match"; return "owned launcher parent identity did not match";
if (ownership.expectedCommand !== undefined) { if (
if (launcher.command === undefined) ownership.expectedCommand !== undefined &&
return "owned launcher command identity could not be verified"; !matchesOwnedProcessCommand(launcher.command, ownership.expectedCommand)
if ( )
!matchesOwnedProcessCommand(launcher.command, ownership.expectedCommand) return `owned launcher command identity did not match (observed=${launcher.command}; expected=${ownership.expectedCommand})`;
)
return `owned launcher command identity did not match (observed=${launcher.command}; expected=${ownership.expectedCommand})`;
}
return null; return null;
}; };
+22 -19
View File
@@ -33,7 +33,6 @@ import {
import { darwinHelperCacheDirectory } from "./DarwinHelperCache.js"; import { darwinHelperCacheDirectory } from "./DarwinHelperCache.js";
import { import {
ProcessRunTokenReadError, ProcessRunTokenReadError,
readProcessRunToken,
readProcessRunTokens, readProcessRunTokens,
} from "./ProcessRunTokenObservations.js"; } from "./ProcessRunTokenObservations.js";
import type { import type {
@@ -1669,35 +1668,39 @@ export const observeProcessStartIdentity = async (
host: ProcessOwnershipHost = systemProcessOwnershipHost, host: ProcessOwnershipHost = systemProcessOwnershipHost,
expectedRunId?: string, expectedRunId?: string,
): Promise<ProcessIdentityObservation | undefined> => { ): Promise<ProcessIdentityObservation | undefined> => {
const process = (await host.listProcesses()).find(
(entry) => entry.pid === pid,
);
if (process === undefined || liveProcesses([process]).length === 0)
return undefined;
if (host.processIdentities === undefined) if (host.processIdentities === undefined)
return { state: "unavailable", reason: "process identity is unsupported" }; return { state: "unavailable", reason: "process identity is unsupported" };
const identity: ProcessIdentityObservation = ( const identity: ProcessIdentityObservation = (
await host.processIdentities([{ pid }]) await host.processIdentities([process])
).get(pid) ?? { ).get(pid) ?? {
state: "unavailable", state: "unavailable",
reason: "process identity was not returned", reason: "process identity was not returned",
}; };
if (identity.state === "absent" || processIdentityHasExited(identity))
return undefined;
if (expectedRunId === undefined || identity.state !== "readable") if (expectedRunId === undefined || identity.state !== "readable")
return identity; return identity;
const process = (await host.listProcesses()).find( let owned = false;
(entry) => entry.pid === pid, for await (const { observation } of readProcessRunTokens(host, [process])) {
); if (observation.state === "unavailable")
let token: ProcessRunTokenObservation | undefined; return {
if (process === undefined) token = await readProcessRunToken(host, pid); state: "unavailable",
else reason: `process ownership token could not be read for PID ${String(pid)}: ${observation.reason}`,
for await (const { observation } of readProcessRunTokens(host, [process])) };
token = observation; if (observation.runId !== expectedRunId)
if (token === undefined || token.state === "unavailable") return {
state: "unavailable",
reason: `process ownership token did not match the captured run for PID ${String(pid)}`,
};
owned = true;
}
if (!owned)
return { return {
state: "unavailable", state: "unavailable",
reason: `process ownership token could not be read for PID ${String(pid)}: ${token?.reason ?? "token was not returned"}`, reason: `process ownership token was not returned for PID ${String(pid)}`,
};
if (token.runId !== expectedRunId)
return {
state: "unavailable",
reason: `process ownership token did not match the captured run for PID ${String(pid)}`,
}; };
const confirmed = await observeProcessStartIdentity(pid, host); const confirmed = await observeProcessStartIdentity(pid, host);
if (confirmed === undefined || confirmed.state !== "readable") if (confirmed === undefined || confirmed.state !== "readable")
+25 -37
View File
@@ -29,39 +29,6 @@ export const processRunIdFromEnvironment = (
? environment.REA_PROCESS_RUN_ID ? environment.REA_PROCESS_RUN_ID
: undefined; : undefined;
/** Read a selected PID's environment token without requiring process-table relations. */
export const readProcessRunToken = async (
host: ProcessOwnershipHost,
pid: number,
signal?: AbortSignal,
): Promise<ProcessRunTokenObservation> => {
signal?.throwIfAborted();
try {
const environment =
signal === undefined
? await host.environment(pid)
: await host.environment(pid, signal);
signal?.throwIfAborted();
return {
state: "readable",
runId: processRunIdFromEnvironment(environment),
};
} catch (cause: unknown) {
signal?.throwIfAborted();
const code =
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string"
? cause.code
: undefined;
return {
state: "unavailable",
...(code === undefined ? {} : { code }),
reason: errorMessage(cause),
};
}
};
/** Read only ownership tokens, using per-process fallback for missing batch rows. */ /** Read only ownership tokens, using per-process fallback for missing batch rows. */
export async function* readProcessRunTokens( export async function* readProcessRunTokens(
host: ProcessOwnershipHost, host: ProcessOwnershipHost,
@@ -90,12 +57,33 @@ export async function* readProcessRunTokens(
signal?.throwIfAborted(); signal?.throwIfAborted();
let observation = batch?.get(process.pid); let observation = batch?.get(process.pid);
if (observation === undefined) { if (observation === undefined) {
observation = await readProcessRunToken(host, process.pid, signal); try {
if (observation.state === "unavailable" && batchFailure !== undefined) const environment =
signal === undefined
? await host.environment(process.pid)
: await host.environment(process.pid, signal);
observation = { observation = {
...observation, state: "readable",
reason: `${observation.reason}; run-token batch failed: ${batchFailure}`, runId: processRunIdFromEnvironment(environment),
}; };
} catch (cause: unknown) {
signal?.throwIfAborted();
const reason = errorMessage(cause);
const code =
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string"
? cause.code
: undefined;
observation = {
state: "unavailable",
...(code === undefined ? {} : { code }),
reason:
batchFailure === undefined
? reason
: `${reason}; run-token batch failed: ${batchFailure}`,
};
}
} }
signal?.throwIfAborted(); signal?.throwIfAborted();
yield { process, observation }; yield { process, observation };
+1 -103
View File
@@ -24,112 +24,10 @@ const host = (identity: string | undefined): ProcessOwnershipHost => ({
], ],
environment: async () => ({}), environment: async () => ({}),
processIdentities: async () => processIdentities: async () =>
new Map([ new Map([[42, { state: "readable", identity: identity ?? "" }]]),
[
42,
identity === undefined
? { state: "absent", reason: "not-found-in-caller-pid-namespace" }
: { state: "readable", identity },
],
]),
signalGroup: () => undefined, signalGroup: () => undefined,
}); });
describe("selected native process identity", () => {
it.each(["readable", "unavailable"] as const)(
"does not infer exit from a table omission when native identity is %s",
async (state) => {
const omitted: ProcessOwnershipHost = {
...host("start-1"),
listProcesses: async () => [],
processIdentities: async () =>
new Map([
[
42,
state === "readable"
? { state: "readable", identity: "start-1" }
: { state: "unavailable", reason: "native inspection denied" },
],
]),
};
await expect(
observeProcessStartIdentityExit(42, "start-1", omitted),
).resolves.toBe(state === "readable" ? "present" : "unverified");
},
);
it.each(["absent", "zombie", "dead"] as const)(
"uses native %s proof despite a stale live process-table row",
async (lifecycle) => {
const exited: ProcessOwnershipHost = {
...host("start-1"),
processIdentities: async () =>
new Map([
[
42,
lifecycle === "absent"
? {
state: "absent",
reason: "not-found-in-caller-pid-namespace",
}
: {
state: "readable",
identity: "start-1",
lifecycle: { state: "observed", lifecycle },
},
],
]),
};
await expect(
observeProcessStartIdentityExit(42, "start-1", exited),
).resolves.toBe("exited");
const sendSignal = vi.fn();
await expect(
signalProcessWithStartIdentity(42, "start-1", "SIGKILL", {
host: exited,
sendSignal,
}),
).resolves.toBe("gone");
expect(sendSignal).not.toHaveBeenCalled();
},
);
it("binds an omitted native PID to its directly observed run token", async () => {
const omitted: ProcessOwnershipHost = {
...host("start-1"),
listProcesses: async () => [],
environment: async () => ({ REA_PROCESS_RUN_ID: "captured-run" }),
};
await expect(
observeProcessStartIdentity(42, omitted, "captured-run"),
).resolves.toMatchObject({ state: "readable", identity: "start-1" });
});
it.each(["unreadable", "foreign", "reused"] as const)(
"does not bind an omitted PID with a %s token observation",
async (condition) => {
let identity = "start-1";
const omitted: ProcessOwnershipHost = {
...host("start-1"),
listProcesses: async () => [],
processIdentities: async () =>
new Map([[42, { state: "readable", identity }]]),
environment: async () => {
if (condition === "unreadable") throw new Error("environment denied");
if (condition === "reused") identity = "replacement-start";
return {
REA_PROCESS_RUN_ID:
condition === "foreign" ? "other-run" : "captured-run",
};
},
};
await expect(
observeProcessStartIdentity(42, omitted, "captured-run"),
).resolves.toMatchObject({ state: "unavailable" });
},
);
});
describe("process start identity lease", () => { describe("process start identity lease", () => {
it("captures a readable identity for a live PID", async () => { it("captures a readable identity for a live PID", async () => {
await expect( await expect(
+1 -2
View File
@@ -642,8 +642,7 @@ export class ProviderProcessSupervisor {
stop( stop(
options: ProviderProcessStopOptions = {}, options: ProviderProcessStopOptions = {},
): Promise<ProviderProcessStopResult> { ): Promise<ProviderProcessStopResult> {
if (this.launch.ownsProcessLifetime && this.#exitObservation === undefined) this.#stopRequested = true;
this.#stopRequested = true;
this.#stopPromise ??= this.#stop(options).then((result) => { this.#stopPromise ??= this.#stop(options).then((result) => {
if (result.status === "incomplete") this.#stopPromise = undefined; if (result.status === "incomplete") this.#stopPromise = undefined;
return result; return result;
-35
View File
@@ -121,9 +121,7 @@ it("retries process cleanup with capture attached until output closes", async ()
}, },
}); });
await expect(owner.stop()).resolves.toMatchObject({ status: "incomplete" }); await expect(owner.stop()).resolves.toMatchObject({ status: "incomplete" });
expect(owner.snapshot().stopRequested).toBe(true);
producer.stderr.write("retained during retry"); producer.stderr.write("retained during retry");
producer.emit("exit", 0, null);
fails = false; fails = false;
const retried = owner.stop(); const retried = owner.stop();
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((resolve) => setImmediate(resolve));
@@ -131,7 +129,6 @@ it("retries process cleanup with capture attached until output closes", async ()
producer.stdout.end(); producer.stdout.end();
producer.emit("close", 0, null); producer.emit("close", 0, null);
await expect(retried).resolves.toEqual({ status: "verified-cleanup" }); await expect(retried).resolves.toEqual({ status: "verified-cleanup" });
expect(owner.snapshot().stopRequested).toBe(true);
expect(owner.snapshot().stderr.text).toBe( expect(owner.snapshot().stderr.text).toBe(
"retained during retry and final tail", "retained during retry and final tail",
); );
@@ -877,38 +874,6 @@ it("marks a stop the owner requested before any signal it sends", async () => {
expect(unowned.snapshot()).toMatchObject({ signal: "SIGKILL" }); expect(unowned.snapshot()).toMatchObject({ signal: "SIGKILL" });
expect(unowned.snapshot()).not.toHaveProperty("stopRequested"); expect(unowned.snapshot()).not.toHaveProperty("stopRequested");
for (const ownsProcessLifetime of [false, true] as const) {
const exited = producer();
const finalized = new ProviderProcessSupervisor(
ownsProcessLifetime
? {
process: exited,
ownsProcessLifetime: true,
cleanup: async () => ({ cleaned: true, signaled: false }),
}
: { process: exited, ownsProcessLifetime: false },
);
exited.emit("exit", null, "SIGKILL");
exited.stdout.end();
exited.stderr.end();
exited.emit("close", null, "SIGKILL");
await expect(finalized.stop()).resolves.toEqual({
status: ownsProcessLifetime ? "verified-cleanup" : "not-owned",
});
expect(finalized.snapshot()).toMatchObject({ signal: "SIGKILL" });
expect(finalized.snapshot()).not.toHaveProperty("stopRequested");
}
const borrowed = producer();
const observer = new ProviderProcessSupervisor({
process: borrowed,
ownsProcessLifetime: false,
});
await expect(observer.stop()).resolves.toEqual({ status: "not-owned" });
expect(observer.snapshot()).not.toHaveProperty("stopRequested");
borrowed.stdout.destroy();
borrowed.stderr.destroy();
const stopped = producer(); const stopped = producer();
const owner = new ProviderProcessSupervisor({ const owner = new ProviderProcessSupervisor({
process: stopped, process: stopped,
-21
View File
@@ -138,27 +138,6 @@ describe("terminateTokenOwnedProcesses", () => {
expect(signalProcess.mock.calls).toEqual([[100, "SIGTERM"]]); expect(signalProcess.mock.calls).toEqual([[100, "SIGTERM"]]);
}); });
it("does not report a refused group signal as delivered and still stops other token carriers", async () => {
const { host, signalGroup } = fakeHost([
row(100, 100, "run"),
row(200, 200, "run"),
]);
signalGroup.mockImplementation(() => {
throw Object.assign(new Error("Signal refused"), { code: "EPERM" });
});
expect(
await terminateTokenOwnedProcesses(
{ runId: "run", processGroupId: 100 },
1_000,
host,
),
).toEqual({ launchGroupSignalled: false });
expect(
(await host.ownership.listProcesses()).map(({ pid }) => pid),
).toEqual([100]);
});
it("waits for the grace period when a process ignores SIGTERM", async () => { it("waits for the grace period when a process ignores SIGTERM", async () => {
const { host } = fakeHost([row(100, 100, "run")], new Set([100])); const { host } = fakeHost([row(100, 100, "run")], new Set([100]));
const started = performance.now(); const started = performance.now();
+7 -13
View File
@@ -82,13 +82,11 @@ const awaitExit = async (
} }
}; };
const send = (deliver: () => void): boolean => { const send = (deliver: () => void): void => {
try { try {
deliver(); deliver();
return true;
} catch { } catch {
// Exit or a failed delivery does not establish that SIGTERM was sent. // A process that exited first has nothing left to stop.
return false;
} }
}; };
@@ -115,20 +113,16 @@ export const terminateTokenOwnedProcesses = async (
} }
const inGroup = (fact: ProcessOwnershipFact) => const inGroup = (fact: ProcessOwnershipFact) =>
processGroupOf(fact) === ownership.processGroupId; processGroupOf(fact) === ownership.processGroupId;
const launchGroupSignalled = const launchGroupSignalled = owned.some(inGroup);
owned.some(inGroup) && if (launchGroupSignalled)
send(() => { send(() => {
host.ownership.signalGroup(ownership.processGroupId, "SIGTERM"); host.ownership.signalGroup(ownership.processGroupId, "SIGTERM");
}); });
const signalled = launchGroupSignalled ? owned.filter(inGroup) : [];
for (const fact of owned) for (const fact of owned)
if ( if (!inGroup(fact))
!inGroup(fact) &&
send(() => { send(() => {
host.signalProcess(fact.process.pid, "SIGTERM"); host.signalProcess(fact.process.pid, "SIGTERM");
}) });
) await awaitExit(owned, deadline, host.ownership);
signalled.push(fact);
await awaitExit(signalled, deadline, host.ownership);
return { launchGroupSignalled }; return { launchGroupSignalled };
}; };
-55
View File
@@ -25,58 +25,3 @@ it("retries one host's retained runtime without failing another host", async ()
await stuck.close(); await stuck.close();
} }
}); });
it("retries every independent retained owner and keeps only exact failures pending", async () => {
const registry = new WorkerCleanupRegistry();
const physical = new Error("first owner still owns its worker");
let failing = true;
const calls: string[] = [];
const first = {
retryRetainedCleanup: async () => {
calls.push("first");
if (failing) throw physical;
registry.delete(first);
},
};
const second = {
retryRetainedCleanup: async () => {
calls.push("second");
registry.delete(second);
},
};
registry.add(first);
registry.add(second);
await expect(registry.retry()).rejects.toBe(physical);
expect(calls).toEqual(["first", "second"]);
expect(registry.has(first)).toBe(true);
expect(registry.has(second)).toBe(false);
failing = false;
await registry.retry();
expect(calls).toEqual(["first", "second", "first"]);
expect(registry.has(first)).toBe(false);
});
it("retains every failed cleanup cause and skips only the caller-selected owner", async () => {
const registry = new WorkerCleanupRegistry();
const causes = [
new Error("first exact cleanup"),
new Error("second exact cleanup"),
];
const skipped = {
retryRetainedCleanup: async () => {
throw new Error("must not retry selected owner");
},
};
const owners = causes.map((cause) => ({
retryRetainedCleanup: async () => {
throw cause;
},
}));
registry.add(skipped);
for (const owner of owners) registry.add(owner);
const failure = await registry.retry(skipped).catch((cause) => cause);
expect(failure).toBeInstanceOf(AggregateError);
expect(failure.errors).toEqual(causes);
expect(registry.has(skipped)).toBe(true);
for (const owner of owners) expect(registry.has(owner)).toBe(true);
});
+54
View File
@@ -407,6 +407,30 @@ it("charges long digits to the string budget and keeps large previews bounded",
); );
}); });
it("reads marshal one-byte string tags as Latin-1 with Unicode identity", () => {
for (const tag of [0x61, 0x41, 0x7a, 0x5a]) {
const encoded = Buffer.concat([
Buffer.from([tag]),
tag === 0x61 || tag === 0x41 ? u32(1) : Buffer.from([1]),
Buffer.from([0xe9]),
]);
const sameUnicode = marshalUnicode(Buffer.from("é", "utf8"));
const set = Buffer.concat([
Buffer.from([0x3c]),
u32(2),
encoded,
sameUnicode,
]);
const report = root(parse(withConsts([encoded, set])));
expect(report.consts[0]).toMatchObject({
kind: "str",
value: "é",
bytes: 1,
});
expect(report.consts[1]).toEqual({ kind: "set", length: 1 });
}
});
it("decodes marshal strings with CPython surrogatepass rules", () => { it("decodes marshal strings with CPython surrogatepass rules", () => {
const report = parse( const report = parse(
withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]), withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]),
@@ -1534,6 +1558,36 @@ it("hashes a long TYPE_REF tuple chain without recursing", () => {
expect(pythonPycSchema.safeParse(report).success).toBe(true); expect(pythonPycSchema.safeParse(report).success).toBe(true);
}); });
it("hashes shallow TYPE_REF code chains without using the JavaScript stack", () => {
const links = 3000;
const codes = Array.from({ length: links }, (_, index) =>
marshalCode("py311", {
flagged: true,
name: "f",
consts: index === 0 ? [Buffer.from([0x4e])] : [marshalRef(index - 1)],
}),
);
const bytes = Buffer.concat([
pythonPycHeader(3531),
marshalCode("py311", {
constsObject: Buffer.concat([
Buffer.from([0x28]),
u32(links + 1),
...codes,
Buffer.from([0x3c]),
u32(1),
marshalRef(links - 1),
]),
}),
]);
const report = parseWith(bytes, { max_objects: 65_536 });
expect(root(report).consts.at(-1)).toEqual({ kind: "set", length: 1 });
expect(report.coverage).toMatchObject({
status: "complete",
code_objects: links + 1,
});
});
it("rejects code CPython treats as corrupt instead of complete coverage", () => { it("rejects code CPython treats as corrupt instead of complete coverage", () => {
const file = ( const file = (
layout: "py37" | "py38" | "py311", layout: "py37" | "py38" | "py311",
+83 -17
View File
@@ -332,7 +332,7 @@ class MarshalReader {
readonly #codeKeys = new WeakMap<CodeNode, string>(); readonly #codeKeys = new WeakMap<CodeNode, string>();
readonly #codeIdentities = new WeakMap<CodeNode, number>(); readonly #codeIdentities = new WeakMap<CodeNode, number>();
readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>(); readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>();
/** Python hashability per code object. False while that check is in progress. */ /** Completed Python hashability checks per code object. */
readonly #codeHashable = new WeakMap<CodeNode, boolean>(); readonly #codeHashable = new WeakMap<CodeNode, boolean>();
/** Tuples whose items are still being read; CPython cannot hash them yet. */ /** Tuples whose items are still being read; CPython cannot hash them yet. */
readonly #readingTuples = new WeakSet<MarshalValue>(); readonly #readingTuples = new WeakSet<MarshalValue>();
@@ -436,13 +436,19 @@ class MarshalReader {
}; };
case TYPE_INTERNED: case TYPE_INTERNED:
case TYPE_UNICODE: case TYPE_UNICODE:
return this.utf8(this.nonNegativeInt32("Marshal string length"), false); return this.string(
this.nonNegativeInt32("Marshal string length"),
"utf8",
);
case TYPE_ASCII: case TYPE_ASCII:
case TYPE_ASCII_INTERNED: case TYPE_ASCII_INTERNED:
return this.utf8(this.nonNegativeInt32("Marshal string length"), true); return this.string(
this.nonNegativeInt32("Marshal string length"),
"latin1",
);
case TYPE_SHORT_ASCII: case TYPE_SHORT_ASCII:
case TYPE_SHORT_ASCII_INTERNED: case TYPE_SHORT_ASCII_INTERNED:
return this.utf8(this.u8(), true); return this.string(this.u8(), "latin1");
case TYPE_TUPLE: case TYPE_TUPLE:
return this.sequenceValue( return this.sequenceValue(
"tuple", "tuple",
@@ -1309,13 +1315,70 @@ class MarshalReader {
#isCodeHashable(node: CodeNode): boolean { #isCodeHashable(node: CodeNode): boolean {
const known = this.#codeHashable.get(node); const known = this.#codeHashable.get(node);
if (known !== undefined) return known; if (known !== undefined) return known;
this.#codeHashable.set(node, false); const frames: { value: MarshalValue; finish: boolean }[] = [
const material = { value: { kind: "code", value: node }, finish: false },
this.#codeEquality.get(node) ?? ];
malformed("Code object is missing equality material."); const gray = new Set<MarshalValue | CodeNode>();
const hashable = this.#equalityKey(material.constsTuple, "hash") !== null; const complete = new Set<MarshalValue>();
this.#codeHashable.set(node, hashable); const reject = (): false => {
return hashable; this.#codeHashable.set(node, false);
return false;
};
while (frames.length > 0) {
this.#takeKeyStep();
const frame = frames.pop()!;
const value = frame.value;
const identity = value.kind === "code" ? value.value : value;
if (frame.finish) {
gray.delete(identity);
if (value.kind === "code") {
const material =
this.#codeEquality.get(value.value) ??
malformed("Code object is missing equality material.");
// Child code checks are now complete, so the existing tuple key
// walker preserves its equality and expansion budgets without recursion.
const hashable =
this.#equalityKey(material.constsTuple, "hash") !== null;
this.#codeHashable.set(value.value, hashable);
if (!hashable) return reject();
} else complete.add(value);
continue;
}
if (value.kind === "code") {
const cached = this.#codeHashable.get(value.value);
if (cached === false) return reject();
if (cached === true) continue;
} else {
const cached = this.#keyCache.get(value);
if (cached === null) return reject();
if (cached !== undefined || complete.has(value)) continue;
}
if (
gray.has(identity) ||
this.#readingTuples.has(value) ||
value.kind === "list" ||
value.kind === "set" ||
value.kind === "dict" ||
value.kind === "null"
)
return reject();
const children =
value.kind === "code"
? [
(
this.#codeEquality.get(value.value) ??
malformed("Code object is missing equality material.")
).constsTuple,
]
: value.kind === "tuple" || value.kind === "frozenset"
? value.items
: [];
gray.add(identity);
frames.push({ value, finish: true });
for (let index = children.length - 1; index >= 0; index -= 1)
frames.push({ value: children[index]!, finish: false });
}
return true;
} }
#codeIdentity(node: CodeNode): number { #codeIdentity(node: CodeNode): number {
@@ -1492,17 +1555,20 @@ class MarshalReader {
); );
} }
utf8(length: number, ascii: boolean): MarshalValue { string(length: number, encoding: "latin1" | "utf8"): MarshalValue {
const bytes = this.blob(length); const bytes = this.blob(length);
if (ascii) { if (encoding === "latin1") {
if (bytes.some((byte) => byte > 0x7f)) // CPython reads the ASCII-tagged forms as one-byte Unicode, even when
malformed("Marshal ASCII string contains a non-ASCII byte."); // high bytes appear. Identity must agree with the UTF-8 string form.
const value = bytes.toString("latin1");
return { return {
kind: "str", kind: "str",
value: bytes.toString("latin1"), value,
previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT), previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT),
bytes: length, bytes: length,
raw: bytes, raw: bytes.some((byte) => byte > 0x7f)
? Buffer.from(value, "utf8")
: bytes,
}; };
} }
const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT); const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT);
@@ -5,7 +5,6 @@ import {
type JSONRPCMessage, type JSONRPCMessage,
} from "@modelcontextprotocol/client"; } from "@modelcontextprotocol/client";
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { OwnedMcpStdioTransport } from "../../../src/process/OwnedMcpStdioTransport.js"; import { OwnedMcpStdioTransport } from "../../../src/process/OwnedMcpStdioTransport.js";
import type { ProviderProcessHandle } from "../../../src/process/ProviderProcess.js"; import type { ProviderProcessHandle } from "../../../src/process/ProviderProcess.js";
import { import {
@@ -259,27 +258,9 @@ it("retries an incomplete owned stop while joining concurrent close callers", as
await transport.start(); await transport.start();
const firstClose = transport.close(); const firstClose = transport.close();
expect(transport.close()).toBe(firstClose); expect(transport.close()).toBe(firstClose);
const failure: unknown = await firstClose.catch((cause: unknown) => cause); await expect(firstClose).rejects.toMatchObject({ cleanupIncomplete: true });
if (!(failure instanceof ProviderCleanupError)) throw failure;
expect(failure.cleanupIncomplete).toBe(true);
expect(failure.cleanupResources).toEqual([transport.runId]);
expect(failure.diagnostics).toMatchObject({
reason: "ownership verification unavailable",
});
const unrelated = new JadxMcpTransport(
{ command: "fixture", arguments: [] },
fixtureLauncher(new FixtureProcess()),
);
await unrelated.start();
await unrelated.close();
expect(failure.cleanupIncomplete).toBe(true);
cleanupFails = false; cleanupFails = false;
await transport.close(); await transport.close();
expect(failure.cleanupIncomplete).toBe(false);
expect(failure.cleanupResources).toEqual([]);
expect(failure.diagnostics).toMatchObject({
reason: "ownership verification unavailable",
});
await transport.close(); await transport.close();
}); });
@@ -1189,16 +1189,18 @@ it.skipIf(process.platform === "win32")(
const baselineSpy = vi const baselineSpy = vi
.spyOn(systemProcessOwnershipHost, "captureBaseline") .spyOn(systemProcessOwnershipHost, "captureBaseline")
.mockImplementation(async (signal) => { .mockImplementation(async (signal) => {
const processes = [ await reader.identities(
{ [
pid: process.pid, {
parentPid: process.ppid, pid: process.pid,
processGroupId: process.pid, parentPid: process.ppid,
state: "S", processGroupId: process.pid,
command: process.execPath, state: "S",
}, command: process.execPath,
]; },
await reader.identities(processes, signal); ],
signal,
);
return []; return [];
}); });
const launchSpy = vi.spyOn(electron, "launch"); const launchSpy = vi.spyOn(electron, "launch");