@bastani/atomic 0.9.28-alpha.4 → 0.9.28-alpha.5
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/CHANGELOG.md +16 -0
- package/dist/builtin/intercom/CHANGELOG.md +6 -0
- package/dist/builtin/intercom/README.md +3 -2
- package/dist/builtin/intercom/broker/broker.ts +90 -14
- package/dist/builtin/intercom/broker/paths.ts +4 -0
- package/dist/builtin/intercom/broker/socket-liveness.ts +32 -0
- package/dist/builtin/intercom/broker/socket-replacement-lock.ts +104 -0
- package/dist/builtin/intercom/broker/spawn.ts +108 -63
- package/dist/builtin/intercom/index.bundle.mjs +114 -61
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +6 -0
- package/dist/builtin/workflows/builtin/{chunk-aqv8ny38.js → chunk-9ywkdsa9.js} +1 -1
- package/dist/builtin/workflows/builtin/{chunk-p6jagvyf.js → chunk-bnz4knhy.js} +1 -1
- package/dist/builtin/workflows/builtin/{chunk-br4ac9a3.js → chunk-dx5sjaxe.js} +1 -1
- package/dist/builtin/workflows/builtin/{chunk-p3xmajmj.js → chunk-v4scsq3y.js} +41 -13
- package/dist/builtin/workflows/builtin/goal.js +2 -2
- package/dist/builtin/workflows/builtin/index.js +3 -3
- package/dist/builtin/workflows/builtin/ralph.js +2 -2
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/src/extension/index.bundle.mjs +38 -14
- package/dist/builtin/workflows/src/index.js +38 -14
- package/dist/cli/startup-ui.d.ts +3 -2
- package/dist/cli/startup-ui.d.ts.map +1 -1
- package/dist/cli/startup-ui.js +19 -4
- package/dist/cli/startup-ui.js.map +1 -1
- package/dist/core/agent-session-runtime.d.ts +5 -1
- package/dist/core/agent-session-runtime.d.ts.map +1 -1
- package/dist/core/agent-session-runtime.js +13 -9
- package/dist/core/agent-session-runtime.js.map +1 -1
- package/dist/core/extensions/session-events.d.ts +2 -0
- package/dist/core/extensions/session-events.d.ts.map +1 -1
- package/dist/core/extensions/session-events.js.map +1 -1
- package/dist/core/provider-alias.d.ts.map +1 -1
- package/dist/core/provider-alias.js +8 -0
- package/dist/core/provider-alias.js.map +1 -1
- package/dist/core/provider-composer-internal.d.ts.map +1 -1
- package/dist/core/provider-composer-internal.js +6 -3
- package/dist/core/provider-composer-internal.js.map +1 -1
- package/dist/core/resource-loader-reload.d.ts.map +1 -1
- package/dist/core/resource-loader-reload.js +1 -0
- package/dist/core/resource-loader-reload.js.map +1 -1
- package/dist/core/resource-loader-types.d.ts +1 -0
- package/dist/core/resource-loader-types.d.ts.map +1 -1
- package/dist/core/resource-loader-types.js.map +1 -1
- package/dist/extensions/herdr/index.d.ts.map +1 -1
- package/dist/extensions/herdr/index.js +20 -2
- package/dist/extensions/herdr/index.js.map +1 -1
- package/dist/extensions/herdr/pane-owner.d.ts +7 -0
- package/dist/extensions/herdr/pane-owner.d.ts.map +1 -1
- package/dist/extensions/herdr/pane-owner.js +53 -11
- package/dist/extensions/herdr/pane-owner.js.map +1 -1
- package/dist/main-deferred-startup.d.ts +8 -0
- package/dist/main-deferred-startup.d.ts.map +1 -1
- package/dist/main-deferred-startup.js +6 -0
- package/dist/main-deferred-startup.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +11 -3
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/interactive-auth-routing.js +2 -2
- package/dist/modes/interactive/interactive-auth-routing.js.map +1 -1
- package/dist/modes/interactive/interactive-extension-dialogs.js +8 -0
- package/dist/modes/interactive/interactive-extension-dialogs.js.map +1 -1
- package/dist/modes/interactive/interactive-mode-base.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode-base.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode-base.js +1 -0
- package/dist/modes/interactive/interactive-mode-base.js.map +1 -1
- package/dist/modes/interactive/interactive-mode-helpers.js +1 -1
- package/dist/modes/interactive/interactive-mode-helpers.js.map +1 -1
- package/dist/modes/interactive/interactive-mode-types.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode-types.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode-types.js.map +1 -1
- package/dist/modes/interactive/interactive-process-lifecycle.js +1 -1
- package/dist/modes/interactive/interactive-process-lifecycle.js.map +1 -1
- package/dist/modes/interactive/interactive-startup-trust.d.ts +6 -0
- package/dist/modes/interactive/interactive-startup-trust.d.ts.map +1 -0
- package/dist/modes/interactive/interactive-startup-trust.js +15 -0
- package/dist/modes/interactive/interactive-startup-trust.js.map +1 -0
- package/dist/modes/interactive/interactive-startup.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-startup.js +3 -0
- package/dist/modes/interactive/interactive-startup.js.map +1 -1
- package/dist/modes/interactive-engine/engine-child-liveness.d.ts +1 -0
- package/dist/modes/interactive-engine/engine-child-liveness.d.ts.map +1 -1
- package/dist/modes/interactive-engine/engine-child-liveness.js +15 -1
- package/dist/modes/interactive-engine/engine-child-liveness.js.map +1 -1
- package/dist/modes/interactive-engine/engine-monitor.d.ts +4 -0
- package/dist/modes/interactive-engine/engine-monitor.d.ts.map +1 -1
- package/dist/modes/interactive-engine/engine-monitor.js +17 -0
- package/dist/modes/interactive-engine/engine-monitor.js.map +1 -1
- package/dist/modes/interactive-engine/engine-quit-intent.d.ts +16 -0
- package/dist/modes/interactive-engine/engine-quit-intent.d.ts.map +1 -0
- package/dist/modes/interactive-engine/engine-quit-intent.js +27 -0
- package/dist/modes/interactive-engine/engine-quit-intent.js.map +1 -0
- package/dist/modes/interactive-engine/extension-ui-bridge.d.ts +1 -0
- package/dist/modes/interactive-engine/extension-ui-bridge.d.ts.map +1 -1
- package/dist/modes/interactive-engine/extension-ui-bridge.js +5 -0
- package/dist/modes/interactive-engine/extension-ui-bridge.js.map +1 -1
- package/dist/modes/interactive-engine/isolated-runtime.d.ts +4 -1
- package/dist/modes/interactive-engine/isolated-runtime.d.ts.map +1 -1
- package/dist/modes/interactive-engine/isolated-runtime.js +19 -2
- package/dist/modes/interactive-engine/isolated-runtime.js.map +1 -1
- package/dist/modes/interactive-engine/protocol.d.ts +18 -1
- package/dist/modes/interactive-engine/protocol.d.ts.map +1 -1
- package/dist/modes/interactive-engine/protocol.js +6 -0
- package/dist/modes/interactive-engine/protocol.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +11 -0
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +42 -0
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-input-scheduler.js +2 -2
- package/dist/modes/rpc/rpc-input-scheduler.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +23 -6
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-provider-auth.js +1 -1
- package/dist/modes/rpc/rpc-provider-auth.js.map +1 -1
- package/docs/extensions/events.md +3 -0
- package/docs/herdr.md +11 -2
- package/docs/models.md +1 -1
- package/docs/providers.md +2 -0
- package/docs/sdk.md +1 -1
- package/docs/settings.md +1 -1
- package/npm-shrinkwrap.json +32 -32
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.28-alpha.5] - 2026-10-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `fromSignal` to the `session_shutdown` event. It is `true` when a `quit` comes from a host `SIGTERM` or `SIGHUP` instead of Ctrl+D or `/quit`, and `runtimeHost.dispose({ fromSignal: true })` sets it for SDK hosts ([#3492](https://github.com/bastani-inc/atomic/issues/3492)).
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `openai-decisions/gpt-6-luna` no longer has its own `/login` entry, so you do not need to sign in again. The classifier uses the API key saved for `openai-api`, then the one saved for `openai`, then `OPENAI_API_KEY`. A ChatGPT sign-in on `openai` cannot call the Decisions API and is not used, so the classifier stays unavailable until one of those keys exists. See [Classifier models](docs/models.md#use-classifier-models).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed the startup project trust prompt opening inside the fully painted interface. Atomic now shows the prompt on its own and paints the interface after you choose.
|
|
18
|
+
- Fixed the "This project is not trusted" warning staying in the transcript after you trusted the project at the startup prompt. The warning still appears when you decline trust or leave the project untrusted.
|
|
19
|
+
- Fixed Herdr restoring panes without their Atomic conversation after a Herdr restart. With Herdr 0.9.2 or newer, Atomic now reports `atomic --session <id>` (plus `--session-dir` for a custom session directory) so Herdr can reopen the same session in the same pane, and keeps it registered when Herdr or the system ends Atomic with `SIGTERM` or `SIGHUP`. Quitting with Ctrl+D or `/quit` still clears it, and older Herdr versions keep status reporting without restore ([#3492](https://github.com/bastani-inc/atomic/issues/3492)).
|
|
20
|
+
|
|
5
21
|
## [0.9.28-alpha.4] - 2026-10-07
|
|
6
22
|
|
|
7
23
|
### Added
|
|
@@ -4,6 +4,12 @@ All notable changes to the `pi-intercom` extension will be documented in this fi
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.28-alpha.5] - 2026-10-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Fixed a slow-starting intercom broker taking over a live broker's socket, which left a connected session unreachable by `intercom list` and `send`, so messages to it were silently not delivered ([#3505](https://github.com/bastani-inc/atomic/issues/3505)).
|
|
12
|
+
|
|
7
13
|
## [0.9.28-alpha.1] - 2026-10-05
|
|
8
14
|
|
|
9
15
|
### Fixed
|
|
@@ -492,7 +492,8 @@ Runtime files live under the active agent directory. Atomic defaults to `~/.atom
|
|
|
492
492
|
- `broker.pid` — Broker process ID
|
|
493
493
|
- `delivered-messages.sqlite` — bounded durable authority containing keyed digests, never plaintext payloads
|
|
494
494
|
- `delivered-messages.key` — random owner-only HMAC key paired with the authority database
|
|
495
|
-
- `broker.spawn.lock` —
|
|
495
|
+
- `broker.spawn.lock` — Lock used to avoid duplicate auto-spawns. It records the spawner and its broker, and is held until the broker answers or the processes it names are gone, with a 60 second backstop. On Windows, where the hidden launcher does not expose the broker process, it ends with the spawner's 5 second readiness wait, and a later spawn that finds `broker.log` still held by a starting broker waits for that broker instead of starting another
|
|
496
|
+
- `broker.sock.replace.lock` — Short-lived lock a starting broker holds while it re-checks and replaces a stale socket file, so two brokers never both replace it
|
|
496
497
|
- `broker.log` — Broker stderr, truncated on every spawn and capped at 8 KiB by the broker itself
|
|
497
498
|
- `config.json` — User configuration
|
|
498
499
|
|
|
@@ -504,7 +505,7 @@ The broker caps the file from the inside, since nothing on the parent side can b
|
|
|
504
505
|
|
|
505
506
|
**Local IPC instead of TCP.** Same-machine only by design. `pi-intercom` uses Unix sockets on macOS/Linux and a named pipe on Windows, which keeps setup simple and avoids port management.
|
|
506
507
|
|
|
507
|
-
**Auto-spawn with file lock.** The broker starts on first connection and exits after 5 seconds idle. There is no daemon to manage. A spawn lock file, keyed by PID and timestamp, prevents duplicate brokers when multiple sessions start at once.
|
|
508
|
+
**Auto-spawn with file lock.** The broker starts on first connection and exits after 5 seconds idle. There is no daemon to manage. A spawn lock file, keyed by PID and timestamp, prevents duplicate brokers when multiple sessions start at once. A broker that starts while another broker already answers on the socket exits instead of taking the socket over; it replaces only a stale socket file. A broker whose socket path comes to point at another broker shuts down, and its sessions reconnect to the broker that owns the path.
|
|
508
509
|
|
|
509
510
|
**`ask` stays client-side.** The broker still routes plain messages; it does not have a special request/response mode for `ask`. The client waits for a matching reply before it triggers a new turn, then returns that reply as the tool result. Reply hints make that flow practical by showing the recipient the exact `send` call to use. Separately, `list` / `sessions` now carry a `requestId` so a delayed session-list reply cannot be mistaken for a newer one.
|
|
510
511
|
|
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
// only position from which the stderr cap covers the other modules' own initialization.
|
|
3
3
|
import "./bounded-stderr-install.js";
|
|
4
4
|
import net from "net";
|
|
5
|
-
import { chmodSync, writeFileSync, unlinkSync, mkdirSync, readFileSync } from "fs";
|
|
5
|
+
import { chmodSync, writeFileSync, unlinkSync, mkdirSync, readFileSync, statSync } from "fs";
|
|
6
6
|
import { randomUUID } from "crypto";
|
|
7
7
|
import { createMessageReader, type JsonWireValue } from "./framing.js";
|
|
8
8
|
import { writeMessageIfOpen, writeMessageWithOutcome } from "./socket-writes.js";
|
|
9
|
+
import { isSocketAnswering } from "./socket-liveness.js";
|
|
10
|
+
import { acquireSocketReplacementLock } from "./socket-replacement-lock.js";
|
|
9
11
|
import {
|
|
10
12
|
getBrokerDeliveredMessagesPath,
|
|
11
13
|
getBrokerPidPath,
|
|
12
14
|
getBrokerSocketPath,
|
|
15
|
+
getBrokerSocketReplacementLockPath,
|
|
13
16
|
getIntercomDirPath,
|
|
14
17
|
} from "./paths.js";
|
|
15
18
|
import type {
|
|
@@ -60,8 +63,17 @@ import {
|
|
|
60
63
|
const INTERCOM_DIR = getIntercomDirPath();
|
|
61
64
|
const SOCKET_PATH = getBrokerSocketPath();
|
|
62
65
|
const PID_PATH = getBrokerPidPath();
|
|
66
|
+
const SOCKET_REPLACEMENT_LOCK_PATH = getBrokerSocketReplacementLockPath();
|
|
63
67
|
|
|
64
68
|
const PENDING_STAGE_MESSAGE_TIMEOUT_MS = 10_000;
|
|
69
|
+
const LISTEN_ATTEMPTS = 5;
|
|
70
|
+
const LISTEN_RETRY_DELAY_MS = 100;
|
|
71
|
+
const SOCKET_OWNERSHIP_CHECK_MS = 1_000;
|
|
72
|
+
|
|
73
|
+
function exitBecauseLiveBrokerOwnsSocket(): never {
|
|
74
|
+
console.error(`Intercom broker not started: a live broker already answers on ${SOCKET_PATH}`);
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
65
77
|
|
|
66
78
|
interface PendingStageRouteRegistration {
|
|
67
79
|
readonly sessionId: string;
|
|
@@ -249,6 +261,7 @@ class IntercomBroker {
|
|
|
249
261
|
private sessions = new Map<string, ConnectedSession>();
|
|
250
262
|
private server: net.Server;
|
|
251
263
|
private shutdownTimer: NodeJS.Timeout | null = null;
|
|
264
|
+
private boundSocketInode: number | undefined;
|
|
252
265
|
private lastConnectionAt = performance.now();
|
|
253
266
|
private deliveredMessages = new DeliveredMessageCache(
|
|
254
267
|
DELIVERED_MESSAGE_TTL_MS,
|
|
@@ -267,25 +280,82 @@ class IntercomBroker {
|
|
|
267
280
|
constructor() {
|
|
268
281
|
mkdirSync(INTERCOM_DIR, { recursive: true, mode: 0o700 });
|
|
269
282
|
if (process.platform !== "win32") chmodSync(INTERCOM_DIR, 0o700);
|
|
270
|
-
if (process.platform !== "win32") {
|
|
271
|
-
try {
|
|
272
|
-
unlinkSync(SOCKET_PATH);
|
|
273
|
-
} catch {
|
|
274
|
-
// A clean startup has no stale socket to remove.
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
283
|
this.server = net.createServer(this.handleConnection.bind(this));
|
|
278
284
|
}
|
|
279
285
|
|
|
280
286
|
start(): void {
|
|
287
|
+
this.listen(1);
|
|
288
|
+
process.on("SIGTERM", () => this.shutdown());
|
|
289
|
+
process.on("SIGINT", () => this.shutdown());
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private listen(attempt: number, releaseReplacementLock: () => void = () => {}): void {
|
|
293
|
+
const onError = (error: NodeJS.ErrnoException) => {
|
|
294
|
+
releaseReplacementLock();
|
|
295
|
+
void this.recoverFromListenError(error, attempt);
|
|
296
|
+
};
|
|
297
|
+
this.server.once("error", onError);
|
|
281
298
|
this.server.listen(SOCKET_PATH, () => {
|
|
299
|
+
this.server.off("error", onError);
|
|
300
|
+
if (process.platform !== "win32") this.boundSocketInode = statSync(SOCKET_PATH).ino;
|
|
301
|
+
releaseReplacementLock();
|
|
282
302
|
writeFileSync(PID_PATH, String(process.pid));
|
|
283
303
|
console.log(`Intercom broker started (pid: ${process.pid})`);
|
|
284
304
|
this.lastConnectionAt = performance.now();
|
|
285
305
|
this.scheduleShutdownCheck();
|
|
306
|
+
if (this.boundSocketInode !== undefined) this.watchSocketOwnership();
|
|
286
307
|
});
|
|
287
|
-
|
|
288
|
-
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private ownsSocketPath(): boolean {
|
|
311
|
+
if (this.boundSocketInode === undefined) return process.platform === "win32";
|
|
312
|
+
try {
|
|
313
|
+
return statSync(SOCKET_PATH).ino === this.boundSocketInode;
|
|
314
|
+
} catch {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A broker whose socket path now leads to another broker can no longer be reached by new clients, so its sessions
|
|
321
|
+
* would be stranded. It shuts down instead, and its clients reconnect through broker.sock to the broker that owns it.
|
|
322
|
+
*/
|
|
323
|
+
private watchSocketOwnership(): void {
|
|
324
|
+
const timer = setInterval(() => {
|
|
325
|
+
if (this.ownsSocketPath()) return;
|
|
326
|
+
clearInterval(timer);
|
|
327
|
+
console.error(`Intercom broker lost ${SOCKET_PATH} to another broker; shutting down so its sessions reconnect`);
|
|
328
|
+
this.shutdown();
|
|
329
|
+
}, SOCKET_OWNERSHIP_CHECK_MS);
|
|
330
|
+
timer.unref();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Take over only a stale socket: a path a live broker answers on is never unlinked. */
|
|
334
|
+
private async recoverFromListenError(error: NodeJS.ErrnoException, attempt: number): Promise<void> {
|
|
335
|
+
if (error.code === "EADDRINUSE" && (await isSocketAnswering(SOCKET_PATH))) exitBecauseLiveBrokerOwnsSocket();
|
|
336
|
+
if (error.code !== "EADDRINUSE" || attempt >= LISTEN_ATTEMPTS) {
|
|
337
|
+
console.error(`Intercom broker could not listen on ${SOCKET_PATH}: ${error.message}`);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
if (process.platform === "win32") {
|
|
341
|
+
await new Promise<void>((resolve) => setTimeout(resolve, LISTEN_RETRY_DELAY_MS));
|
|
342
|
+
this.listen(attempt + 1);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const releaseReplacementLock = await acquireSocketReplacementLock(SOCKET_REPLACEMENT_LOCK_PATH, {
|
|
346
|
+
onWait: () => console.error(`Intercom broker waiting for another broker replacing ${SOCKET_PATH}`),
|
|
347
|
+
});
|
|
348
|
+
// Another broker may have replaced the stale socket while this one waited for the lock.
|
|
349
|
+
if (await isSocketAnswering(SOCKET_PATH)) {
|
|
350
|
+
releaseReplacementLock();
|
|
351
|
+
exitBecauseLiveBrokerOwnsSocket();
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
unlinkSync(SOCKET_PATH);
|
|
355
|
+
} catch {
|
|
356
|
+
// A clean path has no stale socket to remove.
|
|
357
|
+
}
|
|
358
|
+
this.listen(attempt + 1, releaseReplacementLock);
|
|
289
359
|
}
|
|
290
360
|
|
|
291
361
|
private handleConnection(socket: net.Socket): void {
|
|
@@ -1807,9 +1877,10 @@ class IntercomBroker {
|
|
|
1807
1877
|
const ownsRuntime = this.readPidFile() === process.pid;
|
|
1808
1878
|
this.unlinkRuntimeFilesIfOwned();
|
|
1809
1879
|
// Node unlinks a Unix socket path on server.close() even after a successor
|
|
1810
|
-
// rebound that path. Skip close when we no longer own the pid
|
|
1811
|
-
// still closes our fd. Windows named pipes are not
|
|
1812
|
-
|
|
1880
|
+
// rebound that path. Skip close when we no longer own the pid or the socket
|
|
1881
|
+
// path; process.exit still closes our fd. Windows named pipes are not
|
|
1882
|
+
// path-unlinked this way.
|
|
1883
|
+
if (process.platform === "win32" || (ownsRuntime && this.ownsSocketPath())) {
|
|
1813
1884
|
this.server.close();
|
|
1814
1885
|
}
|
|
1815
1886
|
process.exit(0);
|
|
@@ -1817,4 +1888,9 @@ class IntercomBroker {
|
|
|
1817
1888
|
}
|
|
1818
1889
|
|
|
1819
1890
|
// The stderr cap is already in place: `./bounded-stderr-install.js` is this file's first import.
|
|
1820
|
-
|
|
1891
|
+
async function startUnlessLiveBrokerAnswers(): Promise<void> {
|
|
1892
|
+
if (await isSocketAnswering(SOCKET_PATH)) exitBecauseLiveBrokerOwnsSocket();
|
|
1893
|
+
new IntercomBroker().start();
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
void startUnlessLiveBrokerAnswers();
|
|
@@ -46,6 +46,10 @@ export function getBrokerSpawnLockPath(agentDir: string = getAgentDir()): string
|
|
|
46
46
|
return join(getIntercomDirPath(agentDir), "broker.spawn.lock");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function getBrokerSocketReplacementLockPath(agentDir: string = getAgentDir()): string {
|
|
50
|
+
return join(getIntercomDirPath(agentDir), "broker.sock.replace.lock");
|
|
51
|
+
}
|
|
52
|
+
|
|
49
53
|
/** Durable accepted-operation authority shared by replacement broker processes. */
|
|
50
54
|
export function getBrokerDeliveredMessagesPath(agentDir: string = getAgentDir()): string {
|
|
51
55
|
return join(getIntercomDirPath(agentDir), "delivered-messages.sqlite");
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import net from "net";
|
|
2
|
+
|
|
3
|
+
export const SOCKET_PROBE_TIMEOUT_MS = 1000;
|
|
4
|
+
|
|
5
|
+
/** True when something accepts a connection on the Unix socket or Windows named pipe at `socketPath`. */
|
|
6
|
+
export function isSocketAnswering(socketPath: string, timeoutMs: number = SOCKET_PROBE_TIMEOUT_MS): Promise<boolean> {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const socket = net.connect(socketPath);
|
|
9
|
+
const finish = (isConnected: boolean) => {
|
|
10
|
+
clearTimeout(timeout);
|
|
11
|
+
socket.off("connect", onConnect);
|
|
12
|
+
socket.off("error", onError);
|
|
13
|
+
resolve(isConnected);
|
|
14
|
+
};
|
|
15
|
+
const onConnect = () => {
|
|
16
|
+
// end() starts a half-close; a reset can still arrive before close. Keep a handler until then.
|
|
17
|
+
socket.on("error", () => {});
|
|
18
|
+
socket.end();
|
|
19
|
+
finish(true);
|
|
20
|
+
};
|
|
21
|
+
const onError = () => {
|
|
22
|
+
socket.destroy();
|
|
23
|
+
finish(false);
|
|
24
|
+
};
|
|
25
|
+
socket.on("connect", onConnect);
|
|
26
|
+
socket.on("error", onError);
|
|
27
|
+
const timeout = setTimeout(() => {
|
|
28
|
+
socket.destroy();
|
|
29
|
+
finish(false);
|
|
30
|
+
}, timeoutMs);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
const SOCKET_REPLACEMENT_LOCK_POLL_MS = 20;
|
|
4
|
+
/** Locks and tokens get their pid as they are created, so one still unreadable after this was left by a crash. */
|
|
5
|
+
const UNREADABLE_FILE_GRACE_MS = 1_000;
|
|
6
|
+
|
|
7
|
+
export interface SocketReplacementLockOptions {
|
|
8
|
+
readonly onWait?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isProcessAlive(pid: number): boolean {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function holderPid(path: string): number | undefined {
|
|
21
|
+
try {
|
|
22
|
+
const pid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
23
|
+
return Number.isFinite(pid) ? pid : undefined;
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Holder = { readonly kind: "live" } | { readonly kind: "abandoned"; readonly pid: number | undefined };
|
|
30
|
+
|
|
31
|
+
/** A file whose pid is dead, or that stayed unreadable past the grace period, was left by a crashed broker. */
|
|
32
|
+
function holderOf(path: string): Holder | undefined {
|
|
33
|
+
const pid = holderPid(path);
|
|
34
|
+
if (pid !== undefined) return isProcessAlive(pid) ? { kind: "live" } : { kind: "abandoned", pid };
|
|
35
|
+
try {
|
|
36
|
+
return Date.now() - statSync(path).mtimeMs >= UNREADABLE_FILE_GRACE_MS
|
|
37
|
+
? { kind: "abandoned", pid: undefined }
|
|
38
|
+
: { kind: "live" };
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function removeFile(path: string): void {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(path);
|
|
47
|
+
} catch {
|
|
48
|
+
// Already removed.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createExclusively(path: string): boolean {
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(path, `${process.pid}\n`, { flag: "wx" });
|
|
55
|
+
return true;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Removes an abandoned lock without ever touching a live one. Only the waiter that creates the takeover token for
|
|
64
|
+
* that holder may remove its lock, and while that lock exists no newer lock can be created, so the lock it removes is
|
|
65
|
+
* still the abandoned one. An abandoned token is removed so a later waiter can take over.
|
|
66
|
+
*/
|
|
67
|
+
function takeOverAbandonedLock(lockPath: string, abandonedPid: number | undefined): void {
|
|
68
|
+
const token = `${lockPath}.takeover-${abandonedPid ?? "unreadable"}`;
|
|
69
|
+
if (!createExclusively(token)) {
|
|
70
|
+
if (holderOf(token)?.kind === "abandoned") removeFile(token);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
if (holderPid(lockPath) === abandonedPid) removeFile(lockPath);
|
|
75
|
+
} finally {
|
|
76
|
+
removeFile(token);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Serializes stale-socket replacement across brokers, so a broker that probed the socket as stale cannot unlink a
|
|
82
|
+
* socket another broker bound in the meantime: the holder re-probes before it unlinks. A live holder's lock is never
|
|
83
|
+
* taken; an abandoned one is. Resolves with the release function, which removes the lock only while this process
|
|
84
|
+
* holds it.
|
|
85
|
+
*/
|
|
86
|
+
export async function acquireSocketReplacementLock(
|
|
87
|
+
lockPath: string,
|
|
88
|
+
{ onWait }: SocketReplacementLockOptions = {},
|
|
89
|
+
): Promise<() => void> {
|
|
90
|
+
let announcedWait = false;
|
|
91
|
+
while (!createExclusively(lockPath)) {
|
|
92
|
+
const holder = holderOf(lockPath);
|
|
93
|
+
if (holder?.kind === "abandoned") {
|
|
94
|
+
takeOverAbandonedLock(lockPath, holder.pid);
|
|
95
|
+
} else if (holder?.kind === "live" && !announcedWait) {
|
|
96
|
+
announcedWait = true;
|
|
97
|
+
onWait?.();
|
|
98
|
+
}
|
|
99
|
+
await new Promise<void>((resolve) => setTimeout(resolve, SOCKET_REPLACEMENT_LOCK_POLL_MS));
|
|
100
|
+
}
|
|
101
|
+
return () => {
|
|
102
|
+
if (holderPid(lockPath) === process.pid) removeFile(lockPath);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
2
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
3
3
|
import { join, dirname } from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { createRequire } from "module";
|
|
6
|
-
import
|
|
6
|
+
import { isSocketAnswering } from "./socket-liveness.js";
|
|
7
7
|
import { createChildProcessEnvironment, isBunBinary } from "@bastani/atomic";
|
|
8
8
|
import {
|
|
9
9
|
getBrokerLogPath,
|
|
@@ -57,6 +57,12 @@ export const BROKER_POLL_INITIAL_INTERVAL_MS = 10;
|
|
|
57
57
|
/** Upper bound for the readiness poll interval, matching the historic flat poll. */
|
|
58
58
|
export const BROKER_POLL_MAX_INTERVAL_MS = 100;
|
|
59
59
|
|
|
60
|
+
/** How long one spawn attempt waits for the broker socket to answer. */
|
|
61
|
+
export const BROKER_READY_TIMEOUT_MS = 5000;
|
|
62
|
+
|
|
63
|
+
/** Backstop only: a spawn lock normally ends when its broker is ready or the processes it names are gone. */
|
|
64
|
+
export const BROKER_SPAWN_LOCK_MAX_AGE_MS = 60_000;
|
|
65
|
+
|
|
60
66
|
type BrokerRuntime = "node" | "bun-source" | "bun-binary";
|
|
61
67
|
|
|
62
68
|
export const INTERNAL_INTERCOM_BROKER_ARG = "--atomic-internal-intercom-broker";
|
|
@@ -257,10 +263,20 @@ export function getBrokerSpawnOptions(
|
|
|
257
263
|
};
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
/**
|
|
261
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Truncate (or create) the broker log so each spawn starts from a bounded, current file. Returns false when a
|
|
268
|
+
* Windows broker that is still starting holds the log through its launcher's stderr redirect.
|
|
269
|
+
*/
|
|
270
|
+
function resetBrokerLog(logPath: string = BROKER_LOG): boolean {
|
|
262
271
|
ensurePrivateDirectory(dirname(logPath));
|
|
263
|
-
|
|
272
|
+
try {
|
|
273
|
+
closeSync(openSync(logPath, "w"));
|
|
274
|
+
return true;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
277
|
+
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM")) return false;
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
264
280
|
}
|
|
265
281
|
|
|
266
282
|
/** Read at most {@link BROKER_LOG_TAIL_BYTES} trailing bytes of the broker log. */
|
|
@@ -299,19 +315,24 @@ function toError(error: unknown): Error {
|
|
|
299
315
|
return error instanceof Error ? error : new Error(String(error));
|
|
300
316
|
}
|
|
301
317
|
|
|
302
|
-
export async function spawnBrokerIfNeeded(
|
|
318
|
+
export async function spawnBrokerIfNeeded(
|
|
319
|
+
brokerCommand: string,
|
|
320
|
+
brokerArgs: string[],
|
|
321
|
+
readyTimeoutMs: number = BROKER_READY_TIMEOUT_MS,
|
|
322
|
+
): Promise<void> {
|
|
303
323
|
ensurePrivateDirectory(INTERCOM_DIR);
|
|
304
324
|
|
|
305
325
|
if (await isBrokerRunning()) {
|
|
306
326
|
return;
|
|
307
327
|
}
|
|
308
328
|
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
311
|
-
await waitForBroker();
|
|
329
|
+
const lockOwner = acquireSpawnLock();
|
|
330
|
+
if (lockOwner === undefined) {
|
|
331
|
+
await waitForBroker(readyTimeoutMs);
|
|
312
332
|
return;
|
|
313
333
|
}
|
|
314
334
|
|
|
335
|
+
let keepLock = false;
|
|
315
336
|
try {
|
|
316
337
|
if (await isBrokerRunning()) {
|
|
317
338
|
return;
|
|
@@ -323,7 +344,10 @@ export async function spawnBrokerIfNeeded(brokerCommand: string, brokerArgs: str
|
|
|
323
344
|
writeWindowsHiddenLauncher(launch.launcherCommandLine, launch.launcherPath);
|
|
324
345
|
}
|
|
325
346
|
// Reset before spawning either way: the Windows launcher appends to this same path.
|
|
326
|
-
resetBrokerLog()
|
|
347
|
+
if (!resetBrokerLog()) {
|
|
348
|
+
await waitForBroker(readyTimeoutMs);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
327
351
|
// The Windows launcher redirects the broker's own stderr, so only the direct spawn
|
|
328
352
|
// needs the descriptor. Node duplicates it during spawn, so the parent copy is closed
|
|
329
353
|
// immediately afterwards rather than being held open for the broker's lifetime.
|
|
@@ -358,23 +382,38 @@ export async function spawnBrokerIfNeeded(brokerCommand: string, brokerArgs: str
|
|
|
358
382
|
reject(new Error(`Intercom broker exited before startup with signal ${signal}\n${describeBrokerLog()}`));
|
|
359
383
|
return;
|
|
360
384
|
}
|
|
361
|
-
|
|
362
|
-
new Error(`Intercom broker exited before startup with code ${code ?? "unknown"}\n${describeBrokerLog()}`)
|
|
363
|
-
)
|
|
385
|
+
const exitedBeforeStartup = () =>
|
|
386
|
+
new Error(`Intercom broker exited before startup with code ${code ?? "unknown"}\n${describeBrokerLog()}`);
|
|
387
|
+
if (code === 0) {
|
|
388
|
+
// A broker that finds a live broker on the socket yields with a clean exit.
|
|
389
|
+
void checkSocketConnectable().then((answering) => (answering ? resolve() : reject(exitedBeforeStartup())));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
reject(exitedBeforeStartup());
|
|
364
393
|
};
|
|
365
394
|
|
|
366
395
|
child.once("error", onError);
|
|
367
396
|
child.once("exit", onExit);
|
|
368
|
-
waitForBroker().then(() => {
|
|
397
|
+
waitForBroker(readyTimeoutMs).then(() => {
|
|
369
398
|
cleanup();
|
|
370
399
|
resolve();
|
|
371
400
|
}, (error) => {
|
|
372
401
|
cleanup();
|
|
402
|
+
// A broker still starting keeps the lock, so later spawners wait for it instead of starting another.
|
|
403
|
+
// Only now does the lock name the broker: until this hand-off it follows this live spawner, so no other
|
|
404
|
+
// spawner can judge it stale and replace it while this one may still release it.
|
|
405
|
+
// The Windows launcher hides the broker's pid, so its lock could only follow this live spawner and would
|
|
406
|
+
// block every later spawn; there the broker's own live-socket check is what prevents a takeover.
|
|
407
|
+
keepLock =
|
|
408
|
+
launch.kind === "direct" &&
|
|
409
|
+
child.exitCode === null &&
|
|
410
|
+
child.signalCode === null &&
|
|
411
|
+
handSpawnLockToBroker(child.pid, lockOwner);
|
|
373
412
|
reject(toError(error));
|
|
374
413
|
});
|
|
375
414
|
});
|
|
376
415
|
} finally {
|
|
377
|
-
releaseSpawnLock();
|
|
416
|
+
if (!keepLock) releaseSpawnLock(lockOwner);
|
|
378
417
|
}
|
|
379
418
|
}
|
|
380
419
|
|
|
@@ -397,39 +436,37 @@ async function isBrokerRunning(): Promise<boolean> {
|
|
|
397
436
|
}
|
|
398
437
|
|
|
399
438
|
function checkSocketConnectable(): Promise<boolean> {
|
|
400
|
-
return
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
};
|
|
414
|
-
const onError = () => {
|
|
415
|
-
socket.destroy();
|
|
416
|
-
finish(false);
|
|
417
|
-
};
|
|
418
|
-
socket.on("connect", onConnect);
|
|
419
|
-
socket.on("error", onError);
|
|
420
|
-
const timeout = setTimeout(() => {
|
|
421
|
-
socket.destroy();
|
|
422
|
-
finish(false);
|
|
423
|
-
}, 1000);
|
|
424
|
-
});
|
|
439
|
+
return isSocketAnswering(BROKER_SOCKET);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Records the still-starting broker on the lock so staleness follows that process instead of the spawner. */
|
|
443
|
+
function handSpawnLockToBroker(pid: number | undefined, owner: string): boolean {
|
|
444
|
+
if (pid === undefined || !ownsSpawnLock(owner)) return false;
|
|
445
|
+
try {
|
|
446
|
+
appendFileSync(BROKER_SPAWN_LOCK, `${pid}\n`);
|
|
447
|
+
return true;
|
|
448
|
+
} catch {
|
|
449
|
+
// The startup error must still reach the caller; an unrecorded lock is released like any other.
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
425
452
|
}
|
|
426
453
|
|
|
427
|
-
function
|
|
454
|
+
function ownsSpawnLock(owner: string, lockPath: string = BROKER_SPAWN_LOCK): boolean {
|
|
455
|
+
try {
|
|
456
|
+
return readFileSync(lockPath, "utf-8").startsWith(owner);
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Returns the header this spawner wrote, which proves ownership on release, or undefined when another spawner holds the lock. */
|
|
463
|
+
function acquireSpawnLock(): string | undefined {
|
|
428
464
|
const maxRetries = 5;
|
|
429
465
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
466
|
+
const owner = `${process.pid}\n${Date.now()}\n`;
|
|
430
467
|
try {
|
|
431
|
-
writeFileSync(BROKER_SPAWN_LOCK,
|
|
432
|
-
return
|
|
468
|
+
writeFileSync(BROKER_SPAWN_LOCK, owner, { flag: "wx" });
|
|
469
|
+
return owner;
|
|
433
470
|
} catch (error) {
|
|
434
471
|
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
435
472
|
throw error;
|
|
@@ -442,42 +479,50 @@ function acquireSpawnLock(): boolean {
|
|
|
442
479
|
}
|
|
443
480
|
continue;
|
|
444
481
|
}
|
|
445
|
-
return
|
|
482
|
+
return undefined;
|
|
446
483
|
}
|
|
447
484
|
}
|
|
448
|
-
return
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function isProcessAlive(pid: number): boolean {
|
|
489
|
+
try {
|
|
490
|
+
process.kill(pid, 0);
|
|
491
|
+
return true;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
494
|
+
}
|
|
449
495
|
}
|
|
450
496
|
|
|
451
|
-
|
|
452
|
-
|
|
497
|
+
/**
|
|
498
|
+
* A lock is stale once the broker it recorded is gone, or, while no broker is recorded, once its
|
|
499
|
+
* spawner is gone. A live broker keeps the lock even if its spawner died, until the age backstop.
|
|
500
|
+
*/
|
|
501
|
+
export function isSpawnLockStale(lockPath: string = BROKER_SPAWN_LOCK, nowMs: number = Date.now()): boolean {
|
|
502
|
+
if (!existsSync(lockPath)) {
|
|
453
503
|
return false;
|
|
454
504
|
}
|
|
455
505
|
|
|
456
506
|
try {
|
|
457
|
-
const [
|
|
458
|
-
const
|
|
507
|
+
const [spawnerLine = "", createdAtLine = "", brokerLine = ""] = readFileSync(lockPath, "utf-8").trim().split("\n");
|
|
508
|
+
const spawnerPid = Number.parseInt(spawnerLine, 10);
|
|
459
509
|
const createdAt = Number.parseInt(createdAtLine, 10);
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
if (Number.isFinite(pid)) {
|
|
463
|
-
try {
|
|
464
|
-
process.kill(pid, 0);
|
|
465
|
-
} catch {
|
|
466
|
-
// The process that created the lock is gone.
|
|
467
|
-
return true;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
510
|
+
const brokerPid = Number.parseInt(brokerLine, 10);
|
|
470
511
|
|
|
471
|
-
|
|
512
|
+
if (!Number.isFinite(spawnerPid) || !Number.isFinite(createdAt)) return true;
|
|
513
|
+
if (nowMs - createdAt > BROKER_SPAWN_LOCK_MAX_AGE_MS) return true;
|
|
514
|
+
return !isProcessAlive(Number.isFinite(brokerPid) ? brokerPid : spawnerPid);
|
|
472
515
|
} catch {
|
|
473
516
|
// Unreadable lock contents are treated as stale so a new broker can start.
|
|
474
517
|
return true;
|
|
475
518
|
}
|
|
476
519
|
}
|
|
477
520
|
|
|
478
|
-
|
|
521
|
+
/** Removes the lock only while it is still the one `owner` wrote; a spawner that replaced a stale lock keeps its own. */
|
|
522
|
+
export function releaseSpawnLock(owner: string, lockPath: string = BROKER_SPAWN_LOCK): void {
|
|
523
|
+
if (!ownsSpawnLock(owner, lockPath)) return;
|
|
479
524
|
try {
|
|
480
|
-
unlinkSync(
|
|
525
|
+
unlinkSync(lockPath);
|
|
481
526
|
} catch {
|
|
482
527
|
// Another cleanup path may already have removed the lock.
|
|
483
528
|
}
|
|
@@ -488,7 +533,7 @@ function releaseSpawnLock(): void {
|
|
|
488
533
|
* Startup is tens of milliseconds, so the previous flat 100 ms sleep dominated it; the
|
|
489
534
|
* overall timeout semantics are unchanged.
|
|
490
535
|
*/
|
|
491
|
-
export async function waitForBroker(timeoutMs =
|
|
536
|
+
export async function waitForBroker(timeoutMs = BROKER_READY_TIMEOUT_MS): Promise<void> {
|
|
492
537
|
const start = Date.now();
|
|
493
538
|
let interval = BROKER_POLL_INITIAL_INTERVAL_MS;
|
|
494
539
|
while (Date.now() - start < timeoutMs) {
|