reactor-effect-client 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +16 -2
  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 +4 -4
  15. package/dist/orchestration/index.d.ts.map +1 -1
  16. package/dist/orchestration/index.js +2 -2
  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 +396 -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-policy.d.ts +2 -2
  35. package/dist/orchestration/scheduler-policy.d.ts.map +1 -1
  36. package/dist/orchestration/scheduler-policy.js +5 -8
  37. package/dist/orchestration/scheduler-policy.js.map +1 -1
  38. package/dist/orchestration/scheduler.d.ts +5 -3
  39. package/dist/orchestration/scheduler.d.ts.map +1 -1
  40. package/dist/orchestration/scheduler.js +359 -109
  41. package/dist/orchestration/scheduler.js.map +1 -1
  42. package/dist/orchestration/source-slot.d.ts +16 -4
  43. package/dist/orchestration/source-slot.d.ts.map +1 -1
  44. package/dist/orchestration/source-slot.js +122 -30
  45. package/dist/orchestration/source-slot.js.map +1 -1
  46. package/dist/orchestration/types.d.ts +483 -1
  47. package/dist/orchestration/types.d.ts.map +1 -1
  48. package/dist/orchestration/types.js +105 -1
  49. package/dist/orchestration/types.js.map +1 -1
  50. package/dist/session.d.ts.map +1 -1
  51. package/dist/session.js +2 -1
  52. package/dist/session.js.map +1 -1
  53. package/dist/simulation/_internal/source.d.ts.map +1 -1
  54. package/dist/simulation/_internal/source.js +5 -4
  55. package/dist/simulation/_internal/source.js.map +1 -1
  56. 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);
@@ -170,7 +243,8 @@ export const make = (options) => Effect.gen(function* () {
170
243
  });
171
244
  if (replacement._tag === "Ready" && replacement.slot === slot)
172
245
  replacement = { _tag: "Absent" };
173
- if (slot !== current)
246
+ // Close retires what remains; a replacement it refuses is no failure.
247
+ if (slot !== current || stopping())
174
248
  return;
175
249
  const pending = replacement;
