@jini-ai/platform 0.1.2 → 0.5.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/CHANGELOG.md +146 -0
- package/README.md +207 -0
- package/dist/asset-cache.d.ts +0 -22
- package/dist/asset-cache.js +3 -138
- package/dist/download.js +2 -2
- package/dist/fetch-with-timeout.d.ts +105 -0
- package/dist/fetch-with-timeout.js +121 -0
- package/dist/fs/atomic-write.d.ts +103 -0
- package/dist/fs/atomic-write.js +201 -0
- package/dist/fs/durable-json.d.ts +89 -0
- package/dist/fs/durable-json.js +166 -0
- package/dist/fs/env-text.d.ts +11 -0
- package/dist/fs/env-text.js +22 -0
- package/dist/fs/file-lock.d.ts +126 -0
- package/dist/fs/file-lock.js +306 -0
- package/dist/fs/guarded-reader.d.ts +60 -0
- package/dist/fs/guarded-reader.js +310 -0
- package/dist/fs/index.d.ts +8 -0
- package/dist/fs/index.js +9 -0
- package/dist/fs/strict-containment.d.ts +5 -0
- package/dist/fs/strict-containment.js +13 -0
- package/dist/fs.js +2 -3
- package/dist/http/guarded/client.d.ts +13 -0
- package/dist/http/guarded/client.js +240 -0
- package/dist/http/guarded/errors.d.ts +21 -0
- package/dist/http/guarded/errors.js +20 -0
- package/dist/http/guarded/fetch-adapter.d.ts +23 -0
- package/dist/http/guarded/fetch-adapter.js +30 -0
- package/dist/http/guarded/index.d.ts +12 -0
- package/dist/http/guarded/index.js +7 -0
- package/dist/http/guarded/node-ports.d.ts +8 -0
- package/dist/http/guarded/node-ports.js +11 -0
- package/dist/http/guarded/ports.d.ts +51 -0
- package/dist/http/guarded/ports.js +2 -0
- package/dist/http/guarded/transport.fetch.d.ts +11 -0
- package/dist/http/guarded/transport.fetch.js +109 -0
- package/dist/http/guarded/types.d.ts +8 -0
- package/dist/http/guarded/types.js +2 -0
- package/dist/http.js +10 -1
- package/dist/index.d.ts +65 -1
- package/dist/index.js +64 -1
- package/dist/mail/adapter-module.d.ts +20 -0
- package/dist/mail/adapter-module.js +2 -0
- package/dist/mail/delivery-availability.d.ts +7 -0
- package/dist/mail/delivery-availability.js +6 -0
- package/dist/mail/index.d.ts +7 -0
- package/dist/mail/index.js +3 -0
- package/dist/mail/ports.d.ts +43 -0
- package/dist/mail/ports.js +2 -0
- package/dist/mail/purpose-scoped-mailer.d.ts +18 -0
- package/dist/mail/purpose-scoped-mailer.js +50 -0
- package/dist/mail/smtp.d.ts +79 -0
- package/dist/mail/smtp.js +102 -0
- package/dist/mail/types.d.ts +83 -0
- package/dist/mail/types.js +2 -0
- package/dist/messages.d.ts +22 -0
- package/dist/messages.js +15 -0
- package/dist/net/address.d.ts +38 -0
- package/dist/net/address.js +241 -0
- package/dist/net/index.d.ts +2 -0
- package/dist/net/index.js +2 -0
- package/dist/process.d.ts +2 -1
- package/dist/process.js +2 -1
- package/dist/proxy-env.js +1 -1
- package/dist/resource-paths.js +1 -1
- package/dist/sandbox-env.d.ts +1 -1
- package/dist/sandbox-env.js +1 -1
- package/dist/secrets/aad.d.ts +7 -0
- package/dist/secrets/aad.js +5 -0
- package/dist/secrets/credential-sets/aad.d.ts +7 -0
- package/dist/secrets/credential-sets/aad.js +7 -0
- package/dist/secrets/credential-sets/index.d.ts +4 -0
- package/dist/secrets/credential-sets/index.js +3 -0
- package/dist/secrets/credential-sets/repo.memory.d.ts +36 -0
- package/dist/secrets/credential-sets/repo.memory.js +72 -0
- package/dist/secrets/credential-sets/types.d.ts +67 -0
- package/dist/secrets/credential-sets/types.js +6 -0
- package/dist/secrets/index.d.ts +9 -0
- package/dist/secrets/index.js +5 -0
- package/dist/secrets/keyring.env.d.ts +176 -0
- package/dist/secrets/keyring.env.js +271 -0
- package/dist/secrets/keyring.memory.d.ts +10 -0
- package/dist/secrets/keyring.memory.js +9 -0
- package/dist/secrets/ports.d.ts +33 -0
- package/dist/secrets/ports.js +2 -0
- package/dist/secrets/secret-sealer.aesgcm.d.ts +22 -0
- package/dist/secrets/secret-sealer.aesgcm.js +64 -0
- package/dist/secrets/testing.d.ts +4 -0
- package/dist/secrets/testing.js +4 -0
- package/dist/secrets/types.d.ts +10 -0
- package/dist/secrets/types.js +2 -0
- package/dist/shell.d.ts +10 -6
- package/dist/shell.js +10 -6
- package/dist/terminal.d.ts +2 -2
- package/dist/terminal.js +9 -5
- package/package.json +85 -4
- package/dist/asset-cache.d.ts.map +0 -1
- package/dist/asset-cache.js.map +0 -1
- package/dist/aws-sigv4.d.ts.map +0 -1
- package/dist/aws-sigv4.js.map +0 -1
- package/dist/blob-storage.d.ts.map +0 -1
- package/dist/blob-storage.js.map +0 -1
- package/dist/command.d.ts.map +0 -1
- package/dist/command.js.map +0 -1
- package/dist/download.d.ts.map +0 -1
- package/dist/download.js.map +0 -1
- package/dist/fs.d.ts.map +0 -1
- package/dist/fs.js.map +0 -1
- package/dist/home-expansion.d.ts.map +0 -1
- package/dist/home-expansion.js.map +0 -1
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/process.d.ts.map +0 -1
- package/dist/process.js.map +0 -1
- package/dist/proxy-env.d.ts.map +0 -1
- package/dist/proxy-env.js.map +0 -1
- package/dist/resource-paths.d.ts.map +0 -1
- package/dist/resource-paths.js.map +0 -1
- package/dist/sandbox-env.d.ts.map +0 -1
- package/dist/sandbox-env.js.map +0 -1
- package/dist/shell.d.ts.map +0 -1
- package/dist/shell.js.map +0 -1
- package/dist/terminal.d.ts.map +0 -1
- package/dist/terminal.js.map +0 -1
- package/dist/toolchain.d.ts.map +0 -1
- package/dist/toolchain.js.map +0 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stores once wrote JSON directly, treated torn reads as empty, then persisted the empty
|
|
3
|
+
* state on the next write, erasing recoverable records. One mechanism keeps stores from drifting:
|
|
4
|
+
* atomic replacement, explicit unreadable state, quarantine, and locks for shared read-modify-write.
|
|
5
|
+
* Recovery policy stays with each store: salvage, restart, or leave untouched is not decided here.
|
|
6
|
+
* Native effects are Electron-free so desktop, CLI, and bridge processes can share the mechanism.
|
|
7
|
+
*/
|
|
8
|
+
import nodeFs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { createNodeAtomicFilesystem, writeJsonFileAtomic } from "./atomic-write.js";
|
|
12
|
+
import { withFileLockSync } from "./file-lock.js";
|
|
13
|
+
/** Native synchronous adapters. Liveness probes treat permission errors as alive. */
|
|
14
|
+
export function createNodeDurableJsonPorts(_required) {
|
|
15
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
16
|
+
return {
|
|
17
|
+
filesystem: nodeFs,
|
|
18
|
+
clock: { nowMs: () => Date.now() },
|
|
19
|
+
// Synchronous store/IPC writers cannot await lock polling without changing all callers' ABI.
|
|
20
|
+
sleep: { sleep: ({ durationMs }) => { Atomics.wait(cell, 0, 0, durationMs); } },
|
|
21
|
+
process: { pid: process.pid, isAlive: ({ pid }) => {
|
|
22
|
+
// Signal zero sends nothing. Only ESRCH proves death; permission failure must count as alive
|
|
23
|
+
// because a false "dead" authorizes breaking a lock still owned by another process.
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return error.code !== "ESRCH";
|
|
30
|
+
}
|
|
31
|
+
} },
|
|
32
|
+
random: { token: (_required) => randomBytes(4).toString("hex") },
|
|
33
|
+
report: { error: ({ message }) => console.error(message) },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Atomic replacement, distinct corrupt reads, quarantine, and token-owned synchronous locks.
|
|
37
|
+
* Lock stealing rechecks inode, bytes and mtime; conditional unlink is not available on all hosts.
|
|
38
|
+
* File fsync precedes rename; directory fsync follows it. Persistence remains filesystem-dependent.
|
|
39
|
+
*/
|
|
40
|
+
export function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }, { pollMs = 10, staleMs = 30000 } = {}) {
|
|
41
|
+
if (!filePath)
|
|
42
|
+
throw new TypeError("filePath is required");
|
|
43
|
+
for (const value of [pollMs, staleMs])
|
|
44
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
45
|
+
throw new TypeError("lock intervals must be positive safe integers");
|
|
46
|
+
// A synchronous read-modify-write normally takes milliseconds; five seconds allows contention
|
|
47
|
+
// while refusing a wedged change. Short ten-millisecond polling fits those short critical sections.
|
|
48
|
+
// Thirty-second staleness also handles pid reuse after a crash:
|
|
49
|
+
// an unrelated live process must not hold the abandoned file forever merely by inheriting its pid.
|
|
50
|
+
const LOCK_WAIT_MS = 5000, LOCK_POLL_MS = pollMs, LOCK_STALE_MS = staleMs;
|
|
51
|
+
function readJsonFile(filePath) {
|
|
52
|
+
let text;
|
|
53
|
+
try {
|
|
54
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return error.code === "ENOENT" ? { state: "missing" } : { state: "unreadable" };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return { state: "ok", value: JSON.parse(text) };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return { state: "unreadable", text };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Per-process sibling temps prevent one writer truncating another's pending replacement. Writes
|
|
67
|
+
// within one process are synchronous, so it cannot collide with itself; its next write reuses the temp.
|
|
68
|
+
function tempPathFor(filePath, pid = process.pid) {
|
|
69
|
+
return `${filePath}.${pid}.tmp`;
|
|
70
|
+
}
|
|
71
|
+
// Same-directory rename gives readers complete old/new versions rather than a torn intermediate.
|
|
72
|
+
// Flush bytes before rename so metadata cannot precede content. The canonical writer also syncs
|
|
73
|
+
// the parent directory; platform/filesystem support determines persistence after that sync.
|
|
74
|
+
// Keep the legacy temp path, pretty JSON bytes and new-file create mode for these shared stores.
|
|
75
|
+
function writeJson(filePath, value) {
|
|
76
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
77
|
+
writeJsonFileAtomic({ filePath, data: value, fs: createNodeAtomicFilesystem({}, { filesystem: fs }) }, {
|
|
78
|
+
defaultMode: 0o666 & ~globalThis.process.umask(), tempPath: () => tempPathFor(filePath),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Preserve damaged bytes for inspection instead of deleting/overwriting them. False means callers
|
|
82
|
+
// must leave the original alone; an already-moved file is safe because another process cleared it.
|
|
83
|
+
function quarantineUnreadableFile(filePath, notice) {
|
|
84
|
+
const asidePath = `${filePath}.corrupt-${clock.nowMs()}`;
|
|
85
|
+
try {
|
|
86
|
+
fs.renameSync(filePath, asidePath);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code === "ENOENT")
|
|
90
|
+
return true;
|
|
91
|
+
report.error({ message: `${notices.prefix}: ${notice.label} ${filePath} is unreadable and could not be moved aside (${String(error)}). Left untouched; nothing was written over it.` });
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
report.error({ message: `${notices.prefix}: ${notice.label} ${filePath} was unreadable (torn or corrupt). Moved it aside to ${asidePath}; ${notice.consequence}` });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
// Atomic replacement prevents tears; this lock serializes shared read-modify-write operations.
|
|
98
|
+
// Deliberately non-reentrant. Token-owned release must not evict a later owner after a stale break.
|
|
99
|
+
// The canonical lock rechecks the inode at steal time; its documented check-to-unlink race remains.
|
|
100
|
+
return {
|
|
101
|
+
read: (_required) => readJsonFile(filePath),
|
|
102
|
+
tempPath: (_required, { pid = process.pid } = {}) => tempPathFor(filePath, pid),
|
|
103
|
+
write: ({ value }) => writeJson(filePath, value),
|
|
104
|
+
quarantine: ({ notice }) => quarantineUnreadableFile(filePath, notice),
|
|
105
|
+
withLock: ({ critical }, optional = {}) => {
|
|
106
|
+
if (optional.waitMs !== undefined && (!Number.isSafeInteger(optional.waitMs) || optional.waitMs < 0))
|
|
107
|
+
throw new TypeError("waitMs must be a nonnegative safe integer");
|
|
108
|
+
return withFileLockSync({ lockPath: `${filePath}.lock`, run: critical }, {
|
|
109
|
+
timeoutMs: optional.waitMs ?? LOCK_WAIT_MS, staleMs: LOCK_STALE_MS, pollMs: LOCK_POLL_MS,
|
|
110
|
+
filesystem: fs, clock, sleep: ({ durationMs }) => sleep.sleep({ durationMs }), process,
|
|
111
|
+
token: () => random.token({}), format: "token", createParent: true,
|
|
112
|
+
timeoutMessage: notices.lockTimeout, suppressReleaseErrors: true,
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Bound recovery to the latest 1,000 parse attempts by default, rather than parsing once per quoted
|
|
118
|
+
// character in a large damaged file. Stores must validate recovered values normally: partial objects
|
|
119
|
+
// are not trustworthy merely because their brackets can be closed into syntactically valid JSON.
|
|
120
|
+
export function salvageJsonPrefix({ text }, { maxAttempts = 1000 } = {}) {
|
|
121
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0)
|
|
122
|
+
throw new TypeError("maxAttempts must be a positive safe integer");
|
|
123
|
+
const cuts = elementEnds(text);
|
|
124
|
+
const floor = Math.max(0, cuts.length - maxAttempts);
|
|
125
|
+
for (let index = cuts.length - 1; index >= floor; index -= 1) {
|
|
126
|
+
const cut = cuts[index];
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(text.slice(0, cut.end) + cut.closers);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A cut after an object key cannot close validly; try an earlier completed value.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
// Cut only at completed strings/objects/arrays, sufficient for string-valued lists. Damage inside a
|
|
137
|
+
// bare number/boolean falls back to the previous cut and loses that element rather than guessing it.
|
|
138
|
+
function elementEnds(text) {
|
|
139
|
+
const open = [];
|
|
140
|
+
const cuts = [];
|
|
141
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
142
|
+
const char = text[index];
|
|
143
|
+
if (char === '"')
|
|
144
|
+
index = endOfString(text, index);
|
|
145
|
+
else if (char === "{" || char === "[") {
|
|
146
|
+
open.push(char === "{" ? "}" : "]");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
else if (char === "}" || char === "]")
|
|
150
|
+
open.pop();
|
|
151
|
+
else
|
|
152
|
+
continue;
|
|
153
|
+
cuts.push({ end: index + 1, closers: [...open].reverse().join("") });
|
|
154
|
+
}
|
|
155
|
+
return cuts;
|
|
156
|
+
}
|
|
157
|
+
function endOfString(text, start) {
|
|
158
|
+
for (let index = start + 1; index < text.length; index += 1) {
|
|
159
|
+
if (text[index] === "\\")
|
|
160
|
+
index += 1;
|
|
161
|
+
else if (text[index] === '"')
|
|
162
|
+
return index;
|
|
163
|
+
}
|
|
164
|
+
return text.length;
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=durable-json.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Narrow env-text transforms: first exact KEY= match, preserve unrelated lines, one final newline. */
|
|
2
|
+
export declare function upsertEnvLine({ source, key, value }: {
|
|
3
|
+
source: string;
|
|
4
|
+
key: string;
|
|
5
|
+
value: string;
|
|
6
|
+
}): string;
|
|
7
|
+
export declare function readEnvLine({ source, key }: {
|
|
8
|
+
source: string;
|
|
9
|
+
key: string;
|
|
10
|
+
}): string | null;
|
|
11
|
+
//# sourceMappingURL=env-text.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Narrow env-text transforms: first exact KEY= match, preserve unrelated lines, one final newline. */
|
|
2
|
+
export function upsertEnvLine({ source, key, value }) {
|
|
3
|
+
const lines = source.length === 0 ? [] : source.split("\n");
|
|
4
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
5
|
+
lines.pop();
|
|
6
|
+
const prefix = `${key}=`;
|
|
7
|
+
const index = lines.findIndex((line) => line.startsWith(prefix));
|
|
8
|
+
const newLine = `${prefix}${value}`;
|
|
9
|
+
if (index === -1) {
|
|
10
|
+
lines.push(newLine);
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
lines[index] = newLine;
|
|
14
|
+
}
|
|
15
|
+
return `${lines.join("\n")}\n`;
|
|
16
|
+
}
|
|
17
|
+
export function readEnvLine({ source, key }) {
|
|
18
|
+
const prefix = `${key}=`;
|
|
19
|
+
const match = source.split("\n").find((line) => line.startsWith(prefix));
|
|
20
|
+
return match === undefined ? null : match.slice(prefix.length).trim();
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=env-text.js.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic writes prevent tears but not lost updates between shared read-modify-writes. Plain readers
|
|
3
|
+
* need no lock. Deliberately non-reentrant: nesting times out rather than silently sharing an
|
|
4
|
+
* exclusive section; compose store operations from unlocked internals instead.
|
|
5
|
+
* Kernel exclusive create chooses the winner. A unique token AND inode identify that owner;
|
|
6
|
+
* pid alone cannot distinguish a later owner after a stale break. Signal-zero liveness probes
|
|
7
|
+
* treat only ESRCH as death: permission failure must never authorize evicting a live process.
|
|
8
|
+
* Stale removal rechecks inode, bytes and mtime immediately before unlink. There is still a residual
|
|
9
|
+
* check-to-unlink race on filesystems without conditional unlink; rename/restore chains create wider
|
|
10
|
+
* multi-process races and are deliberately avoided. Atomic file replacement independently prevents
|
|
11
|
+
* torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import * as asyncFs from "node:fs/promises";
|
|
15
|
+
import { type Clock } from "@jini-ai/core/primitives";
|
|
16
|
+
import { type PlatformMessages } from "../messages.js";
|
|
17
|
+
export declare const DEFAULT_LOCK_TIMEOUT_MS = 15000;
|
|
18
|
+
export declare const DEFAULT_LOCK_STALE_AFTER_MS = 10000;
|
|
19
|
+
export interface FileLockHolder {
|
|
20
|
+
readonly pid: number;
|
|
21
|
+
readonly hostname: string;
|
|
22
|
+
readonly token: string;
|
|
23
|
+
readonly acquiredAt: string;
|
|
24
|
+
}
|
|
25
|
+
export interface HeldFileLock {
|
|
26
|
+
readonly lockPath: string;
|
|
27
|
+
assertHeld(required: Record<string, never>): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface HeldFileLockSync {
|
|
30
|
+
readonly lockPath: string;
|
|
31
|
+
assertHeld(required: Record<string, never>): void;
|
|
32
|
+
}
|
|
33
|
+
export type FileLockSyncFilesystem = Pick<typeof fs, "mkdirSync" | "openSync" | "writeSync" | "fstatSync" | "readFileSync" | "statSync" | "closeSync" | "rmSync">;
|
|
34
|
+
export type FileLockAsyncFilesystem = Pick<typeof asyncFs, "mkdir" | "open" | "stat" | "rm">;
|
|
35
|
+
export interface FileLockOptions {
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
/** Infinity disables age stealing; dead local owners remain reclaimable. */
|
|
38
|
+
staleMs?: number;
|
|
39
|
+
pollMs?: number;
|
|
40
|
+
clock?: Clock;
|
|
41
|
+
/** Times the wait budget (start and timeout check). Defaults to `clock.nowMs`; hosts with a
|
|
42
|
+
* monotonic source pass it so a wall-clock jump can neither end nor stretch the wait. Stale age
|
|
43
|
+
* stays on `clock.nowMs`, because lock-file mtimes are wall-clock times. */
|
|
44
|
+
monotonicMs?: () => number;
|
|
45
|
+
sleep?: (required: {
|
|
46
|
+
durationMs: number;
|
|
47
|
+
}) => void | Promise<void>;
|
|
48
|
+
process?: {
|
|
49
|
+
pid: number;
|
|
50
|
+
isAlive(required: {
|
|
51
|
+
pid: number;
|
|
52
|
+
}): boolean;
|
|
53
|
+
};
|
|
54
|
+
token?: () => string;
|
|
55
|
+
hostname?: () => string;
|
|
56
|
+
onStaleLockRemoved?: (required: {
|
|
57
|
+
holder: FileLockHolder | undefined;
|
|
58
|
+
}) => void;
|
|
59
|
+
createParent?: boolean;
|
|
60
|
+
format?: "json" | "token";
|
|
61
|
+
/** Host stores keep their existing user-facing timeout copy. */
|
|
62
|
+
timeoutMessage?: (required: {
|
|
63
|
+
lockPath: string;
|
|
64
|
+
waitMs: number;
|
|
65
|
+
}) => string;
|
|
66
|
+
messages?: PlatformMessages;
|
|
67
|
+
/** Durable stores historically ignored release failures to avoid masking their critical section. */
|
|
68
|
+
suppressReleaseErrors?: boolean;
|
|
69
|
+
}
|
|
70
|
+
export declare class FileLockTimeoutError extends Error {
|
|
71
|
+
readonly lockPath: string;
|
|
72
|
+
readonly holder: FileLockHolder | undefined;
|
|
73
|
+
readonly waitedMs: number;
|
|
74
|
+
constructor(required: {
|
|
75
|
+
lockPath: string;
|
|
76
|
+
holder: FileLockHolder | undefined;
|
|
77
|
+
waitedMs: number;
|
|
78
|
+
}, optional?: {
|
|
79
|
+
message?: string;
|
|
80
|
+
messages?: PlatformMessages;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export declare class FileLockLostError extends Error {
|
|
84
|
+
readonly lockPath: string;
|
|
85
|
+
constructor({ lockPath }: {
|
|
86
|
+
lockPath: string;
|
|
87
|
+
}, { messages }?: {
|
|
88
|
+
messages?: PlatformMessages;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Platform-specific contention classification retains the Windows exclusive-open behavior. */
|
|
92
|
+
export declare function isContendedLockError({ code, platform }: {
|
|
93
|
+
code: string | undefined;
|
|
94
|
+
platform: NodeJS.Platform;
|
|
95
|
+
}): boolean;
|
|
96
|
+
/** One pure stale decision serves both I/O adapters. Invalid/foreign pids have unknown liveness,
|
|
97
|
+
* never false liveness; otherwise kill(0/negative, 0) would address a process group. */
|
|
98
|
+
export declare function isLockStale(required: {
|
|
99
|
+
nowMs: number;
|
|
100
|
+
mtimeMs: number;
|
|
101
|
+
staleMs: number;
|
|
102
|
+
ownerAlive: boolean | undefined;
|
|
103
|
+
observedInode: number;
|
|
104
|
+
currentInode: number;
|
|
105
|
+
observedDevice: number;
|
|
106
|
+
currentDevice: number;
|
|
107
|
+
}): boolean;
|
|
108
|
+
/** Synchronous read-modify-write guard. Durable stores pass their former 5s/30s/10ms values.
|
|
109
|
+
* Release never evicts a later owner, even when the old owner reaches finally after a steal. */
|
|
110
|
+
export declare function withFileLockSync<T>({ lockPath, run }: {
|
|
111
|
+
lockPath: string;
|
|
112
|
+
run: (lock: HeldFileLockSync) => T;
|
|
113
|
+
}, options?: Omit<FileLockOptions, "sleep"> & {
|
|
114
|
+
sleep?: (required: {
|
|
115
|
+
durationMs: number;
|
|
116
|
+
}) => void;
|
|
117
|
+
filesystem?: FileLockSyncFilesystem;
|
|
118
|
+
}): T;
|
|
119
|
+
/** Async adapter of the same stale and ownership policy; polling yields to other writers. */
|
|
120
|
+
export declare function withFileLock<T>({ lockPath, run }: {
|
|
121
|
+
lockPath: string;
|
|
122
|
+
run: (lock: HeldFileLock) => Promise<T> | T;
|
|
123
|
+
}, options?: FileLockOptions & {
|
|
124
|
+
filesystem?: FileLockAsyncFilesystem;
|
|
125
|
+
}): Promise<T>;
|
|
126
|
+
//# sourceMappingURL=file-lock.d.ts.map
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic writes prevent tears but not lost updates between shared read-modify-writes. Plain readers
|
|
3
|
+
* need no lock. Deliberately non-reentrant: nesting times out rather than silently sharing an
|
|
4
|
+
* exclusive section; compose store operations from unlocked internals instead.
|
|
5
|
+
* Kernel exclusive create chooses the winner. A unique token AND inode identify that owner;
|
|
6
|
+
* pid alone cannot distinguish a later owner after a stale break. Signal-zero liveness probes
|
|
7
|
+
* treat only ESRCH as death: permission failure must never authorize evicting a live process.
|
|
8
|
+
* Stale removal rechecks inode, bytes and mtime immediately before unlink. There is still a residual
|
|
9
|
+
* check-to-unlink race on filesystems without conditional unlink; rename/restore chains create wider
|
|
10
|
+
* multi-process races and are deliberately avoided. Atomic file replacement independently prevents
|
|
11
|
+
* torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import * as asyncFs from "node:fs/promises";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { hostname as nodeHostname } from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { createSystemClock } from "@jini-ai/core/primitives";
|
|
19
|
+
import { defaultPlatformMessages } from "../messages.js";
|
|
20
|
+
export const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
|
|
21
|
+
export const DEFAULT_LOCK_STALE_AFTER_MS = 10_000;
|
|
22
|
+
export class FileLockTimeoutError extends Error {
|
|
23
|
+
lockPath;
|
|
24
|
+
holder;
|
|
25
|
+
waitedMs;
|
|
26
|
+
constructor(required, optional = {}) {
|
|
27
|
+
super(optional.message ?? (optional.messages ?? defaultPlatformMessages).fileLockTimeout({ waitedMs: required.waitedMs }));
|
|
28
|
+
this.name = "FileLockTimeoutError";
|
|
29
|
+
this.lockPath = required.lockPath;
|
|
30
|
+
this.holder = required.holder;
|
|
31
|
+
this.waitedMs = required.waitedMs;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class FileLockLostError extends Error {
|
|
35
|
+
lockPath;
|
|
36
|
+
constructor({ lockPath }, { messages = defaultPlatformMessages } = {}) {
|
|
37
|
+
super(messages.fileLockLost());
|
|
38
|
+
this.name = "FileLockLostError";
|
|
39
|
+
this.lockPath = lockPath;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Platform-specific contention classification retains the Windows exclusive-open behavior. */
|
|
43
|
+
export function isContendedLockError({ code, platform }) {
|
|
44
|
+
return code === "EEXIST" || (code === "EPERM" && platform === "win32");
|
|
45
|
+
}
|
|
46
|
+
function errorCode(error) {
|
|
47
|
+
return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
48
|
+
}
|
|
49
|
+
function validPid(pid) { return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0; }
|
|
50
|
+
function parseHolder(raw) {
|
|
51
|
+
try {
|
|
52
|
+
const value = JSON.parse(raw);
|
|
53
|
+
if (typeof value !== "object" || value === null || !("pid" in value) || !validPid(value.pid) || !("hostname" in value) || typeof value.hostname !== "string" || !("token" in value) || typeof value.token !== "string" || !("acquiredAt" in value) || typeof value.acquiredAt !== "string")
|
|
54
|
+
return undefined;
|
|
55
|
+
return { pid: value.pid, hostname: value.hostname, token: value.token, acquiredAt: value.acquiredAt };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** One pure stale decision serves both I/O adapters. Invalid/foreign pids have unknown liveness,
|
|
62
|
+
* never false liveness; otherwise kill(0/negative, 0) would address a process group. */
|
|
63
|
+
export function isLockStale(required) {
|
|
64
|
+
return (required.ownerAlive === false || required.nowMs - required.mtimeMs > required.staleMs) && required.observedInode === required.currentInode && required.observedDevice === required.currentDevice;
|
|
65
|
+
}
|
|
66
|
+
function settings(options) {
|
|
67
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_AFTER_MS, pollMs = options.pollMs ?? 10;
|
|
68
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || (!Number.isSafeInteger(staleMs) && staleMs !== Infinity) || staleMs <= 0 || !Number.isSafeInteger(pollMs) || pollMs <= 0)
|
|
69
|
+
throw new RangeError((options.messages ?? defaultPlatformMessages).fileLockIntervalsInvalid());
|
|
70
|
+
const processPort = options.process ?? { pid: process.pid, isAlive: ({ pid }) => { try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return errorCode(error) !== "ESRCH";
|
|
76
|
+
} } };
|
|
77
|
+
const clock = options.clock ?? createSystemClock();
|
|
78
|
+
const monotonicMs = options.monotonicMs ?? (() => clock.nowMs());
|
|
79
|
+
return { timeoutMs, staleMs, pollMs, processPort, clock, monotonicMs, hostname: (options.hostname ?? nodeHostname)(), token: (options.token ?? randomUUID)() };
|
|
80
|
+
}
|
|
81
|
+
function ownership(s, options) {
|
|
82
|
+
return options.format === "token" ? `${s.processPort.pid}:${s.token}` : JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString() });
|
|
83
|
+
}
|
|
84
|
+
function ownerAlive(snapshot, s) {
|
|
85
|
+
const holder = snapshot.holder;
|
|
86
|
+
if (holder)
|
|
87
|
+
return holder.hostname === s.hostname ? s.processPort.isAlive({ pid: holder.pid }) : undefined;
|
|
88
|
+
// Legacy synchronous stores wrote pid:random rather than JSON. Malformed pids remain unknown.
|
|
89
|
+
if (!/^\d+:/.test(snapshot.raw))
|
|
90
|
+
return undefined;
|
|
91
|
+
const pid = Number(snapshot.raw.split(":", 1)[0]);
|
|
92
|
+
return validPid(pid) ? s.processPort.isAlive({ pid }) : undefined;
|
|
93
|
+
}
|
|
94
|
+
function unchanged(a, b) { return a.ino === b.ino && a.dev === b.dev && a.raw === b.raw && a.mtimeMs === b.mtimeMs; }
|
|
95
|
+
function stale(observed, current, s) {
|
|
96
|
+
return unchanged(observed, current) && isLockStale({ nowMs: s.clock.nowMs(), mtimeMs: observed.mtimeMs, staleMs: s.staleMs, ownerAlive: ownerAlive(current, s), observedInode: observed.ino, currentInode: current.ino, observedDevice: observed.dev, currentDevice: current.dev });
|
|
97
|
+
}
|
|
98
|
+
function checkTimeout(lockPath, holder, start, s, options) {
|
|
99
|
+
const waitedMs = s.monotonicMs() - start;
|
|
100
|
+
if (waitedMs >= s.timeoutMs) {
|
|
101
|
+
const message = options.timeoutMessage?.({ lockPath, waitMs: s.timeoutMs });
|
|
102
|
+
throw new FileLockTimeoutError({ lockPath, holder, waitedMs }, { ...(options.messages ? { messages: options.messages } : {}), ...(message !== undefined ? { message } : {}) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function inspectSync(lockPath, filesystem) {
|
|
106
|
+
let descriptor;
|
|
107
|
+
try {
|
|
108
|
+
descriptor = filesystem.openSync(lockPath, "r");
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (errorCode(error) === "ENOENT" || isContendedLockError({ code: errorCode(error), platform: process.platform }))
|
|
112
|
+
return undefined;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const info = filesystem.fstatSync(descriptor), raw = filesystem.readFileSync(descriptor, "utf8");
|
|
117
|
+
return { raw, holder: parseHolder(raw), mtimeMs: info.mtimeMs, ino: info.ino, dev: info.dev };
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
filesystem.closeSync(descriptor);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function inspectAsync(lockPath, filesystem) {
|
|
124
|
+
let handle;
|
|
125
|
+
try {
|
|
126
|
+
handle = await filesystem.open(lockPath, "r");
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (errorCode(error) === "ENOENT" || isContendedLockError({ code: errorCode(error), platform: process.platform }))
|
|
130
|
+
return undefined;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const info = await handle.stat(), raw = await handle.readFile("utf8");
|
|
135
|
+
return { raw, holder: parseHolder(raw), mtimeMs: info.mtimeMs, ino: info.ino, dev: info.dev };
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await handle.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const waitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
142
|
+
/** Synchronous read-modify-write guard. Durable stores pass their former 5s/30s/10ms values.
|
|
143
|
+
* Release never evicts a later owner, even when the old owner reaches finally after a steal. */
|
|
144
|
+
export function withFileLockSync({ lockPath, run }, options = {}) {
|
|
145
|
+
const filesystem = options.filesystem ?? fs, s = settings(options), start = s.monotonicMs(), raw = ownership(s, options);
|
|
146
|
+
if (options.createParent)
|
|
147
|
+
filesystem.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
148
|
+
let identity;
|
|
149
|
+
for (;;) {
|
|
150
|
+
let descriptor;
|
|
151
|
+
try {
|
|
152
|
+
descriptor = filesystem.openSync(lockPath, "wx", 0o600);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (!isContendedLockError({ code: errorCode(error), platform: process.platform }))
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
if (descriptor !== undefined) {
|
|
159
|
+
try {
|
|
160
|
+
identity = filesystem.fstatSync(descriptor);
|
|
161
|
+
// An unidentifiable lock would wedge writers until stale timeout; clean a failed creation
|
|
162
|
+
// only while its inode still matches, rather than deleting a replacement owner.
|
|
163
|
+
const bytes = Buffer.from(raw);
|
|
164
|
+
let offset = 0;
|
|
165
|
+
while (offset < bytes.length) {
|
|
166
|
+
const written = filesystem.writeSync(descriptor, bytes, offset, bytes.length - offset);
|
|
167
|
+
if (written === 0)
|
|
168
|
+
throw new Error((options.messages ?? defaultPlatformMessages).fileLockWriteNoProgress());
|
|
169
|
+
offset += written;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
try {
|
|
174
|
+
const created = filesystem.fstatSync(descriptor), current = filesystem.statSync(lockPath);
|
|
175
|
+
if (created.ino === current.ino && created.dev === current.dev)
|
|
176
|
+
filesystem.rmSync(lockPath, { force: true });
|
|
177
|
+
}
|
|
178
|
+
catch { /* preserve creation failure */ }
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
filesystem.closeSync(descriptor);
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
const observed = inspectSync(lockPath, filesystem);
|
|
187
|
+
if (observed) {
|
|
188
|
+
const current = inspectSync(lockPath, filesystem);
|
|
189
|
+
if (current && stale(observed, current, s)) {
|
|
190
|
+
const last = filesystem.statSync(lockPath, { throwIfNoEntry: false });
|
|
191
|
+
if (last && last.ino === current.ino && last.dev === current.dev && last.mtimeMs === current.mtimeMs) {
|
|
192
|
+
try {
|
|
193
|
+
filesystem.rmSync(lockPath, { force: true });
|
|
194
|
+
options.onStaleLockRemoved?.({ holder: current.holder });
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (errorCode(error) !== "ENOENT")
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Every contention path, including repeated stale breaks and release races, is bounded.
|
|
204
|
+
checkTimeout(lockPath, observed?.holder, start, s, options);
|
|
205
|
+
(options.sleep ?? (({ durationMs }) => { Atomics.wait(waitCell, 0, 0, durationMs); }))({ durationMs: s.pollMs });
|
|
206
|
+
}
|
|
207
|
+
const held = () => { const current = inspectSync(lockPath, filesystem); return current && current.raw === raw && current.ino === identity.ino && current.dev === identity.dev; };
|
|
208
|
+
let completed = false;
|
|
209
|
+
try {
|
|
210
|
+
const value = run({ lockPath, assertHeld: (_required) => { if (!held())
|
|
211
|
+
throw new FileLockLostError({ lockPath }, options.messages ? { messages: options.messages } : {}); } });
|
|
212
|
+
completed = true;
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
try {
|
|
217
|
+
if (held())
|
|
218
|
+
filesystem.rmSync(lockPath, { force: true });
|
|
219
|
+
}
|
|
220
|
+
// A failed critical section keeps its original error; durable hosts also suppress release-only failures.
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (completed && !options.suppressReleaseErrors && errorCode(error) !== "ENOENT")
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Async adapter of the same stale and ownership policy; polling yields to other writers. */
|
|
228
|
+
export async function withFileLock({ lockPath, run }, options = {}) {
|
|
229
|
+
const filesystem = options.filesystem ?? asyncFs, s = settings(options), start = s.monotonicMs(), raw = ownership(s, options);
|
|
230
|
+
if (options.createParent)
|
|
231
|
+
await filesystem.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
232
|
+
let identity;
|
|
233
|
+
for (;;) {
|
|
234
|
+
let handle;
|
|
235
|
+
try {
|
|
236
|
+
handle = await filesystem.open(lockPath, "wx", 0o600);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (!isContendedLockError({ code: errorCode(error), platform: process.platform }))
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
if (handle) {
|
|
243
|
+
try {
|
|
244
|
+
identity = await handle.stat();
|
|
245
|
+
await handle.writeFile(raw, "utf8");
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
try {
|
|
249
|
+
const created = await handle.stat(), current = await inspectAsync(lockPath, filesystem);
|
|
250
|
+
if (current && created.ino === current.ino && created.dev === current.dev)
|
|
251
|
+
await filesystem.rm(lockPath, { force: true });
|
|
252
|
+
}
|
|
253
|
+
catch { /* preserve creation failure */ }
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
await handle.close();
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
const observed = await inspectAsync(lockPath, filesystem);
|
|
262
|
+
if (observed) {
|
|
263
|
+
const current = await inspectAsync(lockPath, filesystem);
|
|
264
|
+
if (current && stale(observed, current, s)) {
|
|
265
|
+
const last = await inspectAsync(lockPath, filesystem);
|
|
266
|
+
if (last && unchanged(current, last)) {
|
|
267
|
+
// Reading a descriptor pins an inode, not the pathname. Re-stat the pathname after the
|
|
268
|
+
// final descriptor closes, so replacement during awaited inspection cannot be stolen.
|
|
269
|
+
try {
|
|
270
|
+
const atPath = await filesystem.stat(lockPath);
|
|
271
|
+
if (atPath.ino === last.ino && atPath.dev === last.dev && atPath.mtimeMs === last.mtimeMs) {
|
|
272
|
+
await filesystem.rm(lockPath, { force: true });
|
|
273
|
+
options.onStaleLockRemoved?.({ holder: current.holder });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (errorCode(error) !== "ENOENT")
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
checkTimeout(lockPath, observed?.holder, start, s, options);
|
|
284
|
+
await (options.sleep ?? (({ durationMs }) => new Promise(resolve => setTimeout(resolve, durationMs))))({ durationMs: s.pollMs });
|
|
285
|
+
}
|
|
286
|
+
const held = async () => { const current = await inspectAsync(lockPath, filesystem); return current && current.raw === raw && current.ino === identity.ino && current.dev === identity.dev; };
|
|
287
|
+
let completed = false;
|
|
288
|
+
try {
|
|
289
|
+
const value = await run({ lockPath, assertHeld: async (_required) => { if (!await held())
|
|
290
|
+
throw new FileLockLostError({ lockPath }, options.messages ? { messages: options.messages } : {}); } });
|
|
291
|
+
completed = true;
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
try {
|
|
296
|
+
if (await held())
|
|
297
|
+
await filesystem.rm(lockPath, { force: true });
|
|
298
|
+
}
|
|
299
|
+
// A failed critical section keeps its original error; durable hosts also suppress release-only failures.
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (completed && !options.suppressReleaseErrors && errorCode(error) !== "ENOENT")
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
//# sourceMappingURL=file-lock.js.map
|