@skanl/brambo-lock 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SKANL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @skanl/brambo-lock
2
+
3
+ The hand-rolled portable lockfile protocol brambo uses to serialize writes to a
4
+ file across PROCESSES: exclusive create, a holder document written through the
5
+ creating handle, stale-lock breaking with reported evidence, and an
6
+ ownership-safe release that restores a successor's lock rather than deleting it.
7
+
8
+ It is a LEAF. It depends on `@skanl/brambo-contracts` and nothing else (AD-2), and it
9
+ raises its own neutral codes — `BRAMBO_LOCK_CONTENTION` and
10
+ `BRAMBO_LOCK_UNAVAILABLE` — so no consumer inherits another package's vocabulary
11
+ (AD-7). `@skanl/brambo-registry` and `@skanl/brambo-projection` each translate those two codes
12
+ into their own at their own boundary.
13
+
14
+ This code was moved here from `@skanl/brambo-registry`, where it had been the store's
15
+ private serialization since Story 2.x. Nothing about the algorithm changed;
16
+ duplicating it into a second consumer was refused because two copies of
17
+ concurrency-critical code drifting is the failure a lock exists to prevent.
@@ -0,0 +1,2 @@
1
+ export { acquireLock } from './lock.ts';
2
+ export type { FileLock, LockHolder, LockOptions, StaleLockBreak } from './lock.ts';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { acquireLock } from './lock.js';
package/dist/lock.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ export interface LockHolder {
2
+ readonly pid: number;
3
+ readonly host: string;
4
+ readonly acquiredAt: string;
5
+ /** Random acquire-time identity used by release to never unlink a successor's lock. */
6
+ readonly token: string;
7
+ }
8
+ export interface StaleLockBreak {
9
+ readonly path: string;
10
+ /** Holder metadata when the lockfile was readable; undefined when corrupt. */
11
+ readonly holder: LockHolder | undefined;
12
+ readonly evidence: string;
13
+ }
14
+ export interface LockOptions {
15
+ /** Bounded wait before giving up with CONTENTION. */
16
+ readonly timeoutMs?: number;
17
+ readonly pollMs?: number;
18
+ /** Same-host locks older than this are broken even if their pid looks alive. */
19
+ readonly maxAgeMs?: number;
20
+ /** Corrupt lockfiles younger than this are treated as held (not broken). */
21
+ readonly corruptGraceMs?: number;
22
+ /** Observes every stale/corrupt-lock break performed on the way to acquisition. */
23
+ readonly onStaleBreak?: (broken: StaleLockBreak) => void;
24
+ /**
25
+ * Injection seam for release-race tests: invoked after release renames the
26
+ * lockfile away but before it verifies ownership of the renamed file.
27
+ */
28
+ readonly beforeReleaseVerify?: (renamedPath: string) => void | Promise<void>;
29
+ /**
30
+ * Injection seam for break-race tests: invoked after the stale break renames
31
+ * the lockfile away but before it verifies that the renamed file still
32
+ * carries the identity judged stale. AWAITED, exactly like
33
+ * `beforeReleaseVerify` — a seam that is not awaited lets a clause BET on a
34
+ * successor's write winning a race instead of forcing it.
35
+ */
36
+ readonly beforeBreakVerify?: (renamedPath: string) => void | Promise<void>;
37
+ }
38
+ export interface FileLock {
39
+ readonly path: string;
40
+ readonly holder: LockHolder;
41
+ release(): Promise<void>;
42
+ }
43
+ /**
44
+ * Acquires the lockfile at `path`, polling up to `timeoutMs`. The holder
45
+ * document is written through the SAME exclusive handle that created the file
46
+ * and only then closed — a contender can never observe an empty lock created
47
+ * by us; if that write fails we remove our own lockfile and rethrow coded.
48
+ */
49
+ export declare function acquireLock(path: string, options?: LockOptions): Promise<FileLock>;
package/dist/lock.js ADDED
@@ -0,0 +1,298 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { open, readFile, rename, stat, unlink } from 'node:fs/promises';
3
+ import { hostname } from 'node:os';
4
+ import { BramboError, BRAMBO_ERROR_CODES } from '@skanl/brambo-contracts';
5
+ // Hand-rolled portable lockfile protocol for machine-scoped write serialization
6
+ // (no locking dependency). A lock is a file at the caller's chosen path, created
7
+ // with O_EXCL semantics so exactly one contender wins per host, containing JSON
8
+ // `{ pid, host, acquiredAt, token }`. Contenders poll until a bounded timeout
9
+ // and then fail with a typed CONTENTION error naming the holder.
10
+ //
11
+ // This code was MOVED here from `@skanl/brambo-registry`, unchanged apart from its
12
+ // error codes and the word "registry" leaving its messages. It is a leaf: it
13
+ // depends on `@skanl/brambo-contracts` and nothing else (AD-2), so any package can
14
+ // serialize writes to a file without importing a sibling's domain — which is
15
+ // what `@skanl/brambo-projection`'s ledger needed and could not have.
16
+ //
17
+ // The codes are NEUTRAL on purpose (AD-7): a lock owned by no domain may not
18
+ // raise another package's code. Callers translate `lockContention` and
19
+ // `lockUnavailable` into their own vocabulary at their own boundary, which is
20
+ // how `@skanl/brambo-registry` goes on raising exactly the two codes it always did.
21
+ //
22
+ // Staleness rules:
23
+ // - SAME HOST: the holder pid is provably dead (`process.kill(pid, 0)` fails
24
+ // with ESRCH), OR the lock age exceeds `maxAgeMs` — the age fallback defends
25
+ // against pid reuse on long-lived machines.
26
+ // - ANY HOST: a CORRUPT lockfile (unreadable JSON, malformed holder fields) is
27
+ // breakable once its file age exceeds `corruptGraceMs`; before that window
28
+ // contenders get CONTENTION with an 'unreadable lockfile' detail.
29
+ // A healthy cross-host lock is never considered stale — we cannot see other
30
+ // machines' processes.
31
+ const DEFAULT_LOCK_TIMEOUT_MS = 2_000;
32
+ const DEFAULT_LOCK_POLL_MS = 25;
33
+ const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
34
+ const DEFAULT_CORRUPT_GRACE_MS = 60 * 1000;
35
+ function currentHolder() {
36
+ const pid = process.pid;
37
+ // Never write a holder document a staleness check could not trust.
38
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
39
+ throw new BramboError(BRAMBO_ERROR_CODES.lockUnavailable, `cannot acquire lock: process pid ${pid} is not a positive integer`);
40
+ }
41
+ return { pid, host: hostname(), acquiredAt: new Date().toISOString(), token: randomUUID() };
42
+ }
43
+ async function readLockFile(path) {
44
+ let raw;
45
+ try {
46
+ raw = await readFile(path, 'utf8');
47
+ }
48
+ catch (error) {
49
+ // The holder released between our failed create and this read; treat as no lock.
50
+ if (error?.code === 'ENOENT')
51
+ return { kind: 'missing' };
52
+ throw unavailable('read', path, error);
53
+ }
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ if (typeof parsed.pid !== 'number' ||
57
+ !Number.isSafeInteger(parsed.pid) ||
58
+ parsed.pid <= 0 ||
59
+ typeof parsed.host !== 'string' ||
60
+ typeof parsed.acquiredAt !== 'string' ||
61
+ typeof parsed.token !== 'string') {
62
+ return { kind: 'corrupt', reason: 'lockfile does not contain a valid holder document', raw };
63
+ }
64
+ return {
65
+ kind: 'held',
66
+ holder: {
67
+ pid: parsed.pid,
68
+ host: parsed.host,
69
+ acquiredAt: parsed.acquiredAt,
70
+ token: parsed.token,
71
+ },
72
+ };
73
+ }
74
+ catch {
75
+ return { kind: 'corrupt', reason: 'lockfile contains truncated or invalid JSON', raw };
76
+ }
77
+ }
78
+ function isHolderDead(pid) {
79
+ try {
80
+ process.kill(pid, 0);
81
+ return false;
82
+ }
83
+ catch (error) {
84
+ // EPERM means the process exists but is owned by another user: alive.
85
+ return error?.code === 'ESRCH';
86
+ }
87
+ }
88
+ function sleep(ms) {
89
+ return new Promise((resolve) => setTimeout(resolve, ms));
90
+ }
91
+ function contention(path, state) {
92
+ if (state.kind === 'corrupt') {
93
+ return new BramboError(BRAMBO_ERROR_CODES.lockContention, `lock '${path}' names an unreadable lockfile (${state.reason}); will become breakable after the corrupt grace period`);
94
+ }
95
+ const named = state.kind === 'held' ? `${state.holder.pid}@${state.holder.host}` : 'a vanished lockfile';
96
+ return new BramboError(BRAMBO_ERROR_CODES.lockContention, `lock '${path}' is held by ${named}; another brambo process is mid-mutation`);
97
+ }
98
+ function unavailable(operation, path, cause) {
99
+ return new BramboError(BRAMBO_ERROR_CODES.lockUnavailable, `lock ${operation} failed on '${path}': ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
100
+ }
101
+ function carriesIdentity(raw, identity) {
102
+ if (identity.kind === 'bytes')
103
+ return raw === identity.bytes;
104
+ try {
105
+ return JSON.parse(raw)?.token === identity.token;
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ /**
112
+ * Ownership-safe stale break, the mirror of `releaseAcquired` below: RENAME the
113
+ * lockfile away FIRST — that rename IS the mutual exclusion, because exactly one
114
+ * process can move a given file away and every loser gets ENOENT — then re-read
115
+ * the renamed file and unlink it ONLY if it still carries the identity that was
116
+ * judged stale; otherwise a live successor's lock was moved and must be put
117
+ * back. Checking ownership before an unlink would be the same TOCTOU one line
118
+ * later; only the rename makes two breakers impossible.
119
+ *
120
+ * Returns true when the stale lock was really removed, false when nothing was
121
+ * broken — another breaker won the rename, or a successor's lock was restored.
122
+ * The caller must not report a break it did not perform.
123
+ *
124
+ * ponytail: the restore has a window — the rename frees `path`, so a third
125
+ * contender can create a lock there and the restore would rename over it. NOT
126
+ * closed here, on purpose: `releaseAcquired` has carried the IDENTICAL window
127
+ * since Story 2.1 and it is where M32.A's once-in-160 `EPERM` comes from. On
128
+ * Windows a rename onto an existing open file FAILS, so the symptom is a loud
129
+ * coded `EPERM` and not silent loss; on POSIX `rename` SILENTLY REPLACES the
130
+ * destination, so the same window is quieter on Linux — which is what CI runs.
131
+ * Recorded in `deferred-work.md`; it is the first thing to look at if a claim is
132
+ * ever lost after this ships.
133
+ */
134
+ async function breakLock(path, identity, options) {
135
+ const breakingPath = `${path}.${randomUUID()}.breaking`;
136
+ let raw;
137
+ try {
138
+ await rename(path, breakingPath);
139
+ await options.beforeBreakVerify?.(breakingPath);
140
+ raw = await readFile(breakingPath, 'utf8');
141
+ }
142
+ catch (error) {
143
+ // ENOENT on the rename: someone else already broke it. ENOENT on the read:
144
+ // it vanished under us. Either way we broke nothing.
145
+ if (error?.code === 'ENOENT')
146
+ return false;
147
+ throw unavailable('stale-break', path, error);
148
+ }
149
+ if (carriesIdentity(raw, identity)) {
150
+ try {
151
+ await unlink(breakingPath);
152
+ }
153
+ catch (error) {
154
+ if (error?.code !== 'ENOENT') {
155
+ throw unavailable('stale-break', path, error);
156
+ }
157
+ }
158
+ return true;
159
+ }
160
+ // Not the lock we judged: a successor acquired inside the break window. Put
161
+ // their lock back so it keeps protecting the store, and report no break.
162
+ await rename(breakingPath, path).catch((error) => {
163
+ if (error.code !== 'ENOENT')
164
+ throw unavailable('stale-break', path, error);
165
+ });
166
+ return false;
167
+ }
168
+ /**
169
+ * Acquires the lockfile at `path`, polling up to `timeoutMs`. The holder
170
+ * document is written through the SAME exclusive handle that created the file
171
+ * and only then closed — a contender can never observe an empty lock created
172
+ * by us; if that write fails we remove our own lockfile and rethrow coded.
173
+ */
174
+ export async function acquireLock(path, options = {}) {
175
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
176
+ const pollMs = Math.max(1, options.pollMs ?? DEFAULT_LOCK_POLL_MS);
177
+ const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
178
+ const corruptGraceMs = options.corruptGraceMs ?? DEFAULT_CORRUPT_GRACE_MS;
179
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || !Number.isFinite(maxAgeMs) || maxAgeMs < 0) {
180
+ throw new BramboError(BRAMBO_ERROR_CODES.lockUnavailable, `invalid lock options for '${path}': timeoutMs and maxAgeMs must be finite non-negative numbers`);
181
+ }
182
+ const deadline = Date.now() + timeoutMs;
183
+ for (;;) {
184
+ const handle = await open(path, 'wx').catch((error) => {
185
+ if (error.code === 'EEXIST')
186
+ return undefined;
187
+ throw unavailable('create', path, error);
188
+ });
189
+ if (handle !== undefined) {
190
+ const holder = currentHolder();
191
+ try {
192
+ await handle.writeFile(JSON.stringify(holder));
193
+ }
194
+ catch (error) {
195
+ // Orphan-proofing: never leave our own empty lock behind.
196
+ await unlink(path).catch(() => { });
197
+ throw unavailable('write', path, error);
198
+ }
199
+ finally {
200
+ await handle.close();
201
+ }
202
+ return {
203
+ path,
204
+ holder,
205
+ async release() {
206
+ await releaseAcquired(path, holder, options);
207
+ },
208
+ };
209
+ }
210
+ const state = await readLockFile(path);
211
+ if (state.kind === 'held') {
212
+ const sameHost = state.holder.host === hostname();
213
+ const ageExceeded = Date.now() - Date.parse(state.holder.acquiredAt) > maxAgeMs;
214
+ if (sameHost && (isHolderDead(state.holder.pid) || ageExceeded)) {
215
+ const why = isHolderDead(state.holder.pid)
216
+ ? `holder ${state.holder.pid}@${state.holder.host} is provably dead on this machine (process.kill(${state.holder.pid}, 0) === ESRCH)`
217
+ : `holder ${state.holder.pid}@${state.holder.host} exceeded maxAgeMs (${maxAgeMs}ms); pid may have been reused`;
218
+ const broken = { path, holder: state.holder, evidence: `${why}; breaking stale lock` };
219
+ // Identity judged stale here: the holder TOKEN of the process we proved
220
+ // dead. Anything else on that path is somebody else's live lock.
221
+ if (await breakLock(path, { kind: 'token', token: state.holder.token }, options)) {
222
+ options.onStaleBreak?.(broken);
223
+ continue;
224
+ }
225
+ // Nothing was broken — a successor holds the path now, or another
226
+ // breaker won. Fall through and contend like anyone else: wait, or time
227
+ // out coded. Reporting a break we did not perform would be a lie.
228
+ }
229
+ }
230
+ else if (state.kind === 'corrupt') {
231
+ const mtimeMs = await stat(path).then((info) => info.mtimeMs, (error) => {
232
+ // Vanished between the read and the stat: retry from the top.
233
+ if (error.code === 'ENOENT')
234
+ return Number.NaN;
235
+ throw unavailable('stat', path, error);
236
+ });
237
+ if (!Number.isNaN(mtimeMs) && Date.now() - mtimeMs > corruptGraceMs) {
238
+ const broken = {
239
+ path,
240
+ holder: undefined,
241
+ evidence: `lockfile is corrupt (${state.reason}) and older than corruptGraceMs (${corruptGraceMs}ms); breaking regardless of host`,
242
+ };
243
+ // A corrupt lockfile carries no token, so the identity to verify is the
244
+ // raw document BYTES read at judgement time — a different rule than the
245
+ // dead-pid site above. This is the more dangerous of the two sites: the
246
+ // evidence above says "regardless of host", so a delete-by-path here
247
+ // could destroy a CROSS-HOST successor's live lock.
248
+ if (await breakLock(path, { kind: 'bytes', bytes: state.raw }, options)) {
249
+ options.onStaleBreak?.(broken);
250
+ continue;
251
+ }
252
+ }
253
+ }
254
+ else {
255
+ // Missing: released between our failed create and the read; retry at once.
256
+ continue;
257
+ }
258
+ if (Date.now() >= deadline)
259
+ throw contention(path, state);
260
+ await sleep(pollMs);
261
+ }
262
+ }
263
+ /**
264
+ * Ownership-safe release: rename the lockfile away FIRST, then re-read the
265
+ * renamed file and unlink it ONLY if it still carries our token; otherwise we
266
+ * lost an acquisition race to a successor and must put their lock back.
267
+ */
268
+ async function releaseAcquired(path, holder, options) {
269
+ const renamedPath = `${path}.${randomUUID()}.releasing`;
270
+ let current;
271
+ try {
272
+ await rename(path, renamedPath);
273
+ await options.beforeReleaseVerify?.(renamedPath);
274
+ current = await readLockFile(renamedPath);
275
+ }
276
+ catch (error) {
277
+ if (error?.code === 'ENOENT')
278
+ return;
279
+ throw unavailable('release', path, error);
280
+ }
281
+ if (current.kind === 'held' && current.holder.token === holder.token) {
282
+ try {
283
+ await unlink(renamedPath);
284
+ }
285
+ catch (error) {
286
+ if (error?.code !== 'ENOENT') {
287
+ throw unavailable('release', path, error);
288
+ }
289
+ }
290
+ return;
291
+ }
292
+ // Not ours anymore (or corrupt): restore whatever we renamed so the real
293
+ // holder's lock keeps protecting the store.
294
+ await rename(renamedPath, path).catch((error) => {
295
+ if (error.code !== 'ENOENT')
296
+ throw unavailable('release', path, error);
297
+ });
298
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@skanl/brambo-lock",
3
+ "version": "0.1.1",
4
+ "description": "The portable lockfile protocol: machine-scoped write serialization for any package, with neutral codes owned by no domain.",
5
+ "keywords": [
6
+ "ai-agent",
7
+ "brambo",
8
+ "lockfile",
9
+ "cross-process",
10
+ "mutual-exclusion"
11
+ ],
12
+ "homepage": "https://github.com/SKANL/brambo#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/SKANL/brambo/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/SKANL/brambo.git",
19
+ "directory": "packages/lock"
20
+ },
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "brambo-source": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "@skanl/brambo-contracts": "0.1.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.13.3",
41
+ "typescript": "~7.0.2",
42
+ "vitest": "^4.1.11"
43
+ },
44
+ "files": [
45
+ "dist"
46
+ ],
47
+ "scripts": {
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest run",
50
+ "lint": "eslint .",
51
+ "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
52
+ }
53
+ }