@nimbus-sh/fabric 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/bindings.d.ts +5 -49
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +59 -27
- package/dist/fanout.d.ts +4 -11
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +38 -42
- package/dist/host-dispatch.d.ts +6 -0
- package/dist/host-dispatch.d.ts.map +1 -0
- package/dist/host-dispatch.js +27 -0
- package/dist/isolate-pool.d.ts +21 -0
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +53 -9
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +60 -33
- package/package.json +3 -3
- package/src/bindings.ts +57 -92
- package/src/fanout.ts +48 -67
- package/src/host-dispatch.ts +34 -0
- package/src/isolate-pool.ts +106 -14
- package/src/process-host.ts +64 -44
package/src/fanout.ts
CHANGED
|
@@ -20,20 +20,18 @@ import { IsolatePool, type FacetTaskFn } from './isolate-pool.js';
|
|
|
20
20
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
21
21
|
import { describeError, isDoOverloaded, isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
|
|
22
22
|
import type { WorkerLoader } from './vendor/types.js';
|
|
23
|
+
import {
|
|
24
|
+
hostNamespaceBinding,
|
|
25
|
+
hostOpDispatch,
|
|
26
|
+
type HostOpDispatch,
|
|
27
|
+
} from './host-dispatch.js';
|
|
23
28
|
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*/
|
|
28
|
-
interface PeerSessionNamespace {
|
|
29
|
-
idFromName(name: string): DurableObjectId;
|
|
30
|
-
get(id: DurableObjectId): unknown;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** The bindings a fan-out needs off the coordinator DO's env. */
|
|
29
|
+
/** The bindings a fan-out needs off the coordinator DO's env. The host
|
|
30
|
+
* namespace key is the composed one — whatever this host named its own
|
|
31
|
+
* binding (default NIMBUS_SESSION); peers dispatch supervisorOp envelopes. */
|
|
34
32
|
export interface FanoutEnv {
|
|
35
33
|
LOADER?: WorkerLoader;
|
|
36
|
-
NIMBUS_SESSION?:
|
|
34
|
+
NIMBUS_SESSION?: unknown;
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
/**
|
|
@@ -168,30 +166,13 @@ export interface FanoutOptions {
|
|
|
168
166
|
maxPeers?: number;
|
|
169
167
|
}
|
|
170
168
|
|
|
171
|
-
interface
|
|
172
|
-
|
|
173
|
-
fnSource: string,
|
|
174
|
-
args: unknown[],
|
|
175
|
-
poolOpts?: Record<string, unknown>,
|
|
176
|
-
): Promise<{ results?: R[] }>;
|
|
169
|
+
interface FanoutPeerResult<R> {
|
|
170
|
+
results: R[];
|
|
177
171
|
}
|
|
178
172
|
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
const execute = Reflect.get(value, '_rpcFanoutExecute');
|
|
184
|
-
return typeof execute === 'function';
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function fanoutPeerStub(value: unknown): FanoutPeerStub {
|
|
188
|
-
if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
|
|
189
|
-
throw new BindingError('Fanout: NIMBUS_SESSION.get() did not return a peer stub.');
|
|
190
|
-
}
|
|
191
|
-
if (!isFanoutPeerStub(value)) {
|
|
192
|
-
throw new BindingError('Fanout: peer stub does not expose _rpcFanoutExecute().');
|
|
193
|
-
}
|
|
194
|
-
return value;
|
|
173
|
+
// Task values retain the submitted function's type; validate the wire envelope.
|
|
174
|
+
function isPeerResult<R>(value: Awaited<ReturnType<HostOpDispatch>>): value is FanoutPeerResult<R> {
|
|
175
|
+
return value !== null && typeof value === 'object' && 'results' in value && Array.isArray(value.results);
|
|
195
176
|
}
|
|
196
177
|
|
|
197
178
|
/**
|
|
@@ -323,14 +304,10 @@ export class Fanout {
|
|
|
323
304
|
tasks: FanoutTask<A>[],
|
|
324
305
|
fn: FacetTaskFn<A, R>,
|
|
325
306
|
): Promise<R[]> {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
'The peer-DO topology requires it. ' +
|
|
331
|
-
'Add the binding via durable_objects.bindings in wrangler.jsonc.',
|
|
332
|
-
);
|
|
333
|
-
}
|
|
307
|
+
// The peer-DO topology routes through the composed host namespace —
|
|
308
|
+
// a workspace host names its own binding, and its stub forwards one
|
|
309
|
+
// supervisorOp entrypoint, not private _rpc* methods.
|
|
310
|
+
const ns = hostNamespaceBinding(this.env ?? {}, 'Fanout');
|
|
334
311
|
|
|
335
312
|
// Serialize the user function ONCE here on the supervisor side.
|
|
336
313
|
// Each peer DO receives the same fnSource string; warm peer
|
|
@@ -380,38 +357,42 @@ export class Fanout {
|
|
|
380
357
|
// stub points at a torn-down object, so a retry re-resolves the
|
|
381
358
|
// sibling by its stable id.
|
|
382
359
|
const peerStub = ns.get(id);
|
|
383
|
-
const stub = fanoutPeerStub(peerStub);
|
|
384
360
|
try {
|
|
361
|
+
const dispatch = hostOpDispatch(peerStub, `Fanout peer ${siblingName}`);
|
|
385
362
|
// Each peer DO RPC call uses ONE LOADER worker on its side.
|
|
386
363
|
// Supervisor → peer DO is a stub.fetch / RPC method call,
|
|
387
364
|
// NOT an env.LOADER.get(); that's the cap-sidestep that
|
|
388
365
|
// makes peer-DO fanout work.
|
|
389
|
-
const rpcResp = await
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
366
|
+
const rpcResp = await dispatch({
|
|
367
|
+
op: 'fanoutExecute',
|
|
368
|
+
args: [
|
|
369
|
+
fnSource,
|
|
370
|
+
peerArgs,
|
|
371
|
+
{
|
|
372
|
+
tag: this.opts.tag,
|
|
373
|
+
timeoutMs: this.opts.timeoutMs,
|
|
374
|
+
preamble: this.opts.preamble,
|
|
375
|
+
wasmModules: this.opts.wasmModules,
|
|
376
|
+
extraBindings: this.opts.extraBindings,
|
|
377
|
+
omitSupervisor: this.opts.omitSupervisor,
|
|
378
|
+
// INSTALL-HONESTY: forward the COORDINATOR's full doId so
|
|
379
|
+
// the peer's IsolatePool can mint a SUPERVISOR
|
|
380
|
+
// binding that routes back HERE (the user's session DO),
|
|
381
|
+
// not to the peer DO itself. Without this, peer DOs'
|
|
382
|
+
// env.SUPERVISOR.writeBatch / writeBatchStream / stdout /
|
|
383
|
+
// ... write into the peer's own VFS — invisible to the
|
|
384
|
+
// user. See INSTALL-HONESTY-retro.md.
|
|
385
|
+
coordinatorDoId: this.coordDoId,
|
|
386
|
+
// Credential source for peer-side writeBatchStream — the
|
|
387
|
+
// invoking process pid, so package writes are authorized
|
|
388
|
+
// as the user (not rejected as pid:0).
|
|
389
|
+
supervisorPid: this.opts.supervisorPid,
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
});
|
|
413
393
|
try {
|
|
414
|
-
|
|
394
|
+
if (!isPeerResult<R>(rpcResp)) throw new BindingError(`Fanout peer '${siblingName}' returned no result array`);
|
|
395
|
+
const peerResults = rpcResp.results;
|
|
415
396
|
if (peerResults.length !== bucket.length) {
|
|
416
397
|
throw new Error(
|
|
417
398
|
`peer DO returned ${peerResults.length} results for ${bucket.length} tasks ` +
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { BindingError } from './vendor/errors.js';
|
|
2
|
+
import { hostDispatchMethod, hostNamespace } from './composition.js';
|
|
3
|
+
import type { createSupervisorOpHandler } from '@nimbus-sh/core/workspace/supervisor-op.js';
|
|
4
|
+
|
|
5
|
+
export type HostNamespaceBinding = Pick<DurableObjectNamespace, 'get' | 'idFromName' | 'idFromString'>;
|
|
6
|
+
export type HostOpDispatch = ReturnType<typeof createSupervisorOpHandler>;
|
|
7
|
+
|
|
8
|
+
function isNamespace(value: object): value is HostNamespaceBinding {
|
|
9
|
+
return typeof Reflect.get(value, 'idFromName') === 'function'
|
|
10
|
+
&& typeof Reflect.get(value, 'idFromString') === 'function'
|
|
11
|
+
&& typeof Reflect.get(value, 'get') === 'function';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hostNamespaceBinding(env: object | null | undefined, usage: string): HostNamespaceBinding {
|
|
15
|
+
const name = hostNamespace();
|
|
16
|
+
const binding = env ? Reflect.get(env, name) : undefined;
|
|
17
|
+
if (binding === null || (typeof binding !== 'object' && typeof binding !== 'function') || !isNamespace(binding)) {
|
|
18
|
+
throw new BindingError(`${usage}: env.${name} must be the Durable Object namespace configured by composeFabric`);
|
|
19
|
+
}
|
|
20
|
+
return binding;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hostOpDispatch(stub: object, usage: string): HostOpDispatch {
|
|
24
|
+
const name = hostDispatchMethod();
|
|
25
|
+
if (stub === null || (typeof stub !== 'object' && typeof stub !== 'function')) {
|
|
26
|
+
throw new BindingError(`${usage}: the workspace namespace returned no stub`);
|
|
27
|
+
}
|
|
28
|
+
const dispatch = Reflect.get(stub, name);
|
|
29
|
+
if (typeof dispatch !== 'function') {
|
|
30
|
+
throw new BindingError(`${usage}: the workspace host must forward ${name}(envelope) to the runtime`);
|
|
31
|
+
}
|
|
32
|
+
// RpcStub.call would invoke a remote method named "call".
|
|
33
|
+
return (envelope) => Promise.resolve(Reflect.apply(dispatch, stub, [envelope]));
|
|
34
|
+
}
|
package/src/isolate-pool.ts
CHANGED
|
@@ -93,6 +93,15 @@ export interface IsolatePoolOptions {
|
|
|
93
93
|
* not receive a Supervisor binding and do not retain user state.
|
|
94
94
|
*/
|
|
95
95
|
cacheScope?: 'session' | 'global';
|
|
96
|
+
/**
|
|
97
|
+
* Baked into the loader id. Two pools sharing tag, preamble, wasm and
|
|
98
|
+
* supervisor but differing in `scope` never reuse each other's warm
|
|
99
|
+
* workers: an interpreter scope that ENDS (REPL close, Ctrl-C reset)
|
|
100
|
+
* must not resurrect the loader-cached heap for the next owner.
|
|
101
|
+
* Default '' preserves the existing id bytes — only scoped pools get
|
|
102
|
+
* the extra segment.
|
|
103
|
+
*/
|
|
104
|
+
scope?: string;
|
|
96
105
|
/**
|
|
97
106
|
* Override the `doId` baked into the auto-injected SUPERVISOR binding.
|
|
98
107
|
* Default: `ctx.id.toString()` (the DO that constructs the pool).
|
|
@@ -308,6 +317,9 @@ export function assembleLoaderWorkerModuleSource(
|
|
|
308
317
|
const callExpr = options.hasBindings
|
|
309
318
|
? '__fn__(...args, this.env)'
|
|
310
319
|
: '__fn__(...args)';
|
|
320
|
+
const requestCallExpr = options.hasBindings
|
|
321
|
+
? '__fn__(request, this.env)'
|
|
322
|
+
: '__fn__(request)';
|
|
311
323
|
lines.push(
|
|
312
324
|
'export default class extends WorkerEntrypoint {',
|
|
313
325
|
' execute(...args) {',
|
|
@@ -315,6 +327,26 @@ export function assembleLoaderWorkerModuleSource(
|
|
|
315
327
|
' if (result instanceof Promise) return result;',
|
|
316
328
|
' return result;',
|
|
317
329
|
' }',
|
|
330
|
+
'',
|
|
331
|
+
' // Request/Response transport: the ONE dispatch path whose abort is',
|
|
332
|
+
' // real. workerd cancels the inner execution context when the request',
|
|
333
|
+
' // signal aborts while the fn is suspended on I/O; RPC execute() above',
|
|
334
|
+
' // has no equivalent (measured: Symbol.dispose on a pending RPC does',
|
|
335
|
+
' // not cancel). The fn is request-shaped — it encodes and decodes its',
|
|
336
|
+
' // own payload; nothing generic is serialized here.',
|
|
337
|
+
' async fetch(request) {',
|
|
338
|
+
' try {',
|
|
339
|
+
` const result = await ${requestCallExpr};`,
|
|
340
|
+
' if (result instanceof Response) return result;',
|
|
341
|
+
' return Response.json(result ?? null);',
|
|
342
|
+
' } catch (err) {',
|
|
343
|
+
' // Fail loud inside the response so an abort of the transport is',
|
|
344
|
+
' // not conflated with a guest exception: the caller sees 500 only',
|
|
345
|
+
' // for a real fn failure, and aborts arrive as fetch rejections.',
|
|
346
|
+
' const message = err instanceof Error && err.message ? err.message : String(err);',
|
|
347
|
+
' return Response.json({ __nimbusFacetError: message }, { status: 500 });',
|
|
348
|
+
' }',
|
|
349
|
+
' }',
|
|
318
350
|
'}',
|
|
319
351
|
);
|
|
320
352
|
return lines.join('\n');
|
|
@@ -400,6 +432,8 @@ export class IsolatePool {
|
|
|
400
432
|
*/
|
|
401
433
|
private readonly supervisorKey: string;
|
|
402
434
|
|
|
435
|
+
/** Extra loader-id segment from options.scope — see IsolatePoolOptions. */
|
|
436
|
+
private readonly scope: string;
|
|
403
437
|
constructor(
|
|
404
438
|
env: unknown,
|
|
405
439
|
ctx: DurableObjectState,
|
|
@@ -427,6 +461,7 @@ export class IsolatePool {
|
|
|
427
461
|
this.doIdShort = opts?.cacheScope === 'global'
|
|
428
462
|
? 'global'
|
|
429
463
|
: ctx.id.toString().slice(0, 12);
|
|
464
|
+
this.scope = opts?.scope ?? '';
|
|
430
465
|
|
|
431
466
|
// Materialise the wasm-modules table. Sanitise each name into a
|
|
432
467
|
// valid JS identifier for the static import binding; key collisions
|
|
@@ -701,15 +736,22 @@ export class IsolatePool {
|
|
|
701
736
|
* warm isolate services the call; callers round-robin slots themselves.
|
|
702
737
|
* Serialized per slot: this call waits for the slot's previous
|
|
703
738
|
* execution to settle before touching the warm isolate.
|
|
739
|
+
*
|
|
740
|
+
* `invoke` is the single-attempt call against the slot's entrypoint
|
|
741
|
+
* stub. `execute` dispatches call `entrypoint.execute(...args)`;
|
|
742
|
+
* `fetch` dispatches call `entrypoint.fetch(<per-attempt Request>)` —
|
|
743
|
+
* the attempt index lets a fetch invoke mint a fresh clone for retries,
|
|
744
|
+
* since a Request's body is consumed once.
|
|
704
745
|
*/
|
|
705
|
-
async #dispatchSlot(
|
|
746
|
+
async #dispatchSlot<T>(
|
|
706
747
|
fnSource: string,
|
|
707
748
|
fnHash: string,
|
|
708
749
|
slotIndex: number,
|
|
709
|
-
args: unknown[],
|
|
750
|
+
invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
|
|
710
751
|
resilience: ResolvedResilience,
|
|
711
752
|
perCallWasm?: Record<string, ArrayBuffer>,
|
|
712
|
-
|
|
753
|
+
wasAborted?: () => boolean,
|
|
754
|
+
): Promise<T> {
|
|
713
755
|
// A warm slot executes one dispatch at a time: queue behind the
|
|
714
756
|
// previous owner, then record this dispatch as the new tail. The
|
|
715
757
|
// tail outlives the caller's outcome: a timeout rejects the
|
|
@@ -726,7 +768,7 @@ export class IsolatePool {
|
|
|
726
768
|
}
|
|
727
769
|
const inFlight: Promise<unknown>[] = [];
|
|
728
770
|
try {
|
|
729
|
-
return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex,
|
|
771
|
+
return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, wasAborted);
|
|
730
772
|
} finally {
|
|
731
773
|
// Do not delay the caller's own outcome — the tail releases when
|
|
732
774
|
// the RPCs the body launched have actually settled.
|
|
@@ -734,15 +776,17 @@ export class IsolatePool {
|
|
|
734
776
|
}
|
|
735
777
|
}
|
|
736
778
|
|
|
737
|
-
|
|
779
|
+
|
|
780
|
+
async #dispatchSlotOwned<T>(
|
|
738
781
|
fnSource: string,
|
|
739
782
|
fnHash: string,
|
|
740
783
|
slotIndex: number,
|
|
741
|
-
args: unknown[],
|
|
784
|
+
invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
|
|
742
785
|
resilience: ResolvedResilience,
|
|
743
786
|
perCallWasm: Record<string, ArrayBuffer> | undefined,
|
|
744
787
|
inFlight: Promise<unknown>[],
|
|
745
|
-
|
|
788
|
+
wasAborted?: () => boolean,
|
|
789
|
+
): Promise<T> {
|
|
746
790
|
// Per-call wasm fingerprint. Mixed into the cache key so two calls
|
|
747
791
|
// with different bytes hit different slots (no cache poisoning).
|
|
748
792
|
// For the common case (no per-call wasm) the fingerprint is '0',
|
|
@@ -756,7 +800,7 @@ export class IsolatePool {
|
|
|
756
800
|
// worker whose SUPERVISOR binding still names the dead generation's
|
|
757
801
|
// pid. See the supervisorKey field comment for the failure mode.
|
|
758
802
|
const buildId = (generation: number): string =>
|
|
759
|
-
`nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}:slot-${slotIndex}:g${generation}`;
|
|
803
|
+
`nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}:slot-${slotIndex}:g${generation}${this.scope ? `:${this.scope}` : ''}`;
|
|
760
804
|
let id = buildId(this.slotGenerations.get(slotIndex) ?? 0);
|
|
761
805
|
const code = this.#buildCode(fnSource, perCallWasmEntries);
|
|
762
806
|
|
|
@@ -765,7 +809,7 @@ export class IsolatePool {
|
|
|
765
809
|
// slot updated on every dispatch.
|
|
766
810
|
try { setLastFacetId(id, slotIndex); } catch { /* best-effort */ }
|
|
767
811
|
|
|
768
|
-
const runOnce = async (): Promise<
|
|
812
|
+
const runOnce = async (): Promise<T> => {
|
|
769
813
|
// loader.get() is synchronous from the caller's POV; the callback
|
|
770
814
|
// is only invoked on cache miss. We wrap the callback tightly so a
|
|
771
815
|
// retry doesn't rebuild workerCode — that's already stable here.
|
|
@@ -797,8 +841,7 @@ export class IsolatePool {
|
|
|
797
841
|
// wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
|
|
798
842
|
const endFetch = beginLoaderFetch(this.ctx);
|
|
799
843
|
try {
|
|
800
|
-
|
|
801
|
-
return out;
|
|
844
|
+
return await invoke(entrypoint, attempt);
|
|
802
845
|
} catch (err) {
|
|
803
846
|
if (err instanceof Error) {
|
|
804
847
|
throw new ExecutionError(err.message, err.stack);
|
|
@@ -870,6 +913,18 @@ export class IsolatePool {
|
|
|
870
913
|
message: lastError.message,
|
|
871
914
|
});
|
|
872
915
|
} catch { /* fail-soft */ }
|
|
916
|
+
if (wasAborted?.()) {
|
|
917
|
+
// The caller aborted this dispatch's Request. workerd cancelled
|
|
918
|
+
// the isolate's execution context wherever it was suspended —
|
|
919
|
+
// mid-syscall, mid-stream — so the interpreter's heap may hold
|
|
920
|
+
// half-applied state. Bump the slot generation: the next
|
|
921
|
+
// dispatch lands on a FRESH worker under a new id rather than
|
|
922
|
+
// reusing the abandoned one. Retrying is wrong — the user
|
|
923
|
+
// interrupted; surface the abort.
|
|
924
|
+
const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
|
|
925
|
+
this.slotGenerations.set(slotIndex, generation);
|
|
926
|
+
throw lastError;
|
|
927
|
+
}
|
|
873
928
|
if (cause === 'clone_refused' && !retriedCloneRefusal) {
|
|
874
929
|
retriedCloneRefusal = true;
|
|
875
930
|
const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
|
|
@@ -920,12 +975,49 @@ export class IsolatePool {
|
|
|
920
975
|
fnSource,
|
|
921
976
|
fnHash,
|
|
922
977
|
0,
|
|
923
|
-
|
|
978
|
+
(entrypoint) => entrypoint.execute(arg) as Promise<Awaited<R>>,
|
|
979
|
+
resilience,
|
|
980
|
+
opts?.wasmModules,
|
|
981
|
+
));
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Dispatch `fn` through the fetch transport — the pool's only
|
|
986
|
+
* cancellable path: aborting `request.signal` cancels the inner
|
|
987
|
+
* execution context at its next I/O suspension (a synchronous CPU
|
|
988
|
+
* section cannot be preempted — the platform's honest limit), the
|
|
989
|
+
* call rejects, and the slot generation bumps so the next dispatch
|
|
990
|
+
* gets a fresh interpreter. `fn` is request-shaped: it encodes and
|
|
991
|
+
* decodes its own payload.
|
|
992
|
+
*/
|
|
993
|
+
async submitRequest(
|
|
994
|
+
fn: (request: Request, env: FacetBindings) => Response | Promise<Response>,
|
|
995
|
+
request: Request,
|
|
996
|
+
opts?: IsolateCallOptions,
|
|
997
|
+
): Promise<Response> {
|
|
998
|
+
if (request.bodyUsed) {
|
|
999
|
+
throw new BindingError(
|
|
1000
|
+
'IsolatePool.submitRequest: request body is already consumed — ' +
|
|
1001
|
+
'pass an unconsumed Request so retries can re-issue it.',
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
const { fnSource, fnHash } = this.#prepare(fn);
|
|
1005
|
+
const resilience = this.#resolve(opts);
|
|
1006
|
+
// Every attempt fetches a clone: a Request's body is consumed once,
|
|
1007
|
+
// so the caller's original stays unspent and retries get a fresh
|
|
1008
|
+
// body that follows the same signal.
|
|
1009
|
+
return this.#dispatchSlot(
|
|
1010
|
+
fnSource,
|
|
1011
|
+
fnHash,
|
|
1012
|
+
0,
|
|
1013
|
+
(entrypoint) => entrypoint.fetch(request.clone()),
|
|
924
1014
|
resilience,
|
|
925
1015
|
opts?.wasmModules,
|
|
926
|
-
|
|
1016
|
+
() => request.signal.aborted,
|
|
1017
|
+
);
|
|
927
1018
|
}
|
|
928
1019
|
|
|
1020
|
+
|
|
929
1021
|
/**
|
|
930
1022
|
* Run `fn` on every item in `items`, at most `concurrency` at a time,
|
|
931
1023
|
* pinned to stable slots so warm isolates are reused.
|
|
@@ -998,7 +1090,7 @@ export class IsolatePool {
|
|
|
998
1090
|
fnSource,
|
|
999
1091
|
fnHash,
|
|
1000
1092
|
slotIndex,
|
|
1001
|
-
|
|
1093
|
+
(entrypoint) => entrypoint.execute(items[idx]),
|
|
1002
1094
|
resilience,
|
|
1003
1095
|
opts?.wasmModules,
|
|
1004
1096
|
)) as Awaited<R>;
|
package/src/process-host.ts
CHANGED
|
@@ -67,6 +67,8 @@
|
|
|
67
67
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
68
68
|
import { isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
|
|
69
69
|
import { PEER_RETRY_BACKOFF_MS, PEER_TRANSIENT_RESET_RETRIES } from './fanout.js';
|
|
70
|
+
import { hostNamespaceBinding, hostOpDispatch, type HostNamespaceBinding } from './host-dispatch.js';
|
|
71
|
+
import { z } from 'zod/v4';
|
|
70
72
|
import {
|
|
71
73
|
type HostedProcess,
|
|
72
74
|
type OneShotParams,
|
|
@@ -219,20 +221,6 @@ export interface HostedHttpResponse {
|
|
|
219
221
|
body: ReadableStream | null;
|
|
220
222
|
}
|
|
221
223
|
|
|
222
|
-
/**
|
|
223
|
-
* Every method a hosting sibling must answer. Checked as a set at first
|
|
224
|
-
* contact: a stub that has the probe but not the router is a deployment skew,
|
|
225
|
-
* and finding that out mid-process is finding it out too late.
|
|
226
|
-
*/
|
|
227
|
-
const PROCESS_PEER_METHODS = [
|
|
228
|
-
'_rpcProcessHostProbe',
|
|
229
|
-
'_rpcHostProcess',
|
|
230
|
-
'_rpcAwaitHostedOpen',
|
|
231
|
-
'_rpcAwaitHostedBoot',
|
|
232
|
-
'_rpcRouteHostedHttp',
|
|
233
|
-
'_rpcCancelHostProcess',
|
|
234
|
-
] as const;
|
|
235
|
-
|
|
236
224
|
/** The host-process surface a sibling session DO exposes. */
|
|
237
225
|
interface ProcessPeerStub {
|
|
238
226
|
_rpcProcessHostProbe(): Promise<{ isolateToken: string }>;
|
|
@@ -257,41 +245,70 @@ interface ProcessPeerStub {
|
|
|
257
245
|
export const HOSTED_WEBSOCKET_KEY_HEADER = 'x-nimbus-hosted-websocket';
|
|
258
246
|
export const HOSTED_WEBSOCKET_CAPABILITY_HEADER = 'x-nimbus-hosted-websocket-capability';
|
|
259
247
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Peer stubs forward one supervisorOp entrypoint: every method the host-
|
|
250
|
+
* process surface needs is an envelope op, not a private _rpc* method.
|
|
251
|
+
*/
|
|
252
|
+
function peerNamespace(env: object): HostNamespaceBinding {
|
|
253
|
+
try {
|
|
254
|
+
return hostNamespaceBinding(env, 'ProcessFabric');
|
|
255
|
+
} catch (e) {
|
|
256
|
+
if (e instanceof BindingError) {
|
|
257
|
+
throw new BindingError(
|
|
258
|
+
e.message
|
|
259
|
+
+ " NIMBUS_PROCESS_HOST='peer' hosts every resident process on a sibling "
|
|
260
|
+
+ "Durable Object; name the host's own binding with composeFabric({ hostNamespace }).",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
throw e;
|
|
264
|
+
}
|
|
263
265
|
}
|
|
264
266
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
267
|
+
const PeerProbeResult = z.object({ isolateToken: z.string() });
|
|
268
|
+
const PeerOpenResult = z.object({ ok: z.boolean() });
|
|
269
|
+
const PeerBootResult = z.object({ payload: z.unknown() });
|
|
270
|
+
const PeerCancelResult = z.object({ cancelled: z.boolean() });
|
|
271
|
+
const PeerHttpResult = z.object({
|
|
272
|
+
status: z.number().int(),
|
|
273
|
+
statusText: z.string(),
|
|
274
|
+
headers: z.array(z.tuple([z.string(), z.string()])),
|
|
275
|
+
body: z.instanceof(ReadableStream).nullable(),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
function processPeerStub(value: object, peerName: string): ProcessPeerStub {
|
|
279
|
+
const dispatch = hostOpDispatch(value, `ProcessFabric peer '${peerName}'`);
|
|
280
|
+
const fetchFn = (typeof value === 'object' || typeof value === 'function') && value !== null
|
|
281
|
+
? Reflect.get(value, 'fetch')
|
|
268
282
|
: undefined;
|
|
269
|
-
if (
|
|
270
|
-
|| typeof Reflect.get(ns, 'idFromName') !== 'function'
|
|
271
|
-
|| typeof Reflect.get(ns, 'get') !== 'function') {
|
|
283
|
+
if (typeof fetchFn !== 'function') {
|
|
272
284
|
throw new BindingError(
|
|
273
|
-
|
|
274
|
-
+
|
|
275
|
-
+ 'Durable Object; add the binding via durable_objects.bindings in wrangler.jsonc.',
|
|
285
|
+
`ProcessFabric: peer '${peerName}' exposes no fetch() — the hosted-websocket `
|
|
286
|
+
+ 'upgrade leg still travels as a service-binding fetch.',
|
|
276
287
|
);
|
|
277
288
|
}
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
289
|
+
const adapter: ProcessPeerStub = {
|
|
290
|
+
_rpcProcessHostProbe: async () => PeerProbeResult.parse(await dispatch({ op: 'processHostProbe', args: [] })),
|
|
291
|
+
_rpcHostProcess: async (boot, opts) => PeerOpenResult.parse(await dispatch({ op: 'hostProcess', args: [boot, opts] })),
|
|
292
|
+
_rpcAwaitHostedOpen: async (key) => PeerOpenResult.parse(await dispatch({ op: 'awaitHostedOpen', args: [key] })),
|
|
293
|
+
_rpcAwaitHostedBoot: async (key) => {
|
|
294
|
+
const result = PeerBootResult.parse(await dispatch({ op: 'awaitHostedBoot', args: [key] }));
|
|
295
|
+
return { payload: result.payload };
|
|
296
|
+
},
|
|
297
|
+
_rpcRouteHostedHttp: async (key, request) => PeerHttpResult.parse(await dispatch({ op: 'routeHostedHttp', args: [key, request] })),
|
|
298
|
+
_rpcCancelHostProcess: async (key) => PeerCancelResult.parse(await dispatch({ op: 'cancelHostProcess', args: [key] })),
|
|
299
|
+
fetch: async (request) => {
|
|
300
|
+
const response = await Reflect.apply(fetchFn, value, [request]);
|
|
301
|
+
if (!(response instanceof Response)) throw new BindingError(`ProcessFabric: peer '${peerName}' returned no Response`);
|
|
302
|
+
return response;
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
// The adapter wraps the raw stub; releasing the adapter must release it —
|
|
306
|
+
// a held RPC stub pins the peer DO for the session's life.
|
|
307
|
+
const disposerKey = Reflect.get(Symbol, 'dispose');
|
|
308
|
+
if (typeof disposerKey === 'symbol') {
|
|
309
|
+
Object.defineProperty(adapter, disposerKey, { value: () => disposeRpcResource(value) });
|
|
293
310
|
}
|
|
294
|
-
return
|
|
311
|
+
return adapter;
|
|
295
312
|
}
|
|
296
313
|
|
|
297
314
|
interface PeerPlacement {
|
|
@@ -312,13 +329,16 @@ class PeerProcessHost implements ProcessHost {
|
|
|
312
329
|
storageSharedWithSession: false,
|
|
313
330
|
};
|
|
314
331
|
|
|
315
|
-
private readonly ns:
|
|
332
|
+
private readonly ns: HostNamespaceBinding;
|
|
316
333
|
private readonly env: ResidentFacetEnv;
|
|
317
334
|
private readonly coordDoId: string;
|
|
318
335
|
/** pid → the isolate token of the peer currently hosting that process. */
|
|
319
336
|
private readonly tokensInUse = new Map<number, string>();
|
|
320
337
|
|
|
321
338
|
constructor(private readonly ctx: DurableObjectState, env: unknown) {
|
|
339
|
+
if (env === null || (typeof env !== 'object' && typeof env !== 'function')) {
|
|
340
|
+
throw new BindingError('ProcessFabric: a peer host requires environment bindings');
|
|
341
|
+
}
|
|
322
342
|
this.ns = peerNamespace(env);
|
|
323
343
|
this.env = (env ?? {}) as ResidentFacetEnv;
|
|
324
344
|
this.coordDoId = ctx.id.toString();
|
|
@@ -445,7 +465,7 @@ class PeerProcessHost implements ProcessHost {
|
|
|
445
465
|
/** One sibling name, resolved and probed. Leaks nothing on failure. */
|
|
446
466
|
private async _probePlacement(pid: number, attempt: number): Promise<PeerPlacement> {
|
|
447
467
|
const peerName = `${this.coordDoId}:proc:${pid}:${attempt}`;
|
|
448
|
-
let resource:
|
|
468
|
+
let resource: DurableObjectStub | null = null;
|
|
449
469
|
try {
|
|
450
470
|
resource = this.ns.get(this.ns.idFromName(peerName));
|
|
451
471
|
const stub = processPeerStub(resource, peerName);
|