@nimbus-sh/fabric 0.1.0 → 0.3.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/README.md +208 -293
- package/dist/bindings.js +5 -5
- package/dist/budgets.d.ts +132 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +248 -0
- package/dist/composition.d.ts +3 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +2 -0
- package/dist/connections.d.ts +81 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +114 -0
- package/dist/derived.d.ts +65 -0
- package/dist/derived.d.ts.map +1 -0
- package/dist/derived.js +95 -0
- package/dist/do-calls.d.ts +94 -0
- package/dist/do-calls.d.ts.map +1 -0
- package/dist/do-calls.js +111 -0
- package/dist/facet-pool.d.ts +90 -0
- package/dist/facet-pool.d.ts.map +1 -0
- package/dist/facet-pool.js +113 -0
- package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
- package/dist/fanout.d.ts.map +1 -0
- package/dist/{fanout-pool.js → fanout.js} +20 -20
- package/dist/{launch-journal.d.ts → fenced-work.d.ts} +58 -17
- package/dist/fenced-work.d.ts.map +1 -0
- package/dist/fenced-work.js +241 -0
- package/dist/generation.d.ts +69 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +118 -0
- package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
- package/dist/image-store.d.ts.map +1 -0
- package/dist/{facet-image-store.js → image-store.js} +4 -4
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -8
- package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
- package/dist/isolate-pool.d.ts.map +1 -0
- package/dist/{loader-pool.js → isolate-pool.js} +20 -20
- package/dist/journal.d.ts +111 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +177 -0
- package/dist/outbox.d.ts +249 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +355 -0
- package/dist/process-fabric.d.ts +33 -15
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +25 -15
- package/dist/process-host.d.ts +1 -1
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +19 -11
- package/dist/sealed.d.ts +78 -0
- package/dist/sealed.d.ts.map +1 -0
- package/dist/sealed.js +145 -0
- package/dist/timers.d.ts +138 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +231 -0
- package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +24 -21
- package/dist/turn-budget.d.ts.map +1 -0
- package/dist/{launch-pacer.js → turn-budget.js} +24 -12
- package/dist/workerd-facet-host.d.ts +67 -70
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +129 -181
- package/examples/agent-core-adapter.ts +191 -0
- package/package.json +4 -2
- package/src/bindings.ts +6 -6
- package/src/budgets.ts +308 -0
- package/src/composition.ts +16 -0
- package/src/connections.ts +140 -0
- package/src/derived.ts +135 -0
- package/src/do-calls.ts +156 -0
- package/src/facet-pool.ts +157 -0
- package/src/{fanout-pool.ts → fanout.ts} +35 -35
- package/src/{launch-journal.ts → fenced-work.ts} +129 -42
- package/src/generation.ts +144 -0
- package/src/{facet-image-store.ts → image-store.ts} +9 -9
- package/src/index.ts +16 -8
- package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
- package/src/journal.ts +242 -0
- package/src/node-async-hooks.d.ts +14 -0
- package/src/outbox.ts +520 -0
- package/src/process-fabric.ts +43 -34
- package/src/process-host.ts +22 -20
- package/src/sealed.ts +150 -0
- package/src/timers.ts +294 -0
- package/src/{launch-pacer.ts → turn-budget.ts} +34 -27
- package/src/workerd-facet-host.ts +159 -208
- package/dist/alarms.d.ts +0 -134
- package/dist/alarms.d.ts.map +0 -1
- package/dist/alarms.js +0 -214
- package/dist/ctx-exports.d.ts +0 -47
- package/dist/ctx-exports.d.ts.map +0 -1
- package/dist/ctx-exports.js +0 -54
- package/dist/facet-image-store.d.ts.map +0 -1
- package/dist/fanout-pool.d.ts.map +0 -1
- package/dist/launch-journal.d.ts.map +0 -1
- package/dist/launch-journal.js +0 -154
- package/dist/launch-pacer.d.ts.map +0 -1
- package/dist/loader-ledger.d.ts +0 -57
- package/dist/loader-ledger.d.ts.map +0 -1
- package/dist/loader-ledger.js +0 -91
- package/dist/loader-pool.d.ts.map +0 -1
- package/src/alarms.ts +0 -275
- package/src/ctx-exports.ts +0 -77
- package/src/loader-ledger.ts +0 -112
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
* that is not a Durable Object implements `ProcessHost` against the same
|
|
13
13
|
* `HostedProcess` and never imports this file.
|
|
14
14
|
*/
|
|
15
|
-
import { disposeRpcResource } from '@nimbus-sh/
|
|
16
|
-
import { getCtxExports, supervisorEntrypoint, supervisorEntrypointName, } from './
|
|
17
|
-
import { beginLoaderFetch, recordLoaderId, withDynamicWorkerCapNamed, } from './
|
|
18
|
-
import { RESIDENT_PROCESS_CLASS,
|
|
15
|
+
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
16
|
+
import { getCtxExports, stagedBootAssembler, supervisorEntrypoint, supervisorEntrypointName, } from './composition.js';
|
|
17
|
+
import { assertModuleMapWithinCodeLimit, beginLoaderFetch, facetNameCount, facetNameCountDurable, recordFacetNameMinted, recordLoaderId, withDynamicWorkerCapNamed, withFacetBudgetNamed, } from './budgets.js';
|
|
18
|
+
import { RESIDENT_PROCESS_CLASS, residentLoaderConfig, } from './process-fabric.js';
|
|
19
19
|
export function getNimbusCtxExports() {
|
|
20
20
|
const ctxExports = getCtxExports();
|
|
21
21
|
if (!ctxExports || typeof ctxExports !== 'object') {
|
|
@@ -43,69 +43,6 @@ export async function createLoadedWorkerEntrypoint(ctxExports, supervisor, stage
|
|
|
43
43
|
},
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
|
-
/**
|
|
47
|
-
* Total bytes a dynamic Worker's module map may carry, across every member of
|
|
48
|
-
* it. A hard platform limit, not a policy knob: 62 MiB lands and 64 MiB is
|
|
49
|
-
* refused with "Dynamic Worker code size (N bytes) exceeds the maximum allowed
|
|
50
|
-
* size of 67108864 bytes", confirmed at five sizes with two trials each. The
|
|
51
|
-
* budget is shared, so a ruby process is already 34.3 MiB down before its disk
|
|
52
|
-
* is counted.
|
|
53
|
-
*/
|
|
54
|
-
export const DYNAMIC_WORKER_CODE_LIMIT_BYTES = 67_108_864;
|
|
55
|
-
/**
|
|
56
|
-
* Refuse a module map over {@link DYNAMIC_WORKER_CODE_LIMIT_BYTES}, naming
|
|
57
|
-
* the largest members. The platform's own refusal reports one number for a
|
|
58
|
-
* budget shared across every member of the map, which tells the operator
|
|
59
|
-
* nothing about WHAT to shrink — so every fabric seam that assembles a map
|
|
60
|
-
* runs this before the loader sees it.
|
|
61
|
-
*
|
|
62
|
-
* Costed to its two paths. Under the ceiling: one length read per member —
|
|
63
|
-
* UTF-16 code units for text, which equal UTF-8 bytes for the ASCII module
|
|
64
|
-
* text the generators emit and undercount otherwise; the platform's own
|
|
65
|
-
* refusal still backstops the exotic case, because this check exists to name
|
|
66
|
-
* members, not to be the ceiling. Over it: exact UTF-8 sizes, computed only
|
|
67
|
-
* then, sorted so the biggest lever is first.
|
|
68
|
-
*/
|
|
69
|
-
export function assertModuleMapWithinCodeLimit(modules) {
|
|
70
|
-
let estimate = 0;
|
|
71
|
-
for (const content of Object.values(modules)) {
|
|
72
|
-
estimate += memberBytes(content, null);
|
|
73
|
-
}
|
|
74
|
-
if (estimate <= DYNAMIC_WORKER_CODE_LIMIT_BYTES)
|
|
75
|
-
return;
|
|
76
|
-
const encoder = new TextEncoder();
|
|
77
|
-
const sized = Object.entries(modules)
|
|
78
|
-
.map(([name, content]) => ({ name, bytes: memberBytes(content, encoder) }))
|
|
79
|
-
.sort((a, b) => b.bytes - a.bytes);
|
|
80
|
-
const total = sized.reduce((sum, member) => sum + member.bytes, 0);
|
|
81
|
-
const top = sized.slice(0, 5)
|
|
82
|
-
.map(({ name, bytes }) => `'${name}' (${bytes.toLocaleString('en-US')} bytes)`)
|
|
83
|
-
.join(', ');
|
|
84
|
-
throw new Error(`Nimbus: dynamic-worker module map is ${total.toLocaleString('en-US')} bytes, over the `
|
|
85
|
-
+ `${DYNAMIC_WORKER_CODE_LIMIT_BYTES.toLocaleString('en-US')}-byte platform ceiling shared by `
|
|
86
|
-
+ `every member. Largest members: ${top}`);
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Bytes one module-map member carries, across the loader's content kinds
|
|
90
|
-
* (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`). With an
|
|
91
|
-
* encoder, text is measured exactly; without one, by code-unit length.
|
|
92
|
-
*/
|
|
93
|
-
function memberBytes(content, encoder) {
|
|
94
|
-
const textBytes = (text) => encoder ? encoder.encode(text).byteLength : text.length;
|
|
95
|
-
if (typeof content === 'string')
|
|
96
|
-
return textBytes(content);
|
|
97
|
-
if (content !== null && typeof content === 'object') {
|
|
98
|
-
for (const value of Object.values(content)) {
|
|
99
|
-
if (typeof value === 'string')
|
|
100
|
-
return textBytes(value);
|
|
101
|
-
if (value instanceof ArrayBuffer)
|
|
102
|
-
return value.byteLength;
|
|
103
|
-
if (ArrayBuffer.isView(value))
|
|
104
|
-
return value.byteLength;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return 0;
|
|
108
|
-
}
|
|
109
46
|
function facetContainer(ctx) {
|
|
110
47
|
const facets = ctx.facets;
|
|
111
48
|
if (!facets || typeof facets.get !== 'function') {
|
|
@@ -142,7 +79,7 @@ function facetContainer(ctx) {
|
|
|
142
79
|
* storage budget grants no copy-on-write credit — crossing it resets the
|
|
143
80
|
* object rather than raising an error.
|
|
144
81
|
*/
|
|
145
|
-
export async function
|
|
82
|
+
export async function cloneStorage(ctx, clone) {
|
|
146
83
|
const facets = facetContainer(ctx);
|
|
147
84
|
if (typeof facets.clone !== 'function') {
|
|
148
85
|
throw new Error('Nimbus: ctx.facets.clone is unavailable in this runtime; the reflink image '
|
|
@@ -161,7 +98,7 @@ export async function cloneFacetStorage(ctx, clone) {
|
|
|
161
98
|
}
|
|
162
99
|
}
|
|
163
100
|
/**
|
|
164
|
-
* The facet name for
|
|
101
|
+
* The facet name for an ephemeral slot. Reused, and that is the entire point.
|
|
165
102
|
*
|
|
166
103
|
* A Durable Object admits 65,536 facets over its LIFETIME: the IDs are
|
|
167
104
|
* append-only and are never reclaimed, so the bound is on facets ever CREATED,
|
|
@@ -173,19 +110,18 @@ export async function cloneFacetStorage(ctx, clone) {
|
|
|
173
110
|
* Reusing a NAME costs no new ID. So the name comes from a free list and the
|
|
174
111
|
* pid stays what it always was: the process identity in the ProcessTable. The
|
|
175
112
|
* two were only ever conflated because one of them happened to be handy.
|
|
113
|
+
*
|
|
114
|
+
* The book shares the facet-ID space with one other namespace: durable
|
|
115
|
+
* applications, which mint `app-slot-<n>` names of their own (one ID per app,
|
|
116
|
+
* ever). The prefixes are disjoint BY CONSTRUCTION, and that disjointness is
|
|
117
|
+
* load-bearing — a proc-slot name reissued onto a durable app's retained
|
|
118
|
+
* storage would boot the wrong process into someone else's disk.
|
|
176
119
|
*/
|
|
177
120
|
export function residentFacetName(slot) {
|
|
178
121
|
return `proc-slot-${slot}`;
|
|
179
122
|
}
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
* never reclaimed, so crossing it is unrecoverable for the object — which is
|
|
183
|
-
* why the ledger below counts consumption durably instead of leaving the
|
|
184
|
-
* bound as prose the slot book merely respects.
|
|
185
|
-
*/
|
|
186
|
-
export const FACET_ID_LIFETIME_BUDGET = 65_536;
|
|
187
|
-
/** Where the ledger persists the count of facet names ever minted. */
|
|
188
|
-
export const FACET_NAME_HIGH_WATER_KEY = 'fabric_facet_name_high_water';
|
|
123
|
+
/** The prefix every durable application's facet name carries. */
|
|
124
|
+
export const DURABLE_FACET_NAME_PREFIX = 'app-slot-';
|
|
189
125
|
/**
|
|
190
126
|
* Slot books, per hosting actor, because the facet index is per Durable
|
|
191
127
|
* Object.
|
|
@@ -200,47 +136,21 @@ export const FACET_NAME_HIGH_WATER_KEY = 'fabric_facet_name_high_water';
|
|
|
200
136
|
* VFS epoch, in which case `invalidatedSince` can only answer poison and the
|
|
201
137
|
* whole store is dropped. A process therefore cannot boot onto a previous
|
|
202
138
|
* tenant's filesystem even when release never ran.
|
|
139
|
+
*
|
|
140
|
+
* The book names only the `proc-slot-` space. Durable `app-slot-` names are
|
|
141
|
+
* allocated against DO storage instead (their owner survives a reset), so a
|
|
142
|
+
* fresh incarnation's `next` starting at 0 can never collide with them even
|
|
143
|
+
* before the durable ledger is adopted.
|
|
203
144
|
*/
|
|
204
145
|
const slotBooks = new WeakMap();
|
|
205
146
|
function slotBook(ctx) {
|
|
206
147
|
let book = slotBooks.get(ctx);
|
|
207
148
|
if (!book) {
|
|
208
|
-
|
|
209
|
-
free: [],
|
|
210
|
-
next: 0,
|
|
211
|
-
held: new Map(),
|
|
212
|
-
ledger: Promise.resolve(0),
|
|
213
|
-
ledgerKnown: 0,
|
|
214
|
-
};
|
|
215
|
-
created.ledger = Promise.resolve(ctx.storage.get(FACET_NAME_HIGH_WATER_KEY))
|
|
216
|
-
.then((value) => (typeof value === 'number' ? value : 0))
|
|
217
|
-
.catch(() => 0)
|
|
218
|
-
.then((adopted) => {
|
|
219
|
-
created.ledgerKnown = Math.max(created.ledgerKnown, adopted);
|
|
220
|
-
return adopted;
|
|
221
|
-
});
|
|
222
|
-
book = created;
|
|
149
|
+
book = { free: [], next: 0, held: new Map() };
|
|
223
150
|
slotBooks.set(ctx, book);
|
|
224
151
|
}
|
|
225
152
|
return book;
|
|
226
153
|
}
|
|
227
|
-
/**
|
|
228
|
-
* The lifetime facet-ID ledger: how many facet names this fabric has ever
|
|
229
|
-
* minted on the Durable Object, against the 65,536 the platform will ever
|
|
230
|
-
* grant it. `consumed` only ever counts FIRST uses — a reused name, in this
|
|
231
|
-
* incarnation or any earlier one, cost no new ID, which is the slot book's
|
|
232
|
-
* whole reason to exist. Surfaced so an operator can see proximity to a wall
|
|
233
|
-
* whose crossing is unrecoverable, instead of discovering it from the
|
|
234
|
-
* platform's opaque failure.
|
|
235
|
-
*/
|
|
236
|
-
export async function facetIdBudget(ctx) {
|
|
237
|
-
const book = slotBook(ctx);
|
|
238
|
-
const durable = await book.ledger;
|
|
239
|
-
return {
|
|
240
|
-
consumed: Math.max(durable, book.next),
|
|
241
|
-
budget: FACET_ID_LIFETIME_BUDGET,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
154
|
/** Take a slot for `pid`, reusing a returned one before minting a new name. */
|
|
245
155
|
function acquireSlot(ctx, pid) {
|
|
246
156
|
const book = slotBook(ctx);
|
|
@@ -250,47 +160,12 @@ function acquireSlot(ctx, pid) {
|
|
|
250
160
|
const reused = book.free.length > 0;
|
|
251
161
|
const slot = reused ? book.free.shift() : book.next++;
|
|
252
162
|
book.held.set(pid, slot);
|
|
163
|
+
// A fresh name is a permanently consumed facet ID; the durable count lives
|
|
164
|
+
// in the budgets ledger (see budgets.ts).
|
|
253
165
|
if (!reused)
|
|
254
|
-
|
|
166
|
+
recordFacetNameMinted(ctx, book.next);
|
|
255
167
|
return slot;
|
|
256
168
|
}
|
|
257
|
-
/**
|
|
258
|
-
* Advance the durable ledger to this incarnation's name count, if it is a new
|
|
259
|
-
* lifetime high. Chained behind adoption so the comparison is always against
|
|
260
|
-
* the real persisted value; a failed write leaves the old link's count and the
|
|
261
|
-
* next mint tries again — the ledger may transiently undercount, never over.
|
|
262
|
-
*/
|
|
263
|
-
function recordNameMinted(ctx, book) {
|
|
264
|
-
const count = book.next;
|
|
265
|
-
book.ledger = book.ledger.then(async (durable) => {
|
|
266
|
-
if (count <= durable)
|
|
267
|
-
return durable;
|
|
268
|
-
try {
|
|
269
|
-
await ctx.storage.put(FACET_NAME_HIGH_WATER_KEY, count);
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
return durable;
|
|
273
|
-
}
|
|
274
|
-
book.ledgerKnown = Math.max(book.ledgerKnown, count);
|
|
275
|
-
return count;
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
/**
|
|
279
|
-
* Name the facet-ID budget on a creation failure at the wall; below it, hand
|
|
280
|
-
* the error back untouched. Exhaustion is the one failure here the platform
|
|
281
|
-
* reports opaquely AND that no teardown, retry or reset can undo, so the
|
|
282
|
-
* ledger — the only witness to the real cause — does the naming. Not a
|
|
283
|
-
* threshold: the comparison is against the budget itself.
|
|
284
|
-
*/
|
|
285
|
-
function withFacetBudgetNamed(consumed, error) {
|
|
286
|
-
if (consumed < FACET_ID_LIFETIME_BUDGET)
|
|
287
|
-
return error;
|
|
288
|
-
const platform = error instanceof Error ? error.message : String(error);
|
|
289
|
-
return new Error(`Nimbus: facet creation failed with this Durable Object's `
|
|
290
|
-
+ `${FACET_ID_LIFETIME_BUDGET.toLocaleString('en-US')} facet-ID lifetime budget consumed `
|
|
291
|
-
+ `(${consumed} facet names ever created). Facet IDs are append-only and never reclaimed, `
|
|
292
|
-
+ `so this failure is permanent for the object: ${platform}`, { cause: error });
|
|
293
|
-
}
|
|
294
169
|
/** Return `pid`'s slot to the free list. */
|
|
295
170
|
function releaseSlot(ctx, pid) {
|
|
296
171
|
const book = slotBook(ctx);
|
|
@@ -302,21 +177,72 @@ function releaseSlot(ctx, pid) {
|
|
|
302
177
|
book.free.sort((a, b) => a - b);
|
|
303
178
|
}
|
|
304
179
|
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
180
|
+
* Drop one facet's SQLite by name — the ONLY call site that may delete facet
|
|
181
|
+
* storage. `spawnResident` releases ephemeral processes with abort+delete
|
|
182
|
+
* (storage is slot-reuse hygiene) and durable ones with abort alone (the
|
|
183
|
+
* storage IS the durable application's state); explicit removal arrives here
|
|
184
|
+
* through the coordinator's durable-slot book, owner-checked.
|
|
185
|
+
*/
|
|
186
|
+
export function deleteFacetStorage(ctx, name) {
|
|
187
|
+
facetContainer(ctx).delete(name);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The process surface of one hosting actor: how a resident process comes
|
|
191
|
+
* into existence on workerd, and how a one-shot program runs to completion.
|
|
307
192
|
*
|
|
308
|
-
*
|
|
193
|
+
* `spawn` is the ONE way a resident process comes into existence, and every
|
|
309
194
|
* substrate goes through it: the facet host calls it with the coordinator's
|
|
310
195
|
* own `ctx`, the peer host calls it — over one RPC — with a sibling session
|
|
311
196
|
* DO's. Everything a substrate could plausibly want to special-case is a
|
|
312
197
|
* PARAMETER here rather than a branch: which actor hosts the child, and how
|
|
313
198
|
* the boot spec's by-path members are read.
|
|
314
199
|
*/
|
|
315
|
-
export function
|
|
200
|
+
export function processes(ctx, env) {
|
|
201
|
+
return new Processes(ctx, env);
|
|
202
|
+
}
|
|
203
|
+
export class Processes {
|
|
204
|
+
ctx;
|
|
205
|
+
env;
|
|
206
|
+
constructor(ctx, env) {
|
|
207
|
+
this.ctx = ctx;
|
|
208
|
+
this.env = env;
|
|
209
|
+
}
|
|
210
|
+
/** Open a resident process as a facet of this actor, and start its runner. */
|
|
211
|
+
spawn(disk, supervisor, params) {
|
|
212
|
+
return spawnResident(this.ctx, this.env, disk, supervisor, params);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Run one program to completion as an UNKEYED dynamic worker.
|
|
216
|
+
*
|
|
217
|
+
* Unkeyed is the whole difference from `spawn`: nothing can re-resolve this
|
|
218
|
+
* worker into a later request's context, so it can never be a routeable
|
|
219
|
+
* target and never has to be released by name. It exists for the duration
|
|
220
|
+
* of one call and its stubs are dropped as that call unwinds.
|
|
221
|
+
*
|
|
222
|
+
* Shared by both substrates on purpose. `peer` places processes that have a
|
|
223
|
+
* residency to place; a one-shot has none, and shipping its fully-inline
|
|
224
|
+
* map across a sibling hop would meet the 32 MiB RPC ceiling that by-path
|
|
225
|
+
* boot specs exist to avoid — for a run that gains nothing by moving.
|
|
226
|
+
*/
|
|
227
|
+
run(supervisor, params, consume) {
|
|
228
|
+
return runOneShot(this.ctx, this.env, supervisor, params, consume);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function spawnResident(ctx, env, disk, supervisor, params) {
|
|
316
232
|
const facets = facetContainer(ctx);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
233
|
+
// An explicit name is the durable path: the caller allocated an
|
|
234
|
+
// `app-slot-<n>` identity out of DO storage and this facet keeps its SQLite
|
|
235
|
+
// across aborts. Anything else takes the in-memory book — and that book's
|
|
236
|
+
// `proc-slot-` names must never be minted for it, or an ephemeral release's
|
|
237
|
+
// delete would wipe the app's storage and a reused slot would land a new
|
|
238
|
+
// process on someone else's disk.
|
|
239
|
+
const explicit = params.facet;
|
|
240
|
+
if (explicit && !explicit.name.startsWith(DURABLE_FACET_NAME_PREFIX)) {
|
|
241
|
+
throw new Error(`Nimbus: an explicit facet name must carry the '${DURABLE_FACET_NAME_PREFIX}' `
|
|
242
|
+
+ `prefix, got '${explicit.name}'`);
|
|
243
|
+
}
|
|
244
|
+
const slot = explicit ? undefined : acquireSlot(ctx, params.pid);
|
|
245
|
+
const name = explicit ? explicit.name : residentFacetName(slot);
|
|
320
246
|
// The start callback is the ONLY way this facet is ever created, and it
|
|
321
247
|
// fires AT MOST ONCE. Every later use goes through the stub below, so the
|
|
322
248
|
// callback running a second time means the facet was released or died —
|
|
@@ -341,8 +267,9 @@ export function openResidentFacet(ctx, env, disk, supervisor, params) {
|
|
|
341
267
|
facet = facets.get(name, start);
|
|
342
268
|
}
|
|
343
269
|
catch (error) {
|
|
344
|
-
|
|
345
|
-
|
|
270
|
+
if (slot !== undefined)
|
|
271
|
+
releaseSlot(ctx, params.pid);
|
|
272
|
+
throw withFacetBudgetNamed(facetNameCount(ctx), error);
|
|
346
273
|
}
|
|
347
274
|
let disposed = false;
|
|
348
275
|
const release = async () => {
|
|
@@ -354,13 +281,21 @@ export function openResidentFacet(ctx, env, disk, supervisor, params) {
|
|
|
354
281
|
facets.abort(name, new Error('Nimbus: resident process released'));
|
|
355
282
|
}
|
|
356
283
|
catch { /* already gone */ }
|
|
357
|
-
|
|
358
|
-
|
|
284
|
+
// The two release classes: an ephemeral facet's SQLite is slot-reuse
|
|
285
|
+
// hygiene — the name is handed out again, so the store must not be — and
|
|
286
|
+
// a durable one's is the application itself: abort ends the process, the
|
|
287
|
+
// data stays for the next boot, and only removeDurableApp's explicit
|
|
288
|
+
// deleteFacetStorage call ever drops it.
|
|
289
|
+
if (!explicit?.durable) {
|
|
290
|
+
try {
|
|
291
|
+
facets.delete(name);
|
|
292
|
+
}
|
|
293
|
+
catch { /* already gone */ }
|
|
359
294
|
}
|
|
360
|
-
catch { /* already gone */ }
|
|
361
295
|
// Only after the facet is gone. A slot handed out while its previous
|
|
362
296
|
// tenant were still being torn down would have two processes on one name.
|
|
363
|
-
|
|
297
|
+
if (slot !== undefined)
|
|
298
|
+
releaseSlot(ctx, params.pid);
|
|
364
299
|
};
|
|
365
300
|
let started;
|
|
366
301
|
try {
|
|
@@ -368,15 +303,14 @@ export function openResidentFacet(ctx, env, disk, supervisor, params) {
|
|
|
368
303
|
}
|
|
369
304
|
catch (error) {
|
|
370
305
|
void release();
|
|
371
|
-
throw withFacetBudgetNamed(
|
|
306
|
+
throw withFacetBudgetNamed(facetNameCount(ctx), error);
|
|
372
307
|
}
|
|
373
308
|
// The rejection that carries the platform's failure at ID exhaustion is
|
|
374
309
|
// this one, and it is annotated AFTER awaiting the ledger — the first
|
|
375
310
|
// failure of a fresh incarnation must compare against the persisted count,
|
|
376
311
|
// not the zero its adoption read has not yet replaced.
|
|
377
312
|
started = started.catch(async (error) => {
|
|
378
|
-
|
|
379
|
-
throw withFacetBudgetNamed(Math.max(durable, book.next), error);
|
|
313
|
+
throw withFacetBudgetNamed(await facetNameCountDurable(ctx), error);
|
|
380
314
|
});
|
|
381
315
|
// A caller reads whichever of `started` and the lifecycle it needs, so keep
|
|
382
316
|
// the runtime from reporting the other as an unhandled rejection.
|
|
@@ -389,6 +323,7 @@ export function openResidentFacet(ctx, env, disk, supervisor, params) {
|
|
|
389
323
|
handleHttpRequest: (request) => facet.handleHttpRequest(request),
|
|
390
324
|
handleWebSocketRequest: (request) => facet.fetch(request),
|
|
391
325
|
release,
|
|
326
|
+
name,
|
|
392
327
|
slot,
|
|
393
328
|
};
|
|
394
329
|
}
|
|
@@ -415,20 +350,7 @@ function residentProcessClass(ctx, env, disk, supervisor, params) {
|
|
|
415
350
|
throw withDynamicWorkerCapNamed(ctx, error);
|
|
416
351
|
}
|
|
417
352
|
}
|
|
418
|
-
|
|
419
|
-
* Run one program to completion as an UNKEYED dynamic worker.
|
|
420
|
-
*
|
|
421
|
-
* Unkeyed is the whole difference from `openResidentFacet`: nothing can
|
|
422
|
-
* re-resolve this worker into a later request's context, so it can never be a
|
|
423
|
-
* routeable target and never has to be released by name. It exists for the
|
|
424
|
-
* duration of one call and its stubs are dropped as that call unwinds.
|
|
425
|
-
*
|
|
426
|
-
* Shared by both substrates on purpose. `peer` places processes that have a
|
|
427
|
-
* residency to place; a one-shot has none, and shipping its fully-inline map
|
|
428
|
-
* across a sibling hop would meet the 32 MiB RPC ceiling that by-path boot
|
|
429
|
-
* specs exist to avoid — for a run that gains nothing by moving.
|
|
430
|
-
*/
|
|
431
|
-
export async function runOneShotWorker(ctx, env, supervisor, params, consume) {
|
|
353
|
+
async function runOneShot(ctx, env, supervisor, params, consume) {
|
|
432
354
|
const loader = env.LOADER;
|
|
433
355
|
if (!loader || typeof loader.load !== 'function') {
|
|
434
356
|
throw new Error('Nimbus: env.LOADER binding missing or invalid. Running a program requires '
|
|
@@ -495,14 +417,40 @@ export async function runOneShotWorker(ctx, env, supervisor, params, consume) {
|
|
|
495
417
|
disposeRpcResource(supervisorBinding);
|
|
496
418
|
}
|
|
497
419
|
}
|
|
498
|
-
|
|
420
|
+
/**
|
|
421
|
+
* The WorkerCode the loader callback returns for one resident boot: the
|
|
422
|
+
* module map from {@link residentLoaderConfig} (or the staged assembler),
|
|
423
|
+
* plus the isolate's env and network posture.
|
|
424
|
+
*
|
|
425
|
+
* A `code` boot with an explicit `env` — defined, even as `{}` — is the
|
|
426
|
+
* embedder's whole statement about the isolate: the env rides through
|
|
427
|
+
* exactly as minted (loopback stubs by reference), and the composed supervisor
|
|
428
|
+
* entrypoint is not consulted at all, so no SUPERVISOR binding appears.
|
|
429
|
+
* Without one, the default holds: inherited network plus a SUPERVISOR
|
|
430
|
+
* minted from the composed entrypoint for the coordinator's identity.
|
|
431
|
+
*/
|
|
432
|
+
export async function residentWorkerConfig(env, disk, supervisor, boot) {
|
|
433
|
+
if (boot.kind === 'code' && boot.code.env !== undefined) {
|
|
434
|
+
const isolated = await residentLoaderConfig(boot.code, disk());
|
|
435
|
+
assertModuleMapWithinCodeLimit(configModules(isolated));
|
|
436
|
+
return isolated;
|
|
437
|
+
}
|
|
499
438
|
const config = boot.kind === 'staged'
|
|
500
|
-
? await
|
|
439
|
+
? await stagedBootAssembler()(env, boot.stage)
|
|
501
440
|
: await residentLoaderConfig(boot.code, disk());
|
|
502
|
-
assertModuleMapWithinCodeLimit(config
|
|
441
|
+
assertModuleMapWithinCodeLimit(configModules(config));
|
|
503
442
|
const supervisorRpc = supervisorEntrypoint();
|
|
504
443
|
if (!supervisorRpc) {
|
|
505
444
|
throw new Error(`Nimbus: ctx.exports.${supervisorEntrypointName() ?? '<supervisor entrypoint>'} unavailable`);
|
|
506
445
|
}
|
|
507
446
|
return { ...config, env: { SUPERVISOR: supervisorRpc({ props: supervisor }) } };
|
|
508
447
|
}
|
|
448
|
+
/** The module map a loader config assembled, or empty when it named none. */
|
|
449
|
+
function configModules(config) {
|
|
450
|
+
const modules = 'modules' in config ? config.modules : undefined;
|
|
451
|
+
if (typeof modules !== 'object' || modules === null)
|
|
452
|
+
return {};
|
|
453
|
+
// Loader configs only ever carry a module map under this key.
|
|
454
|
+
const map = modules;
|
|
455
|
+
return map;
|
|
456
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-core-adapter.ts — EXAMPLE: agent-core's process and environment
|
|
3
|
+
* seams, implemented over the fabric.
|
|
4
|
+
*
|
|
5
|
+
* agent-core defines two pure seams with no Cloudflare implementation:
|
|
6
|
+
* `ShellProcessBackend` (packages/agent-core/src/facets/shell/facet.ts:38-43
|
|
7
|
+
* — exactly `completion`, `forceTerminate`, `confirmTerminated`, `fence`)
|
|
8
|
+
* and `EnvironmentProvider` (src/environments/provider.ts:195-225 — every
|
|
9
|
+
* mutating verb paired with an inspect twin, requests generation-pinned,
|
|
10
|
+
* outcomes `ready | absent | failed | indeterminate`). This file shows the
|
|
11
|
+
* mapping onto fabric's `ResidentProcessHandle`, `cloneStorage`, and the
|
|
12
|
+
* hostname-per-port preview scheme. It is a worked example, not a shipped
|
|
13
|
+
* runtime: the seam types are mirrored here structurally (agent-core's repo
|
|
14
|
+
* is its own), and an embedder writes this file's equivalent against the
|
|
15
|
+
* real imports.
|
|
16
|
+
*
|
|
17
|
+
* The lifecycle facts the mapping rests on, from agent-core's own boundary
|
|
18
|
+
* (`ShellExecutionBoundary`, facet.ts:60-154):
|
|
19
|
+
* - a rejected `completion` is treated as exit 1 by the boundary;
|
|
20
|
+
* - `forceTerminate` may throw — the boundary then fences immediately;
|
|
21
|
+
* - `confirmTerminated() === false` means "still deciding" and defers to
|
|
22
|
+
* the boundary's timeout, never "failed";
|
|
23
|
+
* - a throwing `fence` is swallowed: "The local fence still closes this
|
|
24
|
+
* handle even if remote cleanup reports failure."
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ResidentProcessHandle } from '../src/process-fabric.js';
|
|
28
|
+
import { cloneStorage } from '../src/workerd-facet-host.js';
|
|
29
|
+
|
|
30
|
+
// ── agent-core's seams, mirrored structurally (do not import their repo) ────
|
|
31
|
+
|
|
32
|
+
/** facet.ts:38-43, verbatim shape. */
|
|
33
|
+
export interface ShellProcessBackend {
|
|
34
|
+
readonly completion: Promise<number>;
|
|
35
|
+
forceTerminate(): void;
|
|
36
|
+
confirmTerminated(): boolean | Promise<boolean>;
|
|
37
|
+
fence(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** provider.ts:37-61 — the outcome unions the provider seam speaks. */
|
|
41
|
+
export type ProviderActionOutcome = { readonly name: 'succeeded' | 'failed' | 'indeterminate' };
|
|
42
|
+
export type ProviderResourceOutcome<Value> =
|
|
43
|
+
| { readonly name: 'ready'; readonly value: Value }
|
|
44
|
+
| { readonly name: 'absent' }
|
|
45
|
+
| { readonly name: 'failed' }
|
|
46
|
+
| { readonly name: 'indeterminate' };
|
|
47
|
+
|
|
48
|
+
/** provider.ts:138-145 — the live-session handle openSession yields. */
|
|
49
|
+
export interface LiveEnvironmentSession {
|
|
50
|
+
readonly children: ReadonlyArray<{ dispose(): void | Promise<void> }>;
|
|
51
|
+
release(): void | Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** provider.ts:171-193 — every request pins environment identity and
|
|
55
|
+
* generation, so a stale caller cannot act on a successor. */
|
|
56
|
+
export interface OpenSessionRequest {
|
|
57
|
+
readonly environmentId: string;
|
|
58
|
+
readonly environmentRevision: number;
|
|
59
|
+
readonly generation: number;
|
|
60
|
+
readonly sessionId: string;
|
|
61
|
+
}
|
|
62
|
+
export interface SnapshotEnvironmentRequest extends OpenSessionRequest {
|
|
63
|
+
readonly sessionEpoch: number;
|
|
64
|
+
readonly snapshotId: string;
|
|
65
|
+
}
|
|
66
|
+
export interface ExposePortRequest extends OpenSessionRequest {
|
|
67
|
+
readonly sessionEpoch: number;
|
|
68
|
+
readonly exposureId: string;
|
|
69
|
+
readonly port: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── ShellProcessBackend over one resident process ───────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A shell command as a fabric resident. The exit code rides the `lifetime`
|
|
76
|
+
* runner's startProcess payload, whose shape is the embedder's — so the
|
|
77
|
+
* embedder names how to read it.
|
|
78
|
+
*/
|
|
79
|
+
export function shellBackendFor(
|
|
80
|
+
handle: ResidentProcessHandle,
|
|
81
|
+
exitCodeOf: (startPayload: unknown) => number,
|
|
82
|
+
): ShellProcessBackend {
|
|
83
|
+
return {
|
|
84
|
+
// A lifetime runner settles booted() at exit; a host that dies under the
|
|
85
|
+
// process rejects it, and the boundary maps that rejection to exit 1.
|
|
86
|
+
completion: handle.booted().then(exitCodeOf),
|
|
87
|
+
// Synchronous and idempotent, matching ResidentProcessHandle.kill.
|
|
88
|
+
forceTerminate(): void {
|
|
89
|
+
handle.kill();
|
|
90
|
+
},
|
|
91
|
+
// False before a kill was asked for — "still deciding", the boundary
|
|
92
|
+
// waits on its own timeout. After a kill, settle with the teardown: done
|
|
93
|
+
// resolves only once the process is actually gone.
|
|
94
|
+
confirmTerminated(): boolean | Promise<boolean> {
|
|
95
|
+
if (!handle.killed) return false;
|
|
96
|
+
return handle.done.then(() => true, () => true);
|
|
97
|
+
},
|
|
98
|
+
// The local fence: close this handle's side whatever the remote did.
|
|
99
|
+
// kill() is idempotent and swallows teardown throws, which is exactly
|
|
100
|
+
// the "local fence still closes this handle" contract.
|
|
101
|
+
fence(): void {
|
|
102
|
+
handle.kill();
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── The four EnvironmentProvider verbs over the fabric ──────────────────────
|
|
108
|
+
|
|
109
|
+
/** What the example provider needs from its embedder: how to spawn one
|
|
110
|
+
* session process, and where port previews are served. */
|
|
111
|
+
export interface FabricEnvironmentHost {
|
|
112
|
+
/** Spawn the resident process backing one session (the embedder owns the
|
|
113
|
+
* boot spec; `ProcessFabric.startResidentProcess` is the fabric half). */
|
|
114
|
+
spawnSession(request: OpenSessionRequest): Promise<ResidentProcessHandle>;
|
|
115
|
+
/** The hosting Durable Object's ctx — snapshots clone same-object only. */
|
|
116
|
+
ctx: Parameters<typeof cloneStorage>[0];
|
|
117
|
+
/** Positive populated-ness probe for a facet name (the caller's schema —
|
|
118
|
+
* see cloneStorage: a clone of an unresolvable source silently EMPTIES
|
|
119
|
+
* the destination, so both ends are verified). */
|
|
120
|
+
facetPopulated(name: string): boolean | Promise<boolean>;
|
|
121
|
+
/** The facet name holding a session's storage. */
|
|
122
|
+
sessionFacetName(sessionId: string): string;
|
|
123
|
+
/** Preview apex, e.g. 'nimbus-os.dev' — ports serve as `<port>--<id>.<apex>`. */
|
|
124
|
+
previewApex: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class FabricEnvironmentProvider {
|
|
128
|
+
/** exposureId → preview URL. Inbound requests for these hosts route into
|
|
129
|
+
* the session's `ResidentProcessHandle.routeTarget.handleHttpRequest`. */
|
|
130
|
+
private readonly exposures = new Map<string, string>();
|
|
131
|
+
|
|
132
|
+
constructor(private readonly host: FabricEnvironmentHost) {}
|
|
133
|
+
|
|
134
|
+
async openSession(request: OpenSessionRequest): Promise<ProviderResourceOutcome<LiveEnvironmentSession>> {
|
|
135
|
+
try {
|
|
136
|
+
const handle = await this.host.spawnSession(request);
|
|
137
|
+
return {
|
|
138
|
+
name: 'ready',
|
|
139
|
+
value: { children: [], release: () => handle.kill() },
|
|
140
|
+
};
|
|
141
|
+
} catch {
|
|
142
|
+
// The spawn either failed before any facet existed (failed) or the
|
|
143
|
+
// host died mid-boot (indeterminate); the fabric rejects both loudly.
|
|
144
|
+
return { name: 'failed' };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Snapshot = same-object copy-on-write of the session facet's SQLite
|
|
150
|
+
* (measured 18-31 ms for a 45.73 MB corpus, flat in size). The ContentRef
|
|
151
|
+
* is the snapshot facet's name; `cloneStorage` verifies BOTH ends are
|
|
152
|
+
* populated, because an unresolvable source silently empties the
|
|
153
|
+
* destination while reporting success.
|
|
154
|
+
*/
|
|
155
|
+
async createSnapshot(request: SnapshotEnvironmentRequest): Promise<ProviderResourceOutcome<string>> {
|
|
156
|
+
const src = this.host.sessionFacetName(request.sessionId);
|
|
157
|
+
const dst = `snapshot-${request.snapshotId}`;
|
|
158
|
+
try {
|
|
159
|
+
await cloneStorage(this.host.ctx, {
|
|
160
|
+
src,
|
|
161
|
+
dst,
|
|
162
|
+
populated: (name) => this.host.facetPopulated(name),
|
|
163
|
+
});
|
|
164
|
+
return { name: 'ready', value: dst };
|
|
165
|
+
} catch {
|
|
166
|
+
// A refused clone left nothing to trust in dst — never 'ready'.
|
|
167
|
+
return { name: 'failed' };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Hostname-per-port: `<port>--<sessionId>.<apex>`, no path rewriting. The
|
|
173
|
+
* URL is derivable, so re-exposing is idempotent and inspection is a map
|
|
174
|
+
* read.
|
|
175
|
+
*/
|
|
176
|
+
async exposePort(request: ExposePortRequest): Promise<ProviderResourceOutcome<string>> {
|
|
177
|
+
const url = `https://${request.port}--${request.sessionId}.${this.host.previewApex}/`;
|
|
178
|
+
this.exposures.set(request.exposureId, url);
|
|
179
|
+
return { name: 'ready', value: url };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async inspectExposure(request: ExposePortRequest): Promise<ProviderResourceOutcome<string>> {
|
|
183
|
+
const url = this.exposures.get(request.exposureId);
|
|
184
|
+
return url === undefined ? { name: 'absent' } : { name: 'ready', value: url };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async revokeExposure(request: ExposePortRequest): Promise<ProviderActionOutcome> {
|
|
188
|
+
this.exposures.delete(request.exposureId);
|
|
189
|
+
return { name: 'succeeded' };
|
|
190
|
+
}
|
|
191
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nimbus-sh/fabric",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"files": [
|
|
41
41
|
"dist",
|
|
42
42
|
"src",
|
|
43
|
+
"examples",
|
|
43
44
|
"README.md",
|
|
44
45
|
"LICENSE"
|
|
45
46
|
],
|
|
@@ -49,7 +50,8 @@
|
|
|
49
50
|
"typecheck": "tsc --noEmit"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
|
-
"@nimbus-sh/core": "^0.
|
|
53
|
+
"@nimbus-sh/core": "^0.7.0",
|
|
54
|
+
"@nimbus-sh/platform": "^0.2.0",
|
|
53
55
|
"zod": "^4.4.3"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|