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