mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 13:26:10 +00:00
Carry historical cleanup reports with the exact lease that can confirm release. Bind originating capture errors and typed retry aggregates to those owners while preserving primary failures and completed observations. Keep those bindings through MCP close and compose mixed session/process cleanup failures. Start both close operations even when a child throws a typed failure synchronously. Fixes #5144 Fixes #5163 Fixes #5166
230 lines
7.0 KiB
TypeScript
230 lines
7.0 KiB
TypeScript
import { createRequire } from "node:module";
|
|
import { mkdtemp, rm, stat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { expect, it, vi } from "vitest";
|
|
|
|
import { TerminalRenderer } from "./TerminalRenderer.js";
|
|
import { ProcessCaptureResourceScope } from "./ProcessCaptureLifecycle.js";
|
|
import { createBoundedProcessTextNormalizer } from "./ProcessNormalization.js";
|
|
import { parseProcessScenario } from "../../domain/process/processScenario.js";
|
|
import type { BoundedProcessTextNormalization } from "./ProcessNormalization.js";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { Terminal } =
|
|
require("@xterm/headless") as typeof import("@xterm/headless");
|
|
const { SerializeAddon } =
|
|
require("@xterm/addon-serialize") as typeof import("@xterm/addon-serialize");
|
|
|
|
const boundedNormalize =
|
|
(
|
|
normalize: (value: string) => string,
|
|
): ((value: string, maxBytes: number) => BoundedProcessTextNormalization) =>
|
|
(value, maxBytes) => {
|
|
const normalized = normalize(value);
|
|
const utf8Bytes = Buffer.byteLength(normalized);
|
|
return utf8Bytes <= maxBytes
|
|
? { state: "normalized", value: normalized, utf8Bytes }
|
|
: { state: "over_budget" };
|
|
};
|
|
|
|
const renderFrames = async (
|
|
data: string,
|
|
normalize: (value: string) => string = (value) => value,
|
|
) => {
|
|
const renderer = new TerminalRenderer({
|
|
columns: 20,
|
|
rows: 4,
|
|
scrollback: 10,
|
|
maxBytes: 100_000,
|
|
normalize: boundedNormalize(normalize),
|
|
});
|
|
renderer.write(data, 0);
|
|
const frames = await renderer.frames();
|
|
const retention = renderer.retention();
|
|
await renderer.dispose();
|
|
return { frames, retention };
|
|
};
|
|
|
|
it("omits trailing blank cells from visible lines without losing rows", async () => {
|
|
const { frames, retention } = await renderFrames(
|
|
"left right \r\n\r\n indented",
|
|
);
|
|
const [frame] = frames;
|
|
expect(frame?.lines).toEqual(["left right", "", " indented", ""]);
|
|
// Fixed-width rows remain recoverable from the recorded column count.
|
|
expect(frame?.lines.map((line) => line.padEnd(frame.columns, " "))).toEqual([
|
|
"left right ",
|
|
" ".repeat(20),
|
|
" indented ",
|
|
" ".repeat(20),
|
|
]);
|
|
expect(retention.retained_bytes).toBe(
|
|
Buffer.byteLength(frame?.serialized_state ?? "") +
|
|
Buffer.byteLength("left right indented"),
|
|
);
|
|
});
|
|
|
|
it("applies rendered retention cumulatively across sparse frames", async () => {
|
|
const renderer = new TerminalRenderer({
|
|
columns: 10,
|
|
rows: 1,
|
|
scrollback: 0,
|
|
maxBytes: 5,
|
|
normalize: boundedNormalize((value) => value),
|
|
});
|
|
try {
|
|
renderer.write("a", 0);
|
|
renderer.write("b", 1);
|
|
const frames = await renderer.frames();
|
|
|
|
expect(frames.map(({ lines }) => lines)).toEqual([["a"]]);
|
|
expect(renderer.retention()).toEqual({
|
|
budget_bytes: 5,
|
|
observed_bytes: 2,
|
|
retained_bytes: 2,
|
|
observed_frames: 2,
|
|
retained_frames: 1,
|
|
unknown_size_frames: 1,
|
|
});
|
|
expect(renderer.truncated()).toBe(true);
|
|
} finally {
|
|
await renderer.dispose();
|
|
}
|
|
});
|
|
|
|
it("trims only after normalization sees the full-width row", async () => {
|
|
const { frames } = await renderFrames("pid 4242", (value) =>
|
|
value.replace(/4242 +$/u, "<pid>"),
|
|
);
|
|
expect(frames[0]?.lines[0]).toBe("pid <pid>");
|
|
});
|
|
|
|
it("omits rendered frames with unknown expanded size and keeps later bounded states", async () => {
|
|
const renderer = new TerminalRenderer({
|
|
columns: 10,
|
|
rows: 1,
|
|
scrollback: 0,
|
|
maxBytes: 1_000,
|
|
normalize: createBoundedProcessTextNormalizer(
|
|
parseProcessScenario({
|
|
executable: "unused",
|
|
normalization: {
|
|
paths: false,
|
|
pids: false,
|
|
ports: false,
|
|
patterns: [{ pattern: "a", replacement: "x".repeat(2_000) }],
|
|
},
|
|
}),
|
|
"/tmp",
|
|
-1,
|
|
),
|
|
});
|
|
try {
|
|
renderer.write("a", 0);
|
|
renderer.write("\u001b[2J\rz", 1);
|
|
const frames = await renderer.frames();
|
|
|
|
expect(frames).toHaveLength(1);
|
|
expect(frames[0]?.lines[0]).toBe("z");
|
|
expect(renderer.retention()).toMatchObject({
|
|
observed_frames: 2,
|
|
retained_frames: 1,
|
|
unknown_size_frames: 1,
|
|
});
|
|
expect(renderer.truncated()).toBe(true);
|
|
} finally {
|
|
await renderer.dispose();
|
|
}
|
|
});
|
|
|
|
it.each(["write", "resize"] as const)(
|
|
"releases terminal resources after a queued %s observation fails",
|
|
async (operation) => {
|
|
const failure = new Error("normalization failed");
|
|
const terminalDispose = vi.spyOn(Terminal.prototype, "dispose");
|
|
const addonDispose = vi.spyOn(SerializeAddon.prototype, "dispose");
|
|
const renderer = new TerminalRenderer({
|
|
columns: 20,
|
|
rows: 4,
|
|
scrollback: 10,
|
|
maxBytes: 100_000,
|
|
normalize: boundedNormalize(() => {
|
|
throw failure;
|
|
}),
|
|
});
|
|
const scope = new ProcessCaptureResourceScope();
|
|
const temporaryRoot = await mkdtemp(
|
|
join(tmpdir(), "rea-terminal-cleanup-"),
|
|
);
|
|
try {
|
|
if (operation === "write") renderer.write("output", 0);
|
|
else renderer.resize(30, 4, 0);
|
|
await expect(renderer.frames()).rejects.toBe(failure);
|
|
const cleanup = await scope.release({
|
|
timers: new Set(),
|
|
terminal: undefined,
|
|
renderer,
|
|
runId: "failed-observation",
|
|
temporaryRoot,
|
|
});
|
|
expect(cleanup.report.terminal_renderer).toEqual({
|
|
state: "cleaned",
|
|
reason: null,
|
|
});
|
|
expect(terminalDispose).toHaveBeenCalledTimes(1);
|
|
expect(addonDispose).toHaveBeenCalledTimes(1);
|
|
await expect(stat(temporaryRoot)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
await expect(scope.run(async () => "next capture")).resolves.toBe(
|
|
"next capture",
|
|
);
|
|
await expect(renderer.frames()).rejects.toBe(failure);
|
|
} finally {
|
|
await scope.close();
|
|
terminalDispose.mockRestore();
|
|
addonDispose.mockRestore();
|
|
await rm(temporaryRoot, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("retains disposal failures for a later cleanup retry", async () => {
|
|
const failure = new Error("terminal disposal failed");
|
|
const terminalDispose = vi.spyOn(Terminal.prototype, "dispose");
|
|
terminalDispose.mockImplementationOnce(() => {
|
|
throw failure;
|
|
});
|
|
const renderer = new TerminalRenderer({
|
|
columns: 20,
|
|
rows: 4,
|
|
scrollback: 10,
|
|
maxBytes: 100_000,
|
|
normalize: boundedNormalize((value) => value),
|
|
});
|
|
const scope = new ProcessCaptureResourceScope();
|
|
const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-terminal-retry-"));
|
|
try {
|
|
const cleanup = await scope.release({
|
|
timers: new Set(),
|
|
terminal: undefined,
|
|
renderer,
|
|
runId: "failed-disposal",
|
|
temporaryRoot,
|
|
});
|
|
expect(cleanup.report.terminal_renderer).toEqual({
|
|
state: "failed",
|
|
reason: failure.message,
|
|
});
|
|
await expect(scope.run(async () => "next capture")).resolves.toBe(
|
|
"next capture",
|
|
);
|
|
expect(terminalDispose).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
await scope.close();
|
|
terminalDispose.mockRestore();
|
|
await rm(temporaryRoot, { recursive: true, force: true });
|
|
}
|
|
});
|