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.
- package/dist/bridge/daemon-instance.d.ts +92 -0
- package/dist/bridge/daemon-instance.js +342 -6
- package/dist/bridge/daemon-instance.js.map +1 -1
- package/dist/bridge/daemon.js +15 -0
- package/dist/bridge/daemon.js.map +1 -1
- package/dist/bridge/index.js +29 -4
- package/dist/bridge/index.js.map +1 -1
- package/dist/bridge/service.d.ts +82 -28
- package/dist/bridge/service.js +191 -262
- package/dist/bridge/service.js.map +1 -1
- package/dist/bridge/singleton-policy.d.ts +84 -0
- package/dist/bridge/singleton-policy.js +99 -0
- package/dist/bridge/singleton-policy.js.map +1 -0
- package/package.json +1 -1
package/dist/bridge/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
349
|
-
* the
|
|
350
|
-
*
|
|
351
|
-
*
|
|
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
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
/**
|
|
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
|
|
634
|
-
if (
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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) {
|