teamshare-bridge 0.22.1 → 0.22.3

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.
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runDaemon = exports.CRASH_BACKOFF_MS = exports.AUTOSTART_TASK_NAME = exports.LAUNCH_LOG_PATH = exports.LOG_PATH = void 0;
3
+ exports.runDaemon = exports.SINGLETON_SWEEP_EVERY_TICKS = exports.CRASH_BACKOFF_MS = exports.pidInfo = exports.listOurDaemonPids = exports.isOurDaemonProcess = exports.AUTOSTART_TASK_NAME = exports.LAUNCH_LOG_PATH = exports.LOG_PATH = void 0;
4
4
  exports.runningPid = runningPid;
5
5
  exports.isPortAnswering = isPortAnswering;
6
- exports.listOurDaemonPids = listOurDaemonPids;
7
6
  exports.stopAllDaemons = stopAllDaemons;
7
+ exports.describeRunningDaemon = describeRunningDaemon;
8
8
  exports.verifyRunningPid = verifyRunningPid;
9
9
  exports.startDaemon = startDaemon;
10
10
  exports.crashBackoffMs = crashBackoffMs;
@@ -79,13 +79,7 @@ function runningPid() {
79
79
  }
80
80
  /** True when a TeamShare daemon answers /ping with ok:true on `port` (sync probe). */
81
81
  function probeHealthy(port) {
82
- const res = (0, node_child_process_1.spawnSync)(process.execPath, [
83
- '-e',
84
- `fetch('http://127.0.0.1:${port}/ping',{signal:AbortSignal.timeout(2000)})` +
85
- '.then(async r=>{try{const b=await r.json();process.exit(b&&b.ok===true?0:1)}catch{process.exit(1)}})' +
86
- '.catch(()=>process.exit(1))',
87
- ], { stdio: 'ignore', timeout: 5000, windowsHide: true });
88
- return res.status === 0;
82
+ return (0, daemon_instance_1.probePortAnswering)(port);
89
83
  }
90
84
  /**
91
85
  * True when a TeamShare daemon answers /ping on `port` (for honest status).
@@ -101,6 +95,10 @@ function probeHealthy(port) {
101
95
  * fallback port), so a healthy daemon is never mistaken for a zombie - and
102
96
  * because it is exclusive, "some daemon is healthy" and "the daemon I asked
103
97
  * about is healthy" cannot disagree.
98
+ *
99
+ * It is NOT, however, proof that no SECOND daemon is running: that question is
100
+ * answered by process enumeration in `startDaemon`/`stopDaemon`, which is the
101
+ * only place that can see a daemon whose lock was lost.
104
102
  */
