@electron-stagewright/host-supervisor 0.0.0-stage → 0.2.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +253 -2
  3. package/dist/contract.d.ts +52 -0
  4. package/dist/contract.d.ts.map +1 -0
  5. package/dist/contract.js +2 -0
  6. package/dist/contract.js.map +1 -0
  7. package/dist/env.d.ts +9 -0
  8. package/dist/env.d.ts.map +1 -0
  9. package/dist/env.js +30 -0
  10. package/dist/env.js.map +1 -0
  11. package/dist/errors.d.ts +11 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +38 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/host.d.ts +129 -0
  16. package/dist/host.d.ts.map +1 -0
  17. package/dist/host.js +907 -0
  18. package/dist/host.js.map +1 -0
  19. package/dist/in-process.d.ts +8 -0
  20. package/dist/in-process.d.ts.map +1 -0
  21. package/dist/in-process.js +60 -0
  22. package/dist/in-process.js.map +1 -0
  23. package/dist/index.d.ts +15 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +14 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/policy.d.ts +34 -0
  28. package/dist/policy.d.ts.map +1 -0
  29. package/dist/policy.js +46 -0
  30. package/dist/policy.js.map +1 -0
  31. package/dist/protocol.d.ts +94 -0
  32. package/dist/protocol.d.ts.map +1 -0
  33. package/dist/protocol.js +111 -0
  34. package/dist/protocol.js.map +1 -0
  35. package/dist/serve.d.ts +42 -0
  36. package/dist/serve.d.ts.map +1 -0
  37. package/dist/serve.js +287 -0
  38. package/dist/serve.js.map +1 -0
  39. package/dist/utility.d.ts +46 -0
  40. package/dist/utility.d.ts.map +1 -0
  41. package/dist/utility.js +50 -0
  42. package/dist/utility.js.map +1 -0
  43. package/dist/worker.d.ts +31 -0
  44. package/dist/worker.d.ts.map +1 -0
  45. package/dist/worker.js +50 -0
  46. package/dist/worker.js.map +1 -0
  47. package/package.json +56 -3
