@vincemakes/kiso-runtime 0.1.37 → 1.0.0
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/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lock-adapter.d.ts +92 -0
- package/dist/lock-adapter.js +339 -0
- package/dist/store.d.ts +41 -33
- package/dist/store.js +69 -236
- package/package.json +5 -5
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockAdapter — the cross-process single-writer lock contract (R-G 0.1.47,
|
|
3
|
+
* ADR-0050).
|
|
4
|
+
*
|
|
5
|
+
* The default adapter is the identity-confirmed link lock — a pure Node
|
|
6
|
+
* mechanism. Node's stdlib has no advisory-lock primitive (the round-4
|
|
7
|
+
* python3 flock helper is the dependency this round retires), so possession
|
|
8
|
+
* of `<id>.lock` is decided by atomic filesystem operations, and the file
|
|
9
|
+
* itself carries the holder's identity.
|
|
10
|
+
*
|
|
11
|
+
* The protocol (the decision path is synchronous — no awaits between the
|
|
12
|
+
* read and the act):
|
|
13
|
+
*
|
|
14
|
+
* acquire — read the identity; if it is absent or names a dead pid (fresh
|
|
15
|
+
* path / dead holder / stale residue): rename the path away (atomic —
|
|
16
|
+
* exactly one contender wins), re-read what was moved, and if it differs
|
|
17
|
+
* from what was judged (a rival's LIVE file got moved) abort the takeover
|
|
18
|
+
* and restore-or-keep. Then write MY identity to a temp file, fsync it,
|
|
19
|
+
* and LINK it at the final path (atomic create-if-absent). The link is
|
|
20
|
+
* the only way the final path ever exists — a kill can never leave an
|
|
21
|
+
* empty or half-written lock (see the fsync note below). A live foreign
|
|
22
|
+
* identity refuses immediately; a live identity naming OUR OWN process is
|
|
23
|
+
* a same-process writer's residue (round 5) and is retried until its
|
|
24
|
+
* release or the cap.
|
|
25
|
+
*
|
|
26
|
+
* verify — possession is re-checked at every append: the file must still
|
|
27
|
+
* name this handle's pid AND token. Failure is a STRICT refusal — no
|
|
28
|
+
* retry, no wait heuristic (ADR-0050: the guard must be reason-able; a
|
|
29
|
+
* displaced holder fails honestly and the session resumes from a fresh
|
|
30
|
+
* store — never two writers).
|
|
31
|
+
*
|
|
32
|
+
* release — rename my file away, confirm it is mine, leave the EMPTY
|
|
33
|
+
* released marker (the path is never deleted — a contender must be able
|
|
34
|
+
* to read it), remove the tombstone. A moved rival's file is
|
|
35
|
+
* restored-or-kept, never clobbered.
|
|
36
|
+
*
|
|
37
|
+
* The fsync-before-link order is LOAD-BEARING: the temp file is fully
|
|
38
|
+
* written AND fsynced before the final name is linked, so a power loss can
|
|
39
|
+
* never produce an empty or half-written file at the final path — the
|
|
40
|
+
* inode's data is durable before the name exists. The final path itself
|
|
41
|
+
* needs no directory fsync: if the name is lost in a crash, the holder
|
|
42
|
+
* died with it, and the residue is acquirable (ADR-0050 §crash-durability).
|
|
43
|
+
*
|
|
44
|
+
* The identity FILE format is the cross-version channel (unchanged from
|
|
45
|
+
* round 4): modern `{"pid": number, "token": string}`, legacy bare-pid
|
|
46
|
+
* (string or JSON number), empty (the released marker / a legacy-format
|
|
47
|
+
* writer's create window), half-written (crash residue). A legacy-format
|
|
48
|
+
* writer sees a live modern identity and refuses to take over; we refuse a
|
|
49
|
+
* live foreign legacy pid. Empty and half-written files are taken over as
|
|
50
|
+
* residue — under the documented QUARANTINE upgrade contract (round 5
|
|
51
|
+
* P1-4), no live legacy holder exists to be split (ADR-0050 §migration).
|
|
52
|
+
*
|
|
53
|
+
* Hard-link dependence: linkSync requires a link-capable filesystem
|
|
54
|
+
* (macOS/Linux/Windows NTFS). EPERM/ENOTSUP is an honest
|
|
55
|
+
* LockUnavailableError carrying the errno — never a silent degradation to
|
|
56
|
+
* a weaker scheme (that would re-open the empty-file window, ADR-0050).
|
|
57
|
+
*
|
|
58
|
+
* Test-only affordances (KISO_LOCK_TEST_* env, default off): the race
|
|
59
|
+
* gates (native-lock-race.test.ts) freeze a contender between the read and
|
|
60
|
+
* the rename-away, and between the rename-away and the verify, via fixed
|
|
61
|
+
* pauses plus SIGSTOP/SIGCONT, and locate the freeze points via
|
|
62
|
+
* ready-marker files (ADR-0050 §test affordances).
|
|
63
|
+
*/
|
|
64
|
+
/** A live foreign writer owns the lock — never taken over. */
|
|
65
|
+
export declare class LockedError extends Error {
|
|
66
|
+
constructor(message: string);
|
|
67
|
+
}
|
|
68
|
+
/** The mechanism cannot operate (fs/link failure) — never a lock conflict. */
|
|
69
|
+
export declare class LockUnavailableError extends Error {
|
|
70
|
+
constructor(reason: string);
|
|
71
|
+
}
|
|
72
|
+
export interface LockHandle {
|
|
73
|
+
readonly pid: number;
|
|
74
|
+
readonly token: string;
|
|
75
|
+
/** True iff the lock file at the path still names THIS identity. */
|
|
76
|
+
verify(): boolean;
|
|
77
|
+
/** Idempotent release; the path is left as the empty released marker. */
|
|
78
|
+
release(): void;
|
|
79
|
+
}
|
|
80
|
+
export interface LockAdapter {
|
|
81
|
+
readonly name: string;
|
|
82
|
+
/**
|
|
83
|
+
* Take (or take over) the lock at lockPath. Rejects with LockedError
|
|
84
|
+
* when a live foreign writer owns it (or the same-process-residue retry
|
|
85
|
+
* cap is hit), LockUnavailableError when the mechanism cannot operate.
|
|
86
|
+
* cancelled() is invoked at each retry decision and may throw to abort
|
|
87
|
+
* the acquisition (the store's lifecycle barrier).
|
|
88
|
+
*/
|
|
89
|
+
acquire(lockPath: string, sessionId: string, cancelled: () => void): Promise<LockHandle>;
|
|
90
|
+
}
|
|
91
|
+
/** The default adapter: the identity-confirmed link lock (ADR-0050). */
|
|
92
|
+
export declare const nativeLockAdapter: LockAdapter;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LockAdapter — the cross-process single-writer lock contract (R-G 0.1.47,
|
|
3
|
+
* ADR-0050).
|
|
4
|
+
*
|
|
5
|
+
* The default adapter is the identity-confirmed link lock — a pure Node
|
|
6
|
+
* mechanism. Node's stdlib has no advisory-lock primitive (the round-4
|
|
7
|
+
* python3 flock helper is the dependency this round retires), so possession
|
|
8
|
+
* of `<id>.lock` is decided by atomic filesystem operations, and the file
|
|
9
|
+
* itself carries the holder's identity.
|
|
10
|
+
*
|
|
11
|
+
* The protocol (the decision path is synchronous — no awaits between the
|
|
12
|
+
* read and the act):
|
|
13
|
+
*
|
|
14
|
+
* acquire — read the identity; if it is absent or names a dead pid (fresh
|
|
15
|
+
* path / dead holder / stale residue): rename the path away (atomic —
|
|
16
|
+
* exactly one contender wins), re-read what was moved, and if it differs
|
|
17
|
+
* from what was judged (a rival's LIVE file got moved) abort the takeover
|
|
18
|
+
* and restore-or-keep. Then write MY identity to a temp file, fsync it,
|
|
19
|
+
* and LINK it at the final path (atomic create-if-absent). The link is
|
|
20
|
+
* the only way the final path ever exists — a kill can never leave an
|
|
21
|
+
* empty or half-written lock (see the fsync note below). A live foreign
|
|
22
|
+
* identity refuses immediately; a live identity naming OUR OWN process is
|
|
23
|
+
* a same-process writer's residue (round 5) and is retried until its
|
|
24
|
+
* release or the cap.
|
|
25
|
+
*
|
|
26
|
+
* verify — possession is re-checked at every append: the file must still
|
|
27
|
+
* name this handle's pid AND token. Failure is a STRICT refusal — no
|
|
28
|
+
* retry, no wait heuristic (ADR-0050: the guard must be reason-able; a
|
|
29
|
+
* displaced holder fails honestly and the session resumes from a fresh
|
|
30
|
+
* store — never two writers).
|
|
31
|
+
*
|
|
32
|
+
* release — rename my file away, confirm it is mine, leave the EMPTY
|
|
33
|
+
* released marker (the path is never deleted — a contender must be able
|
|
34
|
+
* to read it), remove the tombstone. A moved rival's file is
|
|
35
|
+
* restored-or-kept, never clobbered.
|
|
36
|
+
*
|
|
37
|
+
* The fsync-before-link order is LOAD-BEARING: the temp file is fully
|
|
38
|
+
* written AND fsynced before the final name is linked, so a power loss can
|
|
39
|
+
* never produce an empty or half-written file at the final path — the
|
|
40
|
+
* inode's data is durable before the name exists. The final path itself
|
|
41
|
+
* needs no directory fsync: if the name is lost in a crash, the holder
|
|
42
|
+
* died with it, and the residue is acquirable (ADR-0050 §crash-durability).
|
|
43
|
+
*
|
|
44
|
+
* The identity FILE format is the cross-version channel (unchanged from
|
|
45
|
+
* round 4): modern `{"pid": number, "token": string}`, legacy bare-pid
|
|
46
|
+
* (string or JSON number), empty (the released marker / a legacy-format
|
|
47
|
+
* writer's create window), half-written (crash residue). A legacy-format
|
|
48
|
+
* writer sees a live modern identity and refuses to take over; we refuse a
|
|
49
|
+
* live foreign legacy pid. Empty and half-written files are taken over as
|
|
50
|
+
* residue — under the documented QUARANTINE upgrade contract (round 5
|
|
51
|
+
* P1-4), no live legacy holder exists to be split (ADR-0050 §migration).
|
|
52
|
+
*
|
|
53
|
+
* Hard-link dependence: linkSync requires a link-capable filesystem
|
|
54
|
+
* (macOS/Linux/Windows NTFS). EPERM/ENOTSUP is an honest
|
|
55
|
+
* LockUnavailableError carrying the errno — never a silent degradation to
|
|
56
|
+
* a weaker scheme (that would re-open the empty-file window, ADR-0050).
|
|
57
|
+
*
|
|
58
|
+
* Test-only affordances (KISO_LOCK_TEST_* env, default off): the race
|
|
59
|
+
* gates (native-lock-race.test.ts) freeze a contender between the read and
|
|
60
|
+
* the rename-away, and between the rename-away and the verify, via fixed
|
|
61
|
+
* pauses plus SIGSTOP/SIGCONT, and locate the freeze points via
|
|
62
|
+
* ready-marker files (ADR-0050 §test affordances).
|
|
63
|
+
*/
|
|
64
|
+
import { closeSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
65
|
+
import { randomUUID } from "node:crypto";
|
|
66
|
+
import { join } from "node:path";
|
|
67
|
+
/** A live foreign writer owns the lock — never taken over. */
|
|
68
|
+
export class LockedError extends Error {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "LockedError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The mechanism cannot operate (fs/link failure) — never a lock conflict. */
|
|
75
|
+
export class LockUnavailableError extends Error {
|
|
76
|
+
constructor(reason) {
|
|
77
|
+
super(`session locking unavailable: ${reason}`);
|
|
78
|
+
this.name = "LockUnavailableError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function isAlive(pid) {
|
|
82
|
+
try {
|
|
83
|
+
process.kill(pid, 0);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return err.code === "EPERM";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Read a lock file's holder identity (round 4 formats, unchanged — the
|
|
92
|
+
* cross-version channel). Empty, unreadable, or half-written locks have no
|
|
93
|
+
* identity: they are residue, taken over (ADR-0050 §migration).
|
|
94
|
+
*/
|
|
95
|
+
function readLockIdentity(lockPath) {
|
|
96
|
+
let raw;
|
|
97
|
+
try {
|
|
98
|
+
raw = readFileSync(lockPath, "utf8");
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const trimmed = raw.trim();
|
|
104
|
+
if (trimmed === "")
|
|
105
|
+
return null;
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(trimmed);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
parsed = trimmed; // half-written JSON — try as a bare pid
|
|
112
|
+
}
|
|
113
|
+
if (typeof parsed === "number" && Number.isInteger(parsed)) {
|
|
114
|
+
return { pid: parsed }; // JSON.parse("123") — a legacy bare pid
|
|
115
|
+
}
|
|
116
|
+
if (typeof parsed === "string") {
|
|
117
|
+
const pid = Number.parseInt(parsed, 10);
|
|
118
|
+
return Number.isFinite(pid) ? { pid } : null;
|
|
119
|
+
}
|
|
120
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
121
|
+
const v = parsed;
|
|
122
|
+
return {
|
|
123
|
+
...(typeof v.pid === "number" ? { pid: v.pid } : {}),
|
|
124
|
+
...(typeof v.token === "string" ? { token: v.token } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function sameIdentity(a, b) {
|
|
130
|
+
if (a === null && b === null)
|
|
131
|
+
return true;
|
|
132
|
+
if (a === null || b === null)
|
|
133
|
+
return false;
|
|
134
|
+
return a.pid === b.pid && (a.token ?? null) === (b.token ?? null);
|
|
135
|
+
}
|
|
136
|
+
function unavailable(err) {
|
|
137
|
+
const e = err;
|
|
138
|
+
return new LockUnavailableError(`${e.code ?? "?"}: ${e.message}`);
|
|
139
|
+
}
|
|
140
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
141
|
+
class NativeLockHandle {
|
|
142
|
+
pid;
|
|
143
|
+
token;
|
|
144
|
+
#lockPath;
|
|
145
|
+
#tombstone;
|
|
146
|
+
#released = false;
|
|
147
|
+
constructor(lockPath, tombstone, identity) {
|
|
148
|
+
this.#lockPath = lockPath;
|
|
149
|
+
this.#tombstone = tombstone;
|
|
150
|
+
this.pid = identity.pid;
|
|
151
|
+
this.token = identity.token;
|
|
152
|
+
}
|
|
153
|
+
verify() {
|
|
154
|
+
if (this.#released)
|
|
155
|
+
return false;
|
|
156
|
+
const now = readLockIdentity(this.#lockPath);
|
|
157
|
+
return now !== null && now.pid === this.pid && now.token === this.token;
|
|
158
|
+
}
|
|
159
|
+
release() {
|
|
160
|
+
if (this.#released)
|
|
161
|
+
return;
|
|
162
|
+
this.#released = true;
|
|
163
|
+
try {
|
|
164
|
+
renameSync(this.#lockPath, this.#tombstone);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (err.code === "ENOENT")
|
|
168
|
+
return; // nothing of ours at the path
|
|
169
|
+
// A fs-level failure: fall back to clearing in place (advisory
|
|
170
|
+
// best effort — a missing marker can only wedge same-process
|
|
171
|
+
// residue, never split a writer).
|
|
172
|
+
try {
|
|
173
|
+
writeFileSync(this.#lockPath, "");
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// advisory only
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const moved = readLockIdentity(this.#tombstone);
|
|
181
|
+
if (moved !== null && moved.pid === this.pid && moved.token === this.token) {
|
|
182
|
+
// Mine. Leave the EMPTY released marker — the path is never
|
|
183
|
+
// deleted (a contender must be able to read it; the
|
|
184
|
+
// storage-identity suite pins it) — created ONLY-if-absent, so
|
|
185
|
+
// a rival that linked in the window keeps its lock untouched.
|
|
186
|
+
try {
|
|
187
|
+
const fd = openSync(this.#lockPath, "wx");
|
|
188
|
+
closeSync(fd);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
// EEXIST → a rival's lock stands; anything else → advisory.
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
unlinkSync(this.#tombstone);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// the stray tombstone is inert
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// A rival's file was moved (the displacement cascade, ADR-0050
|
|
202
|
+
// §residual) — restore-or-keep, never clobber: link it back
|
|
203
|
+
// only-if-absent; on EEXIST the path already carries a newer lock
|
|
204
|
+
// and the moved file stays at its distinct name (inert residue).
|
|
205
|
+
try {
|
|
206
|
+
linkSync(this.#tombstone, this.#lockPath);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// EEXIST or fs failure — inert
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
class NativeLock {
|
|
214
|
+
name = "link-lock";
|
|
215
|
+
async acquire(lockPath, sessionId, cancelled) {
|
|
216
|
+
const pid = process.pid;
|
|
217
|
+
const token = randomUUID();
|
|
218
|
+
// The staging name is PER-ATTEMPT: a later rename-away in the same
|
|
219
|
+
// acquire must never atomically overwrite an earlier attempt's
|
|
220
|
+
// abandoned staging (that would clobber the displacement residue,
|
|
221
|
+
// ADR-0050 §residual). tmp/tombstone are unique per acquire and are
|
|
222
|
+
// always cleaned or inert.
|
|
223
|
+
const tmp = `${lockPath}.tmp-${pid}-${token}`;
|
|
224
|
+
const tombstone = `${lockPath}.tomb-${pid}-${token}`;
|
|
225
|
+
// Test-only affordances (ADR-0050 §test affordances): the cascade
|
|
226
|
+
// gate freezes a contender at the two decision points.
|
|
227
|
+
const readyDir = process.env.KISO_LOCK_TEST_READY_DIR;
|
|
228
|
+
const readPause = Number(process.env.KISO_LOCK_TEST_PAUSE_READ_MS ?? 0) || 0;
|
|
229
|
+
const takeoverPause = Number(process.env.KISO_LOCK_TEST_PAUSE_TAKEOVER_MS ?? 0) || 0;
|
|
230
|
+
for (let attempt = 0;; attempt++) {
|
|
231
|
+
cancelled();
|
|
232
|
+
const staging = `${lockPath}.staging-${pid}-${token}-${attempt}`;
|
|
233
|
+
const seen = readLockIdentity(lockPath);
|
|
234
|
+
if (seen === null || seen.pid === undefined || !isAlive(seen.pid)) {
|
|
235
|
+
// Fresh path, dead holder, or stale residue — take it over
|
|
236
|
+
// by identity confirmation: rename-away → verify → link.
|
|
237
|
+
if (readyDir !== undefined)
|
|
238
|
+
writeFileSync(join(readyDir, `read-${pid}`), "");
|
|
239
|
+
if (readPause > 0)
|
|
240
|
+
await sleep(readPause);
|
|
241
|
+
let moved = false;
|
|
242
|
+
try {
|
|
243
|
+
renameSync(lockPath, staging);
|
|
244
|
+
moved = true;
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
if (err.code !== "ENOENT")
|
|
248
|
+
throw unavailable(err);
|
|
249
|
+
// ENOENT — the path is already absent; proceed to the link.
|
|
250
|
+
}
|
|
251
|
+
if (moved) {
|
|
252
|
+
if (readyDir !== undefined)
|
|
253
|
+
writeFileSync(join(readyDir, `takeover-${pid}`), "");
|
|
254
|
+
if (takeoverPause > 0)
|
|
255
|
+
await sleep(takeoverPause);
|
|
256
|
+
const s = readLockIdentity(staging);
|
|
257
|
+
if (!sameIdentity(s, seen)) {
|
|
258
|
+
// The path was replaced between my read and my rename
|
|
259
|
+
// — a RIVAL'S LIVE file was moved. Abort the takeover:
|
|
260
|
+
// restore-or-keep (link back only-if-absent; on EEXIST
|
|
261
|
+
// the moved file is abandoned — inert residue at its
|
|
262
|
+
// distinct name, ADR-0050 §residual).
|
|
263
|
+
try {
|
|
264
|
+
linkSync(staging, lockPath);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
if (err.code !== "EEXIST")
|
|
268
|
+
throw unavailable(err);
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Write my identity fully, fsync it, THEN link — the
|
|
274
|
+
// load-bearing order (ADR-0050 §crash-durability): the final
|
|
275
|
+
// path can never exist empty or half-written, even across a
|
|
276
|
+
// power loss.
|
|
277
|
+
try {
|
|
278
|
+
const fd = openSync(tmp, "w");
|
|
279
|
+
try {
|
|
280
|
+
writeFileSync(fd, JSON.stringify({ pid, token }), "utf8");
|
|
281
|
+
fsyncSync(fd);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
closeSync(fd);
|
|
285
|
+
}
|
|
286
|
+
linkSync(tmp, lockPath);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
const code = err.code;
|
|
290
|
+
if (code === "EEXIST") {
|
|
291
|
+
// A rival linked first — retry from a fresh read.
|
|
292
|
+
try {
|
|
293
|
+
unlinkSync(tmp);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// the stray is inert
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
throw unavailable(err);
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
unlinkSync(tmp);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// the stray is inert
|
|
307
|
+
}
|
|
308
|
+
// The takeover succeeded — our identity owns the path, and the
|
|
309
|
+
// verified-dead file we moved away may be cleaned up (its
|
|
310
|
+
// name is distinct; unlinking it can never touch the lock
|
|
311
|
+
// path). Only the EEXIST-abandoned staging of a FAILED
|
|
312
|
+
// takeover lingers — the displacement fingerprint.
|
|
313
|
+
if (moved) {
|
|
314
|
+
try {
|
|
315
|
+
unlinkSync(staging);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// the stray is inert
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return new NativeLockHandle(lockPath, tombstone, { pid, token });
|
|
322
|
+
}
|
|
323
|
+
if (seen.pid === pid && seen.token !== undefined) {
|
|
324
|
+
// Same-process writer's residue (round 5): another store in
|
|
325
|
+
// THIS process holds the lock — it will release; retry until
|
|
326
|
+
// it does (never a spurious self-conflict). A legacy
|
|
327
|
+
// bare-pid lock naming our own process is refused like any
|
|
328
|
+
// live foreign owner.
|
|
329
|
+
if (attempt >= 25)
|
|
330
|
+
throw new LockedError(`session ${sessionId} is locked by another writer`);
|
|
331
|
+
await sleep(20);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
throw new LockedError(`session ${sessionId} is locked by another writer (pid ${seen.pid})`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** The default adapter: the identity-confirmed link lock (ADR-0050). */
|
|
339
|
+
export const nativeLockAdapter = new NativeLock();
|
package/dist/store.d.ts
CHANGED
|
@@ -3,24 +3,32 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One file per session: `<root>/<id>.jsonl`, lines of
|
|
5
5
|
* `{"runId": string, "ts": number, "event": Event}`. The single-writer
|
|
6
|
-
* lock (
|
|
7
|
-
*
|
|
6
|
+
* lock (R-G 0.1.47, ADR-0050) is the native identity-confirmed LINK LOCK
|
|
7
|
+
* on `<id>.lock` — no helper process, no python3. Possession is decided by
|
|
8
|
+
* atomic filesystem operations (see lock-adapter.ts for the full protocol
|
|
9
|
+
* and the residual family):
|
|
8
10
|
*
|
|
9
|
-
* - the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
11
|
+
* - the final path exists ONLY by linking a fully-written and fsynced
|
|
12
|
+
* identity file (atomic create-if-absent) — a kill can never leave an
|
|
13
|
+
* empty or half-written lock; dead holders are taken over by identity
|
|
14
|
+
* confirmation (rename-away → verify → link), never by deletion;
|
|
15
|
+
* - the file carries `{"pid": number, "token": string}`; the holder's
|
|
16
|
+
* possession is RE-CHECKED at every append (the file must still name its
|
|
17
|
+
* pid AND token) and a failure is a strict refusal — no retry, no wait
|
|
18
|
+
* heuristic: a displaced holder fails honestly and the session resumes
|
|
19
|
+
* from a fresh store, never two writers (ADR-0050 §residual);
|
|
20
|
+
* - the identity format is the cross-version channel (round 4 formats
|
|
21
|
+
* unchanged). A dead/empty/unreadable legacy lock is residue and is
|
|
22
|
+
* taken over — the documented upgrade contract is QUARANTINE (round 5
|
|
23
|
+
* P1-4): stop every old-format process, THEN start the new version
|
|
24
|
+
* (ADR-0050 §migration);
|
|
25
|
+
* - the mechanism is an injection point: `new SessionStore(root, {
|
|
26
|
+
* lockAdapter })` — the interface is the extension point, and the
|
|
27
|
+
* default adapter is `nativeLockAdapter` (ADR-0050);
|
|
28
|
+
* - `close()` releases only THIS instance's handle; `closeAll()` every
|
|
29
|
+
* held handle — a foreign close can never release another writer's
|
|
30
|
+
* lock. Release leaves the EMPTY released marker; the path is never
|
|
31
|
+
* deleted.
|
|
24
32
|
*
|
|
25
33
|
* Consistency contract (A group):
|
|
26
34
|
* - every id is validated BEFORE any file side effect (append, close,
|
|
@@ -35,6 +43,7 @@
|
|
|
35
43
|
* tolerated damage; everything else throws StoreCorruptionError.
|
|
36
44
|
*/
|
|
37
45
|
import { type Event } from "@vincemakes/kiso-core";
|
|
46
|
+
import { type LockAdapter } from "./lock-adapter.js";
|
|
38
47
|
/** History that does not parse as a contiguous kiso trajectory. */
|
|
39
48
|
export declare class StoreCorruptionError extends Error {
|
|
40
49
|
constructor(message: string);
|
|
@@ -60,29 +69,28 @@ export interface SessionMeta {
|
|
|
60
69
|
export declare class SessionStore {
|
|
61
70
|
#private;
|
|
62
71
|
readonly root: string;
|
|
63
|
-
constructor(root: string
|
|
72
|
+
constructor(root: string, opts?: {
|
|
73
|
+
lockAdapter?: LockAdapter;
|
|
74
|
+
});
|
|
64
75
|
private pathFor;
|
|
65
76
|
private lockPathFor;
|
|
66
77
|
/**
|
|
67
|
-
* Take the single-writer lock (
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* stop every old-format process, then start the new version.
|
|
76
|
-
* No recursion, no deletion, no window between NEW-format writers.
|
|
78
|
+
* Take the single-writer lock (R-G 0.1.47, ADR-0050): the identity-
|
|
79
|
+
* confirmed link lock (see lock-adapter.ts). The adapter decides
|
|
80
|
+
* possession by atomic filesystem operations; a dead holder is taken
|
|
81
|
+
* over by identity confirmation (rename-away → verify → link), a live
|
|
82
|
+
* foreign writer refuses. No recursion, no deletion, no window between
|
|
83
|
+
* writers. The adapter's `cancelled` callback throws the store's closed
|
|
84
|
+
* message so a close() that landed mid-acquisition aborts it
|
|
85
|
+
* immediately (round 5 P1-3).
|
|
77
86
|
*/
|
|
78
87
|
private acquireLock;
|
|
79
|
-
/** round 5(P1-2): true only while
|
|
88
|
+
/** round 5(P1-2): true only while THIS instance holds its handle. */
|
|
80
89
|
private lockHeld;
|
|
81
90
|
/**
|
|
82
|
-
* Release OUR lock only:
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* the flock is the authority, the file is advisory (round 4).
|
|
91
|
+
* Release OUR lock only: release OUR handle (the adapter leaves the
|
|
92
|
+
* empty released marker at the path — never a deletion). A foreign
|
|
93
|
+
* close can never release another writer's lock (ADR-0050).
|
|
86
94
|
*/
|
|
87
95
|
private releaseLock;
|
|
88
96
|
/** Write-ahead: durable (written + fsynced) before returning. */
|
package/dist/store.js
CHANGED
|
@@ -3,24 +3,32 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One file per session: `<root>/<id>.jsonl`, lines of
|
|
5
5
|
* `{"runId": string, "ts": number, "event": Event}`. The single-writer
|
|
6
|
-
* lock (
|
|
7
|
-
*
|
|
6
|
+
* lock (R-G 0.1.47, ADR-0050) is the native identity-confirmed LINK LOCK
|
|
7
|
+
* on `<id>.lock` — no helper process, no python3. Possession is decided by
|
|
8
|
+
* atomic filesystem operations (see lock-adapter.ts for the full protocol
|
|
9
|
+
* and the residual family):
|
|
8
10
|
*
|
|
9
|
-
* - the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
11
|
+
* - the final path exists ONLY by linking a fully-written and fsynced
|
|
12
|
+
* identity file (atomic create-if-absent) — a kill can never leave an
|
|
13
|
+
* empty or half-written lock; dead holders are taken over by identity
|
|
14
|
+
* confirmation (rename-away → verify → link), never by deletion;
|
|
15
|
+
* - the file carries `{"pid": number, "token": string}`; the holder's
|
|
16
|
+
* possession is RE-CHECKED at every append (the file must still name its
|
|
17
|
+
* pid AND token) and a failure is a strict refusal — no retry, no wait
|
|
18
|
+
* heuristic: a displaced holder fails honestly and the session resumes
|
|
19
|
+
* from a fresh store, never two writers (ADR-0050 §residual);
|
|
20
|
+
* - the identity format is the cross-version channel (round 4 formats
|
|
21
|
+
* unchanged). A dead/empty/unreadable legacy lock is residue and is
|
|
22
|
+
* taken over — the documented upgrade contract is QUARANTINE (round 5
|
|
23
|
+
* P1-4): stop every old-format process, THEN start the new version
|
|
24
|
+
* (ADR-0050 §migration);
|
|
25
|
+
* - the mechanism is an injection point: `new SessionStore(root, {
|
|
26
|
+
* lockAdapter })` — the interface is the extension point, and the
|
|
27
|
+
* default adapter is `nativeLockAdapter` (ADR-0050);
|
|
28
|
+
* - `close()` releases only THIS instance's handle; `closeAll()` every
|
|
29
|
+
* held handle — a foreign close can never release another writer's
|
|
30
|
+
* lock. Release leaves the EMPTY released marker; the path is never
|
|
31
|
+
* deleted.
|
|
24
32
|
*
|
|
25
33
|
* Consistency contract (A group):
|
|
26
34
|
* - every id is validated BEFORE any file side effect (append, close,
|
|
@@ -34,10 +42,10 @@
|
|
|
34
42
|
* - load is strict (A group round 1): a partial final line is the only
|
|
35
43
|
* tolerated damage; everything else throws StoreCorruptionError.
|
|
36
44
|
*/
|
|
37
|
-
import {
|
|
38
|
-
import { appendFileSync, closeSync, existsSync, fsyncSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, readSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
45
|
+
import { appendFileSync, closeSync, existsSync, fsyncSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, } from "node:fs";
|
|
39
46
|
import { dirname, join } from "node:path";
|
|
40
47
|
import { isKisoEvent } from "@vincemakes/kiso-core";
|
|
48
|
+
import { LockedError, nativeLockAdapter, } from "./lock-adapter.js";
|
|
41
49
|
/** History that does not parse as a contiguous kiso trajectory. */
|
|
42
50
|
export class StoreCorruptionError extends Error {
|
|
43
51
|
constructor(message) {
|
|
@@ -57,10 +65,11 @@ const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
|
57
65
|
export class SessionStore {
|
|
58
66
|
root;
|
|
59
67
|
#fds = new Map();
|
|
60
|
-
/** sessionId → the lock
|
|
61
|
-
#
|
|
68
|
+
/** sessionId → the lock handle THIS instance holds (ADR-0050). */
|
|
69
|
+
#lockHandles = new Map();
|
|
70
|
+
#lockAdapter;
|
|
62
71
|
/** round 4 (adversarial): serialize concurrent acquireLock calls ON this instance —
|
|
63
|
-
* two racing appends must not
|
|
72
|
+
* two racing appends must not issue two acquisitions and fight each other. */
|
|
64
73
|
#lockAcquiring = new Map();
|
|
65
74
|
/** round 5(P1-1): serialize the WHOLE append critical section per session on
|
|
66
75
|
* this instance — lock check → CAS → write → fsync. A rejected write
|
|
@@ -68,8 +77,9 @@ export class SessionStore {
|
|
|
68
77
|
* never land after a stale failure (which would fork memory and disk). */
|
|
69
78
|
#appendQueues = new Map();
|
|
70
79
|
#closed = new Set();
|
|
71
|
-
constructor(root) {
|
|
80
|
+
constructor(root, opts) {
|
|
72
81
|
this.root = root;
|
|
82
|
+
this.#lockAdapter = opts?.lockAdapter ?? nativeLockAdapter;
|
|
73
83
|
mkdirSync(root, { recursive: true });
|
|
74
84
|
fsyncDir(root);
|
|
75
85
|
}
|
|
@@ -84,21 +94,18 @@ export class SessionStore {
|
|
|
84
94
|
return join(this.root, `${sessionId}.lock`);
|
|
85
95
|
}
|
|
86
96
|
/**
|
|
87
|
-
* Take the single-writer lock (
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* stop every old-format process, then start the new version.
|
|
96
|
-
* No recursion, no deletion, no window between NEW-format writers.
|
|
97
|
+
* Take the single-writer lock (R-G 0.1.47, ADR-0050): the identity-
|
|
98
|
+
* confirmed link lock (see lock-adapter.ts). The adapter decides
|
|
99
|
+
* possession by atomic filesystem operations; a dead holder is taken
|
|
100
|
+
* over by identity confirmation (rename-away → verify → link), a live
|
|
101
|
+
* foreign writer refuses. No recursion, no deletion, no window between
|
|
102
|
+
* writers. The adapter's `cancelled` callback throws the store's closed
|
|
103
|
+
* message so a close() that landed mid-acquisition aborts it
|
|
104
|
+
* immediately (round 5 P1-3).
|
|
97
105
|
*/
|
|
98
106
|
async acquireLock(sessionId) {
|
|
99
|
-
// round 5(P1-2):
|
|
100
|
-
//
|
|
101
|
-
// entry must never be trusted as "locked".
|
|
107
|
+
// round 5(P1-2): a lock is held only while THIS instance's handle is
|
|
108
|
+
// held — a displaced/dead lock must never be trusted as "locked".
|
|
102
109
|
if (this.lockHeld(sessionId))
|
|
103
110
|
return;
|
|
104
111
|
const inFlight = this.#lockAcquiring.get(sessionId);
|
|
@@ -108,102 +115,36 @@ export class SessionStore {
|
|
|
108
115
|
this.#lockAcquiring.set(sessionId, attempt);
|
|
109
116
|
return attempt;
|
|
110
117
|
}
|
|
111
|
-
/** round 5(P1-2): true only while
|
|
118
|
+
/** round 5(P1-2): true only while THIS instance holds its handle. */
|
|
112
119
|
lockHeld(sessionId) {
|
|
113
|
-
|
|
114
|
-
if (child === undefined || child.pid === undefined || child.pid <= 0)
|
|
115
|
-
return false;
|
|
116
|
-
return isAlive(child.pid);
|
|
120
|
+
return this.#lockHandles.has(sessionId);
|
|
117
121
|
}
|
|
118
122
|
async #acquireLockOnce(sessionId) {
|
|
119
123
|
const lockPath = this.lockPathFor(sessionId);
|
|
120
|
-
|
|
121
|
-
const child = spawn("python3", ["-c", LOCK_HELPER_SCRIPT, lockPath], {
|
|
122
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
123
|
-
});
|
|
124
|
-
const verdict = await helperVerdict(child);
|
|
125
|
-
if (verdict === "LOCKED") {
|
|
126
|
-
// The kernel flock is ours. One last compatibility gate: an
|
|
127
|
-
// OLD-format writer (which does not honor flock) may still
|
|
128
|
-
// be alive — its lock file names it. Refuse, and release
|
|
129
|
-
// the flock (the helper dies). A MODERN lock (with a token)
|
|
130
|
-
// naming OUR OWN process is a same-process writer's residue
|
|
131
|
-
// (round 4: the file is advisory; the flock is the authority).
|
|
132
|
-
const legacy = readLockIdentity(lockPath);
|
|
133
|
-
if (legacy?.pid !== undefined && isAlive(legacy.pid) && (legacy.token === undefined || legacy.pid !== process.pid)) {
|
|
134
|
-
child.kill();
|
|
135
|
-
throw new Error(`session ${sessionId} is locked by another writer (pid ${legacy.pid})`);
|
|
136
|
-
}
|
|
137
|
-
// Record our identity in the file: irrelevant to flock, but
|
|
138
|
-
// an OLD-format contender reads it and refuses to take over
|
|
139
|
-
// a live writer's lock.
|
|
140
|
-
try {
|
|
141
|
-
writeFileSync(lockPath, JSON.stringify({ pid: process.pid, token: crypto.randomUUID() }));
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
// the file itself is advisory — the kernel lock holds
|
|
145
|
-
}
|
|
146
|
-
this.#lockHelpers.set(sessionId, child);
|
|
147
|
-
// round 5(P1-2): the helper's death removes the entry — the
|
|
148
|
-
// flock dies with the process; a later append re-acquires
|
|
149
|
-
// (and fails honestly if a rival holds the flock now).
|
|
150
|
-
child.on("exit", () => {
|
|
151
|
-
if (this.#lockHelpers.get(sessionId) === child) {
|
|
152
|
-
this.#lockHelpers.delete(sessionId);
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
child.kill();
|
|
158
|
-
if (verdict === "SPAWN_FAILED") {
|
|
159
|
-
// round 4 (adversarial): the helper could not start (python3 missing) —
|
|
160
|
-
// an HONEST error, never a fake lock conflict.
|
|
161
|
-
throw new Error(`session locking unavailable: the flock helper (python3) failed to start for ${sessionId}`);
|
|
162
|
-
}
|
|
163
|
-
// BUSY: either a live modern writer, or a holder that is just
|
|
164
|
-
// exiting (its helper is dying). A FOREIGN live writer's identity
|
|
165
|
-
// is in the file — refuse at once. A MODERN lock (with a token)
|
|
166
|
-
// naming OUR OWN process is a same-process writer — it will
|
|
167
|
-
// release its helper; retry until it does (round 4: never a
|
|
168
|
-
// spurious self-conflict). A legacy bare-pid lock naming our own
|
|
169
|
-
// process is still a live foreign owner and is refused.
|
|
170
|
-
const legacy = readLockIdentity(lockPath);
|
|
171
|
-
if (legacy?.pid !== undefined && isAlive(legacy.pid) && (legacy.token === undefined || legacy.pid !== process.pid)) {
|
|
172
|
-
throw new Error(`session ${sessionId} is locked by another writer (pid ${legacy.pid})`);
|
|
173
|
-
}
|
|
174
|
-
if (attempt >= 25) {
|
|
175
|
-
throw new Error(`session ${sessionId} is locked by another writer`);
|
|
176
|
-
}
|
|
177
|
-
// round 5(P1-3): a close() that landed while we waited ends the
|
|
178
|
-
// acquisition immediately — no 500ms wait, no lock at all.
|
|
124
|
+
const handle = await this.#lockAdapter.acquire(lockPath, sessionId, () => {
|
|
179
125
|
if (this.#closed.has(sessionId)) {
|
|
180
126
|
throw new Error(`session store is closed for ${sessionId}`);
|
|
181
127
|
}
|
|
182
|
-
|
|
128
|
+
});
|
|
129
|
+
// round 5(P1-3): a close() that landed while we waited ends the
|
|
130
|
+
// acquisition immediately — no lock outlives the instance.
|
|
131
|
+
if (this.#closed.has(sessionId)) {
|
|
132
|
+
handle.release();
|
|
133
|
+
throw new Error(`session store is closed for ${sessionId}`);
|
|
183
134
|
}
|
|
135
|
+
this.#lockHandles.set(sessionId, handle);
|
|
184
136
|
}
|
|
185
137
|
/**
|
|
186
|
-
* Release OUR lock only:
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* the flock is the authority, the file is advisory (round 4).
|
|
138
|
+
* Release OUR lock only: release OUR handle (the adapter leaves the
|
|
139
|
+
* empty released marker at the path — never a deletion). A foreign
|
|
140
|
+
* close can never release another writer's lock (ADR-0050).
|
|
190
141
|
*/
|
|
191
142
|
releaseLock(sessionId) {
|
|
192
|
-
const
|
|
193
|
-
if (
|
|
143
|
+
const handle = this.#lockHandles.get(sessionId);
|
|
144
|
+
if (handle === undefined)
|
|
194
145
|
return;
|
|
195
|
-
this.#
|
|
196
|
-
|
|
197
|
-
// contender that acquires the flock in the release gap writes its
|
|
198
|
-
// own identity AFTER our clear, so it is never wiped by us (the
|
|
199
|
-
// file is advisory; the kernel flock is the authority).
|
|
200
|
-
try {
|
|
201
|
-
writeFileSync(this.lockPathFor(sessionId), "");
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
// advisory only
|
|
205
|
-
}
|
|
206
|
-
child.kill();
|
|
146
|
+
this.#lockHandles.delete(sessionId);
|
|
147
|
+
handle.release();
|
|
207
148
|
}
|
|
208
149
|
// ── append: lock, open, repair, CAS, write, fsync ────────────────────
|
|
209
150
|
/** Write-ahead: durable (written + fsynced) before returning. */
|
|
@@ -241,6 +182,14 @@ export class SessionStore {
|
|
|
241
182
|
this.releaseLock(sessionId);
|
|
242
183
|
throw new Error(`session store is closed for ${sessionId}`);
|
|
243
184
|
}
|
|
185
|
+
// R-G 0.1.47 (ADR-0050): possession is RE-CHECKED at every append —
|
|
186
|
+
// the file must still name this handle's pid AND token. A displaced
|
|
187
|
+
// holder's next append SELF-REFUSES honestly (strict refusal, no
|
|
188
|
+
// retry): never a lockless write, never a second writer.
|
|
189
|
+
const handle = this.#lockHandles.get(sessionId);
|
|
190
|
+
if (handle === undefined || !handle.verify()) {
|
|
191
|
+
throw new LockedError(`session ${sessionId} is locked by another writer`);
|
|
192
|
+
}
|
|
244
193
|
let fd;
|
|
245
194
|
try {
|
|
246
195
|
fd = this.fd(sessionId);
|
|
@@ -372,7 +321,7 @@ export class SessionStore {
|
|
|
372
321
|
}
|
|
373
322
|
/** Release every held fd and lock, including locks whose JSONL open failed. */
|
|
374
323
|
closeAll() {
|
|
375
|
-
for (const id of new Set([...this.#fds.keys(), ...this.#
|
|
324
|
+
for (const id of new Set([...this.#fds.keys(), ...this.#lockHandles.keys()])) {
|
|
376
325
|
this.close(id);
|
|
377
326
|
}
|
|
378
327
|
}
|
|
@@ -383,122 +332,6 @@ function isRecord(value) {
|
|
|
383
332
|
const v = value;
|
|
384
333
|
return typeof v.runId === "string" && typeof v.ts === "number" && isKisoEvent(v.event);
|
|
385
334
|
}
|
|
386
|
-
/**
|
|
387
|
-
* Read a lock file's holder identity (round 4). Formats:
|
|
388
|
-
* modern: {"pid": 123, "token": "..."}
|
|
389
|
-
* legacy: a bare pid — either the STRING "123" or, because
|
|
390
|
-
* JSON.parse("123") yields the NUMBER 123, the number itself.
|
|
391
|
-
* Neither may be mistaken for an object without a pid.
|
|
392
|
-
* Empty, unreadable, or half-written locks have no identity — the kernel
|
|
393
|
-
* flock supersedes them (there is nothing to refuse, and nothing to
|
|
394
|
-
* delete).
|
|
395
|
-
*/
|
|
396
|
-
function readLockIdentity(lockPath) {
|
|
397
|
-
let raw;
|
|
398
|
-
try {
|
|
399
|
-
raw = readFileSync(lockPath, "utf8");
|
|
400
|
-
}
|
|
401
|
-
catch {
|
|
402
|
-
return null;
|
|
403
|
-
}
|
|
404
|
-
const trimmed = raw.trim();
|
|
405
|
-
if (trimmed === "")
|
|
406
|
-
return null;
|
|
407
|
-
let parsed;
|
|
408
|
-
try {
|
|
409
|
-
parsed = JSON.parse(trimmed);
|
|
410
|
-
}
|
|
411
|
-
catch {
|
|
412
|
-
parsed = trimmed; // half-written JSON — try as a bare pid
|
|
413
|
-
}
|
|
414
|
-
if (typeof parsed === "number" && Number.isInteger(parsed)) {
|
|
415
|
-
return { pid: parsed }; // JSON.parse("123") — a legacy bare pid
|
|
416
|
-
}
|
|
417
|
-
if (typeof parsed === "string") {
|
|
418
|
-
const pid = Number.parseInt(parsed, 10);
|
|
419
|
-
return Number.isFinite(pid) ? { pid } : null;
|
|
420
|
-
}
|
|
421
|
-
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
422
|
-
const v = parsed;
|
|
423
|
-
return {
|
|
424
|
-
...(typeof v.pid === "number" ? { pid: v.pid } : {}),
|
|
425
|
-
...(typeof v.token === "string" ? { token: v.token } : {}),
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
return null;
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* The lock helper: a python3 process that takes an EXCLUSIVE flock on the
|
|
432
|
-
* lock path and HOLDS it until it dies (its stdin is closed / it is
|
|
433
|
-
* killed). The kernel releases the flock with the helper — the lock is
|
|
434
|
-
* tied to the open file description, so a dead helper can never leave a
|
|
435
|
-
* stale lock behind, and no contender can ever remove a live one.
|
|
436
|
-
* python3's `fcntl` module provides flock on both macOS and Linux.
|
|
437
|
-
*/
|
|
438
|
-
const LOCK_HELPER_SCRIPT = [
|
|
439
|
-
"import fcntl, os, sys",
|
|
440
|
-
"fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT, 0o644)",
|
|
441
|
-
"try:",
|
|
442
|
-
" fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)",
|
|
443
|
-
"except OSError:",
|
|
444
|
-
" print('BUSY', flush=True)",
|
|
445
|
-
" sys.exit(0)",
|
|
446
|
-
"print('LOCKED', flush=True)",
|
|
447
|
-
"try:",
|
|
448
|
-
" while sys.stdin.buffer.read(1):",
|
|
449
|
-
" pass",
|
|
450
|
-
"except Exception:",
|
|
451
|
-
" pass",
|
|
452
|
-
].join("\n");
|
|
453
|
-
/** The helper's first stdout line: "LOCKED" or anything else = busy/dead. */
|
|
454
|
-
function helperVerdict(child) {
|
|
455
|
-
return new Promise((resolve) => {
|
|
456
|
-
let buf = "";
|
|
457
|
-
let settled = false;
|
|
458
|
-
const done = (verdict) => {
|
|
459
|
-
if (settled)
|
|
460
|
-
return;
|
|
461
|
-
settled = true;
|
|
462
|
-
child.stdout?.removeAllListeners();
|
|
463
|
-
// The helper is a LOCK DAEMON: it must never keep the parent's
|
|
464
|
-
// event loop alive (a finished store exits cleanly), and when the
|
|
465
|
-
// parent DOES exit the pipes close, the helper's read hits EOF,
|
|
466
|
-
// the helper exits, and the kernel releases the flock. The child
|
|
467
|
-
// process handle, its stdin hold, and its verdict channel are all
|
|
468
|
-
// unref'd — the lock outlives nothing the parent does not.
|
|
469
|
-
const unref = (s) => s?.unref?.();
|
|
470
|
-
unref(child);
|
|
471
|
-
unref(child.stdin);
|
|
472
|
-
unref(child.stdout);
|
|
473
|
-
resolve(verdict);
|
|
474
|
-
};
|
|
475
|
-
child.stdout?.on("data", (d) => {
|
|
476
|
-
buf += d.toString();
|
|
477
|
-
const nl = buf.indexOf("\n");
|
|
478
|
-
if (nl !== -1)
|
|
479
|
-
done(buf.slice(0, nl).trim());
|
|
480
|
-
});
|
|
481
|
-
child.stdout?.on("end", () => done(buf.trim()));
|
|
482
|
-
child.stdout?.on("error", () => done("FAILED"));
|
|
483
|
-
// round 5(P2-1): a spawn failure (python3 missing, exec denied) is
|
|
484
|
-
// DISTINCT from a busy lock — the caller must not report "locked by
|
|
485
|
-
// another writer" for a missing helper. The verdict is SPAWN_FAILED
|
|
486
|
-
// and the acquire path checks exactly that string.
|
|
487
|
-
child.on("error", (err) => {
|
|
488
|
-
void err;
|
|
489
|
-
done("SPAWN_FAILED");
|
|
490
|
-
});
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
function isAlive(pid) {
|
|
494
|
-
try {
|
|
495
|
-
process.kill(pid, 0);
|
|
496
|
-
return true;
|
|
497
|
-
}
|
|
498
|
-
catch (err) {
|
|
499
|
-
return err.code === "EPERM";
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
335
|
/**
|
|
503
336
|
* If the file does not end with a newline, truncate to the last complete
|
|
504
337
|
* line (or 0) — the torn-tail repair. Runs on open AND before every append,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "kiso runtime — durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
"test": "vitest run"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@vincemakes/kiso-core": "0.
|
|
24
|
+
"@vincemakes/kiso-core": "1.0.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"@vincemakes/kiso-provider-anthropic": "0.
|
|
28
|
-
"@vincemakes/kiso-provider-openai": "0.
|
|
27
|
+
"@vincemakes/kiso-provider-anthropic": "1.0.0",
|
|
28
|
+
"@vincemakes/kiso-provider-openai": "1.0.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependenciesMeta": {
|
|
31
31
|
"@vincemakes/kiso-provider-anthropic": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@vincemakes/kiso-evals": "0.
|
|
39
|
+
"@vincemakes/kiso-evals": "1.0.0",
|
|
40
40
|
"@types/node": "^26.1.2",
|
|
41
41
|
"typescript": "^5.7.2",
|
|
42
42
|
"vitest": "^3.0.0"
|