105
103
  function daemonHealthy() {
106
104
  return (0, daemon_instance_1.resolveDaemonInstanceSync)() !== null;
@@ -109,70 +107,26 @@ function daemonHealthy() {
109
107
  function isPortAnswering(port) {
110
108
  return probeHealthy(port);
111
109
  }
112
- function pidInfo(pid) {
113
- try {
114
- if (process.platform === 'win32') {
115
- const res = (0, node_child_process_1.spawnSync)('powershell.exe', [
116
- '-NoProfile',
117
- '-NonInteractive',
118
- '-Command',
119
- '$n=(Get-Process -Id ' +
120
- pid +
121
- ' -ErrorAction SilentlyContinue).ProcessName; ' +
122
- '$c=(Get-CimInstance Win32_Process -Filter "ProcessId=' +
123
- pid +
124
- '" -ErrorAction SilentlyContinue).CommandLine; ' +
125
- '"$n`t$c"',
126
- ], { stdio: 'pipe', encoding: 'utf8', timeout: 5_000, windowsHide: true });
127
- const out = (res.stdout ?? '').trim();
128
- if (!out)
129
- return null;
130
- const tab = out.indexOf('\t');
131
- const name = (tab >= 0 ? out.slice(0, tab) : out).trim();
132
- const cmdline = tab >= 0 ? out.slice(tab + 1).trim() : '';
133
- return { name: name || null, cmdline: cmdline || null };
134
- }
135
- const res = (0, node_child_process_1.spawnSync)('ps', ['-p', String(pid), '-o', 'args='], {
136
- stdio: 'pipe',
137
- encoding: 'utf8',
138
- timeout: 5_000,
139
- });
140
- const out = (res.stdout ?? '').trim();
141
- if (!out || out === 'args=')
142
- return null;
143
- return { name: null, cmdline: out };
144
- }
145
- catch {
146
- return null;
147
- }
148
- }
149
110
  /**
150
- * True only when the pid is verifiably a TeamShare daemon process.
151
- * `node …/index.js daemon` (or a teamshare path containing `daemon`).
152
- * false = foreign process (PID reuse) — NEVER kill it, just clear the pid file.
153
- * null = unqueryable/gone — treated as not-ours for killing purposes.
111
+ * Best-effort identity of a pid: image name + command line. null = process
112
+ * gone or unqueryable - never treat as "ours to kill".
113
+ *
114
+ * Re-exported from `daemon-instance`, which owns process identity for the whole
115
+ * daemon lifecycle: `listOurDaemonPids` and the boot guard must agree with
116
+ * `stopDaemon` about what "our daemon" means, and two copies of that test is
117
+ * how they drift apart.
154
118
  */
155
- function isOurDaemonProcess(pid) {
156
- const info = pidInfo(pid);
157
- if (!info)
158
- return null;
159
- // Name first: a recycled pid held by a foreign process (GameInputSvc,
160
- // services, …) is NEVER ours, even when its command line is unqueryable.
161
- if (info.name && !/^node(?:\.exe)?$/i.test(info.name))
162
- return false;
163
- if (info.cmdline) {
164
- return /(index\.js|teamshare)/i.test(info.cmdline) && /\bdaemon\b/i.test(info.cmdline);
165
- }
166
- // node-ish but cmdline denied — caller falls back to runtime.json.
167
- return null;
168
- }
119
+ var daemon_instance_2 = require("./daemon-instance");
120
+ Object.defineProperty(exports, "isOurDaemonProcess", { enumerable: true, get: function () { return daemon_instance_2.isOurDaemonProcess; } });
121
+ Object.defineProperty(exports, "listOurDaemonPids", { enumerable: true, get: function () { return daemon_instance_2.listOurDaemonPids; } });
122
+ Object.defineProperty(exports, "pidInfo", { enumerable: true, get: function () { return daemon_instance_2.pidInfo; } });
169
123
  /**
170
124
  * Identity decision before killing a pid from the pid file:
171
125
  * ours (cmdline match, or runtime.json agrees while cmdline is unqueryable)
172
126
  * → kill allowed; anything else → clear the stale file, kill nothing.
173
127
  */
174
128
  function pidIsOurDaemon(pid) {
175
- const identity = isOurDaemonProcess(pid);
129
+ const identity = (0, daemon_instance_1.isOurDaemonProcess)(pid);
176
130
  if (identity === true)
177
131
  return true;
178
132
  if (identity === false)
@@ -201,192 +155,136 @@ function clearForeignPidFile(pid) {
201
155
  /* best-effort */
202
156
  }
203
157
  }
204
- /**
205
- * Every pid on this machine that is VERIFIABLY a TeamShare daemon (2026-10).
206
- *
207
- * `daemon.lock` is NOT good enough on its own, and that is the whole reason this
208
- * exists. During the incident that motivated it, `daemon.lock` held pid 24148
209
- * (already dead) while THREE real daemons ran on three different ports. Anything
210
- * that trusts the lock alone — `stopDaemon`, and therefore the auto-upgrade —
211
- * concludes "daemon is not running", skips the stop, and runs `npm i -g` over a
212
- * directory that live processes still have open. On Windows that aborts the
213
- * install with EBUSY on a mapped native DLL and leaves the install HALF-WRITTEN:
214
- * `socket.io-client/build/cjs/index.js` never gets created, and every subsequent
215
- * daemon boot dies at require.
216
- *
217
- * A pid is returned only when its command line matches `isOurDaemonProcess`, so a
218
- * recycled pid held by an unrelated program is never returned and can never be
219
- * killed. The supervisor is excluded by the same test (its argv says `supervisor`,
220
- * not `daemon`) — an upgrade stops daemons, never the watchdog that restarts them.
221
- *
222
- * Unqueryable processes (access denied) are simply absent from the list; callers
223
- * treat "cannot enumerate" as "cannot confirm", which is the safe direction.
224
- */
225
- function listOurDaemonPids() {
226
- const out = (() => {
227
- if (process.platform === 'win32') {
228
- const res = (0, node_child_process_1.spawnSync)('powershell.exe', [
229
- '-NoProfile',
230
- '-NonInteractive',
231
- '-Command',
232
- 'Get-CimInstance Win32_Process -Filter "Name=\'node.exe\'" | ' +
233
- 'ForEach-Object { "$($_.ProcessId)`t$($_.CommandLine)" }',
234
- ], { stdio: 'pipe', encoding: 'utf8', timeout: 10_000, windowsHide: true });
235
- return res.stdout ?? '';
236
- }
237
- const res = (0, node_child_process_1.spawnSync)('ps', ['-eo', 'pid=,args='], {
238
- stdio: 'pipe',
239
- encoding: 'utf8',
240
- timeout: 10_000,
241
- });
242
- return res.stdout ?? '';
243
- })();
244
- const pids = [];
245
- for (const line of out.split(/\r?\n/)) {
246
- const tab = line.indexOf('\t');
247
- const pid = Number(tab >= 0 ? line.slice(0, tab) : line.slice(0, line.indexOf(' ')).trim());
248
- if (!Number.isInteger(pid) || pid <= 0)
249
- continue;
250
- // Re-use the single-process identity test so "verified" means exactly the same
251
- // thing here as it does everywhere else a pid is about to be killed.
252
- if (isOurDaemonProcess(pid) === true)
253
- pids.push(pid);
254
- }
255
- return pids;
256
- }
257
- /** Terminate one verified daemon pid: SIGTERM, then force on Windows. */
258
- function killDaemonPid(pid, timeoutMs) {
259
- try {
260
- process.kill(pid);
261
- }
262
- catch {
263
- /* already gone */
264
- }
265
- const deadline = Date.now() + timeoutMs;
266
- while (pidAlive(pid) && Date.now() < deadline) {
267
- /* brief wait for a clean exit */
268
- }
269
- if (!pidAlive(pid))
270
- return true;
271
- if (process.platform === 'win32') {
272
- // Windows: a detached console node.exe has no window to close politely, so
273
- // WM_CLOSE is not an option and /F is the only way.
274
- (0, node_child_process_1.spawnSync)('taskkill', ['/pid', String(pid), '/f'], {
275
- stdio: 'ignore',
276
- windowsHide: true,
277
- });
278
- }
279
- else {
280
- try {
281
- process.kill(pid, 'SIGKILL');
282
- }
283
- catch {
284
- /* best-effort */
285
- }
286
- }
287
- const hard = Date.now() + 3000;
288
- while (pidAlive(pid) && Date.now() < hard) {
289
- /* wait for the kill to land */
290
- }
291
- return !pidAlive(pid);
292
- }
293
158
  /**
294
159
  * Stop EVERY verified daemon on this machine, not just the one in the lock.
295
160
  *
296
- * Exists for the auto-upgrade, which must not write over a directory a live
297
- * process still has mapped (see `listOurDaemonPids`). Returns the pids it could
298
- * NOT kill, so the caller can ABORT rather than proceed — a half-installed bridge
299
- * is strictly worse than an out-of-date one.
161
+ * The auto-upgrade needs this (it must not write over a directory a live process
162
+ * still has mapped), and so does plain `stop`: a lock-only `stop` is what let
163
+ * three daemons survive a "daemon is not running" and then break the next
164
+ * `npm i -g` with EBUSY. Returns the pids it could NOT kill so callers can
165
+ * ABORT rather than proceed — a half-installed bridge is strictly worse than an
166
+ * out-of-date one.
300
167
  */
301
168
  function stopAllDaemons() {
302
169
  const stopped = [];
303
170
  const survivors = [];
304
- for (const pid of listOurDaemonPids()) {
305
- if (killDaemonPid(pid, 5000))
171
+ for (const pid of (0, daemon_instance_1.listOurDaemonPids)()) {
172
+ if ((0, daemon_instance_1.killDaemonPid)(pid, 5000))
306
173
  stopped.push(pid);
307
174
  else
308
175
  survivors.push(pid);
309
176
  }
310
- // The lock/runtime files describe a daemon that no longer exists either way;
311
- // leaving a stale one behind is what made the next `stopDaemon` a no-op.
312
- try {
313
- (0, node_fs_1.rmSync)(PID_PATH, { force: true });
314
- }
315
- catch {
316
- /* already gone */
317
- }
318
- try {
319
- (0, daemon_instance_1.clearDaemonLock)();
320
- }
321
- catch {
322
- /* already gone */
177
+ // Only clear the files when the machine is genuinely free. Clearing them while
178
+ // a survivor is still running is precisely how a live daemon becomes invisible
179
+ // and the next start piles a second one on top of it.
180
+ if (survivors.length === 0) {
181
+ try {
182
+ (0, node_fs_1.rmSync)(PID_PATH, { force: true });
183
+ }
184
+ catch {
185
+ /* already gone */
186
+ }
187
+ try {
188
+ (0, daemon_instance_1.clearDaemonLock)();
189
+ }
190
+ catch {
191
+ /* already gone */
192
+ }
323
193
  }
324
194
  const message = survivors.length > 0
325
195
  ? `${stopped.length} stopped, ${survivors.length} could not be stopped (${survivors.join(', ')})`
326
196
  : `stopped ${stopped.length} daemon(s)`;
327
197
  return { stopped, survivors, message };
328
198
  }
199
+ /**
200
+ * The honest answer to "is the daemon running?", for READ-ONLY commands.
201
+ *
202
+ * `verifyRunningPid` used to answer from `daemon.lock` alone, so a daemon whose
203
+ * lock had been taken over by a dead pid printed `stopped` while it was serving
204
+ * every wake on the machine. The lock is still consulted first (it is cheapest
205
+ * and normally right), but it is no longer the only witness:
206
+ *
207
+ * lock -> runtime.json -> process enumeration.
208
+ *
209
+ * Enumeration is last because it is the only one that costs a process query, and
210
+ * because a pid it finds with no port behind it is a weaker claim than a daemon
211
+ * that stamped its own lock.
212
+ */
213
+ function describeRunningDaemon() {
214
+ const rawLock = (0, daemon_instance_1.readDaemonLockFile)();
215
+ const lock = (0, daemon_instance_1.readLiveDaemonLock)();
216
+ const live = (0, daemon_instance_1.listOurDaemonPids)();
217
+ const runtime = (0, bus_1.readRuntimeFile)();
218
+ const claimed = (pid) => typeof pid === 'number' && pid > 0 && (live.includes(pid) || pidIsOurDaemon(pid));
219
+ const stale = rawLock && (!lock || rawLock.pid !== lock.pid) ? rawLock.pid : null;
220
+ if (lock && pidIsOurDaemon(lock.pid)) {
221
+ return { pid: lock.pid, source: 'lock', staleLockPid: null, daemonCount: live.length };
222
+ }
223
+ if (claimed(runtime?.pid)) {
224
+ return { pid: runtime.pid, source: 'runtime', staleLockPid: stale, daemonCount: live.length };
225
+ }
226
+ if (live.length > 0) {
227
+ return { pid: live[0], source: 'process', staleLockPid: stale, daemonCount: live.length };
228
+ }
229
+ return { pid: null, source: 'none', staleLockPid: stale, daemonCount: 0 };
230
+ }
329
231
  /**
330
232
  * Live daemon pid WITH an identity check, for an honest `status`: a recycled
331
233
  * pid (pid file survives a reboot, Windows hands the number to another
332
234
  * process) clears the stale file and reports stopped instead of "running".
333
235
  */
334
236
  function verifyRunningPid() {
335
- const pid = runningPid();
336
- if (pid === null)
337
- return null;
338
- if (!pidIsOurDaemon(pid)) {
339
- clearForeignPidFile(pid);
340
- return null;
341
- }
342
- return pid;
237
+ return describeRunningDaemon().pid;
343
238
  }
344
239
  /**
345
240
  * `start`: spawns the daemon detached (stdio -> ~/.teamshare/daemon.log) and
346
241
  * records the pid. `daemon` stays the foreground alias for debugging.
347
242
  *
348
- * IMP-9xx zombie takeover: a pid-file hit alone no longer refuses to start -
349
- * the instance must also answer /ping. Alive-but-wedged daemons (network
350
- * stack wedged, event loop stuck) are killed and replaced so `start` always
351
- * yields a WORKING daemon.
243
+ * ONE DAEMON, ALWAYS (2026-10). The old version asked the pid file one question
244
+ * — "is the pid in the file healthy?" — and that is not the question that keeps
245
+ * a machine clean. A daemon holding no lock (lock expired after 24h, cleared by
246
+ * a half-written install, unreadable) is invisible to it, so every 30s the
247
+ * watchdog answered "no daemon" and spawned another: three were alive at once,
248
+ * and `npm i -g` over their still-mapped native DLL failed with EBUSY and left
249
+ * the bridge unusable.
250
+ *
251
+ * So the question is now "which verified daemon processes exist, and does one of
252
+ * them answer?" (`reconcileSingleton`), and its three answers are the only
253
+ * outcomes:
254
+ * - a healthy daemon is already there -> adopt it and return it
255
+ * - processes exist but nothing answers -> stop them all, spawn one
256
+ * - nothing at all -> spawn one
352
257
  */
353
258
  function startDaemon(port) {
354
- const existing = runningPid();
355
- if (existing !== null) {
356
- // The lock already records the BOUND port, so a fallback-bound daemon is
357
- // recognised as healthy even when config.port is busy.
358
- if (daemonHealthy()) {
359
- return { started: false, pid: existing, message: `daemon already running (pid ${existing})` };
360
- }
361
- // Identity guard: only kill a pid we can VERIFY is our daemon — a
362
- // recycled pid pointing at an innocent process must never be taskkill'd.
363
- if (!pidIsOurDaemon(existing)) {
364
- clearForeignPidFile(existing);
365
- // Fall through: spawn a fresh daemon (the foreign pid does not own our probe port).
259
+ const recon = (0, daemon_instance_1.reconcileSingleton)();
260
+ if (recon.decision.action === 'keep' && recon.decision.keep !== null) {
261
+ return {
262
+ started: false,
263
+ pid: recon.decision.keep,
264
+ message: `daemon already running (pid ${recon.decision.keep})${recon.stopped.length > 0 ? ` - stopped ${recon.stopped.length} duplicate(s): ${recon.stopped.join(', ')}` : ''}`,
265
+ };
266
+ }
267
+ if (recon.decision.action === 'blocked') {
268
+ return { started: false, message: recon.message };
269
+ }
270
+ if (recon.stopped.length > 0 || recon.decision.action === 'replace') {
271
+ // Logged, not swallowed: a machine that had to be reconciled is exactly the
272
+ // machine whose next `npm i -g` needs looking at.
273
+ try {
274
+ (0, node_fs_1.appendFileSync)(exports.LOG_PATH, `[${new Date().toISOString()}] [service] singleton reconcile: ${recon.message}\n`, 'utf8');
366
275
  }
367
- else {
368
- // Wedged zombie - replace it.
369
- try {
370
- process.kill(existing);
371
- }
372
- catch {
373
- /* already gone */
374
- }
375
- const deadline = Date.now() + 3000;
376
- while (pidAlive(existing) && Date.now() < deadline) {
377
- /* brief wait */
378
- }
379
- if (pidAlive(existing)) {
380
- (0, node_child_process_1.spawnSync)('taskkill', ['/pid', String(existing), '/f'], { stdio: 'ignore', windowsHide: true });
381
- }
382
- try {
383
- (0, node_fs_1.rmSync)(PID_PATH);
384
- }
385
- catch {
386
- /* best-effort */
387
- }
276
+ catch {
277
+ /* best-effort */
388
278
  }
389
279
  }
280
+ if (recon.survivors.length > 0) {
281
+ return {
282
+ started: false,
283
+ message: `refusing to start: ${recon.survivors.length} TeamShare daemon process(es) ` +
284
+ `(${recon.survivors.join(', ')}) could not be stopped. Installing/starting over a ` +
285
+ `live tree is what produces an unusable bridge - retry once they are gone.`,
286
+ };
287
+ }
390
288
  const args = [(0, node_path_1.join)(__dirname, 'index.js'), 'daemon'];
391
289
  if (port)
392
290
  args.push('--port', String(port));
@@ -500,6 +398,15 @@ const CRASH_LOOP_THRESHOLD = 3;
500
398
  * bridge back within one cycle instead of needing a reboot.
501
399
  */
502
400
  exports.CRASH_BACKOFF_MS = [0, 30_000, 60_000, 120_000, 300_000];
401
+ /**
402
+ * Healthy watchdog ticks between duplicate sweeps (~5 min at the 30s cadence).
403
+ *
404
+ * Exported for the unit test. Enumerating processes is not free (one PowerShell
405
+ * call), so it must not run every tick — but it must run periodically, because
406
+ * "one healthy lock" and "one daemon" are different facts and only the second
407
+ * one is the invariant.
408
+ */
409
+ exports.SINGLETON_SWEEP_EVERY_TICKS = 10;
503
410
  /** The wait before attempt `streak + 1`, clamped to the table. */
504
411
  function crashBackoffMs(streak) {
505
412
  if (streak <= 0)
@@ -515,6 +422,8 @@ function runSupervisor(intervalMs = 30_000) {
515
422
  let lastAttemptAt = 0;
516
423
  /** One diagnostic per crash-loop episode, not one per attempt. */
517
424
  let crashLoopReported = false;
425
+ /** Healthy ticks since the last duplicate sweep. */
426
+ let ticksSinceSweep = 0;
518
427
  const appendWatchdogLog = (line) => {
519
428
  try {
520
429
  (0, node_fs_1.appendFileSync)(exports.LOG_PATH, `[${new Date().toISOString()}] [watchdog] ${line}\n`, 'utf8');
@@ -555,6 +464,32 @@ function runSupervisor(intervalMs = 30_000) {
555
464
  }
556
465
  process.exit(1);
557
466
  });
467
+ /**
468
+ * A healthy lock does NOT mean the machine holds one daemon — only that one of
469
+ * them owns the lock. This is the sweep that makes "exactly one" true over
470
+ * time instead of only at the moment someone runs `start`.
471
+ *
472
+ * Cost matters: it runs on the 30s tick, so the steady state is ONE process
473
+ * enumeration every `SWEEP_EVERY_HEALTHY_TICKS` ticks (~5 min), and the
474
+ * expensive part (port probes + kills) only runs when a duplicate is actually
475
+ * found. Without it, a lockless second daemon — the exact state this machine
476
+ * sat in for hours — survives every tick, because a healthy lock makes the
477
+ * watchdog return early.
478
+ */
479
+ const sweepDuplicates = () => {
480
+ try {
481
+ const live = (0, daemon_instance_1.listOurDaemonPids)();
482
+ if (live.length <= 1)
483
+ return;
484
+ const recon = (0, daemon_instance_1.reconcileSingleton)();
485
+ appendWatchdogLog(recon.stopped.length > 0 || recon.decision.action !== 'keep'
486
+ ? `singleton sweep: ${recon.message}`
487
+ : `singleton sweep found ${live.length} daemons but kept ${recon.decision.keep} - ${recon.message}`);
488
+ }
489
+ catch {
490
+ /* never die */
491
+ }
492
+ };
558
493
  const tick = () => {
559
494
  try {
560
495
  // A silent auto-upgrade helper owns the machine while it runs
@@ -571,8 +506,17 @@ function runSupervisor(intervalMs = 30_000) {
571
506
  crashStreak = 0;
572
507
  crashLoopReported = false;
573
508
  }
509
+ // Periodic duplicate sweep: a healthy lock says nothing about a SECOND,
510
+ // lockless daemon, and this machine ran three for hours because every
511
+ // tick returned early on that healthy lock.
512
+ ticksSinceSweep += 1;
513
+ if (ticksSinceSweep >= exports.SINGLETON_SWEEP_EVERY_TICKS) {
514
+ ticksSinceSweep = 0;
515
+ sweepDuplicates();
516
+ }
574
517
  return;
575
518
  }
519
+ ticksSinceSweep = 0;
576
520
  // ── CRASH-LOOP DETECTION (2026-10) ───────────────────────────────────
577
521
  // A daemon that dies within seconds of starting is not a daemon to keep
578
522
  // respawning — it is a broken INSTALL. Observed: 262 restarts at an exact
@@ -628,53 +572,38 @@ function runSupervisor(intervalMs = 30_000) {
628
572
  tick();
629
573
  setInterval(tick, intervalMs);
630
574
  }
631
- /** `stop`: terminates the live daemon and clears the pid file. */
575
+ /**
576
+ * `stop`: terminates EVERY verified daemon on this machine and clears the files.
577
+ *
578
+ * It used to signal exactly one pid — the one in `daemon.lock` — and report
579
+ * `daemon is not running` whenever that file was stale. That is precisely how
580
+ * three daemons survived a `stop` on this machine: the human ran it, saw
581
+ * "daemon is not running", and the three survivors kept their native DLLs
582
+ * mapped, so the next `npm i -g` aborted with EBUSY and left the install
583
+ * half-written (every later boot died on a missing
584
+ * `socket.io-client/build/cjs/index.js`).
585
+ *
586
+ * `stop` now asks the OS, not the file. `restart` inherits that, which is why a
587
+ * restart can no longer leave the old daemon behind to collide with the new one.
588
+ */
632
589
  function stopDaemon() {
633
- const pid = runningPid();
634
- if (pid === null) {
635
- try {
636
- (0, node_fs_1.rmSync)(PID_PATH);
637
- }
638
- catch {
639
- /* already gone */
640
- }
590
+ const claimed = runningPid();
591
+ if (claimed !== null && !pidIsOurDaemon(claimed)) {
592
+ // Identity guard: never signal a pid that is not verifiably our daemon. The
593
+ // stale file is cleared, and we still go on to enumerate - the real daemon
594
+ // may be a different, lockless pid.
595
+ clearForeignPidFile(claimed);
596
+ }
597
+ const result = stopAllDaemons();
598
+ if (result.stopped.length === 0 && result.survivors.length === 0) {
641
599
  return { stopped: false, message: 'daemon is not running' };
642
600
  }
643
- // Identity guard: never signal a pid that is not verifiably our daemon.
644
- if (!pidIsOurDaemon(pid)) {
645
- clearForeignPidFile(pid);
646
- return { stopped: false, message: `pid ${pid} is not a TeamShare daemon (PID reuse) - pid file cleared, nothing killed` };
647
- }
648
- try {
649
- process.kill(pid);
650
- }
651
- catch {
652
- /* best-effort */
653
- }
654
- const deadline = Date.now() + 5000;
655
- while (pidAlive(pid) && Date.now() < deadline) {
656
- /* wait briefly for the daemon to exit */
657
- }
658
- if (pidAlive(pid)) {
659
- if (process.platform === 'win32') {
660
- // Windows: force kill via taskkill.
661
- (0, node_child_process_1.spawnSync)('taskkill', ['/pid', String(pid), '/f'], { stdio: 'ignore', windowsHide: true });
662
- }
663
- else {
664
- // POSIX: SIGKILL if SIGTERM didn't work.
665
- try {
666
- process.kill(pid, 'SIGKILL');
667
- }
668
- catch { /* best-effort */ }
669
- }
670
- }
671
- try {
672
- (0, node_fs_1.rmSync)(PID_PATH);
673
- }
674
- catch {
675
- /* already gone */
676
- }
677
- return { stopped: true, message: `daemon stopped (pid ${pid})` };
601
+ return {
602
+ stopped: result.survivors.length === 0,
603
+ message: result.survivors.length > 0
604
+ ? `${result.message} - the machine is NOT clean; do not run npm install -g teamshare-bridge until they are gone`
605
+ : `daemon stopped (${result.stopped.length} process(es): ${result.stopped.join(', ')})`,
606
+ };
678
607
  }
679
608
  /** `restart`: stop + start. */
680
609
  function restartDaemon(port) {