@cortexkit/aft-bridge 0.61.0 → 0.63.4
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/bash-env.d.ts +28 -0
- package/dist/bash-env.d.ts.map +1 -0
- package/dist/bash-env.js +161 -0
- package/dist/bash-env.js.map +1 -0
- package/dist/bash-hints.js +1 -1
- package/dist/bash-hints.js.map +1 -1
- package/dist/bash-host-fallback.d.ts +8 -3
- package/dist/bash-host-fallback.d.ts.map +1 -1
- package/dist/bash-host-fallback.js +8 -3
- package/dist/bash-host-fallback.js.map +1 -1
- package/dist/bash-output-page.d.ts +35 -0
- package/dist/bash-output-page.d.ts.map +1 -0
- package/dist/bash-output-page.js +78 -0
- package/dist/bash-output-page.js.map +1 -0
- package/dist/bash-pattern-ack.d.ts +21 -0
- package/dist/bash-pattern-ack.d.ts.map +1 -0
- package/dist/bash-pattern-ack.js +60 -0
- package/dist/bash-pattern-ack.js.map +1 -0
- package/dist/bash-permissions.d.ts +23 -0
- package/dist/bash-permissions.d.ts.map +1 -0
- package/dist/bash-permissions.js +84 -0
- package/dist/bash-permissions.js.map +1 -0
- package/dist/bash-watch-lines.d.ts +11 -0
- package/dist/bash-watch-lines.d.ts.map +1 -0
- package/dist/bash-watch-lines.js +91 -0
- package/dist/bash-watch-lines.js.map +1 -0
- package/dist/bash-watch-progress.d.ts +34 -0
- package/dist/bash-watch-progress.d.ts.map +1 -0
- package/dist/bash-watch-progress.js +154 -0
- package/dist/bash-watch-progress.js.map +1 -0
- package/dist/bridge.d.ts +207 -24
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +393 -81
- package/dist/bridge.js.map +1 -1
- package/dist/callgraph-format.d.ts.map +1 -1
- package/dist/callgraph-format.js +36 -12
- package/dist/callgraph-format.js.map +1 -1
- package/dist/coerce.d.ts +4 -4
- package/dist/coerce.d.ts.map +1 -1
- package/dist/coerce.js +10 -7
- package/dist/coerce.js.map +1 -1
- package/dist/command-timeouts.d.ts +13 -0
- package/dist/command-timeouts.d.ts.map +1 -1
- package/dist/command-timeouts.js +34 -0
- package/dist/command-timeouts.js.map +1 -1
- package/dist/config-watch.d.ts +3 -0
- package/dist/config-watch.d.ts.map +1 -1
- package/dist/config-watch.js +4 -0
- package/dist/config-watch.js.map +1 -1
- package/dist/edit-summary.d.ts +2 -0
- package/dist/edit-summary.d.ts.map +1 -1
- package/dist/edit-summary.js +9 -1
- package/dist/edit-summary.js.map +1 -1
- package/dist/error-contract.d.ts +15 -6
- package/dist/error-contract.d.ts.map +1 -1
- package/dist/error-contract.js +97 -8
- package/dist/error-contract.js.map +1 -1
- package/dist/feature-config.d.ts +32 -4
- package/dist/feature-config.d.ts.map +1 -1
- package/dist/feature-config.js +127 -8
- package/dist/feature-config.js.map +1 -1
- package/dist/index.d.ts +16 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/lsp-github-install.d.ts +20 -0
- package/dist/lsp-github-install.d.ts.map +1 -1
- package/dist/lsp-github-install.js +39 -69
- package/dist/lsp-github-install.js.map +1 -1
- package/dist/lsp-github-probe.d.ts +11 -1
- package/dist/lsp-github-probe.d.ts.map +1 -1
- package/dist/lsp-github-probe.js +85 -5
- package/dist/lsp-github-probe.js.map +1 -1
- package/dist/lsp-github-table.d.ts.map +1 -1
- package/dist/lsp-github-table.js +5 -1
- package/dist/lsp-github-table.js.map +1 -1
- package/dist/lsp-npm-policy.d.ts +7 -0
- package/dist/lsp-npm-policy.d.ts.map +1 -0
- package/dist/lsp-npm-policy.js +125 -0
- package/dist/lsp-npm-policy.js.map +1 -0
- package/dist/path-aliases.d.ts +1 -1
- package/dist/path-aliases.d.ts.map +1 -1
- package/dist/path-aliases.js +63 -39
- package/dist/path-aliases.js.map +1 -1
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +6 -1
- package/dist/pool.js.map +1 -1
- package/dist/protocol.d.ts +48 -10
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +11 -0
- package/dist/protocol.js.map +1 -1
- package/dist/revision-reads.d.ts +25 -0
- package/dist/revision-reads.d.ts.map +1 -0
- package/dist/revision-reads.js +31 -0
- package/dist/revision-reads.js.map +1 -0
- package/dist/subc-transport.d.ts +6 -0
- package/dist/subc-transport.d.ts.map +1 -1
- package/dist/subc-transport.js +210 -33
- package/dist/subc-transport.js.map +1 -1
- package/dist/tool-format.js +2 -2
- package/dist/view-token.d.ts +9 -0
- package/dist/view-token.d.ts.map +1 -0
- package/dist/view-token.js +22 -0
- package/dist/view-token.js.map +1 -0
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -4,12 +4,37 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
4
4
|
import { error, getActiveLogger, getLogFilePath, log, warn } from "./active-logger.js";
|
|
5
5
|
import { binaryContentHash, binaryStampKey, peekBinaryContentHash } from "./binary-identity.js";
|
|
6
6
|
import { spawn } from "./child-process.js";
|
|
7
|
-
import { isPassiveCommand, PASSIVE_COMMAND_TIMEOUT_MS } from "./command-timeouts.js";
|
|
7
|
+
import { isPassiveCommand, isReadOnlyRetryCommand, PASSIVE_COMMAND_TIMEOUT_MS, } from "./command-timeouts.js";
|
|
8
8
|
import { withPathPrepended } from "./path-env.js";
|
|
9
9
|
import { isTerminalBashStatus } from "./protocol.js";
|
|
10
10
|
import { HOST_TOOL_NAMES_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
|
|
11
11
|
const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
|
|
12
|
-
|
|
12
|
+
/** Default floor of the hang watchdog's silence window (`hangWindowMinMs`). */
|
|
13
|
+
const DEFAULT_MIN_HANG_WINDOW_MS = 60_000;
|
|
14
|
+
/** Default wait for the answer to the hang watchdog's `ping` (`hangProbeTimeoutMs`). */
|
|
15
|
+
const DEFAULT_HANG_PROBE_TIMEOUT_MS = 5_000;
|
|
16
|
+
/**
|
|
17
|
+
* How long the child may write nothing at all (no reply, progress frame or
|
|
18
|
+
* push frame) while it owes a reply, before the hang watchdog probes it with a
|
|
19
|
+
* `ping`. The window starts at the child's last output, or at the oldest
|
|
20
|
+
* unanswered request when that request was sent after the last output.
|
|
21
|
+
*
|
|
22
|
+
* The window is twice the longest transport budget among those unanswered
|
|
23
|
+
* requests and the calls still waiting, and never less than `minWindowMs`. A
|
|
24
|
+
* request that is slow but healthy finishes within its own budget, and a
|
|
25
|
+
* request queued behind it within roughly one more, so a child that stays
|
|
26
|
+
* silent for twice the longest budget is suspected hung and gets the probe.
|
|
27
|
+
* (A deeper silent backlog can still reach the window; any parsed stdout frame
|
|
28
|
+
* resets it.) The floor keeps short budgets (5 s status polls, 30 s defaults)
|
|
29
|
+
* from turning a slow disk or a cold index into a kill. This is the only place
|
|
30
|
+
* the window is computed.
|
|
31
|
+
*/
|
|
32
|
+
export function hangWindowMs(budgetsMs, minWindowMs = DEFAULT_MIN_HANG_WINDOW_MS) {
|
|
33
|
+
let longest = 0;
|
|
34
|
+
for (const budget of budgetsMs)
|
|
35
|
+
longest = Math.max(longest, budget);
|
|
36
|
+
return Math.max(minWindowMs, 2 * longest);
|
|
37
|
+
}
|
|
13
38
|
const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
|
|
14
39
|
/** Characters of an unparseable stdout line quoted in the warning log. */
|
|
15
40
|
const MALFORMED_STDOUT_LOG_CHARS = 512;
|
|
@@ -149,6 +174,13 @@ export function compareSemver(a, b) {
|
|
|
149
174
|
}
|
|
150
175
|
return 0;
|
|
151
176
|
}
|
|
177
|
+
// Looks up the global timer functions at call time, so code that replaces
|
|
178
|
+
// globalThis.setTimeout (as some tests do) still sees bridge timers.
|
|
179
|
+
const SYSTEM_CLOCK = {
|
|
180
|
+
now: () => Date.now(),
|
|
181
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
182
|
+
clearTimeout: (timer) => clearTimeout(timer),
|
|
183
|
+
};
|
|
152
184
|
function coerceConfigureDroppedKeys(value) {
|
|
153
185
|
if (!Array.isArray(value))
|
|
154
186
|
return [];
|
|
@@ -239,6 +271,35 @@ export class BridgeTransportUnknownOutcomeError extends BridgeTransportUnavailab
|
|
|
239
271
|
this.name = "BridgeTransportUnknownOutcomeError";
|
|
240
272
|
}
|
|
241
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* The hang watchdog killed the child while this request was waiting: the child
|
|
276
|
+
* had written nothing for longer than its silence window (see `hangWindowMs`).
|
|
277
|
+
* The bridge respawns on the next call, so the request can be sent again.
|
|
278
|
+
*
|
|
279
|
+
* `outcomeUnknown` is false for read-only tools (`READ_ONLY_RETRY_COMMANDS`):
|
|
280
|
+
* they cannot have changed anything, and the bridge retries them once on the
|
|
281
|
+
* respawned child (unless the call was aborted or the bridge is shutting
|
|
282
|
+
* down), so a caller sees this only if that retry was cut off too. For every
|
|
283
|
+
* other command it is true: the child may
|
|
284
|
+
* have applied the request before it went silent, so a mutation must be
|
|
285
|
+
* inspected before it is repeated.
|
|
286
|
+
*/
|
|
287
|
+
export class BridgeRestartedError extends BridgeTransportUnknownOutcomeError {
|
|
288
|
+
tool;
|
|
289
|
+
outcomeUnknown;
|
|
290
|
+
retryable = true;
|
|
291
|
+
restartReason = "hang_watchdog";
|
|
292
|
+
constructor(tool, outcomeUnknown, message) {
|
|
293
|
+
super(message);
|
|
294
|
+
this.tool = tool;
|
|
295
|
+
this.outcomeUnknown = outcomeUnknown;
|
|
296
|
+
this.name = "BridgeRestartedError";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Type guard for a rejection caused by a hang-watchdog restart. */
|
|
300
|
+
export function isBridgeRestarted(err) {
|
|
301
|
+
return err instanceof BridgeRestartedError;
|
|
302
|
+
}
|
|
242
303
|
/** Type guard for a transport-timeout rejection (bridge busy, retryable). */
|
|
243
304
|
export function isBridgeTransportTimeout(err) {
|
|
244
305
|
return err instanceof Error && err.code === "transport_timeout";
|
|
@@ -300,7 +361,9 @@ export class BinaryBridge {
|
|
|
300
361
|
_restartCount = 0;
|
|
301
362
|
_shuttingDown = false;
|
|
302
363
|
timeoutMs;
|
|
303
|
-
|
|
364
|
+
hangWindowMinMs;
|
|
365
|
+
hangProbeTimeoutMs;
|
|
366
|
+
clock;
|
|
304
367
|
lateShellReplyGraceMs;
|
|
305
368
|
maxRestarts;
|
|
306
369
|
configured = false;
|
|
@@ -321,13 +384,37 @@ export class BinaryBridge {
|
|
|
321
384
|
configureWarningClients = new Map();
|
|
322
385
|
restartTimer = null;
|
|
323
386
|
restartResetTimer = null;
|
|
324
|
-
/**
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
387
|
+
/**
|
|
388
|
+
* Hang evidence: requests written to the current child that it has not
|
|
389
|
+
* answered. An entry stays after its caller timed out, because the child
|
|
390
|
+
* still owes the reply. An entry is removed when the child answers that
|
|
391
|
+
* request (`settleHangEvidence`), when an answered liveness probe shows the
|
|
392
|
+
* child is alive while the entry's caller is gone (`onHangProbeAnswered`),
|
|
393
|
+
* or when a new child starts (`resetHangWatchdog`). Output for other
|
|
394
|
+
* requests does not remove it: the child may have answered one session and
|
|
395
|
+
* then frozen on another's request.
|
|
396
|
+
*/
|
|
397
|
+
awaitingReply = new Map();
|
|
398
|
+
/**
|
|
399
|
+
* Bridge-clock time of the current child's last parsed stdout frame of any
|
|
400
|
+
* kind. The watchdog's silence is measured from here, so a child that keeps
|
|
401
|
+
* writing is never judged silent.
|
|
402
|
+
*/
|
|
403
|
+
lastChildActivity = Number.NEGATIVE_INFINITY;
|
|
404
|
+
/**
|
|
405
|
+
* The watchdog's `ping` to the current child that has not been answered
|
|
406
|
+
* yet; at most one is in flight. It is not a caller's request: it is never
|
|
407
|
+
* retried, never counts as hang evidence, and its answer is handled only by
|
|
408
|
+
* `onHangProbeAnswered`.
|
|
409
|
+
*/
|
|
410
|
+
hangProbe = null;
|
|
411
|
+
hangWatchdogTimer = null;
|
|
412
|
+
/** Set once per child when a suppressed watchdog kill has been logged. */
|
|
413
|
+
hangSuppressionLogged = false;
|
|
414
|
+
// One watchdog restart may recover a genuine hang. Restarting again before
|
|
415
|
+
// any tool succeeds only repeats the same cold startup work, so the
|
|
416
|
+
// watchdog stands down until then. This survives spawn/configure/version
|
|
417
|
+
// and is re-armed by a successful tool response.
|
|
331
418
|
timeoutRestartAwaitingRecovery = false;
|
|
332
419
|
errorPrefix;
|
|
333
420
|
logger;
|
|
@@ -337,7 +424,9 @@ export class BinaryBridge {
|
|
|
337
424
|
this.binaryPath = binaryPath;
|
|
338
425
|
this.cwd = cwd;
|
|
339
426
|
this.timeoutMs = options?.timeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS;
|
|
340
|
-
this.
|
|
427
|
+
this.hangWindowMinMs = options?.hangWindowMinMs ?? DEFAULT_MIN_HANG_WINDOW_MS;
|
|
428
|
+
this.hangProbeTimeoutMs = options?.hangProbeTimeoutMs ?? DEFAULT_HANG_PROBE_TIMEOUT_MS;
|
|
429
|
+
this.clock = options?.clock ?? SYSTEM_CLOCK;
|
|
341
430
|
this.lateShellReplyGraceMs =
|
|
342
431
|
options?.lateShellReplyGraceMs ?? DEFAULT_LATE_SHELL_REPLY_GRACE_MS;
|
|
343
432
|
this.maxRestarts = options?.maxRestarts ?? 3;
|
|
@@ -389,6 +478,23 @@ export class BinaryBridge {
|
|
|
389
478
|
log(message, meta);
|
|
390
479
|
}
|
|
391
480
|
}
|
|
481
|
+
debugVia(message, meta) {
|
|
482
|
+
const logger = this.logger ?? getActiveLogger();
|
|
483
|
+
// A host logger without a debug method, or no logger at all, gets the same
|
|
484
|
+
// destination as an INFO record, as before DEBUG forwarding existed. Never
|
|
485
|
+
// fall back to the console: engine DEBUG records can be numerous.
|
|
486
|
+
if (!logger?.debug) {
|
|
487
|
+
this.logVia(message, meta);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
logger.debug(message, meta);
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
console.error(`[aft-bridge] ERROR: logger debug threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
495
|
+
console.error(`[aft-bridge] ${message}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
392
498
|
warnVia(message, meta) {
|
|
393
499
|
const logger = this.logger ?? getActiveLogger();
|
|
394
500
|
if (logger) {
|
|
@@ -673,7 +779,7 @@ export class BinaryBridge {
|
|
|
673
779
|
params[HOST_TOOL_NAMES_FIELD] = hostToolNames;
|
|
674
780
|
return (await this.send("tool_call", params, Object.keys(sendOptions).length > 0 ? sendOptions : undefined));
|
|
675
781
|
}
|
|
676
|
-
async sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap) {
|
|
782
|
+
async sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap, retriedAfterHangRestart = false) {
|
|
677
783
|
try {
|
|
678
784
|
if (this._retiringDueToBinaryChange) {
|
|
679
785
|
throw new BridgeTransportUnavailableError(`${this.errorPrefix} Bridge is retiring after the on-disk binary changed; retry to respawn on the updated binary`);
|
|
@@ -809,17 +915,22 @@ export class BinaryBridge {
|
|
|
809
915
|
else {
|
|
810
916
|
request = { id, command, ...params };
|
|
811
917
|
}
|
|
918
|
+
delete request.handles_progress;
|
|
919
|
+
if (options?.onProgress)
|
|
920
|
+
request.handles_progress = true;
|
|
812
921
|
const line = `${JSON.stringify(request)}\n`;
|
|
813
|
-
// Passive polls
|
|
814
|
-
// a queued status poll timing out means
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
// forget it.
|
|
922
|
+
// Passive polls and completion drains always take the typed keep-warm
|
|
923
|
+
// timeout regardless of caller: a queued status poll timing out means
|
|
924
|
+
// the bridge is BUSY, not hung (issue #117). Enforced bridge-side so no
|
|
925
|
+
// call site can forget it.
|
|
818
926
|
const keepBridgeOnTimeout = passive || command === "bash_drain_completions" || options?.keepBridgeOnTimeout === true;
|
|
819
|
-
let requestSentAt = Date.now();
|
|
820
927
|
const child = this.process;
|
|
821
928
|
const response = await new Promise((resolve, reject) => {
|
|
822
|
-
|
|
929
|
+
// A timeout settles only this request. It never kills the child or
|
|
930
|
+
// rejects other requests: one slow call says nothing about the calls
|
|
931
|
+
// of other sessions sharing this child, and a child that is truly
|
|
932
|
+
// hung is caught by the hang watchdog, which watches its silence.
|
|
933
|
+
const timer = this.clock.setTimeout(() => {
|
|
823
934
|
const entry = this.pending.get(id);
|
|
824
935
|
if (!entry)
|
|
825
936
|
return;
|
|
@@ -828,51 +939,26 @@ export class BinaryBridge {
|
|
|
828
939
|
return;
|
|
829
940
|
}
|
|
830
941
|
this.pending.delete(id);
|
|
831
|
-
clearTimeout(entry.timer);
|
|
942
|
+
this.clock.clearTimeout(entry.timer);
|
|
832
943
|
entry.onSettled?.();
|
|
833
|
-
|
|
834
|
-
const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`;
|
|
835
|
-
if (requestSessionId) {
|
|
836
|
-
this.sessionWarnVia(requestSessionId, timeoutMsg);
|
|
837
|
-
}
|
|
838
|
-
else {
|
|
839
|
-
this.warnVia(timeoutMsg);
|
|
840
|
-
}
|
|
841
|
-
entry.reject(new BridgeTransportTimeoutError(command, effectiveTimeoutMs, `${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
const childActiveSinceRequest = this.lastChildActivityAt > requestSentAt;
|
|
845
|
-
const consecutiveTimeouts = this.consecutiveRequestTimeouts + 1;
|
|
846
|
-
this.consecutiveRequestTimeouts = consecutiveTimeouts;
|
|
847
|
-
const restartSuppressed = this.timeoutRestartAwaitingRecovery;
|
|
848
|
-
const keepWarm = restartSuppressed ||
|
|
849
|
-
childActiveSinceRequest ||
|
|
850
|
-
consecutiveTimeouts < this.hangThreshold;
|
|
851
|
-
const restartSuffix = keepWarm ? " — bridge kept warm" : " — restarting bridge";
|
|
852
|
-
const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms${restartSuffix}`;
|
|
944
|
+
const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms — bridge kept warm`;
|
|
853
945
|
if (requestSessionId) {
|
|
854
946
|
this.sessionWarnVia(requestSessionId, timeoutMsg);
|
|
855
947
|
}
|
|
856
948
|
else {
|
|
857
949
|
this.warnVia(timeoutMsg);
|
|
858
950
|
}
|
|
859
|
-
if (
|
|
860
|
-
entry.reject(new
|
|
861
|
-
? `${this.errorPrefix} request "${command}" timed out after ${effectiveTimeoutMs}ms; automatic timeout restart already attempted; bridge kept warm to finish startup. Retry later or restart the host explicitly if it remains unresponsive. A timed-out mutation may still complete; inspect before repeating it.`
|
|
862
|
-
: `${this.errorPrefix} request "${command}" timed out after ${effectiveTimeoutMs}ms (bridge busy/under load); bridge kept warm — retry`));
|
|
951
|
+
if (keepBridgeOnTimeout) {
|
|
952
|
+
entry.reject(new BridgeTransportTimeoutError(command, effectiveTimeoutMs, `${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
|
|
863
953
|
return;
|
|
864
954
|
}
|
|
865
|
-
entry.reject(new Error(`${this.errorPrefix}
|
|
866
|
-
this.handleTimeout({
|
|
867
|
-
requestId: id,
|
|
868
|
-
tool: entry.tool,
|
|
869
|
-
generation: entry.generation,
|
|
870
|
-
}, requestSessionId);
|
|
955
|
+
entry.reject(new Error(`${this.errorPrefix} request "${command}" timed out after ${effectiveTimeoutMs}ms (bridge busy/under load); bridge kept warm — retry. A timed-out mutation may still complete; inspect before repeating it.`));
|
|
871
956
|
}, effectiveTimeoutMs);
|
|
872
957
|
const entry = {
|
|
873
958
|
resolve,
|
|
874
959
|
reject,
|
|
875
960
|
timer,
|
|
961
|
+
budgetMs: effectiveTimeoutMs,
|
|
876
962
|
onProgress: options?.onProgress,
|
|
877
963
|
command,
|
|
878
964
|
tool,
|
|
@@ -881,21 +967,21 @@ export class BinaryBridge {
|
|
|
881
967
|
this.pending.set(id, entry);
|
|
882
968
|
if (!child?.stdin?.writable) {
|
|
883
969
|
this.pending.delete(id);
|
|
884
|
-
clearTimeout(timer);
|
|
970
|
+
this.clock.clearTimeout(timer);
|
|
885
971
|
const error = new BridgeTransportUnavailableError(`${this.errorPrefix} stdin not writable for command "${command}"`);
|
|
886
972
|
reject(error);
|
|
887
973
|
if (this.process === child)
|
|
888
974
|
this.invalidateTransportProcess(error);
|
|
889
975
|
return;
|
|
890
976
|
}
|
|
891
|
-
|
|
977
|
+
this.recordAwaitingReply(id, effectiveTimeoutMs, tool, requestSessionId);
|
|
892
978
|
const handleWriteFailure = (cause) => {
|
|
893
979
|
const writeError = cause instanceof Error ? cause : new Error(String(cause));
|
|
894
980
|
const error = new BridgeTransportUnknownOutcomeError(`${this.errorPrefix} Failed to write to stdin: ${writeError.message}`, { cause: writeError });
|
|
895
981
|
const entry = this.pending.get(id);
|
|
896
982
|
if (entry) {
|
|
897
983
|
this.pending.delete(id);
|
|
898
|
-
clearTimeout(entry.timer);
|
|
984
|
+
this.clock.clearTimeout(entry.timer);
|
|
899
985
|
entry.onSettled?.();
|
|
900
986
|
entry.reject(error);
|
|
901
987
|
}
|
|
@@ -928,7 +1014,19 @@ export class BinaryBridge {
|
|
|
928
1014
|
command !== "configure" &&
|
|
929
1015
|
command !== "version") {
|
|
930
1016
|
this.logVia(`Retrying request "${command}" once after coordinated binary replacement: ${err.newBinaryPath}`);
|
|
931
|
-
return this.sendWithVersionMismatchRetry(command, params, options, false);
|
|
1017
|
+
return this.sendWithVersionMismatchRetry(command, params, options, false, retriedAfterHangRestart);
|
|
1018
|
+
}
|
|
1019
|
+
// A watchdog restart rejected this call. A read-only tool is sent once
|
|
1020
|
+
// more on the respawned child; anything else may already have taken
|
|
1021
|
+
// effect, so its caller gets the typed restart error to decide on.
|
|
1022
|
+
const tool = command === "tool_call" && typeof params.name === "string" ? params.name : command;
|
|
1023
|
+
if (err instanceof BridgeRestartedError &&
|
|
1024
|
+
!retriedAfterHangRestart &&
|
|
1025
|
+
isReadOnlyRetryCommand(tool) &&
|
|
1026
|
+
!this._shuttingDown &&
|
|
1027
|
+
options?.abortSignal?.aborted !== true) {
|
|
1028
|
+
this.logVia(`Retrying read-only request "${tool}" once after the hang watchdog restart`);
|
|
1029
|
+
return this.sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap, true);
|
|
932
1030
|
}
|
|
933
1031
|
throw err;
|
|
934
1032
|
}
|
|
@@ -1009,6 +1107,7 @@ export class BinaryBridge {
|
|
|
1009
1107
|
async shutdown() {
|
|
1010
1108
|
this._shuttingDown = true;
|
|
1011
1109
|
this.spawnedBinaryFingerprint = null;
|
|
1110
|
+
this.resetHangWatchdog();
|
|
1012
1111
|
this.clearRestartResetTimer();
|
|
1013
1112
|
this.clearRestartTimer();
|
|
1014
1113
|
this.configureWarningClients.clear();
|
|
@@ -1258,8 +1357,7 @@ export class BinaryBridge {
|
|
|
1258
1357
|
this.stdoutPending = [];
|
|
1259
1358
|
this.stdoutPendingLength = 0;
|
|
1260
1359
|
this.stderrBuffer = "";
|
|
1261
|
-
this.
|
|
1262
|
-
this.consecutiveRequestTimeouts = 0;
|
|
1360
|
+
this.resetHangWatchdog();
|
|
1263
1361
|
// Fresh spawn — clear the stderr ring so crash diagnostics only reflect
|
|
1264
1362
|
// the current child's output, not output from prior restart cycles.
|
|
1265
1363
|
this.stderrTail = [];
|
|
@@ -1307,7 +1405,17 @@ export class BinaryBridge {
|
|
|
1307
1405
|
if (!line || !shouldSurfaceStderrLine(line))
|
|
1308
1406
|
return;
|
|
1309
1407
|
const tagged = tagStderrLine(line);
|
|
1310
|
-
|
|
1408
|
+
// Only the engine's own formatter supplies a trustworthy severity prefix.
|
|
1409
|
+
const level = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[aft(?:-lsp)?\] (INFO|WARN|ERROR|DEBUG) /.exec(line)?.[1];
|
|
1410
|
+
const meta = level ? { engineRecord: true } : undefined;
|
|
1411
|
+
if (level === "WARN")
|
|
1412
|
+
this.warnVia(tagged, meta);
|
|
1413
|
+
else if (level === "ERROR")
|
|
1414
|
+
this.errorVia(tagged, meta);
|
|
1415
|
+
else if (level === "DEBUG")
|
|
1416
|
+
this.debugVia(tagged, meta);
|
|
1417
|
+
else
|
|
1418
|
+
this.logVia(tagged, meta);
|
|
1311
1419
|
this.pushStderrLine(tagged);
|
|
1312
1420
|
}
|
|
1313
1421
|
/**
|
|
@@ -1374,7 +1482,9 @@ export class BinaryBridge {
|
|
|
1374
1482
|
processStdoutLine(line) {
|
|
1375
1483
|
try {
|
|
1376
1484
|
const response = JSON.parse(line);
|
|
1377
|
-
|
|
1485
|
+
// Any parsed frame proves the child is alive: it restarts the silence
|
|
1486
|
+
// clock but forgets no unanswered request (see `awaitingReply`).
|
|
1487
|
+
this.lastChildActivity = this.clock.now();
|
|
1378
1488
|
if (response.type === "progress") {
|
|
1379
1489
|
const requestId = response.request_id;
|
|
1380
1490
|
const entry = requestId ? this.pending.get(requestId) : undefined;
|
|
@@ -1388,7 +1498,7 @@ export class BinaryBridge {
|
|
|
1388
1498
|
const entry = requestId ? this.pending.get(requestId) : undefined;
|
|
1389
1499
|
if (requestId && entry) {
|
|
1390
1500
|
this.pending.delete(requestId);
|
|
1391
|
-
clearTimeout(entry.timer);
|
|
1501
|
+
this.clock.clearTimeout(entry.timer);
|
|
1392
1502
|
entry.onSettled?.();
|
|
1393
1503
|
entry.resolve({
|
|
1394
1504
|
success: false,
|
|
@@ -1397,6 +1507,10 @@ export class BinaryBridge {
|
|
|
1397
1507
|
asks: response.asks,
|
|
1398
1508
|
});
|
|
1399
1509
|
}
|
|
1510
|
+
// The ask settles the request in place of a reply. This runs after the
|
|
1511
|
+
// pending entry is gone, so its budget no longer widens the window.
|
|
1512
|
+
if (requestId)
|
|
1513
|
+
this.settleHangEvidence(requestId);
|
|
1400
1514
|
return;
|
|
1401
1515
|
}
|
|
1402
1516
|
if (response.type === "bash_completed") {
|
|
@@ -1426,14 +1540,20 @@ export class BinaryBridge {
|
|
|
1426
1540
|
return;
|
|
1427
1541
|
}
|
|
1428
1542
|
const id = response.id;
|
|
1543
|
+
if (id && this.hangProbe?.id === id) {
|
|
1544
|
+
this.onHangProbeAnswered();
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1429
1547
|
if (id && this.pending.has(id)) {
|
|
1430
1548
|
const entry = this.pending.get(id);
|
|
1431
1549
|
if (!entry)
|
|
1432
1550
|
return;
|
|
1433
1551
|
this.pending.delete(id);
|
|
1434
|
-
|
|
1552
|
+
// Answered: the child no longer owes this request. Settled after the
|
|
1553
|
+
// pending entry is gone, so its budget no longer widens the window.
|
|
1554
|
+
this.settleHangEvidence(id);
|
|
1555
|
+
this.clock.clearTimeout(entry.timer);
|
|
1435
1556
|
entry.onSettled?.();
|
|
1436
|
-
this.consecutiveRequestTimeouts = 0;
|
|
1437
1557
|
if (response.success !== false &&
|
|
1438
1558
|
![
|
|
1439
1559
|
"configure",
|
|
@@ -1451,6 +1571,8 @@ export class BinaryBridge {
|
|
|
1451
1571
|
entry.resolve(response);
|
|
1452
1572
|
}
|
|
1453
1573
|
else if (id && typeof response.type !== "string") {
|
|
1574
|
+
// A late reply still means the child no longer owes this request.
|
|
1575
|
+
this.settleHangEvidence(id);
|
|
1454
1576
|
// A reply for a request that already settled (its deadline and any
|
|
1455
1577
|
// grace period passed). One that names a task is a warning: the task
|
|
1456
1578
|
// may still be running, and this log line is the only record of its id.
|
|
@@ -1506,8 +1628,8 @@ export class BinaryBridge {
|
|
|
1506
1628
|
// A reply now arrives after the caller's deadline, so it settles the call
|
|
1507
1629
|
// as a timeout that reports what AFT answered.
|
|
1508
1630
|
entry.resolve = (response) => settle(shellOutcomeFromLateReply(response));
|
|
1509
|
-
clearTimeout(entry.timer);
|
|
1510
|
-
entry.timer = setTimeout(() => {
|
|
1631
|
+
this.clock.clearTimeout(entry.timer);
|
|
1632
|
+
entry.timer = this.clock.setTimeout(() => {
|
|
1511
1633
|
if (this.pending.get(id) !== entry)
|
|
1512
1634
|
return;
|
|
1513
1635
|
this.pending.delete(id);
|
|
@@ -1533,23 +1655,211 @@ export class BinaryBridge {
|
|
|
1533
1655
|
this.outstandingBackgroundTaskIds.add(taskId);
|
|
1534
1656
|
}
|
|
1535
1657
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1658
|
+
/** Record a request the child now owes a reply, and make sure the watchdog runs. */
|
|
1659
|
+
recordAwaitingReply(id, budgetMs, tool, sessionId) {
|
|
1660
|
+
this.awaitingReply.set(id, { sentAt: this.clock.now(), budgetMs, tool, sessionId });
|
|
1661
|
+
this.armHangWatchdog();
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* The child answered request `id`, so it no longer owes that reply. Only
|
|
1665
|
+
* this request's evidence goes: other unanswered requests stay, because a
|
|
1666
|
+
* child can answer one session and then freeze on another's request.
|
|
1667
|
+
*/
|
|
1668
|
+
settleHangEvidence(id) {
|
|
1669
|
+
if (!this.awaitingReply.delete(id))
|
|
1670
|
+
return;
|
|
1671
|
+
// The window may have shrunk with this request's budget, so the timer is
|
|
1672
|
+
// set again from the remaining evidence (or dropped when none is left).
|
|
1673
|
+
this.clearHangWatchdogTimer();
|
|
1674
|
+
this.armHangWatchdog();
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* When the current child counts as silent for too long: the silence window
|
|
1678
|
+
* measured from its last output, or from the oldest unanswered request when
|
|
1679
|
+
* that was sent later (a child owes nothing before it is asked). The window
|
|
1680
|
+
* covers the budgets of the unanswered requests and of every call still
|
|
1681
|
+
* waiting, so a slow request that is still within its budget widens it.
|
|
1682
|
+
* Null when the child owes no reply.
|
|
1683
|
+
*/
|
|
1684
|
+
hangDeadline() {
|
|
1685
|
+
if (this.awaitingReply.size === 0)
|
|
1686
|
+
return null;
|
|
1687
|
+
let oldestId = "";
|
|
1688
|
+
let oldestSentAt = Number.POSITIVE_INFINITY;
|
|
1689
|
+
const budgets = [];
|
|
1690
|
+
for (const [id, entry] of this.awaitingReply) {
|
|
1691
|
+
budgets.push(entry.budgetMs);
|
|
1692
|
+
if (entry.sentAt < oldestSentAt) {
|
|
1693
|
+
oldestSentAt = entry.sentAt;
|
|
1694
|
+
oldestId = id;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
for (const entry of this.pending.values())
|
|
1698
|
+
budgets.push(entry.budgetMs);
|
|
1699
|
+
const windowMs = hangWindowMs(budgets, this.hangWindowMinMs);
|
|
1700
|
+
const silentSince = Math.max(oldestSentAt, this.lastChildActivity);
|
|
1701
|
+
return { deadline: silentSince + windowMs, windowMs, silentSince, oldestId };
|
|
1702
|
+
}
|
|
1703
|
+
armHangWatchdog() {
|
|
1704
|
+
// While a probe is in flight, its own timer decides what happens next.
|
|
1705
|
+
if (this.hangWatchdogTimer || this.hangProbe)
|
|
1706
|
+
return;
|
|
1707
|
+
const hang = this.hangDeadline();
|
|
1708
|
+
if (!hang)
|
|
1709
|
+
return;
|
|
1710
|
+
const timer = this.clock.setTimeout(() => {
|
|
1711
|
+
this.hangWatchdogTimer = null;
|
|
1712
|
+
this.checkForHang();
|
|
1713
|
+
}, Math.max(0, hang.deadline - this.clock.now()));
|
|
1714
|
+
// Waiting calls have their own referenced timeout timers that keep the
|
|
1715
|
+
// host alive; an idle host must be able to exit with a hung child.
|
|
1716
|
+
timer.unref?.();
|
|
1717
|
+
this.hangWatchdogTimer = timer;
|
|
1718
|
+
}
|
|
1719
|
+
clearHangWatchdogTimer() {
|
|
1720
|
+
if (this.hangWatchdogTimer)
|
|
1721
|
+
this.clock.clearTimeout(this.hangWatchdogTimer);
|
|
1722
|
+
this.hangWatchdogTimer = null;
|
|
1723
|
+
}
|
|
1724
|
+
clearHangProbe() {
|
|
1725
|
+
if (this.hangProbe)
|
|
1726
|
+
this.clock.clearTimeout(this.hangProbe.timer);
|
|
1727
|
+
this.hangProbe = null;
|
|
1728
|
+
}
|
|
1729
|
+
resetHangWatchdog() {
|
|
1730
|
+
this.clearHangWatchdogTimer();
|
|
1731
|
+
this.clearHangProbe();
|
|
1732
|
+
this.awaitingReply.clear();
|
|
1733
|
+
this.lastChildActivity = Number.NEGATIVE_INFINITY;
|
|
1734
|
+
this.hangSuppressionLogged = false;
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Runs when the silence window may have elapsed. A child that owes a reply
|
|
1738
|
+
* and has written nothing for the whole window is either hung, or healthy
|
|
1739
|
+
* and idle while one request it was given never finishes (a stuck worker, a
|
|
1740
|
+
* lost reply). Only a hung child should be replaced: the bridge is shared by
|
|
1741
|
+
* every session, and a restart throws away everyone's warm state. So the
|
|
1742
|
+
* watchdog sends a `ping` (answered by the engine's dispatcher without
|
|
1743
|
+
* touching any index) and decides when it is answered or times out; see
|
|
1744
|
+
* `onHangProbeAnswered` and `onHangProbeTimeout`.
|
|
1745
|
+
*
|
|
1746
|
+
* The probe goes out at this tick even when no caller is waiting any more
|
|
1747
|
+
* (every caller may already have timed out). Waiting for the next call
|
|
1748
|
+
* instead would let that call run into the same silent child. So a hung
|
|
1749
|
+
* child is replaced one window plus one probe timeout after its silence
|
|
1750
|
+
* began (its last output, or the oldest unanswered request if that came
|
|
1751
|
+
* later), and a call made in that time waits at most until then.
|
|
1752
|
+
*
|
|
1753
|
+
* After one watchdog restart it stands down until a tool succeeds (see
|
|
1754
|
+
* `timeoutRestartAwaitingRecovery`), and then sends no probe either.
|
|
1755
|
+
*/
|
|
1756
|
+
checkForHang() {
|
|
1757
|
+
if (!this.isAlive() || this.hangProbe)
|
|
1758
|
+
return;
|
|
1759
|
+
const hang = this.hangDeadline();
|
|
1760
|
+
if (!hang)
|
|
1761
|
+
return;
|
|
1762
|
+
const now = this.clock.now();
|
|
1763
|
+
if (now < hang.deadline) {
|
|
1764
|
+
this.armHangWatchdog();
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
if (this.timeoutRestartAwaitingRecovery) {
|
|
1768
|
+
// Pending calls are left to their own timeouts; see the field comment.
|
|
1769
|
+
if (!this.hangSuppressionLogged) {
|
|
1770
|
+
this.hangSuppressionLogged = true;
|
|
1771
|
+
this.warnVia(`Hang watchdog: child silent past its window, but it was already restarted once and no tool has succeeded since; not restarting again; ${this.hangAttribution(hang)}`);
|
|
1772
|
+
}
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
this.sendHangProbe(hang);
|
|
1776
|
+
}
|
|
1777
|
+
hangAttribution(hang) {
|
|
1778
|
+
return (`silent_ms=${this.clock.now() - hang.silentSince} ` +
|
|
1779
|
+
`hang_window_ms=${hang.windowMs} ` +
|
|
1780
|
+
`oldest_unanswered_request_id=${hang.oldestId} ` +
|
|
1781
|
+
`oldest_unanswered_tool=${JSON.stringify(this.awaitingReply.get(hang.oldestId)?.tool)} ` +
|
|
1782
|
+
`bridge_generation=${this.processGeneration}`);
|
|
1783
|
+
}
|
|
1784
|
+
/** Write the liveness `ping` straight to the child, outside the request path. */
|
|
1785
|
+
sendHangProbe(hang) {
|
|
1786
|
+
const id = `hang-probe-${this.nextId++}`;
|
|
1787
|
+
const timer = this.clock.setTimeout(() => this.onHangProbeTimeout(id), this.hangProbeTimeoutMs);
|
|
1788
|
+
// Like the watchdog timer, the probe must not keep an idle host alive.
|
|
1789
|
+
timer.unref?.();
|
|
1790
|
+
this.hangProbe = { id, sentAt: this.clock.now(), timer };
|
|
1791
|
+
this.logVia(`Hang watchdog: child silent past its window; sending liveness probe id=${id} probe_timeout_ms=${this.hangProbeTimeoutMs}; ${this.hangAttribution(hang)}`);
|
|
1792
|
+
// A probe that cannot be written goes unanswered, and its timeout decides.
|
|
1793
|
+
try {
|
|
1794
|
+
this.process?.stdin?.write(`${JSON.stringify({ id, command: "ping" })}\n`, () => { });
|
|
1795
|
+
}
|
|
1796
|
+
catch {
|
|
1797
|
+
// Same as above: the probe timeout handles it.
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* The child answered the probe, so it can still respond. Requests whose
|
|
1802
|
+
* callers already gave up (timed out or aborted) stop counting as evidence:
|
|
1803
|
+
* their missing replies alone are no reason to replace a responsive child.
|
|
1804
|
+
* Requests whose callers are still waiting stay, and the silence window
|
|
1805
|
+
* restarts from this answer.
|
|
1806
|
+
*/
|
|
1807
|
+
onHangProbeAnswered() {
|
|
1808
|
+
const probeId = this.hangProbe?.id;
|
|
1809
|
+
this.clearHangProbe();
|
|
1810
|
+
let dropped = 0;
|
|
1811
|
+
for (const id of Array.from(this.awaitingReply.keys())) {
|
|
1812
|
+
if (this.pending.has(id))
|
|
1813
|
+
continue;
|
|
1814
|
+
this.awaitingReply.delete(id);
|
|
1815
|
+
dropped += 1;
|
|
1816
|
+
}
|
|
1817
|
+
this.logVia(`Hang watchdog: liveness probe id=${probeId} answered; child kept; dropped ${dropped} unanswered request(s) whose callers already settled, ${this.awaitingReply.size} still waiting`);
|
|
1818
|
+
this.clearHangWatchdogTimer();
|
|
1819
|
+
this.armHangWatchdog();
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* No answer to the probe within its timeout. If the child wrote anything
|
|
1823
|
+
* else since the probe was sent it is not silent, and the watchdog simply
|
|
1824
|
+
* starts over from that output. Otherwise it is hung and is killed.
|
|
1825
|
+
*/
|
|
1826
|
+
onHangProbeTimeout(probeId) {
|
|
1827
|
+
const probe = this.hangProbe;
|
|
1828
|
+
if (!probe || probe.id !== probeId)
|
|
1829
|
+
return;
|
|
1830
|
+
this.hangProbe = null;
|
|
1831
|
+
if (!this.isAlive())
|
|
1832
|
+
return;
|
|
1833
|
+
const hang = this.hangDeadline();
|
|
1834
|
+
if (!hang)
|
|
1835
|
+
return;
|
|
1836
|
+
if (this.lastChildActivity >= probe.sentAt) {
|
|
1837
|
+
this.armHangWatchdog();
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
const attribution = `${this.hangAttribution(hang)} probe_id=${probeId} probe_timeout_ms=${this.hangProbeTimeoutMs}`;
|
|
1841
|
+
this.killHungChild(attribution, this.awaitingReply.get(hang.oldestId)?.sessionId);
|
|
1842
|
+
}
|
|
1843
|
+
killHungChild(attribution, sessionId) {
|
|
1538
1844
|
this.timeoutRestartAwaitingRecovery = true;
|
|
1539
1845
|
this.spawnedBinaryFingerprint = null;
|
|
1540
|
-
const
|
|
1846
|
+
const aborted = Array.from(this.pending, ([requestId, entry]) => ({
|
|
1541
1847
|
request_id: requestId,
|
|
1542
1848
|
tool: entry.tool,
|
|
1849
|
+
retry: isReadOnlyRetryCommand(entry.tool),
|
|
1543
1850
|
}));
|
|
1544
|
-
const
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1851
|
+
const fullAttribution = `${attribution} aborted_requests=${JSON.stringify(aborted)}`;
|
|
1852
|
+
this.configureWarningClients.clear();
|
|
1853
|
+
const pending = Array.from(this.pending.values());
|
|
1854
|
+
this.pending.clear();
|
|
1855
|
+
for (const entry of pending) {
|
|
1856
|
+
this.clock.clearTimeout(entry.timer);
|
|
1857
|
+
entry.onSettled?.();
|
|
1858
|
+
const readOnly = isReadOnlyRetryCommand(entry.tool);
|
|
1859
|
+
entry.reject(new BridgeRestartedError(entry.tool, !readOnly, readOnly
|
|
1860
|
+
? `${this.errorPrefix} bridge restarted: the child wrote nothing for longer than its hang window and did not answer a liveness probe; "${entry.tool}" is read-only and safe to retry; ${fullAttribution}`
|
|
1861
|
+
: `${this.errorPrefix} bridge restarted: the child wrote nothing for longer than its hang window and did not answer a liveness probe; the outcome of "${entry.tool}" is unknown — a mutation may already have been applied, inspect before repeating it; ${fullAttribution}`));
|
|
1862
|
+
}
|
|
1553
1863
|
// Forget outstanding background task ids: their removal hooks died with
|
|
1554
1864
|
// the child (foreground polls were just rejected and won't resume, and a
|
|
1555
1865
|
// completion frame can't arrive from a killed process), so keeping them
|
|
@@ -1557,6 +1867,7 @@ export class BinaryBridge {
|
|
|
1557
1867
|
// detached tasks persist undelivered completions on disk, and the next
|
|
1558
1868
|
// spawn rehydrates and delivers them.
|
|
1559
1869
|
this.outstandingBackgroundTaskIds.clear();
|
|
1870
|
+
this.resetHangWatchdog();
|
|
1560
1871
|
if (this.process) {
|
|
1561
1872
|
this.process.kill("SIGKILL");
|
|
1562
1873
|
this.process = null;
|
|
@@ -1569,11 +1880,12 @@ export class BinaryBridge {
|
|
|
1569
1880
|
// rejection error. Clear the ring so the next spawn doesn't inherit it.
|
|
1570
1881
|
const tail = this.formatStderrTail();
|
|
1571
1882
|
this.stderrTail = [];
|
|
1883
|
+
// "Bridge killed after timeout" is the phrase log tooling groups on.
|
|
1572
1884
|
const killedMsg = tail
|
|
1573
|
-
? `Bridge killed after timeout; ${
|
|
1574
|
-
: `Bridge killed after timeout; ${
|
|
1575
|
-
if (
|
|
1576
|
-
this.sessionErrorVia(
|
|
1885
|
+
? `Bridge killed after timeout of the hang watchdog; ${fullAttribution}.${tail}`
|
|
1886
|
+
: `Bridge killed after timeout of the hang watchdog; ${fullAttribution} (see ${this.getLogFilePathVia()})`;
|
|
1887
|
+
if (sessionId) {
|
|
1888
|
+
this.sessionErrorVia(sessionId, killedMsg);
|
|
1577
1889
|
}
|
|
1578
1890
|
else {
|
|
1579
1891
|
this.errorVia(killedMsg);
|
|
@@ -1590,7 +1902,7 @@ export class BinaryBridge {
|
|
|
1590
1902
|
this.clearRestartResetTimer();
|
|
1591
1903
|
this.configured = false; // Force reconfigure on next command after restart
|
|
1592
1904
|
// Forget outstanding background task ids — same rationale as
|
|
1593
|
-
//
|
|
1905
|
+
// killHungChild: abandoned removal hooks would otherwise pin this bridge
|
|
1594
1906
|
// against idle eviction permanently (phantom ids). Disk-persisted
|
|
1595
1907
|
// completions are re-delivered after the next spawn rehydrates.
|
|
1596
1908
|
this.outstandingBackgroundTaskIds.clear();
|
|
@@ -1638,7 +1950,7 @@ export class BinaryBridge {
|
|
|
1638
1950
|
rejectAllPending(error) {
|
|
1639
1951
|
this.configureWarningClients.clear();
|
|
1640
1952
|
for (const [_id, entry] of this.pending) {
|
|
1641
|
-
clearTimeout(entry.timer);
|
|
1953
|
+
this.clock.clearTimeout(entry.timer);
|
|
1642
1954
|
entry.onSettled?.();
|
|
1643
1955
|
entry.reject(error);
|
|
1644
1956
|
}
|