mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452ed97031 | ||
|
|
c837575512 | ||
|
|
cdb51058f6 |
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(
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -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");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" ||
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,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;
|
||||||
};
|
};
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in new issue
Block a user