@cortexkit/aft-bridge 0.58.1 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bash-format.d.ts.map +1 -1
- package/dist/bash-format.js +5 -0
- package/dist/bash-format.js.map +1 -1
- package/dist/bash-hints.d.ts +159 -20
- package/dist/bash-hints.d.ts.map +1 -1
- package/dist/bash-hints.js +343 -37
- package/dist/bash-hints.js.map +1 -1
- package/dist/bash-host-fallback.d.ts +27 -0
- package/dist/bash-host-fallback.d.ts.map +1 -1
- package/dist/bash-host-fallback.js +55 -16
- package/dist/bash-host-fallback.js.map +1 -1
- package/dist/binary-identity.d.ts +13 -0
- package/dist/binary-identity.d.ts.map +1 -1
- package/dist/binary-identity.js +91 -2
- package/dist/binary-identity.js.map +1 -1
- package/dist/bridge.d.ts +12 -3
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +85 -40
- package/dist/bridge.js.map +1 -1
- package/dist/callgraph-format.d.ts.map +1 -1
- package/dist/callgraph-format.js +18 -10
- package/dist/callgraph-format.js.map +1 -1
- package/dist/child-process.d.ts.map +1 -1
- package/dist/child-process.js +10 -1
- package/dist/child-process.js.map +1 -1
- package/dist/coerce.d.ts +27 -0
- package/dist/coerce.d.ts.map +1 -1
- package/dist/coerce.js +64 -0
- package/dist/coerce.js.map +1 -1
- package/dist/config-error.d.ts +2 -2
- package/dist/config-error.js +2 -2
- package/dist/config-watch.d.ts +2 -0
- package/dist/config-watch.d.ts.map +1 -1
- package/dist/config-watch.js +35 -0
- package/dist/config-watch.js.map +1 -1
- package/dist/durable-log.d.ts +11 -0
- package/dist/durable-log.d.ts.map +1 -1
- package/dist/durable-log.js +67 -24
- package/dist/durable-log.js.map +1 -1
- package/dist/error-contract.d.ts +14 -5
- package/dist/error-contract.d.ts.map +1 -1
- package/dist/error-contract.js +42 -9
- package/dist/error-contract.js.map +1 -1
- package/dist/feature-config.d.ts +35 -18
- package/dist/feature-config.d.ts.map +1 -1
- package/dist/feature-config.js +161 -94
- package/dist/feature-config.js.map +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -7
- package/dist/index.js.map +1 -1
- package/dist/location-lifecycle.d.ts.map +1 -1
- package/dist/location-lifecycle.js +22 -4
- package/dist/location-lifecycle.js.map +1 -1
- package/dist/message-detach.d.ts +15 -0
- package/dist/message-detach.d.ts.map +1 -1
- package/dist/message-detach.js +48 -0
- package/dist/message-detach.js.map +1 -1
- package/dist/migration-notices.d.ts.map +1 -1
- package/dist/migration-notices.js +12 -5
- package/dist/migration-notices.js.map +1 -1
- package/dist/migration.d.ts +1 -0
- package/dist/migration.d.ts.map +1 -1
- package/dist/migration.js +39 -12
- package/dist/migration.js.map +1 -1
- package/dist/onnx-runtime.d.ts +5 -0
- package/dist/onnx-runtime.d.ts.map +1 -1
- package/dist/onnx-runtime.js +55 -32
- package/dist/onnx-runtime.js.map +1 -1
- package/dist/path-aliases.d.ts.map +1 -1
- package/dist/path-aliases.js +8 -7
- package/dist/path-aliases.js.map +1 -1
- package/dist/path-display.d.ts +5 -0
- package/dist/path-display.d.ts.map +1 -0
- package/dist/path-display.js +21 -0
- package/dist/path-display.js.map +1 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +7 -6
- package/dist/paths.js.map +1 -1
- package/dist/private-storage.d.ts +9 -0
- package/dist/private-storage.d.ts.map +1 -0
- package/dist/private-storage.js +75 -0
- package/dist/private-storage.js.map +1 -0
- package/dist/protocol.d.ts +4 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/revivable-transport.d.ts +4 -2
- package/dist/revivable-transport.d.ts.map +1 -1
- package/dist/revivable-transport.js +48 -19
- package/dist/revivable-transport.js.map +1 -1
- package/dist/storage-paths.d.ts +3 -4
- package/dist/storage-paths.d.ts.map +1 -1
- package/dist/storage-paths.js +4 -5
- package/dist/storage-paths.js.map +1 -1
- package/dist/subc-transport.d.ts +62 -3
- package/dist/subc-transport.d.ts.map +1 -1
- package/dist/subc-transport.js +460 -53
- package/dist/subc-transport.js.map +1 -1
- package/dist/tar-executable.d.ts +1 -1
- package/dist/tar-executable.d.ts.map +1 -1
- package/dist/tar-executable.js +16 -2
- package/dist/tar-executable.js.map +1 -1
- package/dist/test-child-environment.d.ts +8 -0
- package/dist/test-child-environment.d.ts.map +1 -0
- package/dist/test-child-environment.js +105 -0
- package/dist/test-child-environment.js.map +1 -0
- package/dist/transport.d.ts +29 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +26 -1
- package/dist/transport.js.map +1 -1
- package/dist/zoom-format.d.ts +1 -0
- package/dist/zoom-format.d.ts.map +1 -1
- package/dist/zoom-format.js +5 -1
- package/dist/zoom-format.js.map +1 -1
- package/package.json +2 -2
package/dist/subc-transport.js
CHANGED
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
* top of it. subc-client is a build-time path dependency bundled into the
|
|
17
17
|
* published plugin dist; it is never a published runtime dependency.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
20
|
-
import { connectionFileExists, isConsumerReconnectTransient, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
|
|
19
|
+
import { statSync } from "node:fs";
|
|
20
|
+
import { AuthError, connectionFileExists, isConsumerReconnectTransient, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
|
|
21
21
|
import { log, warn } from "./active-logger.js";
|
|
22
22
|
import { isRouteOpenReloadWindowError, isRouteRequestReloadRefusal } from "./error-contract.js";
|
|
23
23
|
import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
|
|
24
24
|
import { canonicalizeProjectRoot } from "./project-identity.js";
|
|
25
|
+
import { callPresetFor, PRESET_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
|
|
25
26
|
/** The subc pool is closing and cannot carry another request. */
|
|
26
27
|
export class SubcTransportShuttingDownError extends SubcCallError {
|
|
27
28
|
constructor() {
|
|
@@ -29,6 +30,57 @@ export class SubcTransportShuttingDownError extends SubcCallError {
|
|
|
29
30
|
this.name = "SubcTransportShuttingDownError";
|
|
30
31
|
}
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The plugin closed one of its own routes (pool shutdown, session close,
|
|
35
|
+
* project-root close, or a sibling call on the same session discarding the
|
|
36
|
+
* shared route) while this call's request was already in flight on it.
|
|
37
|
+
*
|
|
38
|
+
* subc-client records a request as pending and starts writing its frame in the
|
|
39
|
+
* same synchronous step, and its `closeRoute` rejects exactly those pending
|
|
40
|
+
* requests. So a request rejected that way was written, or queued to be
|
|
41
|
+
* written, and the daemon may have run it: the outcome is unknown. The call
|
|
42
|
+
* must not be blind-retried, and bash must not re-run it on the host.
|
|
43
|
+
*/
|
|
44
|
+
export class SubcRouteClosedMidCallError extends SubcCallError {
|
|
45
|
+
reason;
|
|
46
|
+
constructor(reason, cause) {
|
|
47
|
+
super("outcome_unknown", `the command may have run; the route closed mid-call: ${reason}`, "route_closed", cause);
|
|
48
|
+
this.name = "SubcRouteClosedMidCallError";
|
|
49
|
+
this.reason = reason;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* subc-client's rejection of a request that was pending when the client itself
|
|
54
|
+
* closed the route: `closeRoute` tearing that route down, or the client closing
|
|
55
|
+
* its connection. The client types both with `closeReason: "closed_by_caller"`;
|
|
56
|
+
* a daemon GOODBYE carries the daemon's reason and a lost connection carries
|
|
57
|
+
* `"connection_lost"`. A route closed with no request pending produces no error
|
|
58
|
+
* at all, and a request started on an already-closed route fails with
|
|
59
|
+
* `StaleRouteHandleError`. A route closed while still opening is reported as a
|
|
60
|
+
* not-sent `SubcCallError`, which is not a `SubcError` and so never matches.
|
|
61
|
+
*/
|
|
62
|
+
export function isLocalRouteCloseRejection(error) {
|
|
63
|
+
return error instanceof SubcError && error.closeReason === "closed_by_caller";
|
|
64
|
+
}
|
|
65
|
+
/** The route-close reason that matches a session's teardown, if it was torn down. */
|
|
66
|
+
function teardownCloseReason(teardown) {
|
|
67
|
+
switch (teardown) {
|
|
68
|
+
case "shutdown":
|
|
69
|
+
return "transport shutting down";
|
|
70
|
+
case "session_closed":
|
|
71
|
+
return "session closed";
|
|
72
|
+
case "root_reaped":
|
|
73
|
+
return "project root closed";
|
|
74
|
+
default:
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
class ReconnectKeyChangedError extends Error {
|
|
79
|
+
constructor() {
|
|
80
|
+
super("the subc daemon restarted and its connection key changed; the reconnect did not complete within the deadline, retry");
|
|
81
|
+
this.name = "ReconnectKeyChangedError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
32
84
|
/** True when subc-client rejected locally because its connection was already closed. */
|
|
33
85
|
export function isSubcClientClosedError(error) {
|
|
34
86
|
return error instanceof SubcError && error.message === "client closed";
|
|
@@ -49,6 +101,10 @@ export function isSubcClientClosedError(error) {
|
|
|
49
101
|
* the connection has already been torn down locally.
|
|
50
102
|
*/
|
|
51
103
|
export function isSocketDeathError(error, depth = 0) {
|
|
104
|
+
// Raw data-plane requests wrap socket death in a SubcError; its typed
|
|
105
|
+
// connection-loss reason survives even when the underlying errno does not.
|
|
106
|
+
if (error instanceof SubcError && error.closeReason === "connection_lost")
|
|
107
|
+
return true;
|
|
52
108
|
if (error instanceof SubcCallError) {
|
|
53
109
|
const cause = error.cause;
|
|
54
110
|
return depth < 4 && cause !== undefined && cause !== error
|
|
@@ -343,6 +399,11 @@ class BgSubscription {
|
|
|
343
399
|
const initial = client.droppedIngressFrames;
|
|
344
400
|
if (typeof initial !== "number")
|
|
345
401
|
return () => undefined;
|
|
402
|
+
const existing = dispatchProbes.get(client);
|
|
403
|
+
if (existing) {
|
|
404
|
+
existing.users += 1;
|
|
405
|
+
return () => releaseDispatchProbe(client);
|
|
406
|
+
}
|
|
346
407
|
let previous = initial;
|
|
347
408
|
const timer = setInterval(() => {
|
|
348
409
|
const total = client.droppedIngressFrames;
|
|
@@ -352,8 +413,10 @@ class BgSubscription {
|
|
|
352
413
|
previous = total;
|
|
353
414
|
this.info("dispatch-epoch-drop", `client ingress epoch drops scope=client observed_while_channel=${routeId} delta=${delta} total=${total}`);
|
|
354
415
|
}, this.dispatchProbeIntervalMs);
|
|
416
|
+
dispatchProbeTimers += 1;
|
|
355
417
|
timer.unref?.();
|
|
356
|
-
|
|
418
|
+
dispatchProbes.set(client, { users: 1, timer });
|
|
419
|
+
return () => releaseDispatchProbe(client);
|
|
357
420
|
}
|
|
358
421
|
async run() {
|
|
359
422
|
let backoffAttempt = 0;
|
|
@@ -469,7 +532,7 @@ class BgSubscription {
|
|
|
469
532
|
if (!this.isCurrent()) {
|
|
470
533
|
this.info("nudge-stale-carrier", `nudge carried by stale subscription; checking current session channel=${routeId}`);
|
|
471
534
|
}
|
|
472
|
-
this.onNudge();
|
|
535
|
+
this.onNudge(true);
|
|
473
536
|
});
|
|
474
537
|
this.current = sub;
|
|
475
538
|
this.attached = client;
|
|
@@ -487,7 +550,7 @@ class BgSubscription {
|
|
|
487
550
|
// A reconnect may have missed completions, so force a drain after every
|
|
488
551
|
// successful subscribe to replay anything queued while the stream was down.
|
|
489
552
|
if (!this.stopped && this.isCurrent())
|
|
490
|
-
this.onNudge();
|
|
553
|
+
this.onNudge(false);
|
|
491
554
|
await sub.closed;
|
|
492
555
|
this.info("stream-end", `stream ended channel=${routeId}`);
|
|
493
556
|
if (this.stopped) {
|
|
@@ -603,6 +666,21 @@ function abortedRequestError() {
|
|
|
603
666
|
error.name = "AbortError";
|
|
604
667
|
return error;
|
|
605
668
|
}
|
|
669
|
+
// The metric is connection-wide, so observing it needs one timer, not one per
|
|
670
|
+
// session. Last unsubscribe releases the timer, including on a dead socket.
|
|
671
|
+
const dispatchProbes = new Map();
|
|
672
|
+
let dispatchProbeTimers = 0;
|
|
673
|
+
function releaseDispatchProbe(client) {
|
|
674
|
+
const probe = dispatchProbes.get(client);
|
|
675
|
+
if (!probe || --probe.users > 0)
|
|
676
|
+
return;
|
|
677
|
+
clearInterval(probe.timer);
|
|
678
|
+
dispatchProbeTimers -= 1;
|
|
679
|
+
dispatchProbes.delete(client);
|
|
680
|
+
}
|
|
681
|
+
export function __dispatchProbeCountForTests() {
|
|
682
|
+
return dispatchProbeTimers;
|
|
683
|
+
}
|
|
606
684
|
/**
|
|
607
685
|
* A route open that resolved AFTER its session was torn down (closeSession /
|
|
608
686
|
* shutdown / client swap). The caller's request can't proceed, but this is an
|
|
@@ -611,6 +689,25 @@ function abortedRequestError() {
|
|
|
611
689
|
*/
|
|
612
690
|
class RouteTornDownError extends Error {
|
|
613
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* True when a call stopped at a teardown before its request was handed to the
|
|
694
|
+
* client (a closed session, or a route that opened after the teardown), so the
|
|
695
|
+
* request provably never reached AFT.
|
|
696
|
+
*/
|
|
697
|
+
export function isSubcRouteTornDownError(error) {
|
|
698
|
+
return error instanceof RouteTornDownError;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Turn subc-client's bare rejection of a request that was in flight on a route
|
|
702
|
+
* the plugin closed into an outcome-unknown error that names the close reason.
|
|
703
|
+
* Any other error passes through unchanged.
|
|
704
|
+
*/
|
|
705
|
+
function routeClosedMidCallError(error, entry, record) {
|
|
706
|
+
if (!isLocalRouteCloseRejection(error))
|
|
707
|
+
return error;
|
|
708
|
+
const reason = teardownCloseReason(record.teardownReason) ?? entry.closedBy ?? "route closed by the plugin";
|
|
709
|
+
return new SubcRouteClosedMidCallError(reason, error);
|
|
710
|
+
}
|
|
614
711
|
/**
|
|
615
712
|
* Re-lift the route reply into the flat {@link ToolCallResult} shape the standalone
|
|
616
713
|
* `BinaryBridge.toolCall` returns. The Rust module wraps the full flat response
|
|
@@ -700,6 +797,9 @@ class SubcTransport {
|
|
|
700
797
|
body.edit_slot_survives = editSlotSurvives;
|
|
701
798
|
if (preview === true)
|
|
702
799
|
body.preview = true;
|
|
800
|
+
if (options?.workerSession === true)
|
|
801
|
+
body[WORKER_SESSION_FIELD] = true;
|
|
802
|
+
body[PRESET_FIELD] = callPresetFor(options?.workerSession === true);
|
|
703
803
|
const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation, abortSignal);
|
|
704
804
|
return reliftReply(reply);
|
|
705
805
|
}
|
|
@@ -720,7 +820,14 @@ class SubcTransport {
|
|
|
720
820
|
}
|
|
721
821
|
const { timeoutMs, onProgress, abortSignal } = this.splitOptions(options);
|
|
722
822
|
const session = typeof params.session_id === "string" ? params.session_id : undefined;
|
|
723
|
-
|
|
823
|
+
// The caller's role travels beside the call, like the session (which the
|
|
824
|
+
// route bind carries), not inside the command's arguments: the module
|
|
825
|
+
// reads it from the call body.
|
|
826
|
+
const { [WORKER_SESSION_FIELD]: workerSession, ...args } = params;
|
|
827
|
+
const body = { name: command, arguments: args };
|
|
828
|
+
if (workerSession === true)
|
|
829
|
+
body[WORKER_SESSION_FIELD] = true;
|
|
830
|
+
body[PRESET_FIELD] = callPresetFor(workerSession === true);
|
|
724
831
|
const editSlotSurvives = this.pool.getEditSlotSurvives();
|
|
725
832
|
if (editSlotSurvives !== undefined)
|
|
726
833
|
body.edit_slot_survives = editSlotSurvives;
|
|
@@ -765,6 +872,7 @@ export class SubcTransportPool {
|
|
|
765
872
|
lifecycleRegistration = null;
|
|
766
873
|
outerFacadeEvictor;
|
|
767
874
|
client = null;
|
|
875
|
+
hadWorkingClient = false;
|
|
768
876
|
/** Single-flight guard so concurrent first calls share one connect. */
|
|
769
877
|
connecting = null;
|
|
770
878
|
/** The growing delay for a safe, once-only route resend after route closure. */
|
|
@@ -775,6 +883,10 @@ export class SubcTransportPool {
|
|
|
775
883
|
routeReopenRetryMs = null;
|
|
776
884
|
/** Per-session records keyed by the opaque identity key. */
|
|
777
885
|
sessions = new Map();
|
|
886
|
+
// Pending host creation events are bounded. Forgetting one only makes that
|
|
887
|
+
// session unknown (retained), never falsely eligible for eviction.
|
|
888
|
+
observedStarts = new Set();
|
|
889
|
+
sessionSweep = null;
|
|
778
890
|
/**
|
|
779
891
|
* The sole root-scoped session enumeration authority. Keys are opaque and are
|
|
780
892
|
* removed by the same detacher that removes the corresponding session record.
|
|
@@ -896,9 +1008,10 @@ export class SubcTransportPool {
|
|
|
896
1008
|
return asCanonicalRootPath(canonicalizeProjectRoot(projectRoot));
|
|
897
1009
|
}
|
|
898
1010
|
/**
|
|
899
|
-
* Check a root immediately before opening a route.
|
|
900
|
-
*
|
|
901
|
-
*
|
|
1011
|
+
* Check a root immediately before opening a route. Only the directory's
|
|
1012
|
+
* existence decides: a reclaim marker beside it (`<root>.reclaimed`) needs
|
|
1013
|
+
* no check, since an absent directory suspends the bind either way and a
|
|
1014
|
+
* directory that has returned wins over a stale marker.
|
|
902
1015
|
*/
|
|
903
1016
|
rootCanAttach(root) {
|
|
904
1017
|
let directoryExists = false;
|
|
@@ -912,13 +1025,6 @@ export class SubcTransportPool {
|
|
|
912
1025
|
this.dormantRoots.delete(root);
|
|
913
1026
|
return true;
|
|
914
1027
|
}
|
|
915
|
-
// Read the marker only as a latency hint. Directory absence is sufficient
|
|
916
|
-
// to suspend the bind, and the marker's JSON is intentionally irrelevant.
|
|
917
|
-
const reclaimedMarkerPresent = existsSync(`${root}.reclaimed`);
|
|
918
|
-
if (reclaimedMarkerPresent) {
|
|
919
|
-
this.markRootDormant(root);
|
|
920
|
-
return false;
|
|
921
|
-
}
|
|
922
1028
|
this.markRootDormant(root);
|
|
923
1029
|
return false;
|
|
924
1030
|
}
|
|
@@ -1150,6 +1256,7 @@ export class SubcTransportPool {
|
|
|
1150
1256
|
if (this.lifecycleEnabled() && record.generation !== generation) {
|
|
1151
1257
|
throw this.generationExpiredError(root, generation ?? asRootGeneration(1));
|
|
1152
1258
|
}
|
|
1259
|
+
record.lastUsedAt = Date.now();
|
|
1153
1260
|
return record;
|
|
1154
1261
|
}
|
|
1155
1262
|
record = {
|
|
@@ -1158,10 +1265,18 @@ export class SubcTransportPool {
|
|
|
1158
1265
|
canonicalRoot: root,
|
|
1159
1266
|
generation,
|
|
1160
1267
|
routeEntry: null,
|
|
1268
|
+
retiredRoutes: new Set(),
|
|
1161
1269
|
bgSub: null,
|
|
1162
1270
|
closed: false,
|
|
1163
1271
|
teardownReason: null,
|
|
1164
1272
|
inflight: 0,
|
|
1273
|
+
lastUsedAt: Date.now(),
|
|
1274
|
+
observedFromStart: this.observedStarts.delete(key),
|
|
1275
|
+
backgroundTasks: new Map(),
|
|
1276
|
+
backgroundWatches: new Map(),
|
|
1277
|
+
unknownBackgroundWork: false,
|
|
1278
|
+
pendingBgNudge: false,
|
|
1279
|
+
pendingDetach: false,
|
|
1165
1280
|
};
|
|
1166
1281
|
this.sessions.set(key, record);
|
|
1167
1282
|
let keys = this.rootIndex.get(root);
|
|
@@ -1170,8 +1285,173 @@ export class SubcTransportPool {
|
|
|
1170
1285
|
this.rootIndex.set(root, keys);
|
|
1171
1286
|
}
|
|
1172
1287
|
keys.add(key);
|
|
1288
|
+
if (!this.sessionSweep) {
|
|
1289
|
+
this.sessionSweep = setInterval(() => {
|
|
1290
|
+
void this.reapIdleSessions();
|
|
1291
|
+
}, 60_000);
|
|
1292
|
+
this.sessionSweep.unref?.();
|
|
1293
|
+
}
|
|
1173
1294
|
return record;
|
|
1174
1295
|
}
|
|
1296
|
+
/** Release only idle carriers, not the daemon's durable session or completion queue.
|
|
1297
|
+
* The next request opens a new route and subscribes again; subscription startup
|
|
1298
|
+
* always nudges a drain, replaying completions queued while no carrier existed.
|
|
1299
|
+
*/
|
|
1300
|
+
async reapIdleSessions(now = Date.now()) {
|
|
1301
|
+
for (const [key, state] of this.nudgeDeliveryLogState) {
|
|
1302
|
+
if (now - state.lastEmittedAt >= 15 * 60_000)
|
|
1303
|
+
this.nudgeDeliveryLogState.delete(key);
|
|
1304
|
+
}
|
|
1305
|
+
const detached = [];
|
|
1306
|
+
for (const [key, record] of this.sessions) {
|
|
1307
|
+
if (
|
|
1308
|
+
// The wire has no session-wide background census. A resumed/rebound
|
|
1309
|
+
// session may have tasks or watches this pool never saw, so only a
|
|
1310
|
+
// host-observed creation can establish a complete lifetime history.
|
|
1311
|
+
!record.observedFromStart ||
|
|
1312
|
+
record.backgroundTasks.size > 0 ||
|
|
1313
|
+
record.backgroundWatches.size > 0 ||
|
|
1314
|
+
record.unknownBackgroundWork ||
|
|
1315
|
+
record.pendingBgNudge ||
|
|
1316
|
+
record.pendingDetach ||
|
|
1317
|
+
record.inflight > 0 ||
|
|
1318
|
+
record.retiredRoutes.size > 0 ||
|
|
1319
|
+
record.routeEntry?.opening ||
|
|
1320
|
+
now - record.lastUsedAt < 15 * 60_000)
|
|
1321
|
+
continue;
|
|
1322
|
+
const owner = this.detachSession(key, "session_closed");
|
|
1323
|
+
if (owner)
|
|
1324
|
+
detached.push(owner);
|
|
1325
|
+
}
|
|
1326
|
+
if (this.sessions.size === 0 && this.sessionSweep) {
|
|
1327
|
+
clearInterval(this.sessionSweep);
|
|
1328
|
+
this.sessionSweep = null;
|
|
1329
|
+
}
|
|
1330
|
+
const cleanup = Promise.allSettled(detached.map((owner) => this.cleanupDetached(owner)));
|
|
1331
|
+
this.pendingRootCleanups.add(cleanup);
|
|
1332
|
+
try {
|
|
1333
|
+
await cleanup;
|
|
1334
|
+
}
|
|
1335
|
+
finally {
|
|
1336
|
+
this.pendingRootCleanups.delete(cleanup);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
observeSessionStart(projectRoot, session) {
|
|
1340
|
+
if (this.shuttingDown || !session)
|
|
1341
|
+
return;
|
|
1342
|
+
const key = identityKey({
|
|
1343
|
+
project_root: canonicalizeProjectRoot(projectRoot),
|
|
1344
|
+
harness: this.harness,
|
|
1345
|
+
session,
|
|
1346
|
+
});
|
|
1347
|
+
const record = this.sessions.get(key);
|
|
1348
|
+
if (record) {
|
|
1349
|
+
record.observedFromStart = true;
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (this.observedStarts.size >= 256)
|
|
1353
|
+
this.observedStarts.delete(this.observedStarts.values().next().value);
|
|
1354
|
+
this.observedStarts.add(key);
|
|
1355
|
+
}
|
|
1356
|
+
__pendingSessionStartsForTests() {
|
|
1357
|
+
return this.observedStarts.size;
|
|
1358
|
+
}
|
|
1359
|
+
observeBackgroundReply(record, body, reply) {
|
|
1360
|
+
const name = String(body.name);
|
|
1361
|
+
const args = isRecord(body.arguments) ? body.arguments : {};
|
|
1362
|
+
const data = isRecord(reply) && isRecord(reply.structuredContent) ? reply.structuredContent : null;
|
|
1363
|
+
const affectsBackground = name === "bash" ||
|
|
1364
|
+
name === "powershell" ||
|
|
1365
|
+
[
|
|
1366
|
+
"bash_status",
|
|
1367
|
+
"bash_kill",
|
|
1368
|
+
"bash_write",
|
|
1369
|
+
"bash_promote",
|
|
1370
|
+
"bash_watch",
|
|
1371
|
+
"bash_notify",
|
|
1372
|
+
"bash_unnotify",
|
|
1373
|
+
"bash_wait_detach",
|
|
1374
|
+
"bash_drain_completions",
|
|
1375
|
+
"bash_ack_completions",
|
|
1376
|
+
].includes(name);
|
|
1377
|
+
if (!data || data.success !== true) {
|
|
1378
|
+
if (affectsBackground) {
|
|
1379
|
+
if (typeof args.task_id === "string") {
|
|
1380
|
+
if (!record.backgroundTasks.has(args.task_id))
|
|
1381
|
+
record.backgroundTasks.set(args.task_id, "unknown");
|
|
1382
|
+
}
|
|
1383
|
+
else
|
|
1384
|
+
record.unknownBackgroundWork = true;
|
|
1385
|
+
}
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
if (typeof data.task_id === "string") {
|
|
1389
|
+
// Terminal tasks remain pinned until the daemon explicitly ACKs them.
|
|
1390
|
+
const terminal = ["completed", "failed", "killed", "timed_out"].includes(String(data.status));
|
|
1391
|
+
const running = ["starting", "running", "killing"].includes(String(data.status));
|
|
1392
|
+
record.backgroundTasks.set(data.task_id, terminal ? "terminal" : running ? "running" : "unknown");
|
|
1393
|
+
if (name === "bash" || name === "powershell")
|
|
1394
|
+
record.pendingDetach = false;
|
|
1395
|
+
}
|
|
1396
|
+
if (Array.isArray(data.bg_completions)) {
|
|
1397
|
+
for (const completion of data.bg_completions) {
|
|
1398
|
+
if (isRecord(completion) && typeof completion.task_id === "string")
|
|
1399
|
+
record.backgroundTasks.set(completion.task_id, "terminal");
|
|
1400
|
+
else
|
|
1401
|
+
record.unknownBackgroundWork = true;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (Array.isArray(data.pending_matches)) {
|
|
1405
|
+
for (const match of data.pending_matches) {
|
|
1406
|
+
if (isRecord(match) &&
|
|
1407
|
+
typeof match.watch_id === "string" &&
|
|
1408
|
+
typeof match.task_id === "string")
|
|
1409
|
+
record.backgroundWatches.set(match.watch_id, match.task_id);
|
|
1410
|
+
else
|
|
1411
|
+
record.unknownBackgroundWork = true;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (name === "bash_drain_completions" &&
|
|
1415
|
+
Array.isArray(data.bg_completions) &&
|
|
1416
|
+
Array.isArray(data.pending_matches))
|
|
1417
|
+
record.pendingBgNudge = false;
|
|
1418
|
+
if (name === "bash_notify") {
|
|
1419
|
+
if (typeof data.watch_id === "string" && typeof args.task_id === "string")
|
|
1420
|
+
record.backgroundWatches.set(data.watch_id, args.task_id);
|
|
1421
|
+
else
|
|
1422
|
+
record.unknownBackgroundWork = true;
|
|
1423
|
+
}
|
|
1424
|
+
if (name === "bash_unnotify" && data.unregistered === true && typeof args.watch_id === "string")
|
|
1425
|
+
record.backgroundWatches.delete(args.watch_id);
|
|
1426
|
+
if (name === "bash_ack_completions" && Array.isArray(data.acked_task_ids)) {
|
|
1427
|
+
// ACK also acknowledges mid-run pattern matches. It does not prove a
|
|
1428
|
+
// running/unknown task is terminal, so those must keep their carrier.
|
|
1429
|
+
for (const task of data.acked_task_ids)
|
|
1430
|
+
if (typeof task === "string" && record.backgroundTasks.get(task) === "terminal") {
|
|
1431
|
+
record.backgroundTasks.delete(task);
|
|
1432
|
+
// The daemon's terminal ACK also removes every watch on that task.
|
|
1433
|
+
for (const [watch, owner] of record.backgroundWatches) {
|
|
1434
|
+
if (owner === task)
|
|
1435
|
+
record.backgroundWatches.delete(watch);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (name === "bash_wait_detach") {
|
|
1440
|
+
if (typeof data.detached === "boolean")
|
|
1441
|
+
record.pendingDetach = data.detached;
|
|
1442
|
+
else
|
|
1443
|
+
record.unknownBackgroundWork = true;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
__retainedSessionCountsForTests() {
|
|
1447
|
+
return {
|
|
1448
|
+
sessions: this.sessions.size,
|
|
1449
|
+
roots: this.rootIndex.size,
|
|
1450
|
+
routes: [...this.sessions.values()].filter((r) => r.routeEntry !== null).length,
|
|
1451
|
+
subscriptions: [...this.sessions.values()].filter((r) => r.bgSub !== null).length,
|
|
1452
|
+
sweepTimers: this.sessionSweep ? 1 : 0,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1175
1455
|
isCurrentSession(key, record) {
|
|
1176
1456
|
return this.sessions.get(key) === record && !record.closed;
|
|
1177
1457
|
}
|
|
@@ -1216,6 +1496,12 @@ export class SubcTransportPool {
|
|
|
1216
1496
|
if (this.sessions.get(record.identityKey) === record &&
|
|
1217
1497
|
!record.closed &&
|
|
1218
1498
|
record.inflight === 0 &&
|
|
1499
|
+
record.retiredRoutes.size === 0 &&
|
|
1500
|
+
record.backgroundTasks.size === 0 &&
|
|
1501
|
+
record.backgroundWatches.size === 0 &&
|
|
1502
|
+
!record.unknownBackgroundWork &&
|
|
1503
|
+
!record.pendingBgNudge &&
|
|
1504
|
+
!record.pendingDetach &&
|
|
1219
1505
|
record.routeEntry === null &&
|
|
1220
1506
|
record.bgSub === null) {
|
|
1221
1507
|
this.sessions.delete(record.identityKey);
|
|
@@ -1234,6 +1520,10 @@ export class SubcTransportPool {
|
|
|
1234
1520
|
record.teardownReason = reason;
|
|
1235
1521
|
this.sessions.delete(key);
|
|
1236
1522
|
this.removeIndexMembership(record);
|
|
1523
|
+
for (const logKey of this.nudgeDeliveryLogState.keys()) {
|
|
1524
|
+
if (logKey.endsWith(`\u0000${key}`))
|
|
1525
|
+
this.nudgeDeliveryLogState.delete(logKey);
|
|
1526
|
+
}
|
|
1237
1527
|
const bgSub = record.bgSub;
|
|
1238
1528
|
record.bgSub = null;
|
|
1239
1529
|
const routeEntry = record.routeEntry;
|
|
@@ -1246,14 +1536,19 @@ export class SubcTransportPool {
|
|
|
1246
1536
|
const cleanup = [];
|
|
1247
1537
|
if (detached.bgSub)
|
|
1248
1538
|
cleanup.push(detached.bgSub.stop());
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
if (routeEntry
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1539
|
+
const entries = new Set(detached.record.retiredRoutes);
|
|
1540
|
+
detached.record.retiredRoutes.clear();
|
|
1541
|
+
if (detached.routeEntry)
|
|
1542
|
+
entries.add(detached.routeEntry);
|
|
1543
|
+
for (const entry of entries) {
|
|
1544
|
+
entry.closed = true;
|
|
1545
|
+
if (entry.handle != null) {
|
|
1546
|
+
try {
|
|
1547
|
+
cleanup.push(Promise.resolve(entry.client.closeRouteChannel(entry.handle)).catch(() => undefined));
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
// A client may throw synchronously when its socket is already closed.
|
|
1551
|
+
}
|
|
1257
1552
|
}
|
|
1258
1553
|
}
|
|
1259
1554
|
await Promise.allSettled(cleanup);
|
|
@@ -1283,9 +1578,25 @@ export class SubcTransportPool {
|
|
|
1283
1578
|
}
|
|
1284
1579
|
return this.rootReapedError(record);
|
|
1285
1580
|
}
|
|
1581
|
+
closeRetiredRoute(record, entry) {
|
|
1582
|
+
if (entry.inflight > 0 || !record.retiredRoutes.delete(entry))
|
|
1583
|
+
return;
|
|
1584
|
+
if (entry.handle != null)
|
|
1585
|
+
safeCloseRoute(entry.client, entry.handle);
|
|
1586
|
+
this.deleteSessionIfEmpty(record.identityKey, record);
|
|
1587
|
+
}
|
|
1588
|
+
releaseRouteRequest(record, entry) {
|
|
1589
|
+
entry.inflight -= 1;
|
|
1590
|
+
this.closeRetiredRoute(record, entry);
|
|
1591
|
+
}
|
|
1286
1592
|
/** Open or reuse a route while guarding every lifecycle boundary. */
|
|
1287
1593
|
async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration, abortSignal) {
|
|
1288
1594
|
const callStartedAt = performance.now();
|
|
1595
|
+
const callDeadlineMs = timeoutMs ?? SUBC_DEFAULT_REQUEST_TIMEOUT_MS;
|
|
1596
|
+
// Charge shared backoff delays even when an injected sleeper advances no real time.
|
|
1597
|
+
let scheduledDelayMs = 0;
|
|
1598
|
+
const spentMs = () => Math.max(performance.now() - callStartedAt, scheduledDelayMs);
|
|
1599
|
+
const remainingMs = () => callDeadlineMs - spentMs();
|
|
1289
1600
|
const root = asCanonicalRootPath(identity.project_root);
|
|
1290
1601
|
let generation = expectedGeneration;
|
|
1291
1602
|
if (this.lifecycleEnabled()) {
|
|
@@ -1301,7 +1612,54 @@ export class SubcTransportPool {
|
|
|
1301
1612
|
try {
|
|
1302
1613
|
let client;
|
|
1303
1614
|
try {
|
|
1304
|
-
|
|
1615
|
+
let keyChanged;
|
|
1616
|
+
while (true) {
|
|
1617
|
+
const remaining = remainingMs();
|
|
1618
|
+
if (remaining <= 0)
|
|
1619
|
+
throw keyChanged ?? callDeadlinePassedError(undefined, callDeadlineMs);
|
|
1620
|
+
let timer;
|
|
1621
|
+
try {
|
|
1622
|
+
// Each attempt calls SubcClient.connect anew, which re-reads the connection file.
|
|
1623
|
+
const acquired = await Promise.race([
|
|
1624
|
+
this.ensureClient(),
|
|
1625
|
+
new Promise((resolve) => {
|
|
1626
|
+
timer = setTimeout(() => resolve(CALL_DEADLINE_EXPIRED), remaining);
|
|
1627
|
+
}),
|
|
1628
|
+
]);
|
|
1629
|
+
if (acquired === CALL_DEADLINE_EXPIRED) {
|
|
1630
|
+
throw keyChanged ?? callDeadlinePassedError(undefined, callDeadlineMs);
|
|
1631
|
+
}
|
|
1632
|
+
client = acquired;
|
|
1633
|
+
break;
|
|
1634
|
+
}
|
|
1635
|
+
catch (error) {
|
|
1636
|
+
if (!(error instanceof ReconnectKeyChangedError))
|
|
1637
|
+
throw error;
|
|
1638
|
+
keyChanged = error;
|
|
1639
|
+
if (spentMs() + this.nextRouteReopenDelayMs() >= callDeadlineMs)
|
|
1640
|
+
throw error;
|
|
1641
|
+
}
|
|
1642
|
+
finally {
|
|
1643
|
+
clearTimeout(timer);
|
|
1644
|
+
}
|
|
1645
|
+
const { delayMs, wait } = this.waitForRouteReopenBackoff();
|
|
1646
|
+
scheduledDelayMs = spentMs() + delayMs;
|
|
1647
|
+
// A shared retry sleeper may outlive this caller's remaining budget.
|
|
1648
|
+
let retryTimer;
|
|
1649
|
+
try {
|
|
1650
|
+
const waited = await Promise.race([
|
|
1651
|
+
wait,
|
|
1652
|
+
new Promise((resolve) => {
|
|
1653
|
+
retryTimer = setTimeout(() => resolve(CALL_DEADLINE_EXPIRED), callDeadlineMs - (performance.now() - callStartedAt));
|
|
1654
|
+
}),
|
|
1655
|
+
]);
|
|
1656
|
+
if (waited === CALL_DEADLINE_EXPIRED)
|
|
1657
|
+
throw keyChanged;
|
|
1658
|
+
}
|
|
1659
|
+
finally {
|
|
1660
|
+
clearTimeout(retryTimer);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1305
1663
|
this.assertRecordLive(record);
|
|
1306
1664
|
}
|
|
1307
1665
|
catch (error) {
|
|
@@ -1351,18 +1709,10 @@ export class SubcTransportPool {
|
|
|
1351
1709
|
// (a bind relay can hold one for about 12 s), retry sleeps, and the
|
|
1352
1710
|
// request itself, which is sent with only the time that is left. So the
|
|
1353
1711
|
// call never outlives the deadline it was given, whichever path it takes.
|
|
1354
|
-
const callDeadlineMs = timeoutMs ?? SUBC_DEFAULT_REQUEST_TIMEOUT_MS;
|
|
1355
1712
|
const reloadWaitBudgetMs = Math.min(callDeadlineMs, ROUTE_OPEN_RELOAD_WAIT_CEILING_MS);
|
|
1356
|
-
// Retry delays this call has been charged for. Spent time never counts
|
|
1357
|
-
// less than these: a caller that joins a shared retry timer part-way is
|
|
1358
|
-
// still charged its full delay, as it always was, and an injected test
|
|
1359
|
-
// sleeper may finish a delay without real time passing.
|
|
1360
|
-
let scheduledDelayMs = 0;
|
|
1361
1713
|
// The most recent route refusal, so a deadline that passes mid-open still
|
|
1362
1714
|
// names the daemon's reason instead of a bare timeout.
|
|
1363
1715
|
let lastRefusal;
|
|
1364
|
-
const spentMs = () => Math.max(performance.now() - callStartedAt, scheduledDelayMs);
|
|
1365
|
-
const remainingMs = () => callDeadlineMs - spentMs();
|
|
1366
1716
|
// subc-client's routeOpen takes no deadline of its own (only its fixed
|
|
1367
1717
|
// channel-0 default), so the call stops waiting for the open once its
|
|
1368
1718
|
// own time is up. An open that completes later still lands in the
|
|
@@ -1411,9 +1761,10 @@ export class SubcTransportPool {
|
|
|
1411
1761
|
if (record.routeEntry !== entry)
|
|
1412
1762
|
return;
|
|
1413
1763
|
entry.closed = true;
|
|
1764
|
+
entry.closedBy ??= "route replaced";
|
|
1414
1765
|
record.routeEntry = null;
|
|
1415
|
-
|
|
1416
|
-
|
|
1766
|
+
record.retiredRoutes.add(entry);
|
|
1767
|
+
this.closeRetiredRoute(record, entry);
|
|
1417
1768
|
};
|
|
1418
1769
|
const handleRequestFailure = (error, entry) => {
|
|
1419
1770
|
const aborted = error instanceof Error && error.name === "AbortError";
|
|
@@ -1429,20 +1780,30 @@ export class SubcTransportPool {
|
|
|
1429
1780
|
route: routeLabel(entry.handle),
|
|
1430
1781
|
});
|
|
1431
1782
|
}
|
|
1432
|
-
|
|
1783
|
+
// A call's timeout or refusal says nothing about the route's health.
|
|
1784
|
+
// Retire only a cancelled carrier or one known to be gone; a slow live
|
|
1785
|
+
// route remains reusable, and no replacement may cut its other calls.
|
|
1786
|
+
if (aborted ||
|
|
1787
|
+
isRouteProvenAbsentError(error) ||
|
|
1788
|
+
(error instanceof SubcError && error.closeReason !== undefined)) {
|
|
1789
|
+
clearRouteEntry(entry);
|
|
1790
|
+
}
|
|
1433
1791
|
if (aborted || !ownsClient || this.client !== client)
|
|
1434
1792
|
return;
|
|
1435
1793
|
if (isRouteProvenAbsentError(error))
|
|
1436
1794
|
return;
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1795
|
+
if (isDaemonAnswerError(error)) {
|
|
1796
|
+
this.transportFailures = 0;
|
|
1797
|
+
this.noteClientAlive(client);
|
|
1798
|
+
}
|
|
1799
|
+
else if (isUnansweredCallError(error)) {
|
|
1800
|
+
this.noteUnansweredCall(client, {
|
|
1801
|
+
error,
|
|
1802
|
+
trigger: "request",
|
|
1803
|
+
session: identity.session,
|
|
1804
|
+
route: routeLabel(entry.handle),
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1446
1807
|
};
|
|
1447
1808
|
const requestOnRoute = async (route, entry, requestTimeoutMs) => {
|
|
1448
1809
|
this.assertRecordLive(record);
|
|
@@ -1460,10 +1821,22 @@ export class SubcTransportPool {
|
|
|
1460
1821
|
};
|
|
1461
1822
|
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
1462
1823
|
try {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1824
|
+
// Track the actual client promise, not the abort race. A cancelled
|
|
1825
|
+
// caller has no terminal from the daemon yet, so closing its carrier
|
|
1826
|
+
// here would still cut sibling calls and invalidate request credits.
|
|
1827
|
+
entry.inflight += 1;
|
|
1828
|
+
let request;
|
|
1829
|
+
try {
|
|
1830
|
+
request = client.request(route, body, {
|
|
1831
|
+
timeoutMs: Math.ceil(requestTimeoutMs),
|
|
1832
|
+
onProgress,
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
catch (error) {
|
|
1836
|
+
this.releaseRouteRequest(record, entry);
|
|
1837
|
+
throw error;
|
|
1838
|
+
}
|
|
1839
|
+
request = request.finally(() => this.releaseRouteRequest(record, entry));
|
|
1467
1840
|
const reply = abortSignal ? await Promise.race([request, aborted]) : await request;
|
|
1468
1841
|
// A legacy closeSession may intentionally let an already-delivered reply
|
|
1469
1842
|
// settle. It must not mutate shared state or recreate a subscription.
|
|
@@ -1476,6 +1849,7 @@ export class SubcTransportPool {
|
|
|
1476
1849
|
if (this.client === client)
|
|
1477
1850
|
this.transportFailures = 0;
|
|
1478
1851
|
this.noteClientAlive(client);
|
|
1852
|
+
this.observeBackgroundReply(record, body, reply);
|
|
1479
1853
|
this.ensureBgSubscription(identity, record);
|
|
1480
1854
|
return reply;
|
|
1481
1855
|
}
|
|
@@ -1487,6 +1861,10 @@ export class SubcTransportPool {
|
|
|
1487
1861
|
let reopened = false;
|
|
1488
1862
|
let retriedAbsentRoute = false;
|
|
1489
1863
|
while (true) {
|
|
1864
|
+
// Another call can retire a just-acquired carrier before this caller
|
|
1865
|
+
// dispatches. Only already-pending requests belong on retired routes.
|
|
1866
|
+
if (routeAndEntry.entry.closed)
|
|
1867
|
+
routeAndEntry = await openRouteAfterReloadWindow();
|
|
1490
1868
|
// Checked outside the try below so the retry branches never mistake
|
|
1491
1869
|
// this call's own expiry for a daemon refusal worth retrying.
|
|
1492
1870
|
const requestTimeoutMs = remainingMs();
|
|
@@ -1498,7 +1876,8 @@ export class SubcTransportPool {
|
|
|
1498
1876
|
this.resetRouteReopenBackoff();
|
|
1499
1877
|
return reply;
|
|
1500
1878
|
}
|
|
1501
|
-
catch (
|
|
1879
|
+
catch (requestError) {
|
|
1880
|
+
const error = routeClosedMidCallError(requestError, routeAndEntry.entry, record);
|
|
1502
1881
|
if (this.isReapInduced(record))
|
|
1503
1882
|
throw this.annotateReapError(error, record);
|
|
1504
1883
|
const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
|
|
@@ -1547,10 +1926,14 @@ export class SubcTransportPool {
|
|
|
1547
1926
|
catch (error) {
|
|
1548
1927
|
if (this.isReapInduced(record))
|
|
1549
1928
|
throw this.annotateReapError(error, record);
|
|
1929
|
+
this.observeBackgroundReply(record, body, null);
|
|
1930
|
+
if (record.backgroundTasks.size > 0 || record.unknownBackgroundWork)
|
|
1931
|
+
this.ensureBgSubscription(identity, record);
|
|
1550
1932
|
throw error;
|
|
1551
1933
|
}
|
|
1552
1934
|
finally {
|
|
1553
1935
|
record.inflight -= 1;
|
|
1936
|
+
record.lastUsedAt = Date.now();
|
|
1554
1937
|
this.deleteSessionIfEmpty(key, record);
|
|
1555
1938
|
}
|
|
1556
1939
|
}
|
|
@@ -1611,11 +1994,18 @@ export class SubcTransportPool {
|
|
|
1611
1994
|
throw new SubcTransportShuttingDownError();
|
|
1612
1995
|
}
|
|
1613
1996
|
this.client = client;
|
|
1997
|
+
this.hadWorkingClient = true;
|
|
1614
1998
|
this.transportFailures = 0;
|
|
1615
1999
|
return client;
|
|
1616
2000
|
})
|
|
1617
2001
|
.catch((error) => {
|
|
1618
2002
|
this.connecting = null;
|
|
2003
|
+
// A previously authenticated transport can race the daemon's key rotation.
|
|
2004
|
+
// Without a prior authenticated client, a proof mismatch can mean an impostor
|
|
2005
|
+
// or misconfiguration, so preserve the SDK's permanent authentication error.
|
|
2006
|
+
if (this.hadWorkingClient && error instanceof AuthError) {
|
|
2007
|
+
throw new ReconnectKeyChangedError();
|
|
2008
|
+
}
|
|
1619
2009
|
throw error;
|
|
1620
2010
|
});
|
|
1621
2011
|
return this.connecting;
|
|
@@ -1633,6 +2023,8 @@ export class SubcTransportPool {
|
|
|
1633
2023
|
opening: null,
|
|
1634
2024
|
handle: null,
|
|
1635
2025
|
closed: false,
|
|
2026
|
+
inflight: 0,
|
|
2027
|
+
closedBy: null,
|
|
1636
2028
|
};
|
|
1637
2029
|
this.assertRootCanAttach(record.canonicalRoot);
|
|
1638
2030
|
const opening = client
|
|
@@ -1680,12 +2072,12 @@ export class SubcTransportPool {
|
|
|
1680
2072
|
return;
|
|
1681
2073
|
if (!this.isCurrentSession(record.identityKey, record))
|
|
1682
2074
|
return;
|
|
1683
|
-
if (!this.rootCanAttach(record.canonicalRoot))
|
|
1684
|
-
return;
|
|
1685
2075
|
if (record.bgSub)
|
|
1686
2076
|
return;
|
|
2077
|
+
if (!this.rootCanAttach(record.canonicalRoot))
|
|
2078
|
+
return;
|
|
1687
2079
|
const nudgeRef = this.nudgeRefFor(record);
|
|
1688
|
-
const onNudge = () => {
|
|
2080
|
+
const onNudge = (observedEvent) => {
|
|
1689
2081
|
const currentRecord = this.currentSessionForNudge(identity);
|
|
1690
2082
|
if (!currentRecord) {
|
|
1691
2083
|
this.logNudgeDelivery("drop-no-current-session", record, `nudge dropped cause=no-current-session root=${record.canonicalRoot}`);
|
|
@@ -1694,6 +2086,9 @@ export class SubcTransportPool {
|
|
|
1694
2086
|
if (currentRecord !== record) {
|
|
1695
2087
|
this.logNudgeDelivery("forward-superseded-carrier", currentRecord, `nudge forwarding cause=superseded-carrying-record root=${currentRecord.canonicalRoot}`);
|
|
1696
2088
|
}
|
|
2089
|
+
currentRecord.lastUsedAt = Date.now();
|
|
2090
|
+
if (observedEvent)
|
|
2091
|
+
currentRecord.pendingBgNudge = true;
|
|
1697
2092
|
const currentRef = this.nudgeRefFor(currentRecord);
|
|
1698
2093
|
let delivered = false;
|
|
1699
2094
|
if (currentRef && this.onBgEventsNudgeRef) {
|
|
@@ -1758,6 +2153,12 @@ export class SubcTransportPool {
|
|
|
1758
2153
|
` message=${JSON.stringify(message)}`);
|
|
1759
2154
|
this.client = null;
|
|
1760
2155
|
for (const record of this.sessions.values()) {
|
|
2156
|
+
for (const retired of record.retiredRoutes) {
|
|
2157
|
+
if (retired.client !== client)
|
|
2158
|
+
continue;
|
|
2159
|
+
retired.closed = true;
|
|
2160
|
+
record.retiredRoutes.delete(retired);
|
|
2161
|
+
}
|
|
1761
2162
|
const entry = record.routeEntry;
|
|
1762
2163
|
if (entry?.client !== client)
|
|
1763
2164
|
continue;
|
|
@@ -1929,6 +2330,12 @@ export class SubcTransportPool {
|
|
|
1929
2330
|
if (this.shuttingDown)
|
|
1930
2331
|
return;
|
|
1931
2332
|
this.shuttingDown = true;
|
|
2333
|
+
if (this.sessionSweep)
|
|
2334
|
+
clearInterval(this.sessionSweep);
|
|
2335
|
+
this.sessionSweep = null;
|
|
2336
|
+
this.nudgeDeliveryLogState.clear();
|
|
2337
|
+
this.generationRejections.clear();
|
|
2338
|
+
this.observedStarts.clear();
|
|
1932
2339
|
this.dormantRoots.clear();
|
|
1933
2340
|
// Deregistration synchronously tombstones all registered roots and invokes
|
|
1934
2341
|
// closeProjectRoot before this method reaches its first await.
|