@nimbus-sh/fabric 0.4.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/generation.d.ts +6 -0
- package/dist/generation.d.ts.map +1 -1
- package/dist/generation.js +6 -0
- 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/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 +40 -0
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +120 -14
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +60 -33
- package/dist/turn-budget.d.ts +4 -4
- package/dist/turn-budget.js +4 -4
- package/package.json +4 -4
- package/src/bindings.ts +57 -92
- package/src/fanout.ts +48 -67
- package/src/generation.ts +6 -0
- package/src/host-dispatch.ts +34 -0
- package/src/image-store.ts +33 -20
- package/src/isolate-pool.ts +179 -17
- package/src/process-host.ts +64 -44
- package/src/turn-budget.ts +4 -4
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');
|
|
@@ -345,6 +377,18 @@ export class IsolatePool {
|
|
|
345
377
|
private readonly defaultRetries: number;
|
|
346
378
|
private readonly tag: string;
|
|
347
379
|
private readonly slotGenerations = new Map<number, number>();
|
|
380
|
+
/**
|
|
381
|
+
* Per-slot execution ownership: the tail of each slot's in-flight
|
|
382
|
+
* dispatch chain. Two dispatches on the same warm isolate at once
|
|
383
|
+
* interleave on its QueueState — map() callers used to trust the
|
|
384
|
+
* caller's slot round-robin, which could not prevent submit() (slot
|
|
385
|
+
* 0) or a second map() landing on a slot a task still occupied. Every
|
|
386
|
+
* dispatch now waits for the slot's previous execution to settle
|
|
387
|
+
* before touching it.
|
|
388
|
+
*/
|
|
389
|
+
private readonly slotTails = new Map<number, Promise<void>>();
|
|
390
|
+
/** Set by dispose() — queued dispatches reject instead of running. */
|
|
391
|
+
private disposed = false;
|
|
348
392
|
private bindings: Record<string, unknown> | undefined;
|
|
349
393
|
|
|
350
394
|
private readonly preamble: string | undefined;
|
|
@@ -380,7 +424,16 @@ export class IsolatePool {
|
|
|
380
424
|
* 12 chars is enough entropy for DO ids to collide-free per process.
|
|
381
425
|
*/
|
|
382
426
|
private readonly doIdShort: string;
|
|
427
|
+
/**
|
|
428
|
+
* The supervisor identity the minted worker's env.SUPERVISOR binding
|
|
429
|
+
* bakes (doId short-form + pid), folded into the loader cache key. 's-none'
|
|
430
|
+
* when no SUPERVISOR binding was minted, so a supervisor-less pool keeps
|
|
431
|
+
* the old key shape and its warm slots stay shared.
|
|
432
|
+
*/
|
|
433
|
+
private readonly supervisorKey: string;
|
|
383
434
|
|
|
435
|
+
/** Extra loader-id segment from options.scope — see IsolatePoolOptions. */
|
|
436
|
+
private readonly scope: string;
|
|
384
437
|
constructor(
|
|
385
438
|
env: unknown,
|
|
386
439
|
ctx: DurableObjectState,
|
|
@@ -408,6 +461,7 @@ export class IsolatePool {
|
|
|
408
461
|
this.doIdShort = opts?.cacheScope === 'global'
|
|
409
462
|
? 'global'
|
|
410
463
|
: ctx.id.toString().slice(0, 12);
|
|
464
|
+
this.scope = opts?.scope ?? '';
|
|
411
465
|
|
|
412
466
|
// Materialise the wasm-modules table. Sanitise each name into a
|
|
413
467
|
// valid JS identifier for the static import binding; key collisions
|
|
@@ -459,6 +513,7 @@ export class IsolatePool {
|
|
|
459
513
|
}
|
|
460
514
|
|
|
461
515
|
const bindings: Record<string, unknown> = { ...(opts?.extraBindings ?? {}) };
|
|
516
|
+
this.supervisorKey = 's-none';
|
|
462
517
|
if (!opts?.omitSupervisor) {
|
|
463
518
|
const supervisorRpc = supervisorEntrypoint();
|
|
464
519
|
if (supervisorRpc) {
|
|
@@ -467,9 +522,19 @@ export class IsolatePool {
|
|
|
467
522
|
// to the user's session DO, not the peer DO. Default to the
|
|
468
523
|
// local ctx.id (single-DO callers and the in-DO in-DO fanout path).
|
|
469
524
|
const supDoId = opts?.supervisorDoIdOverride ?? ctx.id.toString();
|
|
525
|
+
const supPid = opts?.supervisorPid ?? 0;
|
|
470
526
|
bindings.SUPERVISOR = supervisorRpc({
|
|
471
|
-
props: { doId: supDoId, pid:
|
|
527
|
+
props: { doId: supDoId, pid: supPid },
|
|
472
528
|
});
|
|
529
|
+
// Whatever the minted worker's env carries must be in its loader
|
|
530
|
+
// cache key — workerd's loader cache survives a DO hibernation
|
|
531
|
+
// wake while generation-strided pids (1000001 → 2000001) do not:
|
|
532
|
+
// a warm slot keyed without the supervisor identity returns in
|
|
533
|
+
// the new generation still credentialed to the dead pid, and
|
|
534
|
+
// every pid-authorized RPC from it fails "process pid … does
|
|
535
|
+
// not exist". doIdShort alone cannot cover this — it changes
|
|
536
|
+
// across sessions, not across wakes of the same session.
|
|
537
|
+
this.supervisorKey = `s${supDoId.slice(0, 12)}-${supPid}`;
|
|
473
538
|
} else {
|
|
474
539
|
// Supervisor entrypoint unavailable — running without ctx.exports
|
|
475
540
|
// (e.g. unit-test harness, or LOADER.load contexts where the
|
|
@@ -669,15 +734,59 @@ export class IsolatePool {
|
|
|
669
734
|
/**
|
|
670
735
|
* Dispatch a single task to the slot isolate. `slotIndex` picks which
|
|
671
736
|
* warm isolate services the call; callers round-robin slots themselves.
|
|
737
|
+
* Serialized per slot: this call waits for the slot's previous
|
|
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.
|
|
672
745
|
*/
|
|
673
|
-
async #dispatchSlot(
|
|
746
|
+
async #dispatchSlot<T>(
|
|
674
747
|
fnSource: string,
|
|
675
748
|
fnHash: string,
|
|
676
749
|
slotIndex: number,
|
|
677
|
-
args: unknown[],
|
|
750
|
+
invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
|
|
678
751
|
resilience: ResolvedResilience,
|
|
679
752
|
perCallWasm?: Record<string, ArrayBuffer>,
|
|
680
|
-
|
|
753
|
+
wasAborted?: () => boolean,
|
|
754
|
+
): Promise<T> {
|
|
755
|
+
// A warm slot executes one dispatch at a time: queue behind the
|
|
756
|
+
// previous owner, then record this dispatch as the new tail. The
|
|
757
|
+
// tail outlives the caller's outcome: a timeout rejects the
|
|
758
|
+
// Promise.race while runOnce's RPC is still live on the Worker, so
|
|
759
|
+
// release waits for every execution the owned body started to
|
|
760
|
+
// settle — never the caller's settle alone.
|
|
761
|
+
const previous = this.slotTails.get(slotIndex) ?? Promise.resolve();
|
|
762
|
+
let release: () => void;
|
|
763
|
+
this.slotTails.set(slotIndex, new Promise<void>((resolve) => { release = resolve; }));
|
|
764
|
+
await previous;
|
|
765
|
+
if (this.disposed) {
|
|
766
|
+
release!();
|
|
767
|
+
throw new BindingError(`IsolatePool(${this.tag}) is disposed`);
|
|
768
|
+
}
|
|
769
|
+
const inFlight: Promise<unknown>[] = [];
|
|
770
|
+
try {
|
|
771
|
+
return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, wasAborted);
|
|
772
|
+
} finally {
|
|
773
|
+
// Do not delay the caller's own outcome — the tail releases when
|
|
774
|
+
// the RPCs the body launched have actually settled.
|
|
775
|
+
void Promise.allSettled(inFlight).then(() => release!());
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
async #dispatchSlotOwned<T>(
|
|
781
|
+
fnSource: string,
|
|
782
|
+
fnHash: string,
|
|
783
|
+
slotIndex: number,
|
|
784
|
+
invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
|
|
785
|
+
resilience: ResolvedResilience,
|
|
786
|
+
perCallWasm: Record<string, ArrayBuffer> | undefined,
|
|
787
|
+
inFlight: Promise<unknown>[],
|
|
788
|
+
wasAborted?: () => boolean,
|
|
789
|
+
): Promise<T> {
|
|
681
790
|
// Per-call wasm fingerprint. Mixed into the cache key so two calls
|
|
682
791
|
// with different bytes hit different slots (no cache poisoning).
|
|
683
792
|
// For the common case (no per-call wasm) the fingerprint is '0',
|
|
@@ -686,12 +795,12 @@ export class IsolatePool {
|
|
|
686
795
|
const perCallWasmHash = this.#fingerprintWasm(perCallWasmEntries);
|
|
687
796
|
|
|
688
797
|
// Cache key includes the short DO id so warm isolates are scoped to
|
|
689
|
-
// ONE session
|
|
690
|
-
// a
|
|
691
|
-
//
|
|
692
|
-
//
|
|
798
|
+
// ONE session (see the doIdShort field comment), and the supervisor
|
|
799
|
+
// identity so a wake of that same session can never reuse a warm
|
|
800
|
+
// worker whose SUPERVISOR binding still names the dead generation's
|
|
801
|
+
// pid. See the supervisorKey field comment for the failure mode.
|
|
693
802
|
const buildId = (generation: number): string =>
|
|
694
|
-
`nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}: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}` : ''}`;
|
|
695
804
|
let id = buildId(this.slotGenerations.get(slotIndex) ?? 0);
|
|
696
805
|
const code = this.#buildCode(fnSource, perCallWasmEntries);
|
|
697
806
|
|
|
@@ -700,7 +809,7 @@ export class IsolatePool {
|
|
|
700
809
|
// slot updated on every dispatch.
|
|
701
810
|
try { setLastFacetId(id, slotIndex); } catch { /* best-effort */ }
|
|
702
811
|
|
|
703
|
-
const runOnce = async (): Promise<
|
|
812
|
+
const runOnce = async (): Promise<T> => {
|
|
704
813
|
// loader.get() is synchronous from the caller's POV; the callback
|
|
705
814
|
// is only invoked on cache miss. We wrap the callback tightly so a
|
|
706
815
|
// retry doesn't rebuild workerCode — that's already stable here.
|
|
@@ -732,8 +841,7 @@ export class IsolatePool {
|
|
|
732
841
|
// wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
|
|
733
842
|
const endFetch = beginLoaderFetch(this.ctx);
|
|
734
843
|
try {
|
|
735
|
-
|
|
736
|
-
return out;
|
|
844
|
+
return await invoke(entrypoint, attempt);
|
|
737
845
|
} catch (err) {
|
|
738
846
|
if (err instanceof Error) {
|
|
739
847
|
throw new ExecutionError(err.message, err.stack);
|
|
@@ -767,8 +875,10 @@ export class IsolatePool {
|
|
|
767
875
|
// See plan in close-plan-2026-04-28.
|
|
768
876
|
let timerId: ReturnType<typeof setTimeout> | undefined;
|
|
769
877
|
try {
|
|
878
|
+
const attemptPromise = runOnce();
|
|
879
|
+
inFlight.push(attemptPromise);
|
|
770
880
|
return await Promise.race([
|
|
771
|
-
|
|
881
|
+
attemptPromise,
|
|
772
882
|
new Promise<never>((_, reject) => {
|
|
773
883
|
timerId = setTimeout(
|
|
774
884
|
() => reject(new TimeoutError(resilience.timeoutMs)),
|
|
@@ -780,7 +890,9 @@ export class IsolatePool {
|
|
|
780
890
|
if (timerId !== undefined) clearTimeout(timerId);
|
|
781
891
|
}
|
|
782
892
|
}
|
|
783
|
-
|
|
893
|
+
const attemptPromise = runOnce();
|
|
894
|
+
inFlight.push(attemptPromise);
|
|
895
|
+
return await attemptPromise;
|
|
784
896
|
} catch (err) {
|
|
785
897
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
786
898
|
const cause = classifyError(lastError);
|
|
@@ -801,6 +913,18 @@ export class IsolatePool {
|
|
|
801
913
|
message: lastError.message,
|
|
802
914
|
});
|
|
803
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
|
+
}
|
|
804
928
|
if (cause === 'clone_refused' && !retriedCloneRefusal) {
|
|
805
929
|
retriedCloneRefusal = true;
|
|
806
930
|
const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
|
|
@@ -851,12 +975,49 @@ export class IsolatePool {
|
|
|
851
975
|
fnSource,
|
|
852
976
|
fnHash,
|
|
853
977
|
0,
|
|
854
|
-
|
|
978
|
+
(entrypoint) => entrypoint.execute(arg) as Promise<Awaited<R>>,
|
|
855
979
|
resilience,
|
|
856
980
|
opts?.wasmModules,
|
|
857
|
-
))
|
|
981
|
+
));
|
|
858
982
|
}
|
|
859
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()),
|
|
1014
|
+
resilience,
|
|
1015
|
+
opts?.wasmModules,
|
|
1016
|
+
() => request.signal.aborted,
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
|
|
860
1021
|
/**
|
|
861
1022
|
* Run `fn` on every item in `items`, at most `concurrency` at a time,
|
|
862
1023
|
* pinned to stable slots so warm isolates are reused.
|
|
@@ -929,7 +1090,7 @@ export class IsolatePool {
|
|
|
929
1090
|
fnSource,
|
|
930
1091
|
fnHash,
|
|
931
1092
|
slotIndex,
|
|
932
|
-
|
|
1093
|
+
(entrypoint) => entrypoint.execute(items[idx]),
|
|
933
1094
|
resilience,
|
|
934
1095
|
opts?.wasmModules,
|
|
935
1096
|
)) as Awaited<R>;
|
|
@@ -974,6 +1135,7 @@ export class IsolatePool {
|
|
|
974
1135
|
* Safe to call more than once; idempotent.
|
|
975
1136
|
*/
|
|
976
1137
|
dispose(): void {
|
|
1138
|
+
this.disposed = true;
|
|
977
1139
|
if (!this.bindings) return;
|
|
978
1140
|
for (const key of Object.keys(this.bindings)) {
|
|
979
1141
|
disposeRpcResource(this.bindings[key]);
|
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);
|
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. */
|