fix(native): retain command cleanup across source finalization (#10009)

This commit is contained in:
morluto authored and GitHub committed 2026-11-11 17:20:14 +08:00
1 parent 12a32d934a
commit e7c4a26316
3 files changed
+432 -8

No files matched your search

@@ -1,9 +1,14 @@
import { EventEmitter } from "node:events";
import { open, writeFile, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { NativeMacOSProvider } from "./NativeMacOSProvider.js";
import { NativeCommandFailure, XcrunCommandRunner } from "./CommandRunner.js";
import { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import { err } from "../domain/result.js";
import { primaryAnalysisError } from "../domain/analysisErrorBase.js";
import { MachoCommandNamesLimitError } from "./MachoCommandNames.js";
import {
NativeFixtureRunner,
@@ -490,3 +495,165 @@ it.each([
}
},
);
it.each(
(["inspect_macho", "list_architectures"] as const).flatMap((operation) => [
...[false, true].flatMap((cancelDuringClose) =>
[false, true].map((failDescriptorClose) => ({
operation,
cancelDuringClose,
failDescriptorClose,
changeTarget: false,
})),
),
{
operation,
cancelDuringClose: false,
failDescriptorClose: false,
changeTarget: true,
},
]),
)(
"keeps $operation command cleanup live through finalization (cancel: $cancelDuringClose, failed close: $failDescriptorClose, changed target: $changeTarget)",
async ({
operation,
cancelDuringClose,
failDescriptorClose,
changeTarget,
}) => {
const root = await createTestTempDirectory("rea-macho-command-owner-");
const path = join(root, "fixture.macho");
await writeFile(path, inspectionImage());
const target = await nativeMachoTargetForFile(path);
// The exited event handle and cleanup callback are inert; no process starts.
const process = Object.assign(new EventEmitter(), {
exitCode: 1,
signalCode: null,
stdout: null,
stderr: null,
kill: () => {
throw new Error("Exited fixture must not be signalled");
},
});
let mayClean = false;
let cleanupAttempts = 0;
const owner = new ProviderProcessSupervisor({
process,
ownsProcessLifetime: true,
cleanup: async () => {
cleanupAttempts++;
return mayClean
? { cleaned: true, signaled: false }
: { cleaned: false, reason: "worker remains owned" };
},
});
process.emit("close", 1, null);
const physical = new Error("physical command read failure");
const failure = new NativeCommandFailure(
"lipo",
"io",
1,
{ cause: physical, cleanupOwner: owner },
{
stdout: "partial native output",
stderr: "read failed",
stdoutBytes: 21,
stderrBytes: 11,
exitCode: 1,
signal: null,
truncated: false,
},
"worker remains owned",
["native-command-process"],
);
const runner = new XcrunCommandRunner({}, async () => {
if (changeTarget) await writeFile(path, "changed Mach-O source bytes");
return err(failure);
});
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
target,
);
const controller = new AbortController();
const reason = { request: "cancel during descriptor close" };
let descriptorCloseFails = failDescriptorClose;
const probe = await open(path, "r");
const prototype = Object.getPrototypeOf(probe) as FileHandle;
await probe.close();
const original = prototype.stat;
const stat = vi
.spyOn(prototype, "stat")
.mockImplementationOnce(async function (
this: FileHandle,
...args: Parameters<FileHandle["stat"]>
) {
const close = this.close.bind(this);
this.close = async () => {
if (cancelDuringClose) controller.abort(reason);
if (descriptorCloseFails) throw new Error("descriptor remains owned");
await close();
};
return Reflect.apply(original, this, args);
});
onTestFinished(async () => {
stat.mockRestore();
descriptorCloseFails = false;
mayClean = true;
expect((await client.close()).ok).toBe(true);
});
const result = await client.execute(
operation,
{},
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected command failure");
expect(result.error._tag).toBe("ProviderAdapterError");
const primary = primaryAnalysisError(result.error);
if (changeTarget) {
expect(primary._tag).toBe("AnalysisArtifactChangedError");
expect(primary.diagnostics).toMatchObject({
previous_error: {
details: {
captured_output: {
stdout: "partial native output",
stderr: "read failed",
},
},
},
});
} else {
expect(primary.cause).toBeInstanceOf(NativeCommandFailure);
if (!(primary.cause instanceof NativeCommandFailure))
throw new Error("Expected the runner's physical command failure");
expect(primary.cause.cause).toBe(failure);
expect(primary.capturedOutput).toMatchObject({
stdout: "partial native output",
stderr: "read failed",
});
}
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources.toSorted()).toEqual(
[...(failDescriptorClose ? [path] : []), "native-command-process"].sort(),
);
expect(primary.secondaryFailures).toEqual(
cancelDuringClose
? [
expect.objectContaining({
_tag: "AnalysisCancelledError",
cause: reason,
}),
]
: [],
);
descriptorCloseFails = false;
const blocked = await client.close();
expect(blocked.ok).toBe(false);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual(["native-command-process"]);
expect(cleanupAttempts).toBe(1);
mayClean = true;
expect((await client.close()).ok).toBe(true);
expect(cleanupAttempts).toBe(2);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
},
);