livedesk 0.1.446 → 0.1.448

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.
@@ -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,8 +1,10 @@
1
- import { existsSync, linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, linkSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { join } from 'node:path';
4
4
  import os from 'node:os';
5
-
5
+
6
+ const MAX_TAKEOVER_LEASE_AGE_MS = 15_000;
7
+
6
8
  function readLock(path) {
7
9
  try {
8
10
  return JSON.parse(readFileSync(path, 'utf8'));
@@ -43,7 +45,56 @@ function releaseOwnedLock(path, payload) {
43
45
  }
44
46
  }
45
47
 
46
- function isProcessAlive(pid) {
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) {
47
98
  if (!Number.isInteger(pid) || pid <= 1) {
48
99
  return false;
49
100
  }
@@ -52,17 +103,138 @@ function isProcessAlive(pid) {
52
103
  return true;
53
104
  } catch (error) {
54
105
  return error?.code !== 'ESRCH';
55
- }
56
- }
57
-
58
- export function getRuntimeLockPath(stateDir = join(os.homedir(), '.livedesk')) {
59
- return join(stateDir, 'livedesk-runtime.lock');
60
- }
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
+ }
61
231
 
62
232
  export function acquireRuntimeLock({
63
233
  stateDir,
64
234
  role = '',
65
235
  ownerInstanceMarker = '',
236
+ ownerStartOrder = '',
237
+ staleOwnerProof,
66
238
  openExisting
67
239
  } = {}) {
68
240
  const path = getRuntimeLockPath(stateDir);
@@ -71,27 +243,44 @@ export function acquireRuntimeLock({
71
243
  role: String(role || '').trim(),
72
244
  startedAt: new Date().toISOString(),
73
245
  ownerToken: randomBytes(16).toString('hex'),
74
- ownerInstanceMarker: String(ownerInstanceMarker || '').trim()
246
+ ownerInstanceMarker: String(ownerInstanceMarker || '').trim(),
247
+ ownerStartOrder: String(ownerStartOrder || '').trim()
75
248
  };
76
249
  const stagedPath = `${path}.${process.pid}.${payload.ownerToken}.pending`;
77
250
  const takeoverPath = `${path}.takeover`;
78
251
  const takeoverStagedPath = `${takeoverPath}.${process.pid}.${payload.ownerToken}.pending`;
79
252
  const existingTakeover = readLock(takeoverPath);
253
+ let reclaimLease = null;
254
+ const releaseReclaimLease = () => {
255
+ reclaimLease?.release();
256
+ reclaimLease = null;
257
+ };
80
258
 
81
259
  // Stale repair is synchronous and normally lasts only a few filesystem
82
260
  // operations. Every contender that observes its marker must fail closed so
83
- // nobody can acquire the main path in the unlink/link handoff window.
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.
84
266
  if (existingTakeover || existsSync(takeoverPath)) {
85
- const existing = readLock(path);
86
- if (typeof openExisting === 'function') {
87
- openExisting(existing || existingTakeover);
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
+ };
88
283
  }
89
- return {
90
- acquired: false,
91
- path,
92
- existing: existing || existingTakeover,
93
- error: new Error('LiveDesk runtime lock takeover is already in progress.')
94
- };
95
284
  }
96
285
 
97
286
  try {
@@ -106,12 +295,19 @@ export function acquireRuntimeLock({
106
295
  released = true;
107
296
  releaseOwnedLock(path, payload);
108
297
  };
109
- return { acquired: true, path, payload, release };
298
+ releaseReclaimLease();
299
+ return {
300
+ acquired: true,
301
+ path,
302
+ payload,
303
+ release,
304
+ updateRole: nextRole => updateOwnedLockRole(path, payload, nextRole)
305
+ };
110
306
  } catch (error) {
111
307
  try { unlinkSync(stagedPath); } catch { /* staged inode was never created or was already removed */ }
112
308
  const existingText = readLockText(path);
113
309
  const existing = readLock(path);
114
- if (existing && !isProcessAlive(Number(existing.pid))) {
310
+ if (existing && !isRuntimeOwnerAlive(existing, staleOwnerProof)) {
115
311
  const takeoverPayload = {
116
312
  pid: process.pid,
117
313
  role: 'runtime-lock-takeover',
@@ -122,6 +318,7 @@ export function acquireRuntimeLock({
122
318
  try {
123
319
  publishCompleteLock(takeoverPath, takeoverStagedPath, takeoverPayload);
124
320
  } catch {
321
+ releaseReclaimLease();
125
322
  if (typeof openExisting === 'function') {
126
323
  openExisting(existing);
127
324
  }
@@ -133,7 +330,7 @@ export function acquireRuntimeLock({
133
330
  const current = readLock(path);
134
331
  if (!current
135
332
  || currentText !== existingText
136
- || isProcessAlive(Number(current.pid))) {
333
+ || isRuntimeOwnerAlive(current, staleOwnerProof)) {
137
334
  if (typeof openExisting === 'function') {
138
335
  openExisting(current);
139
336
  }
@@ -155,9 +352,16 @@ export function acquireRuntimeLock({
155
352
  released = true;
156
353
  releaseOwnedLock(path, payload);
157
354
  };
158
- return { acquired: true, path, payload, release };
355
+ return {
356
+ acquired: true,
357
+ path,
358
+ payload,
359
+ release,
360
+ updateRole: nextRole => updateOwnedLockRole(path, payload, nextRole)
361
+ };
159
362
  } finally {
160
363
  releaseOwnedLock(takeoverPath, takeoverPayload);
364
+ releaseReclaimLease();
161
365
  }
162
366
  }
163
367
  // An unreadable existing file is never evidence that the lock is stale.
@@ -165,7 +369,8 @@ export function acquireRuntimeLock({
165
369
  // owner disappears, without ever admitting two launchers concurrently.
166
370
  if (typeof openExisting === 'function') {
167
371
  openExisting(existing);
168
- }
169
- return { acquired: false, path, existing, error };
170
- }
171
- }
372
+ }
373
+ releaseReclaimLease();
374
+ return { acquired: false, path, existing, error };
375
+ }
376
+ }