@gregiteen/ssss-cli 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.2] - 2026-09-24
11
+ ### Fixed
12
+ - The file adapters are now safe to share between processes. Each one checked
13
+ state and then wrote it as two steps, so concurrent processes could both pass
14
+ the check: `FileSystemVfs` could lose an update behind a passed
15
+ compare-and-swap, `FileLeaseStore` could grant the same lease twice (spec §7),
16
+ `FileIdempotencyStore.put` could replace an existing entry, and
17
+ `JsonlEventStore` could append one `event_id` twice. Each check-then-write now
18
+ runs under an exclusive lock file (`O_EXCL`). A lock whose holder process has
19
+ exited is broken immediately; any lock older than 30 s is treated as stale;
20
+ a waiter gives up after 40 s with `SSSS_LOCK_TIMEOUT`. Adapters accept
21
+ `{ lock: { timeoutMs, staleMs } }`. `FileSystemVfs` keeps its locks in a
22
+ reserved `.ssss-locks/` directory that VFS paths cannot address and `list()`
23
+ skips. Locking costs about 0.5 ms per write on APFS.
24
+ - `scripts/release.sh` bumps `package.json` and `package-lock.json` and moves the
25
+ `[Unreleased]` entries into the new version's section instead of inserting a
26
+ stub above `[Unreleased]`.
27
+
28
+ ### Added
29
+ - `ssss help concurrency`.
30
+ - Conformance checks that race six processes against each file adapter, plus
31
+ lock checks for dead holders, stale locks, timeouts, symlinked locks, and the
32
+ reserved VFS lock directory.
33
+ - Git tags `v0.8.0` and `v0.9.0` on commits whose package files match the
34
+ published npm tarballs byte-for-byte. Both versions were published from
35
+ uncommitted trees, so no existing commit matched. The push skill now publishes
36
+ from a clean clone of the release tag.
37
+
10
38
  ## [0.9.1] - 2026-09-24
11
39
  ### Fixed
12
40
  - `JsonlEventStore.append` no longer re-reads and re-parses the whole
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.1
1
+ 0.9.2
@@ -106,7 +106,7 @@
106
106
  "file_count": 14,
107
107
  "provenance": {
108
108
  "content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
109
- "exporter": "@gregiteen/ssss-cli@0.9.1"
109
+ "exporter": "@gregiteen/ssss-cli@0.9.2"
110
110
  }
111
111
  },
