baychat 0.13.0 → 0.14.0
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/README.md +146 -1
- package/dist/args.js +57 -0
- package/dist/client-paths.js +69 -0
- package/dist/commands.js +108 -0
- package/dist/connect.js +46 -14
- package/dist/credential-refresh.js +97 -0
- package/dist/doctor-command.js +154 -0
- package/dist/doctor.js +502 -0
- package/dist/help-topics.js +197 -0
- package/dist/index.js +69 -27
- package/dist/relay/adapters.js +82 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +177 -31
- package/dist/relay/daemon.js +259 -3
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +319 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +98 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +269 -0
- package/dist/runtimes.js +122 -68
- package/package.json +2 -2
package/dist/relay/daemon.js
CHANGED
|
@@ -36,10 +36,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.RelayDaemon = void 0;
|
|
37
37
|
const fs = __importStar(require("fs"));
|
|
38
38
|
const net = __importStar(require("net"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
39
40
|
const config_1 = require("../config");
|
|
41
|
+
const runtime_binary_1 = require("../runtime-binary");
|
|
40
42
|
const adapters_1 = require("./adapters");
|
|
41
43
|
const queue_1 = require("./queue");
|
|
42
44
|
const registry_1 = require("./registry");
|
|
45
|
+
const mailbox_1 = require("./mailbox");
|
|
46
|
+
const mailbox_watcher_1 = require("./mailbox-watcher");
|
|
43
47
|
const socket_1 = require("./socket");
|
|
44
48
|
const transport_1 = require("./transport");
|
|
45
49
|
const watermarks_1 = require("./watermarks");
|
|
@@ -57,6 +61,9 @@ class RelayDaemon {
|
|
|
57
61
|
abort = new AbortController();
|
|
58
62
|
/** Live attach sockets by session name. */
|
|
59
63
|
attached = new Map();
|
|
64
|
+
/** Sessions reachable over a mailbox FIFO, by session name. */
|
|
65
|
+
mailboxes = new Map();
|
|
66
|
+
mailboxWatchers;
|
|
60
67
|
/** Per (session, conversation) delivery positions — the 409 catch-up baseline. */
|
|
61
68
|
watermarks = new watermarks_1.Watermarks();
|
|
62
69
|
pending = [];
|
|
@@ -75,11 +82,58 @@ class RelayDaemon {
|
|
|
75
82
|
discoveryReasons = new Map();
|
|
76
83
|
discovery;
|
|
77
84
|
spawnHeadless;
|
|
85
|
+
resolveBinary;
|
|
86
|
+
runTurn;
|
|
87
|
+
/**
|
|
88
|
+
* Resolutions already proven this process, by runtime binary name.
|
|
89
|
+
*
|
|
90
|
+
* Resolution costs a `--version` spawn per candidate, which must not be paid
|
|
91
|
+
* per message. The entry is dropped whenever a headless turn fails, so a user
|
|
92
|
+
* who installs the missing runtime is believed on the next message rather
|
|
93
|
+
* than after a relay restart nothing would have told them to perform.
|
|
94
|
+
*/
|
|
95
|
+
binaries = new Map();
|
|
78
96
|
log;
|
|
79
97
|
constructor(opts = {}) {
|
|
80
98
|
this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
|
|
99
|
+
// EVERY candidate root, because the daemon cannot know which one the agent's
|
|
100
|
+
// sandbox turned out to permit — it may have fallen back past the one this
|
|
101
|
+
// process would have chosen.
|
|
102
|
+
this.mailboxWatchers = (0, mailbox_1.mailboxRootCandidates)().map((root) => new mailbox_watcher_1.MailboxWatcher({
|
|
103
|
+
root,
|
|
104
|
+
onRegister: (reg) => {
|
|
105
|
+
this.registry.upsert((0, mailbox_1.registrationToTarget)(reg));
|
|
106
|
+
// A session that names itself supersedes anything discovery guessed,
|
|
107
|
+
// exactly as an attach frame does.
|
|
108
|
+
if (reg.resumeId)
|
|
109
|
+
this.discoveryReasons.delete(reg.session);
|
|
110
|
+
// The daemon makes the FIFO, because the agent cannot: creating one
|
|
111
|
+
// needs `mkfifo(1)` spawned, and a sandbox that lets an agent write
|
|
112
|
+
// files still refuses it a helper process. This side has the privilege,
|
|
113
|
+
// so this side does the privileged step.
|
|
114
|
+
try {
|
|
115
|
+
// ENSURE, never recreate: a re-arm must not unlink the FIFO a live
|
|
116
|
+
// reader is already blocked on. See `ensureWakeFifo`.
|
|
117
|
+
(0, mailbox_1.ensureWakeFifo)(path.dirname(reg.fifo));
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
this.log(`could not create the wake FIFO for ${reg.session}: ${errText(err)}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.mailboxes.set(reg.session, reg);
|
|
124
|
+
this.registry.setAttached(reg.session, true);
|
|
125
|
+
this.log(`attached via mailbox: ${reg.session} (${reg.runtime}) at ${reg.fifo}`);
|
|
126
|
+
},
|
|
127
|
+
onDrop: (session, reason) => {
|
|
128
|
+
this.mailboxes.delete(session);
|
|
129
|
+
this.registry.setAttached(session, false);
|
|
130
|
+
this.log(`detached: ${session} — ${reason}`);
|
|
131
|
+
},
|
|
132
|
+
}));
|
|
81
133
|
this.discovery = opts.discovery ?? {};
|
|
82
134
|
this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
|
|
135
|
+
this.resolveBinary = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
|
|
136
|
+
this.runTurn = opts.runTurn;
|
|
83
137
|
this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
|
|
84
138
|
this.lastError = `delivery failed for ${session}: ${errText(err)}`;
|
|
85
139
|
this.log(this.lastError);
|
|
@@ -104,6 +158,10 @@ class RelayDaemon {
|
|
|
104
158
|
throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
|
|
105
159
|
}
|
|
106
160
|
(0, socket_1.unlinkStaleSocket)(sockPath);
|
|
161
|
+
// Sandboxed agents cannot reach the socket below, so the mailbox has to be
|
|
162
|
+
// watched from the moment the daemon is up — not lazily on first delivery.
|
|
163
|
+
for (const w of this.mailboxWatchers)
|
|
164
|
+
w.start();
|
|
107
165
|
this.server = net.createServer((sock) => this.onConnection(sock));
|
|
108
166
|
await new Promise((resolve, reject) => {
|
|
109
167
|
this.server.once("error", reject);
|
|
@@ -189,6 +247,26 @@ class RelayDaemon {
|
|
|
189
247
|
async deliver(session, batch) {
|
|
190
248
|
const target = this.registry.get(session);
|
|
191
249
|
if (!target) {
|
|
250
|
+
// NOT reported as pending, though an earlier version of this did.
|
|
251
|
+
//
|
|
252
|
+
// `liveSessions` lists every session of the USER, not of this machine. A
|
|
253
|
+
// person with WSL and Windows side by side has BOTH relays receiving
|
|
254
|
+
// events for BOTH machines' sessions, because the server fans out per
|
|
255
|
+
// credential and a session's host is not something it records. Treating
|
|
256
|
+
// "live for this user" as "ours" made each relay report DELIVERY PENDING
|
|
257
|
+
// for sessions the other machine was serving perfectly well — and a false
|
|
258
|
+
// pending is worse than none, because the entire value of that signal is
|
|
259
|
+
// that it means something is genuinely wrong.
|
|
260
|
+
//
|
|
261
|
+
// A session in the registry cannot reach here at all (`target` would be
|
|
262
|
+
// set), so there is no local evidence to appeal to: this name has never
|
|
263
|
+
// been seen on this machine. Another device is very likely handling it,
|
|
264
|
+
// and silence is the honest answer.
|
|
265
|
+
//
|
|
266
|
+
// The genuinely uncovered case — a session that joined a room and never
|
|
267
|
+
// attached ANYWHERE — is per-machine, not per-message, and belongs in
|
|
268
|
+
// `doctor`, which can compare the Bay's live sessions against this
|
|
269
|
+
// machine's registry without ever firing a false alarm.
|
|
192
270
|
this.record({ kind: "ignored", reason: `unknown session ${session}` }, session, batch);
|
|
193
271
|
return;
|
|
194
272
|
}
|
|
@@ -202,10 +280,98 @@ class RelayDaemon {
|
|
|
202
280
|
return;
|
|
203
281
|
}
|
|
204
282
|
const adapter = (0, adapters_1.adapterFor)(target.runtime);
|
|
283
|
+
// ORDERED ABOVE THE FIFO, deliberately.
|
|
284
|
+
//
|
|
285
|
+
// A FIFO hands bytes to a blocked `relay attach`, which prints them and
|
|
286
|
+
// exits. Where the harness re-invokes on that exit (Claude) it is a wake;
|
|
287
|
+
// for an interactive TUI it is only a PRINT — the text appears and nothing
|
|
288
|
+
// makes the agent act on it. Measured 2026-08-31 01:08: delivered via fifo
|
|
289
|
+
// to a live Codex, which then said nothing.
|
|
290
|
+
//
|
|
291
|
+
// `codex queue` puts the message in the session's own turn queue, so the
|
|
292
|
+
// agent ACTS. Prefer the transport that produces an ANSWER over the one
|
|
293
|
+
// that only produces a delivery.
|
|
294
|
+
// RUNG 2: the runtime's OWN inter-session queue, where it has one.
|
|
295
|
+
//
|
|
296
|
+
// Preferred over the headless spawn below because it reaches the LIVE
|
|
297
|
+
// session, where the human already is — so approvals work normally and the
|
|
298
|
+
// relay never acquires a privilege on a chat message's behalf. The headless
|
|
299
|
+
// rung must run `approvalPolicy: "never"`, which blocks Codex's own BayChat
|
|
300
|
+
// write path: observed 2026-08-31, a headlessly-woken Codex read the room
|
|
301
|
+
// and was structurally unable to answer it.
|
|
302
|
+
const resolvedForQueue = await this.resolveResume(target);
|
|
303
|
+
if (adapter.queueMessage && resolvedForQueue.resumeId) {
|
|
304
|
+
// Same rule as the headless spawn: an ABSOLUTE runtimeBin came from the
|
|
305
|
+
// session itself and is never re-resolved; only a bare name is looked up.
|
|
306
|
+
const declared = resolvedForQueue.runtimeBin ?? target.runtime;
|
|
307
|
+
let queueBin = declared;
|
|
308
|
+
if (!path.isAbsolute(declared)) {
|
|
309
|
+
const resolved = this.binaryFor(declared);
|
|
310
|
+
queueBin = resolved.ok ? resolved.path : "";
|
|
311
|
+
}
|
|
312
|
+
if (!queueBin) {
|
|
313
|
+
this.log(`queue unavailable for ${session}: no usable ${target.runtime} binary`);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
const outcome = await adapter.queueMessage({
|
|
317
|
+
binaryPath: queueBin,
|
|
318
|
+
target: resolvedForQueue,
|
|
319
|
+
message: (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, undefined),
|
|
320
|
+
});
|
|
321
|
+
if (outcome.kind === "queued") {
|
|
322
|
+
this.record({ kind: "woken", via: "queue", session }, session, batch);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// EVERY non-success falls through, including `failed`.
|
|
326
|
+
//
|
|
327
|
+
// The queue is an OPTIMISATION, not a gate: it reaches the live session so
|
|
328
|
+
// approvals work, but the headless rung below is strictly more capable —
|
|
329
|
+
// it carries the Windows `.cmd` spawn plan, the app-server transport, and
|
|
330
|
+
// the binary resolution this path does not replicate. A rung that could
|
|
331
|
+
// not deliver must never shadow one that might; that exact mistake made a
|
|
332
|
+
// stale FIFO swallow three messages earlier tonight, and it would be the
|
|
333
|
+
// same mistake to repeat here for a different reason.
|
|
334
|
+
this.log(`queue did not deliver for ${session} (${outcome.reason}) — falling through to headless`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
205
337
|
// A target with no resume id is the unbounded failure this whole path
|
|
206
338
|
// exists to close: without one, `canResume` says no and the message waits
|
|
207
339
|
// for a human. Ask the runtime's own on-disk state who this session is
|
|
208
340
|
// before accepting that answer.
|
|
341
|
+
// RUNG 3: a session whose sandbox refuses the socket, waiting on a FIFO.
|
|
342
|
+
//
|
|
343
|
+
// Ordered after the socket and before headless because it means the SAME
|
|
344
|
+
// thing the socket does — a live session is waiting — while headless means
|
|
345
|
+
// the opposite. `writeWake` never blocks: a FIFO write with no reader would
|
|
346
|
+
// otherwise stall this daemon on one detached agent and stop it serving
|
|
347
|
+
// every other session, which is worse than the bug this rung fixes.
|
|
348
|
+
const mailbox = this.mailboxes.get(session);
|
|
349
|
+
if (mailbox) {
|
|
350
|
+
const payload = `${JSON.stringify({ type: "wake", conversationId: batch[0].conversationId, messages: batch })}\n`;
|
|
351
|
+
const written = (0, mailbox_1.writeWake)(mailbox.fifo, payload);
|
|
352
|
+
if (written.ok) {
|
|
353
|
+
// The one rung where `delivered` is EVIDENCED rather than assumed: a
|
|
354
|
+
// FIFO write completes only when a reader takes the bytes.
|
|
355
|
+
this.record({ kind: "woken", via: "fifo", session }, session, batch);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// NOBODY IS WAITING — so FALL THROUGH, do not stop here.
|
|
359
|
+
//
|
|
360
|
+
// A registration is not a listener. The daemon cannot tell a stale
|
|
361
|
+
// registration from a live one on this rung, because a sandboxed agent
|
|
362
|
+
// records a NAMESPACED pid that `processIsAlive` reports alive forever
|
|
363
|
+
// (spec §13). Measured 2026-08-31 00:04: a stale mailbox intercepted three
|
|
364
|
+
// deliveries in a row and recorded each `pending`, while the session had a
|
|
365
|
+
// perfectly good resume id and could have been woken headlessly the whole
|
|
366
|
+
// time. A dead rung was shadowing a working one.
|
|
367
|
+
//
|
|
368
|
+
// ENXIO is the only trustworthy evidence available here, and what it
|
|
369
|
+
// proves is exactly "no live session is waiting" — which is the condition
|
|
370
|
+
// the rung below exists for. So we take it.
|
|
371
|
+
this.log(`mailbox for ${session} has no reader (${written.reason}) — falling through to headless`);
|
|
372
|
+
this.mailboxes.delete(session);
|
|
373
|
+
this.registry.setAttached(session, false);
|
|
374
|
+
}
|
|
209
375
|
const resolved = await this.resolveResume(target);
|
|
210
376
|
const check = adapter.canResume(resolved);
|
|
211
377
|
if (!check.ok) {
|
|
@@ -215,13 +381,75 @@ class RelayDaemon {
|
|
|
215
381
|
this.record({ kind: "pending", session, reason }, session, batch);
|
|
216
382
|
return;
|
|
217
383
|
}
|
|
218
|
-
|
|
384
|
+
// The daemon knows both absolute paths because it IS them — and a session it
|
|
385
|
+
// resumes inherits ITS environment, where neither `node` nor `baychat` is on
|
|
386
|
+
// PATH. Without this the woken session is told to re-arm with a command it
|
|
387
|
+
// cannot run, answers once, and goes quiet.
|
|
388
|
+
const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, {
|
|
389
|
+
node: process.execPath,
|
|
390
|
+
cli: process.argv[1] ?? "",
|
|
391
|
+
runtime: target.runtime,
|
|
392
|
+
// A session that last armed over a mailbox is sandboxed, and a sandboxed
|
|
393
|
+
// session cannot background its re-arm. Telling it to would hand it an
|
|
394
|
+
// instruction that silently does nothing.
|
|
395
|
+
transport: target.transport,
|
|
396
|
+
});
|
|
219
397
|
const { file, args } = adapter.headlessCommand(resolved, prompt);
|
|
398
|
+
// An ABSOLUTE path came from `SessionTarget.runtimeBin` — recorded by
|
|
399
|
+
// `attach`, inside the session, because the binary a session was launched
|
|
400
|
+
// with is knowable there and nowhere else. Re-resolving it against the
|
|
401
|
+
// daemon's PATH is precisely the guess that rule exists to forbid, and
|
|
402
|
+
// doing so broke a live session: the path ended in `claude.exe`, PATH
|
|
403
|
+
// resolution was asked for a file of that name, and every wake died with
|
|
404
|
+
// "not found on PATH".
|
|
405
|
+
//
|
|
406
|
+
// A BARE NAME has no such provenance, and is where PATH gets it wrong —
|
|
407
|
+
// a Windows shim ahead of the real binary under WSL. That one is resolved.
|
|
408
|
+
let executable = file;
|
|
409
|
+
if (!path.isAbsolute(file)) {
|
|
410
|
+
const binary = this.binaryFor(file);
|
|
411
|
+
if (!binary.ok) {
|
|
412
|
+
this.record({ kind: "pending", session, reason: (0, runtime_binary_1.summarizeResolutionFailure)(binary) }, session, batch);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
executable = binary.path;
|
|
416
|
+
}
|
|
417
|
+
// A runtime with a richer transport than "spawn a command" gets to use it.
|
|
418
|
+
// Only a transport that could not be used AT ALL falls through to the spawn:
|
|
419
|
+
// a failure that is a real answer about this session — an id that names no
|
|
420
|
+
// thread, a turn the runtime refused — would say exactly the same thing
|
|
421
|
+
// again down the older path, more slowly and less clearly.
|
|
422
|
+
const richTransport = this.runTurn
|
|
423
|
+
? (input) => this.runTurn({ runtime: target.runtime, ...input })
|
|
424
|
+
: adapter.runTurn?.bind(adapter);
|
|
425
|
+
if (richTransport) {
|
|
426
|
+
const outcome = await richTransport({ binaryPath: executable, target: resolved, prompt });
|
|
427
|
+
if (outcome.kind === "completed") {
|
|
428
|
+
this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!outcome.transportUnusable) {
|
|
432
|
+
this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
|
|
436
|
+
}
|
|
437
|
+
// A Windows .cmd needs its interpreter named explicitly, whatever produced
|
|
438
|
+
// the path — resolution or the session's own runtimeBin — so the decision
|
|
439
|
+
// lives here, after both, rather than inside either. Never a shell: these
|
|
440
|
+
// args carry the wake prompt, and therefore other people's message text.
|
|
441
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
|
|
220
442
|
// The session's own directory when we know it: `cwd` is only where the
|
|
221
443
|
// attach process ran, and `codex exec` refuses to start outside a trusted
|
|
222
444
|
// directory at all.
|
|
223
|
-
const { exitCode, stderr } = await this.spawnHeadless(file, args, {
|
|
445
|
+
const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
|
|
446
|
+
cwd: resolved.resumeCwd ?? resolved.cwd,
|
|
447
|
+
});
|
|
224
448
|
if (exitCode !== 0) {
|
|
449
|
+
// The binary ran and the turn still failed, so what we resolved may be
|
|
450
|
+
// stale — a half-finished upgrade, a runtime removed since. Drop it and
|
|
451
|
+
// prove it again next time rather than trusting it for the process's life.
|
|
452
|
+
this.binaries.delete(file);
|
|
225
453
|
// A non-zero headless turn did not necessarily reply. Recording it as
|
|
226
454
|
// delivered would claim an answer we cannot evidence.
|
|
227
455
|
this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
|
|
@@ -229,6 +457,21 @@ class RelayDaemon {
|
|
|
229
457
|
}
|
|
230
458
|
this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
|
|
231
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* The proven executable for a runtime, resolved at most once per process
|
|
462
|
+
* until something gives us reason to doubt it.
|
|
463
|
+
*/
|
|
464
|
+
binaryFor(name) {
|
|
465
|
+
const known = this.binaries.get(name);
|
|
466
|
+
if (known)
|
|
467
|
+
return known;
|
|
468
|
+
const resolved = this.resolveBinary(name);
|
|
469
|
+
this.binaries.set(name, resolved);
|
|
470
|
+
this.log(resolved.ok
|
|
471
|
+
? `${name} resolved to ${resolved.path} (${resolved.version})`
|
|
472
|
+
: `${name} unresolved: ${(0, runtime_binary_1.summarizeResolutionFailure)(resolved)}`);
|
|
473
|
+
return resolved;
|
|
474
|
+
}
|
|
232
475
|
/**
|
|
233
476
|
* Fill in a missing resume id from the runtime's on-disk state, at most once
|
|
234
477
|
* every `DISCOVERY_INTERVAL_MS` per session.
|
|
@@ -309,6 +552,16 @@ class RelayDaemon {
|
|
|
309
552
|
// for it, so drop the throttle and let the next wake use the new id.
|
|
310
553
|
if (frame.resumeId)
|
|
311
554
|
this.discoveryReasons.delete(frame.session);
|
|
555
|
+
// EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
|
|
556
|
+
// and `set` alone would leave the old socket open and its process
|
|
557
|
+
// running — which is how two orphaned attaches came to be stacked on
|
|
558
|
+
// one name on 2026-08-30, each one a place a wake could vanish into.
|
|
559
|
+
// Destroying it makes the displaced attach exit instead of lingering.
|
|
560
|
+
const previous = this.attached.get(frame.session);
|
|
561
|
+
if (previous && previous !== sock) {
|
|
562
|
+
this.log(`replacing attach for ${frame.session}: dropping the previous one`);
|
|
563
|
+
previous.destroy();
|
|
564
|
+
}
|
|
312
565
|
this.attached.set(frame.session, sock);
|
|
313
566
|
this.registry.setAttached(frame.session, true);
|
|
314
567
|
(0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
|
|
@@ -340,7 +593,8 @@ class RelayDaemon {
|
|
|
340
593
|
status() {
|
|
341
594
|
const sessions = this.registry.all().map((t) => ({
|
|
342
595
|
...t,
|
|
343
|
-
attached: this.attached.has(t.name),
|
|
596
|
+
attached: this.attached.has(t.name) || this.mailboxes.has(t.name),
|
|
597
|
+
transport: this.attached.has(t.name) ? "socket" : this.mailboxes.has(t.name) ? "fifo" : undefined,
|
|
344
598
|
}));
|
|
345
599
|
return {
|
|
346
600
|
running: true,
|
|
@@ -362,6 +616,8 @@ class RelayDaemon {
|
|
|
362
616
|
sock.destroy();
|
|
363
617
|
this.attached.clear();
|
|
364
618
|
await this.queue.idle();
|
|
619
|
+
for (const w of this.mailboxWatchers)
|
|
620
|
+
w.stop();
|
|
365
621
|
await new Promise((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
|
|
366
622
|
(0, socket_1.unlinkStaleSocket)((0, socket_1.socketPath)());
|
|
367
623
|
try {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MailboxWatcher = void 0;
|
|
37
|
+
const crypto = __importStar(require("crypto"));
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const mailbox_1 = require("./mailbox");
|
|
41
|
+
const parent_watch_1 = require("./parent-watch");
|
|
42
|
+
/**
|
|
43
|
+
* Watch the mailbox root for sessions arming and dying.
|
|
44
|
+
*
|
|
45
|
+
* `fs.watch` AND a periodic sweep, not one or the other. `fs.watch` gives
|
|
46
|
+
* instant pickup but is unreliable across filesystems — notably a tmpfs
|
|
47
|
+
* directory the agent created moments earlier — and a sweep alone would add
|
|
48
|
+
* seconds to every arming. The sweep is also the only thing that can notice a
|
|
49
|
+
* registration whose process has since died, because nothing on disk changes
|
|
50
|
+
* when a process exits and there is no event to watch for.
|
|
51
|
+
*/
|
|
52
|
+
class MailboxWatcher {
|
|
53
|
+
opts;
|
|
54
|
+
seen = new Map();
|
|
55
|
+
isAlive;
|
|
56
|
+
sweepMs;
|
|
57
|
+
watcher;
|
|
58
|
+
timer;
|
|
59
|
+
constructor(opts) {
|
|
60
|
+
this.opts = opts;
|
|
61
|
+
this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
|
|
62
|
+
this.sweepMs = opts.sweepMs ?? 2_000;
|
|
63
|
+
}
|
|
64
|
+
start() {
|
|
65
|
+
fs.mkdirSync(this.opts.root, { recursive: true, mode: 0o700 });
|
|
66
|
+
try {
|
|
67
|
+
this.watcher = fs.watch(this.opts.root, { recursive: true }, () => this.sweep());
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Not every platform or filesystem supports recursive watching. The sweep
|
|
71
|
+
// below is the floor, so this degrades to slower pickup, never to none.
|
|
72
|
+
}
|
|
73
|
+
this.timer = setInterval(() => this.sweep(), this.sweepMs);
|
|
74
|
+
this.timer.unref();
|
|
75
|
+
this.sweep();
|
|
76
|
+
}
|
|
77
|
+
stop() {
|
|
78
|
+
this.watcher?.close();
|
|
79
|
+
if (this.timer)
|
|
80
|
+
clearInterval(this.timer);
|
|
81
|
+
}
|
|
82
|
+
sweep() {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = fs.readdirSync(this.opts.root, { withFileTypes: true });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return; // No root yet: nothing has armed. Not a fault.
|
|
89
|
+
}
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (!entry.isDirectory())
|
|
92
|
+
continue;
|
|
93
|
+
const dir = path.join(this.opts.root, entry.name);
|
|
94
|
+
const reg = (0, mailbox_1.readRegistration)(dir);
|
|
95
|
+
if (!reg)
|
|
96
|
+
continue;
|
|
97
|
+
if (!this.isAlive(reg.pid)) {
|
|
98
|
+
this.seen.delete(reg.session);
|
|
99
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
this.opts.onDrop(reg.session, `mailbox process ${reg.pid} is no longer running`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Identity is the registration's CONTENT, not its timestamp.
|
|
104
|
+
//
|
|
105
|
+
// `registeredAt` has millisecond resolution, so two armings inside one
|
|
106
|
+
// millisecond share a timestamp — which in a test is a flake and in
|
|
107
|
+
// production is a re-arm that goes unannounced. Hashing the content is
|
|
108
|
+
// exact: anything the daemon would act on differently is a different hash,
|
|
109
|
+
// and a byte-identical rewrite is one the daemon already has right.
|
|
110
|
+
const stamp = crypto.createHash("sha256").update(JSON.stringify(reg)).digest("hex");
|
|
111
|
+
if (this.seen.get(reg.session) === stamp)
|
|
112
|
+
continue;
|
|
113
|
+
this.seen.set(reg.session, stamp);
|
|
114
|
+
this.opts.onRegister(reg);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
exports.MailboxWatcher = MailboxWatcher;
|