diff --git a/docs/installation.md b/docs/installation.md index 0b7319e0d6..0d4d0b6e9c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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`. diff --git a/src/application/ClientConfigurationPublication.ts b/src/application/ClientConfigurationPublication.ts new file mode 100644 index 0000000000..454fb82537 --- /dev/null +++ b/src/application/ClientConfigurationPublication.ts @@ -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 { + 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(operation: () => Promise): Promise { + return withOwnedFileHandle(this.path, this.owner, {}, operation); + } + + /** Readback cannot establish a successful transaction through a replaced name. */ + async assertCurrent(): Promise { + 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 { + 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 { + 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; + } +} diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 44a73c8049..3066956284 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -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 => { - const rollback = await restoreConfig(targetPath, original); + const rollback = await restoreConfig(publication, original); return rollback.ok ? { status: "failed", diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 0840385e4e..48670d8f3b 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -404,19 +404,42 @@ const runClientConfigurationOperation = async ( 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, ); } }); diff --git a/tests/process-global/setupConfigurationRollback.test.ts b/tests/process-global/setupConfigurationRollback.test.ts index cf6504c324..eb6ed91cbd 100644 --- a/tests/process-global/setupConfigurationRollback.test.ts +++ b/tests/process-global/setupConfigurationRollback.test.ts @@ -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) | 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(); return { ...original, - rm: ( - ...arguments_: Parameters - ): ReturnType => { - if (state.rejectRemove && state.readbackFailed) - return Promise.reject(new Error("injected restore remove failure")); - return original.rm(...arguments_); + open: async (...arguments_: Parameters) => { + 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 @@ -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( + "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( + "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( + "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( + "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( + "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( + "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(); + } +});