@nimbus-sh/fabric 0.5.0 → 0.7.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.
@@ -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
- interface PeerNamespace {
261
- idFromName(name: string): unknown;
262
- get(id: unknown): unknown;
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
- function peerNamespace(env: unknown): PeerNamespace {
266
- const ns = (typeof env === 'object' || typeof env === 'function') && env !== null
267
- ? Reflect.get(env, 'NIMBUS_SESSION')
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 ((typeof ns !== 'object' && typeof ns !== 'function') || ns === null
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
- 'ProcessFabric: env.NIMBUS_SESSION binding missing or invalid. '
274
- + "NIMBUS_PROCESS_HOST='peer' hosts every resident process on a sibling "
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
- return ns as unknown as PeerNamespace;
279
- }
280
-
281
- function processPeerStub(value: unknown, peerName: string): ProcessPeerStub {
282
- if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
283
- throw new BindingError(`ProcessFabric: NIMBUS_SESSION.get() returned no stub for peer '${peerName}'.`);
284
- }
285
- for (const method of PROCESS_PEER_METHODS) {
286
- if (typeof Reflect.get(value, method) !== 'function') {
287
- throw new BindingError(
288
- `ProcessFabric: peer '${peerName}' does not expose ${method}(). `
289
- + 'A sibling that answers some of the host-process surface and not the rest '
290
- + 'would fail somewhere inside a running process instead of here.',
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 value as unknown as ProcessPeerStub;
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: PeerNamespace;
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: unknown;
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);
@@ -142,7 +142,7 @@ export interface PacedWorkHost {
142
142
  * reason. Without it the pump degrades to a same-context timer — see
143
143
  * {@link PacedWork.nextTurn}.
144
144
  */
145
- requestTurn?: (notBefore?: number) => void;
145
+ requestTurn?: (notBefore?: number) => void | Promise<void>;
146
146
  }
147
147
 
148
148
  /**
@@ -160,7 +160,8 @@ export class PacedWork implements TurnScheduler {
160
160
  * half-built work for pids that no longer exist, where re-driving a launch
161
161
  * from its inputs is the same idempotent work again.
162
162
  */
163
- private waiters: Array<{ resume: () => void; chunkEnded: Promise<void>; notBefore: number }> = [];
163
+ private waiters: Array<{ resume: () => void; reject: (error: unknown) => void; chunkEnded: Promise<void>; notBefore: number }> = [];
164
+ private closed: Error | undefined;
164
165
 
165
166
  /**
166
167
  * `ctx` keys the cold-start queue the pump drains first on every turn it
@@ -182,10 +183,17 @@ export class PacedWork implements TurnScheduler {
182
183
  * responsiveness but keeps the behaviour.
183
184
  */
184
185
  nextTurn(chunkEnded: Promise<void>, notBefore = 0): Promise<void> {
185
- return new Promise<void>((resume) => {
186
- this.waiters.push({ resume, chunkEnded, notBefore });
186
+ if (this.closed) return Promise.reject(this.closed);
187
+ return new Promise<void>((resume, reject) => {
188
+ const waiter = { resume, reject, chunkEnded, notBefore };
189
+ this.waiters.push(waiter);
187
190
  if (this.host.requestTurn) {
188
- this.host.requestTurn(notBefore);
191
+ void this.requestTurn(notBefore).catch((error: unknown) => {
192
+ const index = this.waiters.indexOf(waiter);
193
+ if (index < 0) return;
194
+ this.waiters.splice(index, 1);
195
+ waiter.reject(error);
196
+ });
189
197
  return;
190
198
  }
191
199
  setTimeout(() => { void this.pump(); }, Math.max(0, notBefore - Date.now()));
@@ -201,18 +209,39 @@ export class PacedWork implements TurnScheduler {
201
209
  * the runtime may tear the context down mid-chunk.
202
210
  */
203
211
  async pump(): Promise<void> {
204
- // Deferred reconciliation runs first, before any waiter resumes. Where
205
- // the resident-launch journal's recovery sits (`onColdStart`): the pump
206
- // is what an alarm calls, and the first turn after a reset is the
207
- // re-delivered alarm of a launch the reset interrupted.
212
+ if (this.closed) return;
208
213
  await runColdStart(this.ctx);
214
+ if (this.closed) return;
209
215
  const now = Date.now();
210
216
  const waiting = this.waiters.filter((waiter) => waiter.notBefore <= now);
211
217
  this.waiters = this.waiters.filter((waiter) => waiter.notBefore > now);
212
- if (this.waiters.length > 0) this.host.requestTurn?.(Math.min(...this.waiters.map((waiter) => waiter.notBefore)));
213
- if (waiting.length === 0) return;
218
+ const future = [...this.waiters];
219
+ const scheduled = future.length > 0
220
+ ? this.requestTurn(Math.min(...future.map((waiter) => waiter.notBefore))).catch((error: unknown) => {
221
+ for (const waiter of future) {
222
+ const index = this.waiters.indexOf(waiter);
223
+ if (index < 0) continue;
224
+ this.waiters.splice(index, 1);
225
+ waiter.reject(error);
226
+ }
227
+ throw error;
228
+ })
229
+ : Promise.resolve();
214
230
  for (const waiter of waiting) waiter.resume();
215
- await Promise.all(waiting.map((waiter) => waiter.chunkEnded));
231
+ const results = await Promise.allSettled([scheduled, ...waiting.map((waiter) => waiter.chunkEnded)]);
232
+ const failure = results.find((result) => result.status === 'rejected');
233
+ if (failure?.status === 'rejected') throw failure.reason;
234
+ }
235
+
236
+ private async requestTurn(notBefore: number): Promise<void> {
237
+ if (this.closed) throw this.closed;
238
+ await this.host.requestTurn?.(notBefore);
239
+ }
240
+
241
+ /** Reject parked work before the owner drains launch cleanup and cancels its alarm. */
242
+ close(reason = new Error('Launch scheduler is closed')): void {
243
+ this.closed ??= reason;
244
+ for (const waiter of this.waiters.splice(0)) waiter.reject(this.closed);
216
245
  }
217
246
 
218
247
  /** Whether any launch is suspended waiting for a turn. */