@linxin666/dsh-client-ui-task-board 0.2.7 → 0.2.8
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/lib/index.js +87 -4
- package/lib/types/host-ledger.d.ts +9 -0
- package/package.json +17 -14
- package/src/host-ledger.ts +105 -12
package/lib/index.js
CHANGED
|
@@ -852,8 +852,48 @@ function cloneTasks(tasks) {
|
|
|
852
852
|
function hasOpenExecution(task) {
|
|
853
853
|
return task.executions.some((execution) => execution.endedAt === void 0);
|
|
854
854
|
}
|
|
855
|
+
/**
|
|
856
|
+
* Process states that are dead but still occupy the PID table: `Z` (zombie)
|
|
857
|
+
* and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
|
|
858
|
+
* alive, so a crash leftover whose child was never reaped would otherwise be
|
|
859
|
+
* mistaken for a live owner and block ledger startup forever.
|
|
860
|
+
*/
|
|
861
|
+
const DEAD_STATES = /* @__PURE__ */ new Set(["Z", "X"]);
|
|
862
|
+
/**
|
|
863
|
+
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
864
|
+
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
865
|
+
* directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
|
|
866
|
+
* has no zombie state, so it returns undefined and the kill(0) probe alone
|
|
867
|
+
* is authoritative there.
|
|
868
|
+
*/
|
|
869
|
+
function processState(pid) {
|
|
870
|
+
if (process.platform === "linux") try {
|
|
871
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
872
|
+
const end = stat.lastIndexOf(")");
|
|
873
|
+
if (end === -1) return void 0;
|
|
874
|
+
return stat.slice(end + 2).split(" ")[0] || void 0;
|
|
875
|
+
} catch {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (process.platform === "win32") return void 0;
|
|
879
|
+
try {
|
|
880
|
+
const probe = spawnSync("ps", [
|
|
881
|
+
"-o",
|
|
882
|
+
"stat=",
|
|
883
|
+
"-p",
|
|
884
|
+
String(pid)
|
|
885
|
+
], { timeout: PROCESS_PROBE_TIMEOUT_MS });
|
|
886
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
|
|
887
|
+
const state = probe.stdout.toString("utf8").trim();
|
|
888
|
+
return state.length > 0 ? state[0] : void 0;
|
|
889
|
+
} catch {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
855
893
|
function processIsAlive(pid) {
|
|
856
894
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
895
|
+
const state = processState(pid);
|
|
896
|
+
if (state !== void 0 && DEAD_STATES.has(state)) return false;
|
|
857
897
|
try {
|
|
858
898
|
process.kill(pid, 0);
|
|
859
899
|
return true;
|
|
@@ -865,6 +905,28 @@ const PROCESS_PROBE_TIMEOUT_MS = 3e3;
|
|
|
865
905
|
let ownStartTime;
|
|
866
906
|
let ownStartTimeResolved = false;
|
|
867
907
|
/**
|
|
908
|
+
* Exact process start time (Unix epoch ms) on Linux, read straight from
|
|
909
|
+
* /proc (field 22 = start ticks since boot, btime = boot epoch seconds).
|
|
910
|
+
* No subprocess and no rounding, so the recorded `startedAt` from a previous
|
|
911
|
+
* boot compares exactly against the live process identity.
|
|
912
|
+
*/
|
|
913
|
+
function linuxStartTimeMs(pid) {
|
|
914
|
+
try {
|
|
915
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
916
|
+
const end = stat.lastIndexOf(")");
|
|
917
|
+
if (end === -1) return void 0;
|
|
918
|
+
const ticks = Number(stat.slice(end + 2).split(" ")[19]);
|
|
919
|
+
if (!Number.isFinite(ticks)) return void 0;
|
|
920
|
+
const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync("/proc/stat", "utf8"));
|
|
921
|
+
if (bootMatch === null) return void 0;
|
|
922
|
+
const btime = Number(bootMatch[1]);
|
|
923
|
+
if (!Number.isFinite(btime)) return void 0;
|
|
924
|
+
return btime * 1e3 + ticks * 1e3 / 100;
|
|
925
|
+
} catch {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
868
930
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
869
931
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
870
932
|
* crash leftover whose PID was reused by an unrelated process (issue #786)
|
|
@@ -872,6 +934,7 @@ let ownStartTimeResolved = false;
|
|
|
872
934
|
* undefined when the platform probe is unavailable; callers fail closed.
|
|
873
935
|
*/
|
|
874
936
|
function processStartTimeMs(pid) {
|
|
937
|
+
if (process.platform === "linux") return linuxStartTimeMs(pid);
|
|
875
938
|
if (process.platform === "win32") {
|
|
876
939
|
const probe = spawnSync("powershell", [
|
|
877
940
|
"-NoProfile",
|
|
@@ -924,6 +987,21 @@ function ownProcessStartTimeMs() {
|
|
|
924
987
|
}
|
|
925
988
|
return ownStartTime;
|
|
926
989
|
}
|
|
990
|
+
/**
|
|
991
|
+
* Bounded tolerance for legacy lock records. Locks written before the
|
|
992
|
+
* ms-precise probe recorded `startedAt` from `ps -o lstart=` at whole-second
|
|
993
|
+
* resolution; probing the SAME live process exactly (via /proc) then differs
|
|
994
|
+
* in the sub-second remainder. Treating that as PID reuse would steal a live
|
|
995
|
+
* owner's lock during a rolling upgrade and start a second ledger writer.
|
|
996
|
+
* Records written by the ms-precise probe carry `probe: 'exact'` and are
|
|
997
|
+
* compared strictly; anything else (older locks, second-granularity probes)
|
|
998
|
+
* falls back to this bounded tolerance.
|
|
999
|
+
*/
|
|
1000
|
+
const LEGACY_START_TOLERANCE_MS = 2e3;
|
|
1001
|
+
/** Whether the recorded start time proves the recorded PID is another process. */
|
|
1002
|
+
function startTimeMismatch(recorded, actual, exact) {
|
|
1003
|
+
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS;
|
|
1004
|
+
}
|
|
927
1005
|
function betterExecution(a, b) {
|
|
928
1006
|
if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
|
|
929
1007
|
if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
|
|
@@ -1369,10 +1447,13 @@ var HostTaskLedger = class {
|
|
|
1369
1447
|
acquireLock() {
|
|
1370
1448
|
for (let attempt = 0; attempt < 2; attempt += 1) try {
|
|
1371
1449
|
const fd = openSync(this.lockFile, "wx", 384);
|
|
1450
|
+
const startedAt = ownProcessStartTimeMs();
|
|
1451
|
+
const probe = process.platform === "linux" || process.platform === "win32" ? "exact" : "legacy";
|
|
1372
1452
|
writeFileSync(fd, JSON.stringify({
|
|
1373
1453
|
pid: process.pid,
|
|
1374
1454
|
token: this.lockToken,
|
|
1375
|
-
startedAt
|
|
1455
|
+
startedAt,
|
|
1456
|
+
probe
|
|
1376
1457
|
}), { encoding: "utf8" });
|
|
1377
1458
|
fsyncSync(fd);
|
|
1378
1459
|
try {
|
|
@@ -1383,23 +1464,25 @@ var HostTaskLedger = class {
|
|
|
1383
1464
|
if (error.code !== "EEXIST") throw error;
|
|
1384
1465
|
let pid;
|
|
1385
1466
|
let ownerStartedAt;
|
|
1467
|
+
let ownerExact = false;
|
|
1386
1468
|
try {
|
|
1387
1469
|
const owner = JSON.parse(readFileSync(this.lockFile, "utf8"));
|
|
1388
1470
|
if (typeof owner.pid === "number") pid = owner.pid;
|
|
1389
1471
|
if (typeof owner.startedAt === "number") ownerStartedAt = owner.startedAt;
|
|
1472
|
+
ownerExact = owner.probe === "exact";
|
|
1390
1473
|
} catch {
|
|
1391
|
-
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`);
|
|
1474
|
+
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`);
|
|
1392
1475
|
}
|
|
1393
1476
|
if (pid !== void 0 && processIsAlive(pid)) {
|
|
1394
1477
|
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid);
|
|
1395
|
-
if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? actualStartedAt
|
|
1478
|
+
if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) : (() => {
|
|
1396
1479
|
try {
|
|
1397
1480
|
return statSync(this.lockFile).mtimeMs < actualStartedAt;
|
|
1398
1481
|
} catch {
|
|
1399
1482
|
return true;
|
|
1400
1483
|
}
|
|
1401
1484
|
})()))) {
|
|
1402
|
-
const hint = ownerStartedAt !== void 0 && actualStartedAt
|
|
1485
|
+
const hint = ownerStartedAt !== void 0 && actualStartedAt !== void 0 && !startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) ? "" : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`;
|
|
1403
1486
|
throw new Error(`task-board ledger is already owned by process ${pid}${hint}`);
|
|
1404
1487
|
}
|
|
1405
1488
|
}
|
|
@@ -9,6 +9,15 @@ export interface OpenedRun {
|
|
|
9
9
|
task: TaskRecord;
|
|
10
10
|
execution: ExecutionRecord;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
14
|
+
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
15
|
+
* directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
|
|
16
|
+
* has no zombie state, so it returns undefined and the kill(0) probe alone
|
|
17
|
+
* is authoritative there.
|
|
18
|
+
*/
|
|
19
|
+
export declare function processState(pid: number): string | undefined;
|
|
20
|
+
export declare function processIsAlive(pid: number): boolean;
|
|
12
21
|
export declare class HostTaskLedger {
|
|
13
22
|
private readonly now;
|
|
14
23
|
private document;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
3
|
"description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.8",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"./package.json": "./package.json"
|
|
23
23
|
},
|
|
24
24
|
"dsh": {
|
|
25
|
+
"engines": {
|
|
26
|
+
"dsh": ">=0.1.1-rc.1"
|
|
27
|
+
},
|
|
25
28
|
"bundle": {
|
|
26
29
|
"patch": "./cordis.patch.yml"
|
|
27
30
|
},
|
|
@@ -35,8 +38,8 @@
|
|
|
35
38
|
}
|
|
36
39
|
},
|
|
37
40
|
"peerDependencies": {
|
|
38
|
-
"@deepseek-ai/dsh-agent": "^0.1.1-rc.
|
|
39
|
-
"@deepseek-ai/dsh-commands": "^0.1.1-rc.
|
|
41
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
42
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
40
43
|
"react": "^18.2.0"
|
|
41
44
|
},
|
|
42
45
|
"dependencies": {
|
|
@@ -44,17 +47,17 @@
|
|
|
44
47
|
},
|
|
45
48
|
"devDependencies": {
|
|
46
49
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
47
|
-
"@deepseek-ai/dsh-agent": "^0.1.1-rc.
|
|
48
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.
|
|
49
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.
|
|
50
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.
|
|
51
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.
|
|
52
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.
|
|
53
|
-
"@deepseek-ai/dsh-commands": "^0.1.1-rc.
|
|
54
|
-
"@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.
|
|
55
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.
|
|
56
|
-
"@deepseek-ai/dsh-settings": "^0.1.1-rc.
|
|
57
|
-
"@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.
|
|
50
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
|
|
58
61
|
"@types/react": "~18.3.1",
|
|
59
62
|
"@types/react-dom": "^18.3.5",
|
|
60
63
|
"@types/node": "^22.20.0",
|
package/src/host-ledger.ts
CHANGED
|
@@ -59,8 +59,47 @@ function hasOpenExecution(task: TaskRecord): boolean {
|
|
|
59
59
|
return task.executions.some(execution => execution.endedAt === undefined)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Process states that are dead but still occupy the PID table: `Z` (zombie)
|
|
64
|
+
* and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
|
|
65
|
+
* alive, so a crash leftover whose child was never reaped would otherwise be
|
|
66
|
+
* mistaken for a live owner and block ledger startup forever.
|
|
67
|
+
*/
|
|
68
|
+
const DEAD_STATES = new Set(['Z', 'X'])
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
72
|
+
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
73
|
+
* directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
|
|
74
|
+
* has no zombie state, so it returns undefined and the kill(0) probe alone
|
|
75
|
+
* is authoritative there.
|
|
76
|
+
*/
|
|
77
|
+
export function processState(pid: number): string | undefined {
|
|
78
|
+
if (process.platform === 'linux') {
|
|
79
|
+
try {
|
|
80
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
81
|
+
const end = stat.lastIndexOf(')')
|
|
82
|
+
if (end === -1) return undefined
|
|
83
|
+
return stat.slice(end + 2).split(' ')[0] || undefined
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined // no such process (or unreadable)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (process.platform === 'win32') return undefined
|
|
89
|
+
try {
|
|
90
|
+
const probe = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS })
|
|
91
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
92
|
+
const state = probe.stdout.toString('utf8').trim()
|
|
93
|
+
return state.length > 0 ? state[0] : undefined
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function processIsAlive(pid: number): boolean {
|
|
63
100
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
|
101
|
+
const state = processState(pid)
|
|
102
|
+
if (state !== undefined && DEAD_STATES.has(state)) return false
|
|
64
103
|
try {
|
|
65
104
|
process.kill(pid, 0)
|
|
66
105
|
return true
|
|
@@ -74,6 +113,29 @@ const PROCESS_PROBE_TIMEOUT_MS = 3000
|
|
|
74
113
|
let ownStartTime: number | undefined
|
|
75
114
|
let ownStartTimeResolved = false
|
|
76
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Exact process start time (Unix epoch ms) on Linux, read straight from
|
|
118
|
+
* /proc (field 22 = start ticks since boot, btime = boot epoch seconds).
|
|
119
|
+
* No subprocess and no rounding, so the recorded `startedAt` from a previous
|
|
120
|
+
* boot compares exactly against the live process identity.
|
|
121
|
+
*/
|
|
122
|
+
function linuxStartTimeMs(pid: number): number | undefined {
|
|
123
|
+
try {
|
|
124
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
125
|
+
const end = stat.lastIndexOf(')')
|
|
126
|
+
if (end === -1) return undefined
|
|
127
|
+
const ticks = Number(stat.slice(end + 2).split(' ')[19])
|
|
128
|
+
if (!Number.isFinite(ticks)) return undefined
|
|
129
|
+
const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync('/proc/stat', 'utf8'))
|
|
130
|
+
if (bootMatch === null) return undefined
|
|
131
|
+
const btime = Number(bootMatch[1])
|
|
132
|
+
if (!Number.isFinite(btime)) return undefined
|
|
133
|
+
return btime * 1000 + (ticks * 1000) / 100 // USER_HZ is 100 on Linux
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
77
139
|
/**
|
|
78
140
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
79
141
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
@@ -82,6 +144,7 @@ let ownStartTimeResolved = false
|
|
|
82
144
|
* undefined when the platform probe is unavailable; callers fail closed.
|
|
83
145
|
*/
|
|
84
146
|
function processStartTimeMs(pid: number): number | undefined {
|
|
147
|
+
if (process.platform === 'linux') return linuxStartTimeMs(pid)
|
|
85
148
|
if (process.platform === 'win32') {
|
|
86
149
|
const probe = spawnSync(
|
|
87
150
|
'powershell',
|
|
@@ -93,8 +156,8 @@ function processStartTimeMs(pid: number): number | undefined {
|
|
|
93
156
|
const started = Number(probe.stdout.toString('utf8').trim())
|
|
94
157
|
return Number.isFinite(started) ? started : undefined
|
|
95
158
|
}
|
|
96
|
-
// POSIX: ps lstart with a forced English locale, falling
|
|
97
|
-
// elapsed-seconds column when lstart cannot be parsed.
|
|
159
|
+
// Other POSIX (macOS...): ps lstart with a forced English locale, falling
|
|
160
|
+
// back to the elapsed-seconds column when lstart cannot be parsed.
|
|
98
161
|
const env = { ...process.env, LC_ALL: 'C' }
|
|
99
162
|
const probe = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
|
|
100
163
|
if (probe.status === 0 && probe.stdout.length > 0) {
|
|
@@ -116,6 +179,23 @@ function ownProcessStartTimeMs(): number | undefined {
|
|
|
116
179
|
return ownStartTime
|
|
117
180
|
}
|
|
118
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Bounded tolerance for legacy lock records. Locks written before the
|
|
184
|
+
* ms-precise probe recorded `startedAt` from `ps -o lstart=` at whole-second
|
|
185
|
+
* resolution; probing the SAME live process exactly (via /proc) then differs
|
|
186
|
+
* in the sub-second remainder. Treating that as PID reuse would steal a live
|
|
187
|
+
* owner's lock during a rolling upgrade and start a second ledger writer.
|
|
188
|
+
* Records written by the ms-precise probe carry `probe: 'exact'` and are
|
|
189
|
+
* compared strictly; anything else (older locks, second-granularity probes)
|
|
190
|
+
* falls back to this bounded tolerance.
|
|
191
|
+
*/
|
|
192
|
+
const LEGACY_START_TOLERANCE_MS = 2000
|
|
193
|
+
|
|
194
|
+
/** Whether the recorded start time proves the recorded PID is another process. */
|
|
195
|
+
function startTimeMismatch(recorded: number, actual: number, exact: boolean): boolean {
|
|
196
|
+
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS
|
|
197
|
+
}
|
|
198
|
+
|
|
119
199
|
function betterExecution(a: ExecutionRecord, b: ExecutionRecord): ExecutionRecord {
|
|
120
200
|
if (a.endedAt === undefined && b.endedAt !== undefined) return b
|
|
121
201
|
if (b.endedAt === undefined && a.endedAt !== undefined) return a
|
|
@@ -560,7 +640,13 @@ export class HostTaskLedger {
|
|
|
560
640
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
561
641
|
try {
|
|
562
642
|
const fd = openSync(this.lockFile, 'wx', 0o600)
|
|
563
|
-
|
|
643
|
+
const startedAt = ownProcessStartTimeMs()
|
|
644
|
+
// Linux /proc and Windows PowerShell probes are ms-precise; locks they
|
|
645
|
+
// write are compared strictly. Other POSIX probes (ps) stay
|
|
646
|
+
// second-granularity, so their records are compared with the bounded
|
|
647
|
+
// legacy tolerance.
|
|
648
|
+
const probe = process.platform === 'linux' || process.platform === 'win32' ? 'exact' : 'legacy'
|
|
649
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken, startedAt, probe }), { encoding: 'utf8' })
|
|
564
650
|
fsyncSync(fd)
|
|
565
651
|
try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ }
|
|
566
652
|
return fd
|
|
@@ -569,29 +655,36 @@ export class HostTaskLedger {
|
|
|
569
655
|
if (code !== 'EEXIST') throw error
|
|
570
656
|
let pid: number | undefined
|
|
571
657
|
let ownerStartedAt: number | undefined
|
|
658
|
+
let ownerExact = false
|
|
572
659
|
try {
|
|
573
|
-
const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown }
|
|
660
|
+
const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown; probe?: unknown }
|
|
574
661
|
if (typeof owner.pid === 'number') pid = owner.pid
|
|
575
662
|
if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
|
|
663
|
+
ownerExact = owner.probe === 'exact'
|
|
576
664
|
} catch {
|
|
577
|
-
|
|
665
|
+
// A power-loss mid-write can leave a truncated lock; the same event
|
|
666
|
+
// killed the writer, so fail closed but explain the recovery.
|
|
667
|
+
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`)
|
|
578
668
|
}
|
|
579
669
|
if (pid !== undefined && processIsAlive(pid)) {
|
|
580
670
|
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
|
|
581
671
|
// A reused PID is exposed when the live process identity no longer
|
|
582
|
-
// matches the recorded one: either the recorded start time differs
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
672
|
+
// matches the recorded one: either the recorded start time differs
|
|
673
|
+
// beyond the probe's resolution (strict for ms-precise 'exact'
|
|
674
|
+
// records, a bounded legacy tolerance for old second-granularity
|
|
675
|
+
// records written by ps), or (legacy locks without a start time)
|
|
676
|
+
// the lock file predates the live process and therefore cannot
|
|
677
|
+
// have been written by it. Takeover is safe in both cases — the
|
|
678
|
+
// original owner is gone.
|
|
586
679
|
const staleReuse = actualStartedAt !== undefined && (
|
|
587
680
|
ownerStartedAt !== undefined
|
|
588
|
-
? actualStartedAt
|
|
681
|
+
? startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact)
|
|
589
682
|
: (() => {
|
|
590
683
|
try { return statSync(this.lockFile).mtimeMs < actualStartedAt } catch { return true }
|
|
591
684
|
})()
|
|
592
685
|
)
|
|
593
686
|
if (!staleReuse) {
|
|
594
|
-
const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt
|
|
687
|
+
const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt !== undefined && !startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact)
|
|
595
688
|
const hint = confirmedOwner
|
|
596
689
|
? ''
|
|
597
690
|
: `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`
|