@nimbus-sh/core 0.5.0 → 0.6.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/_shared/cache-stats.d.ts +23 -1
- package/dist/_shared/cache-stats.d.ts.map +1 -1
- package/dist/_shared/cache-stats.js +13 -1
- package/dist/_shared/exports-resolver.d.ts +1 -1
- package/dist/_shared/exports-resolver.js +1 -1
- package/dist/_shared/retry.js +1 -1
- package/dist/constants.d.ts +5 -11
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +16 -56
- package/dist/runtime/assets-loader.js +1 -1
- package/dist/runtime/virtual-socket-kernel.generated.d.ts +1 -1
- package/dist/runtime/virtual-socket-kernel.generated.js +1 -1
- package/dist/vfs/seed-project.d.ts.map +1 -1
- package/dist/vfs/seed-project.js +1 -1
- package/dist/vfs/sqlite-vfs.d.ts +2 -30
- package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
- package/dist/vfs/sqlite-vfs.js +7 -6
- package/package.json +3 -2
- package/src/_shared/cache-stats.ts +27 -1
- package/src/_shared/exports-resolver.ts +1 -1
- package/src/_shared/retry.ts +1 -1
- package/src/constants.ts +30 -60
- package/src/runtime/assets-loader.ts +1 -1
- package/src/runtime/virtual-socket-kernel.generated.ts +1 -1
- package/src/vfs/seed-project.ts +3 -3
- package/src/vfs/sqlite-vfs.ts +24 -41
- package/dist/_shared/install-phase.d.ts +0 -47
- package/dist/_shared/install-phase.d.ts.map +0 -1
- package/dist/_shared/install-phase.js +0 -1
- package/dist/_shared/rpc-dispose.d.ts +0 -4
- package/dist/_shared/rpc-dispose.d.ts.map +0 -1
- package/dist/_shared/rpc-dispose.js +0 -30
- package/dist/_shared/w7-frame.d.ts +0 -79
- package/dist/_shared/w7-frame.d.ts.map +0 -1
- package/dist/_shared/w7-frame.js +0 -840
- package/dist/_shared/weighted-credit-pool.d.ts +0 -61
- package/dist/_shared/weighted-credit-pool.d.ts.map +0 -1
- package/dist/_shared/weighted-credit-pool.js +0 -196
- package/dist/observability/diag-counters.d.ts +0 -236
- package/dist/observability/diag-counters.d.ts.map +0 -1
- package/dist/observability/diag-counters.js +0 -195
- package/dist/observability/heap-estimate.d.ts +0 -225
- package/dist/observability/heap-estimate.d.ts.map +0 -1
- package/dist/observability/heap-estimate.js +0 -185
- package/dist/observability/heavy-alloc-coord.d.ts +0 -93
- package/dist/observability/heavy-alloc-coord.d.ts.map +0 -1
- package/dist/observability/heavy-alloc-coord.js +0 -181
- package/dist/observability/oom-classify.d.ts +0 -138
- package/dist/observability/oom-classify.d.ts.map +0 -1
- package/dist/observability/oom-classify.js +0 -268
- package/dist/observability/oom-discriminator.d.ts +0 -176
- package/dist/observability/oom-discriminator.d.ts.map +0 -1
- package/dist/observability/oom-discriminator.js +0 -272
- package/src/_shared/install-phase.ts +0 -56
- package/src/_shared/rpc-dispose.ts +0 -31
- package/src/_shared/w7-frame.ts +0 -1013
- package/src/_shared/weighted-credit-pool.ts +0 -244
- package/src/observability/diag-counters.ts +0 -343
- package/src/observability/heap-estimate.ts +0 -331
- package/src/observability/heavy-alloc-coord.ts +0 -241
- package/src/observability/oom-classify.ts +0 -260
- package/src/observability/oom-discriminator.ts +0 -405
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* oom-classify.ts — discriminator for OOM-like errors at Nimbus
|
|
3
|
-
* boundaries.
|
|
4
|
-
*
|
|
5
|
-
* Why this exists
|
|
6
|
-
* ───────────────
|
|
7
|
-
* Nimbus has at least five distinct "the work failed" failure modes
|
|
8
|
-
* that all surface today as either a thrown JS Error or a console
|
|
9
|
-
* line:
|
|
10
|
-
*
|
|
11
|
-
* 1. SQLITE_NOMEM at the storage layer (per-DO SQLite cap, post-
|
|
12
|
-
* STOR/SPEC: Address SQLITE_NOMEM issues).
|
|
13
|
-
* 2. Isolate memory exhaustion → 'oom'.
|
|
14
|
-
* 3. Isolate CPU-budget exhaustion → 'cpu_exceeded'.
|
|
15
|
-
* 4. Structured-clone refusal (`Cannot deserialize cloned data`)
|
|
16
|
-
* between supervisor ↔ facet RPC (32 MiB cap).
|
|
17
|
-
* 5. RPC timeout (TimeoutError from facet-pool's per-task race).
|
|
18
|
-
*
|
|
19
|
-
* Plus a few platform-side terminations (subrequest cap, condemnation,
|
|
20
|
-
* hard eviction) that the user sees but Nimbus has no first-party
|
|
21
|
-
* signal for.
|
|
22
|
-
*
|
|
23
|
-
* Memory and CPU are separate buckets
|
|
24
|
-
* ───────────────────────────────────
|
|
25
|
-
* workerd models them as distinct trace outcomes — `exceededMemory` vs
|
|
26
|
-
* `exceededCpu` (EventOutcome, workerd/io/outcome.capnp) — and words the
|
|
27
|
-
* two message families so neither one's signature appears in the other:
|
|
28
|
-
*
|
|
29
|
-
* memory "Worker has exceeded memory limit."
|
|
30
|
-
* "Worker exceeded memory limit."
|
|
31
|
-
* "broken.exceededMemory; jsg.Error: Durable Object's isolate
|
|
32
|
-
* exceeded its memory limit …"
|
|
33
|
-
* "Memory limit exceeded" (RangeError)
|
|
34
|
-
* cpu "Worker exceeded CPU time limit."
|
|
35
|
-
* "Durable Object exceeded its CPU time limit and was reset."
|
|
36
|
-
* "Python Worker exceeded CPU time limit"
|
|
37
|
-
*
|
|
38
|
-
* Read a low 'cpu_exceeded' count carefully: neither condition reliably
|
|
39
|
-
* produces a message. Both are uncatchable inside the isolate that died,
|
|
40
|
-
* so the string is only observable by a CALLER across an RPC boundary,
|
|
41
|
-
* and Nimbus has repeatedly measured isolates vanishing with no throw at
|
|
42
|
-
* all. Absence of 'cpu_exceeded' entries is therefore NOT evidence that
|
|
43
|
-
* CPU was not the cause — confirm against `wrangler tail` either way.
|
|
44
|
-
*
|
|
45
|
-
* Without a classifier, every recordFailure() call has to stringify-
|
|
46
|
-
* match its own error site. With this module, we pin the regex once
|
|
47
|
-
* and reuse it everywhere.
|
|
48
|
-
*
|
|
49
|
-
* Inputs we accept
|
|
50
|
-
* ────────────────
|
|
51
|
-
* The classifier accepts:
|
|
52
|
-
* - Error instances (read e.message)
|
|
53
|
-
* - plain strings (use directly — for stderr-line classification at
|
|
54
|
-
* facet-exit boundaries where we don't have an Error)
|
|
55
|
-
* - anything else (return 'unknown')
|
|
56
|
-
*
|
|
57
|
-
* Output values
|
|
58
|
-
* ─────────────
|
|
59
|
-
* Same set used by `DiagFailure.cause` in oom-discriminator.ts. Keep
|
|
60
|
-
* the union narrow and additive — adding a new value is fine, but
|
|
61
|
-
* never re-purpose an existing one.
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
const OOM_CAUSES = [
|
|
65
|
-
'sqlite_nomem',
|
|
66
|
-
'oom',
|
|
67
|
-
'cpu_exceeded',
|
|
68
|
-
'clone_refused',
|
|
69
|
-
'rpc_timeout',
|
|
70
|
-
'subrequest_cap',
|
|
71
|
-
'dynamic_worker_cap',
|
|
72
|
-
'condemnation',
|
|
73
|
-
'hard_evict',
|
|
74
|
-
'unknown',
|
|
75
|
-
] as const;
|
|
76
|
-
|
|
77
|
-
export type OomCause = typeof OOM_CAUSES[number];
|
|
78
|
-
|
|
79
|
-
export function isOomCause(input: unknown): input is OomCause {
|
|
80
|
-
return OOM_CAUSES.some((cause) => cause === input);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Classify an error or message string into an OomCause. Returns
|
|
85
|
-
* 'unknown' when no signature matches — callers should still record
|
|
86
|
-
* the message via DiagFailure.message so a human can later widen the
|
|
87
|
-
* classifier rules.
|
|
88
|
-
*/
|
|
89
|
-
export function classifyError(input: unknown): OomCause {
|
|
90
|
-
const msg = readMessage(input);
|
|
91
|
-
if (msg.length === 0) return 'unknown';
|
|
92
|
-
return classifyMessage(msg);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Variant for callers that already have the message string. Prefer
|
|
97
|
-
* classifyError() at boundaries; this is exposed for cases where the
|
|
98
|
-
* message has already been extracted (e.g. truncated / sanitised).
|
|
99
|
-
*/
|
|
100
|
-
export function classifyMessage(msg: string): OomCause {
|
|
101
|
-
// Use lower-case match for forgiveness; SQLITE_NOMEM is canonically
|
|
102
|
-
// upper but stderr can be either.
|
|
103
|
-
const m = msg.toLowerCase();
|
|
104
|
-
|
|
105
|
-
// SQLITE_NOMEM signals
|
|
106
|
-
if (m.includes('sqlite_nomem')) return 'sqlite_nomem';
|
|
107
|
-
if (m.includes('out of memory')) return 'sqlite_nomem';
|
|
108
|
-
if (m.includes('database or disk is full')) return 'sqlite_nomem';
|
|
109
|
-
|
|
110
|
-
// Structured-clone refusal — a 32 MiB-cap cousin
|
|
111
|
-
if (m.includes('deserialize cloned data')) return 'clone_refused';
|
|
112
|
-
if (m.includes('could not be cloned')) return 'clone_refused';
|
|
113
|
-
|
|
114
|
-
// RPC timeout (Nimbus's TimeoutError from facet-pool)
|
|
115
|
-
if (m.includes('timeouterror')) return 'rpc_timeout';
|
|
116
|
-
if (/\btimed?\s*out\b/.test(m)) return 'rpc_timeout';
|
|
117
|
-
|
|
118
|
-
// Subrequest cap (Cloudflare platform)
|
|
119
|
-
if (m.includes('too many subrequests')) return 'subrequest_cap';
|
|
120
|
-
|
|
121
|
-
// Worker Loader concurrency cap (Cloudflare platform): a Durable Object
|
|
122
|
-
// admits ~5-6 concurrent dynamic workers, and loader-cache entries are
|
|
123
|
-
// never released — every distinct loader.get(id) permanently consumes a
|
|
124
|
-
// slot, so this cap recurs until the DO itself is replaced.
|
|
125
|
-
if (m.includes('too many concurrent dynamic workers')) return 'dynamic_worker_cap';
|
|
126
|
-
|
|
127
|
-
// Memory exhaustion. workerd trace outcome `exceededMemory`; in-band it
|
|
128
|
-
// arrives as a `broken.exceededMemory` actor break, a "…exceeded [its]
|
|
129
|
-
// memory limit" kill, or the "Memory limit exceeded" RangeError.
|
|
130
|
-
if (m.includes('exceededmemory')) return 'oom';
|
|
131
|
-
if (/exceeded (?:its )?memory limit/.test(m)) return 'oom';
|
|
132
|
-
if (m.includes('memory limit exceeded')) return 'oom';
|
|
133
|
-
if (m.includes('memory limit') && m.includes('reset')) return 'oom';
|
|
134
|
-
|
|
135
|
-
// CPU-budget exhaustion. A DIFFERENT platform condition from memory —
|
|
136
|
-
// workerd reports it as trace outcome `exceededCpu`. Never fold the two:
|
|
137
|
-
// a CPU kill recurs on the same input, a memory kill recurs on the same
|
|
138
|
-
// working-set size, and the remedies are opposites.
|
|
139
|
-
//
|
|
140
|
-
// `cpu time limit` covers both scopes, which differ only by the
|
|
141
|
-
// possessive: "Worker exceeded CPU time limit." and "Durable Object
|
|
142
|
-
// exceeded its CPU time limit and was reset."
|
|
143
|
-
//
|
|
144
|
-
// Wall-clock handler timeouts are deliberately NOT matched here.
|
|
145
|
-
// "Actor exceeded event execution time and was disconnected." is the
|
|
146
|
-
// caller-configurable setHibernatableWebSocketEventTimeout() bound and
|
|
147
|
-
// "Alarm exceeded its allowed execution time" is its alarm twin. Both
|
|
148
|
-
// are elapsed-time bounds a handler blocked on I/O trips without burning
|
|
149
|
-
// CPU, so bucketing them here would repeat the merge this split undoes.
|
|
150
|
-
if (m.includes('cpu time limit')) return 'cpu_exceeded';
|
|
151
|
-
if (m.includes('exceeded cpu limit')) return 'cpu_exceeded';
|
|
152
|
-
|
|
153
|
-
// Explicit eviction (per CF research §A.1.2)
|
|
154
|
-
if (m.includes('condemned')) return 'condemnation';
|
|
155
|
-
if (m.includes('hard evict')) return 'hard_evict';
|
|
156
|
-
|
|
157
|
-
return 'unknown';
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Transient Durable Object reset — the object was reset by the platform
|
|
162
|
-
* mid-request for a reason unrelated to the request's own resource use:
|
|
163
|
-
* a code deploy rolling over, or a storage-subsystem cold-start hiccup.
|
|
164
|
-
* The in-flight request/RPC rejects, but the work itself never ran to a
|
|
165
|
-
* conclusion and is safe to re-attempt.
|
|
166
|
-
*
|
|
167
|
-
* Deliberately narrow: it must NOT match resource kills ("isolate exceeded
|
|
168
|
-
* its memory limit and was reset", "Durable Object exceeded its CPU time
|
|
169
|
-
* limit and was reset"), which classifyMessage() routes to 'oom' and
|
|
170
|
-
* 'cpu_exceeded' — those recur on retry and must surface, not loop. Note
|
|
171
|
-
* both end in "and was reset"; the checks below key on the CAUSE clause,
|
|
172
|
-
* never on the reset itself.
|
|
173
|
-
* Observed verbatim signatures:
|
|
174
|
-
* - "Durable Object reset because its code was updated."
|
|
175
|
-
* - "Internal error while starting up Durable Object storage caused
|
|
176
|
-
* object to be reset; reference = ..."
|
|
177
|
-
* - "Internal error in Durable Object storage caused object to be reset;
|
|
178
|
-
* reference = ..."
|
|
179
|
-
* - "Durable Object storage operation exceeded timeout which caused
|
|
180
|
-
* the object to be reset."
|
|
181
|
-
*
|
|
182
|
-
* The second and third are the SAME condition worded for two moments —
|
|
183
|
-
* startup versus a live write — and only the first was matched here, which is
|
|
184
|
-
* why an object reset mid-write still failed a whole install. Nimbus has
|
|
185
|
-
* measured the live-write wording twice: a 45.7 MB single-turn write reset the
|
|
186
|
-
* object once and the same write then succeeded 12/12 on retry
|
|
187
|
-
* (`vfs/facet-resident-store.ts`), and it reset a session DO mid-npm-install
|
|
188
|
-
* on 2026-08-10 (probe `agentic-cli/new/pi-official-installer`). Retrying is
|
|
189
|
-
* bounded, so the one case that is NOT transient — crossing the object's
|
|
190
|
-
* storage budget, which the platform also reports this way
|
|
191
|
-
* (`loaders/process-fabric.ts`) — recurs, exhausts the budget and surfaces.
|
|
192
|
-
*/
|
|
193
|
-
export function isTransientDoReset(input: unknown): boolean {
|
|
194
|
-
const m = readMessage(input).toLowerCase();
|
|
195
|
-
if (m.length === 0) return false;
|
|
196
|
-
if (m.includes('reset because its code was updated')) return true;
|
|
197
|
-
if (m.includes('durable object storage caused object to be reset')) return true;
|
|
198
|
-
if (m.includes('storage operation') && m.includes('reset')) return true;
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Workerd shed the call because the target Durable Object's input-gate
|
|
204
|
-
* queue was too deep or too old: "Durable Object is overloaded."
|
|
205
|
-
*
|
|
206
|
-
* Distinct from `isTransientDoReset` on purpose. The object is alive and
|
|
207
|
-
* the work never started, so the call is safe to re-attempt — but the
|
|
208
|
-
* cause is queue pressure, so the only useful retry is one that backs off
|
|
209
|
-
* long enough for the queue to drain. Callers pair this with a longer
|
|
210
|
-
* backoff than a reset retry uses.
|
|
211
|
-
*/
|
|
212
|
-
export function isDoOverloaded(input: unknown): boolean {
|
|
213
|
-
return readMessage(input).toLowerCase().includes('durable object is overloaded');
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* One line about a failure, in terms someone can act on.
|
|
218
|
-
*
|
|
219
|
-
* npm install's catch sites each wrote `e?.remoteMessage || e?.message ||
|
|
220
|
-
* String(e)`. That reduces a failure to a bare sentence, and when the platform
|
|
221
|
-
* declines to describe a rejected Durable Object call the sentence is the word
|
|
222
|
-
* `internal error` — which is how `resolver-fanout layer 2 failed: internal
|
|
223
|
-
* error` reached users for months carrying nothing at all. The message is
|
|
224
|
-
* often the least we know: the error's class, whether the description came
|
|
225
|
-
* from a remote isolate, and which condition it classifies as are all still in
|
|
226
|
-
* hand, and naming them costs nothing and invents nothing.
|
|
227
|
-
*/
|
|
228
|
-
export function describeError(input: unknown): string {
|
|
229
|
-
const remote = typeof input === 'object' && input !== null
|
|
230
|
-
? (input as { remoteMessage?: unknown }).remoteMessage
|
|
231
|
-
: undefined;
|
|
232
|
-
const message = (typeof remote === 'string' && remote.length > 0)
|
|
233
|
-
? `${remote} (remote)`
|
|
234
|
-
: readMessage(input) || String(input);
|
|
235
|
-
const name = input instanceof Error && input.name && input.name !== 'Error'
|
|
236
|
-
? `${input.name}: `
|
|
237
|
-
: '';
|
|
238
|
-
const cause = classifyError(input);
|
|
239
|
-
const suffix = cause !== 'unknown' ? ` [${cause}]`
|
|
240
|
-
: isTransientDoReset(input) ? ' [transient-do-reset]'
|
|
241
|
-
: isDoOverloaded(input) ? ' [do-overloaded]'
|
|
242
|
-
: '';
|
|
243
|
-
return `${name}${message}${suffix}`;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function readMessage(input: unknown): string {
|
|
247
|
-
if (input == null) return '';
|
|
248
|
-
if (typeof input === 'string') return input;
|
|
249
|
-
if (input instanceof Error) return input.message ?? '';
|
|
250
|
-
// Some thrown values are plain objects with .message
|
|
251
|
-
if (typeof input === 'object') {
|
|
252
|
-
const m = (input as { message?: unknown }).message;
|
|
253
|
-
if (typeof m === 'string') return m;
|
|
254
|
-
}
|
|
255
|
-
try {
|
|
256
|
-
return String(input);
|
|
257
|
-
} catch {
|
|
258
|
-
return '';
|
|
259
|
-
}
|
|
260
|
-
}
|
|
@@ -1,405 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* oom-discriminator.ts — singleton ring buffer for OOM / failure
|
|
3
|
-
* forensics + last-known-RPC-frame + last-known-facet-id +
|
|
4
|
-
* session-recovery-event ring, plus snapshot/rehydrate hooks for DO
|
|
5
|
-
* storage persistence.
|
|
6
|
-
*
|
|
7
|
-
* W5 (Lever 5 / J.1.1) — original module. Pairs with oom-classify.ts.
|
|
8
|
-
* C'.2 — added the recovery_event ring.
|
|
9
|
-
*
|
|
10
|
-
* Why singleton-per-isolate
|
|
11
|
-
* ─────────────────────────
|
|
12
|
-
* Same pattern as src/observability/diag-counters.ts. The supervisor bundle
|
|
13
|
-
* is the consumer; all writers (sqlite-vfs, facet-pool, facet-manager,
|
|
14
|
-
* supervisor-rpc, npm-installer, nimbus-session) live in the same
|
|
15
|
-
* isolate. Module scope provides one process-local diagnostic without
|
|
16
|
-
* threading a handle through each writer.
|
|
17
|
-
*
|
|
18
|
-
* Two distinct rings
|
|
19
|
-
* ──────────────────
|
|
20
|
-
* - failures: things that failed (the original W5 ring).
|
|
21
|
-
* - recoveryEvents: lifecycle transitions of the session. Cold isolate
|
|
22
|
-
* boot → 'cold' → 'hydrated' → 'active' → 'drained'.
|
|
23
|
-
*
|
|
24
|
-
* Bounded-size guarantees
|
|
25
|
-
* ───────────────────────
|
|
26
|
-
* - failures ring: 50 entries (RING_SIZE).
|
|
27
|
-
* - recoveryEvents ring: 50 entries (RECOVERY_RING_SIZE).
|
|
28
|
-
* - per-message cap: 200 chars (truncated at insert).
|
|
29
|
-
* - per-RPC-frame: single slot, one object.
|
|
30
|
-
* - per-facet-id: single slot, one object.
|
|
31
|
-
*
|
|
32
|
-
* Snapshot size stays bounded even with both rings full.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import { isOomCause, type OomCause } from './oom-classify.js';
|
|
36
|
-
|
|
37
|
-
const RING_SIZE = 50;
|
|
38
|
-
const RECOVERY_RING_SIZE = 50;
|
|
39
|
-
const MESSAGE_CAP = 200;
|
|
40
|
-
|
|
41
|
-
export interface RpcFrame {
|
|
42
|
-
method: string;
|
|
43
|
-
payloadBytes: number;
|
|
44
|
-
atMs: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface FacetId {
|
|
48
|
-
codeId: string;
|
|
49
|
-
slotIndex: number;
|
|
50
|
-
atMs: number;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface DiagFailure {
|
|
54
|
-
/** ms epoch */
|
|
55
|
-
at: number;
|
|
56
|
-
/** Lifecycle stage (e.g. 'install', 'resolve', 'rpc', 'facet', 'ws'). */
|
|
57
|
-
phase: string;
|
|
58
|
-
/** Discriminated cause. See oom-classify.ts. */
|
|
59
|
-
cause: OomCause;
|
|
60
|
-
/** Best-effort RSS estimate in bytes (peak observed). */
|
|
61
|
-
rssEstimateBytes: number;
|
|
62
|
-
/** process.memoryUsage().heapUsed if available; 0 in DO contexts. */
|
|
63
|
-
heapUsedBytes: number;
|
|
64
|
-
/** SqliteVFS LRU hot bytes at time of failure. */
|
|
65
|
-
lruBytes: number;
|
|
66
|
-
/** Sum of in-flight RPC payload bytes (best-effort). */
|
|
67
|
-
inFlightBytes: number;
|
|
68
|
-
/** Snapshot of the most recent RPC frame, if any. */
|
|
69
|
-
lastRpcFrame: RpcFrame | null;
|
|
70
|
-
/** Snapshot of the most recent facet dispatch, if any. */
|
|
71
|
-
lastFacetId: FacetId | null;
|
|
72
|
-
/** Optional facet exit code (when failure represents a facet termination). */
|
|
73
|
-
exitCode?: number;
|
|
74
|
-
/** Optional facet pid. */
|
|
75
|
-
pid?: number;
|
|
76
|
-
/** Free-form message; truncated to MESSAGE_CAP characters. */
|
|
77
|
-
message?: string;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Session lifecycle states for the C'.2 recovery_event ring.
|
|
82
|
-
*
|
|
83
|
-
* This module records state-machine transitions; it does not enforce them.
|
|
84
|
-
*
|
|
85
|
-
* State semantics:
|
|
86
|
-
* - 'cold' : fresh DO instance, no in-memory session state yet.
|
|
87
|
-
* - 'hydrated' : Phase R completed — SQL state read into in-memory cache.
|
|
88
|
-
* - 'active' : Phase B+W+O completed — kernel/shell/terminal wired.
|
|
89
|
-
* - 'drained' : webSocketError/Close fired; in-memory caches nulled,
|
|
90
|
-
* SQL state freshly persisted; awaiting next reconnect.
|
|
91
|
-
*
|
|
92
|
-
* Trigger labels: human-readable event that caused the transition.
|
|
93
|
-
* Examples: 'first-fetch', 'ws-upgrade', 'ws-close', 'ws-error',
|
|
94
|
-
* 'isolate-evicted', 'manual-test'.
|
|
95
|
-
*/
|
|
96
|
-
/** Session state union.
|
|
97
|
-
*
|
|
98
|
-
* The original four are the high-level lifecycle markers:
|
|
99
|
-
* cold — never inited
|
|
100
|
-
* hydrated — re-init complete (warm path; Phase O skipped)
|
|
101
|
-
* active — live shell processing input
|
|
102
|
-
* drained — webSocketError/Close fired; SQL freshly persisted
|
|
103
|
-
*
|
|
104
|
-
* The four [B'.4] additions are the fine-grained phases of
|
|
105
|
-
* initSession itself, recorded as the function progresses:
|
|
106
|
-
* rehydrate — Phase R: loading state from DO SQLite
|
|
107
|
-
* build — Phase B: constructing Kernel/Shell/registry
|
|
108
|
-
* wire — Phase W: attaching terminal, replaying scrollback
|
|
109
|
-
* online — Phase O: cold-start MOTD + starter hint (cold only;
|
|
110
|
-
* on warm re-init this phase is skipped and the
|
|
111
|
-
* initSession ends with 'hydrated' instead)
|
|
112
|
-
*
|
|
113
|
-
* The phase transitions are SUPPLEMENTAL to the high-level markers,
|
|
114
|
-
* not a replacement. Probes that look for the legacy `'hydrated'`
|
|
115
|
-
* marker continue to work; probes that want fine-grained debugging
|
|
116
|
-
* can grep for the phase states.
|
|
117
|
-
*/
|
|
118
|
-
export type SessionState =
|
|
119
|
-
| 'cold' | 'hydrated' | 'active' | 'drained'
|
|
120
|
-
| 'rehydrate' | 'build' | 'wire' | 'online';
|
|
121
|
-
|
|
122
|
-
export interface DiagRecoveryEvent {
|
|
123
|
-
/** ms epoch */
|
|
124
|
-
at: number;
|
|
125
|
-
/** Source state. */
|
|
126
|
-
fromState: SessionState;
|
|
127
|
-
/** Destination state. */
|
|
128
|
-
toState: SessionState;
|
|
129
|
-
/** Human-readable trigger ('ws-close', 'isolate-evicted', etc.). */
|
|
130
|
-
trigger: string;
|
|
131
|
-
/** _isolateGen at the time of the event. Lets a probe detect the
|
|
132
|
-
* difference between an in-isolate transition and a cross-isolate one
|
|
133
|
-
* (the latter implies workerd recycled the DO). */
|
|
134
|
-
isolateGen: number;
|
|
135
|
-
/** True when the transition could not preserve state that should
|
|
136
|
-
* have survived (e.g. SQL persist threw or a snapshot was missing). */
|
|
137
|
-
dataLoss: boolean;
|
|
138
|
-
/** Number of SQL keys/rows rehydrated on a hydrated transition.
|
|
139
|
-
* Zero when the transition is one that doesn't read SQL. */
|
|
140
|
-
snapshotKeysRehydrated: number;
|
|
141
|
-
/** Optional free-form notes (200 char cap). */
|
|
142
|
-
notes?: string;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
interface RingState {
|
|
146
|
-
failures: DiagFailure[];
|
|
147
|
-
recoveryEvents: DiagRecoveryEvent[];
|
|
148
|
-
lastRpcFrame: RpcFrame | null;
|
|
149
|
-
lastFacetId: FacetId | null;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const state: RingState = {
|
|
153
|
-
failures: [],
|
|
154
|
-
recoveryEvents: [],
|
|
155
|
-
lastRpcFrame: null,
|
|
156
|
-
lastFacetId: null,
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
function getState(): RingState {
|
|
160
|
-
return state;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Append a failure to the ring. Newest first. Capped at RING_SIZE. */
|
|
164
|
-
export function recordFailure(f: DiagFailure): void {
|
|
165
|
-
const s = getState();
|
|
166
|
-
// Defensive copy + message cap.
|
|
167
|
-
const entry: DiagFailure = {
|
|
168
|
-
at: Number(f.at) || Date.now(),
|
|
169
|
-
phase: String(f.phase ?? 'unknown'),
|
|
170
|
-
cause: f.cause,
|
|
171
|
-
rssEstimateBytes: Number(f.rssEstimateBytes) || 0,
|
|
172
|
-
heapUsedBytes: Number(f.heapUsedBytes) || 0,
|
|
173
|
-
lruBytes: Number(f.lruBytes) || 0,
|
|
174
|
-
inFlightBytes: Number(f.inFlightBytes) || 0,
|
|
175
|
-
lastRpcFrame: f.lastRpcFrame ?? s.lastRpcFrame,
|
|
176
|
-
lastFacetId: f.lastFacetId ?? s.lastFacetId,
|
|
177
|
-
};
|
|
178
|
-
if (typeof f.exitCode === 'number') entry.exitCode = f.exitCode;
|
|
179
|
-
if (typeof f.pid === 'number') entry.pid = f.pid;
|
|
180
|
-
if (f.message) {
|
|
181
|
-
const m = String(f.message);
|
|
182
|
-
entry.message = m.length > MESSAGE_CAP ? m.slice(0, MESSAGE_CAP) : m;
|
|
183
|
-
}
|
|
184
|
-
s.failures.unshift(entry);
|
|
185
|
-
if (s.failures.length > RING_SIZE) s.failures.length = RING_SIZE;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Read a snapshot of the ring. Newest first. Caller-side mutations
|
|
189
|
-
* do not affect the singleton. */
|
|
190
|
-
export function getFailures(): DiagFailure[] {
|
|
191
|
-
return getState().failures.slice(0);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Record the current RPC frame (called at every RPC entry). Bounded
|
|
195
|
-
* to a single slot — the LATEST frame wins. */
|
|
196
|
-
export function setLastRpcFrame(method: string, payloadBytes: number): void {
|
|
197
|
-
const s = getState();
|
|
198
|
-
s.lastRpcFrame = {
|
|
199
|
-
method: String(method),
|
|
200
|
-
payloadBytes: Number(payloadBytes) || 0,
|
|
201
|
-
atMs: Date.now(),
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export function getLastRpcFrame(): RpcFrame | null {
|
|
206
|
-
return getState().lastRpcFrame;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Record the most recent facet dispatch. */
|
|
210
|
-
export function setLastFacetId(codeId: string, slotIndex: number): void {
|
|
211
|
-
const s = getState();
|
|
212
|
-
s.lastFacetId = {
|
|
213
|
-
codeId: String(codeId),
|
|
214
|
-
slotIndex: Number(slotIndex) || 0,
|
|
215
|
-
atMs: Date.now(),
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function getLastFacetId(): FacetId | null {
|
|
220
|
-
return getState().lastFacetId;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ── C'.2 recovery_event ring ────────────────────────────────────────────
|
|
224
|
-
//
|
|
225
|
-
// The ring is bounded at RECOVERY_RING_SIZE; the diag endpoint reads via
|
|
226
|
-
// getRecoveryEvents() (newest first).
|
|
227
|
-
|
|
228
|
-
/** Append a recovery event to the ring. Newest first. Capped at
|
|
229
|
-
* RECOVERY_RING_SIZE. */
|
|
230
|
-
export function recordRecoveryEvent(e: DiagRecoveryEvent): void {
|
|
231
|
-
const s = getState();
|
|
232
|
-
// Defensive copy + notes cap. We intentionally do NOT validate the
|
|
233
|
-
// state-machine direction here (e.g. that 'drained' only follows
|
|
234
|
-
// 'active') — the state machine lives elsewhere; this ring is a
|
|
235
|
-
// recorder, not an enforcer. If a probe sees an impossible
|
|
236
|
-
// transition, that's a real bug in the state machine and the
|
|
237
|
-
// probe must fail.
|
|
238
|
-
const entry: DiagRecoveryEvent = {
|
|
239
|
-
at: Number(e.at) || Date.now(),
|
|
240
|
-
fromState: e.fromState,
|
|
241
|
-
toState: e.toState,
|
|
242
|
-
trigger: String(e.trigger ?? 'unknown'),
|
|
243
|
-
isolateGen: Number(e.isolateGen) || 0,
|
|
244
|
-
dataLoss: !!e.dataLoss,
|
|
245
|
-
snapshotKeysRehydrated: Number(e.snapshotKeysRehydrated) || 0,
|
|
246
|
-
};
|
|
247
|
-
if (e.notes) {
|
|
248
|
-
const n = String(e.notes);
|
|
249
|
-
entry.notes = n.length > MESSAGE_CAP ? n.slice(0, MESSAGE_CAP) : n;
|
|
250
|
-
}
|
|
251
|
-
s.recoveryEvents.unshift(entry);
|
|
252
|
-
if (s.recoveryEvents.length > RECOVERY_RING_SIZE) {
|
|
253
|
-
s.recoveryEvents.length = RECOVERY_RING_SIZE;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Read a snapshot of the recovery ring. Newest first. */
|
|
258
|
-
export function getRecoveryEvents(): DiagRecoveryEvent[] {
|
|
259
|
-
return getState().recoveryEvents.slice(0);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Reset the recovery ring. Tests + manual operator use only. */
|
|
263
|
-
export function resetRecoveryEvents(): void {
|
|
264
|
-
getState().recoveryEvents.length = 0;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Build a JSON-serializable snapshot suitable for ctx.storage.put.
|
|
269
|
-
* Bounded ≤ 40 KB even with both rings full of MESSAGE_CAP-truncated
|
|
270
|
-
* messages. Schema version embedded so a future shape change can
|
|
271
|
-
* cleanly reject old snapshots.
|
|
272
|
-
*
|
|
273
|
-
* Schema version 2 includes both failure and recovery-event rings.
|
|
274
|
-
*/
|
|
275
|
-
export interface DiagSnapshot {
|
|
276
|
-
/** Schema version. Bump when shape changes. */
|
|
277
|
-
v: 2;
|
|
278
|
-
failures: DiagFailure[];
|
|
279
|
-
recoveryEvents: DiagRecoveryEvent[];
|
|
280
|
-
lastRpcFrame: RpcFrame | null;
|
|
281
|
-
lastFacetId: FacetId | null;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export function snapshotForStorage(): DiagSnapshot {
|
|
285
|
-
const s = getState();
|
|
286
|
-
return {
|
|
287
|
-
v: 2,
|
|
288
|
-
failures: s.failures.slice(0),
|
|
289
|
-
recoveryEvents: s.recoveryEvents.slice(0),
|
|
290
|
-
lastRpcFrame: s.lastRpcFrame,
|
|
291
|
-
lastFacetId: s.lastFacetId,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Restore from a snapshot. Garbage / null / wrong-shape input is
|
|
297
|
-
* silently ignored. Does NOT throw — constructor-time rehydration must
|
|
298
|
-
* never block DO startup.
|
|
299
|
-
*
|
|
300
|
-
* Only the current v2 schema is accepted.
|
|
301
|
-
*/
|
|
302
|
-
export function rehydrateFromStorage(blob: unknown): void {
|
|
303
|
-
if (!isRecord(blob) || blob.v !== 2) return;
|
|
304
|
-
const b = blob;
|
|
305
|
-
if (!Array.isArray(b.failures)) return;
|
|
306
|
-
|
|
307
|
-
const s = getState();
|
|
308
|
-
s.failures.length = 0;
|
|
309
|
-
for (const f of b.failures) {
|
|
310
|
-
const entry = parseFailure(f);
|
|
311
|
-
if (!entry) continue;
|
|
312
|
-
s.failures.push(entry);
|
|
313
|
-
if (s.failures.length >= RING_SIZE) break;
|
|
314
|
-
}
|
|
315
|
-
s.recoveryEvents.length = 0;
|
|
316
|
-
if (Array.isArray(b.recoveryEvents)) {
|
|
317
|
-
for (const e of b.recoveryEvents) {
|
|
318
|
-
const entry = parseRecoveryEvent(e);
|
|
319
|
-
if (!entry) continue;
|
|
320
|
-
s.recoveryEvents.push(entry);
|
|
321
|
-
if (s.recoveryEvents.length >= RECOVERY_RING_SIZE) break;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
const lastRpcFrame = parseRpcFrame(b.lastRpcFrame);
|
|
325
|
-
if (lastRpcFrame) s.lastRpcFrame = lastRpcFrame;
|
|
326
|
-
const lastFacetId = parseFacetId(b.lastFacetId);
|
|
327
|
-
if (lastFacetId) s.lastFacetId = lastFacetId;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
331
|
-
return typeof value === 'object' && value !== null;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function parseRpcFrame(value: unknown): RpcFrame | null {
|
|
335
|
-
if (!isRecord(value)
|
|
336
|
-
|| typeof value.method !== 'string'
|
|
337
|
-
|| typeof value.payloadBytes !== 'number'
|
|
338
|
-
|| typeof value.atMs !== 'number') return null;
|
|
339
|
-
return { method: value.method, payloadBytes: value.payloadBytes, atMs: value.atMs };
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function parseFacetId(value: unknown): FacetId | null {
|
|
343
|
-
if (!isRecord(value)
|
|
344
|
-
|| typeof value.codeId !== 'string'
|
|
345
|
-
|| typeof value.slotIndex !== 'number'
|
|
346
|
-
|| typeof value.atMs !== 'number') return null;
|
|
347
|
-
return { codeId: value.codeId, slotIndex: value.slotIndex, atMs: value.atMs };
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function parseFailure(value: unknown): DiagFailure | null {
|
|
351
|
-
if (!isRecord(value)
|
|
352
|
-
|| typeof value.at !== 'number'
|
|
353
|
-
|| typeof value.phase !== 'string'
|
|
354
|
-
|| !isOomCause(value.cause)
|
|
355
|
-
|| typeof value.rssEstimateBytes !== 'number'
|
|
356
|
-
|| typeof value.heapUsedBytes !== 'number'
|
|
357
|
-
|| typeof value.lruBytes !== 'number'
|
|
358
|
-
|| typeof value.inFlightBytes !== 'number') return null;
|
|
359
|
-
const entry: DiagFailure = {
|
|
360
|
-
at: value.at,
|
|
361
|
-
phase: value.phase,
|
|
362
|
-
cause: value.cause,
|
|
363
|
-
rssEstimateBytes: value.rssEstimateBytes,
|
|
364
|
-
heapUsedBytes: value.heapUsedBytes,
|
|
365
|
-
lruBytes: value.lruBytes,
|
|
366
|
-
inFlightBytes: value.inFlightBytes,
|
|
367
|
-
lastRpcFrame: parseRpcFrame(value.lastRpcFrame),
|
|
368
|
-
lastFacetId: parseFacetId(value.lastFacetId),
|
|
369
|
-
};
|
|
370
|
-
if (typeof value.exitCode === 'number') entry.exitCode = value.exitCode;
|
|
371
|
-
if (typeof value.pid === 'number') entry.pid = value.pid;
|
|
372
|
-
if (typeof value.message === 'string') entry.message = value.message.slice(0, MESSAGE_CAP);
|
|
373
|
-
return entry;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const SESSION_STATES: readonly SessionState[] = [
|
|
377
|
-
'cold', 'hydrated', 'active', 'drained',
|
|
378
|
-
'rehydrate', 'build', 'wire', 'online',
|
|
379
|
-
];
|
|
380
|
-
|
|
381
|
-
function isSessionState(value: unknown): value is SessionState {
|
|
382
|
-
return SESSION_STATES.some((state) => state === value);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function parseRecoveryEvent(value: unknown): DiagRecoveryEvent | null {
|
|
386
|
-
if (!isRecord(value)
|
|
387
|
-
|| typeof value.at !== 'number'
|
|
388
|
-
|| !isSessionState(value.fromState)
|
|
389
|
-
|| !isSessionState(value.toState)
|
|
390
|
-
|| typeof value.trigger !== 'string'
|
|
391
|
-
|| typeof value.isolateGen !== 'number'
|
|
392
|
-
|| typeof value.dataLoss !== 'boolean'
|
|
393
|
-
|| typeof value.snapshotKeysRehydrated !== 'number') return null;
|
|
394
|
-
const entry: DiagRecoveryEvent = {
|
|
395
|
-
at: value.at,
|
|
396
|
-
fromState: value.fromState,
|
|
397
|
-
toState: value.toState,
|
|
398
|
-
trigger: value.trigger,
|
|
399
|
-
isolateGen: value.isolateGen,
|
|
400
|
-
dataLoss: value.dataLoss,
|
|
401
|
-
snapshotKeysRehydrated: value.snapshotKeysRehydrated,
|
|
402
|
-
};
|
|
403
|
-
if (typeof value.notes === 'string') entry.notes = value.notes.slice(0, MESSAGE_CAP);
|
|
404
|
-
return entry;
|
|
405
|
-
}
|