112
112
  "files": [
@@ -0,0 +1,45 @@
1
+ # File adapter concurrency
2
+
3
+ The reference file adapters can be shared by several processes on one
4
+ filesystem. Each adapter's check-then-write step runs under a lock file, so at
5
+ most one racer wins:
6
+
7
+ | Adapter | Guarantee across processes | Lock file |
8
+ |---------|----------------------------|-----------|
9
+ | `JsonlEventStore.append` | An `event_id` is appended at most once. | `<events>/<workspace>.jsonl.lock` |
10
+ | `FileSystemVfs.writeAtomic` / `remove` | `ifAbsent`, `ifPresent`, and `version` preconditions are compare-and-swap. | `<vault>/.ssss-locks/<sha256(path)>.lock` |
11
+ | `FileLeaseStore.acquire` / `renew` / `release` | At most one active lease per `(workspace_id, target)` (spec §7). | `<leases>/<sha256>.lease.json.lock` |
12
+ | `FileIdempotencyStore.put` | A second `put` for a key fails instead of replacing the first. | `<idempotency>/<sha256>.json.lock` |
13
+
14
+ `.ssss-locks/` is reserved: VFS paths under it are rejected and `list()` skips it.
15
+
16
+ ## Crashed holders
17
+
18
+ A lock records its holder's pid and host. A waiter breaks the lock when the
19
+ holder is on the same host and no longer running, or when the lock is older than
20
+ `staleMs` (default 30 s). A waiter gives up after `timeoutMs` (default 40 s) with
21
+ `SSSS_LOCK_TIMEOUT`. Both are adjustable per adapter:
22
+
23
+ ```js
24
+ new JsonlEventStore(root, { lock: { timeoutMs: 10_000, staleMs: 15_000 } });
25
+ new FileSystemVfs(root, { lock: { timeoutMs: 10_000 } });
26
+ ```
27
+
28
+ Symlinked lock files are refused.
29
+
30
+ ## Limits
31
+
32
+ - Locks are advisory: only writers using these adapters take them. Writers from
33
+ `@gregiteen/ssss-cli` 0.9.1 and earlier do not.
34
+ - The kernel's idempotency check for `event` envelopes is read before commit and
35
+ written after, so two processes sending the same idempotency key at the same
36
+ moment can each append an event. `operation`, `patch`, and `delete` are
37
+ protected by the VFS compare-and-swap.
38
+ - Network filesystems must support exclusive create (`O_EXCL`).
39
+ - Dead-holder detection assumes processes that share the filesystem have
40
+ distinct hostnames (containers with the same hostname and separate pid
41
+ namespaces would look dead to each other). Stale detection compares the lock's
42
+ mtime with the local clock, so hosts' clocks must agree to well within
43
+ `staleMs`.
44
+
45
+ See also: `ssss help leases`, `ssss help conformance`.
@@ -33,7 +33,8 @@ vault ──export──▶ .ucw bundle ──provision──▶ envelope plan
33
33
 
34
34
  Run `ssss help <topic>` for any of:
35
35
  `runtime`, `semantic`, `portability`, `bundle`, `provisioning`,
36
- `export`, `provision`, `import`, `conformance`, `autolink`.
36
+ `export`, `provision`, `import`, `conformance`, `autolink`, `leases`,
37
+ `concurrency`.
37
38
 
38
39
  Run `ssss <command> --help` for: `export`, `validate`, `inspect`, `provision`,
39
40
  `import`, `semantic`, `primitive`, `registry`, `adapter`, `migrate`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gregiteen/ssss-cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "CLI tools and the reference engine for the Structured Semantic Syntax System",
5
5
  "main": "src/engine.mjs",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs';
2
- import { execFileSync } from 'node:child_process';
2
+ import { execFileSync, spawn, spawnSync } from 'node:child_process';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { definePrimitive, definitionsToExtensionRegistry } from '../src/primitive.mjs';
@@ -25,7 +25,7 @@ import { createKernel } from '../src/kernel.mjs';
25
25
  import { UiRegistry, actionToEnvelope, createDeterministicUi, planUi, validateUiManifest } from '../src/ui.mjs';
26
26
  import { createCommandHandler, createDomainCommand } from '../src/http.mjs';
27
27
  import { detectDirectWrites } from '../src/guard.mjs';
28
- import { MemoryLeaseStore, FileLeaseStore, runLeaseContract } from '../src/leases.mjs';
28
+ import { MemoryLeaseStore, FileLeaseStore, leaseStatePath, runLeaseContract } from '../src/leases.mjs';
29
29
  import { MemoryIdempotencyStore, FileIdempotencyStore, runIdempotencyContract } from '../src/idempotency.mjs';
30
30
  import { createCapabilityAuthorizer } from '../src/authorization.mjs';
31
31
  import { validateVerifiedPrincipal } from '../src/authorization.mjs';
@@ -40,25 +40,133 @@ async function replayed(store, options) {
40
40
  return items;
41
41
  }
42
42
 
43
- // Counts bytes JsonlEventStore reads from disk, to prove appends do not re-read the log.
44
- async function bytesReadDuring(fn) {
45
- const { readSync, readFileSync } = fs;
43
+ // Counts bytes read from `file` during `fn`, to prove appends do not re-read the log.
44
+ async function bytesReadFrom(file, fn) {
45
+ const original = { openSync: fs.openSync, closeSync: fs.closeSync, readSync: fs.readSync, readFileSync: fs.readFileSync };
46
+ const fds = new Set();
46
47
  let bytes = 0;
47
- fs.readSync = (...args) => { const read = readSync(...args); bytes += read; return read; };
48
- fs.readFileSync = (...args) => { const out = readFileSync(...args); bytes += Buffer.byteLength(out); return out; };
49
- try { await fn(); } finally { fs.readSync = readSync; fs.readFileSync = readFileSync; }
48
+ fs.openSync = (target, ...rest) => { const fd = original.openSync(target, ...rest); if (target === file) fds.add(fd); return fd; };
49
+ fs.closeSync = (fd) => { fds.delete(fd); return original.closeSync(fd); };
50
+ fs.readSync = (fd, ...rest) => { const read = original.readSync(fd, ...rest); if (fds.has(fd)) bytes += read; return read; };
51
+ fs.readFileSync = (target, ...rest) => {
52
+ const out = original.readFileSync(target, ...rest);
53
+ if (target === file || fds.has(target)) bytes += Buffer.byteLength(out);
54
+ return out;
55
+ };
56
+ try { await fn(); } finally { Object.assign(fs, original); }
50
57
  return bytes;
51
58
  }
52
59
 
60
+ // Runs `body` in `count` child processes released on one shared deadline, so
61
+ // their check-then-write sections overlap. Each resolves to { ok, racer, error }.
62
+ function raceProcesses(count, imports, body) {
63
+ const startAt = Date.now() + 1000;
64
+ const code = [
65
+ ...Object.entries(imports).map(([name, file]) => `import { ${name} } from ${JSON.stringify(new URL(file, import.meta.url).href)};`),
66
+ 'const racer = Number(process.env.SSSS_RACER);',
67
+ `while (Date.now() < ${startAt});`,
68
+ `try { ${body}; console.log(JSON.stringify({ ok: true, racer })); }`,
69
+ 'catch (error) { console.log(JSON.stringify({ ok: false, racer, error: error.message })); }',
70
+ ].join('\n');
71
+ return Promise.all(Array.from({ length: count }, (_, racer) => new Promise((resolve) => {
72
+ const child = spawn(process.execPath, ['--input-type=module', '-e', code], { env: { ...process.env, SSSS_RACER: String(racer) } });
73
+ let out = '';
74
+ let err = '';
75
+ child.stdout.on('data', (chunk) => { out += chunk; });
76
+ child.stderr.on('data', (chunk) => { err += chunk; });
77
+ child.on('close', () => {
78
+ try { resolve(JSON.parse(out.trim().split('\n').pop())); }
79
+ catch { resolve({ ok: false, racer, error: `no result: ${err || out}` }); }
80
+ });
81
+ })));
82
+ }
83
+
84
+ const winners = (results) => results.filter((result) => result.ok);
85
+ const describe = (results) => JSON.stringify(results.map(({ ok, racer, error }) => (ok ? racer : `${racer}:${error}`)));
86
+
87
+ async function checkFileAdapterRaces(root, principal, check) {
88
+ const racers = 6;
89
+ const eventsRoot = path.join(root, 'events');
90
+ const event = createCanonicalEvent({ workspace_id: 'acme', action: 'event', subject: 'events/race.md', operation_id: 'race', idempotency_key: 'race', principal });
91
+ const appends = await raceProcesses(racers, { JsonlEventStore: '../src/events.mjs' },
92
+ `await new JsonlEventStore(${JSON.stringify(eventsRoot)}).append(${JSON.stringify(event)})`);
93
+ const lines = fs.readFileSync(path.join(eventsRoot, 'acme.jsonl'), 'utf8').split('\n').filter(Boolean);
94
+ check('jsonl event store lets one of several processes append the same event_id', winners(appends).length === 1 && lines.length === 1, describe(appends));
95
+
96
+ const vfsRoot = path.join(root, 'vfs');
97
+ const creates = await raceProcesses(racers, { FileSystemVfs: '../src/vfs.mjs' },
98
+ `await new FileSystemVfs(${JSON.stringify(vfsRoot)}).writeAtomic('race/create.md', 'racer-' + racer, { ifAbsent: true })`);
99
+ const created = fs.readFileSync(path.join(vfsRoot, 'race', 'create.md'), 'utf8');
100
+ check('filesystem VFS lets one of several processes create the same path',
101
+ winners(creates).length === 1 && created === `racer-${winners(creates)[0]?.racer}`, describe(creates));
102
+ const base = await new FileSystemVfs(vfsRoot).writeAtomic('race/cas.md', 'base', { ifAbsent: true });
103
+ const swaps = await raceProcesses(racers, { FileSystemVfs: '../src/vfs.mjs' },
104
+ `await new FileSystemVfs(${JSON.stringify(vfsRoot)}).writeAtomic('race/cas.md', 'racer-' + racer, { version: ${JSON.stringify(base.version)} })`);
105
+ const swapped = fs.readFileSync(path.join(vfsRoot, 'race', 'cas.md'), 'utf8');
106
+ check('filesystem VFS lets one of several processes compare-and-swap the same version',
107
+ winners(swaps).length === 1 && swapped === `racer-${winners(swaps)[0]?.racer}`, describe(swaps));
108
+
109
+ const leaseRoot = path.join(root, 'leases');
110
+ const leases = await raceProcesses(racers, { FileLeaseStore: '../src/leases.mjs' },
111
+ `await new FileLeaseStore(${JSON.stringify(leaseRoot)}).acquire({ workspace_id: 'acme', target: 'race.md', principal_id: 'p' + racer, operation_id: 'o' + racer }, 60000)`);
112
+ const held = JSON.parse(fs.readFileSync(leaseStatePath(fs.realpathSync(leaseRoot), 'acme', 'race.md'), 'utf8'));
113
+ check('filesystem lease store grants one of several processes the same lease',
114
+ winners(leases).length === 1 && held.principal_id === `p${winners(leases)[0]?.racer}`, describe(leases));
115
+
116
+ const idempotencyRoot = path.join(root, 'idempotency');
117
+ const puts = await raceProcesses(racers, { FileIdempotencyStore: '../src/idempotency.mjs' },
118
+ `await new FileIdempotencyStore(${JSON.stringify(idempotencyRoot)}).put('acme', 'race', { request_hash: 'racer-' + racer })`);
119
+ const stored = await new FileIdempotencyStore(idempotencyRoot).get('acme', 'race');
120
+ check('filesystem idempotency store accepts one of several processes putting the same key',
121
+ winners(puts).length === 1 && stored?.request_hash === `racer-${winners(puts)[0]?.racer}`, describe(puts));
122
+ }
123
+
124
+ async function checkFileLocks(root, principal, check) {
125
+ const make = (id) => createCanonicalEvent({ workspace_id: 'acme', action: 'event', subject: 'events/lock.md', operation_id: id, idempotency_key: id, principal });
126
+ const lock = path.join(root, 'acme.jsonl.lock');
127
+ const holder = (fields) => fs.writeFileSync(lock, JSON.stringify({ token: 'other', host: os.hostname(), ...fields }));
128
+ const store = new JsonlEventStore(root, { lock: { timeoutMs: 500, staleMs: 60_000 } });
129
+
130
+ holder({ pid: process.pid });
131
+ const started = Date.now();
132
+ check('file locks make a writer wait for a live holder and then time out',
133
+ await rejects(store.append(make('lock-live')), /Timed out waiting for lock/) && Date.now() - started >= 450 && fs.existsSync(lock));
134
+ const exited = spawnSync(process.execPath, ['-e', '']).pid;
135
+ holder({ pid: exited });
136
+ await store.append(make('lock-dead'));
137
+ check('file locks break a lock whose holder process has exited', !fs.existsSync(lock));
138
+ holder({ pid: process.pid, host: 'another-host' });
139
+ const past = new Date(Date.now() - 120_000);
140
+ fs.utimesSync(lock, past, past);
141
+ await new JsonlEventStore(root, { lock: { timeoutMs: 500, staleMs: 30_000 } }).append(make('lock-stale'));
142
+ check('file locks break a lock older than the stale threshold', !fs.existsSync(lock));
143
+ const decoy = path.join(root, 'decoy.txt');
144
+ fs.writeFileSync(decoy, 'untouched');
145
+ fs.symlinkSync(decoy, lock);
146
+ check('file locks refuse a symlinked lock file',
147
+ await rejects(store.append(make('lock-symlink')), /not a regular file/) && fs.readFileSync(decoy, 'utf8') === 'untouched');
148
+ fs.rmSync(lock);
149
+
150
+ const vfs = new FileSystemVfs(path.join(root, 'vfs'));
151
+ await vfs.writeAtomic('docs/one.md', 'one', { ifAbsent: true });
152
+ fs.writeFileSync(path.join(vfs.root, '.ssss-locks', 'leftover.lock'), '{}');
153
+ const listed = [];
154
+ for await (const entry of vfs.list()) listed.push(entry.path);
155
+ check('filesystem VFS reserves its lock directory',
156
+ await rejects(vfs.writeAtomic('.ssss-locks/forged.lock', 'x'), /reserved/)
157
+ && await rejects(vfs.writeAtomic('.SSSS-Locks/forged.lock', 'x'), /reserved/)
158
+ && JSON.stringify(listed) === JSON.stringify(['docs/one.md']));
159
+ }
160
+
53
161
  async function checkJsonlEventStoreIndex(root, principal, check) {
54
162
  let seq = 0;
55
163
  const make = (workspace = 'acme') => createCanonicalEvent({
56
164
  workspace_id: workspace, action: 'event', subject: 'events/indexed.md',
57
165
  operation_id: `indexed-${++seq}`, idempotency_key: `indexed-${seq}`, principal,
58
166
  });
59
- const log = path.join(root, 'acme.jsonl');
60
167
  const first = new JsonlEventStore(root);
61
168
  const second = new JsonlEventStore(root);
169
+ const log = path.join(first.root, 'acme.jsonl');
62
170
  const appended = [];
63
171
  const add = async (store, event) => { await store.append(event); appended.push(event.event_id); return event; };
64
172
 
@@ -78,10 +186,10 @@ async function checkJsonlEventStoreIndex(root, principal, check) {
78
186
 
79
187
  const seeded = fs.statSync(log).size;
80
188
  const fresh = new JsonlEventStore(root);
81
- const batchBytes = await bytesReadDuring(async () => { for (let i = 0; i < 200; i++) await add(fresh, make()); });
189
+ const batchBytes = await bytesReadFrom(log, async () => { for (let i = 0; i < 200; i++) await add(fresh, make()); });
82
190
  check('jsonl event store reads the log once for many appends from one instance', batchBytes === seeded, `read ${batchBytes} bytes; log was ${seeded}`);
83
191
  await add(second, make());
84
- const revalidateBytes = await bytesReadDuring(() => add(fresh, make()));
192
+ const revalidateBytes = await bytesReadFrom(log, () => add(fresh, make()));
85
193
  check('jsonl event store rebuilds its index after another writer appends', revalidateBytes > seeded);
86
194
 
87
195
  const other = [];
@@ -277,6 +385,8 @@ export async function runKernel09Conformance() {
277
385
  check(`${name} event store is append-only and replayable`, duplicate && replayed === 1);
278
386
  }
279
387
  await checkJsonlEventStoreIndex(path.join(temp, 'events-index'), principal, check);
388
+ await checkFileAdapterRaces(path.join(temp, 'races'), principal, check);
389
+ await checkFileLocks(path.join(temp, 'locks'), principal, check);
280
390
  const denied = await kernel.execute({ type: 'patch', workspace_id: 'acme', idempotency_key: 'denied', path: 'bookings/one.md', patches: { status: 'pending' } }, {
281
391
  principal: { ...principal, id: 'outsider', workspaceIds: [], capabilities: ['*:*'] },
282
392
  });
@@ -2,7 +2,9 @@
2
2
  # ============================================================
3
3
  # SSSS — Version Release Script
4
4
  #
5
- # Bumps the VERSION file and adds a stub in CHANGELOG.md
5
+ # Bumps VERSION, package.json and package-lock.json, and turns the
6
+ # CHANGELOG's [Unreleased] entries into a section for the new version
7
+ # (a stub when [Unreleased] is empty).
6
8
  # ============================================================
7
9
 
8
10
  set -euo pipefail
@@ -20,16 +22,21 @@ if [[ ! "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
20
22
  exit 1
21
23
  fi
22
24
 
23
- echo "$NEW_VERSION" > VERSION
24
-
25
- # Quick sed to insert new version at the top of the changelog
26
- sed -i.bak "/^## \[.*\]/i \\
27
- ## [$NEW_VERSION] - $(date +%Y-%m-%d)\\
28
- ### Added\\
29
- - \\
30
- \\
31
- " CHANGELOG.md
25
+ cd "$(dirname "$0")/.."
32
26
 
33
- rm -f CHANGELOG.md.bak
34
-
35
- echo "✅ Bumped version to $NEW_VERSION"
27
+ echo "$NEW_VERSION" > VERSION
28
+ npm version "$NEW_VERSION" --no-git-tag-version --allow-same-version > /dev/null
29
+
30
+ node --input-type=module -e '
31
+ import fs from "node:fs";
32
+ const [version, date] = process.argv.slice(1);
33
+ const text = fs.readFileSync("CHANGELOG.md", "utf8");
34
+ if (text.includes(`## [${version}]`)) process.exit(0);
35
+ const match = text.match(/^## \[Unreleased\]\n([\s\S]*?)(?=^## \[)/m);
36
+ if (!match) { console.error("CHANGELOG.md has no ## [Unreleased] section followed by a release."); process.exit(1); }
37
+ const entries = match[1].trim() || "### Added\n- ";
38
+ const section = `## [Unreleased]\n\n## [${version}] - ${date}\n${entries}\n\n`;
39
+ fs.writeFileSync("CHANGELOG.md", text.replace(match[0], section));
40
+ ' "$NEW_VERSION" "$(date +%Y-%m-%d)"
41
+
42
+ echo "✅ Bumped version to $NEW_VERSION (VERSION, package.json, package-lock.json, CHANGELOG.md)"
package/src/events.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { withFileLock } from './file-lock.mjs';
5
6
 
6
7
  export function createCanonicalEvent(input, options = {}) {
7
8
  const required = ['workspace_id', 'action', 'subject', 'operation_id', 'idempotency_key'];
@@ -90,10 +91,13 @@ export class JsonlEventStore {
90
91
  // Per-log index of event_ids, built on the first append to that log. Each
91
92
  // index records the stat of the bytes it covers; any change (another store
92
93
  // instance or process appended, or the file was replaced) rebuilds it.
94
+ // Appenders serialize on `<log>.lock` so the duplicate check and the append
95
+ // are atomic across processes.
93
96
  #indexes = new Map();
94
- constructor(root) {
97
+ constructor(root, options = {}) {
95
98
  fs.mkdirSync(root, { recursive: true });
96
99
  this.root = fs.realpathSync(root);
100
+ this.lockOptions = options.lock || {};
97
101
  }
98
102
  #file(workspaceId) {
99
103
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId || '')) throw new Error('Unsafe event workspace_id.');
@@ -116,6 +120,9 @@ export class JsonlEventStore {
116
120
  }
117
121
  async append(event) {
118
122
  const file = this.#file(event.workspace_id);
123
+ return withFileLock(`${file}.lock`, () => this.#appendLocked(file, event), this.lockOptions);
124
+ }
125
+ #appendLocked(file, event) {
119
126
  const index = this.#index(file);
120
127
  if (index.ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
121
128
  const line = `${JSON.stringify(event)}\n`;
@@ -127,7 +134,8 @@ export class JsonlEventStore {
127
134
  after = fs.fstatSync(fd);
128
135
  } finally { fs.closeSync(fd); }
129
136
  // Keep the index only when the log grew by exactly this line on the same
130
- // file; otherwise another writer interleaved and the next append rebuilds.
137
+ // file; otherwise a writer that skips the lock interleaved, and the next
138
+ // append rebuilds.
131
139
  const before = index.stat;
132
140
  const expectedSize = (before?.size || 0) + Buffer.byteLength(line);
133
141
  if (after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Cross-process exclusive lock files for the reference file adapters.
3
+ *
4
+ * A lock is a file created with O_EXCL and removed by its holder. A lock left
5
+ * behind by a crashed holder is broken once the holder is known to be dead
6
+ * (same host, process gone) or the lock is older than `staleMs`.
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import crypto from 'node:crypto';
12
+
13
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
14
+ const CREATE_EXCLUSIVE = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW;
15
+ const HOST = os.hostname();
16
+
17
+ export const FILE_LOCK_DEFAULTS = Object.freeze({ timeoutMs: 40_000, staleMs: 30_000 });
18
+
19
+ export class FileLockTimeoutError extends Error {
20
+ constructor(lockPath) {
21
+ super(`Timed out waiting for lock '${path.basename(lockPath)}'.`);
22
+ this.name = 'FileLockTimeoutError';
23
+ this.code = 'SSSS_LOCK_TIMEOUT';
24
+ }
25
+ }
26
+
27
+ function notRegular(lockPath) {
28
+ return new Error(`Lock '${path.basename(lockPath)}' is not a regular file.`);
29
+ }
30
+
31
+ function readHolder(lockPath) {
32
+ try {
33
+ const fd = fs.openSync(lockPath, fs.constants.O_RDONLY | NOFOLLOW);
34
+ try { return JSON.parse(fs.readFileSync(fd, 'utf8')); } finally { fs.closeSync(fd); }
35
+ } catch { return null; }
36
+ }
37
+
38
+ function holderIsDead(holder) {
39
+ if (holder?.host !== HOST || !Number.isInteger(holder.pid) || holder.pid === process.pid) return false;
40
+ try { process.kill(holder.pid, 0); return false; }
41
+ catch (error) { return error.code === 'ESRCH'; }
42
+ }
43
+
44
+ const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mtimeMs === b.mtimeMs;
45
+
46
+ /** Create the lock and return its stat (proof of ownership), or null when it is held. */
47
+ function tryAcquire(lockPath, createDir) {
48
+ let fd;
49
+ try { fd = fs.openSync(lockPath, CREATE_EXCLUSIVE, 0o600); }
50
+ catch (error) {
51
+ if (error.code === 'EEXIST') return null;
52
+ if (error.code === 'ELOOP') throw notRegular(lockPath);
53
+ if (error.code === 'ENOENT' && createDir) {
54
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
55
+ return tryAcquire(lockPath, false);
56
+ }
57
+ throw error;
58
+ }
59
+ try {
60
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, host: HOST, acquired_at: new Date().toISOString() }));
61
+ return fs.fstatSync(fd);
62
+ } catch (error) {
63
+ fs.rmSync(lockPath, { force: true });
64
+ throw error;
65
+ } finally { fs.closeSync(fd); }
66
+ }
67
+
68
+ function breakIfStale(lockPath, staleMs) {
69
+ let stat;
70
+ try { stat = fs.lstatSync(lockPath); }
71
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
72
+ if (!stat.isFile()) throw notRegular(lockPath);
73
+ if (Date.now() - stat.mtimeMs < staleMs && !holderIsDead(readHolder(lockPath))) return;
74
+ // Move the lock aside so only one breaker wins. If a live holder replaced it
75
+ // after the stat above, the moved file is a different inode: put it back.
76
+ const aside = `${lockPath}.${crypto.randomUUID()}.stale`;
77
+ try { fs.renameSync(lockPath, aside); }
78
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
79
+ const moved = fs.lstatSync(aside);
80
+ if (moved.ino !== stat.ino || moved.dev !== stat.dev) {
81
+ try { fs.linkSync(aside, lockPath); } catch {}
82
+ }
83
+ fs.rmSync(aside, { force: true });
84
+ }
85
+
86
+ // Remove the lock only if it is still the file we created; a lock broken as
87
+ // stale and re-taken by another holder is a different file.
88
+ function release(lockPath, owned) {
89
+ try {
90
+ if (sameFile(fs.lstatSync(lockPath), owned)) fs.unlinkSync(lockPath);
91
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
92
+ }
93
+
94
+ /**
95
+ * Run `fn` while holding the lock file at `lockPath`. With `createDir`, the
96
+ * lock's directory is created (mode 0700) when it is missing.
97
+ */
98
+ export async function withFileLock(lockPath, fn, options = {}) {
99
+ const timeoutMs = options.timeoutMs ?? FILE_LOCK_DEFAULTS.timeoutMs;
100
+ const staleMs = options.staleMs ?? FILE_LOCK_DEFAULTS.staleMs;
101
+ const deadline = Date.now() + timeoutMs;
102
+ let owned = tryAcquire(lockPath, options.createDir);
103
+ for (let delay = 1; !owned; delay = Math.min(delay * 2, 50)) {
104
+ breakIfStale(lockPath, staleMs);
105
+ owned = tryAcquire(lockPath, options.createDir);
106
+ if (owned) break;
107
+ if (Date.now() >= deadline) throw new FileLockTimeoutError(lockPath);
108
+ await new Promise((resolve) => setTimeout(resolve, delay * (1 + Math.random())));
109
+ }
110
+ try { return await fn(); }
111
+ finally { release(lockPath, owned); }
112
+ }
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { withFileLock } from './file-lock.mjs';
5
6
 
6
7
  function key(workspaceId, idempotencyKey) { return JSON.stringify([workspaceId, idempotencyKey]); }
7
8
 
@@ -16,7 +17,11 @@ export class MemoryIdempotencyStore {
16
17
  }
17
18
 
18
19
  export class FileIdempotencyStore {
19
- constructor(root) { fs.mkdirSync(root, { recursive: true }); this.root = fs.realpathSync(root); }
20
+ constructor(root, options = {}) {
21
+ fs.mkdirSync(root, { recursive: true });
22
+ this.root = fs.realpathSync(root);
23
+ this.lockOptions = options.lock || {};
24
+ }
20
25
  #file(workspaceId, idempotencyKey) {
21
26
  const digest = crypto.createHash('sha256').update(key(workspaceId, idempotencyKey)).digest('hex');
22
27
  return path.join(this.root, `${digest}.json`);
@@ -31,12 +36,15 @@ export class FileIdempotencyStore {
31
36
  }
32
37
  async put(workspaceId, idempotencyKey, value) {
33
38
  const file = this.#file(workspaceId, idempotencyKey);
34
- if (fs.existsSync(file)) throw new Error('Idempotency entry already exists.');
35
- const temp = `${file}.${crypto.randomUUID()}.tmp`;
36
- try {
37
- fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
38
- fs.renameSync(temp, file);
39
- } finally { fs.rmSync(temp, { force: true }); }
39
+ // Serialize writers of one key so a second put fails instead of replacing the first.
40
+ return withFileLock(`${file}.lock`, () => {
41
+ if (fs.existsSync(file)) throw new Error('Idempotency entry already exists.');
42
+ const temp = `${file}.${crypto.randomUUID()}.tmp`;
43
+ try {
44
+ fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
45
+ fs.renameSync(temp, file);
46
+ } finally { fs.rmSync(temp, { force: true }); }
47
+ }, this.lockOptions);
40
48
  }
41
49
  }
42
50
 
package/src/leases.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { withFileLock } from './file-lock.mjs';
5
6
 
6
7
  function validateLeaseInput(value) {
7
8
  for (const field of ['workspace_id', 'target', 'principal_id', 'operation_id']) {
@@ -56,10 +57,16 @@ export class FileLeaseStore {
56
57
  fs.mkdirSync(root, { recursive: true });
57
58
  this.root = fs.realpathSync(root);
58
59
  this.clock = options.clock || (() => Date.now());
60
+ this.lockOptions = options.lock || {};
59
61
  }
60
62
  #file(workspaceId, target) {
61
63
  return leaseStatePath(this.root, workspaceId, target);
62
64
  }
65
+ // Mutations of one lease serialize on a lock so "at most one active lease"
66
+ // holds across processes.
67
+ #locked(input, fn) {
68
+ return withFileLock(`${this.#file(input.workspace_id, input.target)}.lock`, fn, this.lockOptions);
69
+ }
63
70
  #load(workspaceId, target) {
64
71
  const file = this.#file(workspaceId, target);
65
72
  if (!fs.existsSync(file)) return null;
@@ -78,11 +85,13 @@ export class FileLeaseStore {
78
85
  }
79
86
  async acquire(input, ttlMs = 30_000) {
80
87
  validateLeaseInput(input);
81
- const existing = this.#load(input.workspace_id, input.target);
82
- if (existing && existing.expires_at > this.clock()) throw new Error('Lease conflict.');
83
- const lease = { ...input, lease_id: input.lease_id || crypto.randomUUID(), issued_at: this.clock(), expires_at: this.clock() + ttlMs };
84
- this.#write(lease);
85
- return { ...lease };
88
+ return this.#locked(input, () => {
89
+ const existing = this.#load(input.workspace_id, input.target);
90
+ if (existing && existing.expires_at > this.clock()) throw new Error('Lease conflict.');
91
+ const lease = { ...input, lease_id: input.lease_id || crypto.randomUUID(), issued_at: this.clock(), expires_at: this.clock() + ttlMs };
92
+ this.#write(lease);
93
+ return { ...lease };
94
+ });
86
95
  }
87
96
  async verify(input) {
88
97
  const lease = this.#load(input.workspace_id, input.target);
@@ -94,17 +103,21 @@ export class FileLeaseStore {
94
103
  return { valid: true, lease };
95
104
  }
96
105
  async renew(input, ttlMs = 30_000) {
97
- const verified = await this.verify(input);
98
- if (!verified.valid) throw new Error(verified.reason);
99
- verified.lease.expires_at = this.clock() + ttlMs;
100
- this.#write(verified.lease);
101
- return verified.lease;
106
+ return this.#locked(input, async () => {
107
+ const verified = await this.verify(input);
108
+ if (!verified.valid) throw new Error(verified.reason);
109
+ verified.lease.expires_at = this.clock() + ttlMs;
110
+ this.#write(verified.lease);
111
+ return verified.lease;
112
+ });
102
113
  }
103
114
  async release(input) {
104
- const verified = await this.verify(input);
105
- if (!verified.valid) throw new Error(verified.reason);
106
- fs.rmSync(this.#file(input.workspace_id, input.target), { force: true });
107
- return { released: true, lease_id: input.lease_id };
115
+ return this.#locked(input, async () => {
116
+ const verified = await this.verify(input);
117
+ if (!verified.valid) throw new Error(verified.reason);
118
+ fs.rmSync(this.#file(input.workspace_id, input.target), { force: true });
119
+ return { released: true, lease_id: input.lease_id };
120
+ });
108
121
  }
109
122
  }
110
123
 
package/src/vfs.mjs CHANGED
@@ -3,6 +3,10 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import { isSafeDocumentPath } from './registry.mjs';
6
+ import { withFileLock } from './file-lock.mjs';
7
+
8
+ /** Reserved FileSystemVfs directory for per-path write locks; never a document path. */
9
+ export const VFS_LOCK_DIR = '.ssss-locks';
6
10
 
7
11
  export class VfsConflictError extends Error {
8
12
  constructor(message) { super(message); this.name = 'VfsConflictError'; this.code = 'SSSS_VFS_CONFLICT'; }
@@ -77,14 +81,28 @@ export class MemoryVfs {
77
81
  }
78
82
 
79
83
  export class FileSystemVfs {
80
- constructor(root) {
84
+ constructor(root, options = {}) {
81
85
  if (!root) throw new Error('FileSystemVfs requires a root directory.');
82
86
  fs.mkdirSync(root, { recursive: true });
83
87
  this.root = fs.realpathSync(root);
88
+ this.lockOptions = options.lock || {};
89
+ }
90
+
91
+ // Writers to one path serialize on a lock so precondition checks and commits
92
+ // are atomic across processes.
93
+ #locked(vfsPath, fn) {
94
+ const dir = path.join(this.root, VFS_LOCK_DIR);
95
+ let stat = null;
96
+ try { stat = fs.lstatSync(dir); } catch (error) { if (error.code !== 'ENOENT') throw error; }
97
+ if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) throw new VfsPathError(`VFS lock directory '${VFS_LOCK_DIR}' must be a real directory.`);
98
+ const name = crypto.createHash('sha256').update(vfsPath).digest('hex');
99
+ return withFileLock(path.join(dir, `${name}.lock`), fn, { ...this.lockOptions, createDir: true });
84
100
  }
85
101
 
86
102
  #resolve(vfsPath, { allowMissing = true } = {}) {
87
103
  if (!isSafeDocumentPath(vfsPath)) throw new VfsPathError(`Unsafe VFS path '${vfsPath}'.`);
104
+ // Case-insensitive: on APFS/NTFS any casing reaches the same directory.
105
+ if (vfsPath.split('/')[0].toLowerCase() === VFS_LOCK_DIR) throw new VfsPathError(`VFS path '${vfsPath}' is reserved.`);
88
106
  const target = path.resolve(this.root, ...vfsPath.split('/'));
89
107
  if (target === this.root || !target.startsWith(`${this.root}${path.sep}`)) {
90
108
  throw new VfsPathError(`VFS path '${vfsPath}' escapes the root.`);
@@ -131,6 +149,7 @@ export class FileSystemVfs {
131
149
  while (stack.length) {
132
150
  const dir = stack.pop();
133
151
  for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
152
+ if (dir === this.root && entry.name === VFS_LOCK_DIR) continue;
134
153
  const absolute = path.join(dir, entry.name);
135
154
  const relative = path.relative(this.root, absolute).split(path.sep).join('/');
136
155
  if (entry.isSymbolicLink()) throw new VfsPathError(`Symlinked VFS entry is forbidden: '${relative}'.`);
@@ -144,6 +163,10 @@ export class FileSystemVfs {
144
163
 
145
164
  async writeAtomic(vfsPath, bytes, precondition = {}) {
146
165
  const target = this.#resolve(vfsPath);
166
+ return this.#locked(vfsPath, () => this.#commit(vfsPath, target, bytes, precondition));
167
+ }
168
+
169
+ async #commit(vfsPath, target, bytes, precondition) {
147
170
  const current = await this.read(vfsPath);
148
171
  checkPrecondition(current, precondition);
149
172
  fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -171,12 +194,15 @@ export class FileSystemVfs {
171
194
  }
172
195
 
173
196
  async remove(vfsPath, precondition = {}) {
174
- const target = this.#resolve(vfsPath, { allowMissing: false });
175
- const current = await this.read(vfsPath);
176
- checkPrecondition(current, precondition);
177
- if (!target || !current) return { path: vfsPath, removed: false };
178
- fs.rmSync(target);
179
- return { path: vfsPath, removed: true, previous_hash: current.hash };
197
+ this.#resolve(vfsPath);
198
+ return this.#locked(vfsPath, async () => {
199
+ const target = this.#resolve(vfsPath, { allowMissing: false });
200
+ const current = await this.read(vfsPath);
201
+ checkPrecondition(current, precondition);
202
+ if (!target || !current) return { path: vfsPath, removed: false };
203
+ fs.rmSync(target);
204
+ return { path: vfsPath, removed: true, previous_hash: current.hash };
205
+ });
180
206
  }
181
207
  }
182
208