@cortexkit/aft-bridge 0.49.3 → 0.49.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/cache-paths.d.ts +26 -0
- package/dist/cache-paths.d.ts.map +1 -0
- package/dist/cache-paths.js +37 -0
- package/dist/cache-paths.js.map +1 -0
- package/dist/downloader.d.ts +2 -3
- package/dist/downloader.d.ts.map +1 -1
- package/dist/downloader.js +5 -13
- package/dist/downloader.js.map +1 -1
- package/dist/durable-log.d.ts +3 -1
- package/dist/durable-log.d.ts.map +1 -1
- package/dist/durable-log.js +4 -2
- package/dist/durable-log.js.map +1 -1
- package/dist/error-contract.d.ts +7 -0
- package/dist/error-contract.d.ts.map +1 -1
- package/dist/error-contract.js +35 -0
- package/dist/error-contract.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +196 -0
- package/dist/lifecycle-registry.d.ts.map +1 -0
- package/dist/lifecycle-registry.js +471 -0
- package/dist/lifecycle-registry.js.map +1 -0
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +4 -11
- package/dist/resolver.js.map +1 -1
- package/dist/revivable-transport.d.ts +10 -0
- package/dist/revivable-transport.d.ts.map +1 -1
- package/dist/revivable-transport.js +23 -0
- package/dist/revivable-transport.js.map +1 -1
- package/dist/subc-transport.d.ts +202 -42
- package/dist/subc-transport.d.ts.map +1 -1
- package/dist/subc-transport.js +810 -271
- package/dist/subc-transport.js.map +1 -1
- package/dist/transport-factory.d.ts +19 -0
- package/dist/transport-factory.d.ts.map +1 -1
- package/dist/transport-factory.js +71 -2
- package/dist/transport-factory.js.map +1 -1
- package/package.json +1 -1
package/dist/subc-transport.js
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
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 { existsSync, statSync } from "node:fs";
|
|
19
20
|
import { connectionFileExists, isConsumerReconnectTransient, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
|
|
21
|
+
import { log } from "./active-logger.js";
|
|
22
|
+
import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
|
|
20
23
|
import { canonicalizeProjectRoot } from "./project-identity.js";
|
|
21
24
|
import { parseStatusBarCounts } from "./status-bar.js";
|
|
22
25
|
/** The subc module id AFT registers under (matches the daemon manifest). */
|
|
@@ -51,6 +54,52 @@ const DEFAULT_SESSION_ID = "__default__";
|
|
|
51
54
|
* with a synthetic success rather than a route call.
|
|
52
55
|
*/
|
|
53
56
|
const LOCALLY_SATISFIED_COMMANDS = new Set(["configure"]);
|
|
57
|
+
/** Stable classification for a request that loses its root to coordinated reap. */
|
|
58
|
+
export class SubcRootReapedError extends Error {
|
|
59
|
+
code = "root_reaped";
|
|
60
|
+
name = "SubcRootReapedError";
|
|
61
|
+
canonicalRoot;
|
|
62
|
+
expectedGeneration;
|
|
63
|
+
currentGeneration;
|
|
64
|
+
concretePoolId;
|
|
65
|
+
currentConcretePoolId;
|
|
66
|
+
constructor(fields) {
|
|
67
|
+
super(`subc root was reaped: ${fields.canonicalRoot}`);
|
|
68
|
+
this.canonicalRoot = fields.canonicalRoot;
|
|
69
|
+
this.expectedGeneration = fields.expectedGeneration;
|
|
70
|
+
this.currentGeneration = fields.currentGeneration;
|
|
71
|
+
this.concretePoolId = fields.concretePoolId;
|
|
72
|
+
this.currentConcretePoolId = fields.currentConcretePoolId;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Stable classification for an operation holding an older root generation. */
|
|
76
|
+
export class SubcRootGenerationExpiredError extends Error {
|
|
77
|
+
code = "root_generation_expired";
|
|
78
|
+
name = "SubcRootGenerationExpiredError";
|
|
79
|
+
canonicalRoot;
|
|
80
|
+
expectedGeneration;
|
|
81
|
+
currentGeneration;
|
|
82
|
+
concretePoolId;
|
|
83
|
+
currentConcretePoolId;
|
|
84
|
+
constructor(fields) {
|
|
85
|
+
super(`subc root generation expired: ${fields.canonicalRoot}`);
|
|
86
|
+
this.canonicalRoot = fields.canonicalRoot;
|
|
87
|
+
this.expectedGeneration = fields.expectedGeneration;
|
|
88
|
+
this.currentGeneration = fields.currentGeneration;
|
|
89
|
+
this.concretePoolId = fields.concretePoolId;
|
|
90
|
+
this.currentConcretePoolId = fields.currentConcretePoolId;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** A synchronous lifecycle demand check could not establish that the root exists. */
|
|
94
|
+
export class SubcRootDemandRequiredError extends Error {
|
|
95
|
+
code = "root_demand_required";
|
|
96
|
+
canonicalRoot;
|
|
97
|
+
constructor(root) {
|
|
98
|
+
super(`positive live demand is required before creating subc root state: ${root}`);
|
|
99
|
+
this.name = "SubcRootDemandRequiredError";
|
|
100
|
+
this.canonicalRoot = root;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
54
103
|
function identityKey(identity) {
|
|
55
104
|
return `${identity.project_root}\u0000${identity.harness}\u0000${identity.session}`;
|
|
56
105
|
}
|
|
@@ -66,8 +115,8 @@ function identityKey(identity) {
|
|
|
66
115
|
* it until acked; resubscribe + the immediate forced-drain replay it).
|
|
67
116
|
*
|
|
68
117
|
* The loop is a single sequential async task (only one attempt in flight at a
|
|
69
|
-
* time)
|
|
70
|
-
*
|
|
118
|
+
* time). `stopped`, the captured root-generation check, and one instance per
|
|
119
|
+
* identity prevent duplicate or stale subscriptions.
|
|
71
120
|
*/
|
|
72
121
|
class BgSubscription {
|
|
73
122
|
identity;
|
|
@@ -76,17 +125,27 @@ class BgSubscription {
|
|
|
76
125
|
consumerIdentity;
|
|
77
126
|
onNudge;
|
|
78
127
|
sleep;
|
|
128
|
+
canAttach;
|
|
129
|
+
onRootAttachFailure;
|
|
130
|
+
onDormant;
|
|
131
|
+
nudgeRef;
|
|
132
|
+
isCurrent;
|
|
79
133
|
stopped = false;
|
|
80
134
|
/** The live subscription handle, read by stop() to wake the loop's `await closed`. */
|
|
81
135
|
current = null;
|
|
82
136
|
loop;
|
|
83
|
-
constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep) {
|
|
137
|
+
constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, nudgeRef, isCurrent = () => true) {
|
|
84
138
|
this.identity = identity;
|
|
85
139
|
this.acquireClient = acquireClient;
|
|
86
140
|
this.dropClient = dropClient;
|
|
87
141
|
this.consumerIdentity = consumerIdentity;
|
|
88
142
|
this.onNudge = onNudge;
|
|
89
143
|
this.sleep = sleep;
|
|
144
|
+
this.canAttach = canAttach;
|
|
145
|
+
this.onRootAttachFailure = onRootAttachFailure;
|
|
146
|
+
this.onDormant = onDormant;
|
|
147
|
+
this.nudgeRef = nudgeRef;
|
|
148
|
+
this.isCurrent = isCurrent;
|
|
90
149
|
this.loop = this.run();
|
|
91
150
|
}
|
|
92
151
|
async stop() {
|
|
@@ -110,6 +169,12 @@ class BgSubscription {
|
|
|
110
169
|
async run() {
|
|
111
170
|
let attempt = 0;
|
|
112
171
|
while (!this.stopped) {
|
|
172
|
+
if (!this.isCurrent())
|
|
173
|
+
return;
|
|
174
|
+
if (!this.canAttach()) {
|
|
175
|
+
this.onDormant();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
113
178
|
let client;
|
|
114
179
|
try {
|
|
115
180
|
client = await this.acquireClient();
|
|
@@ -118,8 +183,12 @@ class BgSubscription {
|
|
|
118
183
|
await this.backoff(attempt++);
|
|
119
184
|
continue;
|
|
120
185
|
}
|
|
121
|
-
if (this.stopped)
|
|
186
|
+
if (this.stopped || !this.isCurrent())
|
|
187
|
+
return;
|
|
188
|
+
if (!this.canAttach()) {
|
|
189
|
+
this.onDormant();
|
|
122
190
|
return;
|
|
191
|
+
}
|
|
123
192
|
let route;
|
|
124
193
|
try {
|
|
125
194
|
// A second dedicated route isolates bg_events from the tool route's
|
|
@@ -127,6 +196,10 @@ class BgSubscription {
|
|
|
127
196
|
route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, this.identity, { consumerIdentity: this.consumerIdentity });
|
|
128
197
|
}
|
|
129
198
|
catch (err) {
|
|
199
|
+
if (this.isCurrent() && this.onRootAttachFailure(err)) {
|
|
200
|
+
this.onDormant();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
130
203
|
// routeOpen failed. If it signals a dead CONNECTION, drop the shared
|
|
131
204
|
// client so the next `acquireClient` reconnects fresh — the idle-stranding
|
|
132
205
|
// fix (B-#1): `acquireClient` returns the cached client, so without this an
|
|
@@ -136,7 +209,7 @@ class BgSubscription {
|
|
|
136
209
|
await this.backoff(attempt++);
|
|
137
210
|
continue;
|
|
138
211
|
}
|
|
139
|
-
if (this.stopped) {
|
|
212
|
+
if (this.stopped || !this.isCurrent()) {
|
|
140
213
|
safeCloseRoute(client, route);
|
|
141
214
|
return;
|
|
142
215
|
}
|
|
@@ -146,7 +219,7 @@ class BgSubscription {
|
|
|
146
219
|
const subscribedAt = Date.now();
|
|
147
220
|
try {
|
|
148
221
|
const sub = client.subscribe(route, { op: "bg_events" }, () => {
|
|
149
|
-
if (!this.stopped)
|
|
222
|
+
if (!this.stopped && this.isCurrent())
|
|
150
223
|
this.onNudge();
|
|
151
224
|
});
|
|
152
225
|
this.current = sub;
|
|
@@ -157,7 +230,7 @@ class BgSubscription {
|
|
|
157
230
|
sub.unsubscribe();
|
|
158
231
|
// Immediate forced-drain replay: a completion that landed while we were
|
|
159
232
|
// disconnected is recovered now (resubscribe == the outbox replay trigger).
|
|
160
|
-
if (!this.stopped)
|
|
233
|
+
if (!this.stopped && this.isCurrent())
|
|
161
234
|
this.onNudge();
|
|
162
235
|
await sub.closed;
|
|
163
236
|
// StreamEnd = an intentional close (our unsubscribe or module teardown).
|
|
@@ -198,6 +271,20 @@ function isRouteProvenAbsentError(err) {
|
|
|
198
271
|
return ((err instanceof SubcError && err.code === "unknown_channel") ||
|
|
199
272
|
err instanceof StaleRouteHandleError);
|
|
200
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* A daemon can reject a route bind after the root has been reclaimed. This
|
|
276
|
+
* signature is deliberately narrower than config_divergence: other divergence
|
|
277
|
+
* causes remain transient from the transport's point of view.
|
|
278
|
+
*/
|
|
279
|
+
function isAbsentRootRouteError(err) {
|
|
280
|
+
return (isRecord(err) &&
|
|
281
|
+
err.code === "config_divergence" &&
|
|
282
|
+
typeof err.message === "string" &&
|
|
283
|
+
err.message.includes("project root does not exist"));
|
|
284
|
+
}
|
|
285
|
+
function absentRootError(root) {
|
|
286
|
+
return new SubcError(`invalid route project root: project root does not exist: ${root}`, "config_divergence");
|
|
287
|
+
}
|
|
201
288
|
/**
|
|
202
289
|
* Fire-and-forget route close that can never throw — neither synchronously (a
|
|
203
290
|
* client that rejects/throws when closing a route on an already-dead socket) nor
|
|
@@ -227,20 +314,33 @@ class RouteTornDownError extends Error {
|
|
|
227
314
|
* `structuredContent`, so re-lifting it makes everything downstream (status_bar,
|
|
228
315
|
* bg_completions, preview_diff, code, …) byte-identical to NDJSON.
|
|
229
316
|
*
|
|
230
|
-
* Every AFT tool reply over subc carries this envelope with a boolean `success
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
317
|
+
* Every AFT tool reply over subc carries this envelope with a boolean `success`.
|
|
318
|
+
* During the wire transition, `structuredContent.text` may be omitted; the bridge
|
|
319
|
+
* synthesizes it only from exactly one outer text block, while a present field wins
|
|
320
|
+
* unchanged. A reply missing the envelope, lacking boolean `success`, or missing
|
|
321
|
+
* text without an unambiguous outer block is a PROTOCOL VIOLATION — never a tool
|
|
322
|
+
* result — and is thrown rather than coerced. Coercing it (the old
|
|
323
|
+
* `{success:false,text:""}` / raw-record fallback) could let a malformed reply with
|
|
324
|
+
* `success === undefined` read downstream as a successful tool result (audit B-#7).
|
|
325
|
+
* Surfacing it loudly is the honest contract: a broken wire shape is a failure, not
|
|
326
|
+
* a silent empty pass.
|
|
237
327
|
*/
|
|
238
328
|
function reliftReply(reply) {
|
|
239
329
|
if (!isRecord(reply) || !isRecord(reply.structuredContent)) {
|
|
240
330
|
throw new Error("subc tool reply is missing the structuredContent envelope (protocol violation)");
|
|
241
331
|
}
|
|
242
332
|
const flat = reply.structuredContent;
|
|
243
|
-
if (typeof flat.success !== "boolean"
|
|
333
|
+
if (typeof flat.success !== "boolean") {
|
|
334
|
+
throw new Error("subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)");
|
|
335
|
+
}
|
|
336
|
+
if (!Object.hasOwn(flat, "text")) {
|
|
337
|
+
const content = reply.content;
|
|
338
|
+
const block = Array.isArray(content) && content.length === 1 ? content[0] : undefined;
|
|
339
|
+
if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
|
|
340
|
+
return { ...flat, text: block.text };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (typeof flat.text !== "string") {
|
|
244
344
|
throw new Error("subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)");
|
|
245
345
|
}
|
|
246
346
|
return flat;
|
|
@@ -253,15 +353,24 @@ function reliftReply(reply) {
|
|
|
253
353
|
class SubcTransport {
|
|
254
354
|
pool;
|
|
255
355
|
projectRoot;
|
|
356
|
+
generation;
|
|
256
357
|
lastStatusBar;
|
|
257
358
|
cachedStatus = null;
|
|
258
|
-
constructor(pool, projectRoot) {
|
|
359
|
+
constructor(pool, projectRoot, generation) {
|
|
259
360
|
this.pool = pool;
|
|
260
361
|
this.projectRoot = projectRoot;
|
|
362
|
+
this.generation = generation;
|
|
261
363
|
}
|
|
262
364
|
getCwd() {
|
|
263
365
|
return this.projectRoot;
|
|
264
366
|
}
|
|
367
|
+
/** Generation provenance is intentionally observable for lifecycle tests and nudge wiring. */
|
|
368
|
+
getGeneration() {
|
|
369
|
+
return this.generation;
|
|
370
|
+
}
|
|
371
|
+
getConcretePoolId() {
|
|
372
|
+
return this.pool.getConcretePoolId();
|
|
373
|
+
}
|
|
265
374
|
getStatusBar() {
|
|
266
375
|
return this.lastStatusBar;
|
|
267
376
|
}
|
|
@@ -283,12 +392,16 @@ class SubcTransport {
|
|
|
283
392
|
session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
|
|
284
393
|
};
|
|
285
394
|
}
|
|
395
|
+
assertCurrent() {
|
|
396
|
+
this.pool.assertFacadeCurrent(this.projectRoot, this.generation, "facade_use");
|
|
397
|
+
}
|
|
286
398
|
async toolCall(sessionId, name, rawArgs = {}, options) {
|
|
399
|
+
this.assertCurrent();
|
|
287
400
|
const { preview, timeoutMs, onProgress } = this.splitOptions(options);
|
|
288
401
|
const body = { name, arguments: rawArgs };
|
|
289
402
|
if (preview === true)
|
|
290
403
|
body.preview = true;
|
|
291
|
-
const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress);
|
|
404
|
+
const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation);
|
|
292
405
|
const result = reliftReply(reply);
|
|
293
406
|
this.captureStatusBar(result);
|
|
294
407
|
return result;
|
|
@@ -304,12 +417,13 @@ class SubcTransport {
|
|
|
304
417
|
* what scopes it. `configure` is satisfied locally (binding is the configure).
|
|
305
418
|
*/
|
|
306
419
|
async send(command, params = {}, options) {
|
|
420
|
+
this.assertCurrent();
|
|
307
421
|
if (LOCALLY_SATISFIED_COMMANDS.has(command)) {
|
|
308
422
|
return { success: true, command, subc_local: true };
|
|
309
423
|
}
|
|
310
424
|
const { timeoutMs, onProgress } = this.splitOptions(options);
|
|
311
425
|
const session = typeof params.session_id === "string" ? params.session_id : undefined;
|
|
312
|
-
const reply = await this.pool.routeRequest(this.identityFor(session), { name: command, arguments: params }, timeoutMs, onProgress);
|
|
426
|
+
const reply = await this.pool.routeRequest(this.identityFor(session), { name: command, arguments: params }, timeoutMs, onProgress, this.generation);
|
|
313
427
|
const response = reliftReply(reply);
|
|
314
428
|
this.captureStatusBar(response);
|
|
315
429
|
return response;
|
|
@@ -328,9 +442,9 @@ class SubcTransport {
|
|
|
328
442
|
}
|
|
329
443
|
}
|
|
330
444
|
/**
|
|
331
|
-
* Route cache over one authenticated subc client.
|
|
332
|
-
*
|
|
333
|
-
*
|
|
445
|
+
* Route cache over one authenticated subc client. In lifecycle mode this class
|
|
446
|
+
* also owns the concrete pool's root index; the registry owns timer and root
|
|
447
|
+
* transition state, while this class owns sessions, routes, and subscriptions.
|
|
334
448
|
*/
|
|
335
449
|
export class SubcTransportPool {
|
|
336
450
|
harness;
|
|
@@ -339,21 +453,33 @@ export class SubcTransportPool {
|
|
|
339
453
|
consumerIdentity;
|
|
340
454
|
connectFn;
|
|
341
455
|
onBgEventsNudge;
|
|
456
|
+
onBgEventsNudgeRef;
|
|
342
457
|
bgBackoffSleep;
|
|
458
|
+
lifecycleDemandCheck;
|
|
459
|
+
onLifecycleEvent;
|
|
460
|
+
onBgNudgeRejected;
|
|
461
|
+
lifecycleRegistry;
|
|
462
|
+
registryUsesPoolEventSink = false;
|
|
463
|
+
lifecycleRegistration = null;
|
|
464
|
+
outerFacadeEvictor;
|
|
343
465
|
client = null;
|
|
344
466
|
/** Single-flight guard so concurrent first calls share one connect. */
|
|
345
467
|
connecting = null;
|
|
346
|
-
/** Per-session
|
|
468
|
+
/** Per-session records keyed by the opaque identity key. */
|
|
347
469
|
sessions = new Map();
|
|
348
470
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* at {@link MAX_CONSECUTIVE_TRANSPORT_FAILURES} to recover a half-open socket
|
|
352
|
-
* whose timeouts never classify transient (B-#4).
|
|
471
|
+
* The sole root-scoped session enumeration authority. Keys are opaque and are
|
|
472
|
+
* removed by the same detacher that removes the corresponding session record.
|
|
353
473
|
*/
|
|
474
|
+
rootIndex = new Map();
|
|
475
|
+
/** Roots whose route binds must stay dormant until their directories return. */
|
|
476
|
+
dormantRoots = new Map();
|
|
477
|
+
/** Consecutive non-transient failures on the current pool-local client. */
|
|
354
478
|
transportFailures = 0;
|
|
355
|
-
/**
|
|
479
|
+
/** Concrete per-root facades, including their captured root generation. */
|
|
356
480
|
transports = new Map();
|
|
481
|
+
generationRejections = new Set();
|
|
482
|
+
pendingRootCleanups = new Set();
|
|
357
483
|
shuttingDown = false;
|
|
358
484
|
constructor(options) {
|
|
359
485
|
this.connectionFile = options.connectionFile;
|
|
@@ -362,205 +488,572 @@ export class SubcTransportPool {
|
|
|
362
488
|
this.consumerIdentity = options.consumerIdentity;
|
|
363
489
|
this.connectFn = options.connect ?? ((opts) => SubcClient.connect(opts));
|
|
364
490
|
this.onBgEventsNudge = options.onBgEventsNudge;
|
|
491
|
+
this.onBgEventsNudgeRef = options.onBgEventsNudgeRef;
|
|
365
492
|
this.bgBackoffSleep =
|
|
366
493
|
options.bgBackoffSleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
494
|
+
const lifecycle = options.lifecycle;
|
|
495
|
+
const demandCheck = options.lifecycleDemandCheck ?? options.demandCheck ?? lifecycle?.demandCheck;
|
|
496
|
+
this.lifecycleDemandCheck = demandCheck;
|
|
497
|
+
this.onLifecycleEvent = options.onLifecycleEvent ?? lifecycle?.onEvent;
|
|
498
|
+
this.onBgNudgeRejected = options.onBgNudgeRejected;
|
|
499
|
+
this.outerFacadeEvictor =
|
|
500
|
+
options.evictOuterFacade ?? lifecycle?.evictOuterFacade ?? (() => undefined);
|
|
501
|
+
const lifecycleRegistry = options.lifecycleRegistry ?? lifecycle?.registry;
|
|
502
|
+
if (lifecycleRegistry || demandCheck || options.lifecycleRegistration) {
|
|
503
|
+
this.registryUsesPoolEventSink =
|
|
504
|
+
lifecycleRegistry === undefined && this.onLifecycleEvent !== undefined;
|
|
505
|
+
this.lifecycleRegistry =
|
|
506
|
+
lifecycleRegistry ??
|
|
507
|
+
new LifecycleRegistry({
|
|
508
|
+
demandCheck: demandCheck ? (root, poolId) => demandCheck(root, poolId) : undefined,
|
|
509
|
+
onEvent: this.registryUsesPoolEventSink ? this.onLifecycleEvent : undefined,
|
|
510
|
+
});
|
|
511
|
+
if (options.lifecycleRegistration) {
|
|
512
|
+
this.bindLifecycleRegistration(options.lifecycleRegistration);
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
this.bindLifecycleRegistration(this.lifecycleRegistry.registerLifecyclePool(this, {
|
|
516
|
+
reapingEnabled: options.reapingEnabled ?? lifecycle?.reapingEnabled ?? false,
|
|
517
|
+
evictOuterFacade: (root, generation) => this.outerFacadeEvictor(root, generation),
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
367
521
|
}
|
|
368
522
|
/**
|
|
369
|
-
*
|
|
370
|
-
*
|
|
523
|
+
* Bind a wrapper-owned registration after its generation-matched eviction
|
|
524
|
+
* callback has been installed. The handle is the only authority that may
|
|
525
|
+
* deregister this concrete pool.
|
|
371
526
|
*/
|
|
527
|
+
bindLifecycleRegistration(registration) {
|
|
528
|
+
if (this.lifecycleRegistration && this.lifecycleRegistration !== registration) {
|
|
529
|
+
this.lifecycleRegistration.deregister();
|
|
530
|
+
}
|
|
531
|
+
this.lifecycleRegistration = registration;
|
|
532
|
+
}
|
|
533
|
+
/** Construction helper for wrappers that own the registration sequence. */
|
|
534
|
+
registerLifecyclePool(registry, options) {
|
|
535
|
+
this.lifecycleRegistry = registry;
|
|
536
|
+
this.outerFacadeEvictor = options.evictOuterFacade;
|
|
537
|
+
const registration = registry.registerLifecyclePool(this, options);
|
|
538
|
+
this.bindLifecycleRegistration(registration);
|
|
539
|
+
return registration;
|
|
540
|
+
}
|
|
541
|
+
/** Replace only the callback; registration identity and switch stay immutable. */
|
|
542
|
+
setOuterFacadeEvictor(evictOuterFacade) {
|
|
543
|
+
this.outerFacadeEvictor = evictOuterFacade;
|
|
544
|
+
}
|
|
545
|
+
getConcretePoolId() {
|
|
546
|
+
return this.lifecycleRegistration?.concretePoolId;
|
|
547
|
+
}
|
|
548
|
+
getCurrentRootGeneration(root) {
|
|
549
|
+
return this.currentGeneration(root);
|
|
550
|
+
}
|
|
551
|
+
recordBgNudgeRejection(ref) {
|
|
552
|
+
const event = {
|
|
553
|
+
type: "subc_bg_nudge_rejected",
|
|
554
|
+
canonicalRoot: ref.canonicalRoot,
|
|
555
|
+
session: ref.session,
|
|
556
|
+
expectedGeneration: ref.generation,
|
|
557
|
+
currentGeneration: this.currentGeneration(ref.canonicalRoot),
|
|
558
|
+
expectedConcretePoolId: ref.concretePoolId,
|
|
559
|
+
currentConcretePoolId: this.currentPoolId(),
|
|
560
|
+
};
|
|
561
|
+
this.onBgNudgeRejected?.(event);
|
|
562
|
+
}
|
|
563
|
+
getLifecycleRegistration() {
|
|
564
|
+
return this.lifecycleRegistration;
|
|
565
|
+
}
|
|
566
|
+
getLifecycleRegistry() {
|
|
567
|
+
return this.lifecycleRegistry;
|
|
568
|
+
}
|
|
372
569
|
static async connectionAvailable(connectionFile) {
|
|
373
570
|
return connectionFileExists(connectionFile);
|
|
374
571
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
572
|
+
canonicalRoot(projectRoot) {
|
|
573
|
+
return asCanonicalRootPath(canonicalizeProjectRoot(projectRoot));
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Check a root immediately before opening a route. The reclaim marker is only
|
|
577
|
+
* an existence hint; a directory that has already returned wins over a stale
|
|
578
|
+
* sibling marker, so the marker contents are never parsed.
|
|
579
|
+
*/
|
|
580
|
+
rootCanAttach(root) {
|
|
581
|
+
let directoryExists = false;
|
|
582
|
+
try {
|
|
583
|
+
directoryExists = statSync(root).isDirectory();
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
// Missing, inaccessible, or otherwise unusable roots cannot be bound.
|
|
587
|
+
}
|
|
588
|
+
if (directoryExists) {
|
|
589
|
+
this.dormantRoots.delete(root);
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
// Read the marker only as a latency hint. Directory absence is sufficient
|
|
593
|
+
// to suspend the bind, and the marker's JSON is intentionally irrelevant.
|
|
594
|
+
const reclaimedMarkerPresent = existsSync(`${root}.reclaimed`);
|
|
595
|
+
if (reclaimedMarkerPresent) {
|
|
596
|
+
this.markRootDormant(root);
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
this.markRootDormant(root);
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
markRootDormant(root, rejection) {
|
|
603
|
+
const existing = this.dormantRoots.get(root);
|
|
604
|
+
if (existing)
|
|
605
|
+
return existing;
|
|
606
|
+
const error = rejection instanceof SubcError ? rejection : absentRootError(root);
|
|
607
|
+
this.dormantRoots.set(root, error);
|
|
608
|
+
log(`root ${root} reclaimed/absent; suspending attach until it exists`);
|
|
609
|
+
return error;
|
|
610
|
+
}
|
|
611
|
+
assertRootCanAttach(root) {
|
|
612
|
+
if (this.rootCanAttach(root))
|
|
613
|
+
return;
|
|
614
|
+
throw this.dormantRoots.get(root) ?? absentRootError(root);
|
|
615
|
+
}
|
|
616
|
+
lifecycleEnabled() {
|
|
617
|
+
// A disabled registration deliberately behaves like the pre-reaper pool:
|
|
618
|
+
// synthetic roots may be used, no demand check is required, and no sweep can
|
|
619
|
+
// acquire a tombstone for the pool.
|
|
620
|
+
return Boolean(this.lifecycleRegistry && this.lifecycleRegistration?.reapingEnabled);
|
|
621
|
+
}
|
|
622
|
+
currentGeneration(root) {
|
|
623
|
+
const registration = this.lifecycleRegistration;
|
|
624
|
+
return registration && this.lifecycleRegistry
|
|
625
|
+
? this.lifecycleRegistry.currentGeneration(registration.concretePoolId, root)
|
|
626
|
+
: undefined;
|
|
627
|
+
}
|
|
628
|
+
currentPoolId() {
|
|
629
|
+
return this.lifecycleRegistration?.concretePoolId;
|
|
630
|
+
}
|
|
631
|
+
isCurrentLiveGeneration(root, generation) {
|
|
632
|
+
if (!this.lifecycleEnabled())
|
|
633
|
+
return true;
|
|
634
|
+
const registry = this.lifecycleRegistry;
|
|
635
|
+
const poolId = this.currentPoolId();
|
|
636
|
+
return (registry !== undefined &&
|
|
637
|
+
poolId !== undefined &&
|
|
638
|
+
generation !== undefined &&
|
|
639
|
+
registry.isCurrentLiveGeneration(poolId, root, generation));
|
|
640
|
+
}
|
|
641
|
+
isRootTombstoned(root, generation) {
|
|
642
|
+
const registry = this.lifecycleRegistry;
|
|
643
|
+
const poolId = this.currentPoolId();
|
|
644
|
+
return (registry !== undefined &&
|
|
645
|
+
poolId !== undefined &&
|
|
646
|
+
registry.isTombstoned(poolId, root, generation));
|
|
647
|
+
}
|
|
648
|
+
recordGenerationRejection(root, expectedGeneration, boundary) {
|
|
649
|
+
if (!this.lifecycleEnabled())
|
|
650
|
+
return;
|
|
651
|
+
const poolId = this.currentPoolId();
|
|
652
|
+
const registry = this.lifecycleRegistry;
|
|
653
|
+
if (!poolId || !registry)
|
|
654
|
+
return;
|
|
655
|
+
const key = `${poolId}\u0000${root}\u0000${expectedGeneration}\u0000${boundary}`;
|
|
656
|
+
if (this.generationRejections.has(key))
|
|
657
|
+
return;
|
|
658
|
+
this.generationRejections.add(key);
|
|
659
|
+
registry.recordGenerationRejection(poolId, root, expectedGeneration, boundary);
|
|
660
|
+
if (this.registryUsesPoolEventSink)
|
|
661
|
+
return;
|
|
662
|
+
this.onLifecycleEvent?.({
|
|
663
|
+
type: "subc_root_generation_rejected",
|
|
664
|
+
realm: registry.realm,
|
|
665
|
+
concretePoolId: poolId,
|
|
666
|
+
canonicalRoot: root,
|
|
667
|
+
expectedGeneration,
|
|
668
|
+
currentGeneration: registry.currentGeneration(poolId, root),
|
|
669
|
+
boundary,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
generationExpiredError(root, expectedGeneration) {
|
|
673
|
+
const currentGeneration = this.currentGeneration(root);
|
|
674
|
+
this.recordGenerationRejection(root, expectedGeneration, "stale_generation");
|
|
675
|
+
return new SubcRootGenerationExpiredError({
|
|
676
|
+
canonicalRoot: root,
|
|
677
|
+
expectedGeneration,
|
|
678
|
+
currentGeneration,
|
|
679
|
+
concretePoolId: this.currentPoolId(),
|
|
680
|
+
currentConcretePoolId: this.currentPoolId(),
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
rootReapedError(record) {
|
|
684
|
+
return new SubcRootReapedError({
|
|
685
|
+
canonicalRoot: record.canonicalRoot,
|
|
686
|
+
expectedGeneration: record.generation ?? asRootGeneration(1),
|
|
687
|
+
currentGeneration: record.generation
|
|
688
|
+
? this.currentGeneration(record.canonicalRoot)
|
|
689
|
+
: undefined,
|
|
690
|
+
concretePoolId: this.currentPoolId(),
|
|
691
|
+
currentConcretePoolId: this.currentPoolId(),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
assertFacadeCurrent(root, expectedGeneration, boundary) {
|
|
695
|
+
this.assertGeneration(root, expectedGeneration, boundary);
|
|
696
|
+
}
|
|
697
|
+
assertGeneration(root, expectedGeneration, boundary) {
|
|
698
|
+
if (!this.lifecycleEnabled() || expectedGeneration === undefined)
|
|
699
|
+
return;
|
|
700
|
+
if (this.isCurrentLiveGeneration(root, expectedGeneration))
|
|
701
|
+
return;
|
|
702
|
+
if (this.isRootTombstoned(root, expectedGeneration)) {
|
|
703
|
+
throw new SubcRootReapedError({
|
|
704
|
+
canonicalRoot: root,
|
|
705
|
+
expectedGeneration,
|
|
706
|
+
currentGeneration: this.currentGeneration(root),
|
|
707
|
+
concretePoolId: this.currentPoolId(),
|
|
708
|
+
currentConcretePoolId: this.currentPoolId(),
|
|
709
|
+
});
|
|
381
710
|
}
|
|
711
|
+
this.recordGenerationRejection(root, expectedGeneration, boundary);
|
|
712
|
+
throw this.generationExpiredError(root, expectedGeneration);
|
|
713
|
+
}
|
|
714
|
+
assertRecordLive(record) {
|
|
715
|
+
if (!this.isCurrentSession(record.identityKey, record)) {
|
|
716
|
+
if (record.teardownReason === "root_reaped")
|
|
717
|
+
throw this.rootReapedError(record);
|
|
718
|
+
throw new RouteTornDownError("subc session closed");
|
|
719
|
+
}
|
|
720
|
+
this.assertGeneration(record.canonicalRoot, record.generation, "route_request");
|
|
721
|
+
}
|
|
722
|
+
synchronousDemand(root) {
|
|
723
|
+
const poolId = this.currentPoolId();
|
|
724
|
+
if (!this.lifecycleEnabled() || poolId === undefined)
|
|
725
|
+
return true;
|
|
726
|
+
if (!this.lifecycleDemandCheck)
|
|
727
|
+
throw new SubcRootDemandRequiredError(root);
|
|
728
|
+
const result = this.lifecycleDemandCheck(root, poolId);
|
|
729
|
+
if (typeof result === "boolean")
|
|
730
|
+
return result;
|
|
731
|
+
return result.exists !== false;
|
|
732
|
+
}
|
|
733
|
+
makeFacade(root, generation) {
|
|
734
|
+
const existing = this.transports.get(root);
|
|
735
|
+
if (existing && (!this.lifecycleEnabled() || existing.getGeneration() === generation)) {
|
|
736
|
+
return existing;
|
|
737
|
+
}
|
|
738
|
+
const transport = new SubcTransport(this, root, generation);
|
|
739
|
+
this.transports.set(root, transport);
|
|
382
740
|
return transport;
|
|
383
741
|
}
|
|
742
|
+
/**
|
|
743
|
+
* Return a live facade. Legacy construction remains synchronous. Lifecycle
|
|
744
|
+
* construction uses the injected synchronous demand seam so a returned facade
|
|
745
|
+
* always has a registered root and captured generation.
|
|
746
|
+
*/
|
|
747
|
+
getBridge(projectRoot) {
|
|
748
|
+
const root = this.canonicalRoot(projectRoot);
|
|
749
|
+
if (this.shuttingDown && this.lifecycleEnabled()) {
|
|
750
|
+
throw new SubcCallError("terminal", "subc transport is shutting down");
|
|
751
|
+
}
|
|
752
|
+
if (!this.lifecycleEnabled())
|
|
753
|
+
return this.makeFacade(root);
|
|
754
|
+
const current = this.currentGeneration(root);
|
|
755
|
+
const existing = this.transports.get(root);
|
|
756
|
+
if (current !== undefined &&
|
|
757
|
+
this.isCurrentLiveGeneration(root, current) &&
|
|
758
|
+
existing?.getGeneration() === current) {
|
|
759
|
+
return existing;
|
|
760
|
+
}
|
|
761
|
+
if (!this.synchronousDemand(root))
|
|
762
|
+
throw new SubcRootDemandRequiredError(root);
|
|
763
|
+
const registration = this.lifecycleRegistration;
|
|
764
|
+
const registry = this.lifecycleRegistry;
|
|
765
|
+
if (!registration || !registry)
|
|
766
|
+
throw new SubcRootDemandRequiredError(root);
|
|
767
|
+
const generation = registry.registerRoot(registration.concretePoolId, root);
|
|
768
|
+
return this.makeFacade(root, generation);
|
|
769
|
+
}
|
|
770
|
+
/** Async demand entry point for registries whose existence seam is asynchronous. */
|
|
771
|
+
async getBridgeForDemand(projectRoot) {
|
|
772
|
+
const root = this.canonicalRoot(projectRoot);
|
|
773
|
+
if (this.shuttingDown || !this.lifecycleEnabled())
|
|
774
|
+
return null;
|
|
775
|
+
const registration = this.lifecycleRegistration;
|
|
776
|
+
const registry = this.lifecycleRegistry;
|
|
777
|
+
if (!registration || !registry)
|
|
778
|
+
return null;
|
|
779
|
+
const generation = await registry.ensureRootForDemand(registration.concretePoolId, root);
|
|
780
|
+
if (generation === undefined ||
|
|
781
|
+
!registry.isCurrentLiveGeneration(registration.concretePoolId, root, generation)) {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
return this.makeFacade(root, generation);
|
|
785
|
+
}
|
|
786
|
+
/** Alias used by host construction code that calls the seam a demand operation. */
|
|
787
|
+
demandBridge(projectRoot) {
|
|
788
|
+
return this.getBridgeForDemand(projectRoot);
|
|
789
|
+
}
|
|
384
790
|
getActiveBridgeForRoot(projectRoot) {
|
|
385
|
-
const
|
|
386
|
-
|
|
791
|
+
const root = this.canonicalRoot(projectRoot);
|
|
792
|
+
const transport = this.transports.get(root);
|
|
793
|
+
if (!transport)
|
|
387
794
|
return null;
|
|
388
|
-
|
|
795
|
+
if (this.lifecycleEnabled()) {
|
|
796
|
+
return this.isCurrentLiveGeneration(root, transport.getGeneration()) ? transport : null;
|
|
797
|
+
}
|
|
798
|
+
if (!this.client || this.shuttingDown)
|
|
799
|
+
return null;
|
|
800
|
+
return transport;
|
|
801
|
+
}
|
|
802
|
+
/** Non-creating lookup requiring the complete pool/root/generation provenance. */
|
|
803
|
+
getActiveBridgeForRootGeneration(ref) {
|
|
804
|
+
const poolId = this.currentPoolId();
|
|
805
|
+
if (!this.lifecycleEnabled() || poolId === undefined || ref.concretePoolId !== poolId)
|
|
806
|
+
return null;
|
|
807
|
+
if (!this.isCurrentLiveGeneration(ref.canonicalRoot, ref.generation))
|
|
808
|
+
return null;
|
|
809
|
+
const transport = this.transports.get(ref.canonicalRoot);
|
|
810
|
+
return transport?.getGeneration() === ref.generation ? transport : null;
|
|
389
811
|
}
|
|
390
|
-
/** All live per-root facades, for session-scoped signals that must not
|
|
391
|
-
* depend on exact root-key resolution (the command carries the session ID
|
|
392
|
-
* and the module scopes by session, so fan-out is safe). */
|
|
393
812
|
activeBridges() {
|
|
394
|
-
if (
|
|
813
|
+
if (this.shuttingDown)
|
|
395
814
|
return [];
|
|
396
|
-
return [...this.transports.
|
|
815
|
+
return [...this.transports.entries()].flatMap(([root, transport]) => !this.lifecycleEnabled() || this.isCurrentLiveGeneration(root, transport.getGeneration())
|
|
816
|
+
? [transport]
|
|
817
|
+
: []);
|
|
397
818
|
}
|
|
398
819
|
async toolCall(projectRoot, runtime, name, rawArgs = {}, options) {
|
|
399
820
|
return this.getBridge(projectRoot).toolCall(runtime.sessionID, name, rawArgs, options);
|
|
400
821
|
}
|
|
401
|
-
getOrCreateSession(
|
|
822
|
+
getOrCreateSession(identity, generation) {
|
|
823
|
+
const key = identityKey(identity);
|
|
824
|
+
const root = asCanonicalRootPath(identity.project_root);
|
|
402
825
|
let record = this.sessions.get(key);
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
826
|
+
if (record && !record.closed) {
|
|
827
|
+
if (this.lifecycleEnabled() && record.generation !== generation) {
|
|
828
|
+
throw this.generationExpiredError(root, generation ?? asRootGeneration(1));
|
|
829
|
+
}
|
|
830
|
+
return record;
|
|
406
831
|
}
|
|
832
|
+
record = {
|
|
833
|
+
identity,
|
|
834
|
+
identityKey: key,
|
|
835
|
+
canonicalRoot: root,
|
|
836
|
+
generation,
|
|
837
|
+
routeEntry: null,
|
|
838
|
+
bgSub: null,
|
|
839
|
+
closed: false,
|
|
840
|
+
teardownReason: null,
|
|
841
|
+
inflight: 0,
|
|
842
|
+
};
|
|
843
|
+
this.sessions.set(key, record);
|
|
844
|
+
let keys = this.rootIndex.get(root);
|
|
845
|
+
if (!keys) {
|
|
846
|
+
keys = new Set();
|
|
847
|
+
this.rootIndex.set(root, keys);
|
|
848
|
+
}
|
|
849
|
+
keys.add(key);
|
|
407
850
|
return record;
|
|
408
851
|
}
|
|
409
852
|
isCurrentSession(key, record) {
|
|
410
853
|
return this.sessions.get(key) === record && !record.closed;
|
|
411
854
|
}
|
|
412
|
-
|
|
413
|
-
|
|
855
|
+
removeIndexMembership(record) {
|
|
856
|
+
const keys = this.rootIndex.get(record.canonicalRoot);
|
|
857
|
+
if (!keys)
|
|
858
|
+
return;
|
|
859
|
+
keys.delete(record.identityKey);
|
|
860
|
+
if (keys.size === 0)
|
|
861
|
+
this.rootIndex.delete(record.canonicalRoot);
|
|
862
|
+
}
|
|
863
|
+
deleteSessionIfEmpty(_key, record) {
|
|
864
|
+
if (this.sessions.get(record.identityKey) === record &&
|
|
414
865
|
!record.closed &&
|
|
415
866
|
record.inflight === 0 &&
|
|
416
867
|
record.routeEntry === null &&
|
|
417
868
|
record.bgSub === null) {
|
|
418
|
-
this.sessions.delete(
|
|
869
|
+
this.sessions.delete(record.identityKey);
|
|
870
|
+
this.removeIndexMembership(record);
|
|
419
871
|
}
|
|
420
872
|
}
|
|
421
873
|
/**
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* the caller after clearing stale route/client state so the NEXT call
|
|
425
|
-
* re-establishes. The only in-place retries are route-absence proofs: either the
|
|
426
|
-
* daemon returns `unknown_channel`, or the client rejects a stale handle before
|
|
427
|
-
* writing. In both cases, the request was not delivered to the module.
|
|
428
|
-
* Tool-level errors come back as normal replies with `success:false` and are
|
|
429
|
-
* returned, not thrown.
|
|
874
|
+
* Atomically detaches one opaque identity. Nothing in this method awaits: it
|
|
875
|
+
* is the sole owner transfer used by session close, root reap, and shutdown.
|
|
430
876
|
*/
|
|
431
|
-
|
|
877
|
+
detachSession(key, reason) {
|
|
878
|
+
const record = this.sessions.get(key);
|
|
879
|
+
if (!record || record.closed)
|
|
880
|
+
return null;
|
|
881
|
+
record.closed = true;
|
|
882
|
+
record.teardownReason = reason;
|
|
883
|
+
this.sessions.delete(key);
|
|
884
|
+
this.removeIndexMembership(record);
|
|
885
|
+
const bgSub = record.bgSub;
|
|
886
|
+
record.bgSub = null;
|
|
887
|
+
const routeEntry = record.routeEntry;
|
|
888
|
+
record.routeEntry = null;
|
|
889
|
+
if (routeEntry)
|
|
890
|
+
routeEntry.closed = true;
|
|
891
|
+
return { record, bgSub, routeEntry };
|
|
892
|
+
}
|
|
893
|
+
async cleanupDetached(detached) {
|
|
894
|
+
const cleanup = [];
|
|
895
|
+
if (detached.bgSub)
|
|
896
|
+
cleanup.push(detached.bgSub.stop());
|
|
897
|
+
const routeEntry = detached.routeEntry;
|
|
898
|
+
const route = routeEntry?.handle;
|
|
899
|
+
if (routeEntry && route !== null && route !== undefined) {
|
|
900
|
+
try {
|
|
901
|
+
cleanup.push(Promise.resolve(routeEntry.client.closeRouteChannel(route)).catch(() => undefined));
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// A client may throw synchronously when its socket is already closed.
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
await Promise.allSettled(cleanup);
|
|
908
|
+
}
|
|
909
|
+
isReapInduced(record) {
|
|
910
|
+
return record.teardownReason === "root_reaped";
|
|
911
|
+
}
|
|
912
|
+
annotateReapError(error, record) {
|
|
913
|
+
if (!this.isReapInduced(record))
|
|
914
|
+
return error;
|
|
915
|
+
if (error instanceof SubcRootReapedError)
|
|
916
|
+
return error;
|
|
917
|
+
if (error instanceof Error) {
|
|
918
|
+
try {
|
|
919
|
+
Object.defineProperty(error, "subcTeardownReason", {
|
|
920
|
+
configurable: true,
|
|
921
|
+
enumerable: true,
|
|
922
|
+
value: "root_reaped",
|
|
923
|
+
writable: false,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
// A frozen transport error cannot be annotated; use the stable wrapper.
|
|
928
|
+
return this.rootReapedError(record);
|
|
929
|
+
}
|
|
930
|
+
return error;
|
|
931
|
+
}
|
|
932
|
+
return this.rootReapedError(record);
|
|
933
|
+
}
|
|
934
|
+
/** Open or reuse a route while guarding every lifecycle boundary. */
|
|
935
|
+
async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration) {
|
|
936
|
+
const root = asCanonicalRootPath(identity.project_root);
|
|
937
|
+
let generation = expectedGeneration;
|
|
938
|
+
if (this.lifecycleEnabled()) {
|
|
939
|
+
generation ??= this.currentGeneration(root);
|
|
940
|
+
if (generation === undefined)
|
|
941
|
+
throw new SubcRootDemandRequiredError(root);
|
|
942
|
+
this.assertGeneration(root, generation, "route_request");
|
|
943
|
+
}
|
|
944
|
+
this.assertRootCanAttach(root);
|
|
432
945
|
const key = identityKey(identity);
|
|
433
|
-
const record = this.getOrCreateSession(
|
|
946
|
+
const record = this.getOrCreateSession(identity, generation);
|
|
434
947
|
record.inflight += 1;
|
|
435
948
|
try {
|
|
436
|
-
|
|
949
|
+
let client;
|
|
950
|
+
try {
|
|
951
|
+
client = await this.ensureClient();
|
|
952
|
+
this.assertRecordLive(record);
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
throw this.annotateReapError(error, record);
|
|
956
|
+
}
|
|
437
957
|
const openRoute = async () => {
|
|
438
|
-
// closeSession/shutdown marks and deletes the record synchronously. A request
|
|
439
|
-
// that was waiting for connect must not open a route for a session that no
|
|
440
|
-
// longer exists.
|
|
441
|
-
if (!this.isCurrentSession(key, record)) {
|
|
442
|
-
throw new RouteTornDownError("subc session closed");
|
|
443
|
-
}
|
|
444
958
|
try {
|
|
959
|
+
this.assertRecordLive(record);
|
|
445
960
|
const opened = await this.routeHandle(client, identity, record);
|
|
446
|
-
|
|
447
|
-
// if a stale route entry just resolved.
|
|
448
|
-
if (!this.isCurrentSession(key, record)) {
|
|
449
|
-
throw new RouteTornDownError("subc session closed");
|
|
450
|
-
}
|
|
961
|
+
this.assertRecordLive(record);
|
|
451
962
|
return opened;
|
|
452
963
|
}
|
|
453
|
-
catch (
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (
|
|
457
|
-
throw
|
|
458
|
-
|
|
459
|
-
// for transient connection death, but only while this request's session
|
|
460
|
-
// and client are still current. A close-induced failure must not drop a
|
|
461
|
-
// healthy client shared by other sessions.
|
|
462
|
-
if (isConsumerReconnectTransient(err) &&
|
|
964
|
+
catch (error) {
|
|
965
|
+
if (this.isReapInduced(record))
|
|
966
|
+
throw this.annotateReapError(error, record);
|
|
967
|
+
if (error instanceof RouteTornDownError)
|
|
968
|
+
throw error;
|
|
969
|
+
if (isConsumerReconnectTransient(error) &&
|
|
463
970
|
this.isCurrentSession(key, record) &&
|
|
464
971
|
this.client === client) {
|
|
465
972
|
this.dropClient(client);
|
|
466
973
|
}
|
|
467
|
-
throw
|
|
974
|
+
throw error;
|
|
468
975
|
}
|
|
469
976
|
};
|
|
470
977
|
const clearRouteEntry = (entry) => {
|
|
471
|
-
if (record.routeEntry
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
safeCloseRoute(entry.client, entry.handle);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
978
|
+
if (record.routeEntry !== entry)
|
|
979
|
+
return;
|
|
980
|
+
entry.closed = true;
|
|
981
|
+
record.routeEntry = null;
|
|
982
|
+
if (entry.handle != null)
|
|
983
|
+
safeCloseRoute(entry.client, entry.handle);
|
|
480
984
|
};
|
|
481
|
-
const handleRequestFailure = (
|
|
482
|
-
// The raw `request()` path does not turn failures into SubcCallError; it
|
|
483
|
-
// rejects with a base SubcError for route-level failures or a raw socket
|
|
484
|
-
// error for connection failures. Use `isConsumerReconnectTransient`, not
|
|
485
|
-
// `instanceof SubcCallError`, to distinguish a dead connection from a dead
|
|
486
|
-
// route.
|
|
487
|
-
//
|
|
488
|
-
// A failed request makes its own route suspect, so drop it and let the next
|
|
489
|
-
// call re-open. Check only the current entry; a stale failure must not
|
|
490
|
-
// delete a successor route in the same still-open session.
|
|
985
|
+
const handleRequestFailure = (error, entry) => {
|
|
491
986
|
clearRouteEntry(entry);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if (
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
// no response ever returns, so isConsumerReconnectTransient never fires
|
|
506
|
-
// and the client would otherwise be kept forever. Force reconnect.
|
|
507
|
-
this.transportFailures = 0;
|
|
508
|
-
this.dropClient(client);
|
|
509
|
-
}
|
|
987
|
+
if (!this.isCurrentSession(key, record) ||
|
|
988
|
+
this.client !== client ||
|
|
989
|
+
this.isReapInduced(record))
|
|
990
|
+
return;
|
|
991
|
+
if (isConsumerReconnectTransient(error)) {
|
|
992
|
+
this.transportFailures = 0;
|
|
993
|
+
this.dropClient(client);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (!isRouteProvenAbsentError(error) &&
|
|
997
|
+
++this.transportFailures >= MAX_CONSECUTIVE_TRANSPORT_FAILURES) {
|
|
998
|
+
this.transportFailures = 0;
|
|
999
|
+
this.dropClient(client);
|
|
510
1000
|
}
|
|
511
1001
|
};
|
|
512
1002
|
const requestOnRoute = async (route) => {
|
|
513
|
-
|
|
514
|
-
// foreground calls return one final reply, so production does not rely on
|
|
515
|
-
// live progress events here.
|
|
1003
|
+
this.assertRecordLive(record);
|
|
516
1004
|
const reply = await client.request(route, body, { timeoutMs, onProgress });
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
1005
|
+
// A legacy closeSession may intentionally let an already-delivered reply
|
|
1006
|
+
// settle. It must not mutate shared state or recreate a subscription.
|
|
1007
|
+
if (!this.isCurrentSession(key, record)) {
|
|
1008
|
+
if (this.isReapInduced(record))
|
|
1009
|
+
throw this.rootReapedError(record);
|
|
1010
|
+
return reply;
|
|
522
1011
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
// resurrect one.
|
|
1012
|
+
this.assertGeneration(record.canonicalRoot, record.generation, "request_completion");
|
|
1013
|
+
if (this.client === client)
|
|
1014
|
+
this.transportFailures = 0;
|
|
527
1015
|
this.ensureBgSubscription(identity, record);
|
|
528
1016
|
return reply;
|
|
529
1017
|
};
|
|
530
|
-
let
|
|
1018
|
+
let routeAndEntry = await openRoute();
|
|
531
1019
|
try {
|
|
532
|
-
return await requestOnRoute(route);
|
|
1020
|
+
return await requestOnRoute(routeAndEntry.route);
|
|
533
1021
|
}
|
|
534
|
-
catch (
|
|
535
|
-
if (
|
|
1022
|
+
catch (error) {
|
|
1023
|
+
if (this.isReapInduced(record))
|
|
1024
|
+
throw this.annotateReapError(error, record);
|
|
1025
|
+
if (isRouteProvenAbsentError(error) &&
|
|
536
1026
|
this.isCurrentSession(key, record) &&
|
|
537
1027
|
this.client === client) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
// other failures keep the no-auto-retry mutation-safety rule.
|
|
541
|
-
clearRouteEntry(entry);
|
|
542
|
-
({ route, entry } = await openRoute());
|
|
1028
|
+
clearRouteEntry(routeAndEntry.entry);
|
|
1029
|
+
routeAndEntry = await openRoute();
|
|
543
1030
|
try {
|
|
544
|
-
return await requestOnRoute(route);
|
|
1031
|
+
return await requestOnRoute(routeAndEntry.route);
|
|
545
1032
|
}
|
|
546
|
-
catch (
|
|
547
|
-
|
|
548
|
-
|
|
1033
|
+
catch (retryError) {
|
|
1034
|
+
if (this.isReapInduced(record))
|
|
1035
|
+
throw this.annotateReapError(retryError, record);
|
|
1036
|
+
handleRequestFailure(retryError, routeAndEntry.entry);
|
|
1037
|
+
throw retryError;
|
|
549
1038
|
}
|
|
550
1039
|
}
|
|
551
|
-
handleRequestFailure(
|
|
552
|
-
throw
|
|
1040
|
+
handleRequestFailure(error, routeAndEntry.entry);
|
|
1041
|
+
throw error;
|
|
553
1042
|
}
|
|
554
1043
|
}
|
|
1044
|
+
catch (error) {
|
|
1045
|
+
if (this.isReapInduced(record))
|
|
1046
|
+
throw this.annotateReapError(error, record);
|
|
1047
|
+
throw error;
|
|
1048
|
+
}
|
|
555
1049
|
finally {
|
|
556
1050
|
record.inflight -= 1;
|
|
557
1051
|
this.deleteSessionIfEmpty(key, record);
|
|
558
1052
|
}
|
|
559
1053
|
}
|
|
560
1054
|
async ensureClient() {
|
|
561
|
-
if (this.shuttingDown)
|
|
1055
|
+
if (this.shuttingDown)
|
|
562
1056
|
throw new SubcCallError("terminal", "subc transport is shutting down");
|
|
563
|
-
}
|
|
564
1057
|
if (this.client)
|
|
565
1058
|
return this.client;
|
|
566
1059
|
if (this.connecting)
|
|
@@ -571,57 +1064,51 @@ export class SubcTransportPool {
|
|
|
571
1064
|
})
|
|
572
1065
|
.then((client) => {
|
|
573
1066
|
this.connecting = null;
|
|
574
|
-
// shutdown() may have fired while this connect was in flight. Don't
|
|
575
|
-
// install a live client after teardown — close it and fail the call
|
|
576
|
-
// (B-#5: otherwise a socket leaks open past shutdown()).
|
|
577
1067
|
if (this.shuttingDown) {
|
|
578
1068
|
try {
|
|
579
1069
|
client.close();
|
|
580
1070
|
}
|
|
581
1071
|
catch {
|
|
582
|
-
//
|
|
1072
|
+
// A late connection is owned by the failed connect and is closed here.
|
|
583
1073
|
}
|
|
584
1074
|
throw new SubcCallError("terminal", "subc transport is shutting down");
|
|
585
1075
|
}
|
|
586
1076
|
this.client = client;
|
|
587
|
-
// Fresh client generation starts with a clean failure budget (R2-T2).
|
|
588
1077
|
this.transportFailures = 0;
|
|
589
1078
|
return client;
|
|
590
1079
|
})
|
|
591
|
-
.catch((
|
|
1080
|
+
.catch((error) => {
|
|
592
1081
|
this.connecting = null;
|
|
593
|
-
throw
|
|
1082
|
+
throw error;
|
|
594
1083
|
});
|
|
595
1084
|
return this.connecting;
|
|
596
1085
|
}
|
|
597
|
-
/**
|
|
598
|
-
* Resolve the route channel for `identity`, returning BOTH the channel and the
|
|
599
|
-
* `RouteEntry` token that owns it. The route-entry token is the route-cache
|
|
600
|
-
* ownership guard: stale request failures can clear only the route they used,
|
|
601
|
-
* never a successor route in the same still-open session.
|
|
602
|
-
*/
|
|
603
1086
|
async routeHandle(client, identity, record) {
|
|
604
|
-
const key = identityKey(identity);
|
|
605
1087
|
const existing = record.routeEntry;
|
|
606
|
-
if (existing?.handle != null)
|
|
1088
|
+
if (existing?.handle != null && existing.client === client)
|
|
607
1089
|
return { route: existing.handle, entry: existing };
|
|
608
|
-
if (existing?.opening)
|
|
1090
|
+
if (existing?.opening && existing.client === client)
|
|
609
1091
|
return { route: await existing.opening, entry: existing };
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1092
|
+
const entry = {
|
|
1093
|
+
canonicalRoot: record.canonicalRoot,
|
|
1094
|
+
generation: record.generation,
|
|
1095
|
+
client,
|
|
1096
|
+
opening: null,
|
|
1097
|
+
handle: null,
|
|
1098
|
+
closed: false,
|
|
1099
|
+
};
|
|
1100
|
+
this.assertRootCanAttach(record.canonicalRoot);
|
|
613
1101
|
const opening = client
|
|
614
1102
|
.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
|
|
615
1103
|
consumerIdentity: this.consumerIdentity,
|
|
616
1104
|
})
|
|
617
1105
|
.then((route) => {
|
|
618
|
-
|
|
619
|
-
// replaced by a transient drop/reopen. In both cases, release the freshly
|
|
620
|
-
// minted channel and clear only this entry if it still owns the cache slot.
|
|
621
|
-
if (!this.isCurrentSession(key, record) ||
|
|
1106
|
+
if (!this.isCurrentSession(record.identityKey, record) ||
|
|
622
1107
|
record.routeEntry !== entry ||
|
|
623
1108
|
entry.closed ||
|
|
624
|
-
this.client !== client
|
|
1109
|
+
this.client !== client ||
|
|
1110
|
+
(this.lifecycleEnabled() &&
|
|
1111
|
+
!this.isCurrentLiveGeneration(record.canonicalRoot, record.generation))) {
|
|
625
1112
|
safeCloseRoute(client, route);
|
|
626
1113
|
if (record.routeEntry === entry)
|
|
627
1114
|
record.routeEntry = null;
|
|
@@ -631,66 +1118,137 @@ export class SubcTransportPool {
|
|
|
631
1118
|
entry.opening = null;
|
|
632
1119
|
return route;
|
|
633
1120
|
})
|
|
634
|
-
.catch((
|
|
635
|
-
const
|
|
636
|
-
// Failed open: drop this entry so the next call retries cleanly, but never
|
|
637
|
-
// delete a successor entry that replaced it while this open was in flight.
|
|
1121
|
+
.catch((error) => {
|
|
1122
|
+
const currentEntry = this.isCurrentSession(record.identityKey, record) && record.routeEntry === entry;
|
|
638
1123
|
if (record.routeEntry === entry) {
|
|
639
1124
|
entry.closed = true;
|
|
640
1125
|
record.routeEntry = null;
|
|
641
1126
|
}
|
|
642
|
-
if (!
|
|
1127
|
+
if (!this.isCurrentSession(record.identityKey, record) &&
|
|
1128
|
+
!(error instanceof RouteTornDownError)) {
|
|
643
1129
|
throw new RouteTornDownError("subc route opened after session closed");
|
|
644
1130
|
}
|
|
645
|
-
|
|
1131
|
+
if (currentEntry && isAbsentRootRouteError(error)) {
|
|
1132
|
+
this.markRootDormant(record.canonicalRoot, error);
|
|
1133
|
+
}
|
|
1134
|
+
throw error;
|
|
646
1135
|
});
|
|
647
1136
|
entry.opening = opening;
|
|
648
1137
|
record.routeEntry = entry;
|
|
649
1138
|
return { route: await opening, entry };
|
|
650
1139
|
}
|
|
651
|
-
/**
|
|
652
|
-
* Open the dedicated bg_events subscription for `identity` once. Idempotent —
|
|
653
|
-
* a second call for the same identity is a no-op (one sub per session, the
|
|
654
|
-
* duplicate-sub guard from Oracle #2). No-op when no nudge handler is wired,
|
|
655
|
-
* during shutdown, or after the session has been closed.
|
|
656
|
-
*/
|
|
657
1140
|
ensureBgSubscription(identity, record) {
|
|
658
|
-
if (this.shuttingDown || !this.onBgEventsNudge)
|
|
1141
|
+
if (this.shuttingDown || (!this.onBgEventsNudge && !this.onBgEventsNudgeRef))
|
|
659
1142
|
return;
|
|
660
|
-
|
|
661
|
-
|
|
1143
|
+
if (!this.isCurrentSession(record.identityKey, record))
|
|
1144
|
+
return;
|
|
1145
|
+
if (!this.rootCanAttach(record.canonicalRoot))
|
|
662
1146
|
return;
|
|
663
1147
|
if (record.bgSub)
|
|
664
1148
|
return;
|
|
665
|
-
const
|
|
666
|
-
const
|
|
1149
|
+
const poolId = this.currentPoolId();
|
|
1150
|
+
const generation = record.generation;
|
|
1151
|
+
const nudgeRef = poolId !== undefined && generation !== undefined
|
|
1152
|
+
? {
|
|
1153
|
+
canonicalRoot: record.canonicalRoot,
|
|
1154
|
+
session: identity.session,
|
|
1155
|
+
concretePoolId: poolId,
|
|
1156
|
+
generation,
|
|
1157
|
+
}
|
|
1158
|
+
: undefined;
|
|
1159
|
+
const onNudge = () => {
|
|
1160
|
+
if (!this.isCurrentSession(record.identityKey, record))
|
|
1161
|
+
return;
|
|
1162
|
+
this.onBgEventsNudge?.(identity.project_root, identity.session);
|
|
1163
|
+
if (nudgeRef)
|
|
1164
|
+
this.onBgEventsNudgeRef?.(nudgeRef);
|
|
1165
|
+
};
|
|
1166
|
+
let sub = null;
|
|
1167
|
+
const clearDormantSubscription = () => {
|
|
1168
|
+
if (sub && record.bgSub === sub)
|
|
1169
|
+
record.bgSub = null;
|
|
1170
|
+
};
|
|
1171
|
+
sub = new BgSubscription(identity, () => this.ensureClient(), (client) => this.dropClient(client), this.consumerIdentity, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
|
|
1172
|
+
if (!isAbsentRootRouteError(error))
|
|
1173
|
+
return false;
|
|
1174
|
+
this.markRootDormant(record.canonicalRoot, error);
|
|
1175
|
+
return true;
|
|
1176
|
+
}, clearDormantSubscription, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
|
|
1177
|
+
this.isCurrentLiveGeneration(record.canonicalRoot, record.generation));
|
|
667
1178
|
record.bgSub = sub;
|
|
1179
|
+
if (!this.rootCanAttach(record.canonicalRoot)) {
|
|
1180
|
+
record.bgSub = null;
|
|
1181
|
+
void sub.stop();
|
|
1182
|
+
}
|
|
668
1183
|
}
|
|
669
|
-
/**
|
|
1184
|
+
/**
|
|
1185
|
+
* Invalidate only routes owned by a dead client. Sessions remain indexed so a
|
|
1186
|
+
* replacement client can reconnect the same identity and its bg subscription.
|
|
1187
|
+
*/
|
|
670
1188
|
dropClient(client) {
|
|
671
|
-
if (this.client
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1189
|
+
if (this.client !== client)
|
|
1190
|
+
return;
|
|
1191
|
+
this.client = null;
|
|
1192
|
+
for (const record of this.sessions.values()) {
|
|
1193
|
+
const entry = record.routeEntry;
|
|
1194
|
+
if (entry?.client !== client)
|
|
1195
|
+
continue;
|
|
1196
|
+
entry.closed = true;
|
|
1197
|
+
record.routeEntry = null;
|
|
1198
|
+
this.deleteSessionIfEmpty(record.identityKey, record);
|
|
1199
|
+
}
|
|
1200
|
+
this.transportFailures = 0;
|
|
1201
|
+
try {
|
|
1202
|
+
client.close();
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
// A dead socket is already released by the peer.
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/** Synchronous concrete-facade eviction used by the registry coordinator. */
|
|
1209
|
+
evictConcreteFacade(root, generation) {
|
|
1210
|
+
const facade = this.transports.get(root);
|
|
1211
|
+
if (facade?.getGeneration() === generation)
|
|
1212
|
+
this.transports.delete(root);
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Registry-owned coordinated close. All record/index/facade mutations happen
|
|
1216
|
+
* before cleanup promises are created; the registry has already tombstoned the
|
|
1217
|
+
* matching generation and evicted the wrapper facade before this is called.
|
|
1218
|
+
*/
|
|
1219
|
+
async closeProjectRoot(root, generation) {
|
|
1220
|
+
if (!this.lifecycleEnabled())
|
|
1221
|
+
return { tornDownSessionCount: 0, tornDownFacadeCount: 0 };
|
|
1222
|
+
if (!this.isRootTombstoned(root, generation))
|
|
1223
|
+
return { tornDownSessionCount: 0, tornDownFacadeCount: 0 };
|
|
1224
|
+
const keys = [...(this.rootIndex.get(root) ?? new Set())];
|
|
1225
|
+
const detached = [];
|
|
1226
|
+
for (const key of keys) {
|
|
1227
|
+
const record = this.sessions.get(key);
|
|
1228
|
+
if (record?.canonicalRoot === root && record.generation === generation) {
|
|
1229
|
+
const owner = this.detachSession(key, "root_reaped");
|
|
1230
|
+
if (owner)
|
|
1231
|
+
detached.push(owner);
|
|
692
1232
|
}
|
|
693
1233
|
}
|
|
1234
|
+
this.rootIndex.delete(root);
|
|
1235
|
+
const hadFacade = this.transports.get(root)?.getGeneration() === generation;
|
|
1236
|
+
this.evictConcreteFacade(root, generation);
|
|
1237
|
+
const cleanup = Promise.allSettled(detached.map((owner) => this.cleanupDetached(owner))).then(() => ({
|
|
1238
|
+
tornDownSessionCount: detached.length,
|
|
1239
|
+
tornDownFacadeCount: hadFacade ? 1 : 0,
|
|
1240
|
+
}));
|
|
1241
|
+
this.pendingRootCleanups.add(cleanup);
|
|
1242
|
+
void cleanup.finally(() => this.pendingRootCleanups.delete(cleanup));
|
|
1243
|
+
return cleanup;
|
|
1244
|
+
}
|
|
1245
|
+
/** Forward explicit lifecycle close requests to the registry-owned coordinator. */
|
|
1246
|
+
requestProjectRootClose(root, generation, cause = "explicit") {
|
|
1247
|
+
const registry = this.lifecycleRegistry;
|
|
1248
|
+
const registration = this.lifecycleRegistration;
|
|
1249
|
+
if (!registry || !registration || !registration.reapingEnabled)
|
|
1250
|
+
return Promise.resolve();
|
|
1251
|
+
return registry.requestProjectRootClose(registration.concretePoolId, root, generation, cause);
|
|
694
1252
|
}
|
|
695
1253
|
/** No-op over subc: config is read locally by AFT (wire tiers are ignored). */
|
|
696
1254
|
setConfigureOverride(_key, _value) { }
|
|
@@ -700,87 +1258,68 @@ export class SubcTransportPool {
|
|
|
700
1258
|
async replaceBinary(path) {
|
|
701
1259
|
return path;
|
|
702
1260
|
}
|
|
703
|
-
/** A subc pool instance is terminal after shutdown; ownership revives it outside this class. */
|
|
704
1261
|
isShutdown() {
|
|
705
1262
|
return this.shuttingDown;
|
|
706
1263
|
}
|
|
707
1264
|
async shutdown() {
|
|
1265
|
+
if (this.shuttingDown)
|
|
1266
|
+
return;
|
|
708
1267
|
this.shuttingDown = true;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
entry.closed = true;
|
|
721
|
-
entries.push(entry);
|
|
722
|
-
}
|
|
1268
|
+
this.dormantRoots.clear();
|
|
1269
|
+
// Deregistration synchronously tombstones all registered roots and invokes
|
|
1270
|
+
// closeProjectRoot before this method reaches its first await.
|
|
1271
|
+
const registration = this.lifecycleRegistration;
|
|
1272
|
+
registration?.deregister();
|
|
1273
|
+
this.lifecycleRegistration = null;
|
|
1274
|
+
const detached = [];
|
|
1275
|
+
for (const key of [...this.sessions.keys()]) {
|
|
1276
|
+
const owner = this.detachSession(key, "shutdown");
|
|
1277
|
+
if (owner)
|
|
1278
|
+
detached.push(owner);
|
|
723
1279
|
}
|
|
724
|
-
this.
|
|
1280
|
+
this.rootIndex.clear();
|
|
1281
|
+
this.transports.clear();
|
|
725
1282
|
const client = this.client;
|
|
726
1283
|
this.client = null;
|
|
727
|
-
this.
|
|
728
|
-
await Promise.allSettled(
|
|
729
|
-
await Promise.allSettled(entries.map(async (entry) => {
|
|
730
|
-
if (entry.handle == null)
|
|
731
|
-
return;
|
|
732
|
-
try {
|
|
733
|
-
await entry.client.closeRouteChannel(entry.handle);
|
|
734
|
-
}
|
|
735
|
-
catch {
|
|
736
|
-
// best-effort; a dropped connection releases the route on the other side
|
|
737
|
-
}
|
|
738
|
-
}));
|
|
1284
|
+
const pending = [...this.pendingRootCleanups];
|
|
1285
|
+
await Promise.allSettled([...detached.map((owner) => this.cleanupDetached(owner)), ...pending]);
|
|
739
1286
|
if (client) {
|
|
740
1287
|
try {
|
|
741
1288
|
client.close();
|
|
742
1289
|
}
|
|
743
1290
|
catch {
|
|
744
|
-
//
|
|
1291
|
+
// Best effort; route cleanup has already been attempted.
|
|
745
1292
|
}
|
|
746
1293
|
}
|
|
747
1294
|
}
|
|
748
|
-
/**
|
|
749
|
-
* Tear down a single session's bg subscription (and tool route) — the
|
|
750
|
-
* per-session close hook (Oracle #5). Idempotent. Wired to OpenCode session-end
|
|
751
|
-
* / Pi equivalent in S4; until then, shutdown() covers teardown.
|
|
752
|
-
*/
|
|
753
1295
|
async closeSession(projectRoot, session) {
|
|
754
1296
|
const identity = {
|
|
755
1297
|
project_root: canonicalizeProjectRoot(projectRoot),
|
|
756
1298
|
harness: this.harness,
|
|
757
1299
|
session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
|
|
758
1300
|
};
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
return;
|
|
763
|
-
// All lifecycle mutation happens before the first await. In-flight requests
|
|
764
|
-
// resumed after this point observe !isCurrentSession and skip route opens,
|
|
765
|
-
// bg-sub resurrection, and client failure-budget mutation.
|
|
766
|
-
record.closed = true;
|
|
767
|
-
this.sessions.delete(key);
|
|
768
|
-
const sub = record.bgSub;
|
|
769
|
-
record.bgSub = null;
|
|
770
|
-
const entry = record.routeEntry;
|
|
771
|
-
record.routeEntry = null;
|
|
772
|
-
if (entry)
|
|
773
|
-
entry.closed = true;
|
|
774
|
-
if (sub)
|
|
775
|
-
await sub.stop();
|
|
776
|
-
if (entry?.handle != null) {
|
|
777
|
-
try {
|
|
778
|
-
await entry.client.closeRouteChannel(entry.handle);
|
|
779
|
-
}
|
|
780
|
-
catch {
|
|
781
|
-
// best-effort; a dropped connection releases the route on the other side
|
|
782
|
-
}
|
|
783
|
-
}
|
|
1301
|
+
const owner = this.detachSession(identityKey(identity), "session_closed");
|
|
1302
|
+
if (owner)
|
|
1303
|
+
await this.cleanupDetached(owner);
|
|
784
1304
|
}
|
|
785
1305
|
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Resolve a background nudge exactly once by looking up the existing active
|
|
1308
|
+
* bridge without creating one or reviving a shut-down pool; hosts use this
|
|
1309
|
+
* function to acknowledge nudge handling.
|
|
1310
|
+
*/
|
|
1311
|
+
export function resolveBridgeForNudge(pool, ref) {
|
|
1312
|
+
const candidate = pool;
|
|
1313
|
+
const bridge = candidate.getActiveBridgeForRootGeneration?.(ref) ?? null;
|
|
1314
|
+
if (bridge)
|
|
1315
|
+
return bridge;
|
|
1316
|
+
candidate.recordBgNudgeRejection?.(ref);
|
|
1317
|
+
throw new SubcRootGenerationExpiredError({
|
|
1318
|
+
canonicalRoot: ref.canonicalRoot,
|
|
1319
|
+
expectedGeneration: ref.generation,
|
|
1320
|
+
currentGeneration: candidate.getCurrentRootGeneration?.(ref.canonicalRoot),
|
|
1321
|
+
concretePoolId: ref.concretePoolId,
|
|
1322
|
+
currentConcretePoolId: candidate.getConcretePoolId?.(),
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
786
1325
|
//# sourceMappingURL=subc-transport.js.map
|