package/dist/host.js ADDED
@@ -0,0 +1,907 @@
1
+ /**
2
+ * Supervisor side: starts the host on demand, matches answers to calls, and decides what happens
3
+ * when the host exits on its own.
4
+ */
5
+ import { HOST_ERROR_CODES, HostError } from './errors.js';
6
+ import { PROTOCOL_VERSION, deserializeError, parseHostMessage, } from './protocol.js';
7
+ export const DEFAULT_STOP_GRACE_MS = 10_000;
8
+ export const DEFAULT_RETIRE_WAIT_MS = 5_000;
9
+ /** Rejection that sends a waiting call back to `ensureStarted`. */
10
+ const RETRY = Symbol('retry');
11
+ function unref(timer) {
12
+ ;
13
+ timer.unref?.();
14
+ return timer;
15
+ }
16
+ function raceTimeout(promise, ms) {
17
+ let timer;
18
+ return Promise.race([
19
+ promise.then(() => true),
20
+ new Promise((resolve) => {
21
+ timer = unref(setTimeout(() => resolve(false), ms));
22
+ }),
23
+ ]).finally(() => clearTimeout(timer));
24
+ }
25
+ const HOST_CODES = new Set(HOST_ERROR_CODES);
26
+ function supervisorError(serialized) {
27
+ if (serialized.name === 'HostError' && serialized.code && HOST_CODES.has(serialized.code)) {
28
+ return new HostError(serialized.code, serialized.message);
29
+ }
30
+ return null;
31
+ }
32
+ function warn(error) {
33
+ process.emitWarning(error instanceof Error ? error : new Error(String(error)));
34
+ }
35
+ /** Runs a listener outside the transport's callback, so a throw cannot escape as uncaught. */
36
+ function notify(listener, value) {
37
+ if (!listener)
38
+ return;
39
+ Promise.resolve()
40
+ .then(() => listener(value))
41
+ .catch(warn);
42
+ }
43
+ function normalizeDecision(decision) {
44
+ if (decision === 'fatal' || decision === 'on-demand' || decision === 'restart') {
45
+ return { action: decision, delayMs: 0 };
46
+ }
47
+ if (typeof decision === 'object' && decision !== null && decision.action === 'restart') {
48
+ const delayMs = Number.isFinite(decision.delayMs) ? Math.max(0, decision.delayMs) : 0;
49
+ return { action: 'restart', delayMs };
50
+ }
51
+ // Anything else is a policy bug; refusing to restart is the safe reading.
52
+ return { action: 'fatal', delayMs: 0 };
53
+ }
54
+ const MAX_TIMER_MS = 2_147_483_647;
55
+ function checkGraceMs(value) {
56
+ if (value === undefined || (Number.isFinite(value) && value >= 0 && value <= MAX_TIMER_MS)) {
57
+ return value;
58
+ }
59
+ throw new RangeError(`abortGraceMs must be between 0 and ${MAX_TIMER_MS} ms, not ${value}`);
60
+ }
61
+ export function createHost(options) {
62
+ const callOps = new Set(options.operations);
63
+ const streamOps = new Set(options.streams ?? []);
64
+ const graceMs = options.stop?.graceMs ?? DEFAULT_STOP_GRACE_MS;
65
+ const retireWaitMs = options.stop?.retireWaitMs ?? DEFAULT_RETIRE_WAIT_MS;
66
+ const maxPending = options.limits?.maxPending ?? Number.POSITIVE_INFINITY;
67
+ const defaultTimeoutMs = options.limits?.callTimeoutMs;
68
+ const defaultGraceMs = checkGraceMs(options.limits?.abortGraceMs);
69
+ const onExit = options.onExit ?? (() => 'fatal');
70
+ const now = options.now ?? Date.now;
71
+ let state = 'idle';
72
+ let current = null;
73
+ let starting = null;
74
+ /** The host waiting for its `started` reply, so stop and kill can fail a start that hangs. */
75
+ let booting = null;
76
+ let retiring = null;
77
+ let stopping = null;
78
+ let fatalCause = null;
79
+ let closeRequested = false;
80
+ let wakeDelay = null;
81
+ let nextId = 1;
82
+ let launches = 0;
83
+ /** The exit an `on-demand` decision left unanswered, reported with the next start. */
84
+ let pendingRestart = null;
85
+ /** A restart a pause interrupted, launched again by `resume()`. */
86
+ let restartHeld = false;
87
+ let paused = null;
88
+ let resumeGate = null;
89
+ const pending = new Map();
90
+ /** Calls wait for `resume()` from a pause until the hosts it held may start again. */
91
+ const holding = () => !closeRequested && resumeGate !== null && paused?.calls !== 'reject';
92
+ function pausedError() {
93
+ return new HostError('HOST_PAUSED', 'The host is paused');
94
+ }
95
+ function started(life, restart) {
96
+ launches++;
97
+ notify(options.onStarted, { info: life.info, launches, restart });
98
+ }
99
+ function remoteError(serialized) {
100
+ const own = supervisorError(serialized);
101
+ if (own)
102
+ return own;
103
+ let revived;
104
+ try {
105
+ revived = options.errors?.revive?.(serialized);
106
+ }
107
+ catch {
108
+ // A reviver that throws falls back to the plain rebuild.
109
+ }
110
+ return revived instanceof Error ? revived : deserializeError(serialized);
111
+ }
112
+ function unavailable() {
113
+ if (state === 'fatal') {
114
+ return new HostError('HOST_FATAL', 'The host exited and will not be restarted', {
115
+ cause: fatalCause,
116
+ });
117
+ }
118
+ return new HostError('HOST_STOPPED', 'The host is stopped');
119
+ }
120
+ /** A restart backoff that stop and kill can cut short. */
121
+ function restartDelay(ms) {
122
+ return new Promise((resolve) => {
123
+ const done = () => {
124
+ clearTimeout(timer);
125
+ wakeDelay = null;
126
+ resolve();
127
+ };
128
+ const timer = unref(setTimeout(done, ms));
129
+ wakeDelay = done;
130
+ });
131
+ }
132
+ // ---- calls ----
133
+ function finish(entry, error, cancel) {
134
+ if (entry.done)
135
+ return;
136
+ entry.done = true;
137
+ pending.delete(entry.id);
138
+ if (entry.timer)
139
+ clearTimeout(entry.timer);
140
+ entry.timer = null;
141
+ entry.detachAbort?.();
142
+ entry.detachAbort = null;
143
+ const life = entry.life;
144
+ if (cancel && life && !life.exited) {
145
+ try {
146
+ life.channel.send({ type: 'cancel', id: entry.id });
147
+ }
148
+ catch {
149
+ // The host is going away; its exit settles the rest.
150
+ }
151
+ }
152
+ const waiter = entry.waiter;
153
+ entry.waiter = null;
154
+ if (!error) {
155
+ // Only a stream reaches here with a waiter: closed early while a pull was pending.
156
+ waiter?.resolve({ done: true, value: undefined });
157
+ return;
158
+ }
159
+ if (waiter)
160
+ waiter.reject(error);
161
+ else
162
+ entry.error = error;
163
+ }
164
+ /** Forwards the abort and keeps the call open for the host's own answer, within the grace. */
165
+ function settleAfterAbort(entry, life, graceMs, reason) {
166
+ entry.detachAbort?.();
167
+ entry.detachAbort = null;
168
+ if (entry.timer)
169
+ clearTimeout(entry.timer);
170
+ try {
171
+ life.channel.send({ type: 'cancel', id: entry.id });
172
+ }
173
+ catch {
174
+ // The host is going away; its exit settles the call.
175
+ }
176
+ entry.timer = unref(setTimeout(() => {
177
+ finish(entry, new HostError('HOST_ABORT_UNSETTLED', `The host did not settle the aborted call within ${graceMs} ms`, { cause: reason }), false);
178
+ }, graceMs));
179
+ }
180
+ function armTimer(entry) {
181
+ if (entry.timeoutMs === undefined || entry.timer)
182
+ return;
183
+ entry.timer = unref(setTimeout(() => {
184
+ finish(entry, new HostError('HOST_CALL_TIMEOUT', `The host did not answer in ${entry.timeoutMs} ms`), true);
185
+ }, entry.timeoutMs));
186
+ }
187
+ function admit(stream, callOptions) {
188
+ if (state === 'fatal' || state === 'closed')
189
+ throw unavailable();
190
+ if (paused?.calls === 'reject')
191
+ throw pausedError();
192
+ if (state === 'stopping' && !holding())
193
+ throw unavailable();
194
+ if (pending.size >= maxPending) {
195
+ throw new HostError('HOST_QUEUE_FULL', `More than ${maxPending} calls are pending`);
196
+ }
197
+ const signal = callOptions?.signal;
198
+ if (signal?.aborted) {
199
+ throw new HostError('HOST_CALL_ABORTED', 'The call was aborted', { cause: signal.reason });
200
+ }
201
+ const entry = {
202
+ id: nextId++,
203
+ stream,
204
+ timeoutMs: callOptions?.timeoutMs ?? defaultTimeoutMs,
205
+ graceMs: checkGraceMs(callOptions?.abortGraceMs ?? defaultGraceMs),
206
+ life: null,
207
+ done: false,
208
+ error: null,
209
+ waiter: null,
210
+ timer: null,
211
+ detachAbort: null,
212
+ };
213
+ if (signal) {
214
+ const onAbort = () => {
215
+ const life = entry.life;
216
+ if (entry.stream || entry.graceMs === undefined || !life || life.exited) {
217
+ finish(entry, new HostError('HOST_CALL_ABORTED', 'The call was aborted', { cause: signal.reason }), true);
218
+ return;
219
+ }
220
+ settleAfterAbort(entry, life, entry.graceMs, signal.reason);
221
+ };
222
+ signal.addEventListener('abort', onAbort, { once: true });
223
+ entry.detachAbort = () => signal.removeEventListener('abort', onAbort);
224
+ }
225
+ pending.set(entry.id, entry);
226
+ return entry;
227
+ }
228
+ /** Sends the call once a host is running, starting one if needed. */
229
+ function dispatch(entry, message) {
230
+ let ready;
231
+ try {
232
+ ready = ensureStarted();
233
+ }
234
+ catch (error) {
235
+ finish(entry, error, false);
236
+ return;
237
+ }
238
+ ready.then((life) => {
239
+ if (entry.done)
240
+ return;
241
+ if (life.exited || life !== current || resumeGate)
242
+ return dispatch(entry, message);
243
+ entry.life = life;
244
+ try {
245
+ life.channel.send({
246
+ type: 'call',
247
+ id: entry.id,
248
+ op: message.op,
249
+ args: message.args,
250
+ ...(entry.stream ? { stream: true } : {}),
251
+ });
252
+ if (entry.stream)
253
+ pull(entry);
254
+ }
255
+ catch (error) {
256
+ finish(entry, error, false);
257
+ }
258
+ }, (error) => {
259
+ if (entry.done)
260
+ return;
261
+ // While paused a call waits for `resume()`, whatever ended the start it was waiting on.
262
+ if (error === RETRY || holding())
263
+ return dispatch(entry, message);
264
+ finish(entry, error, false);
265
+ });
266
+ }
267
+ function pull(entry) {
268
+ const life = entry.life;
269
+ if (!life || entry.done || !entry.waiter)
270
+ return;
271
+ armTimer(entry);
272
+ try {
273
+ life.channel.send({ type: 'next', id: entry.id });
274
+ }
275
+ catch (error) {
276
+ finish(entry, error, false);
277
+ }
278
+ }
279
+ function call(op, args, callOptions) {
280
+ if (!callOps.has(op)) {
281
+ return Promise.reject(new HostError('HOST_OPERATION_REJECTED', `${op} is not a call operation of this host`));
282
+ }
283
+ let entry;
284
+ try {
285
+ entry = admit(false, callOptions);
286
+ }
287
+ catch (error) {
288
+ return Promise.reject(error);
289
+ }
290
+ return new Promise((resolve, reject) => {
291
+ entry.waiter = { resolve, reject };
292
+ armTimer(entry);
293
+ dispatch(entry, { op, args });
294
+ });
295
+ }
296
+ function stream(op, args, callOptions) {
297
+ let entry = null;
298
+ let ended = false;
299
+ let busy = false;
300
+ const done = () => ({ done: true, value: undefined });
301
+ const iterator = {
302
+ [Symbol.asyncIterator]: () => iterator,
303
+ next() {
304
+ if (ended)
305
+ return Promise.resolve(done());
306
+ if (busy) {
307
+ return Promise.reject(new Error('Wait for the previous item before asking for the next'));
308
+ }
309
+ if (!streamOps.has(op)) {
310
+ ended = true;
311
+ return Promise.reject(new HostError('HOST_OPERATION_REJECTED', `${op} is not a stream operation of this host`));
312
+ }
313
+ busy = true;
314
+ return new Promise((resolve, reject) => {
315
+ const waiter = {
316
+ resolve: (value) => {
317
+ busy = false;
318
+ const result = value;
319
+ if (result.done)
320
+ ended = true;
321
+ resolve(result);
322
+ },
323
+ reject: (error) => {
324
+ busy = false;
325
+ ended = true;
326
+ reject(error);
327
+ },
328
+ };
329
+ if (entry === null) {
330
+ try {
331
+ entry = admit(true, callOptions);
332
+ }
333
+ catch (error) {
334
+ waiter.reject(error);
335
+ return;
336
+ }
337
+ entry.waiter = waiter;
338
+ // The first item's timeout also bounds the wait for a host to start.
339
+ armTimer(entry);
340
+ dispatch(entry, { op, args });
341
+ return;
342
+ }
343
+ if (entry.done) {
344
+ if (entry.error)
345
+ waiter.reject(entry.error);
346
+ else
347
+ waiter.resolve(done());
348
+ return;
349
+ }
350
+ entry.waiter = waiter;
351
+ pull(entry);
352
+ });
353
+ },
354
+ return() {
355
+ ended = true;
356
+ if (entry && !entry.done)
357
+ finish(entry, null, true);
358
+ return Promise.resolve(done());
359
+ },
360
+ };
361
+ return iterator;
362
+ }
363
+ // ---- host lifecycle ----
364
+ function onHostMessage(life, raw) {
365
+ const message = parseHostMessage(raw);
366
+ if (!message)
367
+ return;
368
+ // A host that keeps talking survived the exception it reported.
369
+ if (message.type !== 'crashed')
370
+ life.crashError = null;
371
+ switch (message.type) {
372
+ case 'started':
373
+ life.info = message.info;
374
+ life.started = true;
375
+ life.startedAt = now();
376
+ life.onStartReply?.(null);
377
+ return;
378
+ case 'start-failed':
379
+ life.onStartReply?.(remoteError(message.error));
380
+ return;
381
+ case 'crashed':
382
+ life.crashError = remoteError(message.error);
383
+ return;
384
+ case 'stats':
385
+ life.lastStats = message.stats;
386
+ notify(options.onStats, message.stats);
387
+ return;
388
+ case 'stopped':
389
+ life.stoppedReceived = true;
390
+ if (message.error)
391
+ life.stopError = remoteError(message.error);
392
+ life.resolveStopped();
393
+ return;
394
+ case 'result': {
395
+ const entry = pending.get(message.id);
396
+ if (!entry || entry.life !== life)
397
+ return;
398
+ if (!message.ok) {
399
+ finish(entry, remoteError(message.error), false);
400
+ return;
401
+ }
402
+ if (entry.stream)
403
+ return;
404
+ const waiter = entry.waiter;
405
+ entry.waiter = null;
406
+ finish(entry, null, false);
407
+ waiter?.resolve(message.value);
408
+ return;
409
+ }
410
+ case 'item': {
411
+ const entry = pending.get(message.id);
412
+ if (!entry || entry.life !== life || !entry.stream || !entry.waiter)
413
+ return;
414
+ if (entry.timer)
415
+ clearTimeout(entry.timer);
416
+ entry.timer = null;
417
+ const waiter = entry.waiter;
418
+ entry.waiter = null;
419
+ if (message.done)
420
+ finish(entry, null, false);
421
+ waiter.resolve({ done: message.done, value: message.done ? undefined : message.value });
422
+ return;
423
+ }
424
+ }
425
+ }
426
+ function exitError(life) {
427
+ return (life.crashError ??
428
+ life.exit?.error ??
429
+ new Error(`The host exited with code ${String(life.exit?.code ?? null)}`));
430
+ }
431
+ function onHostExit(life, exit) {
432
+ life.exited = true;
433
+ life.exit = exit;
434
+ life.onStartReply?.(exitError(life));
435
+ const stoppingNow = state === 'stopping' || state === 'closed';
436
+ const cause = exitError(life);
437
+ let rejected = 0;
438
+ for (const entry of [...pending.values()]) {
439
+ if (entry.life !== life)
440
+ continue;
441
+ rejected++;
442
+ finish(entry, stoppingNow
443
+ ? new HostError('HOST_STOPPED', 'The host stopped before answering')
444
+ : new HostError('HOST_EXITED', 'The host exited before answering', { cause }), false);
445
+ }
446
+ if (life !== current || state !== 'running')
447
+ return;
448
+ current = null;
449
+ state = 'restarting';
450
+ const recovery = recover({
451
+ kind: exit.outOfMemory ? 'out-of-memory' : 'crash',
452
+ reason: exitReason(life, exit),
453
+ error: cause,
454
+ code: exit.code,
455
+ uptimeMs: Math.max(0, now() - life.startedAt),
456
+ pendingCalls: rejected,
457
+ lastStats: life.lastStats,
458
+ });
459
+ starting = recovery;
460
+ // Registered before any waiter, so a call sent back by RETRY never sees a stale promise.
461
+ recovery.then(() => undefined, () => {
462
+ if (starting === recovery)
463
+ starting = null;
464
+ });
465
+ }
466
+ function exitReason(life, exit) {
467
+ if (exit.outOfMemory)
468
+ return 'out-of-memory';
469
+ if (life.crashError)
470
+ return 'uncaught';
471
+ if (exit.runtimeFatal)
472
+ return 'runtime-fatal';
473
+ // The worker transport passes the thread's uncaught error as `exit.error`.
474
+ return exit.error ? 'uncaught' : 'exit';
475
+ }
476
+ async function launch() {
477
+ if (retiring)
478
+ await retiring;
479
+ let payload;
480
+ try {
481
+ payload = await options.start?.payload?.();
482
+ }
483
+ catch (error) {
484
+ throw new HostError('HOST_START_FAILED', 'The start payload could not be built', {
485
+ cause: error,
486
+ });
487
+ }
488
+ let channel;
489
+ try {
490
+ channel = options.transport.launch();
491
+ }
492
+ catch (error) {
493
+ throw new HostError('HOST_START_FAILED', 'The host could not be launched', { cause: error });
494
+ }
495
+ let resolveExit;
496
+ let resolveStopped;
497
+ const life = {
498
+ channel,
499
+ started: false,
500
+ startedAt: now(),
501
+ exited: false,
502
+ exit: null,
503
+ exitPromise: new Promise((resolve) => (resolveExit = resolve)),
504
+ crashError: null,
505
+ stoppedReceived: false,
506
+ stoppedPromise: new Promise((resolve) => (resolveStopped = resolve)),
507
+ resolveStopped: () => resolveStopped(),
508
+ stopError: null,
509
+ lastStats: null,
510
+ info: undefined,
511
+ onStartReply: null,
512
+ };
513
+ channel.onMessage((message) => onHostMessage(life, message));
514
+ channel.onExit((exit) => {
515
+ if (life.exited)
516
+ return;
517
+ onHostExit(life, exit);
518
+ resolveExit();
519
+ });
520
+ const reply = new Promise((resolve, reject) => {
521
+ life.onStartReply = (error) => (error ? reject(error) : resolve());
522
+ });
523
+ let timer;
524
+ const startTimeoutMs = options.start?.timeoutMs;
525
+ booting = life;
526
+ try {
527
+ channel.send({ type: 'start', version: PROTOCOL_VERSION, payload });
528
+ await (startTimeoutMs === undefined
529
+ ? reply
530
+ : Promise.race([
531
+ reply,
532
+ new Promise((_, reject) => {
533
+ timer = unref(setTimeout(() => reject(new Error(`The host did not start in ${startTimeoutMs} ms`)), startTimeoutMs));
534
+ }),
535
+ ]));
536
+ if (life.exited)
537
+ throw exitError(life);
538
+ }
539
+ catch (error) {
540
+ await retire(life);
541
+ throw new HostError('HOST_START_FAILED', 'The host failed to start', { cause: error });
542
+ }
543
+ finally {
544
+ clearTimeout(timer);
545
+ life.onStartReply = null;
546
+ if (booting === life)
547
+ booting = null;
548
+ }
549
+ return life;
550
+ }
551
+ function abortStart() {
552
+ booting?.onStartReply?.(new HostError('HOST_STOPPED', 'The host was stopped while starting'));
553
+ }
554
+ /** Terminates a host and remembers its exit, so the next launch waits for it (bounded). */
555
+ function retire(life) {
556
+ if (life.exited)
557
+ return Promise.resolve();
558
+ try {
559
+ life.channel.kill();
560
+ }
561
+ catch {
562
+ // Already gone; the exit event follows.
563
+ }
564
+ const waiting = Promise.all([retiring, raceTimeout(life.exitPromise, retireWaitMs)]).then(() => {
565
+ if (retiring === waiting)
566
+ retiring = null;
567
+ });
568
+ retiring = waiting;
569
+ return waiting;
570
+ }
571
+ function ensureStarted() {
572
+ if (state === 'fatal' || state === 'closed')
573
+ throw unavailable();
574
+ if (paused?.calls === 'reject')
575
+ throw pausedError();
576
+ if (resumeGate) {
577
+ return resumeGate.promise.then(() => {
578
+ throw RETRY;
579
+ });
580
+ }
581
+ if (state === 'stopping')
582
+ throw unavailable();
583
+ if (starting)
584
+ return starting;
585
+ if (current && !current.exited)
586
+ return Promise.resolve(current);
587
+ state = 'starting';
588
+ const launching = launch()
589
+ .then((life) => {
590
+ if (life.exited) {
591
+ throw new HostError('HOST_START_FAILED', 'The host exited right after starting', {
592
+ cause: exitError(life),
593
+ });
594
+ }
595
+ return life;
596
+ })
597
+ .then((life) => {
598
+ current = life;
599
+ if (starting === launching)
600
+ starting = null;
601
+ if (state === 'starting')
602
+ state = 'running';
603
+ const restart = pendingRestart;
604
+ pendingRestart = null;
605
+ started(life, restart);
606
+ return life;
607
+ }, (error) => {
608
+ if (starting === launching)
609
+ starting = null;
610
+ if (state === 'starting')
611
+ state = 'idle';
612
+ if (pendingRestart) {
613
+ pendingRestart = { exit: pendingRestart.exit, attempt: pendingRestart.attempt + 1 };
614
+ }
615
+ throw error;
616
+ });
617
+ starting = launching;
618
+ return launching;
619
+ }
620
+ async function recover(exit, origin = exit, attempt = 1) {
621
+ let decision;
622
+ try {
623
+ decision = normalizeDecision(await onExit(exit));
624
+ }
625
+ catch {
626
+ decision = { action: 'fatal', delayMs: 0 };
627
+ }
628
+ if (state !== 'restarting') {
629
+ if (decision.action === 'fatal')
630
+ throw unavailable();
631
+ throw interrupted(origin, attempt, decision.action === 'restart');
632
+ }
633
+ if (decision.action === 'fatal') {
634
+ state = 'fatal';
635
+ fatalCause = exit.error;
636
+ starting = null;
637
+ throw unavailable();
638
+ }
639
+ if (decision.action === 'on-demand') {
640
+ state = 'idle';
641
+ starting = null;
642
+ pendingRestart = { exit: origin, attempt };
643
+ throw RETRY;
644
+ }
645
+ if (decision.delayMs > 0)
646
+ await restartDelay(decision.delayMs);
647
+ if (state !== 'restarting')
648
+ throw interrupted(origin, attempt, true);
649
+ let life;
650
+ try {
651
+ life = await launch();
652
+ if (life.exited)
653
+ throw new Error('The host exited right after starting');
654
+ }
655
+ catch (error) {
656
+ if (state !== 'restarting')
657
+ throw interrupted(origin, attempt + 1, true);
658
+ const cause = error.cause;
659
+ return recover({
660
+ kind: 'start-failed',
661
+ reason: 'start-failed',
662
+ error: cause instanceof Error ? cause : error,
663
+ code: null,
664
+ uptimeMs: null,
665
+ pendingCalls: 0,
666
+ lastStats: null,
667
+ }, origin, attempt + 1);
668
+ }
669
+ if (state !== 'restarting') {
670
+ launches++;
671
+ // A stop drains the host it finds; a kill expects none left running.
672
+ if (state === 'stopping')
673
+ current = life;
674
+ else
675
+ await retire(life);
676
+ throw unavailable();
677
+ }
678
+ current = life;
679
+ state = 'running';
680
+ starting = null;
681
+ started(life, { exit: origin, attempt });
682
+ return life;
683
+ }
684
+ /** A pause keeps the recovery it interrupted for `resume()`; a stop or kill drops it. */
685
+ function interrupted(origin, attempt, relaunch) {
686
+ if (paused) {
687
+ pendingRestart = { exit: origin, attempt };
688
+ restartHeld ||= relaunch;
689
+ }
690
+ return unavailable();
691
+ }
692
+ async function stopHost() {
693
+ const before = state;
694
+ state = 'stopping';
695
+ wakeDelay?.();
696
+ for (const entry of [...pending.values()]) {
697
+ if (!entry.life && holding())
698
+ continue;
699
+ // Waiting for a start, or a stream between items: nothing in flight to drain.
700
+ if (!entry.life || (entry.stream && !entry.waiter)) {
701
+ finish(entry, new HostError('HOST_STOPPED', 'The host is stopping'), entry.life !== null);
702
+ }
703
+ }
704
+ let failure = null;
705
+ if (starting) {
706
+ const started = starting.then(() => undefined, () => undefined);
707
+ if (!(await raceTimeout(started, graceMs))) {
708
+ abortStart();
709
+ await started;
710
+ failure = new HostError('HOST_STOP_TIMEOUT', `The host did not start within ${graceMs} ms and was terminated`);
711
+ }
712
+ starting = null;
713
+ }
714
+ const life = current;
715
+ if (life && !life.exited) {
716
+ try {
717
+ life.channel.send({ type: 'stop' });
718
+ }
719
+ catch {
720
+ // Treated as a host that never confirmed its stop.
721
+ }
722
+ const answered = Promise.race([life.exitPromise, life.stoppedPromise]);
723
+ if (!(await raceTimeout(answered, graceMs))) {
724
+ await retire(life);
725
+ failure = new HostError('HOST_STOP_TIMEOUT', `The host did not stop within ${graceMs} ms and was terminated`);
726
+ }
727
+ else {
728
+ // A host that confirmed its stop but lingers (a handle left open) is retired quietly.
729
+ if (!(await raceTimeout(life.exitPromise, retireWaitMs)))
730
+ await retire(life);
731
+ if (!life.stoppedReceived) {
732
+ failure = new HostError('HOST_STOP_INCOMPLETE', 'The host exited before finishing its stop');
733
+ }
734
+ else if (life.stopError) {
735
+ failure = new HostError('HOST_STOP_INCOMPLETE', 'The host failed while stopping', {
736
+ cause: life.stopError,
737
+ });
738
+ }
739
+ }
740
+ }
741
+ for (const entry of [...pending.values()]) {
742
+ if (!entry.life && holding())
743
+ continue;
744
+ finish(entry, new HostError('HOST_STOPPED', 'The host stopped before answering'), false);
745
+ }
746
+ current = null;
747
+ state = closeRequested ? 'closed' : before === 'fatal' ? 'fatal' : paused ? 'paused' : 'idle';
748
+ if (failure)
749
+ throw failure;
750
+ }
751
+ function shared(final) {
752
+ if (final)
753
+ closeRequested = true;
754
+ if (!paused || final)
755
+ forgetRestart();
756
+ if (state === 'closed')
757
+ return Promise.resolve();
758
+ if (stopping) {
759
+ // A close joining a pause's stop still fails the calls waiting for `resume()`.
760
+ if (final)
761
+ failWaiting();
762
+ return stopping;
763
+ }
764
+ const run = stopHost().finally(() => {
765
+ stopping = null;
766
+ });
767
+ stopping = run;
768
+ if (final)
769
+ failWaiting();
770
+ return run;
771
+ }
772
+ function failWaiting() {
773
+ paused = null;
774
+ for (const entry of [...pending.values()]) {
775
+ if (!entry.life)
776
+ finish(entry, unavailable(), false);
777
+ }
778
+ releaseGate();
779
+ }
780
+ function forgetRestart() {
781
+ pendingRestart = null;
782
+ restartHeld = false;
783
+ }
784
+ function releaseGate() {
785
+ const gate = resumeGate;
786
+ resumeGate = null;
787
+ gate?.resolve();
788
+ }
789
+ function pause(pauseOptions = {}) {
790
+ if (state === 'closed' || closeRequested)
791
+ return stopping ?? Promise.resolve();
792
+ paused = { calls: pauseOptions.calls ?? 'wait' };
793
+ if (paused.calls === 'reject') {
794
+ for (const entry of [...pending.values()]) {
795
+ if (!entry.life)
796
+ finish(entry, pausedError(), false);
797
+ }
798
+ // A start() waiting on the gate retries and meets HOST_PAUSED.
799
+ releaseGate();
800
+ }
801
+ if (!resumeGate) {
802
+ let resolve;
803
+ const promise = new Promise((done) => (resolve = done));
804
+ resumeGate = { promise, resolve };
805
+ }
806
+ return shared(false);
807
+ }
808
+ function resume() {
809
+ if (!paused)
810
+ return;
811
+ paused = null;
812
+ // Calls waiting for a pause's stop start a host once that stop has finished.
813
+ const ready = stopping ?? Promise.resolve();
814
+ void ready.then(release, release);
815
+ function release() {
816
+ if (paused)
817
+ return;
818
+ if (state === 'paused')
819
+ state = 'idle';
820
+ releaseGate();
821
+ if (restartHeld) {
822
+ restartHeld = false;
823
+ // A failed launch leaves the restart to the next call.
824
+ Promise.resolve()
825
+ .then(ensureStarted)
826
+ .catch(() => undefined);
827
+ }
828
+ }
829
+ }
830
+ async function kill() {
831
+ if (state === 'closed')
832
+ return;
833
+ forgetRestart();
834
+ wakeDelay?.();
835
+ abortStart();
836
+ if (state === 'restarting') {
837
+ // The pending restart sees the new state and gives up.
838
+ state = 'idle';
839
+ await starting?.catch(() => undefined);
840
+ return;
841
+ }
842
+ if (state === 'stopping') {
843
+ // Cuts a stop's grace period short; the stop reports how it ended.
844
+ if (current)
845
+ await retire(current);
846
+ await stopping?.catch(() => undefined);
847
+ return;
848
+ }
849
+ if (starting)
850
+ await starting.catch(() => undefined);
851
+ const life = current;
852
+ if (!life)
853
+ return;
854
+ current = null;
855
+ if (state === 'running')
856
+ state = 'idle';
857
+ for (const entry of [...pending.values()]) {
858
+ if (entry.life === life) {
859
+ finish(entry, new HostError('HOST_STOPPED', 'The host was terminated'), false);
860
+ }
861
+ }
862
+ // Calls made from here on wait for this exit before a new host starts.
863
+ await retire(life);
864
+ }
865
+ const ops = new Proxy({}, {
866
+ get(_target, property) {
867
+ if (typeof property !== 'string')
868
+ return undefined;
869
+ if (streamOps.has(property)) {
870
+ return (...args) => stream(property, args);
871
+ }
872
+ if (callOps.has(property)) {
873
+ return (...args) => call(property, args);
874
+ }
875
+ return undefined;
876
+ },
877
+ });
878
+ return {
879
+ ops,
880
+ call: ((op, args, callOptions) => call(op, args, callOptions)),
881
+ stream: ((op, args, callOptions) => stream(op, args, callOptions)),
882
+ async start() {
883
+ for (;;) {
884
+ try {
885
+ return (await ensureStarted()).info;
886
+ }
887
+ catch (error) {
888
+ // An `on-demand` exit decision hands the start back to us.
889
+ if (error !== RETRY)
890
+ throw error;
891
+ }
892
+ }
893
+ },
894
+ stop: () => shared(false),
895
+ kill,
896
+ close: () => shared(true),
897
+ pause,
898
+ resume,
899
+ get state() {
900
+ return state;
901
+ },
902
+ get info() {
903
+ return current && !current.exited ? current.info : undefined;
904
+ },
905
+ };
906
+ }
907
+ //# sourceMappingURL=host.js.map