livedesk 0.1.445 → 0.1.447
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/bin/livedesk.js +974 -304
- package/bootstrap/client-process-identity.js +20 -6
- package/bootstrap/legacy-client-update.js +22 -20
- package/bootstrap/runtime-lock.js +362 -45
- package/bootstrap/runtime-shutdown.js +414 -0
- package/client/bin/livedesk-client-node.js +17 -4
- package/client/bin/livedesk-client.js +365 -104
- package/client/package.json +5 -5
- package/client/src/runtime/agent-process-lifecycle.js +194 -58
- package/client/src/runtime/windows-owned-process-manifest.js +235 -0
- package/electron/main.mjs +55 -38
- package/hub/package.json +1 -1
- package/hub/src/remote-hub.js +284 -88
- package/hub/src/server.js +173 -28
- package/hub/src/transport/relay-hub-control.js +1045 -0
- package/hub/src/transport/udp-rendezvous.js +2 -0
- package/package.json +6 -6
- package/runtime-core/src/role-transition-supervisor.js +20 -18
- package/runtime-core/src/windows-owned-process-tree.js +432 -0
- package/web/dist/assets/index-5opXQY8X.js +25 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-D3VhnrP6.js +0 -25
|
@@ -50,11 +50,25 @@ export function isKnownLiveDeskClientAgentProcess(processName, commandLine, exec
|
|
|
50
50
|
export function isKnownLiveDeskCaptureHelperProcess(processName, commandLine, executablePath = '') {
|
|
51
51
|
const normalizedName = String(processName || '').trim().replaceAll('\\', '/').split('/').pop() || '';
|
|
52
52
|
const normalizedCommandLine = String(commandLine || '').replaceAll('\\', '/');
|
|
53
|
-
const
|
|
54
|
-
const
|
|
53
|
+
const normalizedExecutablePath = normalizeExecutablePath(executablePath);
|
|
54
|
+
const argvExecutable = normalizeExecutablePath(firstCommandArgument(normalizedCommandLine));
|
|
55
|
+
const videoHelperPath = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264$/i;
|
|
56
|
+
const audioHelperPath = /(?:^|\/)\.livedesk\/helpers\/macos-sck-audio\/livedesk-sck-audio$/i;
|
|
57
|
+
const knownVideoCommand = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264(?:\s|["']|$)/i
|
|
58
|
+
.test(normalizedCommandLine);
|
|
59
|
+
const knownAudioCommand = /(?:^|\/)\.livedesk\/helpers\/macos-sck-audio\/livedesk-sck-audio(?:\s|["']|$)/i
|
|
55
60
|
.test(normalizedCommandLine);
|
|
56
|
-
const
|
|
57
|
-
||
|
|
58
|
-
|
|
59
|
-
||
|
|
61
|
+
const strictVideoExecutableProof = videoHelperPath.test(normalizedExecutablePath)
|
|
62
|
+
|| videoHelperPath.test(argvExecutable);
|
|
63
|
+
const strictAudioExecutableProof = audioHelperPath.test(normalizedExecutablePath)
|
|
64
|
+
|| audioHelperPath.test(argvExecutable);
|
|
65
|
+
const knownRawCaptureCommand = new RegExp(
|
|
66
|
+
`^(?:"[^"]*"|'[^']*'|\\S+)\\s+["']?\\S*${FAST_ROOT}/livedesk-raw-capture-helper\\.mjs(?:["']|\\s|$)`,
|
|
67
|
+
'i'
|
|
68
|
+
).test(normalizedCommandLine);
|
|
69
|
+
return (/^livedesk-sck-h264$/i.test(normalizedName) && knownVideoCommand)
|
|
70
|
+
|| (/^livedesk-sck-h2$/i.test(normalizedName) && strictVideoExecutableProof)
|
|
71
|
+
|| (/^livedesk-sck-audio$/i.test(normalizedName) && knownAudioCommand)
|
|
72
|
+
|| (/^livedesk-sck-aud$/i.test(normalizedName) && strictAudioExecutableProof)
|
|
73
|
+
|| (/^(?:node|node\.exe)$/i.test(normalizedName) && knownRawCaptureCommand);
|
|
60
74
|
}
|
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
import http from 'node:http';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import {
|
|
20
|
+
createWindowsProcessTreeTracker,
|
|
21
|
+
drainOwnedWindowsAgentTreeUntilStopped
|
|
22
|
+
} from '../client/src/runtime/agent-process-lifecycle.js';
|
|
19
23
|
|
|
20
24
|
const DEFAULT_RUNTIME_PORT = 5179;
|
|
21
25
|
const TARGET_ATTEMPT_LIMIT = 3;
|
|
@@ -1101,6 +1105,11 @@ export function spawnReplacement(version, preference, env = process.env, restart
|
|
|
1101
1105
|
stdio: outputFd === null ? 'ignore' : ['ignore', outputFd, outputFd],
|
|
1102
1106
|
windowsHide: true
|
|
1103
1107
|
});
|
|
1108
|
+
if (process.platform === 'win32' && Number(child?.pid || 0) > 1) {
|
|
1109
|
+
const tracker = createWindowsProcessTreeTracker(child);
|
|
1110
|
+
child.livedeskWindowsTreeTracker = tracker;
|
|
1111
|
+
child.once('exit', () => tracker.markRootExited());
|
|
1112
|
+
}
|
|
1104
1113
|
} finally {
|
|
1105
1114
|
if (outputFd !== null) closeSync(outputFd);
|
|
1106
1115
|
}
|
|
@@ -1208,27 +1217,20 @@ async function terminateReplacementTree(child) {
|
|
|
1208
1217
|
const pid = positivePid(child?.pid);
|
|
1209
1218
|
if (pid <= 1) return;
|
|
1210
1219
|
if (process.platform === 'win32') {
|
|
1211
|
-
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
clearTimeout(timer);
|
|
1221
|
-
resolve();
|
|
1222
|
-
};
|
|
1223
|
-
const timer = setTimeout(() => {
|
|
1224
|
-
try { killer.kill('SIGKILL'); } catch { /* taskkill already stopped */ }
|
|
1225
|
-
finish();
|
|
1226
|
-
}, 10_000);
|
|
1227
|
-
killer.once('error', finish);
|
|
1228
|
-
killer.once('exit', finish);
|
|
1220
|
+
if (!child.livedeskWindowsTreeTracker) {
|
|
1221
|
+
const tracker = createWindowsProcessTreeTracker(child);
|
|
1222
|
+
child.livedeskWindowsTreeTracker = tracker;
|
|
1223
|
+
child.once('exit', () => tracker.markRootExited());
|
|
1224
|
+
}
|
|
1225
|
+
const result = await drainOwnedWindowsAgentTreeUntilStopped(child, {
|
|
1226
|
+
reportTerminationFailure: message => console.error(
|
|
1227
|
+
`[LiveDesk update] ${message}`
|
|
1228
|
+
)
|
|
1229
1229
|
});
|
|
1230
|
-
if (
|
|
1231
|
-
|
|
1230
|
+
if (result?.ok === false) {
|
|
1231
|
+
throw result.error || new Error(
|
|
1232
|
+
`Exact Windows replacement tree pid=${pid} could not be drained.`
|
|
1233
|
+
);
|
|
1232
1234
|
}
|
|
1233
1235
|
return;
|
|
1234
1236
|
}
|
|
@@ -1,16 +1,100 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { existsSync, linkSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
const MAX_TAKEOVER_LEASE_AGE_MS = 15_000;
|
|
7
|
+
|
|
8
|
+
function readLock(path) {
|
|
6
9
|
try {
|
|
7
10
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
8
11
|
} catch {
|
|
9
12
|
return null;
|
|
10
13
|
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readLockText(path) {
|
|
17
|
+
try {
|
|
18
|
+
return readFileSync(path, 'utf8');
|
|
19
|
+
} catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function publishCompleteLock(targetPath, stagedPath, payload) {
|
|
25
|
+
writeFileSync(stagedPath, JSON.stringify(payload, null, 2), {
|
|
26
|
+
flag: 'wx',
|
|
27
|
+
mode: 0o600
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
linkSync(stagedPath, targetPath);
|
|
31
|
+
} finally {
|
|
32
|
+
try { unlinkSync(stagedPath); } catch { /* staged inode was already removed */ }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function releaseOwnedLock(path, payload) {
|
|
37
|
+
try {
|
|
38
|
+
const current = readLock(path);
|
|
39
|
+
if (Number(current?.pid) === payload.pid
|
|
40
|
+
&& current?.ownerToken === payload.ownerToken) {
|
|
41
|
+
unlinkSync(path);
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// The exact owner already released the lock.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function updateOwnedLockRole(path, payload, nextRole) {
|
|
49
|
+
const role = String(nextRole || '').trim().toLowerCase();
|
|
50
|
+
if (!['hub', 'client'].includes(role)) {
|
|
51
|
+
throw new Error(`Unsupported LiveDesk runtime-lock role: ${role || '<empty>'}.`);
|
|
52
|
+
}
|
|
53
|
+
const current = readLock(path);
|
|
54
|
+
if (Number(current?.pid) !== Number(payload?.pid)
|
|
55
|
+
|| current?.ownerToken !== payload?.ownerToken
|
|
56
|
+
|| current?.ownerInstanceMarker !== payload?.ownerInstanceMarker
|
|
57
|
+
|| current?.ownerStartOrder !== payload?.ownerStartOrder) {
|
|
58
|
+
throw new Error('LiveDesk runtime-lock ownership changed before its role could be updated.');
|
|
59
|
+
}
|
|
60
|
+
if (current.role === role) {
|
|
61
|
+
payload.role = role;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const next = {
|
|
66
|
+
...current,
|
|
67
|
+
role,
|
|
68
|
+
roleUpdatedAt: new Date().toISOString()
|
|
69
|
+
};
|
|
70
|
+
const stagedPath = `${path}.${process.pid}.${payload.ownerToken}.${randomBytes(6).toString('hex')}.role.pending`;
|
|
71
|
+
let renamed = false;
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(stagedPath, JSON.stringify(next, null, 2), {
|
|
74
|
+
flag: 'wx',
|
|
75
|
+
mode: 0o600
|
|
76
|
+
});
|
|
77
|
+
const revalidated = readLock(path);
|
|
78
|
+
if (Number(revalidated?.pid) !== Number(payload.pid)
|
|
79
|
+
|| revalidated?.ownerToken !== payload.ownerToken
|
|
80
|
+
|| revalidated?.ownerInstanceMarker !== payload.ownerInstanceMarker
|
|
81
|
+
|| revalidated?.ownerStartOrder !== payload.ownerStartOrder
|
|
82
|
+
|| revalidated?.role !== current.role) {
|
|
83
|
+
throw new Error('LiveDesk runtime-lock ownership changed during its role update.');
|
|
84
|
+
}
|
|
85
|
+
// The path remains continuously present: contenders observe either the old
|
|
86
|
+
// live role or the new live role, never a missing-lock acquisition window.
|
|
87
|
+
renameSync(stagedPath, path);
|
|
88
|
+
renamed = true;
|
|
89
|
+
payload.role = role;
|
|
90
|
+
} finally {
|
|
91
|
+
if (!renamed) {
|
|
92
|
+
try { unlinkSync(stagedPath); } catch { /* staged role update was never published */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isProcessAlive(pid) {
|
|
14
98
|
if (!Number.isInteger(pid) || pid <= 1) {
|
|
15
99
|
return false;
|
|
16
100
|
}
|
|
@@ -19,41 +103,274 @@ function isProcessAlive(pid) {
|
|
|
19
103
|
return true;
|
|
20
104
|
} catch (error) {
|
|
21
105
|
return error?.code !== 'ESRCH';
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function exactStaleOwnerProofMatches(lock, proof) {
|
|
110
|
+
const pid = Number(lock?.pid);
|
|
111
|
+
const ownerToken = String(lock?.ownerToken || '').trim();
|
|
112
|
+
const ownerInstanceMarker = String(lock?.ownerInstanceMarker || '').trim();
|
|
113
|
+
const ownerStartOrder = String(lock?.ownerStartOrder || '').trim();
|
|
114
|
+
return Number.isInteger(pid)
|
|
115
|
+
&& pid > 1
|
|
116
|
+
&& ownerToken.length >= 16
|
|
117
|
+
&& ownerInstanceMarker.length > 0
|
|
118
|
+
&& ownerStartOrder.length > 0
|
|
119
|
+
&& Number(proof?.pid) === pid
|
|
120
|
+
&& String(proof?.ownerToken || '').trim() === ownerToken
|
|
121
|
+
&& String(proof?.ownerInstanceMarker || '').trim() === ownerInstanceMarker
|
|
122
|
+
&& String(proof?.ownerStartOrder || '').trim() === ownerStartOrder;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isRuntimeOwnerAlive(lock, staleOwnerProof) {
|
|
126
|
+
return isProcessAlive(Number(lock?.pid))
|
|
127
|
+
&& !exactStaleOwnerProofMatches(lock, staleOwnerProof);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isCompleteTakeoverLease(lock, role = 'runtime-lock-takeover') {
|
|
131
|
+
const pid = Number(lock?.pid);
|
|
132
|
+
const ownerToken = String(lock?.ownerToken || '').trim();
|
|
133
|
+
const startedAt = String(lock?.startedAt || '').trim();
|
|
134
|
+
return Number.isInteger(pid)
|
|
135
|
+
&& pid > 1
|
|
136
|
+
&& lock?.role === role
|
|
137
|
+
&& /^[a-f0-9]{32}$/i.test(ownerToken)
|
|
138
|
+
&& Number.isFinite(Date.parse(startedAt));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isTakeoverLeaseOwnerAlive(lock) {
|
|
142
|
+
const startedAt = Date.parse(String(lock?.startedAt || ''));
|
|
143
|
+
const ageMs = Date.now() - startedAt;
|
|
144
|
+
// Takeover code performs only synchronous local filesystem operations. A
|
|
145
|
+
// lease older than this cannot still be a legitimate repair, even if its PID
|
|
146
|
+
// has since been reused by an unrelated long-lived process.
|
|
147
|
+
if (Number.isFinite(ageMs) && ageMs > MAX_TAKEOVER_LEASE_AGE_MS) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return isProcessAlive(Number(lock?.pid));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function acquireDeadTakeoverReclaimLease(path, observed, ownerInstanceMarker) {
|
|
154
|
+
if (!isCompleteTakeoverLease(observed) || isTakeoverLeaseOwnerAlive(observed)) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const payload = {
|
|
159
|
+
pid: process.pid,
|
|
160
|
+
role: 'runtime-lock-takeover-reclaim',
|
|
161
|
+
startedAt: new Date().toISOString(),
|
|
162
|
+
ownerToken: randomBytes(16).toString('hex'),
|
|
163
|
+
ownerInstanceMarker: String(ownerInstanceMarker || '').trim()
|
|
164
|
+
};
|
|
165
|
+
let generationToken = observed.ownerToken;
|
|
166
|
+
|
|
167
|
+
// Each dead recovery owner creates the next immutable generation path. The
|
|
168
|
+
// hard-link publication is the cross-process winner election: contenders
|
|
169
|
+
// can never delete/replace a live winner, while a crashed winner is followed
|
|
170
|
+
// through its token to a fresh generation instead of being unlinked with an
|
|
171
|
+
// unsafe read-then-delete race.
|
|
172
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
173
|
+
const leasePath = `${path}.reclaim.${generationToken}`;
|
|
174
|
+
const stagedPath = `${leasePath}.${process.pid}.${payload.ownerToken}.pending`;
|
|
175
|
+
const existingLease = readLock(leasePath);
|
|
176
|
+
if (existingLease || existsSync(leasePath)) {
|
|
177
|
+
if (!isCompleteTakeoverLease(existingLease, 'runtime-lock-takeover-reclaim')) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (isTakeoverLeaseOwnerAlive(existingLease)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
generationToken = existingLease.ownerToken;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
publishCompleteLock(leasePath, stagedPath, payload);
|
|
189
|
+
} catch {
|
|
190
|
+
try { unlinkSync(stagedPath); } catch { /* another contender won */ }
|
|
191
|
+
const winner = readLock(leasePath);
|
|
192
|
+
if (!isCompleteTakeoverLease(winner, 'runtime-lock-takeover-reclaim')) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (isTakeoverLeaseOwnerAlive(winner)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
generationToken = winner.ownerToken;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const current = readLock(path);
|
|
203
|
+
if (!isCompleteTakeoverLease(current)
|
|
204
|
+
|| Number(current.pid) !== Number(observed.pid)
|
|
205
|
+
|| current.ownerToken !== observed.ownerToken
|
|
206
|
+
|| current.startedAt !== observed.startedAt
|
|
207
|
+
|| isTakeoverLeaseOwnerAlive(current)) {
|
|
208
|
+
releaseOwnedLock(leasePath, payload);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
unlinkSync(path);
|
|
214
|
+
} catch {
|
|
215
|
+
releaseOwnedLock(leasePath, payload);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
path: leasePath,
|
|
220
|
+
payload,
|
|
221
|
+
release: () => releaseOwnedLock(leasePath, payload)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function getRuntimeLockPath(stateDir = join(os.homedir(), '.livedesk')) {
|
|
229
|
+
return join(stateDir, 'livedesk-runtime.lock');
|
|
230
|
+
}
|
|
28
231
|
|
|
29
|
-
export function acquireRuntimeLock({
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
232
|
+
export function acquireRuntimeLock({
|
|
233
|
+
stateDir,
|
|
234
|
+
role = '',
|
|
235
|
+
ownerInstanceMarker = '',
|
|
236
|
+
ownerStartOrder = '',
|
|
237
|
+
staleOwnerProof,
|
|
238
|
+
openExisting
|
|
239
|
+
} = {}) {
|
|
240
|
+
const path = getRuntimeLockPath(stateDir);
|
|
241
|
+
const payload = {
|
|
242
|
+
pid: process.pid,
|
|
243
|
+
role: String(role || '').trim(),
|
|
244
|
+
startedAt: new Date().toISOString(),
|
|
245
|
+
ownerToken: randomBytes(16).toString('hex'),
|
|
246
|
+
ownerInstanceMarker: String(ownerInstanceMarker || '').trim(),
|
|
247
|
+
ownerStartOrder: String(ownerStartOrder || '').trim()
|
|
248
|
+
};
|
|
249
|
+
const stagedPath = `${path}.${process.pid}.${payload.ownerToken}.pending`;
|
|
250
|
+
const takeoverPath = `${path}.takeover`;
|
|
251
|
+
const takeoverStagedPath = `${takeoverPath}.${process.pid}.${payload.ownerToken}.pending`;
|
|
252
|
+
const existingTakeover = readLock(takeoverPath);
|
|
253
|
+
let reclaimLease = null;
|
|
254
|
+
const releaseReclaimLease = () => {
|
|
255
|
+
reclaimLease?.release();
|
|
256
|
+
reclaimLease = null;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Stale repair is synchronous and normally lasts only a few filesystem
|
|
260
|
+
// operations. Every contender that observes its marker must fail closed so
|
|
261
|
+
// nobody can acquire the main path in the unlink/link handoff window. A
|
|
262
|
+
// complete marker whose exact owner PID is dead is the one exception: the
|
|
263
|
+
// prior repair crashed, so elect one cross-process reclaimer through an
|
|
264
|
+
// immutable generation hard-link before removing that exact marker.
|
|
265
|
+
// Unreadable/incomplete/live markers stay closed.
|
|
266
|
+
if (existingTakeover || existsSync(takeoverPath)) {
|
|
267
|
+
reclaimLease = acquireDeadTakeoverReclaimLease(
|
|
268
|
+
takeoverPath,
|
|
269
|
+
existingTakeover,
|
|
270
|
+
ownerInstanceMarker
|
|
271
|
+
);
|
|
272
|
+
if (!reclaimLease) {
|
|
273
|
+
const existing = readLock(path);
|
|
274
|
+
if (typeof openExisting === 'function') {
|
|
275
|
+
openExisting(existing || existingTakeover);
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
acquired: false,
|
|
279
|
+
path,
|
|
280
|
+
existing: existing || existingTakeover,
|
|
281
|
+
error: new Error('LiveDesk runtime lock takeover is already in progress.')
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
// Publish a fully written inode with one atomic hard-link operation. With
|
|
288
|
+
// open(path, 'wx') followed by write(), another launcher could observe the
|
|
289
|
+
// temporary zero-byte file, classify it as stale, unlink it on macOS, and
|
|
290
|
+
// acquire a second lock while the first owner still held the old inode.
|
|
291
|
+
publishCompleteLock(path, stagedPath, payload);
|
|
292
|
+
let released = false;
|
|
293
|
+
const release = () => {
|
|
294
|
+
if (released) return;
|
|
295
|
+
released = true;
|
|
296
|
+
releaseOwnedLock(path, payload);
|
|
297
|
+
};
|
|
298
|
+
releaseReclaimLease();
|
|
299
|
+
return {
|
|
300
|
+
acquired: true,
|
|
301
|
+
path,
|
|
302
|
+
payload,
|
|
303
|
+
release,
|
|
304
|
+
updateRole: nextRole => updateOwnedLockRole(path, payload, nextRole)
|
|
305
|
+
};
|
|
306
|
+
} catch (error) {
|
|
307
|
+
try { unlinkSync(stagedPath); } catch { /* staged inode was never created or was already removed */ }
|
|
308
|
+
const existingText = readLockText(path);
|
|
309
|
+
const existing = readLock(path);
|
|
310
|
+
if (existing && !isRuntimeOwnerAlive(existing, staleOwnerProof)) {
|
|
311
|
+
const takeoverPayload = {
|
|
312
|
+
pid: process.pid,
|
|
313
|
+
role: 'runtime-lock-takeover',
|
|
314
|
+
startedAt: new Date().toISOString(),
|
|
315
|
+
ownerToken: randomBytes(16).toString('hex'),
|
|
316
|
+
ownerInstanceMarker: String(ownerInstanceMarker || '').trim()
|
|
317
|
+
};
|
|
318
|
+
try {
|
|
319
|
+
publishCompleteLock(takeoverPath, takeoverStagedPath, takeoverPayload);
|
|
320
|
+
} catch {
|
|
321
|
+
releaseReclaimLease();
|
|
322
|
+
if (typeof openExisting === 'function') {
|
|
323
|
+
openExisting(existing);
|
|
324
|
+
}
|
|
325
|
+
return { acquired: false, path, existing, error };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const currentText = readLockText(path);
|
|
330
|
+
const current = readLock(path);
|
|
331
|
+
if (!current
|
|
332
|
+
|| currentText !== existingText
|
|
333
|
+
|| isRuntimeOwnerAlive(current, staleOwnerProof)) {
|
|
334
|
+
if (typeof openExisting === 'function') {
|
|
335
|
+
openExisting(current);
|
|
336
|
+
}
|
|
337
|
+
return { acquired: false, path, existing: current, error };
|
|
338
|
+
}
|
|
339
|
+
unlinkSync(path);
|
|
340
|
+
try {
|
|
341
|
+
publishCompleteLock(path, stagedPath, payload);
|
|
342
|
+
} catch (takeoverError) {
|
|
343
|
+
const winner = readLock(path);
|
|
344
|
+
if (typeof openExisting === 'function') {
|
|
345
|
+
openExisting(winner);
|
|
346
|
+
}
|
|
347
|
+
return { acquired: false, path, existing: winner, error: takeoverError };
|
|
348
|
+
}
|
|
349
|
+
let released = false;
|
|
350
|
+
const release = () => {
|
|
351
|
+
if (released) return;
|
|
352
|
+
released = true;
|
|
353
|
+
releaseOwnedLock(path, payload);
|
|
354
|
+
};
|
|
355
|
+
return {
|
|
356
|
+
acquired: true,
|
|
357
|
+
path,
|
|
358
|
+
payload,
|
|
359
|
+
release,
|
|
360
|
+
updateRole: nextRole => updateOwnedLockRole(path, payload, nextRole)
|
|
361
|
+
};
|
|
362
|
+
} finally {
|
|
363
|
+
releaseOwnedLock(takeoverPath, takeoverPayload);
|
|
364
|
+
releaseReclaimLease();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// An unreadable existing file is never evidence that the lock is stale.
|
|
368
|
+
// Preserve it and fail closed; the next invocation can repair it after its
|
|
369
|
+
// owner disappears, without ever admitting two launchers concurrently.
|
|
370
|
+
if (typeof openExisting === 'function') {
|
|
371
|
+
openExisting(existing);
|
|
372
|
+
}
|
|
373
|
+
releaseReclaimLease();
|
|
374
|
+
return { acquired: false, path, existing, error };
|
|
375
|
+
}
|
|
376
|
+
}
|