reactor-effect-client 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.
Files changed (60) hide show
  1. package/README.md +29 -3
  2. package/dist/_internal/queue.d.ts +6 -0
  3. package/dist/_internal/queue.d.ts.map +1 -0
  4. package/dist/_internal/queue.js +30 -0
  5. package/dist/_internal/queue.js.map +1 -0
  6. package/dist/host.d.ts +1 -0
  7. package/dist/host.d.ts.map +1 -1
  8. package/dist/host.js +1 -0
  9. package/dist/host.js.map +1 -1
  10. package/dist/observation.d.ts +3 -0
  11. package/dist/observation.d.ts.map +1 -1
  12. package/dist/observation.js +9 -4
  13. package/dist/observation.js.map +1 -1
  14. package/dist/orchestration/index.d.ts +6 -6
  15. package/dist/orchestration/index.d.ts.map +1 -1
  16. package/dist/orchestration/index.js +3 -3
  17. package/dist/orchestration/index.js.map +1 -1
  18. package/dist/orchestration/media-buffer.d.ts +2 -1
  19. package/dist/orchestration/media-buffer.d.ts.map +1 -1
  20. package/dist/orchestration/media-buffer.js +5 -4
  21. package/dist/orchestration/media-buffer.js.map +1 -1
  22. package/dist/orchestration/renewal-state.d.ts +14 -2
  23. package/dist/orchestration/renewal-state.d.ts.map +1 -1
  24. package/dist/orchestration/renewal-state.js +34 -4
  25. package/dist/orchestration/renewal-state.js.map +1 -1
  26. package/dist/orchestration/renewal.d.ts +27 -3
  27. package/dist/orchestration/renewal.d.ts.map +1 -1
  28. package/dist/orchestration/renewal.js +400 -95
  29. package/dist/orchestration/renewal.js.map +1 -1
  30. package/dist/orchestration/retention.d.ts +100 -0
  31. package/dist/orchestration/retention.d.ts.map +1 -0
  32. package/dist/orchestration/retention.js +134 -0
  33. package/dist/orchestration/retention.js.map +1 -0
  34. package/dist/orchestration/scheduler-key.d.ts +2 -1
  35. package/dist/orchestration/scheduler-key.d.ts.map +1 -1
  36. package/dist/orchestration/scheduler-key.js +2 -2
  37. package/dist/orchestration/scheduler-key.js.map +1 -1
  38. package/dist/orchestration/scheduler-policy.d.ts +153 -12
  39. package/dist/orchestration/scheduler-policy.d.ts.map +1 -1
  40. package/dist/orchestration/scheduler-policy.js +433 -54
  41. package/dist/orchestration/scheduler-policy.js.map +1 -1
  42. package/dist/orchestration/scheduler.d.ts +206 -5
  43. package/dist/orchestration/scheduler.d.ts.map +1 -1
  44. package/dist/orchestration/scheduler.js +1451 -284
  45. package/dist/orchestration/scheduler.js.map +1 -1
  46. package/dist/orchestration/source-slot.d.ts +16 -4
  47. package/dist/orchestration/source-slot.d.ts.map +1 -1
  48. package/dist/orchestration/source-slot.js +122 -30
  49. package/dist/orchestration/source-slot.js.map +1 -1
  50. package/dist/orchestration/types.d.ts +503 -1
  51. package/dist/orchestration/types.d.ts.map +1 -1
  52. package/dist/orchestration/types.js +105 -1
  53. package/dist/orchestration/types.js.map +1 -1
  54. package/dist/session.d.ts.map +1 -1
  55. package/dist/session.js +2 -1
  56. package/dist/session.js.map +1 -1
  57. package/dist/simulation/_internal/source.d.ts.map +1 -1
  58. package/dist/simulation/_internal/source.js +5 -4
  59. package/dist/simulation/_internal/source.js.map +1 -1
  60. package/package.json +2 -2
@@ -21,8 +21,9 @@ import * as Sequence from "../Sequence.js";
21
21
  import * as Submission from "../Submission.js";
22
22
  import * as MediaBuffer from "./media-buffer.js";
23
23
  import * as SourceSlot from "./source-slot.js";
24
+ import * as Retention from "./retention.js";
24
25
  import { monotonicMillis } from "./elapsed.js";
25
- import { canHandoff, decideRenewal } from "./renewal-state.js";
26
+ import { handoffEvidence, decideRenewal } from "./renewal-state.js";
26
27
  import { PolicyFailure, captureRequest } from "./request.js";
27
28
  import { emptyState, isIdle } from "./queries.js";
28
29
  import { activeIds, generation, resolve } from "./routing.js";
@@ -43,11 +44,39 @@ const withSessionId = (state, sessionId) => {
43
44
  })),
44
45
  });
45
46
  };
47
+ /** A completed caller handle keeps only its Exit, not the routing or source closures. */
48
+ const logicalSubmission = (id) => {
49
+ let execute;
50
+ let inspect;
51
+ let outcome;
52
+ return {
53
+ attach: (submit, state) => {
54
+ execute = submit;
55
+ inspect = state;
56
+ },
57
+ complete: (exit) => {
58
+ outcome ??= exit;
59
+ execute = undefined;
60
+ inspect = undefined;
61
+ },
62
+ outcome: () => outcome,
63
+ submission: {
64
+ id,
65
+ submit: Effect.suspend(() => outcome ?? execute ?? Effect.die(new Error("Logical submission has no execution"))),
66
+ state: Effect.suspend(() => outcome === undefined
67
+ ? (inspect ?? Effect.succeed({ _tag: "Prepared" }))
68
+ : Effect.succeed({
69
+ _tag: "Completed",
70
+ exit: outcome,
71
+ })),
72
+ },
73
+ };
74
+ };
46
75
  /**
47
76
  * Explicit application policy for renewal, sequence affinity and continuous
48
77
  * recovering media. A physical source still owns each connection generation.
49
78
  */
