Files
rea/src/application/ClientConfigurationPublication.ts
T
abdulwasea89 42e1a17d6e fix(setup): preserve concurrent client configuration edits (#4855)
* fix(setup): preserve concurrent client configuration edits

* fix(setup): normalize configuration transaction queue paths

* fix(setup): report concurrent snapshot read conflicts

* test(setup): isolate Pi workflows from installed Hopper

* test: align inherited fixtures with current analysis contracts

* test(ida): assert search recovery instead of diagnostic prose

* fix(setup): share configuration queue keys across directory aliases

Resolve parent-directory aliases for regular and missing configuration
paths so a directory symlink and its real directory serialize setup
instead of racing the same file.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-23 05:06:13 +08:00

317 lines
11 KiB
TypeScript

import { constants, type Stats } from "node:fs";
import {
link,
lstat,
mkdtemp,
open,
realpath,
rename,
type FileHandle,
} from "node:fs/promises";
import { basename, dirname, join, resolve, sep } from "node:path";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js";
import {
RegularFileChangedError,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import type { RegularFileOperationOutcome } from "../filesystem/OwnedRegularFile.js";
import { readRegularFileTextSnapshot } from "./RegularFileRead.js";
import { ClientConfigurationPublicationFailure } from "./ClientConfigurationCleanup.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
/** The original read owner's selection, before merging and preparing a backup. */
export interface ClientConfigurationSelection {
readonly requestedPath: string;
readonly state: Stats | undefined;
}
/** 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,
private readonly staging: OwnedStagedOutput,
private readonly cleanup: AnalysisCleanupLease,
) {}
static async write(
path: string,
contents: string,
previous: string | undefined,
mode?: number,
selection?: ClientConfigurationSelection,
): Promise<ClientConfigurationPublication> {
const parent = await realpath(dirname(path));
const destination = join(parent, basename(path));
const input = await selectedConfiguration(destination, previous);
if (selection !== undefined) {
await assertRequestedConfigurationPath(path, selection.requestedPath);
if (
selection.state === undefined
? input !== undefined
: input === undefined || !sameRegularFileState(selection.state, input)
)
throw new RegularFileChangedError(destination);
}
const directory = await mkdtemp(`${parent}${sep}.rea-config-`);
const staging = await OwnedStagedOutput.create(directory);
const cleanup = new AnalysisCleanupLease(
() => staging.close(),
[directory],
);
try {
const stagedPath = `${directory}${sep}content`;
const owner = new OwnedFileHandle(
await open(
stagedPath,
constants.O_RDWR |
constants.O_CREAT |
constants.O_EXCL |
(constants.O_NONBLOCK ?? 0) |
(constants.O_NOFOLLOW ?? 0),
0o600,
),
);
staging.setFile(owner);
await owner.handle.writeFile(contents, { encoding: "utf8" });
const created = await owner.handle.stat();
if (
input !== undefined &&
(input.uid !== created.uid || input.gid !== created.gid)
)
await owner.handle.chown(input.uid, input.gid);
await owner.handle.chmod(mode ?? (input?.mode ?? 0o600) & 0o7777);
await owner.handle.sync();
if ((await owner.handle.stat()).ino === 0)
throw new RegularFileChangedError(stagedPath);
await staging.assertReadyForPublication(stagedPath);
if (selection !== undefined)
await assertRequestedConfigurationPath(path, selection.requestedPath);
await assertSelectedConfiguration(destination, input);
if (input === undefined) {
// An exclusive link cannot overwrite a file created after absence was checked.
try {
await link(stagedPath, destination);
} catch (cause: unknown) {
if (
selection !== undefined &&
cause instanceof Error &&
"code" in cause &&
cause.code === "EEXIST"
)
throw new RegularFileChangedError(destination, { cause });
throw cause;
}
} else {
// The destination is explicit: rename never follows its final symlink.
await rename(stagedPath, destination);
staging.recordPublishedRename();
}
return new ClientConfigurationPublication(
destination,
contents,
owner,
staging,
cleanup,
);
} catch (cause: unknown) {
await releaseStaging(path, staging, cleanup, { kind: "failed", cause });
throw cause;
}
}
/** Keep all publication resources owned through the complete operation. */
async verify<Value>(operation: () => Promise<Value>): Promise<Value> {
let outcome: RegularFileOperationOutcome<Value>;
try {
outcome = { kind: "completed", value: await operation() };
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
await releaseStaging(this.path, this.staging, this.cleanup, outcome);
if (outcome.kind === "failed") throw outcome.cause;
return outcome.value;
}
async assertCurrent(readback?: string): Promise<void> {
const owned = await this.owner.handle.stat();
const named = await lstat(this.path);
if (
owned.ino === 0 ||
(readback !== undefined && readback !== this.contents) ||
named.isSymbolicLink() ||
!sameRegularFileState(owned, named)
)
throw new RegularFileChangedError(this.path);
}
/** State checks do not exclude a concurrent writer to the same inode. */
async restore(original: string | undefined): Promise<void> {
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()) ||
!sameRegularFileState(before, await lstat(this.path))
)
throw new RegularFileChangedError(this.path);
if (original === undefined)
throw new Error(
"First-run configuration recovery requires manual removal; pathname deletion cannot prove ownership",
);
const bytes = Buffer.from(original, "utf8");
for (let position = 0; position < bytes.length;) {
const { bytesWritten } = await handle.write(
bytes,
position,
bytes.length - position,
position,
);
if (bytesWritten === 0)
throw new Error("Configuration restoration made no write progress");
position += bytesWritten;
}
await handle.truncate(bytes.length);
if (!sameRegularFileState(await handle.stat(), await lstat(this.path)))
throw new RegularFileChangedError(this.path);
}
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;
}
}
const assertRequestedConfigurationPath = async (
path: string,
requestedPath: string,
): Promise<void> => {
const current = await resolveClientConfigTransactionPath(requestedPath);
if (current === undefined)
throw new ConfigurationError(
`Client configuration path could not be safely verified: ${requestedPath}`,
);
if (resolve(current) === resolve(path)) return;
// `path` is the canonical file selected for this transaction. A later
// retarget of a parent-directory alias keeps that file. A configuration
// symlink that now names another target, or a path whose parent is already
// canonical, still conflicts.
if (await isParentAliasRetarget(requestedPath, path, current)) return;
throw new RegularFileChangedError(path);
};
const isParentAliasRetarget = async (
requestedPath: string,
path: string,
current: string,
): Promise<boolean> => {
if (basename(current) !== basename(path)) return false;
let requestedStats: { isSymbolicLink(): boolean };
try {
requestedStats = await lstat(requestedPath);
} catch (cause: unknown) {
if (isNodeErrorCode(cause, "ENOENT")) return false;
throw new ConfigurationError(
`Client configuration path could not be safely verified: ${requestedPath}`,
);
}
if (requestedStats.isSymbolicLink()) return false;
const requestedParent = dirname(resolve(requestedPath));
try {
const canonicalParent = await realpath(requestedParent);
return resolve(canonicalParent) !== requestedParent;
} catch (cause: unknown) {
void cause;
return false;
}
};
const isNodeErrorCode = (cause: unknown, code: string): boolean =>
cause instanceof Error && "code" in cause && cause.code === code;
const selectedConfiguration = async (
path: string,
previous: string | undefined,
): Promise<Stats | undefined> => {
let selected:
| Awaited<ReturnType<typeof readRegularFileTextSnapshot>>
| undefined;
try {
selected = await readRegularFileTextSnapshot(path, { symlinks: "reject" });
} catch (cause: unknown) {
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
throw cause;
}
if (selected?.text !== previous || selected?.state.ino === 0)
throw new RegularFileChangedError(path);
return selected?.state;
};
const assertSelectedConfiguration = async (
path: string,
selected: Stats | undefined,
): Promise<void> => {
let current: Stats | undefined;
try {
current = await lstat(path);
} catch (cause: unknown) {
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
throw cause;
}
if (
selected === undefined
? current !== undefined
: current === undefined ||
current.isSymbolicLink() ||
!sameRegularFileState(selected, current)
)
throw new RegularFileChangedError(path);
};
const releaseStaging = async <Value>(
path: string,
staging: OwnedStagedOutput,
cleanup: AnalysisCleanupLease,
outcome: RegularFileOperationOutcome<Value>,
): Promise<void> => {
try {
await cleanup.release();
} catch (cause: unknown) {
throw new ClientConfigurationPublicationFailure(
path,
outcome,
cause,
{ kind: "staged-output", resource: staging.directory, output: staging },
cleanup,
);
}
};