176
250
  if (!renewing && pending._tag === "Absent") {
@@ -179,10 +253,12 @@ export const make = (options) => Effect.gen(function* () {
179
253
  }
180
254
  const selected = pending._tag === "Ready"
181
255
  ? pending.slot
182
- : yield* pending._tag === "Opening" ? Fiber.join(pending.fiber) : acquire;
256
+ : yield* pending._tag === "Opening"
257
+ ? Effect.uninterruptibleMask((restore) => joinAcquisition(pending.fiber, restore))
258
+ : acquire;
183
259
  replacement = { _tag: "Absent" };
184
- if (closing || terminalFailure !== undefined) {
185
- yield* selected.close;
260
+ if (stopping() || terminal !== undefined) {
261
+ yield* closeSlot(selected);
186
262
  return;
187
263
  }
188
264
  current = selected;
@@ -191,15 +267,15 @@ export const make = (options) => Effect.gen(function* () {
191
267
  if (!publishReady(current))
192
268
  return;
193
269
  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));
270
+ }).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
271
  const recover = (slot, cause) => Effect.gen(function* () {
196
- if (slot.closed || closing || terminalFailure !== undefined)
272
+ if (slot.closed || stopping() || terminal !== undefined)
197
273
  return;
198
274
  if (slot === current)
199
275
  setMedia({ _tag: "Recovering", sessionId: slot.source.id, cause });
200
276
  yield* announce({ _tag: "Recovering", sessionId: slot.source.id, reason: cause.message });
201
277
  yield* slot.joinCommitted(recoveryBudget(slot));
202
- if (slot.closed || closing || terminalFailure !== undefined)
278
+ if (slot.closed || stopping() || terminal !== undefined)
203
279
  return;
204
280
  if (slot.needsReplacement()) {
205
281
  yield* replace(slot, cause);
@@ -215,13 +291,13 @@ export const make = (options) => Effect.gen(function* () {
215
291
  return;
216
292
  }
217
293
  yield* slot.closeMedia;
218
- if (slot.closed || closing || terminalFailure !== undefined)
294
+ if (slot.closed || stopping() || terminal !== undefined)
219
295
  return;
220
296
  const connected = yield* Effect.result(slot.source.reconnect.pipe(Effect.timeoutOrElse({
221
297
  duration: recoveryBudget(slot),
222
298
  orElse: () => Effect.fail(ReactorError.fromCode("Disconnected", "Explicit source reconnect timed out")),
223
299
  })));
224
- if (slot.closed || closing || terminalFailure !== undefined)
300
+ if (slot.closed || stopping() || terminal !== undefined)
225
301
  return;
226
302
  if (Result.isFailure(connected)) {
227
303
  yield* replace(slot, connected.failure);
@@ -245,7 +321,7 @@ export const make = (options) => Effect.gen(function* () {
245
321
  yield* slot.replaceMedia(media, previousPressure);
246
322
  return yield* startMedia(slot);
247
323
  }));
248
- if (slot.closed || closing || terminalFailure !== undefined)
324
+ if (slot.closed || stopping() || terminal !== undefined)
249
325
  return;
250
326
  if (Result.isFailure(restarted)) {
251
327
  yield* replace(slot, restarted.failure);
@@ -264,35 +340,54 @@ export const make = (options) => Effect.gen(function* () {
264
340
  });
265
341
  }).pipe(Effect.withSpan("reactor.orchestration.renewal.recover", { attributes: { "reactor.session.id": slot.source.id } }, { captureStackTrace: false }));
266
342
  const scheduleRecovery = (slot, cause, mode) => Effect.gen(function* () {
267
- if (slot.closed || closing || terminalFailure !== undefined)
343
+ if (slot.closed || stopping() || terminal !== undefined)
268
344
  return;
269
345
  if (!slot.beginRecovery(mode))
270
346
  return;
271
- yield* recover(slot, cause).pipe(commands.withPermits(1), Effect.forkIn(scope));
347
+ yield* recover(slot, cause).pipe(commands.withPermits(1), supervise, Effect.forkIn(scope));
272
348
  });
273
349
  const startMedia = (slot) => slot.startMedia({
274
350
  video: (frame) => Effect.suspend(() => {
275
- if (slot !== current || terminalFailure !== undefined)
351
+ if (slot !== current || terminal !== undefined)
276
352
  return Effect.void;
277
353
  if (slot.recordVideo() && replacement._tag === "Ready")
278
354
  emit({ _tag: "HandoffReady", sessionId: slot.source.id });
279
355
  return buffer.offerVideo(frame).pipe(Effect.catch(fail));
280
356
  }),
281
357
  audio: (frame) => Effect.suspend(() => {
282
- if (slot !== current || terminalFailure !== undefined)
358
+ if (slot !== current || terminal !== undefined)
283
359
  return Effect.void;
284
360
  return buffer.offerAudio(frame).pipe(Effect.tap(() => Effect.sync(() => {
285
361
  slot.recordAudio(frame.samples.length);
286
362
  })), Effect.catch(fail));
287
363
  }),
288
- lost: (cause) => (closing ? Effect.void : scheduleRecovery(slot, cause, "reconnect")),
364
+ lost: (cause) => (stopping() ? Effect.void : scheduleRecovery(slot, cause, "reconnect")),
289
365
  });
290
- const acquire = Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
366
+ // Runs only on a fiber of its own, forked into the owner's scope.
367
+ const acquisition = Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
368
+ const fiber = yield* Effect.fiber;
291
369
  if (opened >= maxSessions)
292
370
  return yield* ReactorError.fromCode("Overflow", "Orchestration session and cleanup-history bound reached");
371
+ if (stopping() || (continuous && (terminal !== undefined || !renewing)))
372
+ return yield* admissionClosed();
373
+ // Registered in the same synchronous step as the stopping check, so
374
+ // either close sees this attempt or this attempt sees close.
375
+ acquisitions.add(fiber);
376
+ const reservation = retention === undefined ? undefined : yield* retention.reserve;
377
+ const attemptIncarnation = reservation?.incarnation ?? ++incarnation;
378
+ const recordAttempt = (cleanup) => {
379
+ if (initializing && firstCleanup === undefined)
380
+ firstCleanup = cleanup;
381
+ return retention !== undefined && reservation !== undefined
382
+ ? retention.record(reservation, cleanup)
383
+ : Effect.sync(() => recordCleanup(cleanup));
384
+ };
293
385
  const owned = yield* Scope.make();
386
+ const attempt = {};
294
387
  let acquired;
295
388
  let acquiredSource;
389
+ let acquisitionCleanup;
390
+ let acquisitionDefect;
296
391
  return yield* restore(Effect.gen(function* () {
297
392
  // Replace the captured Scope explicitly in one context operation.
298
393
  // Dependency provision order cannot move resources to the caller's owner.
@@ -301,26 +396,66 @@ export const make = (options) => Effect.gen(function* () {
301
396
  orElse: () => Effect.fail(ReactorError.fromCode("InvalidState", "Source acquisition timed out")),
302
397
  }));
303
398
  acquiredSource = value.source;
399
+ if (retention !== undefined && reservation !== undefined)
400
+ yield* retention.identify(reservation, value.source.id);
401
+ const owner = Object.freeze({
402
+ namespace,
403
+ incarnation: attemptIncarnation,
404
+ sessionId: value.source.id,
405
+ });
304
406
  const lifetime = yield* parsed(() => duration(value.lifetime, "source lifetime", { allowInfinite: true }));
305
407
  // A lead as long as the lifetime would prepare a replacement at once,
306
408
  // after every open, bounded only by maxSessions.
307
409
  if (Duration.isFinite(lifetime) && leadSeconds >= Duration.toSeconds(lifetime))
308
410
  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))
411
+ if (physicalOwners.has(value.source.id))
310
412
  return yield* ReactorError.fromCode("InvalidInput", "Orchestration sources must have distinct session identities");
311
413
  const media = yield* value.source.media;
312
- const slot = yield* SourceSlot.make({
414
+ const local = yield* SourceSlot.make({
313
415
  source: value.source,
314
416
  scope: owned,
315
417
  media,
316
418
  openedAt: monotonicMillis(clock),
317
419
  maxSeconds: Duration.toSeconds(lifetime),
318
420
  cleanupBudgetMs: reconnectTimeout,
319
- recordCleanup,
320
- retireSequences: affinity.retire(value.source.id).pipe(Effect.asVoid),
421
+ recordCleanup: recordAttempt,
422
+ boundedHistory: continuous,
423
+ retired: (facts, retirementExit) => Effect.gen(function* () {
424
+ const bookkeeping = yield* Effect.exit(Effect.gen(function* () {
425
+ if (acquisitionCleanup !== undefined)
426
+ yield* recordAttempt(acquisitionCleanup);
427
+ if (retention === undefined || reservation === undefined)
428
+ return;
429
+ const lossExit = acquired === undefined
430
+ ? Exit.void
431
+ : yield* Effect.exit(retireOwner(acquired));
432
+ yield* retention.finish(reservation, {
433
+ ...facts,
434
+ errors: [
435
+ ...facts.errors,
436
+ ...(acquisitionDefect === undefined ? [] : [acquisitionDefect]),
437
+ ...(Exit.isFailure(lossExit)
438
+ ? [errorOf(lossExit.cause, "InvalidState", "media loss accounting")]
439
+ : []),
440
+ ],
441
+ });
442
+ slots.delete(owner);
443
+ if (physicalOwners.get(owner.sessionId) === owner)
444
+ physicalOwners.delete(owner.sessionId);
445
+ return yield* lossExit;
446
+ }));
447
+ // The slot calls this once, however many callers wait on its close.
448
+ const exit = Exit.asVoidAll([retirementExit, bookkeeping]);
449
+ if (Exit.isFailure(exit))
450
+ recordFailure(attempt, exit.cause.reasons);
451
+ return yield* bookkeeping;
452
+ }),
453
+ retireSequences: affinity.retire(owner).pipe(Effect.asVoid),
321
454
  });
455
+ const slot = Object.assign(local, { owner });
322
456
  acquired = slot;
323
- slots.set(slot.source.id, slot);
457
+ slots.set(owner, slot);
458
+ physicalOwners.set(owner.sessionId, owner);
324
459
  opened++;
325
460
  yield* slot.source.setAutoplay(false);
326
461
  yield* slot.observe((event) => Effect.gen(function* () {
@@ -355,46 +490,127 @@ export const make = (options) => Effect.gen(function* () {
355
490
  })).pipe(Effect.onExit((exit) => Exit.isSuccess(exit)
356
491
  ? Effect.void
357
492
  : Effect.gen(function* () {
493
+ // Capture an acquisition failure even if the source also
494
+ // returns cleanup. Conflicting reports belong to this attempt.
495
+ // A typed failure is the attempt's result, and cancellation
496
+ // is not a defect: only a Die is a failure close must report.
497
+ const defects = exit.cause.reasons.filter(Cause.isDieReason);
498
+ if (defects.length > 0) {
499
+ recordFailure(attempt, defects);
500
+ acquisitionDefect = errorOf(Cause.fromReasons(defects), "InvalidState", "source acquisition");
501
+ }
502
+ const failure = Cause.findErrorOption(exit.cause);
503
+ if (Option.isSome(failure) && AcquisitionFailure.is(failure.value))
504
+ acquisitionCleanup = { lease: failure.value.cleanup, policy: [] };
358
505
  if (acquired !== undefined)
359
506
  yield* closeSlot(acquired);
360
507
  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: [] });
508
+ const sourceExit = acquiredSource === undefined
509
+ ? Exit.void
510
+ : yield* Effect.exit(acquiredSource.close.pipe(Effect.flatMap(recordAttempt)));
511
+ const scopeExit = yield* Effect.exit(Scope.close(owned, exit));
512
+ if (acquisitionCleanup !== undefined)
513
+ yield* recordAttempt(acquisitionCleanup);
514
+ if (retention !== undefined && reservation !== undefined) {
515
+ yield* retention.finish(reservation, {
516
+ accounting: "not-applicable",
517
+ unknownSubmissions: 0n,
518
+ affinity: "not-applicable",
519
+ scope: Exit.isSuccess(scopeExit) ? "closed" : "failed",
520
+ errors: [
521
+ ...(acquisitionDefect === undefined ? [] : [acquisitionDefect]),
522
+ ...[sourceExit, scopeExit].flatMap((result) => Exit.isFailure(result)
523
+ ? [errorOf(result.cause, "InvalidState", "acquisition retirement")]
524
+ : []),
525
+ ],
526
+ });
368
527
  }
528
+ const retirementExit = Exit.asVoidAll([sourceExit, scopeExit]);
529
+ if (Exit.isFailure(retirementExit))
530
+ recordFailure(attempt, retirementExit.cause.reasons);
531
+ yield* retirementExit;
369
532
  }
370
533
  })));
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;
534
+ })).pipe(
535
+ // The attempt leaves the registry only after its own cleanup has run.
536
+ 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 }));
537
+ // A forked attempt is interruptible whatever its caller is, so close and
538
+ // the owner's scope can stop an open that an uninterruptible caller waits on.
539
+ // A prepared replacement opens on its own after the tick that forks it.
540
+ const forkAcquisition = Effect.forkIn(acquisition, scope);
541
+ // To its caller, an attempt that close, stopRenewal or the owner's scope
542
+ // stopped is refused, not failed. Interrupted while the owner still admits
543
+ // sources, as by its own open, the attempt failed. A caller interrupted
544
+ // while it waits stops the attempt too: the handler is in place before the
545
+ // wait is restored, so an interruption that arrives first still reaches it.
546
+ const joinAcquisition = (fiber, restore) => restore(Fiber.await(fiber)).pipe(Effect.onInterrupt(() => Fiber.interrupt(fiber)), Effect.flatMap((exit) => {
547
+ if (Exit.isSuccess(exit))
548
+ return exit;
549
+ const failures = exit.cause.reasons.filter((reason) => !Cause.isInterruptReason(reason));
550
+ if (failures.length > 0)
551
+ return Effect.failCause(Cause.fromReasons(failures));
552
+ return stopping()
553
+ ? admissionClosed()
554
+ : ReactorError.fromCode("InvalidState", "Source acquisition was interrupted");
555
+ }));
556
+ // The constructor and a replacement start their attempt at once, so an open
557
+ // that completes without waiting is published in the same turn.
558
+ const acquire = Effect.uninterruptibleMask((restore) => Effect.forkIn(acquisition, scope, { startImmediately: true }).pipe(Effect.flatMap((fiber) => joinAcquisition(fiber, restore))));
559
+ const close = yield* SourceSlot.once(Effect.uninterruptible(Effect.gen(function* () {
375
560
  closing = true;
561
+ // Every registered fiber is an attempt this owner forked, never a
562
+ // caller's. Interrupting joins each attempt's cleanup, which records
563
+ // its own defects; cancelling it is not a close failure, and an
564
+ // Opening fiber that has not started never runs. Close does not wait
565
+ // for an attempt it runs on, but one issued from inside `open` cannot
566
+ // finish: `open` runs on a race fiber that stopping its attempt awaits.
567
+ const pending = new Set(acquisitions);
376
568
  if (replacement._tag === "Opening")
377
- yield* Fiber.interrupt(replacement.fiber);
378
- yield* Effect.forEach([...slots.values()], closeSlot, { discard: true });
569
+ pending.add(replacement.fiber);
570
+ yield* Effect.withFiber((self) => {
571
+ pending.delete(self);
572
+ return Fiber.interruptAll(pending);
573
+ });
574
+ for (const slot of [...slots.values()])
575
+ yield* Effect.exit(closeSlot(slot));
576
+ const exits = [...attemptFailures.values()].map((reasons) => Exit.failCause(Cause.fromReasons(reasons)));
379
577
  buffer.end();
380
578
  setMedia({ _tag: "Closed" });
381
579
  observations.end();
382
- // The reader delivers what was announced before close, within a bound.
383
580
  if (renewalReader !== undefined)
384
581
  yield* Fiber.await(renewalReader).pipe(Effect.interruptible, Effect.timeout("1 second"), Effect.ignore);
385
- finalReport = Object.freeze({ sessions: Object.freeze([...cleanups]) });
386
- return finalReport;
582
+ if (retention === undefined)
583
+ finalReport = Object.freeze({ sessions: Object.freeze([...cleanups]) });
584
+ else
585
+ finalSummary = yield* retention.conclude;
586
+ // The closed owner stays current, so final loss totals remain readable.
587
+ replacement = { _tag: "Absent" };
588
+ return yield* Exit.asVoidAll(exits);
387
589
  })));
