Summary
jsonStore({ lock: true }) silently loses writes when two or more update / updateOr / write calls on the same file overlap inside a single process. The second caller is handed the lock the first caller is still holding, both read the same value, both write, and one update disappears with no error and no warning. Locking across separate processes works correctly, so the defect is confined to the in-process reentrancy branch.
src/json-document-store.ts:94 passes allowReentrant: true on every locked JSON store operation, and the reentrancy check in src/sidecar-lock.ts:286-297 is keyed only on the target path plus "this process already holds it". There is no owner token and no AsyncLocalStorage scope, so an unrelated concurrent async task in the same process satisfies the reentrancy condition and is let straight through.
Environment
- Commit: 8cced2a (openclaw/fs-safe main at time of filing), package version 0.4.7
- Node: v26.0.0, macOS 26.3, APFS
- Surface:
jsonStore from fs-safe/store with lock: true or any lock options object. update, updateOr and write are all affected.
Steps to reproduce
- Clone
openclaw/fs-safe at 8cced2a, install dependencies and build so dist/ exists.
- Save this file as
repro.mjs at the repo root:
// node repro.mjs
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
import { jsonStore } from "./dist/store.js";
const self = fileURLToPath(import.meta.url);
async function bump(filePath, tag, log) {
const store = jsonStore({ filePath, lock: true });
await store.updateOr({ count: 0 }, async (prev) => {
log.push(`${tag} read count=${prev.count}`);
await delay(30);
log.push(`${tag} write count=${prev.count + 1}`);
return { count: prev.count + 1 };
});
}
if (process.argv[2] === "child") {
const log = [];
await bump(process.argv[3], `child${process.argv[4]}`, log);
console.log(log.map((line) => ` ${line}`).join("\n"));
process.exit(0);
}
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fs-safe-repro-"));
const onDisk = (p) => fs.readFileSync(p, "utf8").replace(/\s+/g, " ").trim();
// CASE 1: three updaters in ONE process, staggered so each arrives while the
// previous one still holds the lock.
const inproc = path.join(dir, "inproc.json");
await jsonStore({ filePath: inproc, lock: true }).write({ count: 0 });
const log = [];
await Promise.all(
[0, 1, 2].map(async (i) => {
await delay(i * 10);
await bump(inproc, `A${i}`, log);
}),
);
console.log("CASE 1: three updaters, ONE process, jsonStore({ lock: true })");
console.log(log.map((line) => ` ${line}`).join("\n"));
console.log(` on disk: ${onDisk(inproc)} (expected count 3)`);
// CASE 2 (control): the same three updaters, one per process.
const xproc = path.join(dir, "xproc.json");
await jsonStore({ filePath: xproc, lock: true }).write({ count: 0 });
console.log("\nCASE 2 (control): three updaters, THREE processes, same options");
await Promise.all(
[0, 1, 2].map(
(i) =>
new Promise((resolve) => {
setTimeout(() => {
spawn(process.execPath, [self, "child", xproc, String(i)], {
stdio: "inherit",
}).on("exit", resolve);
}, i * 10);
}),
),
);
console.log(` on disk: ${onDisk(xproc)} (expected count 3)`);
- Run
node repro.mjs.
Expected
Three updateOr calls against the same locked store increment the counter to 3, whether they originate in one process or three. docs/json-store.md:117 states that the whole read, run, write sequence runs inside one withLock call.
Actual
In one process the three calls all read count=0 and all write count=1. Final on-disk value is { "count": 1 }. Two updates are gone. No error is raised, no lock timeout, no diagnostic. The same three calls run one per process serialize correctly and produce { "count": 3 }.
At higher concurrency the loss is severe: 40 rounds of 10 staggered update() calls on a locked store lost 273 of 400 updates.
Second observable symptom, same root cause
Because the concurrent entrants are inside the atomic-write path at the same time, they also collide on the temp file and identity checks and intermittently surface a spurious FsSafeError with code path-mismatch at the caller. Observed across the same 400-update run on current main:
2x path-mismatch: path changed during read
12x path-mismatch: unable to resolve opened file path
3x path-mismatch: path mismatch
These errors disappear entirely once the reentrancy branch stops admitting unrelated callers, which is what points them at the same root cause rather than at a separate atomic-write defect.
Root cause
src/json-document-store.ts:83-99:
async function withOptionalLock<R>(run: () => Promise<R>): Promise<R> {
if (!locks || !lockOptions) {
return await run();
}
return await locks.withLock(
{
targetPath: adapter.filePath,
staleMs: lockOptions.staleMs,
timeoutMs: lockOptions.timeoutMs,
retry: lockOptions.retry,
staleRecovery: lockOptions.staleRecovery,
allowReentrant: true,
payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
},
run,
);
}
src/sidecar-lock.ts:286-297:
const held = state.held.get(normalizedTargetPath);
if (held && options.allowReentrant) {
held.count += 1;
const release = () =>
releaseHeldLock(state, normalizedTargetPath, held).then(() => undefined);
return { lockPath, normalizedTargetPath, release, [Symbol.asyncDispose]: release };
}
The condition is "some code in this process holds this path". It carries no notion of which logical owner or call chain holds it. A second, unrelated async task therefore matches, bumps held.count, and enters the critical section concurrently with the first.
Worth noting for anyone reaching for the obvious objection that reentrancy is needed for nesting: jsonStore does not nest its own locks. update, updateOr and write (src/json-document-store.ts:105-121) all call the internal unlocked read / write helpers inside a single withOptionalLock. So allowReentrant: true is not required by the library's own structure here.
Documentation
Not filing this as a flat documentation contradiction. docs/json-store.md:116 is careful and correct: it says concurrent updaters "from different processes serialize cleanly", and that case does work. The softer gap is docs/json-store.md:146, which says the default managerKey means "two jsonStore calls on the same file share lock state automatically". Sharing lock state reads as an assurance that in-process calls serialize, when in practice sharing the manager is exactly what routes the second caller into the reentrancy branch and lets it through.
Two directions, maintainer's call
This is a semantics decision rather than an obvious patch, which is why it is an issue and not a PR.
- Drop
allowReentrant: true at src/json-document-store.ts:94. One line, and it makes concurrent in-process updaters serialize correctly (verified below). The cost is that a caller who re-enters the same store from inside its own update callback moves from "works" to "blocks until the lock timeout". Verified on the current build: such a nested call succeeds today, and after removing the flag it fails with file_lock_timeout. That may be the right trade, since losing a write silently is worse than a loud timeout, but it is a behavior change for any existing caller doing that.
- Make reentrancy owner-scoped rather than process-scoped, via
AsyncLocalStorage or an explicit owner token threaded through SidecarLockAcquireOptions, so the reentrancy branch fires only for the same logical holder. Preserves nesting and fixes the data loss, at the cost of more machinery in sidecar-lock.ts.
Novelty
Checked open and closed issues and pull requests on this repo. Nothing covers this. The only open pull request, #59, refactors sidecar-lock.ts and moves the allowReentrant?: boolean declaration into a new sidecar-lock-types.ts, but it does not change the reentrancy condition and does not touch src/json-document-store.ts, so the defect survives it.
Real behavior proof
Behavior addressed: jsonStore({ lock: true }) silently discards concurrent in-process updates because lock reentrancy is scoped to the process rather than to the logical owner.
Real environment tested: real fs-safe runtime driven through the public jsonStore API from dist/store.js, writing to a real temp directory on APFS, at commit 8cced2a on Node v26.0.0 / macOS 26.3.
Exact steps or command run after this patch: node repro.mjs (source above), then the same script against a build with the single allowReentrant: true line removed from json-document-store.
Evidence after fix:
# OBSERVED on origin/main 8cced2ab62640577005974cc7cf06136dbb1867a
CASE 1: three updaters, ONE process, jsonStore({ lock: true })
A0 read count=0
A1 read count=0
A2 read count=0
A0 write count=1
A1 write count=1
A2 write count=1
on disk: { "count": 1 } (expected count 3)
CASE 2 (control): three updaters, THREE processes, same options
child0 read count=0
child0 write count=1
child2 read count=1
child2 write count=2
child1 read count=2
child1 write count=3
on disk: { "count": 3 } (expected count 3)
# EXPECTED, identical inputs, same build with `allowReentrant: true` removed
CASE 1: three updaters, ONE process, jsonStore({ lock: true })
A0 read count=0
A0 write count=1
A1 read count=1
A1 write count=2
A2 read count=2
A2 write count=3
on disk: { "count": 3 } (expected count 3)
CASE 2 (control): three updaters, THREE processes, same options
child0 read count=0
child0 write count=1
child2 read count=1
child2 write count=2
child1 read count=2
child1 write count=3
on disk: { "count": 3 } (expected count 3)
# Volume run, 40 rounds x 10 staggered update() calls on a locked store
# on origin/main 8cced2ab62640577005974cc7cf06136dbb1867a
40 rounds x 10 staggered update() calls, lock: true
updates requested: 400
updates lost: 273
errors raised to the caller:
2x path-mismatch: path changed during read
12x path-mismatch: unable to resolve opened file path
3x path-mismatch: path mismatch
# same volume run, same build with `allowReentrant: true` removed
40 rounds x 10 staggered update() calls, lock: true
updates requested: 400
updates lost: 0
errors raised to the caller:
(none)
# nested call semantics, the trade-off in option 1, lock timeoutMs 1000
# store.write() called from inside store.update()'s own callback
on origin/main: nested store call inside update(): OK, on disk { "count": 1 }
with `allowReentrant: true` removed: nested store call inside update(): file_lock_timeout: file lock timeout for /private/var/folders/.../n.json
Observed result after fix: on current main three overlapping in-process updateOr calls all read count=0 and the file ends at { "count": 1 } instead of { "count": 3 }, while the identical three calls split across three processes end at { "count": 3 }; removing the process-scoped reentrancy flag makes the in-process case match the cross-process case exactly and eliminates the 273 lost updates and all path-mismatch errors in the volume run.
What was not tested: Windows and Linux (macOS 26.3 / APFS only), worker_threads rather than plain async tasks, configureFsSafeLocks global defaults other than the built-in ones, and the low-level acquireFileLock / withSidecarLock API used directly by callers who deliberately pass allowReentrant: true themselves. No implementation of the owner-scoped option was built or measured; the only alternative build exercised was the one-line flag removal.
Summary
jsonStore({ lock: true })silently loses writes when two or moreupdate/updateOr/writecalls on the same file overlap inside a single process. The second caller is handed the lock the first caller is still holding, both read the same value, both write, and one update disappears with no error and no warning. Locking across separate processes works correctly, so the defect is confined to the in-process reentrancy branch.src/json-document-store.ts:94passesallowReentrant: trueon every locked JSON store operation, and the reentrancy check insrc/sidecar-lock.ts:286-297is keyed only on the target path plus "this process already holds it". There is no owner token and noAsyncLocalStoragescope, so an unrelated concurrent async task in the same process satisfies the reentrancy condition and is let straight through.Environment
jsonStorefromfs-safe/storewithlock: trueor anylockoptions object.update,updateOrandwriteare all affected.Steps to reproduce
openclaw/fs-safeat 8cced2a, install dependencies and build sodist/exists.repro.mjsat the repo root:node repro.mjs.Expected
Three
updateOrcalls against the same locked store increment the counter to 3, whether they originate in one process or three.docs/json-store.md:117states that the whole read, run, write sequence runs inside onewithLockcall.Actual
In one process the three calls all read
count=0and all writecount=1. Final on-disk value is{ "count": 1 }. Two updates are gone. No error is raised, no lock timeout, no diagnostic. The same three calls run one per process serialize correctly and produce{ "count": 3 }.At higher concurrency the loss is severe: 40 rounds of 10 staggered
update()calls on a locked store lost 273 of 400 updates.Second observable symptom, same root cause
Because the concurrent entrants are inside the atomic-write path at the same time, they also collide on the temp file and identity checks and intermittently surface a spurious
FsSafeErrorwith codepath-mismatchat the caller. Observed across the same 400-update run on current main:These errors disappear entirely once the reentrancy branch stops admitting unrelated callers, which is what points them at the same root cause rather than at a separate atomic-write defect.
Root cause
src/json-document-store.ts:83-99:src/sidecar-lock.ts:286-297:The condition is "some code in this process holds this path". It carries no notion of which logical owner or call chain holds it. A second, unrelated async task therefore matches, bumps
held.count, and enters the critical section concurrently with the first.Worth noting for anyone reaching for the obvious objection that reentrancy is needed for nesting:
jsonStoredoes not nest its own locks.update,updateOrandwrite(src/json-document-store.ts:105-121) all call the internal unlockedread/writehelpers inside a singlewithOptionalLock. SoallowReentrant: trueis not required by the library's own structure here.Documentation
Not filing this as a flat documentation contradiction.
docs/json-store.md:116is careful and correct: it says concurrent updaters "from different processes serialize cleanly", and that case does work. The softer gap isdocs/json-store.md:146, which says the defaultmanagerKeymeans "twojsonStorecalls on the same file share lock state automatically". Sharing lock state reads as an assurance that in-process calls serialize, when in practice sharing the manager is exactly what routes the second caller into the reentrancy branch and lets it through.Two directions, maintainer's call
This is a semantics decision rather than an obvious patch, which is why it is an issue and not a PR.
allowReentrant: trueatsrc/json-document-store.ts:94. One line, and it makes concurrent in-process updaters serialize correctly (verified below). The cost is that a caller who re-enters the same store from inside its ownupdatecallback moves from "works" to "blocks until the lock timeout". Verified on the current build: such a nested call succeeds today, and after removing the flag it fails withfile_lock_timeout. That may be the right trade, since losing a write silently is worse than a loud timeout, but it is a behavior change for any existing caller doing that.AsyncLocalStorageor an explicit owner token threaded throughSidecarLockAcquireOptions, so the reentrancy branch fires only for the same logical holder. Preserves nesting and fixes the data loss, at the cost of more machinery insidecar-lock.ts.Novelty
Checked open and closed issues and pull requests on this repo. Nothing covers this. The only open pull request, #59, refactors
sidecar-lock.tsand moves theallowReentrant?: booleandeclaration into a newsidecar-lock-types.ts, but it does not change the reentrancy condition and does not touchsrc/json-document-store.ts, so the defect survives it.Real behavior proof
Behavior addressed:
jsonStore({ lock: true })silently discards concurrent in-process updates because lock reentrancy is scoped to the process rather than to the logical owner.Real environment tested: real
fs-saferuntime driven through the publicjsonStoreAPI fromdist/store.js, writing to a real temp directory on APFS, at commit 8cced2a on Node v26.0.0 / macOS 26.3.Exact steps or command run after this patch:
node repro.mjs(source above), then the same script against a build with the singleallowReentrant: trueline removed fromjson-document-store.Evidence after fix:
Observed result after fix: on current main three overlapping in-process
updateOrcalls all readcount=0and the file ends at{ "count": 1 }instead of{ "count": 3 }, while the identical three calls split across three processes end at{ "count": 3 }; removing the process-scoped reentrancy flag makes the in-process case match the cross-process case exactly and eliminates the 273 lost updates and allpath-mismatcherrors in the volume run.What was not tested: Windows and Linux (macOS 26.3 / APFS only),
worker_threadsrather than plain async tasks,configureFsSafeLocksglobal defaults other than the built-in ones, and the low-levelacquireFileLock/withSidecarLockAPI used directly by callers who deliberately passallowReentrant: truethemselves. No implementation of the owner-scoped option was built or measured; the only alternative build exercised was the one-line flag removal.