@econ-v1/rpc 7.0.24 → 7.0.27
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/describe-client-error.d.ts +18 -0
- package/dist/describe-client-error.d.ts.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/message-port-client.d.ts +10 -0
- package/dist/message-port-client.d.ts.map +1 -1
- package/dist/node-health-query.d.ts +28 -0
- package/dist/node-health-query.d.ts.map +1 -0
- package/dist/node-responsiveness.d.ts +174 -0
- package/dist/node-responsiveness.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/describe-client-error.js +67 -0
- package/src/index.js +8 -1
- package/src/message-port-client.js +64 -11
- package/src/node-health-query.js +49 -0
- package/src/node-responsiveness.js +342 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps any thrown value to what the UI should do with it.
|
|
3
|
+
*
|
|
4
|
+
* The routing is the spec's D4 rule, computed once: transport and sync failures already have an
|
|
5
|
+
* owner (the connectivity pill, the sync indicator), so repeating them per-stage would say one
|
|
6
|
+
* fact N times. A domain failure has no ambient owner — the node ANSWERED, so the pill reads
|
|
7
|
+
* "Connected", truthfully — and only the surface that asked can explain it.
|
|
8
|
+
*
|
|
9
|
+
* @param {unknown} error
|
|
10
|
+
* @returns {DescribedClientError}
|
|
11
|
+
*/
|
|
12
|
+
export function describeClientError(error: unknown): DescribedClientError;
|
|
13
|
+
export type DescribedClientError = {
|
|
14
|
+
readonly message: string;
|
|
15
|
+
readonly showRetry: boolean;
|
|
16
|
+
readonly surface: "ambient" | "inline";
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=describe-client-error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"describe-client-error.d.ts","sourceRoot":"","sources":["../src/describe-client-error.js"],"names":[],"mappings":"AA0BA;;;;;;;;;;GAUG;AACH,2CAHW,OAAO,GACL,oBAAoB,CA+BhC;mCAhEa;IAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,SAAS,GAAG,QAAQ,CAAA;CAAE"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export { DEVICE_BOOT_STATE_QUERY } from "./device-boot-contract.js";
|
|
2
2
|
export { CLIENT_DEVICE_PENDING_QUERY } from "./client-device-pending-contract.js";
|
|
3
3
|
export { SYNC_CURSORS_QUERY } from "./sync-cursors-contract.js";
|
|
4
|
+
export { describeClientError } from "./describe-client-error.js";
|
|
4
5
|
export type KernelRpcClient = import("./client.js").KernelRpcClient;
|
|
6
|
+
export type DescribedClientError = import("./describe-client-error.js").DescribedClientError;
|
|
7
|
+
export type NodeHealthRow = import("./node-health-query.js").NodeHealthRow;
|
|
8
|
+
export type ResponsivenessState = import("./node-responsiveness.js").ResponsivenessState;
|
|
9
|
+
export type ResponsivenessTracker = import("./node-responsiveness.js").ResponsivenessTracker;
|
|
5
10
|
export type PreparedStage = import("./client.js").PreparedStage;
|
|
6
11
|
export type StageRpcSession = import("./client.js").StageRpcSession;
|
|
7
12
|
export type Unsubscribe = import("./client.js").Unsubscribe;
|
|
@@ -158,7 +163,7 @@ export type RpcTransport = import("./transport.js").RpcTransport;
|
|
|
158
163
|
export type RpcTransportState = import("./transport.js").RpcTransportState;
|
|
159
164
|
export { DOMAIN_ERROR_CODES, DomainError, SYNC_ERROR_CODES, SyncError, TRANSPORT_ERROR_CODES, TransportError } from "@econ-v1/domain";
|
|
160
165
|
export { deserializeClientError, isJsonValue, isSerializableClientError, serializeClientError } from "./errors.js";
|
|
161
|
-
export { KernelRpcTimeoutError, MessagePortKernelRpcClient } from "./message-port-client.js";
|
|
166
|
+
export { DEFAULT_REQUEST_TIMEOUT_MS, KernelRpcTimeoutError, MessagePortKernelRpcClient } from "./message-port-client.js";
|
|
162
167
|
export { NODE_APP_LIFECYCLE_COMMAND, NODE_APP_MEMORY_CAPTURE_COMMAND, NODE_APP_MEMORY_DETAIL_QUERY, NODE_APP_MEMORY_DOWNLOAD_COMMAND, NODE_APP_MEMORY_LIST_QUERY, NODE_APP_MEMORY_OVERVIEW_QUERY, NODE_APP_MEMORY_STATUS_COMMAND } from "./node-app-memory-contract.js";
|
|
163
168
|
export { APPS_LIFECYCLE_PIN_COMMAND, APPS_LIFECYCLE_QUERY } from "./apps-lifecycle-contract.js";
|
|
164
169
|
export { L402_ENFORCEMENT_QUERY, L402_ENFORCEMENT_ROW_ID, L402_ENFORCEMENT_SET_COMMAND } from "./l402-enforcement-contract.js";
|
|
@@ -171,4 +176,6 @@ export { isOnboardingCommand, ONBOARDING_COMMAND, ONBOARDING_EVENTS, ONBOARDING_
|
|
|
171
176
|
export { TRUST_INSTALL_RESTART_COMMAND, TRUST_SKIP_COMMAND, TRUST_STATUS_QUERY, TRUST_VERIFY_COMMAND, isTrustInstallRestartPayload, isTrustSkipPayload, isTrustVerifyPayload } from "./trust-contract.js";
|
|
172
177
|
export { isOwnerClaimCompletePayload, OWNER_CLAIM_COMPLETE_COMMAND, OWNER_CLAIM_PREPARE_COMMAND } from "./onboarding/owner-claim-contract.js";
|
|
173
178
|
export { deserializeRpcEvent, deserializeRpcRequest, deserializeRpcResponse, ipPoolHealthChangedEvent, serializeRpcEvent, serializeRpcRequest, serializeRpcResponse } from "./protocol.js";
|
|
179
|
+
export { createNodeHealthQuery, NODE_HEALTH_QUERY } from "./node-health-query.js";
|
|
180
|
+
export { createResponsivenessTracker, EMA_HOLD_MS } from "./node-responsiveness.js";
|
|
174
181
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"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"}
|
|
@@ -16,6 +16,7 @@ export class KernelRpcTimeoutError extends Error {
|
|
|
16
16
|
/** @readonly @type {number} */
|
|
17
17
|
readonly timeoutMs: number;
|
|
18
18
|
}
|
|
19
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS: 30000;
|
|
19
20
|
/** @implements {KernelRpcClient} */
|
|
20
21
|
export class MessagePortKernelRpcClient implements KernelRpcClient {
|
|
21
22
|
/**
|
|
@@ -132,7 +133,10 @@ export type PendingRequest = {
|
|
|
132
133
|
readonly reject: (error: unknown) => void;
|
|
133
134
|
readonly resolve: (value: JsonValue) => void;
|
|
134
135
|
readonly sessionId: string | undefined;
|
|
136
|
+
readonly slowBarId: ReturnType<typeof globalThis.setTimeout>;
|
|
137
|
+
readonly startedAtMs: number;
|
|
135
138
|
readonly timeoutId: ReturnType<typeof globalThis.setTimeout>;
|
|
139
|
+
readonly timeoutMs: number;
|
|
136
140
|
};
|
|
137
141
|
export type EventGroup = {
|
|
138
142
|
readonly eventType: KernelEventType;
|
|
@@ -147,8 +151,14 @@ export type LiveQueryGroup = {
|
|
|
147
151
|
readonly sessionId: string | undefined;
|
|
148
152
|
readonly subscriptionId: string;
|
|
149
153
|
};
|
|
154
|
+
export type RpcResponsivenessObserver = {
|
|
155
|
+
markSlow(id: string): void;
|
|
156
|
+
settle(id: string, elapsedMs: number, answered: boolean, timeoutMs: number): void;
|
|
157
|
+
};
|
|
150
158
|
export type MessagePortKernelRpcClientOptions = {
|
|
159
|
+
readonly monotonicNow?: () => number;
|
|
151
160
|
readonly now?: () => string;
|
|
161
|
+
readonly observer?: RpcResponsivenessObserver;
|
|
152
162
|
readonly requestTimeoutMs?: number;
|
|
153
163
|
};
|
|
154
164
|
//# sourceMappingURL=message-port-client.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"message-port-client.d.ts","sourceRoot":"","sources":["../src/message-port-client.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"message-port-client.d.ts","sourceRoot":"","sources":["../src/message-port-client.js"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {ResponsivenessTracker} tracker
|
|
3
|
+
* @returns {{ subscribe(listener: (diff: QueryDiff<NodeHealthRow>) => void): () => void }}
|
|
4
|
+
*/
|
|
5
|
+
export function createNodeHealthQuery(tracker: ResponsivenessTracker): {
|
|
6
|
+
subscribe(listener: (diff: QueryDiff<NodeHealthRow>) => void): () => void;
|
|
7
|
+
};
|
|
8
|
+
/** @import { ResponsivenessState, ResponsivenessTracker } from "./node-responsiveness.js" */
|
|
9
|
+
/** @import { QueryDiff } from "@econ-v1/domain" */
|
|
10
|
+
/** @typedef {{ readonly emaMs: number | undefined, readonly id: "node-health", readonly since: number, readonly status: "normal" | "slow" }} NodeHealthRow */
|
|
11
|
+
/**
|
|
12
|
+
* The one query name stages use to ask how the node is behaving.
|
|
13
|
+
*
|
|
14
|
+
* It is a live query in every respect a stage can observe, and it is answered WITHOUT crossing
|
|
15
|
+
* the kernel port. That is not an optimisation: if this were an ordinary query it would
|
|
16
|
+
* round-trip through the very port it is reporting on, so a wedged kernel would make
|
|
17
|
+
* "is the kernel wedged?" hang — arriving 30s late, or never, exactly when it is needed.
|
|
18
|
+
*/
|
|
19
|
+
export const NODE_HEALTH_QUERY: "node.health.v1";
|
|
20
|
+
export type NodeHealthRow = {
|
|
21
|
+
readonly emaMs: number | undefined;
|
|
22
|
+
readonly id: "node-health";
|
|
23
|
+
readonly since: number;
|
|
24
|
+
readonly status: "normal" | "slow";
|
|
25
|
+
};
|
|
26
|
+
import type { ResponsivenessTracker } from "./node-responsiveness.js";
|
|
27
|
+
import type { QueryDiff } from "@econ-v1/domain";
|
|
28
|
+
//# sourceMappingURL=node-health-query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-health-query.d.ts","sourceRoot":"","sources":["../src/node-health-query.js"],"names":[],"mappings":"AA0BA;;;GAGG;AACH,+CAHW,qBAAqB,GACnB;IAAE,SAAS,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,aAAa,CAAC,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;CAAE,CAoBzF;AA/CD,6FAA6F;AAC7F,mDAAmD;AAEnD,8JAA8J;AAE9J;;;;;;;GAOG;AACH,gCAAiC,gBAAgB,CAAC;4BAVpC;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAA;CAAE;2CAH5E,0BAA0B;+BAC3D,iBAAiB"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** @param {number} timeoutMs @returns {number} */
|
|
2
|
+
export function slowBarMs(timeoutMs: number): number;
|
|
3
|
+
/**
|
|
4
|
+
* One settle's contribution to the average, expressed in DEFAULT-REQUEST-EQUIVALENT ms.
|
|
5
|
+
*
|
|
6
|
+
* The slow bar is relative (a tenth of the request's OWN declared timeout); `EMA_HOLD_MS` is
|
|
7
|
+
* absolute. Scaling each sample by the ratio of the reference bar to the request's own bar is
|
|
8
|
+
* what reconciles the two: a request is worth `EMA_HOLD_MS` to the average exactly when it has
|
|
9
|
+
* reached its own bar, whatever deadline it declared. So 20s of a 300s-declared operation folds
|
|
10
|
+
* in as 2000 (under the hold, and correctly: its bar is 30s), while 20s of an ordinary request
|
|
11
|
+
* folds in as 20000 (well past its 3s bar). One rule, one frame, and the promise in
|
|
12
|
+
* `SLOW_BAR_DIVISOR` finally holds for the average as well as for the in-flight bar.
|
|
13
|
+
*
|
|
14
|
+
* The scale is CAPPED AT 1 — normalization may only ever discount a sample, never inflate one.
|
|
15
|
+
* A caller may construct the port with a shorter-than-default deadline (`requestTimeoutMs`), and
|
|
16
|
+
* inflating its ordinary settles would invent slowness on a node that is answering promptly.
|
|
17
|
+
* This function can therefore only ever remove `slow`, never add it, which is the side this
|
|
18
|
+
* whole indicator errs on.
|
|
19
|
+
*
|
|
20
|
+
* @param {number} elapsedMs
|
|
21
|
+
* @param {number | undefined} timeoutMs The deadline that request declared, or `undefined` for
|
|
22
|
+
* a caller whose requests all use the default deadline.
|
|
23
|
+
* @returns {number}
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeElapsedMs(elapsedMs: number, timeoutMs: number | undefined): number;
|
|
26
|
+
/** @returns {ResponsivenessState} */
|
|
27
|
+
export function initialResponsiveness(): ResponsivenessState;
|
|
28
|
+
/**
|
|
29
|
+
* The whole state machine. Pressure (something past its bar, or a high recent average) pulls
|
|
30
|
+
* toward `slow`; its absence pulls toward `normal`. Neither takes effect until it has held for
|
|
31
|
+
* its dwell, and the dwells are deliberately asymmetric.
|
|
32
|
+
*
|
|
33
|
+
* @param {ResponsivenessState} state
|
|
34
|
+
* @param {ResponsivenessEvent} event
|
|
35
|
+
* @param {number} nowMs
|
|
36
|
+
* @returns {ResponsivenessState}
|
|
37
|
+
*/
|
|
38
|
+
export function nextResponsiveness(state: ResponsivenessState, event: ResponsivenessEvent, nowMs: number): ResponsivenessState;
|
|
39
|
+
/**
|
|
40
|
+
* The stateful wrapper around `nextResponsiveness`.
|
|
41
|
+
*
|
|
42
|
+
* It exists for one reason the reducer cannot cover: a dwell has to be able to expire when
|
|
43
|
+
* NOTHING else happens. A node that stops answering entirely produces no further observations,
|
|
44
|
+
* and that is precisely the case that must raise `slow`. So every state carrying a pending
|
|
45
|
+
* transition arms a ONE-SHOT timer for the instant `dwellDueAtMs` names.
|
|
46
|
+
*
|
|
47
|
+
* NOT A LOOP, and the distinction is the whole reason CLIENT_NODE_CONVENTIONS C1 exempts a
|
|
48
|
+
* one-shot delay while forbidding a re-armed one. The timer fires once per pending transition
|
|
49
|
+
* and then stops: the `tick` it dispatches carries no new observation, so pressure — and with it
|
|
50
|
+
* the target — is unchanged, and by construction the dwell has elapsed, so `nextResponsiveness`
|
|
51
|
+
* takes the completing branch and clears `candidateSince`. With `candidateSince` cleared there
|
|
52
|
+
* is nothing left to arm. The only way a second timer follows is a host that fired the first one
|
|
53
|
+
* fractionally early, which re-arms for the remaining milliseconds and then converges; it cannot
|
|
54
|
+
* become recurring, because a state with no pending transition arms nothing at all.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ readonly now?: () => number }} [options]
|
|
57
|
+
* @returns {ResponsivenessTracker}
|
|
58
|
+
*/
|
|
59
|
+
export function createResponsivenessTracker(options?: {
|
|
60
|
+
readonly now?: () => number;
|
|
61
|
+
}): ResponsivenessTracker;
|
|
62
|
+
/**
|
|
63
|
+
* `lastAnswerAtMs` is the instant the most recent request ACTUALLY CAME BACK — an answer, not
|
|
64
|
+
* an expiry — on this tracker's own clock, or `undefined` while nothing ever has. It is the
|
|
65
|
+
* only field here that can distinguish those two outcomes: `emaMs` folds both into one number
|
|
66
|
+
* and so cannot tell a fast expiry from a slow success apart. See `settle` below.
|
|
67
|
+
*
|
|
68
|
+
* @typedef {{
|
|
69
|
+
* readonly changedAt: number,
|
|
70
|
+
* readonly candidateSince: number | undefined,
|
|
71
|
+
* readonly emaMs: number | undefined,
|
|
72
|
+
* readonly lastAnswerAtMs: number | undefined,
|
|
73
|
+
* readonly slowInFlight: number,
|
|
74
|
+
* readonly status: "normal" | "slow",
|
|
75
|
+
* }} ResponsivenessState
|
|
76
|
+
*/
|
|
77
|
+
/**
|
|
78
|
+
* `timeoutMs` on a settle is the deadline THAT request declared, and it is what makes the
|
|
79
|
+
* average comparable across requests — see `normalizeElapsedMs`. It is optional because a
|
|
80
|
+
* caller that has not been taught the difference is one whose requests all use the default
|
|
81
|
+
* deadline, which is the frame the average is already expressed in.
|
|
82
|
+
*
|
|
83
|
+
* @typedef {{ type: "slow-bar-passed" }
|
|
84
|
+
* | {
|
|
85
|
+
* readonly answered: boolean,
|
|
86
|
+
* readonly elapsedMs: number,
|
|
87
|
+
* readonly timeoutMs?: number,
|
|
88
|
+
* readonly type: "settled",
|
|
89
|
+
* readonly wasSlow: boolean,
|
|
90
|
+
* }
|
|
91
|
+
* | { type: "tick" }} ResponsivenessEvent
|
|
92
|
+
*/
|
|
93
|
+
/** A request is "slow" at a tenth of its OWN declared timeout, not at a global constant.
|
|
94
|
+
* A default 30s request is slow at 3s; a host-declared 300s operation at 30s. One rule, and
|
|
95
|
+
* a legitimately long operation cannot mark the node slow merely by being long. */
|
|
96
|
+
export const SLOW_BAR_DIVISOR: 10;
|
|
97
|
+
/** Short: one straggler must not flash the badge. */
|
|
98
|
+
export const RAISE_DWELL_MS: 1000;
|
|
99
|
+
/** Longer than the raise dwell, deliberately: an intermittent node must not strobe.
|
|
100
|
+
*
|
|
101
|
+
* Raised from 5s to 15s after measuring it against a live node (Task 10). A node alternating
|
|
102
|
+
* 4s slow / 4s healthy flipped the pill TEN times in 42 seconds at 5s, because the clear is
|
|
103
|
+
* structurally favoured: pressure ends the instant the last slow request settles, but does not
|
|
104
|
+
* return until a NEW request has been outstanding for its full slow bar (3s on a default
|
|
105
|
+
* request). Any clear dwell shorter than (healthy window + slow bar) therefore drops the badge
|
|
106
|
+
* on a node that is still visibly misbehaving. 15s absorbs an oscillation of roughly a 10s
|
|
107
|
+
* period; the only cost is that the badge lingers up to 15s after a genuine recovery, which is
|
|
108
|
+
* the side this pair of dwells is meant to err on.
|
|
109
|
+
*
|
|
110
|
+
* The EMA below cannot cover this gap, and it is worth being explicit about why: it is
|
|
111
|
+
* weighted per SAMPLE, not per unit of time, so how long a high average survives depends
|
|
112
|
+
* entirely on request volume. On a busy shell dozens of fast settles land within a second and
|
|
113
|
+
* take the average under EMA_HOLD_MS almost immediately. The dwell is the only time-based
|
|
114
|
+
* brake here. */
|
|
115
|
+
export const CLEAR_DWELL_MS: 15000;
|
|
116
|
+
/** Recent-weighted. Three fast responses take an 8s average back under EMA_HOLD_MS
|
|
117
|
+
* (8000 -> 5660 -> 4022 -> 2875). A 9s seed needs four: it is still at ~3218 after three.
|
|
118
|
+
* Note this counts RESPONSES, not seconds — see CLEAR_DWELL_MS above. */
|
|
119
|
+
export const EMA_ALPHA: 0.3;
|
|
120
|
+
/** An average at or above this holds `slow` even with nothing currently in flight.
|
|
121
|
+
*
|
|
122
|
+
* ABSOLUTE, and therefore only meaningful against samples that share one frame — which is what
|
|
123
|
+
* `normalizeElapsedMs` below exists to guarantee. Mixing a 30s-deadline request and a
|
|
124
|
+
* 300s-deadline request into one absolutely-thresholded average is a unit error, and it had a
|
|
125
|
+
* user-visible consequence: a single legitimate 20s stage invoke seeded `emaMs = 20000` on an
|
|
126
|
+
* otherwise idle shell, and because the average decays per SAMPLE (see EMA_ALPHA) a quiet shell
|
|
127
|
+
* produced nothing to walk it back down. The status latched `slow` and the 90s notice fired on a
|
|
128
|
+
* perfectly healthy node — exactly what the slow bar's own rule ("a long operation cannot mark
|
|
129
|
+
* the node slow merely by being long") promises cannot happen. */
|
|
130
|
+
export const EMA_HOLD_MS: 3000;
|
|
131
|
+
/** The deadline a request gets when it declares none, and for that reason the frame the EMA is
|
|
132
|
+
* expressed in. `message-port-client.js` exports this same value as `DEFAULT_REQUEST_TIMEOUT_MS`
|
|
133
|
+
* — its home is here so the port's default and the average's frame are one number that cannot
|
|
134
|
+
* drift apart. */
|
|
135
|
+
export const EMA_REFERENCE_TIMEOUT_MS: 30000;
|
|
136
|
+
export type ResponsivenessTracker = {
|
|
137
|
+
dispose(): void;
|
|
138
|
+
markSlow(id: string): void;
|
|
139
|
+
settle(id: string, elapsedMs: number, answered?: boolean, timeoutMs?: number): void;
|
|
140
|
+
snapshot(): ResponsivenessState;
|
|
141
|
+
subscribe(listener: (state: ResponsivenessState) => void): () => void;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* `lastAnswerAtMs` is the instant the most recent request ACTUALLY CAME BACK — an answer, not
|
|
145
|
+
* an expiry — on this tracker's own clock, or `undefined` while nothing ever has. It is the
|
|
146
|
+
* only field here that can distinguish those two outcomes: `emaMs` folds both into one number
|
|
147
|
+
* and so cannot tell a fast expiry from a slow success apart. See `settle` below.
|
|
148
|
+
*/
|
|
149
|
+
export type ResponsivenessState = {
|
|
150
|
+
readonly changedAt: number;
|
|
151
|
+
readonly candidateSince: number | undefined;
|
|
152
|
+
readonly emaMs: number | undefined;
|
|
153
|
+
readonly lastAnswerAtMs: number | undefined;
|
|
154
|
+
readonly slowInFlight: number;
|
|
155
|
+
readonly status: "normal" | "slow";
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* `timeoutMs` on a settle is the deadline THAT request declared, and it is what makes the
|
|
159
|
+
* average comparable across requests — see `normalizeElapsedMs`. It is optional because a
|
|
160
|
+
* caller that has not been taught the difference is one whose requests all use the default
|
|
161
|
+
* deadline, which is the frame the average is already expressed in.
|
|
162
|
+
*/
|
|
163
|
+
export type ResponsivenessEvent = {
|
|
164
|
+
type: "slow-bar-passed";
|
|
165
|
+
} | {
|
|
166
|
+
readonly answered: boolean;
|
|
167
|
+
readonly elapsedMs: number;
|
|
168
|
+
readonly timeoutMs?: number;
|
|
169
|
+
readonly type: "settled";
|
|
170
|
+
readonly wasSlow: boolean;
|
|
171
|
+
} | {
|
|
172
|
+
type: "tick";
|
|
173
|
+
};
|
|
174
|
+
//# sourceMappingURL=node-responsiveness.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-responsiveness.d.ts","sourceRoot":"","sources":["../src/node-responsiveness.js"],"names":[],"mappings":"AA+EA,kDAAkD;AAClD,qCADY,MAAM,GAAsB,MAAM,CAG7C;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,8CALW,MAAM,aACN,MAAM,GAAG,SAAS,GAEhB,MAAM,CAMlB;AAED,qCAAqC;AACrC,yCADc,mBAAmB,CAUhC;AAED;;;;;;;;;GASG;AACH,0CALW,mBAAmB,SACnB,mBAAmB,SACnB,MAAM,GACJ,mBAAmB,CAkE/B;AA8BD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,sDAHW;IAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAE,GAC7B,qBAAqB,CA+FjC;AAnVD;;;;;;;;;;;;;;GAcG;AACH;;;;;;;;;;;;;;;GAeG;AAEH;;mFAEmF;AACnF,+BAAgC,EAAE,CAAC;AACnC,qDAAqD;AACrD,6BAA8B,IAAK,CAAC;AACpC;;;;;;;;;;;;;;;iBAeiB;AACjB,6BAA8B,KAAM,CAAC;AACrC;;yEAEyE;AACzE,wBAAyB,GAAG,CAAC;AAC7B;;;;;;;;;kEASkE;AAClE,0BAA2B,IAAK,CAAC;AAEjC;;;kBAGkB;AAClB,uCAAwC,KAAM,CAAC;oCA4HlC;IACR,OAAO,IAAI,IAAI,CAAC;IAChB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACpF,QAAQ,IAAI,mBAAmB,CAAC;IAChC,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CACvE;;;;;;;kCAvMS;IACZ,QAAY,CAAC,SAAS,EAAE,MAAM,CAAC;IAC/B,QAAY,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IAChD,QAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAY,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IAChD,QAAY,CAAC,YAAY,EAAE,MAAM,CAAC;IAClC,QAAY,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;CACpC;;;;;;;kCAQS;IAAE,IAAI,EAAE,iBAAiB,CAAA;CAAE,GACjC;IACN,QAAgB,CAAC,QAAQ,EAAE,OAAO,CAAC;IACnC,QAAgB,CAAC,SAAS,EAAE,MAAM,CAAC;IACnC,QAAgB,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IACpC,QAAgB,CAAC,IAAI,EAAE,SAAS,CAAC;IACjC,QAAgB,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B,GACD;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@econ-v1/rpc",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.27",
|
|
4
4
|
"description": "Typed runtime and contracts for Node client stage-to-kernel RPC",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": "Node contributors",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"access": "public"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@econ-v1/domain": "7.0.
|
|
60
|
-
"@econ-v1/ports": "7.0.
|
|
59
|
+
"@econ-v1/domain": "7.0.27",
|
|
60
|
+
"@econ-v1/ports": "7.0.27"
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @typedef {{ readonly message: string, readonly showRetry: boolean, readonly surface: "ambient" | "inline" }} DescribedClientError */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Copy for every domain code, plus whether retrying it could plausibly help.
|
|
7
|
+
*
|
|
8
|
+
* The codes themselves NEVER reach the user. A person reading "capability_unavailable" learns
|
|
9
|
+
* nothing they can act on; "This app isn't running on your node" tells them who to ask.
|
|
10
|
+
*
|
|
11
|
+
* @type {Readonly<Record<string, { readonly message: string, readonly showRetry: boolean }>>}
|
|
12
|
+
*/
|
|
13
|
+
const DOMAIN_COPY = {
|
|
14
|
+
"access-code-invalid": { message: "That access code wasn't accepted.", showRetry: false },
|
|
15
|
+
capability_not_declared: { message: "This app asked for something it isn't allowed to use.", showRetry: false },
|
|
16
|
+
capability_unavailable: { message: "This app isn't running on your node.", showRetry: true },
|
|
17
|
+
"code-already-bound": { message: "That access code has already been used.", showRetry: false },
|
|
18
|
+
conflict: { message: "Someone else changed this first.", showRetry: true },
|
|
19
|
+
invalid_request: { message: "Your node couldn't understand that request.", showRetry: false },
|
|
20
|
+
not_found: { message: "That isn't here any more.", showRetry: false },
|
|
21
|
+
stage_composition_not_enabled: { message: "This screen can't open other apps here.", showRetry: false },
|
|
22
|
+
unauthorized: { message: "This device isn't allowed to do that.", showRetry: false },
|
|
23
|
+
unsupported_query: { message: "Your node doesn't support this yet.", showRetry: false },
|
|
24
|
+
validation_failed: { message: "Some of those details weren't accepted.", showRetry: false },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maps any thrown value to what the UI should do with it.
|
|
29
|
+
*
|
|
30
|
+
* The routing is the spec's D4 rule, computed once: transport and sync failures already have an
|
|
31
|
+
* owner (the connectivity pill, the sync indicator), so repeating them per-stage would say one
|
|
32
|
+
* fact N times. A domain failure has no ambient owner — the node ANSWERED, so the pill reads
|
|
33
|
+
* "Connected", truthfully — and only the surface that asked can explain it.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} error
|
|
36
|
+
* @returns {DescribedClientError}
|
|
37
|
+
*/
|
|
38
|
+
export function describeClientError(error) {
|
|
39
|
+
const kind = /** @type {{ kind?: unknown }} */ (error)?.kind;
|
|
40
|
+
const code = /** @type {{ code?: unknown }} */ (error)?.code;
|
|
41
|
+
|
|
42
|
+
if (kind === "transport" || kind === "sync") {
|
|
43
|
+
// Ambient: the message is carried by the pill and the inspector panel, not here. The text
|
|
44
|
+
// is still returned for the one cold-surface case that renders it (spec D5).
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
message: "Can't reach your node right now.",
|
|
47
|
+
showRetry: true,
|
|
48
|
+
surface: /** @type {const} */ ("ambient"),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (kind === "domain" && typeof code === "string") {
|
|
53
|
+
const copy = DOMAIN_COPY[code];
|
|
54
|
+
if (copy !== undefined) {
|
|
55
|
+
return Object.freeze({ ...copy, surface: /** @type {const} */ ("inline") });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Anything else — an unrecognised domain code, a plain Error, a rejected non-Error. Inline,
|
|
60
|
+
// because an unrecognised failure is at least as likely to be surface-specific as ambient,
|
|
61
|
+
// and generic, because an internal message is not written for a person to read.
|
|
62
|
+
return Object.freeze({
|
|
63
|
+
message: "Something went wrong.",
|
|
64
|
+
showRetry: true,
|
|
65
|
+
surface: /** @type {const} */ ("inline"),
|
|
66
|
+
});
|
|
67
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
/** @typedef {import("./client.js").KernelRpcClient} KernelRpcClient */
|
|
4
|
+
/** @typedef {import("./describe-client-error.js").DescribedClientError} DescribedClientError */
|
|
5
|
+
/** @typedef {import("./node-health-query.js").NodeHealthRow} NodeHealthRow */
|
|
6
|
+
/** @typedef {import("./node-responsiveness.js").ResponsivenessState} ResponsivenessState */
|
|
7
|
+
/** @typedef {import("./node-responsiveness.js").ResponsivenessTracker} ResponsivenessTracker */
|
|
4
8
|
/** @typedef {import("./client.js").PreparedStage} PreparedStage */
|
|
5
9
|
/** @typedef {import("./client.js").StageRpcSession} StageRpcSession */
|
|
6
10
|
/** @typedef {import("./client.js").Unsubscribe} Unsubscribe */
|
|
@@ -170,7 +174,7 @@ export {
|
|
|
170
174
|
isSerializableClientError,
|
|
171
175
|
serializeClientError,
|
|
172
176
|
} from "./errors.js";
|
|
173
|
-
export { KernelRpcTimeoutError, MessagePortKernelRpcClient } from "./message-port-client.js";
|
|
177
|
+
export { DEFAULT_REQUEST_TIMEOUT_MS, KernelRpcTimeoutError, MessagePortKernelRpcClient } from "./message-port-client.js";
|
|
174
178
|
export { NODE_APP_LIFECYCLE_COMMAND, NODE_APP_MEMORY_CAPTURE_COMMAND, NODE_APP_MEMORY_DETAIL_QUERY, NODE_APP_MEMORY_DOWNLOAD_COMMAND, NODE_APP_MEMORY_LIST_QUERY, NODE_APP_MEMORY_OVERVIEW_QUERY, NODE_APP_MEMORY_STATUS_COMMAND } from "./node-app-memory-contract.js";
|
|
175
179
|
export { APPS_LIFECYCLE_PIN_COMMAND, APPS_LIFECYCLE_QUERY } from "./apps-lifecycle-contract.js";
|
|
176
180
|
export { L402_ENFORCEMENT_QUERY, L402_ENFORCEMENT_ROW_ID, L402_ENFORCEMENT_SET_COMMAND } from "./l402-enforcement-contract.js";
|
|
@@ -226,3 +230,6 @@ export {
|
|
|
226
230
|
serializeRpcRequest,
|
|
227
231
|
serializeRpcResponse,
|
|
228
232
|
} from "./protocol.js";
|
|
233
|
+
export { describeClientError } from "./describe-client-error.js";
|
|
234
|
+
export { createNodeHealthQuery, NODE_HEALTH_QUERY } from "./node-health-query.js";
|
|
235
|
+
export { createResponsivenessTracker, EMA_HOLD_MS } from "./node-responsiveness.js";
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "@econ-v1/domain";
|
|
6
6
|
|
|
7
7
|
import { isJsonValue } from "./errors.js";
|
|
8
|
+
import { EMA_REFERENCE_TIMEOUT_MS, slowBarMs } from "./node-responsiveness.js";
|
|
8
9
|
import {
|
|
9
10
|
deserializeRpcEvent,
|
|
10
11
|
deserializeRpcResponse,
|
|
@@ -22,10 +23,11 @@ import {
|
|
|
22
23
|
/** @typedef {import("./protocol.js").RpcRequest} RpcRequest */
|
|
23
24
|
/** @typedef {RpcRequest extends infer TRequest ? TRequest extends RpcRequest ? Omit<TRequest, "id"> : never : never} RpcRequestWithoutId */
|
|
24
25
|
/** @typedef {import("./transport.js").RpcTransport} RpcTransport */
|
|
25
|
-
/** @typedef {{ readonly reject: (error: unknown) => void, readonly resolve: (value: JsonValue) => void, readonly sessionId: string | undefined, readonly timeoutId: ReturnType<typeof globalThis.setTimeout
|
|
26
|
+
/** @typedef {{ readonly reject: (error: unknown) => void, readonly resolve: (value: JsonValue) => void, readonly sessionId: string | undefined, readonly slowBarId: ReturnType<typeof globalThis.setTimeout>, readonly startedAtMs: number, readonly timeoutId: ReturnType<typeof globalThis.setTimeout>, readonly timeoutMs: number }} PendingRequest */
|
|
26
27
|
/** @typedef {{ readonly eventType: KernelEventType, readonly listeners: Set<(event: DomainEvent) => void>, readonly sessionId: string | undefined, readonly subscriptionId: string }} EventGroup */
|
|
27
28
|
/** @typedef {{ readonly listeners: Set<(diff: import("@econ-v1/domain").QueryDiff<JsonValue>) => void>, readonly name: QueryName, readonly params: JsonValue, readonly sessionId: string | undefined, readonly subscriptionId: string }} LiveQueryGroup */
|
|
28
|
-
/** @typedef {{
|
|
29
|
+
/** @typedef {{ markSlow(id: string): void, settle(id: string, elapsedMs: number, answered: boolean, timeoutMs: number): void }} RpcResponsivenessObserver */
|
|
30
|
+
/** @typedef {{ readonly monotonicNow?: () => number, readonly now?: () => string, readonly observer?: RpcResponsivenessObserver, readonly requestTimeoutMs?: number }} MessagePortKernelRpcClientOptions */
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* A subscribe request the kernel refused. The group is dropped either way,
|
|
@@ -73,7 +75,24 @@ export class KernelRpcTimeoutError extends Error {
|
|
|
73
75
|
|
|
74
76
|
// Kernel RPC is local, but stage work can cross browser/worker scheduling boundaries.
|
|
75
77
|
// Thirty seconds bounds dropped fire-and-forget messages without penalizing slow devices.
|
|
76
|
-
|
|
78
|
+
//
|
|
79
|
+
// It is the DEFAULT deadline, not a ceiling on observed round trips: `requestTimeoutMs()`
|
|
80
|
+
// below lets a stage capability invoke declare its own, up to MAX_DECLARED_TIMEOUT_MS (300s),
|
|
81
|
+
// so a request can legitimately answer at 290s and report that elapsed time. An earlier
|
|
82
|
+
// revision of this comment claimed the opposite — that no answer could ever be observed above
|
|
83
|
+
// this value, and that an average which reached it could only be made of expiries — and
|
|
84
|
+
// `network-inspector-model.js` was built on that claim. It is false, and it silenced the
|
|
85
|
+
// "still responding" surfaces on exactly the long-turn workloads the 300s ceiling exists for.
|
|
86
|
+
//
|
|
87
|
+
// `network-inspector-model.js` still reads this constant, but as a SILENCE bound rather than a
|
|
88
|
+
// latency ceiling: if nothing has come back for a whole default deadline, every ordinary
|
|
89
|
+
// request issued in that window has already expired, so "still responding" has stopped being
|
|
90
|
+
// a true sentence. What came back and what merely expired is now carried explicitly — see the
|
|
91
|
+
// `answered` argument threaded through `#takePending`.
|
|
92
|
+
// The value itself lives in `node-responsiveness.js` as `EMA_REFERENCE_TIMEOUT_MS`: the
|
|
93
|
+
// responsiveness average is expressed in the frame of this same default deadline, and two
|
|
94
|
+
// declarations of one number are two chances to drift.
|
|
95
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = EMA_REFERENCE_TIMEOUT_MS;
|
|
77
96
|
|
|
78
97
|
// The ceiling for a deadline a STAGE declares on a capability invoke — the same
|
|
79
98
|
// 300s the host validates (InvokeCapabilityRequest, 100..300000) and the kernel
|
|
@@ -122,8 +141,12 @@ export class MessagePortKernelRpcClient {
|
|
|
122
141
|
#liveGroupsByKey = new Map();
|
|
123
142
|
/** @type {Map<string, LiveQueryGroup>} */
|
|
124
143
|
#liveGroupsBySubscription = new Map();
|
|
144
|
+
/** @type {() => number} */
|
|
145
|
+
#monotonicNow;
|
|
125
146
|
/** @type {() => string} */
|
|
126
147
|
#now;
|
|
148
|
+
/** @type {RpcResponsivenessObserver | undefined} */
|
|
149
|
+
#observer;
|
|
127
150
|
/** @type {Map<string, PendingRequest>} */
|
|
128
151
|
#pending = new Map();
|
|
129
152
|
/** @type {number} */
|
|
@@ -145,6 +168,8 @@ export class MessagePortKernelRpcClient {
|
|
|
145
168
|
constructor(transport, options = {}) {
|
|
146
169
|
this.#transport = transport;
|
|
147
170
|
this.#now = options.now ?? (() => new Date().toISOString());
|
|
171
|
+
this.#monotonicNow = options.monotonicNow ?? (() => Date.now());
|
|
172
|
+
this.#observer = options.observer;
|
|
148
173
|
this.#requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
149
174
|
this.#unsubscribeMessages = transport.subscribe((message) => this.#handleMessage(message));
|
|
150
175
|
this.#unsubscribeClose = transport.subscribeClose(() => this.#handleClose());
|
|
@@ -262,7 +287,7 @@ export class MessagePortKernelRpcClient {
|
|
|
262
287
|
const error = new TransportError({ code: "connection_lost", message: "Stage RPC session closed" });
|
|
263
288
|
for (const [requestId, pending] of [...this.#pending]) {
|
|
264
289
|
if (pending.sessionId === sessionId) {
|
|
265
|
-
this.#takePending(requestId)?.reject(error);
|
|
290
|
+
this.#takePending(requestId, false)?.reject(error);
|
|
266
291
|
}
|
|
267
292
|
}
|
|
268
293
|
this.#removeSessionSubscriptions(sessionId);
|
|
@@ -342,7 +367,7 @@ export class MessagePortKernelRpcClient {
|
|
|
342
367
|
this.#unsubscribeMessages();
|
|
343
368
|
this.#unsubscribeClose();
|
|
344
369
|
const error = new TransportError({ code: "connection_lost", message: "Kernel RPC port closed" });
|
|
345
|
-
for (const requestId of [...this.#pending.keys()]) this.#takePending(requestId)?.reject(error);
|
|
370
|
+
for (const requestId of [...this.#pending.keys()]) this.#takePending(requestId, false)?.reject(error);
|
|
346
371
|
this.#eventGroupsByKey.clear();
|
|
347
372
|
this.#eventGroupsBySubscription.clear();
|
|
348
373
|
this.#liveGroupsByKey.clear();
|
|
@@ -363,14 +388,16 @@ export class MessagePortKernelRpcClient {
|
|
|
363
388
|
const responseId = typeof envelope.id === "string" ? envelope.id : undefined;
|
|
364
389
|
try {
|
|
365
390
|
const response = deserializeRpcResponse(message);
|
|
366
|
-
const pending = this.#takePending(response.id);
|
|
391
|
+
const pending = this.#takePending(response.id, true);
|
|
367
392
|
if (pending === undefined) return;
|
|
368
393
|
if (response.ok) pending.resolve(response.value);
|
|
369
394
|
else pending.reject(response.error);
|
|
370
395
|
return;
|
|
371
396
|
} catch {
|
|
372
397
|
if (responseId !== undefined) {
|
|
373
|
-
|
|
398
|
+
// Malformed, but it CAME BACK: the kernel replied to this id, which is exactly the
|
|
399
|
+
// liveness the answer flag reports. The caller still gets an error.
|
|
400
|
+
const pending = this.#takePending(responseId, true);
|
|
374
401
|
if (pending !== undefined) {
|
|
375
402
|
pending.reject(new DomainError({ code: "invalid_request", message: "Malformed kernel RPC response" }));
|
|
376
403
|
}
|
|
@@ -412,13 +439,27 @@ export class MessagePortKernelRpcClient {
|
|
|
412
439
|
|
|
413
440
|
/**
|
|
414
441
|
* @param {string} requestId
|
|
442
|
+
* @param {boolean} answered Whether the kernel actually replied to this request. `true` for
|
|
443
|
+
* any received response, ok or error — an error result is still something coming back.
|
|
444
|
+
* `false` for an expiry, a closed port, or a failed send. Required rather than defaulted
|
|
445
|
+
* so a new call site cannot quietly report a non-answer as an answer.
|
|
415
446
|
* @returns {PendingRequest | undefined}
|
|
416
447
|
*/
|
|
417
|
-
#takePending(requestId) {
|
|
448
|
+
#takePending(requestId, answered) {
|
|
418
449
|
const pending = this.#pending.get(requestId);
|
|
419
450
|
if (pending === undefined) return undefined;
|
|
420
451
|
this.#pending.delete(requestId);
|
|
421
452
|
globalThis.clearTimeout(pending.timeoutId);
|
|
453
|
+
globalThis.clearTimeout(pending.slowBarId);
|
|
454
|
+
try {
|
|
455
|
+
// The request's OWN deadline travels with its elapsed time. Without it the tracker
|
|
456
|
+
// cannot tell 20s of a 300s-declared operation (fast, for what it declared) from 20s of
|
|
457
|
+
// an ordinary one (six times past its bar), and folds both into one absolutely-thresholded
|
|
458
|
+
// average — which latched `slow` on healthy nodes. See `normalizeElapsedMs`.
|
|
459
|
+
this.#observer?.settle(requestId, this.#monotonicNow() - pending.startedAtMs, answered, pending.timeoutMs);
|
|
460
|
+
} catch {
|
|
461
|
+
// Same rule as the bar timer: report-only, never load-bearing.
|
|
462
|
+
}
|
|
422
463
|
return pending;
|
|
423
464
|
}
|
|
424
465
|
|
|
@@ -454,17 +495,29 @@ export class MessagePortKernelRpcClient {
|
|
|
454
495
|
? request.name
|
|
455
496
|
: "capability" in request ? request.capability : request.type;
|
|
456
497
|
const timeoutMs = requestTimeoutMs(request, this.#requestTimeoutMs);
|
|
498
|
+
const startedAtMs = this.#monotonicNow();
|
|
457
499
|
const timeoutId = globalThis.setTimeout(() => {
|
|
458
|
-
this.#takePending(id)?.reject(new KernelRpcTimeoutError(commandName, id, timeoutMs));
|
|
500
|
+
this.#takePending(id, false)?.reject(new KernelRpcTimeoutError(commandName, id, timeoutMs));
|
|
459
501
|
}, timeoutMs);
|
|
460
|
-
|
|
502
|
+
// The earlier of the two timers. It resolves nothing and rejects nothing — it only
|
|
503
|
+
// reports that this request has been outstanding long enough to be worth telling the
|
|
504
|
+
// user about, at a tenth of its OWN declared timeout.
|
|
505
|
+
const slowBarId = globalThis.setTimeout(() => {
|
|
506
|
+
try {
|
|
507
|
+
this.#observer?.markSlow(id);
|
|
508
|
+
} catch {
|
|
509
|
+
// A broken observer must never become a broken request. Instrumentation is
|
|
510
|
+
// report-only and may not degrade the call it is measuring.
|
|
511
|
+
}
|
|
512
|
+
}, slowBarMs(timeoutMs));
|
|
513
|
+
this.#pending.set(id, { reject, resolve, sessionId, slowBarId, startedAtMs, timeoutId, timeoutMs });
|
|
461
514
|
try {
|
|
462
515
|
this.#transport.send(serializeRpcRequest(/** @type {RpcRequest} */ ({ ...request, id })));
|
|
463
516
|
} catch (error) {
|
|
464
517
|
const sendError = error instanceof TransportError
|
|
465
518
|
? error
|
|
466
519
|
: new TransportError({ code: "connection_lost", message: "Kernel RPC send failed" });
|
|
467
|
-
this.#takePending(id)?.reject(sendError);
|
|
520
|
+
this.#takePending(id, false)?.reject(sendError);
|
|
468
521
|
}
|
|
469
522
|
});
|
|
470
523
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @import { ResponsivenessState, ResponsivenessTracker } from "./node-responsiveness.js" */
|
|
3
|
+
/** @import { QueryDiff } from "@econ-v1/domain" */
|
|
4
|
+
|
|
5
|
+
/** @typedef {{ readonly emaMs: number | undefined, readonly id: "node-health", readonly since: number, readonly status: "normal" | "slow" }} NodeHealthRow */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one query name stages use to ask how the node is behaving.
|
|
9
|
+
*
|
|
10
|
+
* It is a live query in every respect a stage can observe, and it is answered WITHOUT crossing
|
|
11
|
+
* the kernel port. That is not an optimisation: if this were an ordinary query it would
|
|
12
|
+
* round-trip through the very port it is reporting on, so a wedged kernel would make
|
|
13
|
+
* "is the kernel wedged?" hang — arriving 30s late, or never, exactly when it is needed.
|
|
14
|
+
*/
|
|
15
|
+
export const NODE_HEALTH_QUERY = "node.health.v1";
|
|
16
|
+
|
|
17
|
+
/** @param {ResponsivenessState} state @returns {NodeHealthRow} */
|
|
18
|
+
function rowOf(state) {
|
|
19
|
+
return Object.freeze({
|
|
20
|
+
emaMs: state.emaMs,
|
|
21
|
+
id: /** @type {const} */ ("node-health"),
|
|
22
|
+
since: state.changedAt,
|
|
23
|
+
status: state.status,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {ResponsivenessTracker} tracker
|
|
29
|
+
* @returns {{ subscribe(listener: (diff: QueryDiff<NodeHealthRow>) => void): () => void }}
|
|
30
|
+
*/
|
|
31
|
+
export function createNodeHealthQuery(tracker) {
|
|
32
|
+
return {
|
|
33
|
+
subscribe(listener) {
|
|
34
|
+
// The single row exists from the first moment, so the opening emission is an add and
|
|
35
|
+
// every later one is an update. A subscriber that folds diffs the ordinary way ends up
|
|
36
|
+
// with exactly one row throughout. `revision` is scoped to this one subscription — like
|
|
37
|
+
// the kernel's own live-query engine (`client/kernel/src/live-query/engine.js`), it
|
|
38
|
+
// increments BEFORE each emission, so the opening emission carries `revision: 1` and
|
|
39
|
+
// every later one increments from there.
|
|
40
|
+
let revision = 0;
|
|
41
|
+
revision += 1;
|
|
42
|
+
listener({ added: [rowOf(tracker.snapshot())], deleted: [], revision, updated: [] });
|
|
43
|
+
return tracker.subscribe((state) => {
|
|
44
|
+
revision += 1;
|
|
45
|
+
listener({ added: [], deleted: [], revision, updated: [rowOf(state)] });
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `lastAnswerAtMs` is the instant the most recent request ACTUALLY CAME BACK — an answer, not
|
|
5
|
+
* an expiry — on this tracker's own clock, or `undefined` while nothing ever has. It is the
|
|
6
|
+
* only field here that can distinguish those two outcomes: `emaMs` folds both into one number
|
|
7
|
+
* and so cannot tell a fast expiry from a slow success apart. See `settle` below.
|
|
8
|
+
*
|
|
9
|
+
* @typedef {{
|
|
10
|
+
* readonly changedAt: number,
|
|
11
|
+
* readonly candidateSince: number | undefined,
|
|
12
|
+
* readonly emaMs: number | undefined,
|
|
13
|
+
* readonly lastAnswerAtMs: number | undefined,
|
|
14
|
+
* readonly slowInFlight: number,
|
|
15
|
+
* readonly status: "normal" | "slow",
|
|
16
|
+
* }} ResponsivenessState
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* `timeoutMs` on a settle is the deadline THAT request declared, and it is what makes the
|
|
20
|
+
* average comparable across requests — see `normalizeElapsedMs`. It is optional because a
|
|
21
|
+
* caller that has not been taught the difference is one whose requests all use the default
|
|
22
|
+
* deadline, which is the frame the average is already expressed in.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {{ type: "slow-bar-passed" }
|
|
25
|
+
* | {
|
|
26
|
+
* readonly answered: boolean,
|
|
27
|
+
* readonly elapsedMs: number,
|
|
28
|
+
* readonly timeoutMs?: number,
|
|
29
|
+
* readonly type: "settled",
|
|
30
|
+
* readonly wasSlow: boolean,
|
|
31
|
+
* }
|
|
32
|
+
* | { type: "tick" }} ResponsivenessEvent
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** A request is "slow" at a tenth of its OWN declared timeout, not at a global constant.
|
|
36
|
+
* A default 30s request is slow at 3s; a host-declared 300s operation at 30s. One rule, and
|
|
37
|
+
* a legitimately long operation cannot mark the node slow merely by being long. */
|
|
38
|
+
export const SLOW_BAR_DIVISOR = 10;
|
|
39
|
+
/** Short: one straggler must not flash the badge. */
|
|
40
|
+
export const RAISE_DWELL_MS = 1_000;
|
|
41
|
+
/** Longer than the raise dwell, deliberately: an intermittent node must not strobe.
|
|
42
|
+
*
|
|
43
|
+
* Raised from 5s to 15s after measuring it against a live node (Task 10). A node alternating
|
|
44
|
+
* 4s slow / 4s healthy flipped the pill TEN times in 42 seconds at 5s, because the clear is
|
|
45
|
+
* structurally favoured: pressure ends the instant the last slow request settles, but does not
|
|
46
|
+
* return until a NEW request has been outstanding for its full slow bar (3s on a default
|
|
47
|
+
* request). Any clear dwell shorter than (healthy window + slow bar) therefore drops the badge
|
|
48
|
+
* on a node that is still visibly misbehaving. 15s absorbs an oscillation of roughly a 10s
|
|
49
|
+
* period; the only cost is that the badge lingers up to 15s after a genuine recovery, which is
|
|
50
|
+
* the side this pair of dwells is meant to err on.
|
|
51
|
+
*
|
|
52
|
+
* The EMA below cannot cover this gap, and it is worth being explicit about why: it is
|
|
53
|
+
* weighted per SAMPLE, not per unit of time, so how long a high average survives depends
|
|
54
|
+
* entirely on request volume. On a busy shell dozens of fast settles land within a second and
|
|
55
|
+
* take the average under EMA_HOLD_MS almost immediately. The dwell is the only time-based
|
|
56
|
+
* brake here. */
|
|
57
|
+
export const CLEAR_DWELL_MS = 15_000;
|
|
58
|
+
/** Recent-weighted. Three fast responses take an 8s average back under EMA_HOLD_MS
|
|
59
|
+
* (8000 -> 5660 -> 4022 -> 2875). A 9s seed needs four: it is still at ~3218 after three.
|
|
60
|
+
* Note this counts RESPONSES, not seconds — see CLEAR_DWELL_MS above. */
|
|
61
|
+
export const EMA_ALPHA = 0.3;
|
|
62
|
+
/** An average at or above this holds `slow` even with nothing currently in flight.
|
|
63
|
+
*
|
|
64
|
+
* ABSOLUTE, and therefore only meaningful against samples that share one frame — which is what
|
|
65
|
+
* `normalizeElapsedMs` below exists to guarantee. Mixing a 30s-deadline request and a
|
|
66
|
+
* 300s-deadline request into one absolutely-thresholded average is a unit error, and it had a
|
|
67
|
+
* user-visible consequence: a single legitimate 20s stage invoke seeded `emaMs = 20000` on an
|
|
68
|
+
* otherwise idle shell, and because the average decays per SAMPLE (see EMA_ALPHA) a quiet shell
|
|
69
|
+
* produced nothing to walk it back down. The status latched `slow` and the 90s notice fired on a
|
|
70
|
+
* perfectly healthy node — exactly what the slow bar's own rule ("a long operation cannot mark
|
|
71
|
+
* the node slow merely by being long") promises cannot happen. */
|
|
72
|
+
export const EMA_HOLD_MS = 3_000;
|
|
73
|
+
|
|
74
|
+
/** The deadline a request gets when it declares none, and for that reason the frame the EMA is
|
|
75
|
+
* expressed in. `message-port-client.js` exports this same value as `DEFAULT_REQUEST_TIMEOUT_MS`
|
|
76
|
+
* — its home is here so the port's default and the average's frame are one number that cannot
|
|
77
|
+
* drift apart. */
|
|
78
|
+
export const EMA_REFERENCE_TIMEOUT_MS = 30_000;
|
|
79
|
+
|
|
80
|
+
/** @param {number} timeoutMs @returns {number} */
|
|
81
|
+
export function slowBarMs(timeoutMs) {
|
|
82
|
+
return Math.max(1, Math.round(timeoutMs / SLOW_BAR_DIVISOR));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One settle's contribution to the average, expressed in DEFAULT-REQUEST-EQUIVALENT ms.
|
|
87
|
+
*
|
|
88
|
+
* The slow bar is relative (a tenth of the request's OWN declared timeout); `EMA_HOLD_MS` is
|
|
89
|
+
* absolute. Scaling each sample by the ratio of the reference bar to the request's own bar is
|
|
90
|
+
* what reconciles the two: a request is worth `EMA_HOLD_MS` to the average exactly when it has
|
|
91
|
+
* reached its own bar, whatever deadline it declared. So 20s of a 300s-declared operation folds
|
|
92
|
+
* in as 2000 (under the hold, and correctly: its bar is 30s), while 20s of an ordinary request
|
|
93
|
+
* folds in as 20000 (well past its 3s bar). One rule, one frame, and the promise in
|
|
94
|
+
* `SLOW_BAR_DIVISOR` finally holds for the average as well as for the in-flight bar.
|
|
95
|
+
*
|
|
96
|
+
* The scale is CAPPED AT 1 — normalization may only ever discount a sample, never inflate one.
|
|
97
|
+
* A caller may construct the port with a shorter-than-default deadline (`requestTimeoutMs`), and
|
|
98
|
+
* inflating its ordinary settles would invent slowness on a node that is answering promptly.
|
|
99
|
+
* This function can therefore only ever remove `slow`, never add it, which is the side this
|
|
100
|
+
* whole indicator errs on.
|
|
101
|
+
*
|
|
102
|
+
* @param {number} elapsedMs
|
|
103
|
+
* @param {number | undefined} timeoutMs The deadline that request declared, or `undefined` for
|
|
104
|
+
* a caller whose requests all use the default deadline.
|
|
105
|
+
* @returns {number}
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeElapsedMs(elapsedMs, timeoutMs) {
|
|
108
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return elapsedMs;
|
|
109
|
+
const scale = slowBarMs(EMA_REFERENCE_TIMEOUT_MS) / slowBarMs(timeoutMs);
|
|
110
|
+
return scale >= 1 ? elapsedMs : elapsedMs * scale;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @returns {ResponsivenessState} */
|
|
114
|
+
export function initialResponsiveness() {
|
|
115
|
+
return Object.freeze({
|
|
116
|
+
changedAt: 0,
|
|
117
|
+
candidateSince: undefined,
|
|
118
|
+
emaMs: undefined,
|
|
119
|
+
lastAnswerAtMs: undefined,
|
|
120
|
+
slowInFlight: 0,
|
|
121
|
+
status: /** @type {const} */ ("normal"),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The whole state machine. Pressure (something past its bar, or a high recent average) pulls
|
|
127
|
+
* toward `slow`; its absence pulls toward `normal`. Neither takes effect until it has held for
|
|
128
|
+
* its dwell, and the dwells are deliberately asymmetric.
|
|
129
|
+
*
|
|
130
|
+
* @param {ResponsivenessState} state
|
|
131
|
+
* @param {ResponsivenessEvent} event
|
|
132
|
+
* @param {number} nowMs
|
|
133
|
+
* @returns {ResponsivenessState}
|
|
134
|
+
*/
|
|
135
|
+
export function nextResponsiveness(state, event, nowMs) {
|
|
136
|
+
let slowInFlight = state.slowInFlight;
|
|
137
|
+
let emaMs = state.emaMs;
|
|
138
|
+
let lastAnswerAtMs = state.lastAnswerAtMs;
|
|
139
|
+
|
|
140
|
+
if (event.type === "slow-bar-passed") {
|
|
141
|
+
slowInFlight += 1;
|
|
142
|
+
} else if (event.type === "settled") {
|
|
143
|
+
// Guarded rather than trusted: a settle for a request that never passed its bar, or a
|
|
144
|
+
// duplicate settle, must not drive the count below zero and pin the node "normal".
|
|
145
|
+
if (event.wasSlow) {
|
|
146
|
+
slowInFlight = Math.max(0, slowInFlight - 1);
|
|
147
|
+
}
|
|
148
|
+
// Normalized, never raw: the average is thresholded absolutely, so every sample entering it
|
|
149
|
+
// has to be in one frame. See `normalizeElapsedMs`.
|
|
150
|
+
const sampleMs = normalizeElapsedMs(event.elapsedMs, event.timeoutMs);
|
|
151
|
+
emaMs = emaMs === undefined
|
|
152
|
+
? sampleMs
|
|
153
|
+
: (EMA_ALPHA * sampleMs) + ((1 - EMA_ALPHA) * emaMs);
|
|
154
|
+
// Expiries deliberately still move the average — a request that ran its whole deadline out
|
|
155
|
+
// IS evidence of slowness — but only an ANSWER moves this clock. That asymmetry is the
|
|
156
|
+
// whole point of the field: `emaMs` answers "how long are round trips taking", while this
|
|
157
|
+
// answers "did anything come back", and no single average can answer both.
|
|
158
|
+
if (event.answered) {
|
|
159
|
+
lastAnswerAtMs = nowMs;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pressure = slowInFlight > 0 || (emaMs !== undefined && emaMs >= EMA_HOLD_MS);
|
|
164
|
+
const target = pressure ? "slow" : "normal";
|
|
165
|
+
|
|
166
|
+
if (target === state.status) {
|
|
167
|
+
// Pressure agrees with where we already are: any pending transition is abandoned.
|
|
168
|
+
return Object.freeze({
|
|
169
|
+
changedAt: state.changedAt,
|
|
170
|
+
candidateSince: undefined,
|
|
171
|
+
emaMs,
|
|
172
|
+
lastAnswerAtMs,
|
|
173
|
+
slowInFlight,
|
|
174
|
+
status: state.status,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const candidateSince = state.candidateSince ?? nowMs;
|
|
179
|
+
const dwellMs = target === "slow" ? RAISE_DWELL_MS : CLEAR_DWELL_MS;
|
|
180
|
+
if (nowMs - candidateSince >= dwellMs) {
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
changedAt: nowMs,
|
|
183
|
+
candidateSince: undefined,
|
|
184
|
+
emaMs,
|
|
185
|
+
lastAnswerAtMs,
|
|
186
|
+
slowInFlight,
|
|
187
|
+
status: target,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
changedAt: state.changedAt,
|
|
193
|
+
candidateSince,
|
|
194
|
+
emaMs,
|
|
195
|
+
lastAnswerAtMs,
|
|
196
|
+
slowInFlight,
|
|
197
|
+
status: state.status,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @typedef {{
|
|
203
|
+
* dispose(): void,
|
|
204
|
+
* markSlow(id: string): void,
|
|
205
|
+
* settle(id: string, elapsedMs: number, answered?: boolean, timeoutMs?: number): void,
|
|
206
|
+
* snapshot(): ResponsivenessState,
|
|
207
|
+
* subscribe(listener: (state: ResponsivenessState) => void): () => void,
|
|
208
|
+
* }} ResponsivenessTracker
|
|
209
|
+
*/
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The instant a pending transition becomes due, or `undefined` when none is pending.
|
|
213
|
+
*
|
|
214
|
+
* Derived purely from the state, which is what makes the tracker's timer a DEADLINE rather than
|
|
215
|
+
* a heartbeat: there is exactly one such instant per pending transition, it is known the moment
|
|
216
|
+
* the transition is proposed, and it does not move while the transition stays pending. The dwell
|
|
217
|
+
* to apply is chosen by the status being LEFT — `candidateSince` is set only when the target
|
|
218
|
+
* disagrees with `status`, so a pending transition out of `slow` is the clear (long) dwell and
|
|
219
|
+
* one out of `normal` is the raise (short) one.
|
|
220
|
+
*
|
|
221
|
+
* @param {ResponsivenessState} state
|
|
222
|
+
* @returns {number | undefined}
|
|
223
|
+
*/
|
|
224
|
+
function dwellDueAtMs(state) {
|
|
225
|
+
if (state.candidateSince === undefined) return undefined;
|
|
226
|
+
return state.candidateSince + (state.status === "slow" ? CLEAR_DWELL_MS : RAISE_DWELL_MS);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The stateful wrapper around `nextResponsiveness`.
|
|
231
|
+
*
|
|
232
|
+
* It exists for one reason the reducer cannot cover: a dwell has to be able to expire when
|
|
233
|
+
* NOTHING else happens. A node that stops answering entirely produces no further observations,
|
|
234
|
+
* and that is precisely the case that must raise `slow`. So every state carrying a pending
|
|
235
|
+
* transition arms a ONE-SHOT timer for the instant `dwellDueAtMs` names.
|
|
236
|
+
*
|
|
237
|
+
* NOT A LOOP, and the distinction is the whole reason CLIENT_NODE_CONVENTIONS C1 exempts a
|
|
238
|
+
* one-shot delay while forbidding a re-armed one. The timer fires once per pending transition
|
|
239
|
+
* and then stops: the `tick` it dispatches carries no new observation, so pressure — and with it
|
|
240
|
+
* the target — is unchanged, and by construction the dwell has elapsed, so `nextResponsiveness`
|
|
241
|
+
* takes the completing branch and clears `candidateSince`. With `candidateSince` cleared there
|
|
242
|
+
* is nothing left to arm. The only way a second timer follows is a host that fired the first one
|
|
243
|
+
* fractionally early, which re-arms for the remaining milliseconds and then converges; it cannot
|
|
244
|
+
* become recurring, because a state with no pending transition arms nothing at all.
|
|
245
|
+
*
|
|
246
|
+
* @param {{ readonly now?: () => number }} [options]
|
|
247
|
+
* @returns {ResponsivenessTracker}
|
|
248
|
+
*/
|
|
249
|
+
export function createResponsivenessTracker(options = {}) {
|
|
250
|
+
const now = options.now ?? (() => Date.now());
|
|
251
|
+
/** @type {Set<string>} */
|
|
252
|
+
const slowIds = new Set();
|
|
253
|
+
/** @type {Set<(state: ResponsivenessState) => void>} */
|
|
254
|
+
const listeners = new Set();
|
|
255
|
+
/** @type {ReturnType<typeof globalThis.setTimeout> | undefined} */
|
|
256
|
+
let dwellTimer;
|
|
257
|
+
let state = initialResponsiveness();
|
|
258
|
+
let disposed = false;
|
|
259
|
+
|
|
260
|
+
function clearDwellTimer() {
|
|
261
|
+
if (dwellTimer !== undefined) {
|
|
262
|
+
globalThis.clearTimeout(dwellTimer);
|
|
263
|
+
dwellTimer = undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The timer's callback, and the ONLY thing it does: hand the reducer one `tick` so a dwell
|
|
268
|
+
* that came due with nothing else happening can complete. Named and hoisted out of the arming
|
|
269
|
+
* site deliberately — scheduling a deadline and reducing an event are two jobs, and keeping
|
|
270
|
+
* them apart is what makes it legible that no timer here schedules its own successor. */
|
|
271
|
+
function dwellDue() {
|
|
272
|
+
dwellTimer = undefined;
|
|
273
|
+
apply({ type: "tick" });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Arm the single one-shot deadline for the currently pending transition. Clamped at zero for
|
|
277
|
+
* a deadline already in the past (a clock that moved under us), which fires on the next turn
|
|
278
|
+
* of the loop rather than never.
|
|
279
|
+
* @param {number} dueAtMs */
|
|
280
|
+
function armDwellDeadline(dueAtMs) {
|
|
281
|
+
dwellTimer = globalThis.setTimeout(dwellDue, Math.max(0, dueAtMs - now()));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @param {ResponsivenessEvent} event */
|
|
285
|
+
function apply(event) {
|
|
286
|
+
if (disposed) return;
|
|
287
|
+
const previous = state;
|
|
288
|
+
state = nextResponsiveness(state, event, now());
|
|
289
|
+
clearDwellTimer();
|
|
290
|
+
const dueAtMs = dwellDueAtMs(state);
|
|
291
|
+
if (dueAtMs !== undefined) {
|
|
292
|
+
armDwellDeadline(dueAtMs);
|
|
293
|
+
}
|
|
294
|
+
// Publish on a status change only. Every settle moves the EMA a little, and republishing
|
|
295
|
+
// on each would rerender the pill continuously on a busy-but-healthy node.
|
|
296
|
+
if (state.status !== previous.status) {
|
|
297
|
+
for (const listener of listeners) listener(state);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
dispose() {
|
|
303
|
+
disposed = true;
|
|
304
|
+
clearDwellTimer();
|
|
305
|
+
listeners.clear();
|
|
306
|
+
slowIds.clear();
|
|
307
|
+
},
|
|
308
|
+
markSlow(id) {
|
|
309
|
+
if (disposed || slowIds.has(id)) return;
|
|
310
|
+
slowIds.add(id);
|
|
311
|
+
apply({ type: "slow-bar-passed" });
|
|
312
|
+
},
|
|
313
|
+
/**
|
|
314
|
+
* A request left the flight list. `answered` says HOW: `true` when the kernel actually
|
|
315
|
+
* replied (an ok result or an error result — either way something came back), `false` when
|
|
316
|
+
* the request expired, the port closed under it, or the send failed.
|
|
317
|
+
*
|
|
318
|
+
* It defaults to `true` because "settled" meant "came back" before the outcome was carried
|
|
319
|
+
* at all, and a caller that has not been taught the difference is one that only ever
|
|
320
|
+
* reports real replies. `MessagePortKernelRpcClient` passes it explicitly at every site.
|
|
321
|
+
*
|
|
322
|
+
* `timeoutMs` is the deadline THAT request declared, which is what makes `elapsedMs`
|
|
323
|
+
* comparable with every other sample (`normalizeElapsedMs`). Omitting it says "this request
|
|
324
|
+
* used the default deadline" — the frame the average is already in — so an untaught caller
|
|
325
|
+
* gets exactly the pre-existing behaviour.
|
|
326
|
+
*/
|
|
327
|
+
settle(id, elapsedMs, answered = true, timeoutMs = undefined) {
|
|
328
|
+
if (disposed) return;
|
|
329
|
+
const wasSlow = slowIds.delete(id);
|
|
330
|
+
apply({ answered, elapsedMs, type: "settled", wasSlow, ...(timeoutMs === undefined ? {} : { timeoutMs }) });
|
|
331
|
+
},
|
|
332
|
+
snapshot() {
|
|
333
|
+
return state;
|
|
334
|
+
},
|
|
335
|
+
subscribe(listener) {
|
|
336
|
+
listeners.add(listener);
|
|
337
|
+
return () => {
|
|
338
|
+
listeners.delete(listener);
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|