Pass the requesting operation into the descriptor failure owner. Discovery retains its own identity; command execution and completion confirmation retain cutter_command with exact cleanup dependencies. Validated 61 focused tests across five files, including denied retry and successful native descriptor release. Controlled close faults; no native Cutter engine verified. Refs: #4634
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { writeFile, type FileHandle } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
|
|
const fault = vi.hoisted(() => ({
|
|
root: "",
|
|
blocked: new Set<FileHandle>(),
|
|
handles: [] as FileHandle[],
|
|
onOpen: undefined as (() => void) | undefined,
|
|
onClose: undefined as ((handle: FileHandle) => Promise<void>) | undefined,
|
|
}));
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const original = await importOriginal<typeof import("node:fs/promises")>();
|
|
return {
|
|
...original,
|
|
open: async (...args: Parameters<typeof original.open>) => {
|
|
const handle = await original.open(...args);
|
|
if (!fault.root || !String(args[0]).startsWith(fault.root)) return handle;
|
|
const close = handle.close.bind(handle);
|
|
fault.handles.push(handle);
|
|
fault.blocked.add(handle);
|
|
handle.close = async () => {
|
|
await fault.onClose?.(handle);
|
|
if (fault.blocked.has(handle))
|
|
throw new Error("controlled Cutter descriptor close failure");
|
|
await close();
|
|
};
|
|
fault.onOpen?.();
|
|
return handle;
|
|
},
|
|
};
|
|
});
|
|
|
|
import { CutterBridgeClient } from "../../src/cutter/CutterBridgeClient.js";
|
|
import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js";
|
|
import { AnalysisError } from "../../src/domain/analysisErrorBase.js";
|
|
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
import { runCliJson } from "../fixtures/cliJsonProcess.js";
|
|
|
|
beforeEach(() => {
|
|
fault.root = "";
|
|
fault.blocked.clear();
|
|
fault.handles = [];
|
|
fault.onClose = undefined;
|
|
fault.onOpen = undefined;
|
|
});
|
|
afterEach(async () => {
|
|
fault.blocked.clear();
|
|
fault.onClose = undefined;
|
|
for (const handle of fault.handles) await handle.close();
|
|
});
|
|
|
|
const descriptor = async (root: string, index: number) => {
|
|
const session_id = `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
|
const path = join(root, `cutter-${process.pid}-${session_id}.json`);
|
|
await writeFile(
|
|
path,
|
|
JSON.stringify({
|
|
session_id,
|
|
pid: process.pid,
|
|
host: "127.0.0.1",
|
|
port: 9,
|
|
token: "fixture-token-never-sent-to-network",
|
|
document_generation: 0,
|
|
current_file: null,
|
|
cutter_version: null,
|
|
identity_status: "partial",
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
return path;
|
|
};
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"public CLI skips a named-pipe descriptor without waiting for a writer",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-cutter-fifo-");
|
|
const fifo = join(
|
|
root,
|
|
"cutter-1-00000000-0000-4000-8000-000000000001.json",
|
|
);
|
|
await promisify(execFile)("mkfifo", [fifo]);
|
|
const result = await runCliJson(["list-cutter-sessions", "--json"], {
|
|
...process.env,
|
|
REA_CUTTER_BRIDGE_DIR: root,
|
|
});
|
|
expect(result).toMatchObject({
|
|
sessions: [],
|
|
discovery_status: "no_live_bridge_found",
|
|
bridge_directory_security: "private_verified",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("retains exact failed descriptors and keeps aggregate status until every close succeeds", async () => {
|
|
const root = await createTestTempDirectory("rea-cutter-descriptor-owners-");
|
|
await Promise.all([descriptor(root, 1), descriptor(root, 2)]);
|
|
fault.root = root;
|
|
const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: root });
|
|
try {
|
|
const original = await client
|
|
.listDescriptors()
|
|
.catch((cause: unknown) => cause);
|
|
if (!(original instanceof AnalysisError))
|
|
throw new Error("Expected typed cleanup aggregate");
|
|
expect(original.cleanupIncomplete).toBe(true);
|
|
expect(original.cleanupResources).toHaveLength(2);
|
|
expect(fault.handles).toHaveLength(2);
|
|
const retry = await client
|
|
.listDescriptors()
|
|
.catch((cause: unknown) => cause);
|
|
if (!(retry instanceof AnalysisError))
|
|
throw new Error("Expected blocked retry");
|
|
expect(fault.handles).toHaveLength(2);
|
|
const [first, second] = fault.handles;
|
|
if (first === undefined || second === undefined)
|
|
throw new Error("Missing exact descriptors");
|
|
fault.blocked.delete(first);
|
|
const mixed = await client.close().catch((cause: unknown) => cause);
|
|
if (!(mixed instanceof AnalysisError))
|
|
throw new Error("Expected mixed cleanup failure");
|
|
expect(first.fd).toBe(-1);
|
|
expect(second.fd).toBeGreaterThanOrEqual(0);
|
|
expect(original.cleanupIncomplete).toBe(true);
|
|
expect(retry.cleanupIncomplete).toBe(true);
|
|
expect(mixed.cleanupResources).toHaveLength(1);
|
|
fault.blocked.clear();
|
|
await client.close();
|
|
expect(second.fd).toBe(-1);
|
|
expect(original.cleanupIncomplete).toBe(false);
|
|
expect(retry.cleanupIncomplete).toBe(false);
|
|
expect(mixed.cleanupIncomplete).toBe(false);
|
|
} finally {
|
|
fault.blocked.clear();
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it("shutdown waits for descriptor finalization before retrying retained cleanup", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-cutter-descriptor-finalization-",
|
|
);
|
|
await descriptor(root, 1);
|
|
fault.root = root;
|
|
let entered!: () => void;
|
|
let release!: () => void;
|
|
const closing = new Promise<void>((resolve) => {
|
|
entered = resolve;
|
|
});
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
fault.onClose = async () => {
|
|
entered();
|
|
await gate;
|
|
};
|
|
const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: root });
|
|
const reading = client.listDescriptors().catch((cause: unknown) => cause);
|
|
await closing;
|
|
let settled = false;
|
|
const shutdown = client.close().then(() => {
|
|
settled = true;
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(settled).toBe(false);
|
|
fault.blocked.clear();
|
|
release();
|
|
await reading;
|
|
await shutdown;
|
|
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
|
});
|
|
|
|
it.each([null, "primitive abort", new Error("selected abort")])(
|
|
"preserves admission cancellation %s when descriptor close fails",
|
|
async (reason) => {
|
|
const root = await createTestTempDirectory("rea-cutter-cancel-admission-");
|
|
await descriptor(root, 1);
|
|
fault.root = root;
|
|
const controller = new AbortController();
|
|
fault.onOpen = () => controller.abort(reason);
|
|
const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: root });
|
|
try {
|
|
const failure = await client
|
|
.listSessions({ signal: controller.signal })
|
|
.catch((cause: unknown) => cause);
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected typed cancellation with cleanup");
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
const aggregate = failure.cause;
|
|
if (!(aggregate instanceof AggregateError))
|
|
throw new Error("Missing primary cancellation");
|
|
const primary = aggregate.errors[0];
|
|
expect(primary).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(primary.cause).toBe(reason);
|
|
fault.blocked.clear();
|
|
await client.close();
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
expect(primary.cause).toBe(reason);
|
|
} finally {
|
|
fault.blocked.clear();
|
|
await client.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("does not assign a peer's later cancellation to a descriptor that already failed close", async () => {
|
|
const root = await createTestTempDirectory("rea-cutter-peer-finalization-");
|
|
await Promise.all([descriptor(root, 1), descriptor(root, 2)]);
|
|
fault.root = root;
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel after the first descriptor failure");
|
|
let entered!: () => void;
|
|
let release!: () => void;
|
|
const waiting = new Promise<void>((resolve) => {
|
|
entered = resolve;
|
|
});
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
fault.onClose = async (handle) => {
|
|
if (handle === fault.handles[0]) return;
|
|
entered();
|
|
await gate;
|
|
};
|
|
const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: root });
|
|
const reading = client
|
|
.listSessions({ signal: controller.signal })
|
|
.catch((cause: unknown) => cause);
|
|
try {
|
|
await waiting;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
controller.abort(reason);
|
|
release();
|
|
const failure = await reading;
|
|
if (
|
|
!(failure instanceof AnalysisError) ||
|
|
!(failure.cause instanceof AggregateError)
|
|
)
|
|
throw new Error("Expected mixed descriptor cleanup failures");
|
|
const failures: unknown[] = failure.cause.errors;
|
|
expect(failures).toHaveLength(2);
|
|
const cancelled = failures.filter(
|
|
(error): error is AnalysisError =>
|
|
error instanceof AnalysisError &&
|
|
error.cause instanceof AggregateError &&
|
|
error.cause.errors[0] instanceof AnalysisCancelledError,
|
|
);
|
|
expect(cancelled).toHaveLength(1);
|
|
expect((cancelled[0]?.cause as AggregateError).errors[0].cause).toBe(
|
|
reason,
|
|
);
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
fault.blocked.clear();
|
|
await client.close();
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
} finally {
|
|
release();
|
|
fault.onClose = undefined;
|
|
fault.blocked.clear();
|
|
await reading;
|
|
await client.close();
|
|
}
|
|
});
|
|
|
|
it.each(["command", "completion"] as const)(
|
|
"attributes retained descriptor cleanup to the requesting %s",
|
|
async (operation) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-cutter-command-descriptor-",
|
|
);
|
|
await descriptor(root, 1);
|
|
fault.root = root;
|
|
const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: root });
|
|
try {
|
|
const original =
|
|
operation === "command"
|
|
? await client
|
|
.execute({
|
|
sessionId: "00000000-0000-4000-8000-000000000001",
|
|
expectedGeneration: 0,
|
|
command: "ij",
|
|
json: false,
|
|
})
|
|
.catch((cause: unknown) => cause)
|
|
: await client
|
|
.confirmCommandCompletion(
|
|
"00000000-0000-4000-8000-000000000001",
|
|
"4b18ce4d-bf31-456e-ae31-e8c547b2f9db",
|
|
)
|
|
.then((result) => {
|
|
if (result.ok) throw new Error("Expected descriptor failure");
|
|
return result.error;
|
|
});
|
|
if (!(original instanceof AnalysisError))
|
|
throw new Error("Expected typed descriptor cleanup");
|
|
expect(projectAnalysisError(original)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: { operation: "cutter_command" },
|
|
});
|
|
expect(fault.handles).toHaveLength(1);
|
|
expect(fault.handles[0]?.fd).toBeGreaterThanOrEqual(0);
|
|
await expect(client.retryDescriptorCleanup()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
expect(original.cleanupIncomplete).toBe(true);
|
|
fault.blocked.clear();
|
|
await client.close();
|
|
expect(fault.handles[0]?.fd).toBe(-1);
|
|
expect(original.cleanupIncomplete).toBe(false);
|
|
} finally {
|
|
fault.blocked.clear();
|
|
await client.close();
|
|
}
|
|
},
|
|
);
|