diff --git a/docs/document-variants-cut.md b/docs/document-variants-cut.md index e9dc51ed..f363521f 100644 --- a/docs/document-variants-cut.md +++ b/docs/document-variants-cut.md @@ -1620,7 +1620,7 @@ The one door per runtime, and who walks through it: - **Shared paths.** Open, flush, commit, `ReadDurable`, `ReplaceDurable`. Rust `pull_all` RS:1243 also checks `is_absent` on the store and on the lock path before it takes the shared lock; that skips lock creation only, and the verdict stays with the reader. - **Cut line.** `merge-r1` h2 (C#) and h4/h6 (TS `readStoreBytes`); `mesh-single-file-reader` h1. - **Pinned by.** C#: `StoreReadabilityTests` `ADanglingFileLinkIsAnIoErrorOnOpenNotAnEmptyStore` :208, `ADanglingDirectoryLinkIsAnIoErrorOnOpenNotAnEmptyStore` :185, and the commit variants :196, :219. Mesh: `ADanglingLinkIsAnIoErrorOnTypedWriteRawWriteAndReadNeverAMissingFile` :107 and `OnlyNothingAtThePathIsAMissingFile` :134 (`CultMeshSingleFileStoreTests.cs`). Rust: `a_dangling_symlink_at_the_store_path_is_an_error_and_nothing_is_written` RS:5832. TS: "readStoreBytes reports nothing at the path only when the lstat finds nothing too". -- **Known gaps.** The writer side still tolerates a dangling link: Rust `write_all_unlocked` RS:992-996 and TS `pushAll` TS:94-100 replace it on a whole-store write (`variants:follow_up:writer-dangling-link-tolerance`, owner `c2a-write-set`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head. +- **Known gaps.** The writer no longer tolerates a dangling link: the reader's refusal stands for every write, whole-store `pushAll` included (Rust `a_push_all_through_a_dangling_link_is_refused_like_push_and_writes_nothing`, TS `a pushAll through a dangling link is refused like push`; `variants:follow_up:writer-dangling-link-tolerance`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head. ### C. Zero-byte file @@ -1672,7 +1672,7 @@ The one door per runtime, and who walks through it: - **Shared paths.** TS `pushAll` and Rust `push_all` only. - **Cut line.** `merge-r1` h4 (TS) and h6 (Rust). - **Pinned by.** The manifest walks run a soft flush over every vector in TS (the `softPushAll` operation, TS test :2336) and Rust (`soft_push_all`, RS:5149), each with an absent and a dangling-link case; `a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link` RS:6019. -- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` applies to the non-soft branch. +- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` is settled: the non-soft branch refuses a dangling link too. ### What no cut owns yet diff --git a/docs/write-ownership-map.md b/docs/write-ownership-map.md index 44452dc6..79140c2b 100644 --- a/docs/write-ownership-map.md +++ b/docs/write-ownership-map.md @@ -107,8 +107,8 @@ Authority map (also in the spec): - `variants:follow_up:public-store-writes-behind-cache`: **leave**. It asks who may stage behind an attached cache (API shape); this cut changes what any staging is judged by. No collision: direct `Push`/`CommitBatch` reach the same on-disk judge, so they get the per-key rule for free. Hands must not give direct staging a different predicate. -- `variants:follow_up:writer-dangling-link-tolerance`: **leave**. Rust/TS whole-store `push_all` reading a dangling link is - a reader/writer tolerance question, not ownership; this cut touches no leaf. +- `variants:follow_up:writer-dangling-link-tolerance`: **settled** (the reader owns the verdict; every runtime's whole-store + `push_all` refuses a dangling link, as `push` does). ## Deferred doc findings this cut fixes diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 678ff4d6..b3d6a441 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -4120,7 +4120,7 @@ struct DiskStore { } impl DiskStore { - /// A file that is gone, or a dangling link a whole-file replace is about to replace: an empty v1 store. + /// Nothing at the path: an empty v1 store. A dangling link is something at the path and is refused, not read as this. fn absent() -> Self { Self { absent: true, format: STORE_FORMAT_V1, envelopes: Vec::new(), catalog: Vec::new() } } diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index dcbfd12e..fea670db 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -7,7 +7,8 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Breaking -- `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush and the dangling-link case are unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged. +- `SingleFileMessagePackBackingStore.pushAll` through a dangling symbolic link at the store path is refused with `ENOENT`, as `push` and `pullAll` are, and writes nothing; it used to replace the link with a new store. The reader owns the verdict on what is at the path. +- `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush is unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged. - A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 298f1bee..b5199ae3 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -87,26 +87,15 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { // another writer added. The store is read by the same reader as `pullAll`, so one it would refuse (not exactly one // store, a variant, a body it cannot decode) is refused and left as it is. A soft flush asks that reader too: nothing // at the path is written, a store is left alone. - let held: CultCacheEnvelope[] = []; - try { - const disk = this.#readDisk(); - if (options.soft && !disk.absent) { - return; - } - - this.#format = disk.format; - held = disk.envelopes; - } catch (error) { - // Only a dangling link reaches here with ENOENT: it holds no store, so a flush has nothing to merge with and no - // header to keep, and replaces the link (R3 pins this). A soft flush does not replace what it cannot read, and every - // merging writer has already refused the link when it read the current entries. - if (options.soft || (error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - - this.#format = STORE_FORMAT_VERSION; + // The reader owns the verdict on what is at the path: a dangling link is refused here as it is for open and push. + const disk = this.#readDisk(); + if (options.soft && !disk.absent) { + return; } + this.#format = disk.format; + const held = disk.envelopes; + // A key is held once: of same-type entries sharing a key in one batch, the last is kept. const supplied = [...new Map(entries.map((entry) => [entry.key, entry])).values()]; requireOneTypePerKey(this.filePath, entries, held); diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 4efa6d78..7c559af7 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2525,14 +2525,15 @@ test("R3 decides resolve-or-refuse: a push through a live link replaces the link assert.deepEqual(await readFile(target), before); }); -test("R3 decides resolve-or-refuse: a pushAll through a dangling link replaces the link", { skip: process.platform === "win32" }, async () => { +test("a pushAll through a dangling link is refused like push, and the link and its volume are left as they were", { skip: process.platform === "win32" }, async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-push-all-")); const volume = join(dir, "volume"); await mkdir(volume); const link = join(dir, "store.cc"); await symlink(join(volume, "store.cc"), link); - await new SingleFileMessagePackBackingStore(link).pushAll([{ key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }]); - assert.ok((await lstat(link)).isFile()); + const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }; + await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([envelope]), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); + assert.ok((await lstat(link)).isSymbolicLink()); assert.deepEqual(await readdir(volume), []); });