@nimbus-sh/fabric 0.4.0 → 0.5.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/generation.d.ts +6 -0
- package/dist/generation.d.ts.map +1 -1
- package/dist/generation.js +6 -0
- package/dist/image-store.d.ts +11 -1
- package/dist/image-store.d.ts.map +1 -1
- package/dist/image-store.js +31 -20
- package/dist/isolate-pool.d.ts +19 -0
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +70 -8
- package/dist/turn-budget.d.ts +4 -4
- package/dist/turn-budget.js +4 -4
- package/package.json +3 -3
- package/src/generation.ts +6 -0
- package/src/image-store.ts +33 -20
- package/src/isolate-pool.ts +78 -8
- package/src/turn-budget.ts +4 -4
package/dist/generation.d.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* counter, and bumps it once. The embedder reads `generation(ctx)` wherever it
|
|
15
15
|
* needs the incarnation number — one source of truth instead of mirrored host
|
|
16
16
|
* fields.
|
|
17
|
+
*
|
|
18
|
+
* This is also the allocator for a workspace's generation: an embedder
|
|
19
|
+
* composing NimbusWorkspace over its own Durable Object calls
|
|
20
|
+
* `adoptGeneration(ctx)` once per incarnation and passes `generation(ctx)`
|
|
21
|
+
* as the workspace's `generation` — the same persisted counter the session
|
|
22
|
+
* derives its pid generation from, so there is no second counter to keep.
|
|
17
23
|
*/
|
|
18
24
|
/**
|
|
19
25
|
* Storage key for the isolate-generation counter (cold-start +
|
package/dist/generation.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generation.d.ts","sourceRoot":"","sources":["../src/generation.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"generation.d.ts","sourceRoot":"","sources":["../src/generation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAIH;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,mBAAmB,CAAC;AAE/C,2DAA2D;AAC3D,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAED,qEAAqE;AACrE,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,iBAAiB,CAAC;CAC5B;AAsBD,6EAA6E;AAC7E,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,yEAAyE;AACzE,wBAAsB,eAAe,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA0B3E;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIjE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAE3E;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAYvD"}
|
package/dist/generation.js
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* counter, and bumps it once. The embedder reads `generation(ctx)` wherever it
|
|
15
15
|
* needs the incarnation number — one source of truth instead of mirrored host
|
|
16
16
|
* fields.
|
|
17
|
+
*
|
|
18
|
+
* This is also the allocator for a workspace's generation: an embedder
|
|
19
|
+
* composing NimbusWorkspace over its own Durable Object calls
|
|
20
|
+
* `adoptGeneration(ctx)` once per incarnation and passes `generation(ctx)`
|
|
21
|
+
* as the workspace's `generation` — the same persisted counter the session
|
|
22
|
+
* derives its pid generation from, so there is no second counter to keep.
|
|
17
23
|
*/
|
|
18
24
|
import { errorText } from '@nimbus-sh/core/_shared/error-text.js';
|
|
19
25
|
/**
|
package/dist/image-store.d.ts
CHANGED
|
@@ -92,10 +92,20 @@ export declare class ImageStore {
|
|
|
92
92
|
* Materialize generated module sources in the content-addressed image store
|
|
93
93
|
* and return the module-name → path map naming them.
|
|
94
94
|
*
|
|
95
|
+
* Takes the images as a SEQUENCE, produced on demand and released as each
|
|
96
|
+
* one's slices land, so exactly one image's text is resident here. The old
|
|
97
|
+
* record-shaped parameter held every source for the whole call, and the
|
|
98
|
+
* caller held its own copy beside it: measured on a real-vite launch, the
|
|
99
|
+
* second image reported not one slice — ~25 MB of module text as UTF-16 in
|
|
100
|
+
* two places, plus the first image's just-freed 10.36 MB encode buffer, on a
|
|
101
|
+
* 128 MiB isolate. Yielding and dropping is what makes the peak one image
|
|
102
|
+
* instead of all of them; it is not a pacing question, and a fresh turn does
|
|
103
|
+
* not shrink a live heap.
|
|
104
|
+
*
|
|
95
105
|
* Writing the sources here, once, is what lets the session stop holding
|
|
96
106
|
* them: after this returns, the only thing it keeps is a path.
|
|
97
107
|
*/
|
|
98
|
-
materialize(pid: number,
|
|
108
|
+
materialize(pid: number, images: AsyncIterable<readonly [string, string]> | Iterable<readonly [string, string]>, pacer: TurnBudget): Promise<Record<string, string>>;
|
|
99
109
|
/**
|
|
100
110
|
* Drop every image no running process boots from.
|
|
101
111
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"image-store.d.ts","sourceRoot":"","sources":["../src/image-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,QAA0D,CAAC;AAErG;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,wEAAwE;IACxE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACpC,wEAAwE;IACxE,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACjD,kDAAkD;IAClD,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IAClE,2EAA2E;IAC3E,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC5B,qDAAqD;IACrD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,qBAAa,UAAU;IAanB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAbzB,8EAA8E;IAC9E,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,QAAQ,CAAS;IAEzB;;;;;;OAMG;gBAEgB,KAAK,EAAE,MAAM,cAAc,EAC3B,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO;IAGnD;;;;;;;;;;;;;;;;OAgBG;IACH,SAAS,IAAI,IAAI;IAOjB
|
|
1
|
+
{"version":3,"file":"image-store.d.ts","sourceRoot":"","sources":["../src/image-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,QAA0D,CAAC;AAErG;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,wEAAwE;IACxE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACpC,wEAAwE;IACxE,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACjD,kDAAkD;IAClD,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IAClE,2EAA2E;IAC3E,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC5B,qDAAqD;IACrD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,qBAAa,UAAU;IAanB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAbzB,8EAA8E;IAC9E,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,QAAQ,CAAS;IAEzB;;;;;;OAMG;gBAEgB,KAAK,EAAE,MAAM,cAAc,EAC3B,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO;IAGnD;;;;;;;;;;;;;;;;OAgBG;IACH,SAAS,IAAI,IAAI;IAOjB;;;;;;;;;;;;;;;;OAgBG;IACG,WAAW,CACf,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,QAAQ,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EACtF,KAAK,EAAE,UAAU,GAChB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAuDlC;;;;;;;;;;OAUG;IACH,OAAO,CAAC,KAAK;CAgBd"}
|
package/dist/image-store.js
CHANGED
|
@@ -84,32 +84,42 @@ export class ImageStore {
|
|
|
84
84
|
* Materialize generated module sources in the content-addressed image store
|
|
85
85
|
* and return the module-name → path map naming them.
|
|
86
86
|
*
|
|
87
|
+
* Takes the images as a SEQUENCE, produced on demand and released as each
|
|
88
|
+
* one's slices land, so exactly one image's text is resident here. The old
|
|
89
|
+
* record-shaped parameter held every source for the whole call, and the
|
|
90
|
+
* caller held its own copy beside it: measured on a real-vite launch, the
|
|
91
|
+
* second image reported not one slice — ~25 MB of module text as UTF-16 in
|
|
92
|
+
* two places, plus the first image's just-freed 10.36 MB encode buffer, on a
|
|
93
|
+
* 128 MiB isolate. Yielding and dropping is what makes the peak one image
|
|
94
|
+
* instead of all of them; it is not a pacing question, and a fresh turn does
|
|
95
|
+
* not shrink a live heap.
|
|
96
|
+
*
|
|
87
97
|
* Writing the sources here, once, is what lets the session stop holding
|
|
88
98
|
* them: after this returns, the only thing it keeps is a path.
|
|
89
99
|
*/
|
|
90
|
-
async materialize(pid,
|
|
100
|
+
async materialize(pid, images, pacer) {
|
|
91
101
|
const fs = this.blobs();
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// byte of it exists on disk. That ordering is the entire protocol between
|
|
102
|
-
// a launch and the sweep: an image is rooted from before it is written, so
|
|
103
|
-
// a sweep can never observe a file this launch has written but not yet
|
|
104
|
-
// claimed. The old loop achieved it by not awaiting at all, which read as
|
|
105
|
-
// "the writes must not be interrupted" — they may be. What must not be
|
|
106
|
-
// interrupted is the gap between writing and rooting, and rooting first
|
|
107
|
-
// closes it for every write that follows, however many turns they span.
|
|
108
|
-
this.residentImages.set(pid, [...sources.keys()]);
|
|
102
|
+
const paths = {};
|
|
103
|
+
// The root set is this ARRAY, held by the sweep's map from before the
|
|
104
|
+
// first byte and appended to as each image is named. Rooting an image
|
|
105
|
+
// before its own first byte is the whole of the protocol: a sweep that
|
|
106
|
+
// runs while this launch is suspended sees every image already written as
|
|
107
|
+
// rooted, and an image not yet written is not yet a file. The array
|
|
108
|
+
// identity is what makes an append visible — do not replace it.
|
|
109
|
+
const rooted = [];
|
|
110
|
+
this.residentImages.set(pid, rooted);
|
|
109
111
|
fs.mkdirp(FACET_IMAGE_DIR);
|
|
110
|
-
|
|
112
|
+
let count = 0;
|
|
113
|
+
for await (const [moduleName, source] of images) {
|
|
114
|
+
const path = facetImagePath(await facetImageDigest(source));
|
|
115
|
+
paths[moduleName] = path;
|
|
116
|
+
rooted.push(path);
|
|
117
|
+
count++;
|
|
111
118
|
const stored = path.replace(/^\/+/, '');
|
|
112
119
|
const bytes = new TextEncoder().encode(source);
|
|
120
|
+
console.log('[image-store] pid=' + pid + ' image ' + count + ' ' + moduleName + ' → '
|
|
121
|
+
+ path.slice(-12) + ' ' + bytes.byteLength + ' bytes, slice=' + FACET_IMAGE_WRITE_SLICE_BYTES
|
|
122
|
+
+ ' turns=' + pacer.chunks);
|
|
113
123
|
// An image at its full size is a COMPLETE one: a write only ever grows
|
|
114
124
|
// the file from offset zero, so a write cut short by a reset leaves a
|
|
115
125
|
// strictly shorter file and fails this test. Size is enough of a check
|
|
@@ -138,7 +148,8 @@ export class ImageStore {
|
|
|
138
148
|
} while (offset < bytes.byteLength);
|
|
139
149
|
}
|
|
140
150
|
this.sweep(fs);
|
|
141
|
-
|
|
151
|
+
console.log('[image-store] pid=' + pid + ' materialized ' + count + ' image(s) in ' + pacer.chunks + ' turn(s)');
|
|
152
|
+
return paths;
|
|
142
153
|
}
|
|
143
154
|
/**
|
|
144
155
|
* Drop every image no running process boots from.
|
package/dist/isolate-pool.d.ts
CHANGED
|
@@ -235,6 +235,18 @@ export declare class IsolatePool {
|
|
|
235
235
|
private readonly defaultRetries;
|
|
236
236
|
private readonly tag;
|
|
237
237
|
private readonly slotGenerations;
|
|
238
|
+
/**
|
|
239
|
+
* Per-slot execution ownership: the tail of each slot's in-flight
|
|
240
|
+
* dispatch chain. Two dispatches on the same warm isolate at once
|
|
241
|
+
* interleave on its QueueState — map() callers used to trust the
|
|
242
|
+
* caller's slot round-robin, which could not prevent submit() (slot
|
|
243
|
+
* 0) or a second map() landing on a slot a task still occupied. Every
|
|
244
|
+
* dispatch now waits for the slot's previous execution to settle
|
|
245
|
+
* before touching it.
|
|
246
|
+
*/
|
|
247
|
+
private readonly slotTails;
|
|
248
|
+
/** Set by dispose() — queued dispatches reject instead of running. */
|
|
249
|
+
private disposed;
|
|
238
250
|
private bindings;
|
|
239
251
|
private readonly preamble;
|
|
240
252
|
private readonly preambleHash;
|
|
@@ -262,6 +274,13 @@ export declare class IsolatePool {
|
|
|
262
274
|
* 12 chars is enough entropy for DO ids to collide-free per process.
|
|
263
275
|
*/
|
|
264
276
|
private readonly doIdShort;
|
|
277
|
+
/**
|
|
278
|
+
* The supervisor identity the minted worker's env.SUPERVISOR binding
|
|
279
|
+
* bakes (doId short-form + pid), folded into the loader cache key. 's-none'
|
|
280
|
+
* when no SUPERVISOR binding was minted, so a supervisor-less pool keeps
|
|
281
|
+
* the old key shape and its warm slots stay shared.
|
|
282
|
+
*/
|
|
283
|
+
private readonly supervisorKey;
|
|
265
284
|
constructor(env: unknown, ctx: DurableObjectState, opts?: IsolatePoolOptions);
|
|
266
285
|
/** Effective concurrency used when no per-call override is supplied. */
|
|
267
286
|
get defaultConcurrency(): number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"isolate-pool.d.ts","sourceRoot":"","sources":["../src/isolate-pool.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"isolate-pool.d.ts","sourceRoot":"","sources":["../src/isolate-pool.ts"],"names":[],"mappings":"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"}
|
package/dist/isolate-pool.js
CHANGED
|
@@ -113,6 +113,18 @@ export class IsolatePool {
|
|
|
113
113
|
defaultRetries;
|
|
114
114
|
tag;
|
|
115
115
|
slotGenerations = new Map();
|
|
116
|
+
/**
|
|
117
|
+
* Per-slot execution ownership: the tail of each slot's in-flight
|
|
118
|
+
* dispatch chain. Two dispatches on the same warm isolate at once
|
|
119
|
+
* interleave on its QueueState — map() callers used to trust the
|
|
120
|
+
* caller's slot round-robin, which could not prevent submit() (slot
|
|
121
|
+
* 0) or a second map() landing on a slot a task still occupied. Every
|
|
122
|
+
* dispatch now waits for the slot's previous execution to settle
|
|
123
|
+
* before touching it.
|
|
124
|
+
*/
|
|
125
|
+
slotTails = new Map();
|
|
126
|
+
/** Set by dispose() — queued dispatches reject instead of running. */
|
|
127
|
+
disposed = false;
|
|
116
128
|
bindings;
|
|
117
129
|
preamble;
|
|
118
130
|
preambleHash;
|
|
@@ -140,6 +152,13 @@ export class IsolatePool {
|
|
|
140
152
|
* 12 chars is enough entropy for DO ids to collide-free per process.
|
|
141
153
|
*/
|
|
142
154
|
doIdShort;
|
|
155
|
+
/**
|
|
156
|
+
* The supervisor identity the minted worker's env.SUPERVISOR binding
|
|
157
|
+
* bakes (doId short-form + pid), folded into the loader cache key. 's-none'
|
|
158
|
+
* when no SUPERVISOR binding was minted, so a supervisor-less pool keeps
|
|
159
|
+
* the old key shape and its warm slots stay shared.
|
|
160
|
+
*/
|
|
161
|
+
supervisorKey;
|
|
143
162
|
constructor(env, ctx, opts) {
|
|
144
163
|
// A host hands its whole env over; the binding is claimed here and the
|
|
145
164
|
// claim is checked on the next line.
|
|
@@ -207,6 +226,7 @@ export class IsolatePool {
|
|
|
207
226
|
this.wasmHash = hashSource(fp);
|
|
208
227
|
}
|
|
209
228
|
const bindings = { ...(opts?.extraBindings ?? {}) };
|
|
229
|
+
this.supervisorKey = 's-none';
|
|
210
230
|
if (!opts?.omitSupervisor) {
|
|
211
231
|
const supervisorRpc = supervisorEntrypoint();
|
|
212
232
|
if (supervisorRpc) {
|
|
@@ -215,9 +235,19 @@ export class IsolatePool {
|
|
|
215
235
|
// to the user's session DO, not the peer DO. Default to the
|
|
216
236
|
// local ctx.id (single-DO callers and the in-DO in-DO fanout path).
|
|
217
237
|
const supDoId = opts?.supervisorDoIdOverride ?? ctx.id.toString();
|
|
238
|
+
const supPid = opts?.supervisorPid ?? 0;
|
|
218
239
|
bindings.SUPERVISOR = supervisorRpc({
|
|
219
|
-
props: { doId: supDoId, pid:
|
|
240
|
+
props: { doId: supDoId, pid: supPid },
|
|
220
241
|
});
|
|
242
|
+
// Whatever the minted worker's env carries must be in its loader
|
|
243
|
+
// cache key — workerd's loader cache survives a DO hibernation
|
|
244
|
+
// wake while generation-strided pids (1000001 → 2000001) do not:
|
|
245
|
+
// a warm slot keyed without the supervisor identity returns in
|
|
246
|
+
// the new generation still credentialed to the dead pid, and
|
|
247
|
+
// every pid-authorized RPC from it fails "process pid … does
|
|
248
|
+
// not exist". doIdShort alone cannot cover this — it changes
|
|
249
|
+
// across sessions, not across wakes of the same session.
|
|
250
|
+
this.supervisorKey = `s${supDoId.slice(0, 12)}-${supPid}`;
|
|
221
251
|
}
|
|
222
252
|
else {
|
|
223
253
|
// Supervisor entrypoint unavailable — running without ctx.exports
|
|
@@ -398,8 +428,35 @@ export class IsolatePool {
|
|
|
398
428
|
/**
|
|
399
429
|
* Dispatch a single task to the slot isolate. `slotIndex` picks which
|
|
400
430
|
* warm isolate services the call; callers round-robin slots themselves.
|
|
431
|
+
* Serialized per slot: this call waits for the slot's previous
|
|
432
|
+
* execution to settle before touching the warm isolate.
|
|
401
433
|
*/
|
|
402
434
|
async #dispatchSlot(fnSource, fnHash, slotIndex, args, resilience, perCallWasm) {
|
|
435
|
+
// A warm slot executes one dispatch at a time: queue behind the
|
|
436
|
+
// previous owner, then record this dispatch as the new tail. The
|
|
437
|
+
// tail outlives the caller's outcome: a timeout rejects the
|
|
438
|
+
// Promise.race while runOnce's RPC is still live on the Worker, so
|
|
439
|
+
// release waits for every execution the owned body started to
|
|
440
|
+
// settle — never the caller's settle alone.
|
|
441
|
+
const previous = this.slotTails.get(slotIndex) ?? Promise.resolve();
|
|
442
|
+
let release;
|
|
443
|
+
this.slotTails.set(slotIndex, new Promise((resolve) => { release = resolve; }));
|
|
444
|
+
await previous;
|
|
445
|
+
if (this.disposed) {
|
|
446
|
+
release();
|
|
447
|
+
throw new BindingError(`IsolatePool(${this.tag}) is disposed`);
|
|
448
|
+
}
|
|
449
|
+
const inFlight = [];
|
|
450
|
+
try {
|
|
451
|
+
return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, args, resilience, perCallWasm, inFlight);
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
// Do not delay the caller's own outcome — the tail releases when
|
|
455
|
+
// the RPCs the body launched have actually settled.
|
|
456
|
+
void Promise.allSettled(inFlight).then(() => release());
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async #dispatchSlotOwned(fnSource, fnHash, slotIndex, args, resilience, perCallWasm, inFlight) {
|
|
403
460
|
// Per-call wasm fingerprint. Mixed into the cache key so two calls
|
|
404
461
|
// with different bytes hit different slots (no cache poisoning).
|
|
405
462
|
// For the common case (no per-call wasm) the fingerprint is '0',
|
|
@@ -407,11 +464,11 @@ export class IsolatePool {
|
|
|
407
464
|
const perCallWasmEntries = this.#materialisePerCallWasm(perCallWasm);
|
|
408
465
|
const perCallWasmHash = this.#fingerprintWasm(perCallWasmEntries);
|
|
409
466
|
// Cache key includes the short DO id so warm isolates are scoped to
|
|
410
|
-
// ONE session
|
|
411
|
-
// a
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
const buildId = (generation) => `nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:slot-${slotIndex}:g${generation}`;
|
|
467
|
+
// ONE session (see the doIdShort field comment), and the supervisor
|
|
468
|
+
// identity so a wake of that same session can never reuse a warm
|
|
469
|
+
// worker whose SUPERVISOR binding still names the dead generation's
|
|
470
|
+
// pid. See the supervisorKey field comment for the failure mode.
|
|
471
|
+
const buildId = (generation) => `nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}:slot-${slotIndex}:g${generation}`;
|
|
415
472
|
let id = buildId(this.slotGenerations.get(slotIndex) ?? 0);
|
|
416
473
|
const code = this.#buildCode(fnSource, perCallWasmEntries);
|
|
417
474
|
// W5 Lever 5: record the dispatch so /api/_diag/memory shows the
|
|
@@ -489,8 +546,10 @@ export class IsolatePool {
|
|
|
489
546
|
// See plan in close-plan-2026-04-28.
|
|
490
547
|
let timerId;
|
|
491
548
|
try {
|
|
549
|
+
const attemptPromise = runOnce();
|
|
550
|
+
inFlight.push(attemptPromise);
|
|
492
551
|
return await Promise.race([
|
|
493
|
-
|
|
552
|
+
attemptPromise,
|
|
494
553
|
new Promise((_, reject) => {
|
|
495
554
|
timerId = setTimeout(() => reject(new TimeoutError(resilience.timeoutMs)), resilience.timeoutMs);
|
|
496
555
|
}),
|
|
@@ -501,7 +560,9 @@ export class IsolatePool {
|
|
|
501
560
|
clearTimeout(timerId);
|
|
502
561
|
}
|
|
503
562
|
}
|
|
504
|
-
|
|
563
|
+
const attemptPromise = runOnce();
|
|
564
|
+
inFlight.push(attemptPromise);
|
|
565
|
+
return await attemptPromise;
|
|
505
566
|
}
|
|
506
567
|
catch (err) {
|
|
507
568
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
@@ -655,6 +716,7 @@ export class IsolatePool {
|
|
|
655
716
|
* Safe to call more than once; idempotent.
|
|
656
717
|
*/
|
|
657
718
|
dispose() {
|
|
719
|
+
this.disposed = true;
|
|
658
720
|
if (!this.bindings)
|
|
659
721
|
return;
|
|
660
722
|
for (const key of Object.keys(this.bindings)) {
|
package/dist/turn-budget.d.ts
CHANGED
|
@@ -58,10 +58,10 @@ export declare const TURN_CHUNK_MAX_BYTES = 2000000;
|
|
|
58
58
|
* spent.
|
|
59
59
|
*
|
|
60
60
|
* Callers report the work they are about to do or have just done and await
|
|
61
|
-
* the result; a pacer that is not yielding returns without suspending, so
|
|
62
|
-
*
|
|
63
|
-
* behaviour and cost. Nothing here decides WHAT the
|
|
64
|
-
* is allowed to stop.
|
|
61
|
+
* the result; a pacer that is not yielding returns without suspending, so a
|
|
62
|
+
* build small enough to fit in one chunk — most one-shot execs, every cache
|
|
63
|
+
* hit — keeps its exact behaviour and cost. Nothing here decides WHAT the
|
|
64
|
+
* launch does, only where it is allowed to stop.
|
|
65
65
|
*/
|
|
66
66
|
export declare class TurnBudget {
|
|
67
67
|
private readonly scheduler;
|
package/dist/turn-budget.js
CHANGED
|
@@ -47,10 +47,10 @@ export const TURN_CHUNK_MAX_BYTES = 2_000_000;
|
|
|
47
47
|
* spent.
|
|
48
48
|
*
|
|
49
49
|
* Callers report the work they are about to do or have just done and await
|
|
50
|
-
* the result; a pacer that is not yielding returns without suspending, so
|
|
51
|
-
*
|
|
52
|
-
* behaviour and cost. Nothing here decides WHAT the
|
|
53
|
-
* is allowed to stop.
|
|
50
|
+
* the result; a pacer that is not yielding returns without suspending, so a
|
|
51
|
+
* build small enough to fit in one chunk — most one-shot execs, every cache
|
|
52
|
+
* hit — keeps its exact behaviour and cost. Nothing here decides WHAT the
|
|
53
|
+
* launch does, only where it is allowed to stop.
|
|
54
54
|
*/
|
|
55
55
|
export class TurnBudget {
|
|
56
56
|
scheduler;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nimbus-sh/fabric",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The Cloudflare half of Nimbus — Durable Object facet hosting, dynamic-worker loader pools, the resident-process fabric, and DO alarm/hibernation machinery.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"typecheck": "tsc --noEmit"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@nimbus-sh/core": "^0.
|
|
54
|
-
"@nimbus-sh/platform": "^0.
|
|
53
|
+
"@nimbus-sh/core": "^0.9.0",
|
|
54
|
+
"@nimbus-sh/platform": "^0.4.0",
|
|
55
55
|
"zod": "^4.4.3"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
package/src/generation.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* counter, and bumps it once. The embedder reads `generation(ctx)` wherever it
|
|
15
15
|
* needs the incarnation number — one source of truth instead of mirrored host
|
|
16
16
|
* fields.
|
|
17
|
+
*
|
|
18
|
+
* This is also the allocator for a workspace's generation: an embedder
|
|
19
|
+
* composing NimbusWorkspace over its own Durable Object calls
|
|
20
|
+
* `adoptGeneration(ctx)` once per incarnation and passes `generation(ctx)`
|
|
21
|
+
* as the workspace's `generation` — the same persisted counter the session
|
|
22
|
+
* derives its pid generation from, so there is no second counter to keep.
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
import { errorText } from '@nimbus-sh/core/_shared/error-text.js';
|
package/src/image-store.ts
CHANGED
|
@@ -107,36 +107,48 @@ export class ImageStore {
|
|
|
107
107
|
* Materialize generated module sources in the content-addressed image store
|
|
108
108
|
* and return the module-name → path map naming them.
|
|
109
109
|
*
|
|
110
|
+
* Takes the images as a SEQUENCE, produced on demand and released as each
|
|
111
|
+
* one's slices land, so exactly one image's text is resident here. The old
|
|
112
|
+
* record-shaped parameter held every source for the whole call, and the
|
|
113
|
+
* caller held its own copy beside it: measured on a real-vite launch, the
|
|
114
|
+
* second image reported not one slice — ~25 MB of module text as UTF-16 in
|
|
115
|
+
* two places, plus the first image's just-freed 10.36 MB encode buffer, on a
|
|
116
|
+
* 128 MiB isolate. Yielding and dropping is what makes the peak one image
|
|
117
|
+
* instead of all of them; it is not a pacing question, and a fresh turn does
|
|
118
|
+
* not shrink a live heap.
|
|
119
|
+
*
|
|
110
120
|
* Writing the sources here, once, is what lets the session stop holding
|
|
111
121
|
* them: after this returns, the only thing it keeps is a path.
|
|
112
122
|
*/
|
|
113
123
|
async materialize(
|
|
114
124
|
pid: number,
|
|
115
|
-
|
|
125
|
+
images: AsyncIterable<readonly [string, string]> | Iterable<readonly [string, string]>,
|
|
116
126
|
pacer: TurnBudget,
|
|
117
127
|
): Promise<Record<string, string>> {
|
|
118
128
|
const fs = this.blobs();
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// byte of it exists on disk. That ordering is the entire protocol between
|
|
129
|
-
// a launch and the sweep: an image is rooted from before it is written, so
|
|
130
|
-
// a sweep can never observe a file this launch has written but not yet
|
|
131
|
-
// claimed. The old loop achieved it by not awaiting at all, which read as
|
|
132
|
-
// "the writes must not be interrupted" — they may be. What must not be
|
|
133
|
-
// interrupted is the gap between writing and rooting, and rooting first
|
|
134
|
-
// closes it for every write that follows, however many turns they span.
|
|
135
|
-
this.residentImages.set(pid, [...sources.keys()]);
|
|
129
|
+
const paths: Record<string, string> = {};
|
|
130
|
+
// The root set is this ARRAY, held by the sweep's map from before the
|
|
131
|
+
// first byte and appended to as each image is named. Rooting an image
|
|
132
|
+
// before its own first byte is the whole of the protocol: a sweep that
|
|
133
|
+
// runs while this launch is suspended sees every image already written as
|
|
134
|
+
// rooted, and an image not yet written is not yet a file. The array
|
|
135
|
+
// identity is what makes an append visible — do not replace it.
|
|
136
|
+
const rooted: string[] = [];
|
|
137
|
+
this.residentImages.set(pid, rooted);
|
|
136
138
|
fs.mkdirp(FACET_IMAGE_DIR);
|
|
137
|
-
|
|
139
|
+
let count = 0;
|
|
140
|
+
for await (const [moduleName, source] of images) {
|
|
141
|
+
const path = facetImagePath(await facetImageDigest(source));
|
|
142
|
+
paths[moduleName] = path;
|
|
143
|
+
rooted.push(path);
|
|
144
|
+
count++;
|
|
138
145
|
const stored = path.replace(/^\/+/, '');
|
|
139
146
|
const bytes = new TextEncoder().encode(source);
|
|
147
|
+
console.log(
|
|
148
|
+
'[image-store] pid=' + pid + ' image ' + count + ' ' + moduleName + ' → '
|
|
149
|
+
+ path.slice(-12) + ' ' + bytes.byteLength + ' bytes, slice=' + FACET_IMAGE_WRITE_SLICE_BYTES
|
|
150
|
+
+ ' turns=' + pacer.chunks,
|
|
151
|
+
);
|
|
140
152
|
// An image at its full size is a COMPLETE one: a write only ever grows
|
|
141
153
|
// the file from offset zero, so a write cut short by a reset leaves a
|
|
142
154
|
// strictly shorter file and fails this test. Size is enough of a check
|
|
@@ -163,7 +175,8 @@ export class ImageStore {
|
|
|
163
175
|
} while (offset < bytes.byteLength);
|
|
164
176
|
}
|
|
165
177
|
this.sweep(fs);
|
|
166
|
-
|
|
178
|
+
console.log('[image-store] pid=' + pid + ' materialized ' + count + ' image(s) in ' + pacer.chunks + ' turn(s)');
|
|
179
|
+
return paths;
|
|
167
180
|
}
|
|
168
181
|
|
|
169
182
|
/**
|
package/src/isolate-pool.ts
CHANGED
|
@@ -345,6 +345,18 @@ export class IsolatePool {
|
|
|
345
345
|
private readonly defaultRetries: number;
|
|
346
346
|
private readonly tag: string;
|
|
347
347
|
private readonly slotGenerations = new Map<number, number>();
|
|
348
|
+
/**
|
|
349
|
+
* Per-slot execution ownership: the tail of each slot's in-flight
|
|
350
|
+
* dispatch chain. Two dispatches on the same warm isolate at once
|
|
351
|
+
* interleave on its QueueState — map() callers used to trust the
|
|
352
|
+
* caller's slot round-robin, which could not prevent submit() (slot
|
|
353
|
+
* 0) or a second map() landing on a slot a task still occupied. Every
|
|
354
|
+
* dispatch now waits for the slot's previous execution to settle
|
|
355
|
+
* before touching it.
|
|
356
|
+
*/
|
|
357
|
+
private readonly slotTails = new Map<number, Promise<void>>();
|
|
358
|
+
/** Set by dispose() — queued dispatches reject instead of running. */
|
|
359
|
+
private disposed = false;
|
|
348
360
|
private bindings: Record<string, unknown> | undefined;
|
|
349
361
|
|
|
350
362
|
private readonly preamble: string | undefined;
|
|
@@ -380,6 +392,13 @@ export class IsolatePool {
|
|
|
380
392
|
* 12 chars is enough entropy for DO ids to collide-free per process.
|
|
381
393
|
*/
|
|
382
394
|
private readonly doIdShort: string;
|
|
395
|
+
/**
|
|
396
|
+
* The supervisor identity the minted worker's env.SUPERVISOR binding
|
|
397
|
+
* bakes (doId short-form + pid), folded into the loader cache key. 's-none'
|
|
398
|
+
* when no SUPERVISOR binding was minted, so a supervisor-less pool keeps
|
|
399
|
+
* the old key shape and its warm slots stay shared.
|
|
400
|
+
*/
|
|
401
|
+
private readonly supervisorKey: string;
|
|
383
402
|
|
|
384
403
|
constructor(
|
|
385
404
|
env: unknown,
|
|
@@ -459,6 +478,7 @@ export class IsolatePool {
|
|
|
459
478
|
}
|
|
460
479
|
|
|
461
480
|
const bindings: Record<string, unknown> = { ...(opts?.extraBindings ?? {}) };
|
|
481
|
+
this.supervisorKey = 's-none';
|
|
462
482
|
if (!opts?.omitSupervisor) {
|
|
463
483
|
const supervisorRpc = supervisorEntrypoint();
|
|
464
484
|
if (supervisorRpc) {
|
|
@@ -467,9 +487,19 @@ export class IsolatePool {
|
|
|
467
487
|
// to the user's session DO, not the peer DO. Default to the
|
|
468
488
|
// local ctx.id (single-DO callers and the in-DO in-DO fanout path).
|
|
469
489
|
const supDoId = opts?.supervisorDoIdOverride ?? ctx.id.toString();
|
|
490
|
+
const supPid = opts?.supervisorPid ?? 0;
|
|
470
491
|
bindings.SUPERVISOR = supervisorRpc({
|
|
471
|
-
props: { doId: supDoId, pid:
|
|
492
|
+
props: { doId: supDoId, pid: supPid },
|
|
472
493
|
});
|
|
494
|
+
// Whatever the minted worker's env carries must be in its loader
|
|
495
|
+
// cache key — workerd's loader cache survives a DO hibernation
|
|
496
|
+
// wake while generation-strided pids (1000001 → 2000001) do not:
|
|
497
|
+
// a warm slot keyed without the supervisor identity returns in
|
|
498
|
+
// the new generation still credentialed to the dead pid, and
|
|
499
|
+
// every pid-authorized RPC from it fails "process pid … does
|
|
500
|
+
// not exist". doIdShort alone cannot cover this — it changes
|
|
501
|
+
// across sessions, not across wakes of the same session.
|
|
502
|
+
this.supervisorKey = `s${supDoId.slice(0, 12)}-${supPid}`;
|
|
473
503
|
} else {
|
|
474
504
|
// Supervisor entrypoint unavailable — running without ctx.exports
|
|
475
505
|
// (e.g. unit-test harness, or LOADER.load contexts where the
|
|
@@ -669,6 +699,8 @@ export class IsolatePool {
|
|
|
669
699
|
/**
|
|
670
700
|
* Dispatch a single task to the slot isolate. `slotIndex` picks which
|
|
671
701
|
* warm isolate services the call; callers round-robin slots themselves.
|
|
702
|
+
* Serialized per slot: this call waits for the slot's previous
|
|
703
|
+
* execution to settle before touching the warm isolate.
|
|
672
704
|
*/
|
|
673
705
|
async #dispatchSlot(
|
|
674
706
|
fnSource: string,
|
|
@@ -677,6 +709,39 @@ export class IsolatePool {
|
|
|
677
709
|
args: unknown[],
|
|
678
710
|
resilience: ResolvedResilience,
|
|
679
711
|
perCallWasm?: Record<string, ArrayBuffer>,
|
|
712
|
+
): Promise<unknown> {
|
|
713
|
+
// A warm slot executes one dispatch at a time: queue behind the
|
|
714
|
+
// previous owner, then record this dispatch as the new tail. The
|
|
715
|
+
// tail outlives the caller's outcome: a timeout rejects the
|
|
716
|
+
// Promise.race while runOnce's RPC is still live on the Worker, so
|
|
717
|
+
// release waits for every execution the owned body started to
|
|
718
|
+
// settle — never the caller's settle alone.
|
|
719
|
+
const previous = this.slotTails.get(slotIndex) ?? Promise.resolve();
|
|
720
|
+
let release: () => void;
|
|
721
|
+
this.slotTails.set(slotIndex, new Promise<void>((resolve) => { release = resolve; }));
|
|
722
|
+
await previous;
|
|
723
|
+
if (this.disposed) {
|
|
724
|
+
release!();
|
|
725
|
+
throw new BindingError(`IsolatePool(${this.tag}) is disposed`);
|
|
726
|
+
}
|
|
727
|
+
const inFlight: Promise<unknown>[] = [];
|
|
728
|
+
try {
|
|
729
|
+
return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, args, resilience, perCallWasm, inFlight);
|
|
730
|
+
} finally {
|
|
731
|
+
// Do not delay the caller's own outcome — the tail releases when
|
|
732
|
+
// the RPCs the body launched have actually settled.
|
|
733
|
+
void Promise.allSettled(inFlight).then(() => release!());
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async #dispatchSlotOwned(
|
|
738
|
+
fnSource: string,
|
|
739
|
+
fnHash: string,
|
|
740
|
+
slotIndex: number,
|
|
741
|
+
args: unknown[],
|
|
742
|
+
resilience: ResolvedResilience,
|
|
743
|
+
perCallWasm: Record<string, ArrayBuffer> | undefined,
|
|
744
|
+
inFlight: Promise<unknown>[],
|
|
680
745
|
): Promise<unknown> {
|
|
681
746
|
// Per-call wasm fingerprint. Mixed into the cache key so two calls
|
|
682
747
|
// with different bytes hit different slots (no cache poisoning).
|
|
@@ -686,12 +751,12 @@ export class IsolatePool {
|
|
|
686
751
|
const perCallWasmHash = this.#fingerprintWasm(perCallWasmEntries);
|
|
687
752
|
|
|
688
753
|
// Cache key includes the short DO id so warm isolates are scoped to
|
|
689
|
-
// ONE session
|
|
690
|
-
// a
|
|
691
|
-
//
|
|
692
|
-
//
|
|
754
|
+
// ONE session (see the doIdShort field comment), and the supervisor
|
|
755
|
+
// identity so a wake of that same session can never reuse a warm
|
|
756
|
+
// worker whose SUPERVISOR binding still names the dead generation's
|
|
757
|
+
// pid. See the supervisorKey field comment for the failure mode.
|
|
693
758
|
const buildId = (generation: number): string =>
|
|
694
|
-
`nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:slot-${slotIndex}:g${generation}`;
|
|
759
|
+
`nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}:slot-${slotIndex}:g${generation}`;
|
|
695
760
|
let id = buildId(this.slotGenerations.get(slotIndex) ?? 0);
|
|
696
761
|
const code = this.#buildCode(fnSource, perCallWasmEntries);
|
|
697
762
|
|
|
@@ -767,8 +832,10 @@ export class IsolatePool {
|
|
|
767
832
|
// See plan in close-plan-2026-04-28.
|
|
768
833
|
let timerId: ReturnType<typeof setTimeout> | undefined;
|
|
769
834
|
try {
|
|
835
|
+
const attemptPromise = runOnce();
|
|
836
|
+
inFlight.push(attemptPromise);
|
|
770
837
|
return await Promise.race([
|
|
771
|
-
|
|
838
|
+
attemptPromise,
|
|
772
839
|
new Promise<never>((_, reject) => {
|
|
773
840
|
timerId = setTimeout(
|
|
774
841
|
() => reject(new TimeoutError(resilience.timeoutMs)),
|
|
@@ -780,7 +847,9 @@ export class IsolatePool {
|
|
|
780
847
|
if (timerId !== undefined) clearTimeout(timerId);
|
|
781
848
|
}
|
|
782
849
|
}
|
|
783
|
-
|
|
850
|
+
const attemptPromise = runOnce();
|
|
851
|
+
inFlight.push(attemptPromise);
|
|
852
|
+
return await attemptPromise;
|
|
784
853
|
} catch (err) {
|
|
785
854
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
786
855
|
const cause = classifyError(lastError);
|
|
@@ -974,6 +1043,7 @@ export class IsolatePool {
|
|
|
974
1043
|
* Safe to call more than once; idempotent.
|
|
975
1044
|
*/
|
|
976
1045
|
dispose(): void {
|
|
1046
|
+
this.disposed = true;
|
|
977
1047
|
if (!this.bindings) return;
|
|
978
1048
|
for (const key of Object.keys(this.bindings)) {
|
|
979
1049
|
disposeRpcResource(this.bindings[key]);
|
package/src/turn-budget.ts
CHANGED
|
@@ -63,10 +63,10 @@ export const TURN_CHUNK_MAX_BYTES = 2_000_000;
|
|
|
63
63
|
* spent.
|
|
64
64
|
*
|
|
65
65
|
* Callers report the work they are about to do or have just done and await
|
|
66
|
-
* the result; a pacer that is not yielding returns without suspending, so
|
|
67
|
-
*
|
|
68
|
-
* behaviour and cost. Nothing here decides WHAT the
|
|
69
|
-
* is allowed to stop.
|
|
66
|
+
* the result; a pacer that is not yielding returns without suspending, so a
|
|
67
|
+
* build small enough to fit in one chunk — most one-shot execs, every cache
|
|
68
|
+
* hit — keeps its exact behaviour and cost. Nothing here decides WHAT the
|
|
69
|
+
* launch does, only where it is allowed to stop.
|
|
70
70
|
*/
|
|
71
71
|
export class TurnBudget {
|
|
72
72
|
/** Turn handoffs this launch has taken. Reported with the launch. */
|