388
590
  yield* Effect.addFinalizer(() => close);
389
- current = yield* acquire.pipe(Effect.tap((slot) => slot.source.setAutoplay(true)), Effect.catch((cause) => close.pipe(Effect.flatMap((report) => {
591
+ current = yield* acquire.pipe(Effect.tap((slot) => slot.source.setAutoplay(true)),
592
+ // Fenced as publishReady is: construction returns no handle whose owner
593
+ // stopped while its first source was activated, and close retires it.
594
+ Effect.tap(() => Effect.gen(function* () {
595
+ if (stopping())
596
+ return yield* admissionClosed();
597
+ })), Effect.catch((cause) => Effect.exit(close).pipe(Effect.flatMap((closed) => {
390
598
  // An AcquisitionFailure from `open` already carries the lease it
391
599
  // 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))
600
+ const lease = firstCleanup?.lease;
601
+ const failure = AcquisitionFailure.is(cause) || (lease === undefined && ReactorError.is(cause))
394
602
  ? cause
395
- : AcquisitionFailure.from(cause, lease ?? noAcquisition));
603
+ : AcquisitionFailure.from(cause, lease ?? noAcquisition);
604
+ // A defect of the close that retired the attempt stays beside the
605
+ // typed failure, which the caller still handles.
606
+ return Exit.isSuccess(closed)
607
+ ? Effect.fail(failure)
608
+ : Effect.failCause(Cause.combine(Cause.fail(failure), closed.cause));
396
609
  }))));
610
+ initializing = false;
611
+ firstCleanup = undefined;
397
612
  // The first owner's loss from its start is the output's: no baseline.
613
+ outputOwner = current.owner;
398
614
  setMedia({
399
615
  _tag: "Ready",
400
616
  sessionId: current.source.id,
@@ -412,7 +628,7 @@ export const make = (options) => Effect.gen(function* () {
412
628
  });
413
629
  const route = (request) => Effect.gen(function* () {
414
630
  const values = yield* Effect.suspend(() => Effect.forEach([...slots.values()], (slot) => Effect.map(slot.source.state, (state) => ({
415
- owner: slot.source.id,
631
+ owner: slot.owner,
416
632
  state,
417
633
  accepted: slot.accepted,
418
634
  closed: slot.closed,
@@ -425,27 +641,43 @@ export const make = (options) => Effect.gen(function* () {
425
641
  : current;
426
642
  if (preferred === undefined)
427
643
  return yield* PolicyFailure.refuse("SessionRecovering", "No source is ready");
428
- return yield* resolve(request, values, preferred.source.id, binding);
644
+ return yield* resolve(request, values, preferred.owner, binding);
429
645
  });
430
646
  const sequenceError = (error) => PolicyFailure.sequence(error.sequenceId, error.code);
431
647
  const prepare = (input, expectedSessionId) => Effect.gen(function* () {
648
+ const expectedOwner = expectedSessionId === undefined ? undefined : physicalOwners.get(expectedSessionId);
432
649
  const request = yield* captureRequest(input);
433
650
  const id = `${namespace}:${++submissionSequence}`;
434
651
  const gate = yield* Semaphore.make(1);
652
+ const logical = logicalSubmission(id);
653
+ let selectedOwner;
435
654
  let active;
436
655
  let activeOwner;
437
656
  const submit = gate.withPermit(Effect.gen(function* () {
657
+ const completed = logical.outcome();
658
+ if (completed !== undefined)
659
+ return yield* completed;
438
660
  if (active === undefined) {
439
661
  // A committed submission remains a readable outcome after the handle
440
662
  // closes. Only selecting or committing fresh work needs a live owner.
441
663
  yield* guard("enqueue", Effect.void);
442
664
  const decision = yield* route(request).pipe(commands.withPermits(1));
443
- if (expectedSessionId !== undefined && decision.owner !== expectedSessionId)
665
+ if (expectedSessionId !== undefined && decision.owner !== expectedOwner)
444
666
  return yield* PolicyFailure.refuse("RouteChanged", "The selected source changed before dispatch");
667
+ if (continuous && selectedOwner !== undefined && selectedOwner !== decision.owner) {
668
+ // The selection stays fenced to its incarnation. While that
669
+ // source is live, only the route moved; it has not retired.
670
+ const selected = slots.get(selectedOwner);
671
+ return yield* selected !== undefined && !selected.closed
672
+ ? PolicyFailure.refuse("RouteChanged", "The selected source changed before dispatch")
673
+ : PolicyFailure.refuse("SessionRetired", "Selected source incarnation was retired");
674
+ }
675
+ selectedOwner = decision.owner;
445
676
  const target = slots.get(decision.owner);
446
677
  if (target === undefined)
447
678
  return yield* PolicyFailure.refuse("SessionRetired", "Selected source was retired");
448
679
  const sequence = request.sequence;
680
+ let resultAccounted = false;
449
681
  active = yield* target.source.prepareRouted({ request, position: decision.position }, {
450
682
  commit: (submissionId) => commands.withPermit(guard("enqueue", Effect.gen(function* () {
451
683
  const checked = yield* route(request);
@@ -457,28 +689,43 @@ export const make = (options) => Effect.gen(function* () {
457
689
  return yield* Effect.die(new Error("Source committed before returning its inert submission"));
458
690
  yield* target.register(active, Effect.gen(function* () {
459
691
  if (sequence !== undefined) {
460
- yield* affinity.bind(sequence.id, target.source.id);
692
+ yield* affinity.bind(sequence.id, target.owner);
461
693
  yield* affinity.begin(sequence.id, sequence.memberId ?? submissionId);
462
694
  }
463
- }).pipe(Effect.mapError(sequenceError)));
695
+ }).pipe(Effect.mapError(sequenceError)), (exit) => {
696
+ logical.complete(exit);
697
+ active = undefined;
698
+ activeOwner = undefined;
699
+ });
464
700
  }))),
465
701
  result: (submissionId, result) => Effect.gen(function* () {
466
- target.recordResult(result);
702
+ if (resultAccounted)
703
+ return;
704
+ resultAccounted = true;
705
+ // An accepted identity past the continuous bound is a
706
+ // source-contract failure, as for Started: it is never
707
+ // retained, its member cannot be attributed and the source
708
+ // is replaced.
709
+ const recorded = yield* Effect.result(target.recordResult(result));
467
710
  if (sequence !== undefined) {
468
711
  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);
712
+ const account = Result.isFailure(recorded)
713
+ ? affinity.uncertain(sequence.id, memberId, recorded.failure.message)
714
+ : Result.isSuccess(result)
715
+ ? affinity.accepted(sequence.id, memberId, result.success, sequence.final)
716
+ : result.failure.context.outcome === "unknown"
717
+ ? affinity.uncertain(sequence.id, memberId, result.failure.message)
718
+ : affinity.rejected(sequence.id, memberId, result.failure.message, sequence.final);
474
719
  yield* account.pipe(Effect.catch((cause) => Effect.gen(function* () {
475
720
  target.markIndeterminate();
476
- yield* affinity.retire(target.source.id);
721
+ yield* affinity.retire(target.owner);
477
722
  yield* fail(ReactorError.fromCode("InvalidState", "Committed sequence accounting failed", { detail: cause }));
478
723
  })));
479
724
  }
480
725
  target.finishAccounting();
481
- if (Result.isFailure(result) &&
726
+ if (Result.isFailure(recorded))
727
+ yield* scheduleRecovery(target, recorded.failure, "replace");
728
+ else if (Result.isFailure(result) &&
482
729
  result.failure.context.outcome === "unknown") {
483
730
  yield* scheduleRecovery(target, result.failure, "replace");
484
731
  }
@@ -488,23 +735,26 @@ export const make = (options) => Effect.gen(function* () {
488
735
  }
489
736
  const selected = active;
490
737
  return yield* selected.submit.pipe(Effect.onExit(() => selected.state.pipe(Effect.flatMap((state) => Effect.sync(() => {
491
- if (state._tag === "Completed")
738
+ if (state._tag === "Completed") {
739
+ logical.complete(state.exit);
492
740
  activeOwner?.forgetCompleted(selected);
741
+ active = undefined;
742
+ activeOwner = undefined;
743
+ }
493
744
  if (state._tag === "Prepared" && active === selected) {
494
745
  active = undefined;
495
746
  activeOwner = undefined;
496
747
  }
497
748
  })))));
498
749
  }));
499
- return {
500
- id,
501
- submit,
502
- state: Effect.suspend(() => active === undefined
503
- ? Effect.succeed({ _tag: "Prepared" })
504
- : active.state),
505
- };
750
+ logical.attach(submit, Effect.suspend(() => active === undefined
751
+ ? Effect.succeed({ _tag: "Prepared" })
752
+ : active.state));
753
+ return logical.submission;
506
754
  });
507
755
  const state = Effect.gen(function* () {
756
+ if (!closing && terminal !== undefined && Exit.isFailure(terminal))
757
+ return yield* Effect.failCause(terminal.cause);
508
758
  const live = [...slots.values()].filter((slot) => !slot.closed);
509
759
  const values = (yield* Effect.forEach(live, (slot) => slot.source.state)).map((value, index) => withSessionId(value, live[index].source.id));
510
760
  const primary = values[live.indexOf(current)] ?? emptyState();
@@ -581,11 +831,13 @@ export const make = (options) => Effect.gen(function* () {
581
831
  if (replacement._tag !== "Opening")
582
832
  return;
583
833
  const pending = replacement.fiber;
584
- replacement = { _tag: "Absent" };
834
+ // It stays Opening until joined, so a concurrent close joins it too.
585
835
  yield* Fiber.interrupt(pending);
586
836
  const result = yield* Fiber.await(pending);
837
+ if (replacement._tag === "Opening" && replacement.fiber === pending)
838
+ replacement = { _tag: "Absent" };
587
839
  if (Exit.isSuccess(result))
588
- yield* result.value.close;
840
+ yield* closeSlot(result.value);
589
841
  }));
590
842
  }),
591
843
  setAutoplay: (enabled) => commands.withPermit(guard("set_autoplay", Effect.gen(function* () {
@@ -632,7 +884,7 @@ export const make = (options) => Effect.gen(function* () {
632
884
  };
633
885
  const tick = commands
634
886
  .withPermit(Effect.gen(function* () {
635
- if (closing ||
887
+ if (stopping() ||
636
888
  current === undefined ||
637
889
  current.closed ||
638
890
  current.recovering ||
@@ -642,6 +894,10 @@ export const make = (options) => Effect.gen(function* () {
642
894
  const result = replacement.fiber.pollUnsafe();
643
895
  if (result !== undefined) {
644
896
  replacement = { _tag: "Absent" };
897
+ // Close or the owner's scope stopped this attempt, and close
898
+ // retires whatever it opened: that is no failed setup.
899
+ if (stopping())
900
+ return;
645
901
  if (Exit.isSuccess(result)) {
646
902
  replacement = { _tag: "Ready", slot: result.value };
647
903
  openFailures = 0;
@@ -649,6 +905,13 @@ export const make = (options) => Effect.gen(function* () {
649
905
  yield* announce({ _tag: "Prepared" });
650
906
  }
651
907
  else {
908
+ if (continuous) {
909
+ const error = Cause.findErrorOption(result.cause);
910
+ if (Option.isSome(error) &&
911
+ ReactorError.is(error.value) &&
912
+ error.value.reason._tag === "Overflow")
913
+ return yield* fail(error.value);
914
+ }
652
915
  openFailures++;
653
916
  retryAt = monotonicMillis(clock) + 5000;
654
917
  yield* announce({
@@ -662,7 +925,7 @@ export const make = (options) => Effect.gen(function* () {
662
925
  }
663
926
  }
664
927
  const decision = decideRenewal({
665
- running: !closing && terminalFailure === undefined,
928
+ running: !stopping() && terminal === undefined,
666
929
  now: monotonicMillis(clock),
667
930
  current,
668
931
  replacement: replacement._tag === "Ready" ? replacement.slot.phase : replacement._tag,
@@ -679,7 +942,7 @@ export const make = (options) => Effect.gen(function* () {
679
942
  return;
680
943
  case "Prepare":
681
944
  if (renewing)
682
- replacement = { _tag: "Opening", fiber: yield* acquire.pipe(Effect.forkIn(scope)) };
945
+ replacement = { _tag: "Opening", fiber: yield* forkAcquisition };
683
946
  return;
684
947
  case "InspectHandoff":
685
948
  break;
@@ -696,14 +959,16 @@ export const make = (options) => Effect.gen(function* () {
696
959
  // The provider reports nothing playing: a lost Ended must not hold
697
960
  // the switch until expiry, so the grace also starts here.
698
961
  current.observedIdle();
699
- if (!canHandoff({
962
+ const evidence = handoffEvidence({
700
963
  sequenceOpen: false,
701
964
  currentIdle: isIdle(oldState),
702
965
  replacementReady: nextState.availability === "Ready" && nextState.ready.length > 0,
703
966
  finalClip: current.finalClip(),
704
967
  graceMs: handoffGraceMs,
705
- }))
968
+ });
969
+ if (evidence === undefined)
706
970
  return;
971
+ const handoff = Object.freeze({ ...evidence, replacementSessionId: next.source.id });
707
972
  // Read only for a switch that happens: it can wait on source pressure.
708
973
  const tail = yield* retired(current);
709
974
  const old = current;
@@ -718,7 +983,7 @@ export const make = (options) => Effect.gen(function* () {
718
983
  yield* closeSlot(old);
719
984
  if (!activated)
720
985
  return;
721
- yield* announce({ _tag: "Switched", ...tail });
986
+ yield* announce({ _tag: "Switched", ...tail, handoff });
722
987
  yield* log("Switched prepared sessions at a sequence boundary");
723
988
  }).pipe(Effect.withSpan("reactor.orchestration.renewal.switch", {
724
989
  attributes: {
@@ -730,14 +995,18 @@ export const make = (options) => Effect.gen(function* () {
730
995
  .pipe(Effect.catch(fail));
731
996
  // Spaced polling, not a fixed rate: each tick runs 100 ms after the previous one
732
997
  // 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));
998
+ yield* Effect.forever(Effect.sleep(100).pipe(Effect.andThen(tick))).pipe(supervise, Effect.forkIn(scope));
734
999
  const pressure = Effect.suspend(() => {
735
1000
  if (current === undefined)
736
1001
  return Effect.fail(ReactorError.fromCode("InvalidState", "No active media source"));
737
1002
  const owner = current;
738
1003
  return owner.pressure.pipe(Effect.flatMap((source) => {
739
1004
  const queued = buffer.pressure();
740
- const loss = SourceSlot.addLoss(outputLoss, SourceSlot.subtractLoss(SourceSlot.lossOf(Result.succeed(source)), ownerBaseline));
1005
+ // A retired owner's loss is already in the output's totals, as the
1006
+ // closed continuous owner's is once its retirement completes.
1007
+ const loss = outputOwner === owner.owner
1008
+ ? SourceSlot.addLoss(outputLoss, SourceSlot.subtractLoss(SourceSlot.lossOf(Result.succeed(source)), ownerBaseline))
1009
+ : outputLoss;
741
1010
  if (loss.video === null || loss.audio === null || loss.readers === null)
742
1011
  return Effect.fail(ReactorError.fromCode("InvalidState", "A former media owner's loss totals are unknown"));
743
1012
  return Effect.succeed({
@@ -752,7 +1021,7 @@ export const make = (options) => Effect.gen(function* () {
752
1021
  });
753
1022
  }));
754
1023
  });
755
- return {
1024
+ const facets = {
756
1025
  engine,
757
1026
  media: {
758
1027
  video: buffer.video,
@@ -760,20 +1029,52 @@ export const make = (options) => Effect.gen(function* () {
760
1029
  pressure,
761
1030
  videoFramesPerSecond: current.media.videoFramesPerSecond,
762
1031
  },
763
- mediaState: Effect.sync(() => mediaState),
1032
+ mediaState: Effect.suspend(() => !closing && terminal !== undefined && Exit.isFailure(terminal)
1033
+ ? Effect.failCause(terminal.cause)
1034
+ : Effect.succeed(mediaState)),
764
1035
  observe: (options) => observations.observeWith(Effect.map(state, (engine) => ({ engine, media: mediaState })), options),
765
1036
  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
1037
  sequences: {
769
- get: affinity.get,
770
- snapshots: affinity.snapshots,
1038
+ get: (id) => affinity
1039
+ .get(id)
1040
+ .pipe(Effect.map((entry) => entry === undefined
1041
+ ? undefined
1042
+ : Object.freeze({ ...entry, owner: entry.owner.sessionId }))),
1043
+ snapshots: affinity.snapshots.pipe(Effect.map((entries) => Object.freeze(entries.map((entry) => Object.freeze({ ...entry, owner: entry.owner.sessionId }))))),
771
1044
  seal: affinity.seal,
772
1045
  acknowledgeIndeterminate: affinity.acknowledgeIndeterminate,
773
1046
  release: affinity.release,
774
1047
  },
775
1048
  };
1049
+ return {
1050
+ legacy: {
1051
+ ...facets,
1052
+ close: close.pipe(Effect.andThen(Effect.sync(() => {
1053
+ if (finalReport === undefined)
1054
+ throw new Error("Missing legacy cleanup report");
1055
+ return finalReport;
1056
+ }))),
1057
+ cleanup: Effect.sync(() => Option.fromUndefinedOr(finalReport)),
1058
+ },
1059
+ continuous: {
1060
+ ...facets,
1061
+ close: close.pipe(Effect.andThen(Effect.sync(() => {
1062
+ if (finalSummary === undefined)
1063
+ throw new Error("Missing continuous cleanup summary");
1064
+ return finalSummary;
1065
+ }))),
1066
+ cleanup: Effect.sync(() => Option.fromUndefinedOr(finalSummary)),
1067
+ },
1068
+ };
776
1069
  });
1070
+ /** Legacy renewal retains complete cleanup history and defaults to 64 successful opens. */
1071
+ export const make = (options) => makeOwner(options, false).pipe(Effect.map((owner) => owner.legacy));
1072
+ /**
1073
+ * Bounded continuous renewal. Adapters must meet ContinuousOptions' unique-ID
1074
+ * precondition. Retired anchors no longer retained can refuse as missing rather
1075
+ * than SessionRetired; captured operations remain fenced to their exact incarnation.
1076
+ */
1077
+ export const makeContinuous = (options) => makeOwner(options, true).pipe(Effect.map((owner) => owner.continuous));
777
1078
  /**
778
1079
  * The handle's `Engine`, `Media` and `Handle` services from one orchestration,
779
1080
  * so the three can never come from two paid session chains. Each build of the