mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Five routes changed the linked-cases registry: link, unlink, rename, and a
create or clone into a custom folder. Each read the file, checked it and wrote
it back on its own, so two requests that both read before either wrote lost
an entry. Two clones that claimed one name and finished together both passed
the name check, both answered success, and the later write replaced the
earlier entry. The same race made "a name linked while the clone ran is a
409" in test/routes/case-clone-custom-folder.test.ts fail about half the time.
- New src/web/linked-cases-writer.ts, mutateLinkedCases(), shaped like
mutateSettingsFile(): mutations run one at a time, each write is a temp
file renamed over the registry (through a symlink, keeping its mode), and
a file that is not a JSON object is refused (409 CONFLICT) instead of being
read as {} and overwritten.
- All five writers go through it, with their name checks inside the
callback, so exactly one of two requests for one name wins. Rename also
re-checks that the old name still points at the old folder, and drops its
manual registry undo, since a failed write no longer changes the file.
- Tests: test/linked-cases-writer.test.ts (writer behaviour, and concurrent
link/unlink through the routes with slowed writes, which fail on the old
code). Tests that forced a failed registry write with chmod 444 or a
directory now inject the failure at the rename, or expect the refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
5.4 KiB
TypeScript
118 lines
5.4 KiB
TypeScript
/**
|
|
* @fileoverview The writer of the linked-cases registry (`linked-cases.json` in the data dir): case
|
|
* name to the absolute folder it stands for.
|
|
*
|
|
* Every read-modify-write of that file goes through `mutateLinkedCases()`, the same shape as
|
|
* `mutateSettingsFile()` (settings-writer.ts). The one direct writer is config-backup restore
|
|
* (`restoreConfigBackup()`), which must work on a file that does not parse. Readers keep reading the
|
|
* file directly; a write is atomic, so they never see half of one.
|
|
*
|
|
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading, changing
|
|
* and writing before the next starts. Link, unlink, rename, a create in a custom folder and a clone
|
|
* into one each used to read the file, check it and write it back on their own. Two clones that
|
|
* claimed one name and finished together both passed the check, both answered success, and the
|
|
* later write replaced the earlier one's entry. Any two writers could lose an entry the same way.
|
|
* A check that must hold when the entry is written belongs inside `fn`: throwing there rejects that
|
|
* caller only, and nothing is written.
|
|
* - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it, so a
|
|
* reader never sees a half-written file (quick-start would read one as an empty registry and resolve
|
|
* a linked case to the cases dir instead), and a failed write leaves the previous file in place.
|
|
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file that
|
|
* cannot be read or is not a JSON object is left alone and the write throws
|
|
* `LinkedCasesWriteRefusedError`: read as `{}`, the next link would write back its own entry alone
|
|
* and unlink every other case.
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
import { dataPath } from '../config/instance.js';
|
|
import { getErrorMessage } from '../types.js';
|
|
|
|
export type LinkedCases = Record<string, string>;
|
|
|
|
/** Resolved at call time, like every other `dataPath()` caller that tests point at a temp HOME. */
|
|
export function linkedCasesPath(): string {
|
|
return dataPath('linked-cases.json');
|
|
}
|
|
|
|
/** A write refused because the existing registry must not be overwritten. The message is user-facing. */
|
|
export class LinkedCasesWriteRefusedError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'LinkedCasesWriteRefusedError';
|
|
}
|
|
}
|
|
|
|
async function readLinkedCasesForWrite(path: string): Promise<LinkedCases> {
|
|
let raw: string;
|
|
try {
|
|
raw = await fs.readFile(path, 'utf-8');
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
|
throw new LinkedCasesWriteRefusedError(
|
|
`Cannot read ${path} (${(err as Error).message}), so the linked cases were not changed.`
|
|
);
|
|
}
|
|
if (raw.trim() === '') return {};
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
parsed = undefined;
|
|
}
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
throw new LinkedCasesWriteRefusedError(
|
|
`${path} is not a valid JSON object, so the linked cases were not changed (saving would have unlinked every other case). Fix or delete the file, or restore it from a config backup, then try again.`
|
|
);
|
|
}
|
|
return parsed as LinkedCases;
|
|
}
|
|
|
|
async function writeLinkedCasesFile(path: string, linked: LinkedCases): Promise<void> {
|
|
// Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file.
|
|
const target = await fs.realpath(path).catch(() => path);
|
|
await fs.mkdir(dirname(target), { recursive: true });
|
|
// A plain writeFile kept the file's permissions; a rename would not, so the temp file takes them.
|
|
const mode = await fs.stat(target).then(
|
|
(s) => s.mode & 0o777,
|
|
() => undefined
|
|
);
|
|
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
try {
|
|
await fs.writeFile(tmp, JSON.stringify(linked, null, 2), mode === undefined ? undefined : { mode });
|
|
// The creation mode is masked by the umask; the existing file's bits are put back as they were.
|
|
if (mode !== undefined) await fs.chmod(tmp, mode);
|
|
await fs.rename(tmp, target);
|
|
} catch (err) {
|
|
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let mutationChain: Promise<unknown> = Promise.resolve();
|
|
|
|
/**
|
|
* Run one change to the linked-cases registry. `fn` receives a copy of the current registry and
|
|
* returns the registry to write, or `undefined` to write nothing; it may throw to refuse the change
|
|
* (its error rejects this call as thrown). Resolves with the registry before and after the change
|
|
* (`after` is `undefined` when nothing was written). A refused or failed mutation rejects its own
|
|
* caller only; the chain keeps going.
|
|
*/
|
|
export function mutateLinkedCases(
|
|
fn: (current: LinkedCases) => LinkedCases | undefined
|
|
): Promise<{ before: LinkedCases; after: LinkedCases | undefined }> {
|
|
const run = mutationChain.then(async () => {
|
|
const path = linkedCasesPath();
|
|
const before = await readLinkedCasesForWrite(path).catch((err: unknown) => {
|
|
console.warn(`[linked-cases] write refused: ${getErrorMessage(err)}`);
|
|
throw err;
|
|
});
|
|
const after = fn({ ...before });
|
|
if (after) await writeLinkedCasesFile(path, after);
|
|
return { before, after };
|
|
});
|
|
mutationChain = run.catch(() => {});
|
|
return run;
|
|
}
|