@particle-academy/prism-harness 0.8.0 → 0.9.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/README.md +85 -15
- package/dist/address-index.d.ts +35 -0
- package/dist/address-index.js +170 -0
- package/dist/address.d.ts +25 -0
- package/dist/address.js +83 -0
- package/dist/harness.d.ts +41 -2
- package/dist/harness.js +99 -4
- package/dist/index.d.ts +11 -5
- package/dist/index.js +4 -1
- package/dist/modes.d.ts +10 -1
- package/dist/modes.js +53 -1
- package/dist/registration.d.ts +6 -0
- package/dist/registration.js +15 -0
- package/dist/runtime.d.ts +21 -15
- package/dist/runtime.js +85 -7
- package/dist/session.d.ts +12 -0
- package/dist/session.js +66 -11
- package/dist/stores/file-lock-identity.d.ts +20 -0
- package/dist/stores/file-lock-identity.js +74 -0
- package/dist/stores/file-lock-windows-identity.d.ts +2 -0
- package/dist/stores/file-lock-windows-identity.js +48 -0
- package/dist/stores/file-owner-lock.d.ts +28 -0
- package/dist/stores/file-owner-lock.js +280 -0
- package/dist/stores/file-session-store.d.ts +13 -6
- package/dist/stores/file-session-store.js +16 -183
- package/dist/stores/memory-session-store.d.ts +2 -0
- package/dist/stores/memory-session-store.js +2 -0
- package/dist/stores/session-store.d.ts +4 -0
- package/dist/subagent-runner.js +10 -3
- package/dist/subagents.d.ts +2 -1
- package/dist/subagents.js +10 -3
- package/dist/task-tools.js +6 -1
- package/dist/thread.d.ts +2 -0
- package/dist/thread.js +6 -0
- package/dist/tools.d.ts +28 -5
- package/dist/tools.js +159 -21
- package/package.json +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync, readlinkSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { windowsLockIdentity } from './file-lock-windows-identity.js';
|
|
4
|
+
let sampled = false;
|
|
5
|
+
let established = null;
|
|
6
|
+
let retryAt = 0;
|
|
7
|
+
let definitive = false;
|
|
8
|
+
let durationMs = 0;
|
|
9
|
+
/** Process metadata snapshot only; never runs a query or performs I/O. */
|
|
10
|
+
export function fileLockIdentityStatus() {
|
|
11
|
+
return Object.freeze({ sampled, recovery_available: established?.pid_namespace != null, definitive,
|
|
12
|
+
retry_after_ms: !sampled || definitive ? null : Math.max(0, retryAt - performance.now()),
|
|
13
|
+
sample_duration_ms: durationMs, code: sampled && established?.pid_namespace == null ? 'file_lock_identity_unavailable' : null });
|
|
14
|
+
}
|
|
15
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
/** Definitive metadata is permanent; transient failure is sampled at most once per minute. */
|
|
17
|
+
export function nativeLockIdentity() {
|
|
18
|
+
if (!sampled || (!definitive && performance.now() >= retryAt)) {
|
|
19
|
+
const start = performance.now();
|
|
20
|
+
const identity = readNativeLockIdentity();
|
|
21
|
+
durationMs = performance.now() - start;
|
|
22
|
+
established = identity === null ? null : Object.freeze(identity);
|
|
23
|
+
definitive = identity !== null && (identity.pid_namespace !== null || identity.platform === 'win32');
|
|
24
|
+
retryAt = performance.now() + 60000;
|
|
25
|
+
sampled = true;
|
|
26
|
+
}
|
|
27
|
+
return established;
|
|
28
|
+
}
|
|
29
|
+
function readNativeLockIdentity() {
|
|
30
|
+
let identity = null;
|
|
31
|
+
try {
|
|
32
|
+
if (process.platform === 'win32')
|
|
33
|
+
identity = windowsLockIdentity();
|
|
34
|
+
else if (process.platform === 'linux') {
|
|
35
|
+
const boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
36
|
+
if (!UUID.test(boot))
|
|
37
|
+
return null;
|
|
38
|
+
let realm = null;
|
|
39
|
+
try {
|
|
40
|
+
const value = readlinkSync('/proc/self/ns/pid');
|
|
41
|
+
if (/^pid:\[[0-9]+\]$/.test(value))
|
|
42
|
+
realm = value;
|
|
43
|
+
}
|
|
44
|
+
catch { /* unknown */ }
|
|
45
|
+
identity = { platform: 'linux', boot_id: boot.toLowerCase(), pid_namespace: realm };
|
|
46
|
+
}
|
|
47
|
+
else if (process.platform === 'darwin') {
|
|
48
|
+
const result = spawnSync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], { encoding: 'utf8', timeout: 15000, maxBuffer: 65536 });
|
|
49
|
+
const boot = result.stdout?.trim();
|
|
50
|
+
if (result.error || result.status !== 0 || !boot || !UUID.test(boot))
|
|
51
|
+
return null;
|
|
52
|
+
identity = { platform: 'darwin', boot_id: boot.toLowerCase(), pid_namespace: 'darwin:kernel' };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return identity;
|
|
59
|
+
}
|
|
60
|
+
export function sameLockIdentity(owner, local) {
|
|
61
|
+
return owner.pid_namespace !== null && local.pid_namespace !== null &&
|
|
62
|
+
owner.platform === local.platform && owner.boot_id === local.boot_id &&
|
|
63
|
+
owner.pid_namespace === local.pid_namespace;
|
|
64
|
+
}
|
|
65
|
+
/** Read-only signal-zero probe. Permission errors and reused live PIDs remain held. */
|
|
66
|
+
export function lockPidIsDead(pid) {
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return error.code === 'ESRCH';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { win32 } from 'node:path';
|
|
3
|
+
// Master2797: fixed metadata only, no compilation, no value payload or Job state.
|
|
4
|
+
// Resolve the first-party CIM module from PSHOME, never a user module search path.
|
|
5
|
+
const COMMAND = String.raw `
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
$PSModuleAutoloadingPreference = 'None'
|
|
8
|
+
try {
|
|
9
|
+
Import-Module -Name ($PSHOME + '\Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1') -ErrorAction Stop
|
|
10
|
+
Import-Module -Name ($PSHOME + '\Modules\CimCmdlets\CimCmdlets.psd1') -ErrorAction Stop
|
|
11
|
+
$boot = (CimCmdlets\Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
|
|
12
|
+
if ($null -eq $boot) { throw 'Missing boot identity' }
|
|
13
|
+
$ticks = $boot.ToUniversalTime().Ticks.ToString([Globalization.CultureInfo]::InvariantCulture)
|
|
14
|
+
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
|
15
|
+
try {
|
|
16
|
+
$key = $base.OpenSubKey('SYSTEM\CurrentControlSet\Control')
|
|
17
|
+
if ($null -eq $key) { throw 'Missing registry key' }
|
|
18
|
+
try { $present = $key.GetValueNames() -contains 'ContainerType' }
|
|
19
|
+
finally { $key.Dispose() }
|
|
20
|
+
} finally { $base.Dispose() }
|
|
21
|
+
[Console]::Out.WriteLine('boot=' + $ticks)
|
|
22
|
+
if ($present) { [Console]::Out.WriteLine('container=present') }
|
|
23
|
+
else { [Console]::Out.WriteLine('container=absent') }
|
|
24
|
+
} catch { exit 1 }
|
|
25
|
+
`;
|
|
26
|
+
export function windowsLockIdentity() {
|
|
27
|
+
const root = process.env.SystemRoot;
|
|
28
|
+
if (!root || !/^[a-z]:[\\/]/i.test(root) || !win32.isAbsolute(root))
|
|
29
|
+
return null;
|
|
30
|
+
const env = { SystemRoot: root, windir: root };
|
|
31
|
+
for (const key of ['TEMP', 'TMP'])
|
|
32
|
+
if (process.env[key])
|
|
33
|
+
env[key] = process.env[key];
|
|
34
|
+
const binary = win32.join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
35
|
+
try {
|
|
36
|
+
const result = spawnSync(binary, ['-NoProfile', '-NonInteractive', '-NoLogo', '-Command', COMMAND], { env, encoding: 'utf8', timeout: 15000, maxBuffer: 16384, windowsHide: true, shell: false });
|
|
37
|
+
if (result.error || result.status !== 0 || result.stderr !== '')
|
|
38
|
+
return null;
|
|
39
|
+
const match = /^boot=([1-9][0-9]{0,18})\r?\ncontainer=(present|absent)\r?\n$/.exec(result.stdout);
|
|
40
|
+
if (!match || match[0] !== result.stdout || BigInt(match[1]) > 3155378975999999999n)
|
|
41
|
+
return null;
|
|
42
|
+
return { platform: 'win32', boot_id: 'cim-utc-ticks:' + match[1],
|
|
43
|
+
pid_namespace: match[2] === 'absent' ? 'win32:kernel' : null };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LockIdentity } from './file-lock-identity.js';
|
|
2
|
+
export interface FileLockOwner {
|
|
3
|
+
version: 2;
|
|
4
|
+
pid: number;
|
|
5
|
+
identity: LockIdentity;
|
|
6
|
+
nonce: string;
|
|
7
|
+
released: boolean;
|
|
8
|
+
expires_at: number;
|
|
9
|
+
}
|
|
10
|
+
interface ReleaseProgress {
|
|
11
|
+
lockPath: string;
|
|
12
|
+
owner: FileLockOwner;
|
|
13
|
+
unlinked: boolean;
|
|
14
|
+
flushing?: Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface PendingFileLockRelease {
|
|
17
|
+
readonly key_digest: string;
|
|
18
|
+
readonly nonce: string;
|
|
19
|
+
}
|
|
20
|
+
/** Snapshot only: no filesystem access, retries, raw keys, paths or PIDs. */
|
|
21
|
+
export declare function pendingFileOwnerReleases(directory: string): readonly PendingFileLockRelease[];
|
|
22
|
+
/** Closed, bounded record; duplicate keys and fractional/exponent number tokens refuse. */
|
|
23
|
+
export declare function parseFileLockOwner(raw: Buffer, filename: string): FileLockOwner | null;
|
|
24
|
+
/** Unlink the observed nonce first. Missing old filename NEVER authorizes rmdir. */
|
|
25
|
+
export declare function cleanFileLockOwner(lockPath: string, owner: FileLockOwner, temporary?: boolean, progress?: ReleaseProgress): Promise<void>;
|
|
26
|
+
/** Decision 0022. Acquisition retry never contains callback execution. */
|
|
27
|
+
export declare function withFileOwnerLock<T>(path: string, key: string, callback: () => T | Promise<T>, ttl: number, wait: number): Promise<T>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { HarnessError } from '../errors.js';
|
|
5
|
+
import { nativeLockIdentity, sameLockIdentity, lockPidIsDead } from './file-lock-identity.js';
|
|
6
|
+
// Failed release is observable, then retried by this process's next lock call.
|
|
7
|
+
// Key by nonce: old and replacement owners of one path must never overwrite
|
|
8
|
+
// each other's pending state. No background timer keeps an idle worker alive.
|
|
9
|
+
const pendingReleases = new Map();
|
|
10
|
+
/** Snapshot only: no filesystem access, retries, raw keys, paths or PIDs. */
|
|
11
|
+
export function pendingFileOwnerReleases(directory) {
|
|
12
|
+
const parent = resolve(directory);
|
|
13
|
+
const rows = [...pendingReleases.values()].filter(progress => dirname(progress.lockPath) === parent)
|
|
14
|
+
.map(progress => Object.freeze({ key_digest: basename(progress.lockPath, '.json.lock'), nonce: progress.owner.nonce }));
|
|
15
|
+
rows.sort((a, b) => a.key_digest === b.key_digest ? (a.nonce < b.nonce ? -1 : a.nonce > b.nonce ? 1 : 0) : (a.key_digest < b.key_digest ? -1 : 1));
|
|
16
|
+
return Object.freeze(rows);
|
|
17
|
+
}
|
|
18
|
+
const OWNER = /^owner-([0-9a-f]{32})\.json$/;
|
|
19
|
+
const HELD = new Set(['EEXIST', 'ENOTEMPTY', 'EISDIR', 'ENOTDIR', 'EPERM', 'EACCES', 'EBUSY']);
|
|
20
|
+
function code(error) { return error?.code; }
|
|
21
|
+
function sleep(ms) { return new Promise(done => { setTimeout(done, ms); }); }
|
|
22
|
+
function object(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
|
|
23
|
+
function keys(value, expected) { return Object.keys(value).sort().join(',') === expected.sort().join(','); }
|
|
24
|
+
function bounded(value) { return typeof value === 'string' && value.length > 0 && Buffer.byteLength(value, 'utf8') <= 256 && Buffer.from(value, 'utf8').toString('utf8') === value; }
|
|
25
|
+
/** Closed, bounded record; duplicate keys and fractional/exponent number tokens refuse. */
|
|
26
|
+
export function parseFileLockOwner(raw, filename) {
|
|
27
|
+
if (raw.length > 4096 || raw.length === 0 || raw.at(-1) !== 10)
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw);
|
|
31
|
+
const value = JSON.parse(text);
|
|
32
|
+
const tokens = text.match(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}\[\]:,]|true|false|null/g) ?? [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
35
|
+
const token = tokens[i];
|
|
36
|
+
if (token.startsWith('"') && tokens[i + 1] === ':') {
|
|
37
|
+
const key = JSON.parse(token);
|
|
38
|
+
if (seen.has(key))
|
|
39
|
+
return null;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
}
|
|
42
|
+
else if (/^-?\d/.test(token) && (!/^-?\d+$/.test(token) || !Number.isSafeInteger(Number(token))))
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const match = OWNER.exec(filename);
|
|
46
|
+
if (!match || !object(value) || !keys(value, ['version', 'pid', 'identity', 'nonce', 'released', 'expires_at']))
|
|
47
|
+
return null;
|
|
48
|
+
if (value.version !== 2 || typeof value.pid !== 'number' || !Number.isInteger(value.pid) || value.pid < 1 || value.pid > 4294967295 ||
|
|
49
|
+
typeof value.released !== 'boolean' || typeof value.expires_at !== 'number' || !Number.isSafeInteger(value.expires_at) || value.nonce !== match[1])
|
|
50
|
+
return null;
|
|
51
|
+
const id = value.identity;
|
|
52
|
+
if (!object(id) || !keys(id, ['platform', 'boot_id', 'pid_namespace']) ||
|
|
53
|
+
typeof id.platform !== 'string' || !['linux', 'darwin', 'win32'].includes(id.platform) || !bounded(id.boot_id) ||
|
|
54
|
+
!(id.pid_namespace === null || bounded(id.pid_namespace)))
|
|
55
|
+
return null;
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function filename(owner) { return 'owner-' + owner.nonce + '.json'; }
|
|
63
|
+
function payload(owner) { return JSON.stringify(owner) + '\n'; }
|
|
64
|
+
/** Unlink the observed nonce first. Missing old filename NEVER authorizes rmdir. */
|
|
65
|
+
export async function cleanFileLockOwner(lockPath, owner, temporary = false, progress) {
|
|
66
|
+
if (temporary)
|
|
67
|
+
await unlink(join(lockPath, 'owner-' + owner.nonce + '.release.tmp')).catch(error => { if (code(error) !== 'ENOENT')
|
|
68
|
+
throw error; });
|
|
69
|
+
if (!progress?.unlinked)
|
|
70
|
+
for (let attempt = 0;; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
await unlink(join(lockPath, filename(owner)));
|
|
73
|
+
if (progress)
|
|
74
|
+
progress.unlinked = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (code(error) === 'ENOENT')
|
|
79
|
+
return;
|
|
80
|
+
if (attempt >= 4 || !HELD.has(code(error) ?? ''))
|
|
81
|
+
throw error;
|
|
82
|
+
await sleep(5);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (let attempt = 0;; attempt++) {
|
|
86
|
+
try {
|
|
87
|
+
await rmdir(lockPath);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// A replacement is populated, or somebody already removed the empty dir.
|
|
92
|
+
if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(code(error) ?? ''))
|
|
93
|
+
return;
|
|
94
|
+
if (attempt >= 4 || !HELD.has(code(error) ?? ''))
|
|
95
|
+
throw error;
|
|
96
|
+
await sleep(5);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function inspect(lockPath, local) {
|
|
101
|
+
let owner, temporary;
|
|
102
|
+
try {
|
|
103
|
+
const info = await lstat(lockPath);
|
|
104
|
+
if (info.isSymbolicLink())
|
|
105
|
+
return 'held';
|
|
106
|
+
if (info.isFile())
|
|
107
|
+
return 'legacy';
|
|
108
|
+
if (!info.isDirectory())
|
|
109
|
+
return 'held';
|
|
110
|
+
const entries = await readdir(lockPath);
|
|
111
|
+
const names = entries.filter(name => OWNER.test(name));
|
|
112
|
+
if (names.length !== 1)
|
|
113
|
+
return 'held';
|
|
114
|
+
const name = names[0], release = name.replace('.json', '.release.tmp');
|
|
115
|
+
if (entries.some(entry => entry !== name && entry !== release))
|
|
116
|
+
return 'held';
|
|
117
|
+
temporary = entries.includes(release);
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const stat = await lstat(join(lockPath, entry));
|
|
120
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096)
|
|
121
|
+
return 'held';
|
|
122
|
+
}
|
|
123
|
+
owner = parseFileLockOwner(await readFile(join(lockPath, name)), name);
|
|
124
|
+
// Entire established identity must match BEFORE the first liveness probe.
|
|
125
|
+
if (!owner || !local || !sameLockIdentity(owner.identity, local))
|
|
126
|
+
return 'held';
|
|
127
|
+
if (!owner.released && !lockPidIsDead(owner.pid))
|
|
128
|
+
return 'held';
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (code(error) === 'ENOENT')
|
|
132
|
+
return 'retry';
|
|
133
|
+
if (HELD.has(code(error) ?? ''))
|
|
134
|
+
return 'held';
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
await cleanFileLockOwner(lockPath, owner, temporary);
|
|
138
|
+
return 'retry';
|
|
139
|
+
}
|
|
140
|
+
async function release(progress) {
|
|
141
|
+
const { lockPath, owner } = progress;
|
|
142
|
+
if (progress.unlinked) {
|
|
143
|
+
await cleanFileLockOwner(lockPath, owner, false, progress);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const name = filename(owner);
|
|
147
|
+
try {
|
|
148
|
+
const observed = parseFileLockOwner(await readFile(join(lockPath, name)), name);
|
|
149
|
+
if (!observed || observed.nonce !== owner.nonce)
|
|
150
|
+
throw new Error('File lock owner changed before release');
|
|
151
|
+
if (observed.released) {
|
|
152
|
+
await cleanFileLockOwner(lockPath, owner, true, progress);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (code(error) === 'ENOENT')
|
|
158
|
+
return;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
const temporary = join(lockPath, 'owner-' + owner.nonce + '.release.tmp');
|
|
162
|
+
// The callback has finished; close the full release record before publishing it.
|
|
163
|
+
// While this exact live owner is still unreleased, nobody may reclaim it.
|
|
164
|
+
// Retrying its own known nonce temporary can safely replace a partial write.
|
|
165
|
+
await writeFile(temporary, payload({ ...owner, released: true }), { encoding: 'utf8', flag: 'w' });
|
|
166
|
+
for (let attempt = 0;; attempt++) {
|
|
167
|
+
try {
|
|
168
|
+
await rename(temporary, join(lockPath, name));
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (attempt >= 4 || !HELD.has(code(error) ?? ''))
|
|
173
|
+
throw error;
|
|
174
|
+
await sleep(5);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await cleanFileLockOwner(lockPath, owner, false, progress);
|
|
178
|
+
}
|
|
179
|
+
async function flushPendingReleases() {
|
|
180
|
+
for (const progress of [...pendingReleases.values()]) {
|
|
181
|
+
if (pendingReleases.get(progress.owner.nonce) !== progress)
|
|
182
|
+
continue;
|
|
183
|
+
if (!progress.flushing)
|
|
184
|
+
progress.flushing = release(progress);
|
|
185
|
+
const operation = progress.flushing;
|
|
186
|
+
try {
|
|
187
|
+
await operation;
|
|
188
|
+
if (pendingReleases.get(progress.owner.nonce) === progress)
|
|
189
|
+
pendingReleases.delete(progress.owner.nonce);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
if (progress.flushing === operation)
|
|
193
|
+
progress.flushing = undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Decision 0022. Acquisition retry never contains callback execution. */
|
|
198
|
+
export async function withFileOwnerLock(path, key, callback, ttl, wait) {
|
|
199
|
+
if (!Number.isFinite(ttl) || !Number.isFinite(wait) || wait < 0 || !Number.isSafeInteger(Math.floor(Date.now() / 1000 + ttl)))
|
|
200
|
+
throw new RangeError('Invalid file lock duration');
|
|
201
|
+
await flushPendingReleases();
|
|
202
|
+
const lockPath = resolve(path);
|
|
203
|
+
try {
|
|
204
|
+
if ((await lstat(lockPath)).isFile())
|
|
205
|
+
throw new HarnessError('session_locked', 'legacy_lock_requires_maintenance: ' + lockPath + '; stop_old_workers_then_inspect_and_archive');
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (code(error) !== 'ENOENT')
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
const identity = nativeLockIdentity();
|
|
212
|
+
const platform = process.platform;
|
|
213
|
+
if (!identity && platform !== 'win32' && platform !== 'linux' && platform !== 'darwin')
|
|
214
|
+
throw new HarnessError('session_locked', 'Unsupported file-lock platform');
|
|
215
|
+
const owner = { version: 2, pid: process.pid, identity: identity ?? { platform: platform, boot_id: 'unknown', pid_namespace: null }, nonce: randomBytes(16).toString('hex'), released: false, expires_at: Math.floor(Date.now() / 1000 + ttl) };
|
|
216
|
+
if (identity?.pid_namespace == null)
|
|
217
|
+
process.emitWarning(JSON.stringify({ key_digest: basename(lockPath, '.json.lock'), nonce: owner.nonce }), { code: 'file_lock_identity_unavailable' });
|
|
218
|
+
const candidate = lockPath + '.candidate-' + owner.nonce;
|
|
219
|
+
if (dirname(candidate) !== dirname(lockPath) || basename(candidate) === basename(lockPath))
|
|
220
|
+
throw new Error('Lock publication must stay inside its parent');
|
|
221
|
+
const deadline = performance.now() + wait * 1000;
|
|
222
|
+
await mkdir(candidate);
|
|
223
|
+
let published = false;
|
|
224
|
+
try {
|
|
225
|
+
await writeFile(join(candidate, filename(owner)), payload(owner), { encoding: 'utf8', flag: 'wx' });
|
|
226
|
+
for (;;) {
|
|
227
|
+
if (pendingReleases.size)
|
|
228
|
+
await flushPendingReleases();
|
|
229
|
+
// Node/Windows rename can replace a regular file. Refuse stale legacy
|
|
230
|
+
// locks before publication; stopping ALL legacy workers is mandatory.
|
|
231
|
+
try {
|
|
232
|
+
const existing = await lstat(lockPath);
|
|
233
|
+
if (existing.isFile())
|
|
234
|
+
throw new HarnessError('session_locked', 'legacy_lock_requires_maintenance: ' + lockPath + '; stop_old_workers_then_inspect_and_archive');
|
|
235
|
+
if (existing.isSymbolicLink() || !existing.isDirectory())
|
|
236
|
+
throw HarnessError.sessionLocked(key, wait);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (code(error) !== 'ENOENT')
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await rename(candidate, lockPath);
|
|
244
|
+
published = true;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (!HELD.has(code(error) ?? ''))
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
const state = await inspect(lockPath, identity);
|
|
252
|
+
if (state === 'legacy')
|
|
253
|
+
throw new HarnessError('session_locked', 'legacy_lock_requires_maintenance: ' + lockPath + '; stop_old_workers_then_inspect_and_archive');
|
|
254
|
+
if (state === 'retry' && performance.now() < deadline)
|
|
255
|
+
continue;
|
|
256
|
+
if (performance.now() >= deadline)
|
|
257
|
+
throw HarnessError.sessionLocked(key, wait);
|
|
258
|
+
await sleep(25);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
if (!published)
|
|
263
|
+
await cleanFileLockOwner(candidate, owner);
|
|
264
|
+
}
|
|
265
|
+
// Exactly one execution, with exact original error identity on normal cleanup.
|
|
266
|
+
try {
|
|
267
|
+
return await callback();
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
const progress = { lockPath, owner, unlinked: false };
|
|
271
|
+
try {
|
|
272
|
+
await release(progress);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
pendingReleases.set(owner.nonce, progress);
|
|
276
|
+
process.emitWarning(JSON.stringify({ key_digest: basename(lockPath, '.json.lock'), nonce: owner.nonce }), { code: 'file_lock_release_pending' });
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { JsonObject } from '../json.js';
|
|
2
|
+
import type { FileLockIdentityStatus } from './file-lock-identity.js';
|
|
3
|
+
import type { PendingFileLockRelease } from './file-owner-lock.js';
|
|
2
4
|
import type { AppendLogStore, Durability, SessionStore } from './session-store.js';
|
|
3
5
|
/**
|
|
4
6
|
* State on disk, as one JSON file per key. DURABLE, and it says so.
|
|
@@ -16,17 +18,20 @@ import type { AppendLogStore, Durability, SessionStore } from './session-store.j
|
|
|
16
18
|
* partial write here is a corrupted thread, and a process killed mid-write is
|
|
17
19
|
* ordinary rather than exotic.
|
|
18
20
|
*
|
|
19
|
-
* **The lock is cross-process.**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* other. Two workers on different machines sharing a network filesystem do NOT
|
|
24
|
-
* reliably, and no file lock can promise that; use a database or Redis there.
|
|
21
|
+
* **The lock is cross-process.** Decision 0022 uses an atomically published
|
|
22
|
+
* owner directory. TTL never proves death. Local filesystems on one host only;
|
|
23
|
+
* SMB/NFS are unsupported. Stop all old workers before upgrading and inspect
|
|
24
|
+
* legacy timestamp lockfiles explicitly. Use a database or Redis across hosts.
|
|
25
25
|
*/
|
|
26
26
|
export declare class FileSessionStore implements SessionStore, AppendLogStore {
|
|
27
27
|
#private;
|
|
28
28
|
constructor(directory: string);
|
|
29
29
|
durability(): Durability;
|
|
30
|
+
/** Local filesystem on one host; SMB/NFS sharing is unsupported. */
|
|
31
|
+
sharingScope(): 'host';
|
|
32
|
+
lockScope(): 'host' | 'process' | 'distributed' | 'unsupported';
|
|
33
|
+
/** Process discovery snapshot; this observer never starts metadata discovery. */
|
|
34
|
+
identityStatus(): FileLockIdentityStatus;
|
|
30
35
|
get(key: string): Promise<JsonObject | null>;
|
|
31
36
|
put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
|
|
32
37
|
forget(key: string): Promise<void>;
|
|
@@ -42,4 +47,6 @@ export declare class FileSessionStore implements SessionStore, AppendLogStore {
|
|
|
42
47
|
logLength(key: string): Promise<number | null>;
|
|
43
48
|
clearLog(key: string): Promise<void>;
|
|
44
49
|
withLock<T>(key: string, callback: () => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
|
|
50
|
+
/** Pending release diagnostics for this directory; length is the pending count. */
|
|
51
|
+
pendingReleases(): readonly PendingFileLockRelease[];
|
|
45
52
|
}
|