@cortexkit/subc-client 0.18.1 → 0.20.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/client.d.ts +17 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +68 -10
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts +21 -16
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +69 -18
- package/dist/provider.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +78 -19
- package/src/index.ts +1 -0
- package/src/provider.ts +87 -17
package/src/client.ts
CHANGED
|
@@ -297,6 +297,12 @@ export class SubcCallError extends Error {
|
|
|
297
297
|
get detail(): unknown {
|
|
298
298
|
return this.cause instanceof SubcError ? this.cause.detail : undefined;
|
|
299
299
|
}
|
|
300
|
+
|
|
301
|
+
/** Why the route ended. A reason never makes an in-flight call safe to resend. */
|
|
302
|
+
get closeReason(): RouteEndReason | undefined {
|
|
303
|
+
return this.cause instanceof SubcError || this.cause instanceof SubcCallError
|
|
304
|
+
? this.cause.closeReason : undefined;
|
|
305
|
+
}
|
|
300
306
|
}
|
|
301
307
|
|
|
302
308
|
/**
|
|
@@ -324,6 +330,7 @@ export class SubcError extends Error {
|
|
|
324
330
|
* those reasons at the transport boundary.
|
|
325
331
|
*/
|
|
326
332
|
readonly detail?: unknown,
|
|
333
|
+
readonly closeReason?: RouteEndReason,
|
|
327
334
|
) {
|
|
328
335
|
super(message);
|
|
329
336
|
}
|
|
@@ -404,15 +411,11 @@ export interface ConnectOptions {
|
|
|
404
411
|
*/
|
|
405
412
|
timeoutArbitrationGraceMs?: number;
|
|
406
413
|
/**
|
|
407
|
-
* Observer for daemon
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
* what they don't know, per the MUST-ignore clause), a push that fails to
|
|
413
|
-
* parse as JSON is dropped without surfacing, and an observer throw is
|
|
414
|
-
* swallowed like every other caller callback so it cannot fail the read loop
|
|
415
|
-
* or unrelated requests.
|
|
414
|
+
* Observer for parsed daemon control pushes, including unrecognized ops which
|
|
415
|
+
* callers should ignore. Malformed JSON is dropped and observer exceptions are
|
|
416
|
+
* swallowed so they cannot fail unrelated requests. The client records route
|
|
417
|
+
* reasons before notifying the observer, but closes routes only on the separate
|
|
418
|
+
* GOODBYE frame, never on a control push.
|
|
416
419
|
*/
|
|
417
420
|
onControlPush?: (push: ControlPush) => void;
|
|
418
421
|
}
|
|
@@ -431,11 +434,18 @@ export type KnownRouteCloseReason =
|
|
|
431
434
|
| "restart"
|
|
432
435
|
| "disable"
|
|
433
436
|
| "crash"
|
|
434
|
-
| "capability_denied"
|
|
437
|
+
| "capability_denied"
|
|
438
|
+
| "scope_ended"
|
|
439
|
+
| "scope_carrier_removed"
|
|
440
|
+
| "scope_delegation_changed"
|
|
441
|
+
| "scope_parent_ended";
|
|
435
442
|
|
|
436
443
|
/** A route-close reason decoded from a daemon control push. */
|
|
437
444
|
export type RouteCloseReason = KnownRouteCloseReason | "unknown";
|
|
438
445
|
|
|
446
|
+
/** Why a route ended. Even a planned closure can leave a call's outcome unknown. */
|
|
447
|
+
export type RouteEndReason = RouteCloseReason | "closed_by_caller" | "connection_lost";
|
|
448
|
+
|
|
439
449
|
/** Whether the close reason alone permits reopening a route automatically. */
|
|
440
450
|
export type RouteCloseDisposition = "may_reopen" | "must_not_reopen";
|
|
441
451
|
|
|
@@ -452,6 +462,10 @@ export function parseRouteCloseReason(reason: unknown): RouteCloseReason {
|
|
|
452
462
|
case "disable":
|
|
453
463
|
case "crash":
|
|
454
464
|
case "capability_denied":
|
|
465
|
+
case "scope_ended":
|
|
466
|
+
case "scope_carrier_removed":
|
|
467
|
+
case "scope_delegation_changed":
|
|
468
|
+
case "scope_parent_ended":
|
|
455
469
|
return reason;
|
|
456
470
|
default:
|
|
457
471
|
return "unknown";
|
|
@@ -467,6 +481,10 @@ export function classifyRouteCloseReason(reason: unknown): RouteCloseDisposition
|
|
|
467
481
|
case "disable":
|
|
468
482
|
case "crash":
|
|
469
483
|
case "capability_denied":
|
|
484
|
+
case "scope_ended":
|
|
485
|
+
case "scope_carrier_removed":
|
|
486
|
+
case "scope_delegation_changed":
|
|
487
|
+
case "scope_parent_ended":
|
|
470
488
|
case "unknown":
|
|
471
489
|
return "must_not_reopen";
|
|
472
490
|
}
|
|
@@ -600,6 +618,9 @@ export class SubcClient {
|
|
|
600
618
|
private readonly lateResponses = new Map<string, (frame: Frame) => void>();
|
|
601
619
|
private readonly routes = new Map<string, CachedRoute>();
|
|
602
620
|
private readonly liveRoutes = new Map<number, RouteHandle>();
|
|
621
|
+
private readonly routeModules = new Map<number, string>();
|
|
622
|
+
private readonly routeEndReasons = new Map<number, { reason: RouteCloseReason; final: boolean }>();
|
|
623
|
+
private readonly legacyChannelReasons = new Map<number, RouteCloseReason>();
|
|
603
624
|
private connectionToken = newConnectionToken();
|
|
604
625
|
private ingressEpochDropCount = 0;
|
|
605
626
|
private closedErr: Error | null = null;
|
|
@@ -724,6 +745,7 @@ export class SubcClient {
|
|
|
724
745
|
throw new SubcError(`route.open returned no route handle: ${JSON.stringify(parsed)}`);
|
|
725
746
|
}
|
|
726
747
|
installed = this.installRoute(parsed.route_channel, parsed.route_epoch, reverseRequests);
|
|
748
|
+
this.routeModules.set(parsed.route_channel, target.module_id);
|
|
727
749
|
return true;
|
|
728
750
|
};
|
|
729
751
|
const closeLateRoute = (frame: Frame): void => {
|
|
@@ -732,7 +754,7 @@ export class SubcClient {
|
|
|
732
754
|
const parsed = this.parseJson(frame) as { route_channel?: number; route_epoch?: number };
|
|
733
755
|
if (typeof parsed.route_channel !== "number" || typeof parsed.route_epoch !== "number") return;
|
|
734
756
|
const lateHandle = this.installRoute(parsed.route_channel, parsed.route_epoch);
|
|
735
|
-
this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed"));
|
|
757
|
+
this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed", undefined, "closed_by_caller"));
|
|
736
758
|
this.liveRoutes.delete(lateHandle.channel);
|
|
737
759
|
this.sendRouteGoodbye(lateHandle, true);
|
|
738
760
|
} catch {
|
|
@@ -1018,7 +1040,7 @@ export class SubcClient {
|
|
|
1018
1040
|
}
|
|
1019
1041
|
}
|
|
1020
1042
|
if (opts.drain) await this.drainUnaryOnHandle(handle);
|
|
1021
|
-
this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed"));
|
|
1043
|
+
this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed", undefined, "closed_by_caller"));
|
|
1022
1044
|
if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
|
|
1023
1045
|
this.sendRouteGoodbye(handle);
|
|
1024
1046
|
}
|
|
@@ -1683,7 +1705,8 @@ export class SubcClient {
|
|
|
1683
1705
|
}
|
|
1684
1706
|
|
|
1685
1707
|
private routeClosedDuringOpen(): SubcCallError {
|
|
1686
|
-
|
|
1708
|
+
const cause = new SubcError("route was closed before route.open completed", "route_closed", undefined, "closed_by_caller");
|
|
1709
|
+
return new SubcCallError("not_sent", cause.message, cause.code, cause);
|
|
1687
1710
|
}
|
|
1688
1711
|
|
|
1689
1712
|
private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
|
|
@@ -1726,12 +1749,12 @@ export class SubcClient {
|
|
|
1726
1749
|
if (frame.header.channel === 0 && frame.header.ty === FrameType.Push) {
|
|
1727
1750
|
// Daemon-originated control push (route.closing / route.closed / future
|
|
1728
1751
|
// ops). Never matches a pending (corr is daemon-chosen), never an error
|
|
1729
|
-
// path: unparseable bodies
|
|
1730
|
-
//
|
|
1752
|
+
// path: unparseable bodies drop silently. Reasons are recorded even without
|
|
1753
|
+
// an observer, and an observer throw is
|
|
1731
1754
|
// swallowed for the same reason as onProgress below -- a caller callback
|
|
1732
1755
|
// must not fail the read loop.
|
|
1733
1756
|
const observer = this.opts.onControlPush;
|
|
1734
|
-
|
|
1757
|
+
{
|
|
1735
1758
|
let parsed: ControlPush | null = null;
|
|
1736
1759
|
try {
|
|
1737
1760
|
const body = this.parseJson(frame) as Record<string, unknown>;
|
|
@@ -1740,8 +1763,9 @@ export class SubcClient {
|
|
|
1740
1763
|
// Unparseable control push: ignored by contract.
|
|
1741
1764
|
}
|
|
1742
1765
|
if (parsed) {
|
|
1766
|
+
this.recordRouteEnd(parsed);
|
|
1743
1767
|
try {
|
|
1744
|
-
observer(parsed);
|
|
1768
|
+
observer?.(parsed);
|
|
1745
1769
|
} catch {
|
|
1746
1770
|
// Observer's own throw, on its own stack; the stream must survive.
|
|
1747
1771
|
}
|
|
@@ -1823,7 +1847,7 @@ export class SubcClient {
|
|
|
1823
1847
|
// so it stays kind=outcome_unknown -- the same class as a mid-flight socket
|
|
1824
1848
|
// drop, and never the not_sent/unknown_channel class that call() retries.
|
|
1825
1849
|
// The code says WHICH route ended, not that it is safe to send again.
|
|
1826
|
-
this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)", "route_closed"));
|
|
1850
|
+
this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)", "route_closed", undefined, this.routeEndReason(handle.channel)));
|
|
1827
1851
|
if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
|
|
1828
1852
|
this.evictRouteHandle(handle);
|
|
1829
1853
|
return;
|
|
@@ -1990,6 +2014,32 @@ export class SubcClient {
|
|
|
1990
2014
|
}
|
|
1991
2015
|
}
|
|
1992
2016
|
|
|
2017
|
+
private recordRouteEnd(push: ControlPush): void {
|
|
2018
|
+
const reason = routeCloseReason(push);
|
|
2019
|
+
if (reason === undefined) return;
|
|
2020
|
+
if ("channels" in push.body) {
|
|
2021
|
+
if (Array.isArray(push.body.channels)) {
|
|
2022
|
+
for (const channel of push.body.channels) {
|
|
2023
|
+
if (typeof channel !== "number" || !Number.isInteger(channel) || channel < 0 || channel > 65535) continue;
|
|
2024
|
+
const final = push.op === "route.closed";
|
|
2025
|
+
if (final || !this.routeEndReasons.get(channel)?.final) {
|
|
2026
|
+
this.routeEndReasons.set(channel, { reason, final });
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
} else if (typeof push.body.module_id === "string") {
|
|
2031
|
+
for (const [channel, module] of this.routeModules) {
|
|
2032
|
+
if (module === push.body.module_id) this.legacyChannelReasons.set(channel, reason);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
private routeEndReason(channel: number): RouteEndReason {
|
|
2038
|
+
const specific = this.routeEndReasons.get(channel);
|
|
2039
|
+
if (specific) return specific.reason;
|
|
2040
|
+
return this.legacyChannelReasons.get(channel) ?? "unknown";
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1993
2043
|
private failHandle(handle: RouteHandle, error: Error): void {
|
|
1994
2044
|
for (const [key, pending] of this.pending) {
|
|
1995
2045
|
if (pending.handle && sameRouteHandle(pending.handle, handle)) this.rejectPending(key, pending, error);
|
|
@@ -1997,9 +2047,15 @@ export class SubcClient {
|
|
|
1997
2047
|
}
|
|
1998
2048
|
|
|
1999
2049
|
private fail(err: Error): void {
|
|
2050
|
+
const routeError = err instanceof SubcError && err.closeReason ? err
|
|
2051
|
+
: new SubcError(err.message, errorCode(err), undefined,
|
|
2052
|
+
this.closeStarted ? "closed_by_caller" : "connection_lost");
|
|
2000
2053
|
if (!this.closedErr) this.closedErr = err;
|
|
2054
|
+
this.routeEndReasons.clear();
|
|
2055
|
+
this.legacyChannelReasons.clear();
|
|
2056
|
+
this.routeModules.clear();
|
|
2001
2057
|
for (const [key, pending] of this.pending) {
|
|
2002
|
-
this.rejectPending(key, pending, err);
|
|
2058
|
+
this.rejectPending(key, pending, pending.handle ? routeError : err);
|
|
2003
2059
|
}
|
|
2004
2060
|
}
|
|
2005
2061
|
|
|
@@ -2033,6 +2089,9 @@ export class SubcClient {
|
|
|
2033
2089
|
reverseRequests = new ReverseRequestRegistry(),
|
|
2034
2090
|
): RouteHandle {
|
|
2035
2091
|
const handle = createRouteHandle(channel, epoch, this.connectionToken, reverseRequests);
|
|
2092
|
+
this.routeEndReasons.delete(channel);
|
|
2093
|
+
this.legacyChannelReasons.delete(channel);
|
|
2094
|
+
this.routeModules.delete(channel);
|
|
2036
2095
|
this.liveRoutes.set(channel, handle);
|
|
2037
2096
|
return handle;
|
|
2038
2097
|
}
|
package/src/index.ts
CHANGED
package/src/provider.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { AuthError, authenticateClient } from "./auth.js";
|
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_RECONNECT_BACKOFF,
|
|
6
6
|
isEstablishedRouteDead,
|
|
7
|
+
parseRouteCloseReason,
|
|
8
|
+
type RouteCloseReason,
|
|
7
9
|
SUBC_MODULE_ID_ENV,
|
|
8
10
|
type BindIdentity,
|
|
9
11
|
type ReconnectBackoff,
|
|
@@ -51,6 +53,7 @@ const BODY_READ_TIMEOUT_MS = 30_000;
|
|
|
51
53
|
const WRITE_TIMEOUT_MS = 30_000;
|
|
52
54
|
const DEFAULT_RESTORED_DEBOUNCE_MS = 250;
|
|
53
55
|
const DEFAULT_PROVIDER_HANDLER_CAPACITY = 64;
|
|
56
|
+
const DRAINING_HOOK_START_LIMIT_MS = 2_000;
|
|
54
57
|
const HEALTH_CHECK_OP = "health.check";
|
|
55
58
|
export const HELLO_CORR = 1n;
|
|
56
59
|
|
|
@@ -287,6 +290,22 @@ export interface SubcProviderConnectOptions {
|
|
|
287
290
|
/** Runs only after an accepted bind ack is queued and the handle is installed. */
|
|
288
291
|
onBound?: (handle: RouteHandle) => void | Promise<void>;
|
|
289
292
|
onRouteGone?: (handle: RouteHandle) => void | Promise<void>;
|
|
293
|
+
/**
|
|
294
|
+
* The daemon has stopped admitting routes and is draining the module. Stop
|
|
295
|
+
* taking new work and finish or hand off work in flight. Called once per
|
|
296
|
+
* notice, independently of the frame reader; throws and rejections are contained.
|
|
297
|
+
* GOODBYE and connection-end reporting wait at most 2 s for hooks to start,
|
|
298
|
+
* not for them to finish. Hooks may still be running after serving ends.
|
|
299
|
+
* The wall-clock deadline is "no later than", never a grant of time: the
|
|
300
|
+
* daemon enforces its own monotonic ceiling, which can expire earlier.
|
|
301
|
+
*
|
|
302
|
+
* Background work the daemon cannot see holds a drain open only when the
|
|
303
|
+
* manifest declares a Busy self-signal anchored to health gauges. Report
|
|
304
|
+
* those gauges above zero in health metrics until that work finishes. During
|
|
305
|
+
* draining the daemon waits for every declared gauge to reach zero (a missing
|
|
306
|
+
* gauge counts as busy), or for its deadline. A pending hook alone is not busy.
|
|
307
|
+
*/
|
|
308
|
+
onDraining?: (reason: RouteCloseReason, deadline: Date) => void | Promise<void>;
|
|
290
309
|
/** Backoff for provider reconnect after an unexpected socket drop. */
|
|
291
310
|
reconnectBackoff?: ReconnectBackoff;
|
|
292
311
|
/** Injectable sleep for timer-free reconnect and debounce tests. */
|
|
@@ -328,6 +347,7 @@ interface NormalizedSubcProviderConnectOptions {
|
|
|
328
347
|
/** Runs only after an accepted bind ack is queued and the handle is installed. */
|
|
329
348
|
onBound?: (handle: RouteHandle) => void | Promise<void>;
|
|
330
349
|
onRouteGone?: (handle: RouteHandle) => void | Promise<void>;
|
|
350
|
+
onDraining?: (reason: RouteCloseReason, deadline: Date) => void | Promise<void>;
|
|
331
351
|
reconnectBackoff: ReconnectBackoff;
|
|
332
352
|
sleep: (ms: number) => Promise<void>;
|
|
333
353
|
restoredDebounceMs: number;
|
|
@@ -337,6 +357,11 @@ interface NormalizedSubcProviderConnectOptions {
|
|
|
337
357
|
reconnectOnDrop: boolean;
|
|
338
358
|
}
|
|
339
359
|
|
|
360
|
+
interface ControlPushState {
|
|
361
|
+
undecodablePushLogged: boolean;
|
|
362
|
+
drainingHooks: Set<Promise<void>>;
|
|
363
|
+
}
|
|
364
|
+
|
|
340
365
|
interface OpenedProviderConnection {
|
|
341
366
|
sock: SubcSocket;
|
|
342
367
|
conn: ConnectionInfo;
|
|
@@ -691,16 +716,18 @@ export class SubcProvider {
|
|
|
691
716
|
}
|
|
692
717
|
|
|
693
718
|
private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
|
|
719
|
+
const control: ControlPushState = { undecodablePushLogged: false, drainingHooks: new Set() };
|
|
694
720
|
try {
|
|
695
721
|
for (;;) {
|
|
696
722
|
const frame = await sock.readFrame(Number.POSITIVE_INFINITY, { afterHeaderMs: BODY_READ_TIMEOUT_MS });
|
|
697
|
-
const keepGoing = await this.dispatch(frame, sock, generation);
|
|
723
|
+
const keepGoing = await this.dispatch(frame, sock, generation, control);
|
|
698
724
|
if (!keepGoing) {
|
|
699
725
|
if (this.sock === sock && this.generation === generation) this.closeStarted = true;
|
|
700
726
|
break;
|
|
701
727
|
}
|
|
702
728
|
}
|
|
703
729
|
} catch (error) {
|
|
730
|
+
await this.waitForDrainingHooksToStart(control);
|
|
704
731
|
if (this.sock === sock && this.generation === generation && !this.closeStarted) {
|
|
705
732
|
this.handleUnexpectedDrop(sock, generation, error instanceof Error ? error : new SubcProviderError(String(error)));
|
|
706
733
|
return;
|
|
@@ -713,7 +740,7 @@ export class SubcProvider {
|
|
|
713
740
|
}
|
|
714
741
|
}
|
|
715
742
|
|
|
716
|
-
private async dispatch(frame: Frame, sock: SubcSocket, generation: number): Promise<boolean> {
|
|
743
|
+
private async dispatch(frame: Frame, sock: SubcSocket, generation: number, control: ControlPushState = { undecodablePushLogged: false, drainingHooks: new Set() }): Promise<boolean> {
|
|
717
744
|
let handle: RouteHandle | null = null;
|
|
718
745
|
if (frame.header.channel !== 0) {
|
|
719
746
|
handle = this.liveRoutes.get(frame.header.channel) ?? null;
|
|
@@ -761,7 +788,11 @@ export class SubcProvider {
|
|
|
761
788
|
);
|
|
762
789
|
}
|
|
763
790
|
return true;
|
|
791
|
+
case FrameType.Push:
|
|
792
|
+
if (frame.header.channel === 0) this.handleControlPush(frame, control);
|
|
793
|
+
return true;
|
|
764
794
|
case FrameType.Goodbye:
|
|
795
|
+
await this.waitForDrainingHooksToStart(control);
|
|
765
796
|
if (!handle) return false;
|
|
766
797
|
this.liveRoutes.delete(handle.channel);
|
|
767
798
|
this.abortHandle(handle);
|
|
@@ -786,21 +817,58 @@ export class SubcProvider {
|
|
|
786
817
|
}
|
|
787
818
|
}
|
|
788
819
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
820
|
+
private handleControlPush(frame: Frame, control: ControlPushState): void {
|
|
821
|
+
let body: { op?: unknown; reason?: unknown; deadline_ms?: unknown };
|
|
822
|
+
try {
|
|
823
|
+
body = JSON.parse(Buffer.from(frame.body).toString("utf8"));
|
|
824
|
+
if (body === null || body.op !== "module.draining" || typeof body.reason !== "string" ||
|
|
825
|
+
typeof body.deadline_ms !== "number" || !Number.isInteger(body.deadline_ms) || body.deadline_ms < 0) {
|
|
826
|
+
throw new Error("invalid module control command");
|
|
827
|
+
}
|
|
828
|
+
} catch (error) {
|
|
829
|
+
if (!control.undecodablePushLogged) {
|
|
830
|
+
control.undecodablePushLogged = true;
|
|
831
|
+
console.warn("SubcProvider ignoring undecodable channel-0 Push; further warnings suppressed on this connection", error);
|
|
832
|
+
}
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const deadline = new Date(body.deadline_ms as number);
|
|
836
|
+
// Out-of-range wall clocks expire now rather than granting extra drain time.
|
|
837
|
+
if (Number.isNaN(deadline.getTime())) deadline.setTime(Date.now());
|
|
838
|
+
let markStarted!: () => void;
|
|
839
|
+
const started = new Promise<void>((resolve) => { markStarted = resolve; });
|
|
840
|
+
control.drainingHooks.add(started);
|
|
841
|
+
// A separate microtask runs the hook through its first await, without waiting
|
|
842
|
+
// for its completion on the reader. JavaScript hooks must not block synchronously.
|
|
843
|
+
void Promise.resolve().then(() => {
|
|
844
|
+
try {
|
|
845
|
+
const result = this.opts.onDraining?.(parseRouteCloseReason(body.reason), deadline);
|
|
846
|
+
void Promise.resolve(result).catch((error) => {
|
|
847
|
+
console.warn("SubcProvider draining callback failed", error);
|
|
848
|
+
});
|
|
849
|
+
} catch (error) {
|
|
850
|
+
console.warn("SubcProvider draining callback failed", error);
|
|
851
|
+
} finally {
|
|
852
|
+
markStarted();
|
|
853
|
+
control.drainingHooks.delete(started);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private async waitForDrainingHooksToStart(control: ControlPushState): Promise<void> {
|
|
859
|
+
if (control.drainingHooks.size === 0) return;
|
|
860
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
861
|
+
try {
|
|
862
|
+
await Promise.race([
|
|
863
|
+
Promise.all(control.drainingHooks),
|
|
864
|
+
new Promise<void>((resolve) => { timer = setTimeout(resolve, DRAINING_HOOK_START_LIMIT_MS); }),
|
|
865
|
+
]);
|
|
866
|
+
} finally {
|
|
867
|
+
clearTimeout(timer);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Contain callback failures so consumer setup cannot tear down unrelated routes. */
|
|
804
872
|
private async reportRouteBound(handle: RouteHandle): Promise<void> {
|
|
805
873
|
if (!this.opts.onBound) return;
|
|
806
874
|
try {
|
|
@@ -810,6 +878,7 @@ export class SubcProvider {
|
|
|
810
878
|
}
|
|
811
879
|
}
|
|
812
880
|
|
|
881
|
+
/** Contain callback failures so route teardown cannot end the frame reader. */
|
|
813
882
|
private async reportRouteGone(handle: RouteHandle): Promise<void> {
|
|
814
883
|
if (!this.opts.onRouteGone) return;
|
|
815
884
|
try {
|
|
@@ -1408,6 +1477,7 @@ function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Norm
|
|
|
1408
1477
|
onBind: opts.onBind,
|
|
1409
1478
|
onBound: opts.onBound,
|
|
1410
1479
|
onRouteGone: opts.onRouteGone,
|
|
1480
|
+
onDraining: opts.onDraining,
|
|
1411
1481
|
reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
|
|
1412
1482
|
sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
1413
1483
|
restoredDebounceMs: opts.restoredDebounceMs ?? DEFAULT_RESTORED_DEBOUNCE_MS,
|