Files
reverse-engineering-anything/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts
T
morluto d0253c99d6 fix(setup): preserve the selected destination during configuration publication
Replace configuration-specific write-file-atomic delegation with a verified
input snapshot, exclusive private staging, and an explicit canonical-parent
destination. Existing files publish by rename; first-run files use an exclusive
link so a newly created destination is retained. Both consumers read back the
publication owner's selected path.

Retain descriptor and staging cleanup capabilities through recovery and host
retry. Preserve mode and ownership, remove temporary permission handoffs, and
use positional restoration writes. Keep UTF-8 diagnostics with failed cleanup.

Validation: 284 focused tests passed, one skipped across 14 files; check:fast,
format:check, docs:check (including five consistency tests), two source reviews,
and 12 completed isolated public CLI setup/uninstall cost controls passed.
Native Windows/Linux, real editor concurrency, and providers were not verified.
Final-check/publication races and same-inode concurrent writers remain unexcluded.

Fixes #4904
2026-10-18 07:06:15 +08:00

335 lines
12 KiB
TypeScript

import { mkdir, open as fsOpen, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DoctorHost } from "../../../src/application/Doctor.js";
import { ConfigurationError } from "../../../src/domain/configurationErrors.js";
import {
filterClientsNeedingConfigure,
systemSetupHost,
} from "../../../src/application/SetupHost.js";
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
import { configureDetectedClients } from "../../../src/application/SetupClients.js";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, open: vi.fn(actual.open) };
});
const openMock = vi.mocked(fsOpen);
let actualFs: typeof import("node:fs/promises");
beforeEach(async () => {
actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
openMock.mockImplementation((...args) => actualFs.open(...args));
});
afterEach(() => openMock.mockReset());
const holdReadHandle = (path: string, reason: string) => {
const closeAttempts: object[] = [];
let released = false;
openMock.mockImplementation(async (...args) => {
const handle = await actualFs.open(...args);
if (String(args[0]) !== path) return handle;
const nativeClose = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closeAttempts.push(handle);
if (!released) throw new Error(reason);
await nativeClose();
});
return handle;
});
return {
closeAttempts,
release: () => {
released = true;
},
};
};
/** Linux setup host with its VS Code client configuration directory prepared. */
const preparedVsCodeSetupHost = async (prefix: string) => {
const home = await createTestTempDirectory(prefix);
const host = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
{ HOME: home, XDG_CONFIG_HOME: join(home, "xdg") },
);
const client = (await host.supportedClients()).find(
({ name }) => name === "vscode",
);
if (client === undefined) throw new Error("VS Code setup client missing");
await mkdir(dirname(client.configPath), { recursive: true });
return { home, host, client };
};
/** Release the held read handle and verify host cleanup retries that same owner. */
const expectCloseReclaimsHandle = async (
host: { close?: () => Promise<string | undefined> },
closeAttempts: readonly object[],
release: () => void,
) => {
const attemptsBeforeClose = closeAttempts.length;
release();
expect(await host.close?.()).toBeUndefined();
expect(closeAttempts).toHaveLength(attemptsBeforeClose + 1);
expect(closeAttempts.at(-1)).toBe(closeAttempts[0]);
};
/** Give up on the assertion path: release the handle and let cleanup close it. */
const cleanupHeldHandle = async (
host: { close?: () => Promise<string | undefined> },
release: () => void,
) => {
release();
await host.close?.();
};
it("retains invalid UTF-8 diagnostics and the uninstall read owner when close fails", async () => {
const home = await createTestTempDirectory("rea-uninstall-utf8-close-");
const path = join(home, "client.json");
const original = Buffer.from([0xff]);
await writeFile(path, original);
const { closeAttempts, release } = holdReadHandle(
path,
"held uninstall handle",
);
const host = systemUninstallHost(home, undefined, { HOME: home }, "linux");
try {
expect(
await host.removeClient({
name: "cursor",
format: "json",
configPath: path,
}),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("not valid UTF-8"),
});
expect(await readFile(path)).toEqual(original);
expect(closeAttempts).toHaveLength(3);
expect(closeAttempts.every((handle) => handle === closeAttempts[0])).toBe(
true,
);
await expectCloseReclaimsHandle(host, closeAttempts, release);
} finally {
await cleanupHeldHandle(host, release);
}
});
it("stops uninstall and retains the backup read owner when its close fails", async () => {
const home = await createTestTempDirectory("rea-uninstall-backup-close-");
const path = join(home, "client.json");
const client = { name: "cursor", format: "json", configPath: path } as const;
await writeFile(path, '{"keep":"before setup"}\n');
expect(
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "configured" });
const configured = await readFile(path, "utf8");
const { closeAttempts, release } = holdReadHandle(
`${path}.rea.backup`,
"held backup handle",
);
const host = systemUninstallHost(home, undefined, { HOME: home }, "linux");
try {
expect(await host.removeClient(client)).toEqual({
name: "cursor",
status: "failed",
detail:
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
});
expect(await readFile(path, "utf8")).toBe(configured);
expect(closeAttempts).toHaveLength(2);
await expectCloseReclaimsHandle(host, closeAttempts, release);
} finally {
await cleanupHeldHandle(host, release);
}
});
describe("ZCode fallback cleanup ownership", () => {
it.each(["preflight", "configure"])(
"retains a failed ZCode fallback close during %s until host cleanup",
async (operation) => {
const home = await createTestTempDirectory("rea-zcode-fallback-close-");
const host = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
{ HOME: home },
);
const client = (await host.supportedClients()).find(
({ name }) => name === "zcode",
);
if (client?.zcodeFallbackPath === undefined)
throw new Error("ZCode fallback path missing");
const fallback = client.zcodeFallbackPath;
await mkdir(dirname(fallback), { recursive: true });
const original = '{"mcpServers":{"other":{"command":"other"}}}\n';
await writeFile(fallback, original);
const { closeAttempts, release } = holdReadHandle(
fallback,
"held fallback handle",
);
try {
const action =
operation === "preflight"
? host.inspectClientConfiguration?.(client, {}, ["rea", "mcp"])
: host.configureClient(client, {}, ["rea", "mcp"]);
if (action === undefined) throw new Error("setup preflight missing");
await expect(action).rejects.toMatchObject({
_tag: "ConfigurationError",
cause: expect.any(RegularFileCleanupFailure),
cleanup: { resources: [fallback] },
});
expect(closeAttempts).toHaveLength(2);
expect(closeAttempts[0]).toBe(closeAttempts[1]);
expect(await readFile(fallback, "utf8")).toBe(original);
await expect(readFile(client.configPath)).rejects.toMatchObject({
code: "ENOENT",
});
await expectCloseReclaimsHandle(host, closeAttempts, release);
} finally {
await cleanupHeldHandle(host, release);
}
},
);
});
describe("setup client configuration cleanup ownership", () => {
it.each([
["valid UTF-8", Buffer.from('{"servers": {"other": {}}}\n')],
["malformed UTF-8", Buffer.from([0xff])],
] as const)(
"stops configuration and retains the %s read owner until host close",
async (encoding, original) => {
const { host, client } = await preparedVsCodeSetupHost(
"rea-setup-config-close-",
);
await writeFile(client.configPath, original);
const { closeAttempts, release } = holdReadHandle(
client.configPath,
"test holds the config handle open",
);
const configure = vi.spyOn(host, "configureClient");
try {
const failure = await filterClientsNeedingConfigure(
host,
[client],
{},
["rea", "mcp"],
)
.then((detectedClients) =>
configureDetectedClients({
host,
detectedClients,
providerEnvironment: {},
command: ["rea", "mcp"],
clients: {},
appliedActions: [],
}),
)
.then(
() => undefined,
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(ConfigurationError);
if (encoding === "malformed UTF-8")
expect(failure).toMatchObject({
cause: {
outcome: { kind: "failed", cause: expect.any(TypeError) },
},
});
expect(failure).toMatchObject({
_tag: "ConfigurationError",
cause: expect.any(RegularFileCleanupFailure),
cleanup: {
resources: [client.configPath],
reason: expect.stringContaining(
"test holds the config handle open",
),
},
});
expect(configure).not.toHaveBeenCalled();
expect(closeAttempts).toHaveLength(2);
expect(closeAttempts[0]).toBe(closeAttempts[1]);
expect(await readFile(client.configPath)).toEqual(original);
await expectCloseReclaimsHandle(host, closeAttempts, release);
} finally {
await cleanupHeldHandle(host, release);
}
},
);
it("waits for an admitted configuration read and rejects later admissions", async () => {
const { host, client } = await preparedVsCodeSetupHost(
"rea-setup-config-inflight-",
);
await writeFile(client.configPath, '{"servers": {}}\n');
let beginRead!: () => void;
const readStarted = new Promise<void>((resolve) => {
beginRead = resolve;
});
let unblockRead!: () => void;
const readGate = new Promise<void>((resolve) => {
unblockRead = resolve;
});
let heldRead = false;
openMock.mockImplementation(async (...args) => {
const handle = await actualFs.open(...args);
if (String(args[0]) !== client.configPath || heldRead) return handle;
heldRead = true;
const nativeRead = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => {
beginRead();
await readGate;
return nativeRead(...readArgs);
});
return handle;
});
let hostCloseFinished = false;
let alignment: Promise<boolean> | undefined;
try {
alignment = host.clientNeedsConfigure(client, {}, ["rea", "mcp"]);
await readStarted;
const close = host.close;
if (close === undefined) throw new Error("Setup host close is missing");
const closeResult = close().then((result) => {
hostCloseFinished = true;
return result;
});
const lateAdmission = await host
.clientNeedsConfigure(client, {}, ["rea", "mcp"])
.then(
() => undefined,
(cause: unknown) => cause,
);
expect(lateAdmission).toBeInstanceOf(ConfigurationError);
expect(lateAdmission).toMatchObject({
message: expect.stringContaining("Artifact resource scope is closed"),
});
expect(hostCloseFinished).toBe(false);
unblockRead();
await expect(alignment).resolves.toBe(true);
expect(await closeResult).toBeUndefined();
expect(hostCloseFinished).toBe(true);
} finally {
unblockRead();
await alignment?.catch(() => undefined);
await host.close?.();
}
});
});