@mearl/daemon-core 2.9.5 → 2.9.6
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 +11 -3
- package/dist/daemon.d.ts +8 -7
- package/dist/daemon.js +92 -24
- package/dist/registry.d.ts +10 -2
- package/dist/registry.js +172 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,9 +34,13 @@ keyedLogFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.log
|
|
|
34
34
|
interface DaemonRecord { pid: number; startedAt?: string; [k: string]: unknown }
|
|
35
35
|
|
|
36
36
|
ensureDir(dir: string): void
|
|
37
|
-
writeRecord(file: string, record: DaemonRecord): void //
|
|
37
|
+
writeRecord(file: string, record: DaemonRecord): void // 跨进程加锁,临时文件 + rename 原子写
|
|
38
|
+
claimRecord(file: string, record: DaemonRecord): boolean // 仅在文件不存在时原子占用
|
|
38
39
|
readRecord<T>(file: string): T | null
|
|
39
40
|
removeRecord(file: string): void
|
|
41
|
+
removeRecordIfPid(file: string, pid: number): boolean // 仅删除指定进程仍拥有的记录
|
|
42
|
+
writeRecordIfPid(file: string, pid: number, record: DaemonRecord): boolean
|
|
43
|
+
removeInvalidRecord(file: string): boolean // 清理无法解析的残留记录
|
|
40
44
|
listRecordFiles(dir: string): string[] // 目录下所有 *.json 绝对路径
|
|
41
45
|
readLiveRecords<T>(dir: string): T[] // 读取存活记录,并顺带清理死记录
|
|
42
46
|
```
|
|
@@ -47,7 +51,10 @@ readLiveRecords<T>(dir: string): T[] // 读取存活记录,
|
|
|
47
51
|
createDaemon(opts: CreateDaemonOptions): Daemon
|
|
48
52
|
```
|
|
49
53
|
|
|
50
|
-
围绕一对 `(configFile, logFile)`
|
|
54
|
+
围绕一对 `(configFile, logFile)` 构建单实例守护进程管理器。`start()` 先原子占用启动锁,
|
|
55
|
+
再以 `--foreground` detached 方式拉起消费方 CLI,并立即写入带子进程 pid 的 `starting`
|
|
56
|
+
记录。子进程就绪后覆盖该记录,父进程据此确认启动完成。并发 `start()` 会复用正在启动或
|
|
57
|
+
已经运行的子进程。多实例子系统按实例 key 派生各自的 `configFile` / `logFile`。
|
|
51
58
|
|
|
52
59
|
```typescript
|
|
53
60
|
interface CreateDaemonOptions {
|
|
@@ -58,11 +65,12 @@ interface CreateDaemonOptions {
|
|
|
58
65
|
foregroundArgs?: string[]; // 追加在 `--foreground` 之后的 argv
|
|
59
66
|
readyTimeoutMs?: number; // 等待子进程写记录的超时(默认 15000ms)
|
|
60
67
|
formatInfo?: (record: DaemonRecord) => string | null; // 起来后额外打印的信息
|
|
68
|
+
startingRecord?: (pid: number, startedAt: string) => DaemonRecord;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
interface Daemon {
|
|
64
72
|
start(): Promise<void>;
|
|
65
|
-
stop(): Promise<void>;
|
|
73
|
+
stop(expectedPid?: number): Promise<void>; // 指定 PID 时仅停止仍匹配的实例
|
|
66
74
|
restart(): Promise<void>;
|
|
67
75
|
status(): void;
|
|
68
76
|
logs(lines?: number): void;
|
package/dist/daemon.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface CreateDaemonOptions {
|
|
|
10
10
|
* module that is bundled INTO the cli entry.
|
|
11
11
|
*/
|
|
12
12
|
scriptPath: string;
|
|
13
|
-
/** Path to this instance's JSON record file
|
|
13
|
+
/** Path to this instance's JSON record file. */
|
|
14
14
|
configFile: string;
|
|
15
15
|
/** Path to this instance's log file (the detached child's stdout/stderr). */
|
|
16
16
|
logFile: string;
|
|
@@ -20,10 +20,12 @@ export interface CreateDaemonOptions {
|
|
|
20
20
|
readyTimeoutMs?: number;
|
|
21
21
|
/** Extra lines to print once the instance is up/queried (e.g. a connect hint). */
|
|
22
22
|
formatInfo?: (record: DaemonRecord) => string | null;
|
|
23
|
+
/** Record persisted immediately after spawn so concurrent starts can reuse the child. */
|
|
24
|
+
startingRecord?: (pid: number, startedAt: string) => DaemonRecord;
|
|
23
25
|
}
|
|
24
26
|
export interface Daemon {
|
|
25
27
|
start(): Promise<void>;
|
|
26
|
-
stop(): Promise<void>;
|
|
28
|
+
stop(expectedPid?: number): Promise<void>;
|
|
27
29
|
restart(): Promise<void>;
|
|
28
30
|
status(): void;
|
|
29
31
|
logs(lines?: number): void;
|
|
@@ -33,10 +35,9 @@ export interface Daemon {
|
|
|
33
35
|
/**
|
|
34
36
|
* Build a single-instance daemon manager around a `(configFile, logFile)` pair.
|
|
35
37
|
*
|
|
36
|
-
* The model: `start()` spawns the consumer's CLI
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* per instance, deriving `configFile`/`logFile` from the instance key.
|
|
38
|
+
* The model: `start()` atomically reserves startup, spawns the consumer's CLI
|
|
39
|
+
* detached with `--foreground`, and records the child pid as `starting`. The
|
|
40
|
+
* child replaces that marker when ready. Multi-instance subsystems create one
|
|
41
|
+
* manager per instance, deriving `configFile` / `logFile` from the instance key.
|
|
41
42
|
*/
|
|
42
43
|
export declare function createDaemon(opts: CreateDaemonOptions): Daemon;
|
package/dist/daemon.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { openSync } from 'node:fs';
|
|
2
|
+
import { closeSync, openSync } from 'node:fs';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { isAlive } from './process.js';
|
|
5
|
-
import { ensureDir, readRecord,
|
|
5
|
+
import { claimRecord, ensureDir, readRecord, removeInvalidRecord, removeRecordIfPid, writeRecord, } from './registry.js';
|
|
6
6
|
import { rotateLogIfNeeded, tailFile } from './log.js';
|
|
7
7
|
function sleep(ms) {
|
|
8
8
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -10,15 +10,15 @@ function sleep(ms) {
|
|
|
10
10
|
/**
|
|
11
11
|
* Build a single-instance daemon manager around a `(configFile, logFile)` pair.
|
|
12
12
|
*
|
|
13
|
-
* The model: `start()` spawns the consumer's CLI
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* per instance, deriving `configFile`/`logFile` from the instance key.
|
|
13
|
+
* The model: `start()` atomically reserves startup, spawns the consumer's CLI
|
|
14
|
+
* detached with `--foreground`, and records the child pid as `starting`. The
|
|
15
|
+
* child replaces that marker when ready. Multi-instance subsystems create one
|
|
16
|
+
* manager per instance, deriving `configFile` / `logFile` from the instance key.
|
|
18
17
|
*/
|
|
19
18
|
export function createDaemon(opts) {
|
|
20
|
-
const { name, scriptPath, configFile, logFile, foregroundArgs = [], readyTimeoutMs = 15_000, formatInfo, } = opts;
|
|
19
|
+
const { name, scriptPath, configFile, logFile, foregroundArgs = [], readyTimeoutMs = 15_000, formatInfo, startingRecord, } = opts;
|
|
21
20
|
const tag = `[${name}]`;
|
|
21
|
+
const startLockFile = `${configFile}.start.lock`;
|
|
22
22
|
function running() {
|
|
23
23
|
const rec = readRecord(configFile);
|
|
24
24
|
return rec && isAlive(rec.pid) ? rec : null;
|
|
@@ -31,28 +31,89 @@ export function createDaemon(opts) {
|
|
|
31
31
|
async function start() {
|
|
32
32
|
const live = running();
|
|
33
33
|
if (live) {
|
|
34
|
-
|
|
34
|
+
const state = live.daemonState === 'starting' ? 'starting' : 'running';
|
|
35
|
+
console.log(`${tag} Already ${state} (pid ${live.pid})`);
|
|
35
36
|
printInfo(live);
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
const existingLock = readRecord(startLockFile);
|
|
40
|
+
if (existingLock && isAlive(existingLock.pid)) {
|
|
41
|
+
console.log(`${tag} Start already in progress (pid ${existingLock.pid})`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (existingLock)
|
|
45
|
+
removeRecordIfPid(startLockFile, existingLock.pid);
|
|
46
|
+
else
|
|
47
|
+
removeInvalidRecord(startLockFile);
|
|
48
|
+
const lockPid = process.pid;
|
|
49
|
+
if (!claimRecord(startLockFile, { pid: lockPid, startedAt: new Date().toISOString() })) {
|
|
50
|
+
const competingLock = readRecord(startLockFile);
|
|
51
|
+
if (competingLock && isAlive(competingLock.pid)) {
|
|
52
|
+
console.log(`${tag} Start already in progress (pid ${competingLock.pid})`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (competingLock)
|
|
56
|
+
removeRecordIfPid(startLockFile, competingLock.pid);
|
|
57
|
+
else
|
|
58
|
+
removeInvalidRecord(startLockFile);
|
|
59
|
+
if (!claimRecord(startLockFile, { pid: lockPid, startedAt: new Date().toISOString() })) {
|
|
60
|
+
const currentLock = readRecord(startLockFile);
|
|
61
|
+
if (currentLock && isAlive(currentLock.pid)) {
|
|
62
|
+
console.log(`${tag} Start already in progress (pid ${currentLock.pid})`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.error(`${tag} Failed to acquire startup lock: ${startLockFile}`);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Clear any stale record left by a crashed run after taking the startup lock.
|
|
71
|
+
const stale = readRecord(configFile);
|
|
72
|
+
if (stale && !isAlive(stale.pid))
|
|
73
|
+
removeRecordIfPid(configFile, stale.pid);
|
|
74
|
+
else if (!stale)
|
|
75
|
+
removeInvalidRecord(configFile);
|
|
40
76
|
ensureDir(dirname(configFile));
|
|
41
77
|
ensureDir(dirname(logFile));
|
|
42
78
|
rotateLogIfNeeded(logFile);
|
|
79
|
+
const startedAt = new Date().toISOString();
|
|
43
80
|
const logFd = openSync(logFile, 'a');
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
let child;
|
|
82
|
+
try {
|
|
83
|
+
child = spawn(process.execPath, [scriptPath, '--foreground', ...foregroundArgs], {
|
|
84
|
+
detached: true,
|
|
85
|
+
stdio: ['ignore', logFd, logFd],
|
|
86
|
+
env: process.env,
|
|
87
|
+
windowsHide: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
removeRecordIfPid(startLockFile, lockPid);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
closeSync(logFd);
|
|
96
|
+
}
|
|
50
97
|
child.unref();
|
|
98
|
+
const childPid = child.pid;
|
|
99
|
+
if (!childPid) {
|
|
100
|
+
removeRecordIfPid(startLockFile, lockPid);
|
|
101
|
+
console.error(`${tag} Failed to start: spawned process has no pid`);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
writeRecord(startLockFile, { pid: childPid, startedAt });
|
|
106
|
+
claimRecord(configFile, {
|
|
107
|
+
...(startingRecord?.(childPid, startedAt) ?? { pid: childPid, startedAt }),
|
|
108
|
+
daemonState: 'starting',
|
|
109
|
+
});
|
|
110
|
+
removeRecordIfPid(startLockFile, childPid);
|
|
51
111
|
// Wait until the child writes its own pid into the record (i.e. it is ready),
|
|
52
112
|
// or until it dies / we time out.
|
|
53
113
|
const deadline = Date.now() + readyTimeoutMs;
|
|
54
114
|
while (Date.now() < deadline) {
|
|
55
|
-
if (!isAlive(
|
|
115
|
+
if (!isAlive(childPid)) {
|
|
116
|
+
removeRecordIfPid(configFile, childPid);
|
|
56
117
|
console.error(`${tag} Failed to start. Recent log:`);
|
|
57
118
|
const log = tailFile(logFile, 20);
|
|
58
119
|
if (log)
|
|
@@ -62,8 +123,8 @@ export function createDaemon(opts) {
|
|
|
62
123
|
return;
|
|
63
124
|
}
|
|
64
125
|
const rec = readRecord(configFile);
|
|
65
|
-
if (rec && rec.pid ===
|
|
66
|
-
console.log(`${tag} Started in background (pid ${
|
|
126
|
+
if (rec && rec.pid === childPid && rec.daemonState !== 'starting') {
|
|
127
|
+
console.log(`${tag} Started in background (pid ${childPid})`);
|
|
67
128
|
console.log(`${tag} Logs: ${logFile}`);
|
|
68
129
|
printInfo(rec);
|
|
69
130
|
return;
|
|
@@ -73,11 +134,18 @@ export function createDaemon(opts) {
|
|
|
73
134
|
console.error(`${tag} Start timed out after ${Math.round(readyTimeoutMs / 1000)}s. Check the log: ${logFile}`);
|
|
74
135
|
process.exitCode = 1;
|
|
75
136
|
}
|
|
76
|
-
async function stop() {
|
|
137
|
+
async function stop(expectedPid) {
|
|
77
138
|
const rec = readRecord(configFile);
|
|
139
|
+
if (expectedPid !== undefined && rec?.pid !== expectedPid) {
|
|
140
|
+
console.log(`${tag} Registered pid ${expectedPid} is no longer the current instance`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
78
143
|
if (!rec || !isAlive(rec.pid)) {
|
|
79
144
|
console.log(`${tag} Not running`);
|
|
80
|
-
|
|
145
|
+
if (rec)
|
|
146
|
+
removeRecordIfPid(configFile, rec.pid);
|
|
147
|
+
else
|
|
148
|
+
removeInvalidRecord(configFile);
|
|
81
149
|
return;
|
|
82
150
|
}
|
|
83
151
|
const pid = rec.pid;
|
|
@@ -101,7 +169,7 @@ export function createDaemon(opts) {
|
|
|
101
169
|
// ignore
|
|
102
170
|
}
|
|
103
171
|
}
|
|
104
|
-
|
|
172
|
+
removeRecordIfPid(configFile, pid);
|
|
105
173
|
console.log(`${tag} Stopped (pid ${pid})`);
|
|
106
174
|
}
|
|
107
175
|
async function restart() {
|
|
@@ -114,7 +182,7 @@ export function createDaemon(opts) {
|
|
|
114
182
|
console.log(`${tag} Status: stopped`);
|
|
115
183
|
return;
|
|
116
184
|
}
|
|
117
|
-
console.log(`${tag} Status: running`);
|
|
185
|
+
console.log(`${tag} Status: ${rec.daemonState === 'starting' ? 'starting' : 'running'}`);
|
|
118
186
|
console.log(` pid: ${rec.pid}`);
|
|
119
187
|
if (rec.startedAt)
|
|
120
188
|
console.log(` started at: ${rec.startedAt}`);
|
package/dist/registry.d.ts
CHANGED
|
@@ -7,12 +7,20 @@ export interface DaemonRecord {
|
|
|
7
7
|
export declare function ensureDir(dir: string): void;
|
|
8
8
|
/**
|
|
9
9
|
* Atomically write a record: serialise to a temp file then rename over the
|
|
10
|
-
* target, so a concurrent reader never observes a half-written file.
|
|
11
|
-
*
|
|
10
|
+
* target, so a concurrent reader never observes a half-written file. Temp file
|
|
11
|
+
* names include a random token so independent writes cannot collide.
|
|
12
12
|
*/
|
|
13
13
|
export declare function writeRecord(file: string, record: DaemonRecord): void;
|
|
14
|
+
/** Atomically create a record only when the target does not already exist. */
|
|
15
|
+
export declare function claimRecord(file: string, record: DaemonRecord): boolean;
|
|
14
16
|
export declare function readRecord<T extends DaemonRecord = DaemonRecord>(file: string): T | null;
|
|
15
17
|
export declare function removeRecord(file: string): void;
|
|
18
|
+
/** Remove a record only while it still belongs to the expected process. */
|
|
19
|
+
export declare function removeRecordIfPid(file: string, pid: number): boolean;
|
|
20
|
+
/** Replace a record only while it is still owned by the expected process. */
|
|
21
|
+
export declare function writeRecordIfPid(file: string, pid: number, record: DaemonRecord): boolean;
|
|
22
|
+
/** Remove an existing record whose contents are not a valid daemon record. */
|
|
23
|
+
export declare function removeInvalidRecord(file: string): boolean;
|
|
16
24
|
/** Absolute paths of every `*.json` record file in a directory. */
|
|
17
25
|
export declare function listRecordFiles(dir: string): string[];
|
|
18
26
|
/**
|
package/dist/registry.js
CHANGED
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, readdirSync, } from 'node:fs';
|
|
1
|
+
import { closeSync, existsSync, linkSync, openSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, readdirSync, } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { isAlive } from './process.js';
|
|
5
|
+
const LOCK_WAIT_MS = 10;
|
|
6
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
7
|
+
const OWNER_WRITE_GRACE_MS = 1_000;
|
|
8
|
+
const lockWaitBuffer = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
4
9
|
export function ensureDir(dir) {
|
|
5
10
|
if (!existsSync(dir)) {
|
|
6
11
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
7
12
|
}
|
|
8
13
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
function pauseForLock() {
|
|
15
|
+
Atomics.wait(lockWaitBuffer, 0, 0, LOCK_WAIT_MS);
|
|
16
|
+
}
|
|
17
|
+
function readRecordUnlocked(file) {
|
|
18
|
+
try {
|
|
19
|
+
if (!existsSync(file))
|
|
20
|
+
return null;
|
|
21
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
22
|
+
return parsed && typeof parsed.pid === 'number' ? parsed : null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function removeRecordUnlocked(file) {
|
|
29
|
+
rmSync(file, { force: true });
|
|
30
|
+
}
|
|
31
|
+
function writeRecordUnlocked(file, record) {
|
|
32
|
+
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
17
33
|
try {
|
|
18
34
|
writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
|
19
35
|
renameSync(tmp, file);
|
|
@@ -28,25 +44,161 @@ export function writeRecord(file, record) {
|
|
|
28
44
|
throw error;
|
|
29
45
|
}
|
|
30
46
|
}
|
|
31
|
-
|
|
47
|
+
function removeStaleLock(lockDir) {
|
|
48
|
+
const ownerFile = join(lockDir, 'owner.json');
|
|
49
|
+
const owner = readRecordUnlocked(ownerFile);
|
|
50
|
+
if (owner) {
|
|
51
|
+
if (isAlive(owner.pid))
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
try {
|
|
56
|
+
if (Date.now() - statSync(lockDir).mtimeMs < OWNER_WRITE_GRACE_MS)
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
32
63
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
36
|
-
return parsed && typeof parsed.pid === 'number' ? parsed : null;
|
|
64
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
65
|
+
return true;
|
|
37
66
|
}
|
|
38
67
|
catch {
|
|
39
|
-
return
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Serialize record ownership changes across processes sharing the registry. */
|
|
72
|
+
function withRecordLock(file, operation) {
|
|
73
|
+
ensureDir(dirname(file));
|
|
74
|
+
const lockDir = `${file}.registry.lock`;
|
|
75
|
+
const ownerFile = join(lockDir, 'owner.json');
|
|
76
|
+
const token = randomUUID();
|
|
77
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
78
|
+
while (true) {
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(lockDir, { mode: 0o700 });
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code !== 'EEXIST')
|
|
84
|
+
throw error;
|
|
85
|
+
if (!removeStaleLock(lockDir)) {
|
|
86
|
+
if (Date.now() >= deadline) {
|
|
87
|
+
throw new Error(`Timed out waiting for registry lock: ${file}`, { cause: error });
|
|
88
|
+
}
|
|
89
|
+
pauseForLock();
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
let ownerFd = null;
|
|
94
|
+
try {
|
|
95
|
+
ownerFd = openSync(ownerFile, 'wx', 0o600);
|
|
96
|
+
writeFileSync(ownerFd, JSON.stringify({ pid: process.pid, token }));
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (ownerFd !== null)
|
|
100
|
+
closeSync(ownerFd);
|
|
101
|
+
ownerFd = null;
|
|
102
|
+
if (error.code === 'EEXIST') {
|
|
103
|
+
if (Date.now() >= deadline) {
|
|
104
|
+
throw new Error(`Timed out acquiring registry lock: ${file}`, { cause: error });
|
|
105
|
+
}
|
|
106
|
+
pauseForLock();
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
if (ownerFd !== null)
|
|
113
|
+
closeSync(ownerFd);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return operation();
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
const owner = readRecordUnlocked(ownerFile);
|
|
120
|
+
if (owner?.token === token)
|
|
121
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
40
123
|
}
|
|
41
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Atomically write a record: serialise to a temp file then rename over the
|
|
127
|
+
* target, so a concurrent reader never observes a half-written file. Temp file
|
|
128
|
+
* names include a random token so independent writes cannot collide.
|
|
129
|
+
*/
|
|
130
|
+
export function writeRecord(file, record) {
|
|
131
|
+
withRecordLock(file, () => writeRecordUnlocked(file, record));
|
|
132
|
+
}
|
|
133
|
+
/** Atomically create a record only when the target does not already exist. */
|
|
134
|
+
export function claimRecord(file, record) {
|
|
135
|
+
return withRecordLock(file, () => {
|
|
136
|
+
const candidate = `${file}.${process.pid}.${randomUUID()}.claim`;
|
|
137
|
+
try {
|
|
138
|
+
writeFileSync(candidate, JSON.stringify(record), { mode: 0o600 });
|
|
139
|
+
linkSync(candidate, file);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error.code === 'EEXIST')
|
|
144
|
+
return false;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
rmSync(candidate, { force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function readRecord(file) {
|
|
153
|
+
return readRecordUnlocked(file);
|
|
154
|
+
}
|
|
42
155
|
export function removeRecord(file) {
|
|
43
156
|
try {
|
|
44
|
-
|
|
157
|
+
withRecordLock(file, () => removeRecordUnlocked(file));
|
|
45
158
|
}
|
|
46
159
|
catch {
|
|
47
160
|
// ignore
|
|
48
161
|
}
|
|
49
162
|
}
|
|
163
|
+
/** Remove a record only while it still belongs to the expected process. */
|
|
164
|
+
export function removeRecordIfPid(file, pid) {
|
|
165
|
+
try {
|
|
166
|
+
return withRecordLock(file, () => {
|
|
167
|
+
const record = readRecordUnlocked(file);
|
|
168
|
+
if (record?.pid !== pid)
|
|
169
|
+
return false;
|
|
170
|
+
removeRecordUnlocked(file);
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Replace a record only while it is still owned by the expected process. */
|
|
179
|
+
export function writeRecordIfPid(file, pid, record) {
|
|
180
|
+
return withRecordLock(file, () => {
|
|
181
|
+
const current = readRecordUnlocked(file);
|
|
182
|
+
if (current?.pid !== pid)
|
|
183
|
+
return false;
|
|
184
|
+
writeRecordUnlocked(file, record);
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/** Remove an existing record whose contents are not a valid daemon record. */
|
|
189
|
+
export function removeInvalidRecord(file) {
|
|
190
|
+
try {
|
|
191
|
+
return withRecordLock(file, () => {
|
|
192
|
+
if (!existsSync(file) || readRecordUnlocked(file))
|
|
193
|
+
return false;
|
|
194
|
+
removeRecordUnlocked(file);
|
|
195
|
+
return true;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
50
202
|
/** Absolute paths of every `*.json` record file in a directory. */
|
|
51
203
|
export function listRecordFiles(dir) {
|
|
52
204
|
try {
|
|
@@ -70,8 +222,11 @@ export function readLiveRecords(dir) {
|
|
|
70
222
|
if (rec && isAlive(rec.pid)) {
|
|
71
223
|
out.push(rec);
|
|
72
224
|
}
|
|
225
|
+
else if (rec) {
|
|
226
|
+
removeRecordIfPid(file, rec.pid);
|
|
227
|
+
}
|
|
73
228
|
else {
|
|
74
|
-
|
|
229
|
+
removeInvalidRecord(file);
|
|
75
230
|
}
|
|
76
231
|
}
|
|
77
232
|
return out;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mearl/daemon-core",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.6",
|
|
4
4
|
"description": "Shared Node daemon lifecycle primitives for Mearl — process liveness, JSON record registry, log rotation, and a detached daemon manager",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|