mirror of
https://github.com/morluto/rea.git
synced 2026-10-20 21:10:17 -05:00
fix(setup): bind recovery to the published configuration file
Retain the staged file descriptor through atomic publication, readback and recovery. Refuse detected replacements or edits and restore through that owned descriptor instead of reopening a pathname. First-run readback failure now retains the file for manual recovery because pathname deletion cannot prove exact ownership. Keep both descriptor owners when nested closes fail; opaque close rejections do not transfer ownership. Validation: 192 focused checks in 9 files, check:fast, formatting and docs:check passed. Three replacement regressions fail on the baseline while six existing recovery controls pass. Six completed setup tasks preserve equivalent config and backups. Two read-only source reviews completed on macOS arm64 / Node24. Native Windows/Linux and real concurrent editor behavior remain unverified. State checks do not exclude an in-place writer after the last check. Closes #4900
This commit is contained in:
1 parent
19329e43bb
commit
7e4eed9dd4
5 files changed
+463
-85
No files matched your search
@@ -65,6 +65,11 @@ before editing; setup also previews it. Both snapshots remain available.
|
||||
When an edit made a backup, a failed uninstall update or restore, and a failed
|
||||
setup readback or rollback, name the backup path that holds the bytes from just
|
||||
before that edit.
|
||||
If setup cannot verify a newly created configuration, it retains the file and
|
||||
reports manual recovery instead of deleting a pathname that may have changed.
|
||||
For an existing configuration, rollback refuses a detected replacement or edit
|
||||
and names its recovery backup. Quit clients before editing their configuration;
|
||||
these checks do not lock out another writer modifying the same file in place.
|
||||
Setup also retains valid environment overrides in REA's existing server entry,
|
||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE`, `REA_MCP_OUTPUT_SCHEMA_PROFILE`, and
|
||||
`REA_MCP_MAX_RESPONSE_BYTES`.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { withOwnedFileHandle } from "../filesystem/OwnedRegularFile.js";
|
||||
import { sameRegularFileState } from "../filesystem/RegularFile.js";
|
||||
|
||||
/** The exact staged file retained through publication, verification and recovery. */
|
||||
export class ClientConfigurationPublication {
|
||||
private constructor(
|
||||
readonly path: string,
|
||||
private readonly contents: string,
|
||||
private readonly owner: OwnedFileHandle,
|
||||
) {}
|
||||
|
||||
static async write(
|
||||
path: string,
|
||||
contents: string,
|
||||
): Promise<ClientConfigurationPublication> {
|
||||
let owner: OwnedFileHandle | undefined;
|
||||
try {
|
||||
await writeFileAtomic(path, contents, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
tmpfileCreated: async (stagedPath) => {
|
||||
owner = new OwnedFileHandle(
|
||||
await open(
|
||||
stagedPath,
|
||||
constants.O_RDWR |
|
||||
(constants.O_NONBLOCK ?? 0) |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
),
|
||||
);
|
||||
if (!(await owner.handle.stat()).isFile())
|
||||
throw new Error("Staged configuration is not a regular file");
|
||||
},
|
||||
});
|
||||
if (owner === undefined)
|
||||
throw new Error("Atomic configuration publication has no file owner");
|
||||
return new ClientConfigurationPublication(path, contents, owner);
|
||||
} catch (cause: unknown) {
|
||||
if (owner === undefined) throw cause;
|
||||
return withOwnedFileHandle(path, owner, {}, async () => {
|
||||
throw cause;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the publication descriptor owned until the complete transaction ends. */
|
||||
verify<Value>(operation: () => Promise<Value>): Promise<Value> {
|
||||
return withOwnedFileHandle(this.path, this.owner, {}, operation);
|
||||
}
|
||||
|
||||
/** Readback cannot establish a successful transaction through a replaced name. */
|
||||
async assertCurrent(): Promise<void> {
|
||||
const owned = await this.owner.handle.stat();
|
||||
const named = await lstat(this.path);
|
||||
if (
|
||||
owned.ino === 0 ||
|
||||
named.isSymbolicLink() ||
|
||||
!sameRegularFileState(owned, named)
|
||||
)
|
||||
throw new Error(
|
||||
"Published configuration path no longer names its owned file",
|
||||
);
|
||||
}
|
||||
|
||||
/** Require observed published bytes and restore through the owned descriptor.
|
||||
* State checks do not exclude a concurrent writer to the same inode.
|
||||
*/
|
||||
async restore(original: string | undefined): Promise<void> {
|
||||
if (original === undefined)
|
||||
throw new Error(
|
||||
"First-run configuration recovery requires manual removal; pathname deletion cannot prove ownership",
|
||||
);
|
||||
const handle = this.owner.handle;
|
||||
const before = await handle.stat();
|
||||
const named = await lstat(this.path);
|
||||
if (
|
||||
before.ino === 0 ||
|
||||
!before.isFile() ||
|
||||
named.isSymbolicLink() ||
|
||||
!sameRegularFileState(before, named) ||
|
||||
!(await this.#hasPublishedBytes(handle)) ||
|
||||
!sameRegularFileState(before, await handle.stat())
|
||||
)
|
||||
throw new Error(
|
||||
"Published configuration changed; recovery requires the retained backup",
|
||||
);
|
||||
// Recovery writes through the retained descriptor, never through the name.
|
||||
if (!sameRegularFileState(before, await lstat(this.path)))
|
||||
throw new Error("Published configuration path changed before recovery");
|
||||
await writeFile(handle, original, { encoding: "utf8" });
|
||||
await handle.truncate(Buffer.byteLength(original, "utf8"));
|
||||
if (!sameRegularFileState(await handle.stat(), await lstat(this.path)))
|
||||
throw new Error("Configuration path changed during recovery");
|
||||
}
|
||||
|
||||
async #hasPublishedBytes(handle: FileHandle): Promise<boolean> {
|
||||
const expected = Buffer.from(this.contents, "utf8");
|
||||
if ((await handle.stat()).size !== expected.length) return false;
|
||||
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, expected.length));
|
||||
for (let position = 0; position < expected.length;) {
|
||||
const { bytesRead } = await handle.read(
|
||||
chunk,
|
||||
0,
|
||||
Math.min(chunk.length, expected.length - position),
|
||||
position,
|
||||
);
|
||||
if (
|
||||
bytesRead === 0 ||
|
||||
!chunk
|
||||
.subarray(0, bytesRead)
|
||||
.equals(expected.subarray(position, position + bytesRead))
|
||||
)
|
||||
return false;
|
||||
position += bytesRead;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,9 @@ import {
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js";
|
||||
import {
|
||||
clientRegistrationEnvironment,
|
||||
clientRegistrationEnvironmentDetails,
|
||||
@@ -291,9 +291,10 @@ const persistClientConfiguration = async ({
|
||||
),
|
||||
}
|
||||
: registered;
|
||||
let publication: ClientConfigurationPublication;
|
||||
try {
|
||||
await mkdir(dirname(client.configPath), { recursive: true });
|
||||
await writeFileAtomic(
|
||||
publication = await ClientConfigurationPublication.write(
|
||||
transactionPath,
|
||||
serializeClientConfiguration(document, format, original, [
|
||||
...Object.keys(fallbackServers)
|
||||
@@ -303,63 +304,63 @@ const persistClientConfiguration = async ({
|
||||
...(legacyPath === undefined ? [] : [legacyPath]),
|
||||
...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []),
|
||||
]),
|
||||
{
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
// Write failure is reported by the status reason.
|
||||
void cause;
|
||||
return { status: "failed", reason: "write" };
|
||||
}
|
||||
try {
|
||||
const readback = parseClientConfiguration(
|
||||
await readRegularFileText(transactionPath),
|
||||
format,
|
||||
);
|
||||
if (
|
||||
!registrationCurrent(readback, desired) ||
|
||||
Object.entries(fallbackServers).some(
|
||||
([name, entry]) =>
|
||||
name !== PRODUCT_IDENTITY.mcpServerKey &&
|
||||
!clientConfigurationValuesEqual(readback.servers[name], entry),
|
||||
)
|
||||
) {
|
||||
return publication.verify(async () => {
|
||||
try {
|
||||
const readback = parseClientConfiguration(
|
||||
await readRegularFileText(transactionPath),
|
||||
format,
|
||||
);
|
||||
await publication.assertCurrent();
|
||||
if (
|
||||
!registrationCurrent(readback, desired) ||
|
||||
Object.entries(fallbackServers).some(
|
||||
([name, entry]) =>
|
||||
name !== PRODUCT_IDENTITY.mcpServerKey &&
|
||||
!clientConfigurationValuesEqual(readback.servers[name], entry),
|
||||
)
|
||||
) {
|
||||
return rollbackOrReadbackFailure(
|
||||
publication,
|
||||
original,
|
||||
recoveryBackupPath,
|
||||
);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) {
|
||||
const rollback = await restoreConfig(publication, original);
|
||||
if (!rollback.ok)
|
||||
throw new ClientConfigurationCleanupRollbackFailure(cause, {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
...(recoveryBackupPath === undefined
|
||||
? {}
|
||||
: { backupPath: recoveryBackupPath }),
|
||||
failure: rollback.error,
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
// Readback failure restores the transaction before reporting.
|
||||
void cause;
|
||||
return rollbackOrReadbackFailure(
|
||||
transactionPath,
|
||||
publication,
|
||||
original,
|
||||
recoveryBackupPath,
|
||||
);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) {
|
||||
const rollback = await restoreConfig(transactionPath, original);
|
||||
if (!rollback.ok)
|
||||
throw new ClientConfigurationCleanupRollbackFailure(cause, {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
...(recoveryBackupPath === undefined
|
||||
? {}
|
||||
: { backupPath: recoveryBackupPath }),
|
||||
failure: rollback.error,
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
// Readback failure restores the transaction before reporting.
|
||||
void cause;
|
||||
return rollbackOrReadbackFailure(
|
||||
transactionPath,
|
||||
original,
|
||||
recoveryBackupPath,
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: "configured",
|
||||
...(recoveryBackupPath === undefined
|
||||
? {}
|
||||
: { backupPath: recoveryBackupPath }),
|
||||
};
|
||||
return {
|
||||
status: "configured",
|
||||
...(recoveryBackupPath === undefined
|
||||
? {}
|
||||
: { backupPath: recoveryBackupPath }),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** Determine whether an existing client configuration matches the desired registration. */
|
||||
@@ -630,7 +631,7 @@ const readZcodeFallbackServers = async (
|
||||
};
|
||||
|
||||
const restoreConfig = async (
|
||||
path: string,
|
||||
publication: ClientConfigurationPublication,
|
||||
original: string | undefined,
|
||||
): Promise<
|
||||
Result<
|
||||
@@ -639,20 +640,23 @@ const restoreConfig = async (
|
||||
>
|
||||
> => {
|
||||
try {
|
||||
if (original === undefined) await rm(path, { force: true });
|
||||
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
|
||||
await publication.restore(original);
|
||||
return ok(undefined);
|
||||
} catch (cause: unknown) {
|
||||
return err({ kind: "restore", path, detail: errorDetail(cause) });
|
||||
return err({
|
||||
kind: "restore",
|
||||
path: publication.path,
|
||||
detail: errorDetail(cause),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackOrReadbackFailure = async (
|
||||
targetPath: string,
|
||||
publication: ClientConfigurationPublication,
|
||||
original: string | undefined,
|
||||
backupPath: string | undefined,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
const rollback = await restoreConfig(targetPath, original);
|
||||
const rollback = await restoreConfig(publication, original);
|
||||
return rollback.ok
|
||||
? {
|
||||
status: "failed",
|
||||
|
||||
@@ -404,19 +404,42 @@ const runClientConfigurationOperation = async <Value>(
|
||||
return await operation();
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
|
||||
const cleanup = await retryRegularFileCleanup(cause, resources);
|
||||
const primary =
|
||||
cleanup.outcome.kind === "failed"
|
||||
? cleanup.outcome.cause
|
||||
: cause.cleanupCause;
|
||||
const rollbackMessage =
|
||||
cause instanceof ClientConfigurationCleanupRollbackFailure
|
||||
? ` ${failedConfigurationMessage(cause.configurationFailure)}`
|
||||
: "";
|
||||
let retained = cause;
|
||||
let primary: unknown;
|
||||
const observations: {
|
||||
readonly reason: string;
|
||||
readonly resources: readonly string[];
|
||||
}[] = [];
|
||||
let rollbackMessage = "";
|
||||
// Nested descriptor operations can each fail close. Only walk explicit
|
||||
// owned-operation outcomes; an arbitrary cause is not an ownership grant.
|
||||
for (;;) {
|
||||
const cleanup = await retryRegularFileCleanup(retained, resources);
|
||||
if (cleanup.cleanup !== undefined) observations.push(cleanup.cleanup);
|
||||
if (retained instanceof ClientConfigurationCleanupRollbackFailure)
|
||||
rollbackMessage = ` ${failedConfigurationMessage(retained.configurationFailure)}`;
|
||||
primary =
|
||||
cleanup.outcome.kind === "failed"
|
||||
? cleanup.outcome.cause
|
||||
: retained.cleanupCause;
|
||||
if (
|
||||
cleanup.outcome.kind !== "failed" ||
|
||||
!(primary instanceof RegularFileCleanupFailure)
|
||||
)
|
||||
break;
|
||||
retained = primary;
|
||||
}
|
||||
const cleanup =
|
||||
observations.length === 0
|
||||
? undefined
|
||||
: {
|
||||
reason: observations.map(({ reason }) => reason).join("; "),
|
||||
resources: observations.flatMap(({ resources }) => resources),
|
||||
};
|
||||
throw configurationCleanupError(
|
||||
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}.${rollbackMessage}`,
|
||||
cause,
|
||||
cleanup.cleanup,
|
||||
cleanup,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { open, readFile } from "node:fs/promises";
|
||||
import { open, readFile, type FileHandle } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -6,8 +6,12 @@ import { afterEach, expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
readbackFailed: false,
|
||||
failReadback: true,
|
||||
rejectPublicationClose: false,
|
||||
publicationCloseFailure: undefined as unknown,
|
||||
publicationHandles: [] as FileHandle[],
|
||||
afterPublication: undefined as ((path: string) => Promise<void>) | undefined,
|
||||
rejectRestore: false,
|
||||
rejectRemove: false,
|
||||
removeCopiedRecovery: false,
|
||||
initialCleanupFailure: undefined as unknown,
|
||||
readbackCleanupFailure: undefined as unknown,
|
||||
@@ -15,8 +19,12 @@ const state = vi.hoisted(() => ({
|
||||
|
||||
afterEach(() => {
|
||||
state.readbackFailed = false;
|
||||
state.failReadback = true;
|
||||
state.rejectPublicationClose = false;
|
||||
state.publicationCloseFailure = undefined;
|
||||
state.publicationHandles = [];
|
||||
state.afterPublication = undefined;
|
||||
state.rejectRestore = false;
|
||||
state.rejectRemove = false;
|
||||
state.removeCopiedRecovery = false;
|
||||
state.initialCleanupFailure = undefined;
|
||||
state.readbackCleanupFailure = undefined;
|
||||
@@ -26,12 +34,21 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...original,
|
||||
rm: (
|
||||
...arguments_: Parameters<typeof original.rm>
|
||||
): ReturnType<typeof original.rm> => {
|
||||
if (state.rejectRemove && state.readbackFailed)
|
||||
return Promise.reject(new Error("injected restore remove failure"));
|
||||
return original.rm(...arguments_);
|
||||
open: async (...arguments_: Parameters<typeof original.open>) => {
|
||||
const handle = await original.open(...arguments_);
|
||||
if (typeof arguments_[1] === "number" && (arguments_[1] & 3) === 2) {
|
||||
state.publicationHandles.push(handle);
|
||||
const close = handle.close.bind(handle);
|
||||
handle.close = async () => {
|
||||
if (state.rejectPublicationClose)
|
||||
throw (
|
||||
state.publicationCloseFailure ??
|
||||
new Error("injected publication close failure")
|
||||
);
|
||||
await close();
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
},
|
||||
writeFile: (
|
||||
...arguments_: Parameters<typeof original.writeFile>
|
||||
@@ -66,6 +83,12 @@ vi.mock("write-file-atomic", async (importOriginal) => {
|
||||
throw new Error("unexpected config write before initial read");
|
||||
const result = await original.default(...arguments_);
|
||||
state.readbackFailed = true;
|
||||
const publishedPath = arguments_[0];
|
||||
if (
|
||||
state.afterPublication !== undefined &&
|
||||
typeof publishedPath === "string"
|
||||
)
|
||||
await state.afterPublication(publishedPath);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
@@ -85,7 +108,7 @@ vi.mock("../../src/application/RegularFileRead.js", async (importOriginal) => {
|
||||
? Promise.reject(state.initialCleanupFailure)
|
||||
: state.readbackFailed && state.readbackCleanupFailure !== undefined
|
||||
? Promise.reject(state.readbackCleanupFailure)
|
||||
: state.readbackFailed
|
||||
: state.readbackFailed && state.failReadback
|
||||
? Promise.reject(new Error("injected readback failure"))
|
||||
: original.readRegularFileText(...arguments_),
|
||||
};
|
||||
@@ -108,7 +131,6 @@ const ownReadHandle = async (path: string) => {
|
||||
it("reports a failed config rollback and retains its backup path", async () => {
|
||||
state.readbackFailed = false;
|
||||
state.rejectRestore = false;
|
||||
state.rejectRemove = false;
|
||||
state.initialCleanupFailure = undefined;
|
||||
state.readbackCleanupFailure = undefined;
|
||||
const root = await createTestTempDirectory("rea-config-rollback-");
|
||||
@@ -144,7 +166,6 @@ it("reports a failed config rollback and retains its backup path", async () => {
|
||||
it("preserves the first backup and reports a verified backup of the current original", async () => {
|
||||
state.readbackFailed = false;
|
||||
state.rejectRestore = false;
|
||||
state.rejectRemove = false;
|
||||
state.initialCleanupFailure = undefined;
|
||||
state.readbackCleanupFailure = undefined;
|
||||
const root = await createTestTempDirectory("rea-config-stale-backup-");
|
||||
@@ -184,7 +205,6 @@ it("preserves the first backup and reports a verified backup of the current orig
|
||||
it("does not write config when a recovery copy disappears before verification", async () => {
|
||||
state.readbackFailed = false;
|
||||
state.rejectRestore = false;
|
||||
state.rejectRemove = false;
|
||||
state.removeCopiedRecovery = false;
|
||||
state.initialCleanupFailure = undefined;
|
||||
state.readbackCleanupFailure = undefined;
|
||||
@@ -213,7 +233,6 @@ it("does not write config when a recovery copy disappears before verification",
|
||||
it("does not roll back or remove config when its initial read cleanup fails", async () => {
|
||||
state.readbackFailed = false;
|
||||
state.rejectRestore = false;
|
||||
state.rejectRemove = false;
|
||||
state.readbackCleanupFailure = undefined;
|
||||
const root = await createTestTempDirectory("rea-config-initial-cleanup-");
|
||||
const path = join(root, "client.json");
|
||||
@@ -239,22 +258,19 @@ it("does not roll back or remove config when its initial read cleanup fails", as
|
||||
expect(await readFile(path, "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
it("reports first-run rollback removal failure without a backup", async () => {
|
||||
it("retains first-run configuration for manual recovery without deleting by pathname", async () => {
|
||||
state.readbackFailed = false;
|
||||
state.rejectRestore = false;
|
||||
state.rejectRemove = false;
|
||||
state.initialCleanupFailure = undefined;
|
||||
state.readbackCleanupFailure = undefined;
|
||||
const root = await createTestTempDirectory("rea-config-first-run-rollback-");
|
||||
const path = join(root, "client.json");
|
||||
state.rejectRemove = true;
|
||||
|
||||
const result = await configureClientConfiguration(
|
||||
{ name: "cursor", configPath: path, format: "json" },
|
||||
{},
|
||||
["rea", "mcp"],
|
||||
);
|
||||
state.rejectRemove = false;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "failed",
|
||||
@@ -262,7 +278,8 @@ it("reports first-run rollback removal failure without a backup", async () => {
|
||||
failure: {
|
||||
kind: "restore",
|
||||
path,
|
||||
detail: "injected restore remove failure",
|
||||
detail:
|
||||
"First-run configuration recovery requires manual removal; pathname deletion cannot prove ownership",
|
||||
},
|
||||
});
|
||||
expect(result).not.toHaveProperty("backupPath");
|
||||
@@ -274,7 +291,6 @@ it("reports first-run rollback removal failure without a backup", async () => {
|
||||
it("retains readback cleanup ownership when rollback also fails", async () => {
|
||||
state.readbackFailed = false;
|
||||
state.rejectRestore = false;
|
||||
state.rejectRemove = false;
|
||||
state.initialCleanupFailure = undefined;
|
||||
const root = await createTestTempDirectory("rea-config-readback-cleanup-");
|
||||
const path = join(root, "client.json");
|
||||
@@ -323,3 +339,211 @@ it("retains readback cleanup ownership when rollback also fails", async () => {
|
||||
expect(cleanupOwnerError.owner).toBe(owner);
|
||||
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
it.each(["existing", "first-run"] as const)(
|
||||
"retains a replacement symlink and its target when %s readback fails",
|
||||
async (kind) => {
|
||||
const root = await createTestTempDirectory("rea-config-replaced-rollback-");
|
||||
const path = join(root, "client.json");
|
||||
const replacement = join(root, "replacement.json");
|
||||
const original = '{"keep":"original"}\n';
|
||||
const foreign = '{"keep":"replacement"}\n';
|
||||
const fs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
await fs.writeFile(replacement, foreign);
|
||||
if (kind === "existing") await fs.writeFile(path, original);
|
||||
state.afterPublication = async (publishedPath) => {
|
||||
await fs.unlink(publishedPath);
|
||||
await fs.symlink(replacement, publishedPath);
|
||||
};
|
||||
|
||||
const result = await configureClientConfiguration(
|
||||
{ name: "cursor", configPath: path, format: "json" },
|
||||
{},
|
||||
["rea", "mcp"],
|
||||
);
|
||||
|
||||
expect(await fs.readFile(replacement, "utf8")).toBe(foreign);
|
||||
expect((await fs.lstat(path)).isSymbolicLink()).toBe(true);
|
||||
expect(result).toMatchObject({ status: "failed", reason: "rollback" });
|
||||
if (kind === "existing")
|
||||
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains a replacement regular file when readback fails", async () => {
|
||||
const root = await createTestTempDirectory("rea-config-replaced-file-");
|
||||
const path = join(root, "client.json");
|
||||
const replacement = join(root, "replacement.json");
|
||||
const original = '{"keep":"original"}\n';
|
||||
const foreign = '{"keep":"replacement"}\n';
|
||||
const fs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
await fs.writeFile(path, original);
|
||||
await fs.writeFile(replacement, foreign);
|
||||
state.afterPublication = (publishedPath) =>
|
||||
fs.rename(replacement, publishedPath);
|
||||
|
||||
const result = await configureClientConfiguration(
|
||||
{ name: "cursor", configPath: path, format: "json" },
|
||||
{},
|
||||
["rea", "mcp"],
|
||||
);
|
||||
|
||||
expect(await fs.readFile(path, "utf8")).toBe(foreign);
|
||||
expect(result).toMatchObject({ status: "failed", reason: "rollback" });
|
||||
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
it("retains and retries both readback and publication descriptors after mixed close failures", async () => {
|
||||
const root = await createTestTempDirectory("rea-config-two-close-owners-");
|
||||
const path = join(root, "client.json");
|
||||
const fs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
await fs.writeFile(path, '{"keep":true}\n');
|
||||
const readOwner = await ownReadHandle(path);
|
||||
const closeRead = readOwner.handle.close.bind(readOwner.handle);
|
||||
let rejectReadClose = true;
|
||||
readOwner.handle.close = async () => {
|
||||
if (rejectReadClose) throw new Error("injected readback close failure");
|
||||
await closeRead();
|
||||
};
|
||||
state.readbackCleanupFailure = new RegularFileCleanupFailure(
|
||||
path,
|
||||
readOwner,
|
||||
{ kind: "completed", value: "" },
|
||||
new Error("injected readback close failure"),
|
||||
);
|
||||
state.rejectPublicationClose = true;
|
||||
const host = systemSetupHost(
|
||||
{ platform: "linux", homeDirectory: root } as DoctorHost,
|
||||
{ HOME: root },
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
host.configureClient(
|
||||
{ name: "cursor", configPath: path, format: "json" },
|
||||
{},
|
||||
["rea", "mcp"],
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConfigurationError);
|
||||
expect(state.publicationHandles).toHaveLength(1);
|
||||
expect(readOwner.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
expect(state.publicationHandles[0]!.fd).toBeGreaterThanOrEqual(0);
|
||||
rejectReadClose = false;
|
||||
state.rejectPublicationClose = false;
|
||||
expect(await host.close?.()).toBeUndefined();
|
||||
expect(readOwner.handle.fd).toBe(-1);
|
||||
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
||||
} finally {
|
||||
rejectReadClose = false;
|
||||
state.rejectPublicationClose = false;
|
||||
await host.close?.();
|
||||
await readOwner.close();
|
||||
for (const handle of state.publicationHandles)
|
||||
if (handle.fd >= 0) await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a replaced publication even when the replacement passes registration readback", async () => {
|
||||
const root = await createTestTempDirectory("rea-config-valid-replacement-");
|
||||
const path = join(root, "client.json");
|
||||
const replacement = join(root, "replacement.json");
|
||||
const fs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
const original = '{"keep":true}\n';
|
||||
await fs.writeFile(path, original);
|
||||
let foreign = "";
|
||||
state.failReadback = false;
|
||||
state.afterPublication = async (publishedPath) => {
|
||||
foreign = await fs.readFile(publishedPath, "utf8");
|
||||
await fs.writeFile(replacement, foreign);
|
||||
await fs.rename(replacement, publishedPath);
|
||||
};
|
||||
expect(
|
||||
await configureClientConfiguration(
|
||||
{ name: "cursor", configPath: path, format: "json" },
|
||||
{},
|
||||
["rea", "mcp"],
|
||||
),
|
||||
).toMatchObject({ status: "failed", reason: "rollback" });
|
||||
expect(await fs.readFile(path, "utf8")).toBe(foreign);
|
||||
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
it("retains concurrent edits to the published inode rather than overwriting them", async () => {
|
||||
const root = await createTestTempDirectory("rea-config-inode-edit-");
|
||||
const path = join(root, "client.json");
|
||||
const fs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
const original = '{"keep":true}\n';
|
||||
const edited = '{"keep":"concurrent edit"}\n';
|
||||
await fs.writeFile(path, original);
|
||||
state.afterPublication = (publishedPath) =>
|
||||
fs.writeFile(publishedPath, edited);
|
||||
expect(
|
||||
await configureClientConfiguration(
|
||||
{ name: "cursor", configPath: path, format: "json" },
|
||||
{},
|
||||
["rea", "mcp"],
|
||||
),
|
||||
).toMatchObject({ status: "failed", reason: "rollback" });
|
||||
expect(await fs.readFile(path, "utf8")).toBe(edited);
|
||||
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
it("does not adopt a descriptor named by an opaque publication close rejection", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-config-close-cause-ownership-",
|
||||
);
|
||||
const path = join(root, "client.json");
|
||||
const unrelated = join(root, "unrelated.json");
|
||||
const fs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
await fs.writeFile(path, '{"keep":true}\n');
|
||||
await fs.writeFile(unrelated, "unrelated");
|
||||
const foreignOwner = await ownReadHandle(unrelated);
|
||||
state.failReadback = false;
|
||||
state.rejectPublicationClose = true;
|
||||
state.publicationCloseFailure = new RegularFileCleanupFailure(
|
||||
unrelated,
|
||||
foreignOwner,
|
||||
{ kind: "completed", value: "unrelated" },
|
||||
new Error("opaque close rejection"),
|
||||
);
|
||||
const host = systemSetupHost(
|
||||
{ platform: "linux", homeDirectory: root } as DoctorHost,
|
||||
{ HOME: root },
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
host.configureClient(
|
||||
{ name: "cursor", configPath: path, format: "json" },
|
||||
{},
|
||||
["rea", "mcp"],
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConfigurationError);
|
||||
state.rejectPublicationClose = false;
|
||||
expect(await host.close?.()).toBeUndefined();
|
||||
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
||||
expect(foreignOwner.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
state.rejectPublicationClose = false;
|
||||
await host.close?.();
|
||||
await foreignOwner.close();
|
||||
for (const handle of state.publicationHandles)
|
||||
if (handle.fd >= 0) await handle.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user