Files
rea/tests/boundary/process/ownedCommand.test.ts
T
Kaoru0822 8ab5024397 fix(kuna): stream string scans instead of capping them at 16 MiB (#4318)
* fix(kuna): stream string scans instead of capping them at 16 MiB

`list_strings` and `search_strings` buffered Kuna's whole string JSON and
refused anything above 16 MiB. Kuna writes about 290 bytes per row, so
every app above roughly 10 MB failed with a non-retryable memory error:
a 20 MB Ghostty binary produces 31 MB, Raycast 30 MB and an arm64
Firefox XUL 179 MB.

Parse the scan as Kuna writes it. `runOwnedCommand` accepts a stdout
sink, and `KunaStringInventoryReader` owns the interpretation: it
tokenizes synchronously with stream-json, keeps exact u64 address
tokens, validates each row before retaining it, skips members it does
not interpret, and admits retained rows against live heap headroom
rather than a fixed scan size. The first contradiction stops Kuna
instead of waiting for the rest; an incomplete document is judged only
after Kuna's own exit status. Diagnostics keep the 8 MiB Kuna budget,
now shared with the project export.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): admit string scan text as it streams and drop sinks with their command

Review found that packed string tokens were assembled whole before the
reader could discard them or check heap headroom, and that a stdout sink
could outlive a cancelled or timed-out command.

Move the token mechanics into `KunaJsonDocumentReader`: keys and numbers
(Kuna's own fields) stay packed, while strings, which carry target-derived
text, arrive in chunks. Ignored strings are dropped unassembled and kept
ones are admitted against live heap headroom as they grow. Kept text is
copied out of the decoded chunk it was sliced from, so retained rows no
longer pin whole chunks (XUL: 278 MB retained before, 156 MB after).
`KunaStringInventoryReader` keeps only the string-scan semantics.

`runOwnedCommand` now ties the stdout pipeline to the command's deadline
signal and aborts it when collection fails, destroying the sink.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): type the stream-json tokenizer where it is imported

The Reverse tools quality lane type-checks a partial project that reaches
KunaJsonStream without the separate streamJsonParser.d.ts augmentation,
so jsonParser had no declaration there. Declare the tokenizer's shape at
its only import site and remove the augmentation file, so every
compilation of the reader is self-contained. Split two helpers out of
the token dispatcher and simplify the stdout sink wait in
runOwnedCommand, which keeps the added complexity minimal.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): bound key and number assembly in streamed Kuna JSON

Review found that packed keys (and packed numbers) were assembled whole
by the tokenizer before the reader could discard or constrain them, so a
malformed scan with one enormous key could still exhaust the heap.

Stream every scalar. Keys are assembled only up to 64 code units, past
every field name the reader keeps, so a longer key is skipped unmatched;
kept numbers longer than that are rejected, and ignored numbers are
dropped unassembled. XUL's 611k-row scan parses in the same time.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): stream the call graph instead of capping it at 32 MiB (#4329)

* fix(kuna): stream the call graph instead of capping it at 32 MiB

Call relationships buffered `kuna decompile-graph` and refused output
above 32 MiB. A 53 MB Raycast binary produces a 157 MB graph (180k
functions, 773k edges), so every call query failed with a non-retryable
memory error after Kuna's minute-long whole-program walk.

`KunaCallGraphReader` validates the graph through the shared streaming
document reader. It retains only the facts queries use (126 MB for
Raycast) and checks edges against the complete node set once Kuna
exits. Unrequested bodies are refused before any of their text is
assembled, through new must-be-null row fields; address fields may be
null, as `forwardsTo` is. Malformed graphs are now `AnalysisOutputError`
like the string scan.

`KunaQueryWork.scan` owns the run-and-validate sequence both producers
shared: diagnostics budget, early rejection and retention of
deterministic overflow.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): index call-graph edges as they stream

Review found that the reader kept every parsed edge and built the edge
indexes only after streaming, outside any heap admission, so a large
graph could exhaust the heap instead of returning a memory constraint.

