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.
@@ -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 helperPathPattern = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264$/i;
54
- const knownCommand = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264(?:\s|["']|$)/i
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 strictExecutableProof = helperPathPattern.test(normalizeExecutablePath(executablePath))
57
- || helperPathPattern.test(normalizeExecutablePath(firstCommandArgument(normalizedCommandLine)));
58
- return (/^livedesk-sck-h264$/i.test(normalizedName) && knownCommand)
59
- || (/^livedesk-sck-h2$/i.test(normalizedName) && strictExecutableProof);
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
- await new Promise(resolve => {
1212
- const killer = spawn('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
1213
- windowsHide: true,
1214
- stdio: 'ignore'
1215
- });
1216
- let settled = false;
1217
- const finish = () => {
1218
- if (settled) return;
1219
- settled = true;
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 (isAlive(pid)) {
1231
- try { child.kill('SIGKILL'); } catch { /* best effort after bounded taskkill */ }
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, openSync, readFileSync, closeSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import os from 'node:os';
4
-
5
- function readLock(path) {
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 isProcessAlive(pid) {
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
- export function getRuntimeLockPath(stateDir = join(os.homedir(), '.livedesk')) {
26
- return join(stateDir, 'livedesk-runtime.lock');
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({ stateDir, role = '', openExisting } = {}) {
30
- const path = getRuntimeLockPath(stateDir);
31
- const payload = {
32
- pid: process.pid,
33
- role: String(role || '').trim(),
34
- startedAt: new Date().toISOString()
35
- };
36
-
37
- try {
38
- const descriptor = openSync(path, 'wx');
39
- writeFileSync(descriptor, JSON.stringify(payload, null, 2));
40
- closeSync(descriptor);
41
- let released = false;
42
- const release = () => {
43
- if (released) return;
44
- released = true;
45
- try { unlinkSync(path); } catch { /* already released */ }
46
- };
47
- return { acquired: true, path, payload, release };
48
- } catch (error) {
49
- const existing = readLock(path);
50
- if (!existing || !isProcessAlive(Number(existing.pid))) {
51
- try { unlinkSync(path); } catch { /* another process may repair it */ }
52
- return acquireRuntimeLock({ stateDir, role, openExisting });
53
- }
54
- if (typeof openExisting === 'function') {
55
- openExisting(existing);
56
- }
57
- return { acquired: false, path, existing, error };
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
+ }