@econ-v1/domain 7.0.53 → 7.0.55

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.
@@ -12,6 +12,7 @@ export function isPotentiallyTrustworthyOrigin(url: RuntimeUrl): boolean;
12
12
  /** @typedef {HomeNodeClient & { close?: () => void | Promise<void> }} ClosableHomeNodeClient */
13
13
  /** @typedef {{ abort(): void, readonly signal: AbortSignalPort }} AbortControllerPort */
14
14
  /** @typedef {{ readonly candidate: DirectHomeNodeCandidate, readonly controller: AbortControllerPort, readonly promise: Promise<HomeNodeClient> }} DialAttempt */
15
+ /** @typedef {{ readonly abort: AbortControllerPort, readonly promise: Promise<HomeNodeClient> }} SharedRun */
15
16
  /** @typedef {{ readonly attempt: DialAttempt, readonly client: HomeNodeClient, readonly success: true }} DialSuccess */
16
17
  /** @typedef {{ readonly attempt: DialAttempt, readonly success: false }} DialFailure */
17
18
  export class TransportManager {
@@ -55,7 +56,17 @@ export class TransportManager {
55
56
  markReconnecting(attempt: number, nextRetryAt: string): void;
56
57
  /** @param {TransportOfflineReason} [reason] @returns {void} */
57
58
  markOffline(reason?: TransportOfflineReason): void;
58
- /** @returns {void} */
59
+ /**
60
+ * Stops everything in flight. This is a global teardown primitive — `close()` and
61
+ * `disconnect()` on the seam above call it — never a per-caller cancel, so it stays
62
+ * "abort all runs" rather than becoming per-run. What did have to change is the
63
+ * bookkeeping: with concurrent runs possible, a single `#runAbort` slot held only the
64
+ * most recently started one, and an older run would have kept walking
65
+ * `DIRECT_TIER_ORDER` — `#connect` re-checks its own run signal between tiers, and that
66
+ * signal was never aborted — dialling fresh candidates after the caller asked for
67
+ * silence. Aborting every registered run closes that gap.
68
+ * @returns {void}
69
+ */
59
70
  abort(): void;
60
71
  #private;
61
72
  }
@@ -92,6 +103,10 @@ export type DialAttempt = {
92
103
  readonly controller: AbortControllerPort;
93
104
  readonly promise: Promise<HomeNodeClient>;
94
105
  };
106
+ export type SharedRun = {
107
+ readonly abort: AbortControllerPort;
108
+ readonly promise: Promise<HomeNodeClient>;
109
+ };
95
110
  export type DialSuccess = {
96
111
  readonly attempt: DialAttempt;
97
112
  readonly client: HomeNodeClient;
@@ -1 +1 @@
1
- {"version":3,"file":"transport-manager.d.ts","sourceRoot":"","sources":["../../src/services/transport-manager.js"],"names":[],"mappings":"AAmVA,sFAAsF;AACtF,+CADY,cAAc,QAAkB,cAAc,GAAiB,OAAO,CAajF;AAkDD;;;GAGG;AACH,oDAHW,MAAM,GACJ,MAAM,CAoBlB;AAKD,iDAAiD;AACjD,oDADY,UAAU,GAAgB,OAAO,CAW5C;AAnaD,oYAAoY;AACpY,oIAAoI;AACpI,gGAAgG;AAChG,yFAAyF;AACzF,kKAAkK;AAClK,wHAAwH;AACxH,wFAAwF;AAExF;IAgCE,+CAA+C;IAC/C,qBADY,uBAAuB,EAWlC;IAED,gCAAgC;IAChC,aADc,cAAc,CAG3B;IAED,qDAAqD;IACrD,yBADc,uBAAuB,GAAG,SAAS,CAKhD;IAED;;;OAGG;IACH,oBAHW,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,GAC7B,MAAM,IAAI,CAKtB;IAED;;;;;;OAMG;IACH,kBAFa,SAAS,uBAAuB,EAAE,CAI9C;IAED;;;OAGG;IACH,8BAHW,SAAS,uBAAuB,EAAE,GAChC,IAAI,CAIhB;IAED;;;OAGG;IACH,kBAHW,uBAAuB,GACrB,OAAO,CAAC,cAAc,CAAC,CA0BnC;IAED;;;OAGG;IACH,oBAHW,mBAAmB,GACjB,IAAI,CAIhB;IAED,8CAA8C;IAC9C,wBADY,MAAM,GAAoB,IAAI,CAQzC;IAED,0EAA0E;IAC1E,0BADY,MAAM,eAAkB,MAAM,GAAwB,IAAI,CASrE;IAED,+DAA+D;IAC/D,qBADY,sBAAsB,GAAqB,IAAI,CAQ1D;IAED,sBAAsB;IACtB,SADc,IAAI,CAKjB;;CAqJF;wBA/Ua,OAAO,gBAAgB,EAAE,SAAS;sCAClC,OAAO,gBAAgB,EAAE,uBAAuB;kCAChD,OAAO,gBAAgB,EAAE,mBAAmB;6BAC5C,OAAO,gBAAgB,EAAE,cAAc;qCACvC,OAAO,gBAAgB,EAAE,sBAAsB;6BAC/C,OAAO,gBAAgB,EAAE,cAAc;8BACvC,OAAO,mBAAmB,EAAE,eAAe;yBAC3C,OAAO,sBAAsB,EAAE,UAAU;sCAYzC;IAAE,QAAQ,CAAC,UAAU,EAAE,SAAS,uBAAuB,EAAE,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,SAAS,EAAE,uBAAuB,EAAE,MAAM,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAA;CAAE;sCAC1V;IAAE,QAAQ,CAAC,kBAAkB,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,mBAAmB,CAAA;CAAE;qCAC1F,cAAc,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE;kCACvD;IAAE,KAAK,IAAI,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAA;CAAE;0BACnD;IAAE,QAAQ,CAAC,SAAS,EAAE,uBAAuB,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,cAAc,CAAC,CAAA;CAAE;0BACpI;IAAE,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAA;CAAE;0BAC1F;IAAE,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAA;CAAE"}
1
+ {"version":3,"file":"transport-manager.d.ts","sourceRoot":"","sources":["../../src/services/transport-manager.js"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@econ-v1/domain",
3
- "version": "7.0.53",
3
+ "version": "7.0.55",
4
4
  "description": "Platform-neutral domain contracts and errors for Node clients",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "Node contributors",
@@ -57,6 +57,6 @@
57
57
  "access": "public"
58
58
  },
59
59
  "dependencies": {
60
- "@econ-v1/ports": "7.0.53"
60
+ "@econ-v1/ports": "7.0.55"
61
61
  }
62
62
  }
@@ -24,6 +24,7 @@ const DIRECT_TIER_ORDER = ["lan-direct", "lan-webtransport", "wan-direct"];
24
24
  /** @typedef {HomeNodeClient & { close?: () => void | Promise<void> }} ClosableHomeNodeClient */
25
25
  /** @typedef {{ abort(): void, readonly signal: AbortSignalPort }} AbortControllerPort */
26
26
  /** @typedef {{ readonly candidate: DirectHomeNodeCandidate, readonly controller: AbortControllerPort, readonly promise: Promise<HomeNodeClient> }} DialAttempt */
27
+ /** @typedef {{ readonly abort: AbortControllerPort, readonly promise: Promise<HomeNodeClient> }} SharedRun */
27
28
  /** @typedef {{ readonly attempt: DialAttempt, readonly client: HomeNodeClient, readonly success: true }} DialSuccess */
28
29
  /** @typedef {{ readonly attempt: DialAttempt, readonly success: false }} DialFailure */
29
30
 
@@ -40,10 +41,15 @@ export class TransportManager {
40
41
  #now;
41
42
  /** @readonly @type {(state: TransportState) => void} */
42
43
  #onState;
43
- /** @type {Promise<HomeNodeClient> | undefined} */
44
- #run;
45
- /** @type {AbortControllerPort | undefined} */
46
- #runAbort;
44
+ /**
45
+ * In-flight dials, keyed by the exact candidate set each was created for (see
46
+ * `#shareRun`). A map rather than a single slot because two callers may legitimately
47
+ * be dialling DIFFERENT ladders at the same moment — the supervisor's LAN promotion
48
+ * probe and a kernel read that wants the full ladder — and neither may inherit the
49
+ * other's result.
50
+ * @readonly @type {Map<string, SharedRun>}
51
+ */
52
+ #runs = new Map();
47
53
  /** @type {DirectHomeNodeCandidate | undefined} */
48
54
  #selectedCandidate;
49
55
  /** @readonly @type {Set<AbortControllerPort>} */
@@ -181,30 +187,64 @@ export class TransportManager {
181
187
  });
182
188
  }
183
189
 
184
- /** @returns {void} */
190
+ /**
191
+ * Stops everything in flight. This is a global teardown primitive — `close()` and
192
+ * `disconnect()` on the seam above call it — never a per-caller cancel, so it stays
193
+ * "abort all runs" rather than becoming per-run. What did have to change is the
194
+ * bookkeeping: with concurrent runs possible, a single `#runAbort` slot held only the
195
+ * most recently started one, and an older run would have kept walking
196
+ * `DIRECT_TIER_ORDER` — `#connect` re-checks its own run signal between tiers, and that
197
+ * signal was never aborted — dialling fresh candidates after the caller asked for
198
+ * silence. Aborting every registered run closes that gap.
199
+ * @returns {void}
200
+ */
185
201
  abort() {
186
- this.#runAbort?.abort();
202
+ for (const run of this.#runs.values()) run.abort.abort();
187
203
  for (const controller of this.#controllers) controller.abort();
188
204
  this.markOffline();
189
205
  }
190
206
 
191
207
  /**
208
+ * Collapses concurrent callers asking for THE SAME dial onto one run, and only those.
209
+ *
210
+ * This used to share `this.#run` with whoever asked next, ignoring the `candidates`
211
+ * argument entirely on the early return. That is a correctness bug, not an
212
+ * optimization: the supervisor's LAN promotion probe calls
213
+ * `connect({ higherPriorityThan: "wan-direct" })` every ~10s, which filters the ladder
214
+ * down to the LAN tiers. On a normal deployment those tiers are empty, so `#connect`
215
+ * finds no tier with members and rejects IMMEDIATELY, with no I/O at all — and that
216
+ * rejected run was then handed to every later caller, including
217
+ * `ManagedHomeNodeClient`'s bare `connect()` that wanted the full ladder and would have
218
+ * reached the node. Measured in a browser against a live node: reads succeeded during
219
+ * page load, the first promotion tick poisoned the slot, and for the remaining 92s of
220
+ * the window the client issued ZERO fetches while every read failed and was recorded
221
+ * against the node as unreachable — the chip flipped to Offline at ~89s with the node
222
+ * up and answering the whole time.
223
+ *
224
+ * So the shared run is keyed by the candidate set it was created for. A caller that
225
+ * asked for a different set gets its own dial; a caller that asked for the same set
226
+ * still gets the in-flight one, which is the single-activation contract
227
+ * `ManagedHomeNodeClient#ensureClient` depends on to avoid duplicate connections. Do
228
+ * not widen this back to unconditional sharing.
229
+ *
192
230
  * @param {readonly DirectHomeNodeCandidate[]} candidates
193
231
  * @param {boolean} publishState
194
232
  * @returns {Promise<HomeNodeClient>}
195
233
  */
196
234
  #shareRun(candidates, publishState) {
197
- if (this.#run !== undefined) return this.#run;
235
+ const key = candidateSetKey(candidates);
236
+ const shared = this.#runs.get(key);
237
+ if (shared !== undefined) return shared.promise;
198
238
  const abort = createRuntimeAbortController();
199
239
  const connection = this.#connect(candidates, publishState, abort.signal);
200
240
  /** @type {Promise<HomeNodeClient>} */
201
- /** @type {Promise<HomeNodeClient>} */
202
241
  const run = connection.finally(() => {
203
- if (this.#run === run) this.#run = undefined;
204
- if (this.#runAbort === abort) this.#runAbort = undefined;
242
+ // Keyed on the same entry, so a settled run can never be matched by a later
243
+ // caller, and a run started after this one for the same key is never evicted
244
+ // by its predecessor settling.
245
+ if (this.#runs.get(key)?.promise === run) this.#runs.delete(key);
205
246
  });
206
- this.#run = run;
207
- this.#runAbort = abort;
247
+ this.#runs.set(key, { abort, promise: run });
208
248
  return run;
209
249
  }
210
250
 
@@ -357,6 +397,30 @@ function wholeAttempt(attempt) {
357
397
  return Number.isFinite(attempt) ? Math.max(1, Math.trunc(attempt)) : 1;
358
398
  }
359
399
 
400
+ /**
401
+ * Identity of a requested dial, for `#shareRun`'s in-flight registry.
402
+ *
403
+ * Deliberately the same `[path, address, id]` triple `normalizeDirectCandidates` already
404
+ * keys its dedup on, so "the same candidate" means one thing in this file rather than
405
+ * two. Every set reaching here is a filtered view of the normalized ladder, so the
406
+ * addresses are already canonical and the order is the ladder's — two callers asking for
407
+ * the same thing produce byte-identical keys. `JSON.stringify` of the whole list (not a
408
+ * join) keeps an empty set at `"[]"`, which no non-empty set can collide with: the
409
+ * empty-set run is exactly the one that rejects without dialling, and it must never be
410
+ * mistaken for a request to dial the ladder.
411
+ * @param {readonly DirectHomeNodeCandidate[]} candidates
412
+ * @returns {string}
413
+ */
414
+ function candidateSetKey(candidates) {
415
+ return JSON.stringify(
416
+ candidates.map((candidate) => [
417
+ candidate.path,
418
+ candidate.address,
419
+ candidate.id,
420
+ ]),
421
+ );
422
+ }
423
+
360
424
  /**
361
425
  * @param {readonly DirectHomeNodeCandidate[]} candidates
362
426
  * @returns {readonly DirectHomeNodeCandidate[]}