Index each edge when it arrives: duplicate pairs and callee orders are
checked per caller, call edges go straight into the caller/callee
indexes, and no edge array is kept, so the whole working set grows under
the per-row admission. Endpoints and order completeness are checked once
all nodes are known, and the validation-only indexes are released then.
Raycast's graph now peaks at 346 MB instead of 503 MB and retains 93 MB
instead of 126 MB, in the same time.

Also drop imports the shared scan made unused.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kuna): state the graph's retained size after incremental indexing

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:53:59 -07:00

539 lines
17 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import { setTimeout as delay } from "node:timers/promises";
import { beforeAll, expect, it, vi } from "vitest";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../../../src/process/OwnedCommand.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import {
cleanupOwnedProcessGroup,
type ProcessCleanupResult,
} from "../../../src/process/ProcessOwnership.js";
import {
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "../../../src/process/ProcessOwnershipObservation.js";
import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
const command = (script: string) => ({
command: process.execPath,
arguments: ["-e", script],
runId: `rea-owned-command-test-${randomUUID()}`,
});
// Output/exit cases start with a prepared inspector; startup deadline cases
// below inject their own preparation so they remain independent of compiler speed.
beforeAll(() => prepareProcessOwnershipInspection());
it("includes ownership preparation in the command deadline before provider creation", async () => {
let prepared = false;
await expect(
runOwnedCommand(
command("process.exit(0)"),
{
timeoutMs: 20,
diagnosticBytes: 1024,
},
{
launcher: async (input) => {
await delay(80, undefined, { signal: input.signal });
prepared = true;
throw new Error(
"Slow preparation must be aborted before provider creation",
);
},
},
),
).rejects.toMatchObject({ reason: "timeout", snapshot: null });
expect(prepared).toBe(false);
});
it("passes cancellation into native ownership preparation before provider creation", async () => {
const controller = new AbortController();
await expect(
runOwnedCommand(
command("process.exit(0)"),
{ timeoutMs: 2000, diagnosticBytes: 1024 },
{
signal: controller.signal,
launcher: async (input) => {
expect(input.signal?.aborted).toBe(false);
controller.abort();
input.signal?.throwIfAborted();
throw new Error("Cancelled preparation must not launch a provider");
},
},
),
).rejects.toMatchObject({ reason: "cancelled", snapshot: null });
});
it("bounds retained diagnostics across both streams during a real output burst", async () => {
try {
await runOwnedCommand(
command(
'process.stdout.write("a".repeat(256 * 1024)); process.stderr.write("b".repeat(256 * 1024))',
),
{ timeoutMs: 2_000, diagnosticBytes: 1024 },
);
throw new Error("An oversized producer must fail");
} catch (cause: unknown) {
if (!(cause instanceof OwnedCommandFailure)) throw cause;
expect(cause.reason).toBe("output-limit");
expect(cause.snapshot).not.toBeNull();
expect(
(cause.snapshot?.stdout.bytes ?? 0) + (cause.snapshot?.stderr.bytes ?? 0),
).toBeLessThanOrEqual(1024);
}
});
it("collects a real owned command and verifies its exit before returning diagnostics", async () => {
const result = await runOwnedCommand(
command('process.stdout.write("observed-output")'),
{ timeoutMs: 2_000, diagnosticBytes: 1024 },
);
expect(result).toMatchObject({
exitCode: 0,
signal: null,
stdout: { text: "observed-output", bytes: 15 },
});
});
it("streams stdout beyond the diagnostic budget into a sink and returns after it finishes", async () => {
let received = 0;
let finished = false;
const sink = new Writable({
write(chunk: Buffer, _encoding, callback) {
received += chunk.byteLength;
callback();
},
final(callback) {
// A sink still parsing after output closes delays the result.
setTimeout(() => {
finished = true;
callback();
}, 50);
},
});
const result = await runOwnedCommand(
command(
'process.stdout.write("a".repeat(256 * 1024)); process.stderr.write("diagnostic")',
),
{ timeoutMs: 2_000, diagnosticBytes: 1024, stdout: sink },
);
expect(finished).toBe(true);
expect(received).toBe(256 * 1024);
expect(result).toMatchObject({
exitCode: 0,
stdout: { text: "", bytes: 0 },
stderr: { text: "diagnostic" },
});
});
it("destroys a sink still finishing when the command deadline elapses", async () => {
const sink = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
final(callback) {
setTimeout(callback, 5_000);
},
});
await expect(
runOwnedCommand(command('process.stdout.write("observed-output")'), {
timeoutMs: 300,
diagnosticBytes: 1024,
stdout: sink,
}),
).rejects.toMatchObject({ reason: "timeout" });
expect(sink.destroyed).toBe(true);
});
it("reports a failed stdout sink as a collection failure", async () => {
const sink = new Writable({
write(_chunk, _encoding, callback) {
callback(new Error("fixture sink failure"));
},
});
await expect(
runOwnedCommand(command('process.stdout.write("observed-output")'), {
timeoutMs: 2_000,
diagnosticBytes: 1024,
stdout: sink,
}),
).rejects.toMatchObject({
reason: "process",
collectionFailure: true,
message: "stdout stream failed: fixture sink failure",
});
});
it("fails an accepted nonzero command when an output stream errors and cleans it up", async () => {
const stdout = new PassThrough();
const stderr = new PassThrough();
class FakeProcess extends EventEmitter {
readonly stdout = stdout;
readonly stderr = stderr;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
readonly pid = 41234;
kill(): boolean {
return true;
}
}
const child = new FakeProcess();
const cleanup = vi.fn(async () => ({
cleaned: true as const,
signaled: false,
}));
const request = command("unused fake launcher command");
const failure = runOwnedCommand(
request,
{ timeoutMs: 2_000, diagnosticBytes: 1024, acceptNonZeroExit: true },
{
launcher: async () => {
setImmediate(() => {
stdout.write("partial output");
stdout.destroy(new Error("fixture read failure"));
stderr.end();
child.exitCode = 7;
child.emit("exit", 7, null);
child.emit("close", 7, null);
});
return {
process: child,
ownership: {
runId: request.runId,
leaderPid: child.pid,
processGroupId: child.pid,
},
cleanup,
};
},
},
);
await expect(failure).rejects.toMatchObject({
reason: "process",
message: "stdout stream failed: fixture read failure",
snapshot: { stdout: { text: "partial output" }, exitCode: 7 },
});
expect(cleanup).toHaveBeenCalledOnce();
});
it("marks an output collection error when the process itself exits zero", async () => {
const stdout = new PassThrough();
const stderr = new PassThrough();
class FakeProcess extends EventEmitter {
readonly stdout = stdout;
readonly stderr = stderr;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
readonly pid = 41235;
kill(): boolean {
return true;
}
}
const child = new FakeProcess();
const request = command("unused fake launcher command");
const failure = runOwnedCommand(
request,
{ timeoutMs: 2_000, diagnosticBytes: 1024, acceptNonZeroExit: true },
{
launcher: async () => {
setImmediate(() => {
stdout.write("partial output");
stdout.destroy(new Error("fixture read failure"));
stderr.end();
child.exitCode = 0;
child.emit("exit", 0, null);
child.emit("close", 0, null);
});
return {
process: child,
ownership: {
runId: request.runId,
leaderPid: child.pid,
processGroupId: child.pid,
},
cleanup: async () => ({ cleaned: true, signaled: false }),
};
},
},
);
await expect(failure).rejects.toMatchObject({
reason: "process",
collectionFailure: true,
message: "stdout stream failed: fixture read failure",
snapshot: { stdout: { text: "partial output" }, exitCode: 0 },
});
});
it.skipIf(process.platform === "win32")(
"sweeps detached run-token descendants and preserves a pre-existing unrelated process",
async () => {
const runId = `rea-owned-descendant-test-${randomUUID()}`;
const unrelated = await spawnOwnedProviderProcess({
command: process.execPath,
arguments: ["-e", "setInterval(() => undefined, 1000)"],
runId: `${runId}-unrelated`,
hostEnvironment: process.env,
});
let descendantPid: number | undefined;
try {
const result = await runOwnedCommand(
{
...command(`
const { spawn } = require("node:child_process");
const child = spawn(process.execPath, ["-e", "setInterval(() => undefined, 1000)"], {
detached: true,
stdio: "ignore",
});
child.unref();
process.stdout.write(String(child.pid));
`),
runId,
sweepTokenOwnedProcesses: true,
},
{ timeoutMs: 5_000, diagnosticBytes: 1024 },
);
descendantPid = Number(result.stdout.text);
expect(Number.isSafeInteger(descendantPid)).toBe(true);
let remainsLive = true;
const deadline = Date.now() + 2_000;
while (remainsLive && Date.now() < deadline) {
remainsLive = (await systemProcessOwnershipHost.listProcesses()).some(
({ pid }) => pid === descendantPid,
);
if (remainsLive) await delay(25);
}
expect(remainsLive).toBe(false);
expect(() => process.kill(unrelated.process.pid!, 0)).not.toThrow();
} finally {
if (descendantPid !== undefined) {
try {
process.kill(descendantPid, "SIGKILL");
} catch {
// The run-token sweep may already have removed it.
}
}
await cleanupOwnedProcessGroup(unrelated.ownership);
}
},
);
it.skipIf(process.platform === "win32")(
"fails before provider spawn when a requested ownership baseline cannot be captured",
async () => {
const baseline = vi
.spyOn(systemProcessOwnershipHost, "captureBaseline")
.mockRejectedValue(new Error("baseline unavailable"));
try {
await expect(
runOwnedCommand(
{ ...command("process.exit(0)"), sweepTokenOwnedProcesses: true },
{ timeoutMs: 2_000, diagnosticBytes: 1024 },
),
).rejects.toThrow("baseline unavailable");
} finally {
baseline.mockRestore();
}
},
);
it("cancels a real acquired process and independently releases it", async () => {
const controller = new AbortController();
let pid: number | undefined;
const result = runOwnedCommand(
command(
'process.stdout.write("ready\\n"); setInterval(() => undefined, 1000)',
),
{ timeoutMs: 5_000, diagnosticBytes: 1024 },
{
signal: controller.signal,
launcher: async (input) => {
const process = await spawnOwnedProviderProcess(input);
pid = process.process.pid;
await waitForProviderProcessReady(process.process);
setImmediate(() => controller.abort());
return process;
},
},
);
await expect(result).rejects.toMatchObject({
reason: "cancelled",
cleanupFailure: null,
});
if (pid === undefined) throw new Error("Process was not acquired");
const acquiredPid = pid;
expect(() => process.kill(acquiredPid, 0)).toThrow();
});
it("returns a timeout for a running process after independent cleanup", async () => {
await expect(
runOwnedCommand(command("setInterval(() => undefined, 1000)"), {
timeoutMs: 50,
diagnosticBytes: 1024,
}),
).rejects.toMatchObject({ reason: "timeout", cleanupFailure: null });
});
it.skipIf(process.platform === "win32").each(["cancelled", "timeout"] as const)(
"retains the real per-member cleanup ledger after %s and settles after cooperative exit",
async (reason) => {
const childScript =
'process.stdin.once("data", () => process.exit(0)); process.stdout.write("child-ready\\n")';
const request = {
...command(`
const { spawn } = require("node:child_process");
const child = spawn(process.execPath, ["-e", ${JSON.stringify(childScript)}], {
env: { ...process.env, REA_PROCESS_RUN_ID: "different-child-owner" },
stdio: ["pipe", "inherit", "inherit"],
});
const release = () => child.stdin.end("release");
process.stdin.once("data", release);
const failsafe = setTimeout(release, 10000);
child.once("exit", () => { clearTimeout(failsafe); process.stdin.destroy(); });
`),
stdin: "pipe" as const,
};
const controller = new AbortController();
let launched:
| Awaited<ReturnType<typeof spawnOwnedProviderProcess>>
| undefined;
let cleanup: ProcessCleanupResult | undefined;
let failure: OwnedCommandFailure | undefined;
try {
await runOwnedCommand(
request,
{ timeoutMs: 2000, diagnosticBytes: 1024 },
{
signal: controller.signal,
launcher: async (input) => {
const acquired = await spawnOwnedProviderProcess(input);
launched = acquired;
acquired.process.stdout?.on("data", (chunk: Buffer) => {
if (
reason === "cancelled" &&
chunk.toString().includes("child-ready")
)
controller.abort();
});
return {
...acquired,
cleanup: async () => {
const observed = await cleanupOwnedProcessGroup(
acquired.ownership,
);
cleanup ??= observed;
return observed;
},
};
},
},
).catch((cause: unknown) => {
if (!(cause instanceof OwnedCommandFailure)) throw cause;
failure = cause;
});
if (failure === undefined || launched === undefined)
throw new Error(
"The acquired command must retain its failed cleanup owner",
);
expect(failure.reason).toBe(reason);
expect(failure.snapshot?.stdout.text).toContain("child-ready");
expect(cleanup).toMatchObject({
cleaned: false,
failures: [expect.objectContaining({ reason: "run-token-mismatch" })],
});
expect(failure.cleanupResult).toEqual(cleanup);
expect(failure.resources).toEqual([
request.runId,
`pid:${launched.ownership.leaderPid}`,
`process-group:${launched.ownership.processGroupId}`,
]);
// An unresolved member does not grant authority to signal the shared group.
const acquiredPid = launched.ownership.leaderPid;
expect(() => process.kill(acquiredPid, 0)).not.toThrow();
if (cleanup?.cleaned === false) {
for (const member of cleanup.failures ?? []) {
expect(() => process.kill(member.pid, 0)).not.toThrow();
expect(failure.cleanupFailure).toContain(String(member.pid));
}
}
} finally {
// Release through the fixture's acquired input channel, never by an unverified PID.
launched?.process.stdin?.end("release");
if (failure?.cleanupOwner !== undefined) {
expect(await failure.cleanupOwner.waitForOutputClose(5000)).toBe(true);
expect(await failure.cleanupOwner.stop()).toEqual({
status: "verified-cleanup",
});
}
}
},
);
it("honors cancellation during cleanup after a successful command", async () => {
const controller = new AbortController();
await expect(
runOwnedCommand(
command("process.exit(0)"),
{ timeoutMs: 2_000, diagnosticBytes: 1024 },
{
signal: controller.signal,
launcher: async (input) => {
const launched = await spawnOwnedProviderProcess(input);
return {
...launched,
cleanup: async () => {
const result = await cleanupOwnedProcessGroup(launched.ownership);
controller.abort();
return result;
},
};
},
},
),
).rejects.toMatchObject({ reason: "cancelled", cleanupFailure: null });
});
it("rejects oversized diagnostics even when the child exits immediately", async () => {
await expect(
runOwnedCommand(command('process.stdout.write("x".repeat(2048))'), {
timeoutMs: 2_000,
diagnosticBytes: 1024,
}),
).rejects.toMatchObject({ reason: "output-limit", cleanupFailure: null });
});
it("preserves original command failure together with failed cleanup resource identities", async () => {
const request = command("process.exit(2)");
await expect(
runOwnedCommand(
request,
{ timeoutMs: 2_000, diagnosticBytes: 1024 },
{
launcher: async (input) => {
const process = await spawnOwnedProviderProcess(input);
return {
...process,
cleanup: async () => ({
cleaned: false as const,
reason: "synthetic cleanup could not be confirmed",
}),
};
},
},
),
).rejects.toMatchObject({
reason: "process",
cleanupFailure: "synthetic cleanup could not be confirmed",
resources: expect.arrayContaining([request.runId]),
snapshot: { exitCode: 2 },
});
});