50
- export const make = (options) => Effect.gen(function* () {
79
+ const makeOwner = (options, continuous) => Effect.gen(function* () {
51
80
  const scope = yield* Effect.scope;
52
81
  const context = yield* Effect.context();
53
82
  const clock = yield* Clock.Clock;
@@ -56,7 +85,6 @@ export const make = (options) => Effect.gen(function* () {
56
85
  .pipe(Effect.mapError((cause) => errorOf(cause, "InvalidState")));
57
86
  const namespace = Array.from(random, (byte) => byte.toString(16).padStart(2, "0")).join("");
58
87
  const commands = yield* Semaphore.make(1);
59
- const closeGate = yield* Semaphore.make(1);
60
88
  const affinity = yield* Sequence.makeAffinity().pipe(Effect.mapError((cause) => errorOf(cause, "InvalidInput")));
61
89
  // One ordered stream: engine events, renewals and media transitions.
62
90
  const observations = new Observations();
@@ -67,10 +95,14 @@ export const make = (options) => Effect.gen(function* () {
67
95
  const fatal = yield* Deferred.make();
68
96
  let renewing = true;
69
97
  const slots = new Map();
98
+ const physicalOwners = new Map();
99
+ const retention = continuous ? yield* Retention.make(options) : undefined;
100
+ let incarnation = 0n;
70
101
  const cleanups = [];
71
102
  const seenCleanups = new Set();
72
- const maxSessions = options.maxSessions ?? 64;
73
- if (!Number.isSafeInteger(maxSessions) || maxSessions < 1 || maxSessions > 4096) {
103
+ const maxSessions = options.maxSessions ?? (continuous ? Infinity : 64);
104
+ if ((options.maxSessions !== undefined || !continuous) &&
105
+ (!Number.isSafeInteger(maxSessions) || maxSessions < 1 || maxSessions > 4096)) {
74
106
  return yield* ReactorError.fromCode("InvalidInput", "Invalid orchestration bounds");
75
107
  }
76
108
  const reconnectTimeout = Duration.toMillis(yield* parsed(() => duration(options.reconnectTimeout ?? "10 seconds", "orchestration reconnectTimeout")));
@@ -82,14 +114,37 @@ export const make = (options) => Effect.gen(function* () {
82
114
  const leadSeconds = Duration.toSeconds(yield* parsed(() => duration(options.lead ?? "30 seconds", "orchestration lead", { allowZero: true })));
83
115
  let current;
84
116
  let replacement = { _tag: "Absent" };
85
- let opened = 0;
117
+ let opened = 0n;
86
118
  let openFailures = 0;
87
119
  let retryAt = 0;
88
120
  let autoplay = true;
89
121
  let closing = false;
90
122
  let finalReport;
123
+ let finalSummary;
124
+ let firstCleanup;
125
+ let initializing = true;
126
+ // Close reports each ownership attempt's failures once. They are recorded
127
+ // where the attempt and its retirement end: every waiter of a cached close
128
+ // sees its own Cause object, and cancellation by close is not a failure.
129
+ const attemptFailures = new Map();
130
+ const recordFailure = (attempt, reasons) => {
131
+ const failed = reasons.filter((reason) => !Cause.isInterruptReason(reason));
132
+ if (failed.length > 0)
133
+ attemptFailures.set(attempt, [...(attemptFailures.get(attempt) ?? []), ...failed]);
134
+ };
135
+ // Every acquisition runs on a fiber this owner forks into its scope, never
136
+ // on a caller's fiber. Close interrupts and joins the registered ones before
137
+ // retiring owned slots, so no source opens after close and every attempt's
138
+ // cleanup is reported, without waiting on a fiber that may be waiting on it.
139
+ const acquisitions = new Set();
140
+ const admissionClosed = () => ReactorError.fromCode("Closed", "Orchestration no longer admits sources");
141
+ // The owner stops renewing when close begins or its scope starts closing,
142
+ // whichever is first: that scope's finalizers can stop an attempt, or wait
143
+ // on an application, before close runs. Commands stay admitted until close
144
+ // begins, so an application finalizer can still issue its last commands.
145
+ const stopping = () => closing || scope.state._tag === "Closed";
91
146
  let mediaState = { _tag: "Closed" };
92
- let terminalFailure;
147
+ let terminal;
93
148
  let submissionSequence = 0n;
94
149
  const announce = (event) => Effect.sync(() => observations.emit({ _tag: "Renewal", event }, 256));
95
150
  const log = (message) => Effect.logDebug(message).pipe(Effect.annotateLogs({ module: "reactor.orchestration" }));
@@ -104,20 +159,33 @@ export const make = (options) => Effect.gen(function* () {
104
159
  ? undefined
105
160
  : yield* observations.subscribe({ capacity: 4096 }).pipe(Effect.flatMap((stream) => stream.pipe(Stream.filterMap(renewalsOnly), Stream.runForEach((event) => Effect.suspend(() => onRenewal(event)).pipe(Effect.catchCause((cause) => Effect.logWarning("onRenewal failed", cause).pipe(Effect.annotateLogs({ module: "reactor.orchestration" }))))), Effect.catchCause((cause) => Effect.logWarning("onRenewal reader stopped", cause).pipe(Effect.annotateLogs({ module: "reactor.orchestration" }))), Effect.forkIn(scope))));
106
161
  const fail = (cause) => Effect.suspend(() => {
107
- if (terminalFailure !== undefined)
162
+ if (terminal !== undefined)
108
163
  return Effect.void;
109
- terminalFailure = cause;
164
+ terminal = Exit.succeed(cause);
110
165
  // Publish the failed state and queues before waking a failure waiter.
111
166
  setMedia({ _tag: "Failed", cause });
112
167
  emit({ _tag: "SessionFailed", failure: cause });
113
- buffer.fail(cause);
168
+ buffer.failCause(Cause.fail(cause));
114
169
  Deferred.doneUnsafe(fatal, Effect.succeed(cause));
115
170
  return announce({ _tag: "Failed", reason: cause.message });
116
171
  });
172
+ // A failed worker must wake observers as well as failure waiters: the
173
+ // scheduler consumes observations, and cannot recover a vanished worker.
174
+ // Claim and publish synchronously before a waiter can close this owner.
175
+ const supervise = (effect) => effect.pipe(Effect.onExit((exit) => Effect.sync(() => {
176
+ if (Exit.isSuccess(exit) ||
177
+ terminal !== undefined ||
178
+ (stopping() && Cause.hasInterruptsOnly(exit.cause)))
179
+ return;
180
+ terminal = Exit.failCause(exit.cause);
181
+ buffer.failCause(exit.cause);
182
+ observations.failCause(exit.cause);
183
+ Deferred.doneUnsafe(fatal, terminal);
184
+ })));
117
185
  // Every asynchronous activation uses this final fence. Closed/failed state
118
186
  // cannot be replaced by a reconnect or autoplay operation that finished late.
119
187
  const publishReady = (slot) => {
120
- if (closing || slot.closed || slot !== current || terminalFailure !== undefined)
188
+ if (stopping() || slot.closed || slot !== current || terminal !== undefined)
121
189
  return false;
122
190
  setMedia({ _tag: "Ready", sessionId: slot.source.id, generation: slot.media.generation });
123
191
  return true;
@@ -128,7 +196,7 @@ export const make = (options) => Effect.gen(function* () {
128
196
  seenCleanups.add(cleanup.lease);
129
197
  cleanups.push(cleanup);
130
198
  };
131
- const openSequences = (slot) => affinity.snapshots.pipe(Effect.map((entries) => entries.some((entry) => entry.owner === slot.source.id && entry.status === "open")));
199
+ const openSequences = (slot) => affinity.snapshots.pipe(Effect.map((entries) => entries.some((entry) => entry.owner === slot.owner && entry.status === "open")));
132
200
  const expired = (slot) => slot.expired();
133
201
  const recoveryBudget = (slot) => slot.recoveryBudget();
134
202
  // The logical output's loss: every former owner's loss while it fed the
@@ -136,18 +204,23 @@ export const make = (options) => Effect.gen(function* () {
136
204
  // replacement's loss before it fed the output is not the output's.
137
205
  let outputLoss = SourceSlot.noLoss;
138
206
  let ownerBaseline = SourceSlot.noLoss;
207
+ let outputOwner;
139
208
  const ownerLoss = (slot) => Effect.result(slot.pressure.pipe(Effect.timeout(recoveryBudget(slot)))).pipe(Effect.map(SourceSlot.lossOf));
140
209
  const activateOwner = (slot) => ownerLoss(slot).pipe(Effect.map((loss) => {
141
210
  ownerBaseline = loss;
211
+ outputOwner = slot.owner;
142
212
  }));
143
- const retireOwner = (slot) => ownerLoss(slot).pipe(Effect.map((loss) => {
144
- outputLoss = SourceSlot.addLoss(outputLoss, SourceSlot.subtractLoss(loss, ownerBaseline));
145
- ownerBaseline = SourceSlot.noLoss;
146
- }));
213
+ const retireOwner = (slot) => Effect.suspend(() => outputOwner !== slot.owner
214
+ ? Effect.void
215
+ : ownerLoss(slot).pipe(Effect.map((loss) => {
216
+ outputLoss = SourceSlot.addLoss(outputLoss, SourceSlot.subtractLoss(loss, ownerBaseline));
217
+ ownerBaseline = SourceSlot.noLoss;
218
+ outputOwner = undefined;
219
+ })));
147
220
  const retired = (slot) => slot.retired(buffer.forwarded);
148
221
  const closeSlot = (slot) => slot.close;
149
222
  const replace = (slot, cause) => Effect.gen(function* () {
150
- if (slot.closed || closing || terminalFailure !== undefined)
223
+ if (slot.closed || stopping() || terminal !== undefined)
151
224
  return;
152
225
  const state = yield* slot.source.state;
153
226
  const lost = activeIds(state);
@@ -158,6 +231,7 @@ export const make = (options) => Effect.gen(function* () {
158
231
  clipId,
159
232
  reason: `Session lost: ${cause.message}`,
160
233
  sessionId: slot.source.id,
234
+ lost: true,
161
235
  });
162
236
  if (slot === current)
163
237
  yield* retireOwner(slot);
@@ -170,7 +244,8 @@ export const make = (options) => Effect.gen(function* () {
170
244
  });
171
245
  if (replacement._tag === "Ready" && replacement.slot === slot)
172
246
  replacement = { _tag: "Absent" };
173
- if (slot !== current)
247
+ // Close retires what remains; a replacement it refuses is no failure.
248
+ if (slot !== current || stopping())
174
249
  return;
175
250
  const pending = replacement;
176
251
  if (!renewing && pending._tag === "Absent") {
@@ -179,10 +254,12 @@ export const make = (options) => Effect.gen(function* () {
179
254
  }
180
255
  const selected = pending._tag === "Ready"
181
256
  ? pending.slot
182
- : yield* pending._tag === "Opening" ? Fiber.join(pending.fiber) : acquire;
257
+ : yield* pending._tag === "Opening"
258
+ ? Effect.uninterruptibleMask((restore) => joinAcquisition(pending.fiber, restore))
259
+ : acquire;
183
260
  replacement = { _tag: "Absent" };
184
- if (closing || terminalFailure !== undefined) {
185
- yield* selected.close;
261
+ if (stopping() || terminal !== undefined) {
262
+ yield* closeSlot(selected);
186
263
  return;
187
264
  }
188
265
  current = selected;
@@ -191,15 +268,15 @@ export const make = (options) => Effect.gen(function* () {
191
268
  if (!publishReady(current))
192
269
  return;
193
270
  yield* log("Replaced lost session; local queue resumes on the new connection");
194
- }).pipe(Effect.withSpan("reactor.orchestration.renewal.replace", { attributes: { "reactor.session.id": slot.source.id } }, { captureStackTrace: false }), Effect.catch(fail));
271
+ }).pipe(Effect.withSpan("reactor.orchestration.renewal.replace", { attributes: { "reactor.session.id": slot.source.id } }, { captureStackTrace: false }), Effect.catch((cause) => (stopping() ? Effect.void : fail(cause))));
195
272
  const recover = (slot, cause) => Effect.gen(function* () {
196
- if (slot.closed || closing || terminalFailure !== undefined)
273
+ if (slot.closed || stopping() || terminal !== undefined)
197
274
  return;
198
275
  if (slot === current)
199
276
  setMedia({ _tag: "Recovering", sessionId: slot.source.id, cause });
200
277
  yield* announce({ _tag: "Recovering", sessionId: slot.source.id, reason: cause.message });
201
278
  yield* slot.joinCommitted(recoveryBudget(slot));
202
- if (slot.closed || closing || terminalFailure !== undefined)
279
+ if (slot.closed || stopping() || terminal !== undefined)
203
280
  return;
204
281
  if (slot.needsReplacement()) {
205
282
  yield* replace(slot, cause);
@@ -215,13 +292,13 @@ export const make = (options) => Effect.gen(function* () {
215
292
  return;
216
293
  }
217
294
  yield* slot.closeMedia;
218
- if (slot.closed || closing || terminalFailure !== undefined)
295
+ if (slot.closed || stopping() || terminal !== undefined)
219
296
  return;
220
297
  const connected = yield* Effect.result(slot.source.reconnect.pipe(Effect.timeoutOrElse({
221
298
  duration: recoveryBudget(slot),
222
299
  orElse: () => Effect.fail(ReactorError.fromCode("Disconnected", "Explicit source reconnect timed out")),
223
300
  })));
224
- if (slot.closed || closing || terminalFailure !== undefined)
301
+ if (slot.closed || stopping() || terminal !== undefined)
225
302
  return;
226
303
  if (Result.isFailure(connected)) {
227
304
  yield* replace(slot, connected.failure);
@@ -245,7 +322,7 @@ export const make = (options) => Effect.gen(function* () {
245
322
  yield* slot.replaceMedia(media, previousPressure);
246
323
  return yield* startMedia(slot);
247
324
  }));
248
- if (slot.closed || closing || terminalFailure !== undefined)
325
+ if (slot.closed || stopping() || terminal !== undefined)
249
326
  return;
250
327
  if (Result.isFailure(restarted)) {
251
328
  yield* replace(slot, restarted.failure);
@@ -264,35 +341,54 @@ export const make = (options) => Effect.gen(function* () {
264
341
  });
265
342
  }).pipe(Effect.withSpan("reactor.orchestration.renewal.recover", { attributes: { "reactor.session.id": slot.source.id } }, { captureStackTrace: false }));
266
343
  const scheduleRecovery = (slot, cause, mode) => Effect.gen(function* () {
267
- if (slot.closed || closing || terminalFailure !== undefined)
344
+ if (slot.closed || stopping() || terminal !== undefined)
268
345
  return;
269
346
  if (!slot.beginRecovery(mode))
270
347
  return;
271
- yield* recover(slot, cause).pipe(commands.withPermits(1), Effect.forkIn(scope));
348
+ yield* recover(slot, cause).pipe(commands.withPermits(1), supervise, Effect.forkIn(scope));
272
349
  });
273
350
  const startMedia = (slot) => slot.startMedia({
274
351
  video: (frame) => Effect.suspend(() => {
275
- if (slot !== current || terminalFailure !== undefined)
352
+ if (slot !== current || terminal !== undefined)
276
353
  return Effect.void;
277
354
  if (slot.recordVideo() && replacement._tag === "Ready")
278
355
  emit({ _tag: "HandoffReady", sessionId: slot.source.id });
279
356
  return buffer.offerVideo(frame).pipe(Effect.catch(fail));
280
357
  }),
281
358
  audio: (frame) => Effect.suspend(() => {
282
- if (slot !== current || terminalFailure !== undefined)
359
+ if (slot !== current || terminal !== undefined)
283
360
  return Effect.void;
284
361
  return buffer.offerAudio(frame).pipe(Effect.tap(() => Effect.sync(() => {
285
362
  slot.recordAudio(frame.samples.length);
286
363
  })), Effect.catch(fail));
287
364
  }),
288
- lost: (cause) => (closing ? Effect.void : scheduleRecovery(slot, cause, "reconnect")),
365
+ lost: (cause) => (stopping() ? Effect.void : scheduleRecovery(slot, cause, "reconnect")),
289
366
  });
290
- const acquire = Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
367
+ // Runs only on a fiber of its own, forked into the owner's scope.
368
+ const acquisition = Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
369
+ const fiber = yield* Effect.fiber;
291
370
  if (opened >= maxSessions)
292
371
  return yield* ReactorError.fromCode("Overflow", "Orchestration session and cleanup-history bound reached");
372
+ if (stopping() || (continuous && (terminal !== undefined || !renewing)))
373
+ return yield* admissionClosed();
374
+ // Registered in the same synchronous step as the stopping check, so
375
+ // either close sees this attempt or this attempt sees close.
376
+ acquisitions.add(fiber);
377
+ const reservation = retention === undefined ? undefined : yield* retention.reserve;
378
+ const attemptIncarnation = reservation?.incarnation ?? ++incarnation;
379
+ const recordAttempt = (cleanup) => {
380
+ if (initializing && firstCleanup === undefined)
381
+ firstCleanup = cleanup;
382
+ return retention !== undefined && reservation !== undefined
383
+ ? retention.record(reservation, cleanup)
384
+ : Effect.sync(() => recordCleanup(cleanup));
385
+ };
293
386
  const owned = yield* Scope.make();
387
+ const attempt = {};
294
388
  let acquired;
295
389
  let acquiredSource;
390
+ let acquisitionCleanup;
391
+ let acquisitionDefect;
296
392
  return yield* restore(Effect.gen(function* () {
297
393
  // Replace the captured Scope explicitly in one context operation.
298
394
  // Dependency provision order cannot move resources to the caller's owner.
@@ -301,26 +397,66 @@ export const make = (options) => Effect.gen(function* () {
301
397
  orElse: () => Effect.fail(ReactorError.fromCode("InvalidState", "Source acquisition timed out")),
302
398
  }));
303
399
  acquiredSource = value.source;
400
+ if (retention !== undefined && reservation !== undefined)
401
+ yield* retention.identify(reservation, value.source.id);
402
+ const owner = Object.freeze({
403
+ namespace,
404
+ incarnation: attemptIncarnation,
405
+ sessionId: value.source.id,
406
+ });
304
407
  const lifetime = yield* parsed(() => duration(value.lifetime, "source lifetime", { allowInfinite: true }));
305
408
  // A lead as long as the lifetime would prepare a replacement at once,
306
409
  // after every open, bounded only by maxSessions.
307
410
  if (Duration.isFinite(lifetime) && leadSeconds >= Duration.toSeconds(lifetime))
308
411
  return yield* ReactorError.fromCode("InvalidInput", "renewal lead must be shorter than the source lifetime", { operation: "renewal", outcome: "not-submitted" });
309
- if (slots.has(value.source.id))
412
+ if (physicalOwners.has(value.source.id))
310
413
  return yield* ReactorError.fromCode("InvalidInput", "Orchestration sources must have distinct session identities");
311
414
  const media = yield* value.source.media;
312
- const slot = yield* SourceSlot.make({
415
+ const local = yield* SourceSlot.make({
313
416
  source: value.source,
314
417
  scope: owned,
315
418
  media,
316
419
  openedAt: monotonicMillis(clock),
317
420
  maxSeconds: Duration.toSeconds(lifetime),
318
421
  cleanupBudgetMs: reconnectTimeout,
319
- recordCleanup,
320
- retireSequences: affinity.retire(value.source.id).pipe(Effect.asVoid),
422
+ recordCleanup: recordAttempt,
423
+ boundedHistory: continuous,
424
+ retired: (facts, retirementExit) => Effect.gen(function* () {
425
+ const bookkeeping = yield* Effect.exit(Effect.gen(function* () {
426
+ if (acquisitionCleanup !== undefined)
427
+ yield* recordAttempt(acquisitionCleanup);
428
+ if (retention === undefined || reservation === undefined)
429
+ return;
430
+ const lossExit = acquired === undefined
431
+ ? Exit.void
432
+ : yield* Effect.exit(retireOwner(acquired));
433
+ yield* retention.finish(reservation, {
434
+ ...facts,
435
+ errors: [
436
+ ...facts.errors,
437
+ ...(acquisitionDefect === undefined ? [] : [acquisitionDefect]),
438
+ ...(Exit.isFailure(lossExit)
439
+ ? [errorOf(lossExit.cause, "InvalidState", "media loss accounting")]
440
+ : []),
441
+ ],
442
+ });
443
+ slots.delete(owner);
444
+ if (physicalOwners.get(owner.sessionId) === owner)
445
+ physicalOwners.delete(owner.sessionId);
446
+ return yield* lossExit;
447
+ }));
448
+ // The slot calls this once, however many callers wait on its close.
449
+ const exit = Exit.asVoidAll([retirementExit, bookkeeping]);
450
+ if (Exit.isFailure(exit))
451
+ recordFailure(attempt, exit.cause.reasons);
452
+ return yield* bookkeeping;
453
+ }),
454
+ retireSequences: affinity.retire(owner).pipe(Effect.asVoid),
321
455
  });
456
+ const slot = Object.assign(local, { owner });
322
457
  acquired = slot;
323
- slots.set(slot.source.id, slot);
458
+ slots.set(owner, slot);
459
+ physicalOwners.set(owner.sessionId, owner);
324
460
  opened++;
325
461
  yield* slot.source.setAutoplay(false);
326
462
  yield* slot.observe((event) => Effect.gen(function* () {
@@ -355,46 +491,127 @@ export const make = (options) => Effect.gen(function* () {
355
491
  })).pipe(Effect.onExit((exit) => Exit.isSuccess(exit)
356
492
  ? Effect.void
357
493
  : Effect.gen(function* () {
494
+ // Capture an acquisition failure even if the source also
495
+ // returns cleanup. Conflicting reports belong to this attempt.
496
+ // A typed failure is the attempt's result, and cancellation
497
+ // is not a defect: only a Die is a failure close must report.
498
+ const defects = exit.cause.reasons.filter(Cause.isDieReason);
499
+ if (defects.length > 0) {
500
+ recordFailure(attempt, defects);
501
+ acquisitionDefect = errorOf(Cause.fromReasons(defects), "InvalidState", "source acquisition");
502
+ }
503
+ const failure = Cause.findErrorOption(exit.cause);
504
+ if (Option.isSome(failure) && AcquisitionFailure.is(failure.value))
505
+ acquisitionCleanup = { lease: failure.value.cleanup, policy: [] };
358
506
  if (acquired !== undefined)
359
507
  yield* closeSlot(acquired);
360
508
  else {
361
- if (acquiredSource !== undefined)
362
- recordCleanup(yield* acquiredSource.close);
363
- yield* Scope.close(owned, exit);
364
- if (Exit.isFailure(exit)) {
365
- const failure = Cause.findErrorOption(exit.cause);
366
- if (Option.isSome(failure) && AcquisitionFailure.is(failure.value))
367
- recordCleanup({ lease: failure.value.cleanup, policy: [] });
509
+ const sourceExit = acquiredSource === undefined
510
+ ? Exit.void
511
+ : yield* Effect.exit(acquiredSource.close.pipe(Effect.flatMap(recordAttempt)));
512
+ const scopeExit = yield* Effect.exit(Scope.close(owned, exit));
513
+ if (acquisitionCleanup !== undefined)
514
+ yield* recordAttempt(acquisitionCleanup);
515
+ if (retention !== undefined && reservation !== undefined) {
516
+ yield* retention.finish(reservation, {
517
+ accounting: "not-applicable",
518
+ unknownSubmissions: 0n,
519
+ affinity: "not-applicable",
520
+ scope: Exit.isSuccess(scopeExit) ? "closed" : "failed",
521
+ errors: [
522
+ ...(acquisitionDefect === undefined ? [] : [acquisitionDefect]),
523
+ ...[sourceExit, scopeExit].flatMap((result) => Exit.isFailure(result)
524
+ ? [errorOf(result.cause, "InvalidState", "acquisition retirement")]
525
+ : []),
526
+ ],
527
+ });
368
528
  }
529
+ const retirementExit = Exit.asVoidAll([sourceExit, scopeExit]);
530
+ if (Exit.isFailure(retirementExit))
531
+ recordFailure(attempt, retirementExit.cause.reasons);
532
+ yield* retirementExit;
369
533
  }
370
534
  })));
371
- })).pipe(Effect.tap((slot) => Effect.annotateCurrentSpan("reactor.session.id", slot.source.id)), Effect.withSpan("reactor.orchestration.renewal.open", {}, { captureStackTrace: false }));
372
- const close = closeGate.withPermit(Effect.uninterruptible(Effect.gen(function* () {
373
- if (finalReport !== undefined)
374
- return finalReport;
535
+ })).pipe(
536
+ // The attempt leaves the registry only after its own cleanup has run.
537
+ Effect.onExit(() => Effect.withFiber((fiber) => Effect.sync(() => void acquisitions.delete(fiber)))), Effect.tap((slot) => Effect.annotateCurrentSpan("reactor.session.id", slot.source.id)), Effect.withSpan("reactor.orchestration.renewal.open", {}, { captureStackTrace: false }));
538
+ // A forked attempt is interruptible whatever its caller is, so close and
539
+ // the owner's scope can stop an open that an uninterruptible caller waits on.
540
+ // A prepared replacement opens on its own after the tick that forks it.
541
+ const forkAcquisition = Effect.forkIn(acquisition, scope);
542
+ // To its caller, an attempt that close, stopRenewal or the owner's scope
543
+ // stopped is refused, not failed. Interrupted while the owner still admits
544
+ // sources, as by its own open, the attempt failed. A caller interrupted
545
+ // while it waits stops the attempt too: the handler is in place before the
546
+ // wait is restored, so an interruption that arrives first still reaches it.
547
+ const joinAcquisition = (fiber, restore) => restore(Fiber.await(fiber)).pipe(Effect.onInterrupt(() => Fiber.interrupt(fiber)), Effect.flatMap((exit) => {
548
+ if (Exit.isSuccess(exit))
549
+ return exit;
550
+ const failures = exit.cause.reasons.filter((reason) => !Cause.isInterruptReason(reason));
551
+ if (failures.length > 0)
552
+ return Effect.failCause(Cause.fromReasons(failures));
553
+ return stopping()
554
+ ? admissionClosed()
555
+ : ReactorError.fromCode("InvalidState", "Source acquisition was interrupted");
556
+ }));
557
+ // The constructor and a replacement start their attempt at once, so an open
558
+ // that completes without waiting is published in the same turn.
559
+ const acquire = Effect.uninterruptibleMask((restore) => Effect.forkIn(acquisition, scope, { startImmediately: true }).pipe(Effect.flatMap((fiber) => joinAcquisition(fiber, restore))));
560
+ const close = yield* SourceSlot.once(Effect.uninterruptible(Effect.gen(function* () {
375
561
  closing = true;
562
+ // Every registered fiber is an attempt this owner forked, never a
563
+ // caller's. Interrupting joins each attempt's cleanup, which records
564
+ // its own defects; cancelling it is not a close failure, and an
565
+ // Opening fiber that has not started never runs. Close does not wait
566
+ // for an attempt it runs on, but one issued from inside `open` cannot
567
+ // finish: `open` runs on a race fiber that stopping its attempt awaits.
568
+ const pending = new Set(acquisitions);
376
569
  if (replacement._tag === "Opening")
377
- yield* Fiber.interrupt(replacement.fiber);
378
- yield* Effect.forEach([...slots.values()], closeSlot, { discard: true });
570
+ pending.add(replacement.fiber);
571
+ yield* Effect.withFiber((self) => {
572
+ pending.delete(self);
573
+ return Fiber.interruptAll(pending);
574
+ });
575
+ for (const slot of [...slots.values()])
576
+ yield* Effect.exit(closeSlot(slot));
577
+ const exits = [...attemptFailures.values()].map((reasons) => Exit.failCause(Cause.fromReasons(reasons)));
379
578
  buffer.end();
380
579
  setMedia({ _tag: "Closed" });
381
580
  observations.end();
382
- // The reader delivers what was announced before close, within a bound.
383
581
  if (renewalReader !== undefined)
384
582
  yield* Fiber.await(renewalReader).pipe(Effect.interruptible, Effect.timeout("1 second"), Effect.ignore);
385
- finalReport = Object.freeze({ sessions: Object.freeze([...cleanups]) });
386
- return finalReport;
583
+ if (retention === undefined)
584
+ finalReport = Object.freeze({ sessions: Object.freeze([...cleanups]) });
585
+ else
586
+ finalSummary = yield* retention.conclude;
587
+ // The closed owner stays current, so final loss totals remain readable.
588
+ replacement = { _tag: "Absent" };
589
+ return yield* Exit.asVoidAll(exits);
387
590
  })));
388
591
  yield* Effect.addFinalizer(() => close);
389
- current = yield* acquire.pipe(Effect.tap((slot) => slot.source.setAutoplay(true)), Effect.catch((cause) => close.pipe(Effect.flatMap((report) => {
592
+ current = yield* acquire.pipe(Effect.tap((slot) => slot.source.setAutoplay(true)),
593
+ // Fenced as publishReady is: construction returns no handle whose owner
594
+ // stopped while its first source was activated, and close retires it.
595
+ Effect.tap(() => Effect.gen(function* () {
596
+ if (stopping())
597
+ return yield* admissionClosed();
598
+ })), Effect.catch((cause) => Effect.exit(close).pipe(Effect.flatMap((closed) => {
390
599
  // An AcquisitionFailure from `open` already carries the lease it
391
600
  // recorded, by reference; any later failure takes the recorded lease.
392
- const lease = report.sessions[0]?.lease;
393
- return Effect.fail(AcquisitionFailure.is(cause) || (lease === undefined && ReactorError.is(cause))
601
+ const lease = firstCleanup?.lease;
602
+ const failure = AcquisitionFailure.is(cause) || (lease === undefined && ReactorError.is(cause))
394
603
  ? cause
395
- : AcquisitionFailure.from(cause, lease ?? noAcquisition));
604
+ : AcquisitionFailure.from(cause, lease ?? noAcquisition);
605
+ // A defect of the close that retired the attempt stays beside the
606
+ // typed failure, which the caller still handles.
607
+ return Exit.isSuccess(closed)
608
+ ? Effect.fail(failure)
609
+ : Effect.failCause(Cause.combine(Cause.fail(failure), closed.cause));
396
610
  }))));
611
+ initializing = false;
612
+ firstCleanup = undefined;
397
613
  // The first owner's loss from its start is the output's: no baseline.
614
+ outputOwner = current.owner;
398
615
  setMedia({
399
616
  _tag: "Ready",
400
617
  sessionId: current.source.id,
@@ -412,7 +629,7 @@ export const make = (options) => Effect.gen(function* () {
412
629
  });
413
630
  const route = (request) => Effect.gen(function* () {
414
631
  const values = yield* Effect.suspend(() => Effect.forEach([...slots.values()], (slot) => Effect.map(slot.source.state, (state) => ({
415
- owner: slot.source.id,
632
+ owner: slot.owner,
416
633
  state,
417
634
  accepted: slot.accepted,
418
635
  closed: slot.closed,
@@ -425,27 +642,43 @@ export const make = (options) => Effect.gen(function* () {
425
642
  : current;
426
643
  if (preferred === undefined)
427
644
  return yield* PolicyFailure.refuse("SessionRecovering", "No source is ready");
428
- return yield* resolve(request, values, preferred.source.id, binding);
645
+ return yield* resolve(request, values, preferred.owner, binding);
429
646
  });
430
647
  const sequenceError = (error) => PolicyFailure.sequence(error.sequenceId, error.code);
431
648
  const prepare = (input, expectedSessionId) => Effect.gen(function* () {
649
+ const expectedOwner = expectedSessionId === undefined ? undefined : physicalOwners.get(expectedSessionId);
432
650
  const request = yield* captureRequest(input);
433
651
  const id = `${namespace}:${++submissionSequence}`;
434
652
  const gate = yield* Semaphore.make(1);
653
+ const logical = logicalSubmission(id);
654
+ let selectedOwner;
435
655
  let active;
436
656
  let activeOwner;
437
657
  const submit = gate.withPermit(Effect.gen(function* () {
658
+ const completed = logical.outcome();
659
+ if (completed !== undefined)
660
+ return yield* completed;
438
661
  if (active === undefined) {
439
662
  // A committed submission remains a readable outcome after the handle
440
663
  // closes. Only selecting or committing fresh work needs a live owner.
441
664
  yield* guard("enqueue", Effect.void);
442
665
  const decision = yield* route(request).pipe(commands.withPermits(1));
443
- if (expectedSessionId !== undefined && decision.owner !== expectedSessionId)
666
+ if (expectedSessionId !== undefined && decision.owner !== expectedOwner)
444
667
  return yield* PolicyFailure.refuse("RouteChanged", "The selected source changed before dispatch");
668
+ if (continuous && selectedOwner !== undefined && selectedOwner !== decision.owner) {
669
+ // The selection stays fenced to its incarnation. While that
670
+ // source is live, only the route moved; it has not retired.
671
+ const selected = slots.get(selectedOwner);
672
+ return yield* selected !== undefined && !selected.closed
673
+ ? PolicyFailure.refuse("RouteChanged", "The selected source changed before dispatch")
674
+ : PolicyFailure.refuse("SessionRetired", "Selected source incarnation was retired");
675
+ }
676
+ selectedOwner = decision.owner;
445
677
  const target = slots.get(decision.owner);
446
678
  if (target === undefined)
447
679
  return yield* PolicyFailure.refuse("SessionRetired", "Selected source was retired");
448
680
  const sequence = request.sequence;
681
+ let resultAccounted = false;
449
682
  active = yield* target.source.prepareRouted({ request, position: decision.position }, {
450
683
  commit: (submissionId) => commands.withPermit(guard("enqueue", Effect.gen(function* () {
451
684
  const checked = yield* route(request);
@@ -457,28 +690,43 @@ export const make = (options) => Effect.gen(function* () {
457
690
  return yield* Effect.die(new Error("Source committed before returning its inert submission"));
458
691
  yield* target.register(active, Effect.gen(function* () {
459
692
  if (sequence !== undefined) {
460
- yield* affinity.bind(sequence.id, target.source.id);
693
+ yield* affinity.bind(sequence.id, target.owner);
461
694
  yield* affinity.begin(sequence.id, sequence.memberId ?? submissionId);
462
695
  }
463
- }).pipe(Effect.mapError(sequenceError)));
696
+ }).pipe(Effect.mapError(sequenceError)), (exit) => {
697
+ logical.complete(exit);
698
+ active = undefined;
699
+ activeOwner = undefined;
700
+ });
464
701
  }))),
465
702
  result: (submissionId, result) => Effect.gen(function* () {
466
- target.recordResult(result);
703
+ if (resultAccounted)
704
+ return;
705
+ resultAccounted = true;
706
+ // An accepted identity past the continuous bound is a
707
+ // source-contract failure, as for Started: it is never
708
+ // retained, its member cannot be attributed and the source
709
+ // is replaced.
710
+ const recorded = yield* Effect.result(target.recordResult(result));
467
711
  if (sequence !== undefined) {
468
712
  const memberId = sequence.memberId ?? submissionId;
469
- const account = Result.isSuccess(result)
470
- ? affinity.accepted(sequence.id, memberId, result.success, sequence.final)
471
- : result.failure.context.outcome === "unknown"
472
- ? affinity.uncertain(sequence.id, memberId, result.failure.message)
473
- : affinity.rejected(sequence.id, memberId, result.failure.message, sequence.final);
713
+ const account = Result.isFailure(recorded)
714
+ ? affinity.uncertain(sequence.id, memberId, recorded.failure.message)
715
+ : Result.isSuccess(result)
716
+ ? affinity.accepted(sequence.id, memberId, result.success, sequence.final)
717
+ : result.failure.context.outcome === "unknown"
718
+ ? affinity.uncertain(sequence.id, memberId, result.failure.message)
719
+ : affinity.rejected(sequence.id, memberId, result.failure.message, sequence.final);
474
720
  yield* account.pipe(Effect.catch((cause) => Effect.gen(function* () {
475
721
  target.markIndeterminate();
476
- yield* affinity.retire(target.source.id);
722
+ yield* affinity.retire(target.owner);
477
723
  yield* fail(ReactorError.fromCode("InvalidState", "Committed sequence accounting failed", { detail: cause }));
478
724
  })));
479
725
  }
480
726
  target.finishAccounting();
481
- if (Result.isFailure(result) &&
727
+ if (Result.isFailure(recorded))
728
+ yield* scheduleRecovery(target, recorded.failure, "replace");
729
+ else if (Result.isFailure(result) &&
482
730
  result.failure.context.outcome === "unknown") {
483
731
  yield* scheduleRecovery(target, result.failure, "replace");
484
732
  }
@@ -488,23 +736,26 @@ export const make = (options) => Effect.gen(function* () {
488
736
  }
489
737
  const selected = active;
490
738
  return yield* selected.submit.pipe(Effect.onExit(() => selected.state.pipe(Effect.flatMap((state) => Effect.sync(() => {
491
- if (state._tag === "Completed")
739
+ if (state._tag === "Completed") {
740
+ logical.complete(state.exit);
492
741
  activeOwner?.forgetCompleted(selected);
742
+ active = undefined;
743
+ activeOwner = undefined;
744
+ }
493
745
  if (state._tag === "Prepared" && active === selected) {
494
746
  active = undefined;
495
747
  activeOwner = undefined;
496
748
  }
497
749
  })))));
498
750
  }));
499
- return {
500
- id,
501
- submit,
502
- state: Effect.suspend(() => active === undefined
503
- ? Effect.succeed({ _tag: "Prepared" })
504
- : active.state),
505
- };
751
+ logical.attach(submit, Effect.suspend(() => active === undefined
752
+ ? Effect.succeed({ _tag: "Prepared" })
753
+ : active.state));
754
+ return logical.submission;
506
755
  });
507
756
  const state = Effect.gen(function* () {
757
+ if (!closing && terminal !== undefined && Exit.isFailure(terminal))
758
+ return yield* Effect.failCause(terminal.cause);
508
759
  const live = [...slots.values()].filter((slot) => !slot.closed);
509
760
  const values = (yield* Effect.forEach(live, (slot) => slot.source.state)).map((value, index) => withSessionId(value, live[index].source.id));
510
761
  const primary = values[live.indexOf(current)] ?? emptyState();
@@ -581,11 +832,13 @@ export const make = (options) => Effect.gen(function* () {
581
832
  if (replacement._tag !== "Opening")
582
833
  return;
583
834
  const pending = replacement.fiber;
584
- replacement = { _tag: "Absent" };
835
+ // It stays Opening until joined, so a concurrent close joins it too.
585
836
  yield* Fiber.interrupt(pending);
586
837
  const result = yield* Fiber.await(pending);
838
+ if (replacement._tag === "Opening" && replacement.fiber === pending)
839
+ replacement = { _tag: "Absent" };
587
840
  if (Exit.isSuccess(result))
588
- yield* result.value.close;
841
+ yield* closeSlot(result.value);
589
842
  }));
590
843
  }),
591
844
  setAutoplay: (enabled) => commands.withPermit(guard("set_autoplay", Effect.gen(function* () {
@@ -602,6 +855,9 @@ export const make = (options) => Effect.gen(function* () {
602
855
  }
603
856
  }))),
604
857
  remove: (id) => commands.withPermit(guard("pop", owner(id, "pop").pipe(Effect.flatMap(({ slot }) => slot.source.remove(id))))),
858
+ cut: (id) => commands.withPermit(guard("stop", owner(id, "stop").pipe(Effect.flatMap(({ slot, state }) => Option.getOrUndefined(state.playing)?.clipId === id
859
+ ? slot.source.stop
860
+ : PolicyFailure.refuse("NotFound", "The clip is no longer playing", "stop"))))),
605
861
  move: (id, position, queue) => commands.withPermit(guard("move", Effect.gen(function* () {
606
862
  if (!Number.isSafeInteger(position) || position < 0)
607
863
  return yield* PolicyFailure.refuse("InvalidRequest", "Move position must be a nonnegative integer", "move");
@@ -632,7 +888,7 @@ export const make = (options) => Effect.gen(function* () {
632
888
  };
633
889
  const tick = commands
634
890
  .withPermit(Effect.gen(function* () {
635
- if (closing ||
891
+ if (stopping() ||
636
892
  current === undefined ||
637
893
  current.closed ||
638
894
  current.recovering ||
@@ -642,6 +898,10 @@ export const make = (options) => Effect.gen(function* () {
642
898
  const result = replacement.fiber.pollUnsafe();
643
899
  if (result !== undefined) {
644
900
  replacement = { _tag: "Absent" };
901
+ // Close or the owner's scope stopped this attempt, and close
902
+ // retires whatever it opened: that is no failed setup.
903
+ if (stopping())
904
+ return;
645
905
  if (Exit.isSuccess(result)) {
646
906
  replacement = { _tag: "Ready", slot: result.value };
647
907
  openFailures = 0;
@@ -649,6 +909,13 @@ export const make = (options) => Effect.gen(function* () {
649
909
  yield* announce({ _tag: "Prepared" });
650
910
  }
651
911
  else {
912
+ if (continuous) {
913
+ const error = Cause.findErrorOption(result.cause);
914
+ if (Option.isSome(error) &&
915
+ ReactorError.is(error.value) &&
916
+ error.value.reason._tag === "Overflow")
917
+ return yield* fail(error.value);
918
+ }
652
919
  openFailures++;
653
920
  retryAt = monotonicMillis(clock) + 5000;
654
921
  yield* announce({
@@ -662,7 +929,7 @@ export const make = (options) => Effect.gen(function* () {
662
929
  }
663
930
  }
664
931
  const decision = decideRenewal({
665
- running: !closing && terminalFailure === undefined,
932
+ running: !stopping() && terminal === undefined,
666
933
  now: monotonicMillis(clock),
667
934
  current,
668
935
  replacement: replacement._tag === "Ready" ? replacement.slot.phase : replacement._tag,
@@ -679,7 +946,7 @@ export const make = (options) => Effect.gen(function* () {
679
946
  return;
680
947
  case "Prepare":
681
948
  if (renewing)
682
- replacement = { _tag: "Opening", fiber: yield* acquire.pipe(Effect.forkIn(scope)) };
949
+ replacement = { _tag: "Opening", fiber: yield* forkAcquisition };
683
950
  return;
684
951
  case "InspectHandoff":
685
952
  break;
@@ -696,14 +963,16 @@ export const make = (options) => Effect.gen(function* () {
696
963
  // The provider reports nothing playing: a lost Ended must not hold
697
964
  // the switch until expiry, so the grace also starts here.
698
965
  current.observedIdle();
699
- if (!canHandoff({
966
+ const evidence = handoffEvidence({
700
967
  sequenceOpen: false,
701
968
  currentIdle: isIdle(oldState),
702
969
  replacementReady: nextState.availability === "Ready" && nextState.ready.length > 0,
703
970
  finalClip: current.finalClip(),
704
971
  graceMs: handoffGraceMs,
705
- }))
972
+ });
973
+ if (evidence === undefined)
706
974
  return;
975
+ const handoff = Object.freeze({ ...evidence, replacementSessionId: next.source.id });
707
976
  // Read only for a switch that happens: it can wait on source pressure.
708
977
  const tail = yield* retired(current);
709
978
  const old = current;
@@ -718,7 +987,7 @@ export const make = (options) => Effect.gen(function* () {
718
987
  yield* closeSlot(old);
719
988
  if (!activated)
720
989
  return;
721
- yield* announce({ _tag: "Switched", ...tail });
990
+ yield* announce({ _tag: "Switched", ...tail, handoff });
722
991
  yield* log("Switched prepared sessions at a sequence boundary");
723
992
  }).pipe(Effect.withSpan("reactor.orchestration.renewal.switch", {
724
993
  attributes: {
@@ -730,14 +999,18 @@ export const make = (options) => Effect.gen(function* () {
730
999
  .pipe(Effect.catch(fail));
731
1000
  // Spaced polling, not a fixed rate: each tick runs 100 ms after the previous one
732
1001
  // finished, so a slow tick delays the next instead of overlapping it.
733
- yield* Effect.forever(Effect.sleep(100).pipe(Effect.andThen(tick))).pipe(Effect.forkIn(scope));
1002
+ yield* Effect.forever(Effect.sleep(100).pipe(Effect.andThen(tick))).pipe(supervise, Effect.forkIn(scope));
734
1003
  const pressure = Effect.suspend(() => {
735
1004
  if (current === undefined)
736
1005
  return Effect.fail(ReactorError.fromCode("InvalidState", "No active media source"));
737
1006
  const owner = current;
738
1007
  return owner.pressure.pipe(Effect.flatMap((source) => {
739
1008
  const queued = buffer.pressure();
740
- const loss = SourceSlot.addLoss(outputLoss, SourceSlot.subtractLoss(SourceSlot.lossOf(Result.succeed(source)), ownerBaseline));
1009
+ // A retired owner's loss is already in the output's totals, as the
1010
+ // closed continuous owner's is once its retirement completes.
1011
+ const loss = outputOwner === owner.owner
1012
+ ? SourceSlot.addLoss(outputLoss, SourceSlot.subtractLoss(SourceSlot.lossOf(Result.succeed(source)), ownerBaseline))
1013
+ : outputLoss;
741
1014
  if (loss.video === null || loss.audio === null || loss.readers === null)
742
1015
  return Effect.fail(ReactorError.fromCode("InvalidState", "A former media owner's loss totals are unknown"));
743
1016
  return Effect.succeed({
@@ -752,7 +1025,7 @@ export const make = (options) => Effect.gen(function* () {
752
1025
  });
753
1026
  }));
754
1027
  });
755
- return {
1028
+ const facets = {
756
1029
  engine,
757
1030
  media: {
758
1031
  video: buffer.video,
@@ -760,20 +1033,52 @@ export const make = (options) => Effect.gen(function* () {
760
1033
  pressure,
761
1034
  videoFramesPerSecond: current.media.videoFramesPerSecond,
762
1035
  },
763
- mediaState: Effect.sync(() => mediaState),
1036
+ mediaState: Effect.suspend(() => !closing && terminal !== undefined && Exit.isFailure(terminal)
1037
+ ? Effect.failCause(terminal.cause)
1038
+ : Effect.succeed(mediaState)),
764
1039
  observe: (options) => observations.observeWith(Effect.map(state, (engine) => ({ engine, media: mediaState })), options),
765
1040
  sessionId: Effect.sync(() => current === undefined || current.closed ? Option.none() : Option.some(current.source.id)),
766
- close,
767
- cleanup: Effect.sync(() => finalReport === undefined ? Option.none() : Option.some(finalReport)),
768
1041
  sequences: {
769
- get: affinity.get,
770
- snapshots: affinity.snapshots,
1042
+ get: (id) => affinity
1043
+ .get(id)
1044
+ .pipe(Effect.map((entry) => entry === undefined
1045
+ ? undefined
1046
+ : Object.freeze({ ...entry, owner: entry.owner.sessionId }))),
1047
+ snapshots: affinity.snapshots.pipe(Effect.map((entries) => Object.freeze(entries.map((entry) => Object.freeze({ ...entry, owner: entry.owner.sessionId }))))),
771
1048
  seal: affinity.seal,
772
1049
  acknowledgeIndeterminate: affinity.acknowledgeIndeterminate,
773
1050
  release: affinity.release,
774
1051
  },
775
1052
  };
1053
+ return {
1054
+ legacy: {
1055
+ ...facets,
1056
+ close: close.pipe(Effect.andThen(Effect.sync(() => {
1057
+ if (finalReport === undefined)
1058
+ throw new Error("Missing legacy cleanup report");
1059
+ return finalReport;
1060
+ }))),
1061
+ cleanup: Effect.sync(() => Option.fromUndefinedOr(finalReport)),
1062
+ },
1063
+ continuous: {
1064
+ ...facets,
1065
+ close: close.pipe(Effect.andThen(Effect.sync(() => {
1066
+ if (finalSummary === undefined)
1067
+ throw new Error("Missing continuous cleanup summary");
1068
+ return finalSummary;
1069
+ }))),
1070
+ cleanup: Effect.sync(() => Option.fromUndefinedOr(finalSummary)),
1071
+ },
1072
+ };
776
1073
  });
1074
+ /** Legacy renewal retains complete cleanup history and defaults to 64 successful opens. */
1075
+ export const make = (options) => makeOwner(options, false).pipe(Effect.map((owner) => owner.legacy));
1076
+ /**
1077
+ * Bounded continuous renewal. Adapters must meet ContinuousOptions' unique-ID
1078
+ * precondition. Retired anchors no longer retained can refuse as missing rather
1079
+ * than SessionRetired; captured operations remain fenced to their exact incarnation.
1080
+ */
1081
+ export const makeContinuous = (options) => makeOwner(options, true).pipe(Effect.map((owner) => owner.continuous));
777
1082
  /**
778
1083
  * The handle's `Engine`, `Media` and `Handle` services from one orchestration,
779
1084
  * so the three can never come from two paid session chains. Each build of the