@cortexkit/aft-bridge 0.25.2 → 0.26.1
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/active-logger.d.ts.map +1 -1
- package/dist/active-logger.js +28 -4
- package/dist/active-logger.js.map +1 -1
- package/dist/bridge.d.ts +16 -17
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +279 -182
- package/dist/bridge.js.map +1 -1
- package/dist/downloader.d.ts.map +1 -1
- package/dist/downloader.js +117 -13
- package/dist/downloader.js.map +1 -1
- package/dist/pool.d.ts +1 -1
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +27 -9
- package/dist/pool.js.map +1 -1
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +29 -8
- package/dist/resolver.js.map +1 -1
- package/dist/url-fetch.d.ts.map +1 -1
- package/dist/url-fetch.js +4 -0
- package/dist/url-fetch.js.map +1 -1
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
|
-
import { error, getActiveLogger, getLogFilePath, log,
|
|
5
|
+
import { error, getActiveLogger, getLogFilePath, log, warn } from "./active-logger.js";
|
|
6
6
|
const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
|
|
7
7
|
const SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS = 5_000;
|
|
8
8
|
const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
|
|
@@ -107,6 +107,14 @@ function clampSemanticTimeout(configOverrides, bridgeTimeoutMs) {
|
|
|
107
107
|
},
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
|
+
class BridgeReplacedDuringVersionCheck extends Error {
|
|
111
|
+
newBinaryPath;
|
|
112
|
+
constructor(newBinaryPath) {
|
|
113
|
+
super(`Bridge binary replaced during version check: ${newBinaryPath}`);
|
|
114
|
+
this.newBinaryPath = newBinaryPath;
|
|
115
|
+
this.name = "BridgeReplacedDuringVersionCheck";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
110
118
|
/**
|
|
111
119
|
* Manages a persistent `aft` child process, communicating via NDJSON over
|
|
112
120
|
* stdin/stdout. Lazy-spawns on first `send()` call. Handles crash detection
|
|
@@ -122,6 +130,7 @@ export class BinaryBridge {
|
|
|
122
130
|
pending = new Map();
|
|
123
131
|
nextId = 1;
|
|
124
132
|
stdoutBuffer = "";
|
|
133
|
+
stderrBuffer = "";
|
|
125
134
|
/** Ring buffer of the last N stderr lines, cleared on every spawn. */
|
|
126
135
|
stderrTail = [];
|
|
127
136
|
_restartCount = 0;
|
|
@@ -157,44 +166,71 @@ export class BinaryBridge {
|
|
|
157
166
|
this.errorPrefix = options?.errorPrefix ?? "[aft-bridge]";
|
|
158
167
|
this.logger = options?.logger;
|
|
159
168
|
}
|
|
160
|
-
/**
|
|
161
|
-
* Internal log helpers that prefer the constructor-provided logger over the
|
|
162
|
-
* active-logger singleton. Mirrors the same pattern used by `BridgePool`
|
|
163
|
-
* (Oracle F9 — D2 deferral for BinaryBridge). These are intentionally
|
|
164
|
-
* unused at the call sites today: migrating every existing `log()`/`warn()`/
|
|
165
|
-
* `error()`/`sessionLog()` call site in this file to `this.logVia()` etc. is
|
|
166
|
-
* a separate mechanical refactor we defer until we have a concrete use case
|
|
167
|
-
* for per-bridge log routing. Until then, the existing module-level helpers
|
|
168
|
-
* fall through to the singleton, which is the same default behavior the
|
|
169
|
-
* `this.logVia` helpers provide when `this.logger` is `undefined`.
|
|
170
|
-
*
|
|
171
|
-
* The constructor `logger` option + the test in `bridge-transport.test.ts`
|
|
172
|
-
* are what verify the F9 wiring. Once a future change starts using these
|
|
173
|
-
* helpers for any new logging, the biome unused-private warnings disappear.
|
|
174
|
-
*/
|
|
175
|
-
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: F9 plumbing kept for future call-site migration
|
|
176
169
|
logVia(message, meta) {
|
|
177
170
|
const logger = this.logger ?? getActiveLogger();
|
|
178
|
-
if (logger)
|
|
179
|
-
|
|
180
|
-
|
|
171
|
+
if (logger) {
|
|
172
|
+
try {
|
|
173
|
+
logger.log(message, meta);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
console.error(`[aft-bridge] ERROR: logger log threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
+
console.error(`[aft-bridge] ${message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
181
|
log(message, meta);
|
|
182
|
+
}
|
|
182
183
|
}
|
|
183
|
-
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: F9 plumbing kept for future call-site migration
|
|
184
184
|
warnVia(message, meta) {
|
|
185
185
|
const logger = this.logger ?? getActiveLogger();
|
|
186
|
-
if (logger)
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
if (logger) {
|
|
187
|
+
try {
|
|
188
|
+
logger.warn(message, meta);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
console.error(`[aft-bridge] ERROR: logger warn threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
192
|
+
console.error(`[aft-bridge] WARN: ${message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
189
196
|
warn(message, meta);
|
|
197
|
+
}
|
|
190
198
|
}
|
|
191
|
-
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: F9 plumbing kept for future call-site migration
|
|
192
199
|
errorVia(message, meta) {
|
|
193
200
|
const logger = this.logger ?? getActiveLogger();
|
|
194
|
-
if (logger)
|
|
195
|
-
|
|
196
|
-
|
|
201
|
+
if (logger) {
|
|
202
|
+
try {
|
|
203
|
+
logger.error(message, meta);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error(`[aft-bridge] ERROR: logger error threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
207
|
+
console.error(`[aft-bridge] ERROR: ${message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
197
211
|
error(message, meta);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
getLogFilePathVia() {
|
|
215
|
+
if (this.logger?.getLogFilePath) {
|
|
216
|
+
try {
|
|
217
|
+
return this.logger.getLogFilePath();
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
console.error(`[aft-bridge] ERROR: logger getLogFilePath threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return getLogFilePath();
|
|
225
|
+
}
|
|
226
|
+
sessionLogVia(sessionId, message) {
|
|
227
|
+
this.logVia(message, sessionId ? { sessionId } : undefined);
|
|
228
|
+
}
|
|
229
|
+
sessionWarnVia(sessionId, message) {
|
|
230
|
+
this.warnVia(message, sessionId ? { sessionId } : undefined);
|
|
231
|
+
}
|
|
232
|
+
sessionErrorVia(sessionId, message) {
|
|
233
|
+
this.errorVia(message, sessionId ? { sessionId } : undefined);
|
|
198
234
|
}
|
|
199
235
|
/** Number of times the binary has been restarted after a crash. */
|
|
200
236
|
get restartCount() {
|
|
@@ -234,142 +270,157 @@ export class BinaryBridge {
|
|
|
234
270
|
* Lazy-spawns the binary on first call.
|
|
235
271
|
*/
|
|
236
272
|
async send(command, params = {}, options) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
273
|
+
return this.sendWithVersionMismatchRetry(command, params, options, true);
|
|
274
|
+
}
|
|
275
|
+
async sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap) {
|
|
276
|
+
try {
|
|
277
|
+
if (this._shuttingDown) {
|
|
278
|
+
throw new Error(`${this.errorPrefix} Bridge is shutting down, cannot send "${command}"`);
|
|
279
|
+
}
|
|
280
|
+
if (Object.hasOwn(params, "id")) {
|
|
281
|
+
throw new Error("params cannot contain reserved key 'id'");
|
|
282
|
+
}
|
|
283
|
+
// Capture session_id BEFORE ensureSpawned so the spawn-time log line gets
|
|
284
|
+
// tagged with the triggering session. Bridges are project-keyed and serve
|
|
285
|
+
// many sessions over their lifetime, but the spawn itself is attributable
|
|
286
|
+
// to whichever session's tool call triggered it.
|
|
287
|
+
const requestSessionId = typeof params.session_id === "string" && params.session_id.length > 0
|
|
288
|
+
? params.session_id
|
|
289
|
+
: undefined;
|
|
290
|
+
this.ensureSpawned(requestSessionId);
|
|
291
|
+
// Auto-configure can reuse the initiating session's notification client
|
|
292
|
+
// when the deferred configure warning frame arrives later. One project
|
|
293
|
+
// bridge can serve many sessions, so keep this per-session instead of one
|
|
294
|
+
// bridge-wide "last client".
|
|
295
|
+
if (requestSessionId && options?.configureWarningClient !== undefined) {
|
|
296
|
+
this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
|
|
297
|
+
}
|
|
298
|
+
// Auto-configure project root + plugin config on first command, then check version.
|
|
299
|
+
// configured is set AFTER success to prevent skipping configuration on failure (#18).
|
|
300
|
+
// When multiple parallel calls arrive before configure completes, they all await
|
|
301
|
+
// the same promise instead of each independently trying to configure.
|
|
302
|
+
if (!this.configured) {
|
|
303
|
+
if (command !== "configure" && command !== "version") {
|
|
304
|
+
if (!this._configurePromise) {
|
|
305
|
+
// First caller — create the configure promise.
|
|
306
|
+
// All parallel callers await this same promise.
|
|
307
|
+
//
|
|
308
|
+
// Forward the triggering call's session_id into configure so
|
|
309
|
+
// Rust's thread-local session context propagates through to
|
|
310
|
+
// background tasks spawned by configure (search-index pre-warm,
|
|
311
|
+
// semantic-index build). Without this, background log lines
|
|
312
|
+
// emitted by configure threads appear with no session prefix.
|
|
313
|
+
const sessionIdForConfigure = typeof params.session_id === "string" ? params.session_id : undefined;
|
|
314
|
+
this._configurePromise = (async () => {
|
|
315
|
+
try {
|
|
316
|
+
const configResult = await this.send("configure", {
|
|
317
|
+
project_root: this.cwd,
|
|
318
|
+
...this.configOverrides,
|
|
319
|
+
...(sessionIdForConfigure ? { session_id: sessionIdForConfigure } : {}),
|
|
320
|
+
});
|
|
321
|
+
if (configResult.success === false) {
|
|
322
|
+
throw new Error(`${this.errorPrefix} Configure failed: ${configResult.message ?? "unknown error"}`);
|
|
323
|
+
}
|
|
324
|
+
// Large-repo warning is emitted by the Rust side via log::warn!
|
|
325
|
+
// and relayed through stderr → plugin log. No need to re-log here
|
|
326
|
+
// (doing so would just duplicate the same line in aft-plugin.log).
|
|
327
|
+
await this.deliverConfigureWarnings(configResult, params, options);
|
|
328
|
+
await this.checkVersion();
|
|
329
|
+
// Re-check liveness after version check — checkVersion() swallows
|
|
330
|
+
// errors as best-effort, so the bridge may have died without throwing.
|
|
331
|
+
if (!this.isAlive()) {
|
|
332
|
+
throw new Error(`${this.errorPrefix} Bridge died during version check. Check logs: ${this.getLogFilePathVia()}`);
|
|
333
|
+
}
|
|
334
|
+
this.configured = true;
|
|
283
335
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// (doing so would just duplicate the same line in aft-plugin.log).
|
|
287
|
-
await this.deliverConfigureWarnings(configResult, params, options);
|
|
288
|
-
await this.checkVersion();
|
|
289
|
-
// Re-check liveness after version check — checkVersion() swallows
|
|
290
|
-
// errors as best-effort, so the bridge may have died without throwing.
|
|
291
|
-
if (!this.isAlive()) {
|
|
292
|
-
throw new Error(`${this.errorPrefix} Bridge died during version check. Check logs: ${getLogFilePath()}`);
|
|
336
|
+
finally {
|
|
337
|
+
this._configurePromise = null;
|
|
293
338
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
})();
|
|
339
|
+
})();
|
|
340
|
+
}
|
|
341
|
+
// All callers (including the first) await the shared promise
|
|
342
|
+
await this._configurePromise;
|
|
300
343
|
}
|
|
301
|
-
// All callers (including the first) await the shared promise
|
|
302
|
-
await this._configurePromise;
|
|
303
344
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
345
|
+
const id = String(this.nextId++);
|
|
346
|
+
// Wire format: when params contains a key that collides with the protocol
|
|
347
|
+
// envelope (`command`/`method`), nest params under a `params` key so the
|
|
348
|
+
// outer dispatch dispatches on `command: "<bridge command>"` rather than
|
|
349
|
+
// the user's payload key. Reserved envelope fields (`session_id`,
|
|
350
|
+
// `lsp_hints`) must STILL be promoted to the top level so RawRequest's
|
|
351
|
+
// dedicated fields deserialize correctly. Without this promotion, e.g.
|
|
352
|
+
// `bash` (whose params include `command: "<shell command>"`) silently
|
|
353
|
+
// loses `session_id` because it stays nested inside `params`.
|
|
354
|
+
let request;
|
|
355
|
+
if (Object.hasOwn(params, "command") || Object.hasOwn(params, "method")) {
|
|
356
|
+
const nested = { ...params };
|
|
357
|
+
const reserved = {};
|
|
358
|
+
for (const key of ["session_id", "lsp_hints"]) {
|
|
359
|
+
if (Object.hasOwn(nested, key)) {
|
|
360
|
+
reserved[key] = nested[key];
|
|
361
|
+
delete nested[key];
|
|
362
|
+
}
|
|
322
363
|
}
|
|
364
|
+
request = { id, command, ...reserved, params: nested };
|
|
323
365
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
else {
|
|
327
|
-
request = { id, command, ...params };
|
|
328
|
-
}
|
|
329
|
-
const line = `${JSON.stringify(request)}\n`;
|
|
330
|
-
// Per-op timeout override: tool wrappers can pass longer budgets for
|
|
331
|
-
// commands that legitimately need them (callers, trace_to, grep on big
|
|
332
|
-
// repos). Defaults to the bridge-wide timeout otherwise.
|
|
333
|
-
const effectiveTimeoutMs = options?.transportTimeoutMs ?? options?.timeoutMs ?? this.timeoutMs;
|
|
334
|
-
const keepBridgeOnTimeout = options?.keepBridgeOnTimeout === true;
|
|
335
|
-
return new Promise((resolve, reject) => {
|
|
336
|
-
const timer = setTimeout(() => {
|
|
337
|
-
this.pending.delete(id);
|
|
338
|
-
const restartSuffix = keepBridgeOnTimeout ? "" : " — restarting bridge";
|
|
339
|
-
const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms${restartSuffix}`;
|
|
340
|
-
if (requestSessionId) {
|
|
341
|
-
sessionWarn(requestSessionId, timeoutMsg);
|
|
342
|
-
}
|
|
343
|
-
else {
|
|
344
|
-
warn(timeoutMsg);
|
|
345
|
-
}
|
|
346
|
-
reject(new Error(`${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
|
|
347
|
-
// Kill the hung process so the next request gets a fresh bridge —
|
|
348
|
-
// unless the caller explicitly opted out (e.g. bash, which enforces
|
|
349
|
-
// its own timeout on the Rust side and shouldn't lose warm bridge
|
|
350
|
-
// state when its response is merely late).
|
|
351
|
-
if (!keepBridgeOnTimeout) {
|
|
352
|
-
this.handleTimeout(requestSessionId);
|
|
353
|
-
}
|
|
354
|
-
}, effectiveTimeoutMs);
|
|
355
|
-
this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress });
|
|
356
|
-
if (!this.process?.stdin?.writable) {
|
|
357
|
-
this.pending.delete(id);
|
|
358
|
-
clearTimeout(timer);
|
|
359
|
-
reject(new Error(`${this.errorPrefix} stdin not writable for command "${command}"`));
|
|
360
|
-
return;
|
|
366
|
+
else {
|
|
367
|
+
request = { id, command, ...params };
|
|
361
368
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
+
const line = `${JSON.stringify(request)}\n`;
|
|
370
|
+
// Per-op timeout override: tool wrappers can pass longer budgets for
|
|
371
|
+
// commands that legitimately need them (callers, trace_to, grep on big
|
|
372
|
+
// repos). Defaults to the bridge-wide timeout otherwise.
|
|
373
|
+
const effectiveTimeoutMs = options?.transportTimeoutMs ?? options?.timeoutMs ?? this.timeoutMs;
|
|
374
|
+
const keepBridgeOnTimeout = options?.keepBridgeOnTimeout === true;
|
|
375
|
+
return new Promise((resolve, reject) => {
|
|
376
|
+
const timer = setTimeout(() => {
|
|
377
|
+
this.pending.delete(id);
|
|
378
|
+
const restartSuffix = keepBridgeOnTimeout ? "" : " — restarting bridge";
|
|
379
|
+
const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms${restartSuffix}`;
|
|
380
|
+
if (requestSessionId) {
|
|
381
|
+
this.sessionWarnVia(requestSessionId, timeoutMsg);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
this.warnVia(timeoutMsg);
|
|
369
385
|
}
|
|
386
|
+
reject(new Error(`${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
|
|
387
|
+
// Kill the hung process so the next request gets a fresh bridge —
|
|
388
|
+
// unless the caller explicitly opted out (e.g. bash, which enforces
|
|
389
|
+
// its own timeout on the Rust side and shouldn't lose warm bridge
|
|
390
|
+
// state when its response is merely late).
|
|
391
|
+
if (!keepBridgeOnTimeout) {
|
|
392
|
+
this.handleTimeout(requestSessionId);
|
|
393
|
+
}
|
|
394
|
+
}, effectiveTimeoutMs);
|
|
395
|
+
this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress });
|
|
396
|
+
if (!this.process?.stdin?.writable) {
|
|
397
|
+
this.pending.delete(id);
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
reject(new Error(`${this.errorPrefix} stdin not writable for command "${command}"`));
|
|
400
|
+
return;
|
|
370
401
|
}
|
|
402
|
+
this.process.stdin.write(line, (err) => {
|
|
403
|
+
if (err) {
|
|
404
|
+
const entry = this.pending.get(id);
|
|
405
|
+
if (entry) {
|
|
406
|
+
this.pending.delete(id);
|
|
407
|
+
clearTimeout(entry.timer);
|
|
408
|
+
entry.reject(new Error(`${this.errorPrefix} Failed to write to stdin: ${err.message}`));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
});
|
|
371
412
|
});
|
|
372
|
-
}
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
if (err instanceof BridgeReplacedDuringVersionCheck &&
|
|
416
|
+
canRetryAfterVersionSwap &&
|
|
417
|
+
command !== "configure" &&
|
|
418
|
+
command !== "version") {
|
|
419
|
+
this.logVia(`Retrying request "${command}" once after coordinated binary replacement: ${err.newBinaryPath}`);
|
|
420
|
+
return this.sendWithVersionMismatchRetry(command, params, options, false);
|
|
421
|
+
}
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
373
424
|
}
|
|
374
425
|
async deliverConfigureWarnings(configResult, params, options) {
|
|
375
426
|
if (!this.onConfigureWarnings || !Array.isArray(configResult.warnings))
|
|
@@ -387,7 +438,7 @@ export class BinaryBridge {
|
|
|
387
438
|
});
|
|
388
439
|
}
|
|
389
440
|
catch (err) {
|
|
390
|
-
|
|
441
|
+
this.warnVia(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
391
442
|
}
|
|
392
443
|
finally {
|
|
393
444
|
if (sessionId) {
|
|
@@ -430,7 +481,7 @@ export class BinaryBridge {
|
|
|
430
481
|
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot))
|
|
431
482
|
return;
|
|
432
483
|
this.cachedStatus = snapshot;
|
|
433
|
-
|
|
484
|
+
this.logVia("Received status_changed push frame; cached AFT status snapshot");
|
|
434
485
|
for (const listener of this.statusListeners) {
|
|
435
486
|
this.deliverStatusSnapshot(listener, this.cachedStatus);
|
|
436
487
|
}
|
|
@@ -440,7 +491,7 @@ export class BinaryBridge {
|
|
|
440
491
|
listener(snapshot);
|
|
441
492
|
}
|
|
442
493
|
catch (err) {
|
|
443
|
-
|
|
494
|
+
this.warnVia(`status listener threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
444
495
|
}
|
|
445
496
|
}
|
|
446
497
|
/** Kill the child process and reject all pending requests. */
|
|
@@ -459,7 +510,7 @@ export class BinaryBridge {
|
|
|
459
510
|
}, 5_000);
|
|
460
511
|
proc.once("exit", () => {
|
|
461
512
|
clearTimeout(forceKillTimer);
|
|
462
|
-
|
|
513
|
+
this.logVia("Process exited during shutdown");
|
|
463
514
|
resolve();
|
|
464
515
|
});
|
|
465
516
|
proc.kill("SIGTERM");
|
|
@@ -480,17 +531,49 @@ export class BinaryBridge {
|
|
|
480
531
|
if (typeof binaryVersion !== "string") {
|
|
481
532
|
throw new Error(`Binary did not report a version — likely too old (minVersion: ${this.minVersion})`);
|
|
482
533
|
}
|
|
483
|
-
|
|
534
|
+
this.logVia(`Binary version: ${binaryVersion}`);
|
|
484
535
|
if (compareSemver(binaryVersion, this.minVersion) < 0) {
|
|
485
|
-
|
|
486
|
-
this.onVersionMismatch?.(binaryVersion, this.minVersion);
|
|
536
|
+
this.warnVia(`Binary version ${binaryVersion} is older than required ${this.minVersion}`);
|
|
537
|
+
const replacementPath = await this.onVersionMismatch?.(binaryVersion, this.minVersion);
|
|
538
|
+
if (replacementPath === undefined) {
|
|
539
|
+
// Backwards compatibility: legacy callbacks returned void and usually kicked off a
|
|
540
|
+
// fire-and-forget download + pool swap. Preserve that behavior for existing callers.
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (replacementPath === null || replacementPath.length === 0) {
|
|
544
|
+
throw new Error(`Binary version ${binaryVersion} is older than required ${this.minVersion}; no compatible replacement binary was provided`);
|
|
545
|
+
}
|
|
546
|
+
await this.replaceCurrentBinary(replacementPath);
|
|
547
|
+
throw new BridgeReplacedDuringVersionCheck(replacementPath);
|
|
487
548
|
}
|
|
488
549
|
}
|
|
489
550
|
catch (err) {
|
|
490
|
-
|
|
551
|
+
this.warnVia(`Version check failed: ${err.message}`);
|
|
491
552
|
throw err;
|
|
492
553
|
}
|
|
493
554
|
}
|
|
555
|
+
async replaceCurrentBinary(newBinaryPath) {
|
|
556
|
+
this.binaryPath = newBinaryPath;
|
|
557
|
+
this.configured = false;
|
|
558
|
+
this.clearRestartResetTimer();
|
|
559
|
+
this.rejectAllPending(new Error(`${this.errorPrefix} Bridge restarting with updated binary: ${newBinaryPath}`));
|
|
560
|
+
if (!this.process)
|
|
561
|
+
return;
|
|
562
|
+
const proc = this.process;
|
|
563
|
+
this.process = null;
|
|
564
|
+
await new Promise((resolve) => {
|
|
565
|
+
const forceKillTimer = setTimeout(() => {
|
|
566
|
+
proc.kill("SIGKILL");
|
|
567
|
+
resolve();
|
|
568
|
+
}, 5_000);
|
|
569
|
+
proc.once("exit", () => {
|
|
570
|
+
clearTimeout(forceKillTimer);
|
|
571
|
+
this.logVia("Process exited during coordinated binary replacement");
|
|
572
|
+
resolve();
|
|
573
|
+
});
|
|
574
|
+
proc.kill("SIGTERM");
|
|
575
|
+
});
|
|
576
|
+
}
|
|
494
577
|
ensureSpawned(triggeringSessionId) {
|
|
495
578
|
if (this.isAlive())
|
|
496
579
|
return;
|
|
@@ -498,10 +581,10 @@ export class BinaryBridge {
|
|
|
498
581
|
}
|
|
499
582
|
spawnProcess(triggeringSessionId) {
|
|
500
583
|
if (triggeringSessionId) {
|
|
501
|
-
|
|
584
|
+
this.sessionLogVia(triggeringSessionId, `Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
|
|
502
585
|
}
|
|
503
586
|
else {
|
|
504
|
-
|
|
587
|
+
this.logVia(`Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
|
|
505
588
|
}
|
|
506
589
|
const semantic = this.configOverrides.semantic;
|
|
507
590
|
const semanticBackend = (() => {
|
|
@@ -565,11 +648,12 @@ export class BinaryBridge {
|
|
|
565
648
|
const remaining = stderrDecoder.end();
|
|
566
649
|
if (remaining)
|
|
567
650
|
this.onStderrData(remaining);
|
|
651
|
+
this.flushStderrBuffer();
|
|
568
652
|
});
|
|
569
653
|
child.on("error", (err) => {
|
|
570
654
|
if (this.process !== currentChild)
|
|
571
655
|
return;
|
|
572
|
-
|
|
656
|
+
this.errorVia(`Process error: ${err.message}${this.formatStderrTail()}`);
|
|
573
657
|
this.handleCrash();
|
|
574
658
|
});
|
|
575
659
|
child.on("exit", (code, signal) => {
|
|
@@ -577,7 +661,7 @@ export class BinaryBridge {
|
|
|
577
661
|
return;
|
|
578
662
|
if (this._shuttingDown)
|
|
579
663
|
return;
|
|
580
|
-
|
|
664
|
+
this.logVia(`Process exited: code=${code}, signal=${signal}`);
|
|
581
665
|
// External termination signals (SIGTERM/SIGKILL/SIGHUP/SIGINT) are almost
|
|
582
666
|
// always intentional kills — from our own shutdown path, OpenCode tearing
|
|
583
667
|
// down, OS shutdown, or the user killing the host. Auto-restarting here
|
|
@@ -599,6 +683,7 @@ export class BinaryBridge {
|
|
|
599
683
|
});
|
|
600
684
|
this.process = child;
|
|
601
685
|
this.stdoutBuffer = "";
|
|
686
|
+
this.stderrBuffer = "";
|
|
602
687
|
// Fresh spawn — clear the stderr ring so crash diagnostics only reflect
|
|
603
688
|
// the current child's output, not output from prior restart cycles.
|
|
604
689
|
this.stderrTail = [];
|
|
@@ -610,15 +695,27 @@ export class BinaryBridge {
|
|
|
610
695
|
}
|
|
611
696
|
}
|
|
612
697
|
onStderrData(data) {
|
|
613
|
-
|
|
614
|
-
|
|
698
|
+
this.stderrBuffer += data;
|
|
699
|
+
let newlineIdx;
|
|
700
|
+
while ((newlineIdx = this.stderrBuffer.indexOf("\n")) !== -1) {
|
|
701
|
+
const line = this.stderrBuffer.slice(0, newlineIdx).replace(/\r$/, "");
|
|
702
|
+
this.stderrBuffer = this.stderrBuffer.slice(newlineIdx + 1);
|
|
615
703
|
if (!line)
|
|
616
704
|
continue;
|
|
617
705
|
const tagged = tagStderrLine(line);
|
|
618
|
-
|
|
706
|
+
this.logVia(tagged);
|
|
619
707
|
this.pushStderrLine(tagged);
|
|
620
708
|
}
|
|
621
709
|
}
|
|
710
|
+
flushStderrBuffer() {
|
|
711
|
+
const line = this.stderrBuffer.replace(/\r$/, "");
|
|
712
|
+
this.stderrBuffer = "";
|
|
713
|
+
if (!line)
|
|
714
|
+
return;
|
|
715
|
+
const tagged = tagStderrLine(line);
|
|
716
|
+
this.logVia(tagged);
|
|
717
|
+
this.pushStderrLine(tagged);
|
|
718
|
+
}
|
|
622
719
|
/**
|
|
623
720
|
* Format the current stderr tail for inclusion in error messages. Returns
|
|
624
721
|
* empty string when nothing has been captured (e.g., silent SIGKILL from
|
|
@@ -678,7 +775,7 @@ export class BinaryBridge {
|
|
|
678
775
|
}
|
|
679
776
|
if (response.type === "configure_warnings") {
|
|
680
777
|
this.handleConfigureWarningsFrame(response).catch((err) => {
|
|
681
|
-
|
|
778
|
+
this.warnVia(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
682
779
|
});
|
|
683
780
|
continue;
|
|
684
781
|
}
|
|
@@ -697,11 +794,11 @@ export class BinaryBridge {
|
|
|
697
794
|
entry.resolve(response);
|
|
698
795
|
}
|
|
699
796
|
else if (typeof response.type === "string") {
|
|
700
|
-
|
|
797
|
+
this.logVia(`Ignoring unknown stdout push frame type: ${response.type}`);
|
|
701
798
|
}
|
|
702
799
|
}
|
|
703
800
|
catch (_err) {
|
|
704
|
-
|
|
801
|
+
this.warnVia(`Failed to parse stdout line: ${line}`);
|
|
705
802
|
}
|
|
706
803
|
}
|
|
707
804
|
}
|
|
@@ -724,20 +821,20 @@ export class BinaryBridge {
|
|
|
724
821
|
this.stderrTail = [];
|
|
725
822
|
const killedMsg = tail
|
|
726
823
|
? `Bridge killed after timeout.${tail}`
|
|
727
|
-
: `Bridge killed after timeout (see ${
|
|
824
|
+
: `Bridge killed after timeout (see ${this.getLogFilePathVia()})`;
|
|
728
825
|
if (tail) {
|
|
729
826
|
if (triggeringSessionId) {
|
|
730
|
-
|
|
827
|
+
this.sessionErrorVia(triggeringSessionId, killedMsg);
|
|
731
828
|
}
|
|
732
829
|
else {
|
|
733
|
-
|
|
830
|
+
this.errorVia(killedMsg);
|
|
734
831
|
}
|
|
735
832
|
}
|
|
736
833
|
else if (triggeringSessionId) {
|
|
737
|
-
|
|
834
|
+
this.sessionWarnVia(triggeringSessionId, killedMsg);
|
|
738
835
|
}
|
|
739
836
|
else {
|
|
740
|
-
|
|
837
|
+
this.warnVia(killedMsg);
|
|
741
838
|
}
|
|
742
839
|
}
|
|
743
840
|
handleCrash(cause) {
|
|
@@ -754,21 +851,21 @@ export class BinaryBridge {
|
|
|
754
851
|
// rejection only carries a pointer to the log.
|
|
755
852
|
const tail = this.formatStderrTail();
|
|
756
853
|
if (tail) {
|
|
757
|
-
|
|
854
|
+
this.errorVia(`Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""}.${tail}`);
|
|
758
855
|
}
|
|
759
|
-
this.rejectAllPending(new Error(`${this.errorPrefix} Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""} (see ${
|
|
856
|
+
this.rejectAllPending(new Error(`${this.errorPrefix} Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""} (see ${this.getLogFilePathVia()})`));
|
|
760
857
|
// Auto-restart with exponential backoff
|
|
761
858
|
if (this._restartCount < this.maxRestarts) {
|
|
762
859
|
const delay = 100 * 2 ** this._restartCount; // 100ms, 200ms, 400ms
|
|
763
860
|
this._restartCount++;
|
|
764
|
-
|
|
861
|
+
this.logVia(`Auto-restart #${this._restartCount} in ${delay}ms`);
|
|
765
862
|
setTimeout(() => {
|
|
766
863
|
if (!this._shuttingDown && !this.isAlive()) {
|
|
767
864
|
try {
|
|
768
865
|
this.spawnProcess();
|
|
769
866
|
}
|
|
770
867
|
catch (err) {
|
|
771
|
-
|
|
868
|
+
this.errorVia(`Failed to restart: ${err.message}`);
|
|
772
869
|
}
|
|
773
870
|
}
|
|
774
871
|
}, delay);
|
|
@@ -777,7 +874,7 @@ export class BinaryBridge {
|
|
|
777
874
|
this.scheduleRestartCountReset();
|
|
778
875
|
}
|
|
779
876
|
else {
|
|
780
|
-
|
|
877
|
+
this.errorVia(`Max restarts (${this.maxRestarts}) reached, giving up. Logs: ${this.getLogFilePathVia()}${tail}`);
|
|
781
878
|
this.scheduleRestartCountReset();
|
|
782
879
|
}
|
|
783
880
|
}
|