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.
- package/README.md +16 -2
- package/dist/_internal/queue.d.ts +6 -0
- package/dist/_internal/queue.d.ts.map +1 -0
- package/dist/_internal/queue.js +30 -0
- package/dist/_internal/queue.js.map +1 -0
- package/dist/host.d.ts +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +1 -0
- package/dist/host.js.map +1 -1
- package/dist/observation.d.ts +3 -0
- package/dist/observation.d.ts.map +1 -1
- package/dist/observation.js +9 -4
- package/dist/observation.js.map +1 -1
- package/dist/orchestration/index.d.ts +4 -4
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js +2 -2
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/media-buffer.d.ts +2 -1
- package/dist/orchestration/media-buffer.d.ts.map +1 -1
- package/dist/orchestration/media-buffer.js +5 -4
- package/dist/orchestration/media-buffer.js.map +1 -1
- package/dist/orchestration/renewal-state.d.ts +14 -2
- package/dist/orchestration/renewal-state.d.ts.map +1 -1
- package/dist/orchestration/renewal-state.js +34 -4
- package/dist/orchestration/renewal-state.js.map +1 -1
- package/dist/orchestration/renewal.d.ts +27 -3
- package/dist/orchestration/renewal.d.ts.map +1 -1
- package/dist/orchestration/renewal.js +396 -95
- package/dist/orchestration/renewal.js.map +1 -1
- package/dist/orchestration/retention.d.ts +100 -0
- package/dist/orchestration/retention.d.ts.map +1 -0
- package/dist/orchestration/retention.js +134 -0
- package/dist/orchestration/retention.js.map +1 -0
- package/dist/orchestration/scheduler-policy.d.ts +2 -2
- package/dist/orchestration/scheduler-policy.d.ts.map +1 -1
- package/dist/orchestration/scheduler-policy.js +5 -8
- package/dist/orchestration/scheduler-policy.js.map +1 -1
- package/dist/orchestration/scheduler.d.ts +5 -3
- package/dist/orchestration/scheduler.d.ts.map +1 -1
- package/dist/orchestration/scheduler.js +359 -109
- package/dist/orchestration/scheduler.js.map +1 -1
- package/dist/orchestration/source-slot.d.ts +16 -4
- package/dist/orchestration/source-slot.d.ts.map +1 -1
- package/dist/orchestration/source-slot.js +122 -30
- package/dist/orchestration/source-slot.js.map +1 -1
- package/dist/orchestration/types.d.ts +483 -1
- package/dist/orchestration/types.d.ts.map +1 -1
- package/dist/orchestration/types.js +105 -1
- package/dist/orchestration/types.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +2 -1
- package/dist/session.js.map +1 -1
- package/dist/simulation/_internal/source.d.ts.map +1 -1
- package/dist/simulation/_internal/source.js +5 -4
- package/dist/simulation/_internal/source.js.map +1 -1
- 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 {
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
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 (
|
|
162
|
+
if (terminal !== undefined)
|
|
108
163
|
return Effect.void;
|
|
109
|
-
|
|
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 (
|
|
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.
|
|
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) =>
|
|
144
|
-
|
|
145
|
-
|
|
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 ||
|
|
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
|
-
|
|
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"
|
|
256
|
+
: yield* pending._tag === "Opening"
|
|
257
|
+
? Effect.uninterruptibleMask((restore) => joinAcquisition(pending.fiber, restore))
|
|
258
|
+
: acquire;
|
|
183
259
|
replacement = { _tag: "Absent" };
|
|
184
|
-
if (
|
|
185
|
-
yield* selected
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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) => (
|
|
364
|
+
lost: (cause) => (stopping() ? Effect.void : scheduleRecovery(slot, cause, "reconnect")),
|
|
289
365
|
});
|
|
290
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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(
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
yield* Effect.
|
|
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
|
-
|
|
386
|
-
|
|
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)),
|
|
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 =
|
|
393
|
-
|
|
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.
|
|
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.
|
|
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 !==
|
|
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.
|
|
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
|
-
|
|
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.
|
|
470
|
-
? affinity.
|
|
471
|
-
: result
|
|
472
|
-
? affinity.
|
|
473
|
-
:
|
|
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.
|
|
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(
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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: !
|
|
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*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
770
|
-
|
|
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
|