reactor-effect-client 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +66 -6
  2. package/dist/FetchHttp.js +1 -1
  3. package/dist/FetchHttp.js.map +1 -1
  4. package/dist/PeerFactory.d.ts +13 -2
  5. package/dist/PeerFactory.d.ts.map +1 -1
  6. package/dist/PeerFactory.js.map +1 -1
  7. package/dist/PeerTypes.d.ts +2 -2
  8. package/dist/PeerTypes.d.ts.map +1 -1
  9. package/dist/Sequence.d.ts +18 -14
  10. package/dist/Sequence.d.ts.map +1 -1
  11. package/dist/Sequence.js +117 -122
  12. package/dist/Sequence.js.map +1 -1
  13. package/dist/SessionTypes.d.ts +172 -15
  14. package/dist/SessionTypes.d.ts.map +1 -1
  15. package/dist/SessionTypes.js +18 -1
  16. package/dist/SessionTypes.js.map +1 -1
  17. package/dist/bytes.js +3 -3
  18. package/dist/bytes.js.map +1 -1
  19. package/dist/contract.d.ts +120 -29
  20. package/dist/contract.d.ts.map +1 -1
  21. package/dist/contract.js +105 -73
  22. package/dist/contract.js.map +1 -1
  23. package/dist/coordinator/_internal/client.d.ts +111 -17
  24. package/dist/coordinator/_internal/client.d.ts.map +1 -1
  25. package/dist/coordinator/_internal/client.js +187 -74
  26. package/dist/coordinator/_internal/client.js.map +1 -1
  27. package/dist/coordinator/_internal/recording.d.ts +33 -0
  28. package/dist/coordinator/_internal/recording.d.ts.map +1 -0
  29. package/dist/coordinator/_internal/recording.js +123 -0
  30. package/dist/coordinator/_internal/recording.js.map +1 -0
  31. package/dist/coordinator/_internal/response.d.ts.map +1 -1
  32. package/dist/coordinator/_internal/response.js +5 -7
  33. package/dist/coordinator/_internal/response.js.map +1 -1
  34. package/dist/coordinator/_internal/schemas.d.ts +40 -6
  35. package/dist/coordinator/_internal/schemas.d.ts.map +1 -1
  36. package/dist/coordinator/_internal/schemas.js +94 -64
  37. package/dist/coordinator/_internal/schemas.js.map +1 -1
  38. package/dist/coordinator/index.d.ts +14 -2
  39. package/dist/coordinator/index.d.ts.map +1 -1
  40. package/dist/coordinator/index.js +14 -18
  41. package/dist/coordinator/index.js.map +1 -1
  42. package/dist/correlation.d.ts +15 -0
  43. package/dist/correlation.d.ts.map +1 -1
  44. package/dist/correlation.js +26 -8
  45. package/dist/correlation.js.map +1 -1
  46. package/dist/duration.d.ts +25 -0
  47. package/dist/duration.d.ts.map +1 -0
  48. package/dist/duration.js +47 -0
  49. package/dist/duration.js.map +1 -0
  50. package/dist/errors.d.ts +1264 -49
  51. package/dist/errors.d.ts.map +1 -1
  52. package/dist/errors.js +698 -46
  53. package/dist/errors.js.map +1 -1
  54. package/dist/h3/_internal/client.d.ts +2 -2
  55. package/dist/h3/_internal/client.d.ts.map +1 -1
  56. package/dist/h3/_internal/client.js +280 -146
  57. package/dist/h3/_internal/client.js.map +1 -1
  58. package/dist/h3/_internal/contracts.d.ts.map +1 -1
  59. package/dist/h3/_internal/contracts.js +2 -2
  60. package/dist/h3/_internal/contracts.js.map +1 -1
  61. package/dist/h3/_internal/deployment.d.ts.map +1 -1
  62. package/dist/h3/_internal/deployment.js +5 -4
  63. package/dist/h3/_internal/deployment.js.map +1 -1
  64. package/dist/h3/_internal/evidence.js +1 -1
  65. package/dist/h3/_internal/evidence.js.map +1 -1
  66. package/dist/h3/_internal/operations.d.ts +58 -0
  67. package/dist/h3/_internal/operations.d.ts.map +1 -0
  68. package/dist/h3/_internal/operations.js +268 -0
  69. package/dist/h3/_internal/operations.js.map +1 -0
  70. package/dist/h3/_internal/references.d.ts.map +1 -1
  71. package/dist/h3/_internal/references.js +3 -8
  72. package/dist/h3/_internal/references.js.map +1 -1
  73. package/dist/h3/_internal/request.js +1 -1
  74. package/dist/h3/_internal/request.js.map +1 -1
  75. package/dist/h3/_internal/retained.d.ts +22 -0
  76. package/dist/h3/_internal/retained.d.ts.map +1 -0
  77. package/dist/h3/_internal/retained.js +89 -0
  78. package/dist/h3/_internal/retained.js.map +1 -0
  79. package/dist/h3/_internal/state.d.ts +15 -1
  80. package/dist/h3/_internal/state.d.ts.map +1 -1
  81. package/dist/h3/_internal/state.js +69 -29
  82. package/dist/h3/_internal/state.js.map +1 -1
  83. package/dist/h3/index.d.ts +1 -1
  84. package/dist/h3/index.d.ts.map +1 -1
  85. package/dist/h3/messages.d.ts +118 -109
  86. package/dist/h3/messages.d.ts.map +1 -1
  87. package/dist/h3/messages.js +73 -51
  88. package/dist/h3/messages.js.map +1 -1
  89. package/dist/h3/types.d.ts +109 -13
  90. package/dist/h3/types.d.ts.map +1 -1
  91. package/dist/host.d.ts +6 -6
  92. package/dist/host.d.ts.map +1 -1
  93. package/dist/host.js +4 -5
  94. package/dist/host.js.map +1 -1
  95. package/dist/index.d.ts +11 -5
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +5 -1
  98. package/dist/index.js.map +1 -1
  99. package/dist/json.d.ts +13 -6
  100. package/dist/json.d.ts.map +1 -1
  101. package/dist/json.js +34 -14
  102. package/dist/json.js.map +1 -1
  103. package/dist/media-stream.d.ts.map +1 -1
  104. package/dist/media-stream.js +9 -11
  105. package/dist/media-stream.js.map +1 -1
  106. package/dist/observation.d.ts +9 -0
  107. package/dist/observation.d.ts.map +1 -1
  108. package/dist/observation.js +20 -18
  109. package/dist/observation.js.map +1 -1
  110. package/dist/orchestration/h3-source.d.ts +32 -4
  111. package/dist/orchestration/h3-source.d.ts.map +1 -1
  112. package/dist/orchestration/h3-source.js +47 -14
  113. package/dist/orchestration/h3-source.js.map +1 -1
  114. package/dist/orchestration/index.d.ts +9 -6
  115. package/dist/orchestration/index.d.ts.map +1 -1
  116. package/dist/orchestration/index.js +5 -3
  117. package/dist/orchestration/index.js.map +1 -1
  118. package/dist/orchestration/media-buffer.d.ts +12 -4
  119. package/dist/orchestration/media-buffer.d.ts.map +1 -1
  120. package/dist/orchestration/media-buffer.js +31 -11
  121. package/dist/orchestration/media-buffer.js.map +1 -1
  122. package/dist/orchestration/open-h3.d.ts +65 -0
  123. package/dist/orchestration/open-h3.d.ts.map +1 -0
  124. package/dist/orchestration/open-h3.js +65 -0
  125. package/dist/orchestration/open-h3.js.map +1 -0
  126. package/dist/orchestration/references.d.ts +5 -1
  127. package/dist/orchestration/references.d.ts.map +1 -1
  128. package/dist/orchestration/references.js +37 -34
  129. package/dist/orchestration/references.js.map +1 -1
  130. package/dist/orchestration/renewal-state.js +1 -1
  131. package/dist/orchestration/renewal-state.js.map +1 -1
  132. package/dist/orchestration/renewal.d.ts +64 -64
  133. package/dist/orchestration/renewal.d.ts.map +1 -1
  134. package/dist/orchestration/renewal.js +149 -88
  135. package/dist/orchestration/renewal.js.map +1 -1
  136. package/dist/orchestration/request.d.ts +11 -12
  137. package/dist/orchestration/request.d.ts.map +1 -1
  138. package/dist/orchestration/request.js +63 -66
  139. package/dist/orchestration/request.js.map +1 -1
  140. package/dist/orchestration/routing.d.ts.map +1 -1
  141. package/dist/orchestration/routing.js +17 -14
  142. package/dist/orchestration/routing.js.map +1 -1
  143. package/dist/orchestration/source-slot.d.ts +15 -5
  144. package/dist/orchestration/source-slot.d.ts.map +1 -1
  145. package/dist/orchestration/source-slot.js +40 -21
  146. package/dist/orchestration/source-slot.js.map +1 -1
  147. package/dist/orchestration/types.d.ts +594 -39
  148. package/dist/orchestration/types.d.ts.map +1 -1
  149. package/dist/orchestration/types.js +20 -0
  150. package/dist/orchestration/types.js.map +1 -1
  151. package/dist/protobuf.js +2 -2
  152. package/dist/protobuf.js.map +1 -1
  153. package/dist/session/_internal/acquire.d.ts +4 -6
  154. package/dist/session/_internal/acquire.d.ts.map +1 -1
  155. package/dist/session/_internal/acquire.js +40 -50
  156. package/dist/session/_internal/acquire.js.map +1 -1
  157. package/dist/session/_internal/cleanup.d.ts +2 -1
  158. package/dist/session/_internal/cleanup.d.ts.map +1 -1
  159. package/dist/session/_internal/cleanup.js +23 -15
  160. package/dist/session/_internal/cleanup.js.map +1 -1
  161. package/dist/session/_internal/lifecycle.d.ts +5 -0
  162. package/dist/session/_internal/lifecycle.d.ts.map +1 -1
  163. package/dist/session/_internal/lifecycle.js +16 -6
  164. package/dist/session/_internal/lifecycle.js.map +1 -1
  165. package/dist/session/_internal/remote.d.ts.map +1 -1
  166. package/dist/session/_internal/remote.js +16 -7
  167. package/dist/session/_internal/remote.js.map +1 -1
  168. package/dist/session/_internal/uploads.d.ts +4 -1
  169. package/dist/session/_internal/uploads.d.ts.map +1 -1
  170. package/dist/session/_internal/uploads.js +25 -27
  171. package/dist/session/_internal/uploads.js.map +1 -1
  172. package/dist/session/commands.d.ts +1 -21
  173. package/dist/session/commands.d.ts.map +1 -1
  174. package/dist/session/commands.js +1 -12
  175. package/dist/session/commands.js.map +1 -1
  176. package/dist/session/files.d.ts +10 -2
  177. package/dist/session/files.d.ts.map +1 -1
  178. package/dist/session/files.js +11 -16
  179. package/dist/session/files.js.map +1 -1
  180. package/dist/session/index.d.ts +6 -6
  181. package/dist/session/index.d.ts.map +1 -1
  182. package/dist/session/index.js.map +1 -1
  183. package/dist/session/media.d.ts +53 -6
  184. package/dist/session/media.d.ts.map +1 -1
  185. package/dist/session/recorder.d.ts +24 -0
  186. package/dist/session/recorder.d.ts.map +1 -0
  187. package/dist/session/recorder.js +19 -0
  188. package/dist/session/recorder.js.map +1 -0
  189. package/dist/session.d.ts +5 -4
  190. package/dist/session.d.ts.map +1 -1
  191. package/dist/session.js +213 -110
  192. package/dist/session.js.map +1 -1
  193. package/dist/simulation/_internal/source.d.ts.map +1 -1
  194. package/dist/simulation/_internal/source.js +57 -48
  195. package/dist/simulation/_internal/source.js.map +1 -1
  196. package/dist/simulation/index.d.ts +4 -5
  197. package/dist/simulation/index.d.ts.map +1 -1
  198. package/dist/simulation/index.js +3 -4
  199. package/dist/simulation/index.js.map +1 -1
  200. package/dist/simulation/types.d.ts +16 -2
  201. package/dist/simulation/types.d.ts.map +1 -1
  202. package/dist/stats.d.ts +19 -0
  203. package/dist/stats.d.ts.map +1 -1
  204. package/dist/stats.js +79 -8
  205. package/dist/stats.js.map +1 -1
  206. package/package.json +5 -4
  207. package/notices/owned-webrtc/Effect-MIT.txt +0 -21
  208. package/notices/owned-webrtc/IETF.txt +0 -43
  209. package/notices/owned-webrtc/LICENSE +0 -21
  210. package/notices/owned-webrtc/UPSTREAM.md +0 -20
  211. package/notices/owned-webrtc/VP8-ADAPTATION.md +0 -32
  212. package/notices/owned-webrtc/VP8-AUTHORS.txt +0 -42
  213. package/notices/owned-webrtc/VP8-LICENSE.txt +0 -25
  214. package/notices/owned-webrtc/VP8-PATENTS.txt +0 -21
  215. package/notices/owned-webrtc/Werift-MIT.txt +0 -21
  216. package/notices/owned-webrtc/libvpx-v1.12.0-LICENSE.txt +0 -28
  217. package/notices/owned-webrtc/libvpx-v1.12.0-PATENTS.txt +0 -22
  218. package/notices/owned-webrtc/rfc3711.txt +0 -3139
  219. package/notices/owned-webrtc/str0m-MIT.txt +0 -7
@@ -3,25 +3,31 @@ import * as Clock from "effect/Clock";
3
3
  import * as Context from "effect/Context";
4
4
  import * as Crypto from "effect/Crypto";
5
5
  import * as Deferred from "effect/Deferred";
6
+ import * as Duration from "effect/Duration";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as Exit from "effect/Exit";
8
9
  import * as Fiber from "effect/Fiber";
10
+ import * as Layer from "effect/Layer";
9
11
  import * as Option from "effect/Option";
10
12
  import * as Result from "effect/Result";
11
13
  import * as Scope from "effect/Scope";
12
14
  import * as Semaphore from "effect/Semaphore";
13
- import { ReactorError, errorOf } from "../errors.js";
15
+ import * as Stream from "effect/Stream";
16
+ import { duration } from "../duration.js";
17
+ import { AcquisitionFailure, CommandFailure, ReactorError, errorOf, parsed, positiveLimit, } from "../errors.js";
14
18
  import { Observations } from "../observation.js";
19
+ import { noAcquisition } from "../session/_internal/acquire.js";
15
20
  import * as Sequence from "../Sequence.js";
16
21
  import * as Submission from "../Submission.js";
17
- import { CommandFailure } from "../session/commands.js";
18
- import { AcquisitionFailure } from "../session/index.js";
19
22
  import * as MediaBuffer from "./media-buffer.js";
20
23
  import * as SourceSlot from "./source-slot.js";
21
24
  import { canHandoff, decideRenewal } from "./renewal-state.js";
22
25
  import { PolicyFailure, captureRequest } from "./request.js";
23
26
  import { emptyState, isIdle } from "./queries.js";
24
27
  import { activeIds, generation, resolve } from "./routing.js";
28
+ import { handleContext } from "./types.js";
29
+ const engineOnly = (event) => event._tag === "Engine" ? Result.succeed(event.event) : Result.fail(event);
30
+ const renewalsOnly = (event) => event._tag === "Renewal" ? Result.succeed(event.event) : Result.fail(event);
25
31
  /**
26
32
  * Explicit application policy for renewal, sequence affinity and continuous
27
33
  * recovering media. A physical source still owns each connection generation.
@@ -37,24 +43,22 @@ export const make = (options) => Effect.gen(function* () {
37
43
  const commands = yield* Semaphore.make(1);
38
44
  const closeGate = yield* Semaphore.make(1);
39
45
  const affinity = yield* Sequence.makeAffinity().pipe(Effect.mapError((cause) => errorOf(cause, "InvalidInput")));
40
- const events = new Observations();
41
- const buffer = yield* MediaBuffer.make;
46
+ // One ordered stream: engine events, renewals and media transitions.
47
+ const observations = new Observations();
48
+ const buffer = yield* MediaBuffer.make(yield* parsed(() => ({
49
+ videoFrames: positiveLimit(options.maxQueuedVideoFrames ?? MediaBuffer.defaultLimits.videoFrames, "orchestration maxQueuedVideoFrames"),
50
+ audioSamples: positiveLimit(options.maxQueuedAudioSamples ?? MediaBuffer.defaultLimits.audioSamples, "orchestration maxQueuedAudioSamples"),
51
+ })));
42
52
  const fatal = yield* Deferred.make();
43
53
  const slots = new Map();
44
54
  const cleanups = [];
45
55
  const seenCleanups = new Set();
46
56
  const maxSessions = options.maxSessions ?? 64;
47
- const reconnectTimeout = options.reconnectTimeoutMs ?? 10_000;
48
- const leadSeconds = options.leadSeconds ?? 30;
49
- if (!Number.isSafeInteger(maxSessions) ||
50
- maxSessions < 1 ||
51
- maxSessions > 4096 ||
52
- !Number.isFinite(reconnectTimeout) ||
53
- reconnectTimeout <= 0 ||
54
- !Number.isFinite(leadSeconds) ||
55
- leadSeconds < 0) {
56
- return yield* Effect.fail(new ReactorError("InvalidInput", "Invalid orchestration bounds"));
57
+ if (!Number.isSafeInteger(maxSessions) || maxSessions < 1 || maxSessions > 4096) {
58
+ return yield* ReactorError.fromCode("InvalidInput", "Invalid orchestration bounds");
57
59
  }
60
+ const reconnectTimeout = Duration.toMillis(yield* parsed(() => duration(options.reconnectTimeout ?? "10 seconds", "orchestration reconnectTimeout")));
61
+ const leadSeconds = Duration.toSeconds(yield* parsed(() => duration(options.lead ?? "30 seconds", "orchestration lead", { allowZero: true })));
58
62
  let current;
59
63
  let replacement = { _tag: "Absent" };
60
64
  let opened = 0;
@@ -66,26 +70,35 @@ export const make = (options) => Effect.gen(function* () {
66
70
  let mediaState = { _tag: "Closed" };
67
71
  let terminalFailure;
68
72
  let submissionSequence = 0n;
69
- const observe = (event) => options.onRenewal?.(event) ?? Effect.void;
70
- const log = (message) => Effect.sync(() => options.log?.(message));
71
- const emit = (event) => events.emit(event, 256);
73
+ const announce = (event) => Effect.sync(() => observations.emit({ _tag: "Renewal", event }, 256));
74
+ const log = (message) => Effect.logDebug(message).pipe(Effect.annotateLogs({ module: "reactor.orchestration" }));
75
+ const emit = (event) => observations.emit({ _tag: "Engine", event }, 256);
76
+ const setMedia = (state) => {
77
+ mediaState = state;
78
+ observations.emit({ _tag: "Media", state }, 256);
79
+ };
80
+ const onRenewal = options.onRenewal;
81
+ // Subscribed before the first source opens, so the reader sees its Opened.
82
+ const renewalReader = onRenewal === undefined
83
+ ? undefined
84
+ : 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))));
72
85
  const fail = (cause) => Effect.suspend(() => {
73
86
  if (terminalFailure !== undefined)
74
87
  return Effect.void;
75
88
  terminalFailure = cause;
76
89
  // Publish the failed state and queues before waking a failure waiter.
77
- mediaState = { _tag: "Failed", cause };
90
+ setMedia({ _tag: "Failed", cause });
78
91
  emit({ _tag: "SessionFailed", failure: cause });
79
92
  buffer.fail(cause);
80
93
  Deferred.doneUnsafe(fatal, Effect.succeed(cause));
81
- return observe({ _tag: "Failed", reason: cause.message });
94
+ return announce({ _tag: "Failed", reason: cause.message });
82
95
  });
83
96
  // Every asynchronous activation uses this final fence. Closed/failed state
84
97
  // cannot be replaced by a reconnect or autoplay operation that finished late.
85
98
  const publishReady = (slot) => {
86
99
  if (closing || slot.closed || slot !== current || terminalFailure !== undefined)
87
100
  return false;
88
- mediaState = { _tag: "Ready", sessionId: slot.source.id, generation: slot.media.generation };
101
+ setMedia({ _tag: "Ready", sessionId: slot.source.id, generation: slot.media.generation });
89
102
  return true;
90
103
  };
91
104
  const recordCleanup = (cleanup) => {
@@ -97,6 +110,19 @@ export const make = (options) => Effect.gen(function* () {
97
110
  const openSequences = (slot) => affinity.snapshots.pipe(Effect.map((entries) => entries.some((entry) => entry.owner === slot.source.id && entry.status === "open")));
98
111
  const expired = (slot) => slot.expired();
99
112
  const recoveryBudget = (slot) => slot.recoveryBudget();
113
+ // The logical output's loss: every former owner's loss while it fed the
114
+ // output, plus the current owner's since it became the owner. A prepared
115
+ // replacement's loss before it fed the output is not the output's.
116
+ let outputLoss = SourceSlot.noLoss;
117
+ let ownerBaseline = SourceSlot.noLoss;
118
+ const ownerLoss = (slot) => Effect.result(slot.pressure.pipe(Effect.timeout(recoveryBudget(slot)))).pipe(Effect.map(SourceSlot.lossOf));
119
+ const activateOwner = (slot) => ownerLoss(slot).pipe(Effect.map((loss) => {
120
+ ownerBaseline = loss;
121
+ }));
122
+ const retireOwner = (slot) => ownerLoss(slot).pipe(Effect.map((loss) => {
123
+ outputLoss = SourceSlot.addLoss(outputLoss, SourceSlot.subtractLoss(loss, ownerBaseline));
124
+ ownerBaseline = SourceSlot.noLoss;
125
+ }));
100
126
  const retired = (slot) => slot.retired(buffer.forwarded);
101
127
  const closeSlot = (slot) => slot.close;
102
128
  const replace = (slot, cause) => Effect.gen(function* () {
@@ -112,8 +138,10 @@ export const make = (options) => Effect.gen(function* () {
112
138
  reason: `Session lost: ${cause.message}`,
113
139
  sessionId: slot.source.id,
114
140
  });
141
+ if (slot === current)
142
+ yield* retireOwner(slot);
115
143
  yield* closeSlot(slot);
116
- yield* observe({
144
+ yield* announce({
117
145
  _tag: "Replaced",
118
146
  reason: cause.message,
119
147
  lostClips: lost.length,
@@ -133,17 +161,18 @@ export const make = (options) => Effect.gen(function* () {
133
161
  return;
134
162
  }
135
163
  current = selected;
164
+ yield* activateOwner(selected);
136
165
  yield* current.source.setAutoplay(autoplay);
137
166
  if (!publishReady(current))
138
167
  return;
139
168
  yield* log("Replaced lost session; local queue resumes on the new connection");
140
- }).pipe(Effect.catch((failure) => fail(errorOf(failure, "Disconnected"))));
169
+ }).pipe(Effect.withSpan("reactor.orchestration.renewal.replace", { attributes: { "reactor.session.id": slot.source.id } }, { captureStackTrace: false }), Effect.catch(fail));
141
170
  const recover = (slot, cause) => Effect.gen(function* () {
142
171
  if (slot.closed || closing || terminalFailure !== undefined)
143
172
  return;
144
173
  if (slot === current)
145
- mediaState = { _tag: "Recovering", sessionId: slot.source.id, cause };
146
- yield* observe({ _tag: "Recovering", sessionId: slot.source.id, reason: cause.message });
174
+ setMedia({ _tag: "Recovering", sessionId: slot.source.id, cause });
175
+ yield* announce({ _tag: "Recovering", sessionId: slot.source.id, reason: cause.message });
147
176
  yield* slot.joinCommitted(recoveryBudget(slot));
148
177
  if (slot.closed || closing || terminalFailure !== undefined)
149
178
  return;
@@ -155,7 +184,7 @@ export const make = (options) => Effect.gen(function* () {
155
184
  // Read the retiring receiver before reconnect makes its counters unavailable.
156
185
  const previousPressure = yield* Effect.result(slot.media.pressure.pipe(Effect.timeout(recoveryBudget(slot))));
157
186
  if (expired(slot)) {
158
- yield* replace(slot, new ReactorError("TerminalSession", "Source expired during recovery", {
187
+ yield* replace(slot, ReactorError.fromCode("TerminalSession", "Source expired during recovery", {
159
188
  operation: "renewal",
160
189
  }));
161
190
  return;
@@ -165,7 +194,7 @@ export const make = (options) => Effect.gen(function* () {
165
194
  return;
166
195
  const connected = yield* Effect.result(slot.source.reconnect.pipe(Effect.timeoutOrElse({
167
196
  duration: recoveryBudget(slot),
168
- orElse: () => Effect.fail(new ReactorError("Disconnected", "Explicit source reconnect timed out")),
197
+ orElse: () => Effect.fail(ReactorError.fromCode("Disconnected", "Explicit source reconnect timed out")),
169
198
  })));
170
199
  if (slot.closed || closing || terminalFailure !== undefined)
171
200
  return;
@@ -203,18 +232,18 @@ export const make = (options) => Effect.gen(function* () {
203
232
  }
204
233
  if (slot === current && !publishReady(slot))
205
234
  return;
206
- yield* observe({
235
+ yield* announce({
207
236
  _tag: "Reconnected",
208
237
  sessionId: slot.source.id,
209
238
  generation: slot.media.generation,
210
239
  });
211
- });
240
+ }).pipe(Effect.withSpan("reactor.orchestration.renewal.recover", { attributes: { "reactor.session.id": slot.source.id } }, { captureStackTrace: false }));
212
241
  const scheduleRecovery = (slot, cause, mode) => Effect.gen(function* () {
213
242
  if (slot.closed || closing || terminalFailure !== undefined)
214
243
  return;
215
244
  if (!slot.beginRecovery(mode))
216
245
  return;
217
- yield* recover(slot, cause).pipe(commands.withPermit, Effect.forkIn(scope));
246
+ yield* recover(slot, cause).pipe(commands.withPermits(1), Effect.forkIn(scope));
218
247
  });
219
248
  const startMedia = (slot) => slot.startMedia({
220
249
  video: (frame) => Effect.suspend(() => {
@@ -234,7 +263,7 @@ export const make = (options) => Effect.gen(function* () {
234
263
  });
235
264
  const acquire = Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
236
265
  if (opened >= maxSessions)
237
- return yield* Effect.fail(new ReactorError("Overflow", "Orchestration session and cleanup-history bound reached"));
266
+ return yield* ReactorError.fromCode("Overflow", "Orchestration session and cleanup-history bound reached");
238
267
  const owned = yield* Scope.make();
239
268
  let acquired;
240
269
  let acquiredSource;
@@ -243,22 +272,23 @@ export const make = (options) => Effect.gen(function* () {
243
272
  // Dependency provision order cannot move resources to the caller's owner.
244
273
  const value = yield* options.open.pipe(Effect.provideContext(Context.add(context, Scope.Scope, owned)), Effect.timeoutOrElse({
245
274
  duration: "30 seconds",
246
- orElse: () => Effect.fail(new ReactorError("InvalidState", "Source acquisition timed out")),
247
- }), Effect.mapError((cause) => errorOf(cause, "InvalidState")));
275
+ orElse: () => Effect.fail(ReactorError.fromCode("InvalidState", "Source acquisition timed out")),
276
+ }));
248
277
  acquiredSource = value.source;
249
- if (!(value.maxSeconds > 0) ||
250
- (value.maxSeconds !== Infinity && !Number.isFinite(value.maxSeconds))) {
251
- return yield* Effect.fail(new ReactorError("InvalidInput", "Source lifetime must be positive or infinite"));
252
- }
278
+ const lifetime = yield* parsed(() => duration(value.lifetime, "source lifetime", { allowInfinite: true }));
279
+ // A lead as long as the lifetime would prepare a replacement at once,
280
+ // after every open, bounded only by maxSessions.
281
+ if (Duration.isFinite(lifetime) && leadSeconds >= Duration.toSeconds(lifetime))
282
+ return yield* ReactorError.fromCode("InvalidInput", "renewal lead must be shorter than the source lifetime", { operation: "renewal", outcome: "not-submitted" });
253
283
  if (slots.has(value.source.id))
254
- return yield* Effect.fail(new ReactorError("InvalidInput", "Orchestration sources must have distinct session identities"));
284
+ return yield* ReactorError.fromCode("InvalidInput", "Orchestration sources must have distinct session identities");
255
285
  const media = yield* value.source.media;
256
286
  const slot = yield* SourceSlot.make({
257
287
  source: value.source,
258
288
  scope: owned,
259
289
  media,
260
290
  openedAt: clock.currentTimeMillisUnsafe(),
261
- maxSeconds: value.maxSeconds,
291
+ maxSeconds: Duration.toSeconds(lifetime),
262
292
  cleanupBudgetMs: reconnectTimeout,
263
293
  recordCleanup,
264
294
  retireSequences: affinity.retire(value.source.id).pipe(Effect.asVoid),
@@ -275,10 +305,10 @@ export const make = (options) => Effect.gen(function* () {
275
305
  return Effect.void;
276
306
  }), (cause) => scheduleRecovery(slot, cause, "replace"));
277
307
  yield* startMedia(slot);
278
- yield* observe({
308
+ yield* announce({
279
309
  _tag: "Opened",
280
310
  sessionId: slot.source.id,
281
- maxSeconds: slot.maxSeconds,
311
+ lifetime: Duration.seconds(slot.maxSeconds),
282
312
  });
283
313
  return slot;
284
314
  })).pipe(Effect.onExit((exit) => Exit.isSuccess(exit)
@@ -292,12 +322,12 @@ export const make = (options) => Effect.gen(function* () {
292
322
  yield* Scope.close(owned, exit);
293
323
  if (Exit.isFailure(exit)) {
294
324
  const failure = Cause.findErrorOption(exit.cause);
295
- if (Option.isSome(failure) && failure.value instanceof AcquisitionFailure)
325
+ if (Option.isSome(failure) && AcquisitionFailure.is(failure.value))
296
326
  recordCleanup({ lease: failure.value.cleanup, policy: [] });
297
327
  }
298
328
  }
299
329
  })));
300
- }));
330
+ })).pipe(Effect.tap((slot) => Effect.annotateCurrentSpan("reactor.session.id", slot.source.id)), Effect.withSpan("reactor.orchestration.renewal.open", {}, { captureStackTrace: false }));
301
331
  const close = closeGate.withPermit(Effect.uninterruptible(Effect.gen(function* () {
302
332
  if (finalReport !== undefined)
303
333
  return finalReport;
@@ -306,29 +336,37 @@ export const make = (options) => Effect.gen(function* () {
306
336
  yield* Fiber.interrupt(replacement.fiber);
307
337
  yield* Effect.forEach([...slots.values()], closeSlot, { discard: true });
308
338
  buffer.end();
309
- events.end();
310
- mediaState = { _tag: "Closed" };
339
+ setMedia({ _tag: "Closed" });
340
+ observations.end();
341
+ // The reader delivers what was announced before close, within a bound.
342
+ if (renewalReader !== undefined)
343
+ yield* Fiber.await(renewalReader).pipe(Effect.interruptible, Effect.timeout("1 second"), Effect.ignore);
311
344
  finalReport = Object.freeze({ sessions: Object.freeze([...cleanups]) });
312
345
  return finalReport;
313
346
  })));
314
347
  yield* Effect.addFinalizer(() => close);
315
348
  current = yield* acquire.pipe(Effect.tap((slot) => slot.source.setAutoplay(true)), Effect.catch((cause) => close.pipe(Effect.flatMap((report) => {
349
+ // An AcquisitionFailure from `open` already carries the lease it
350
+ // recorded, by reference; any later failure takes the recorded lease.
316
351
  const lease = report.sessions[0]?.lease;
317
- return Effect.fail(lease === undefined ? cause : new AcquisitionFailure(cause, lease));
352
+ return Effect.fail(AcquisitionFailure.is(cause) || (lease === undefined && ReactorError.is(cause))
353
+ ? cause
354
+ : AcquisitionFailure.from(cause, lease ?? noAcquisition));
318
355
  }))));
319
- mediaState = {
356
+ // The first owner's loss from its start is the output's: no baseline.
357
+ setMedia({
320
358
  _tag: "Ready",
321
359
  sessionId: current.source.id,
322
360
  generation: current.media.generation,
323
- };
361
+ });
324
362
  const guard = (operation, effect) => Effect.gen(function* () {
325
363
  if (closing)
326
- return yield* Effect.fail(new PolicyFailure("session_closed", "Orchestration is closed", operation));
364
+ return yield* PolicyFailure.refuse("SessionClosed", "Orchestration is closed", operation);
327
365
  if (yield* Deferred.isDone(fatal))
328
- return yield* Effect.fail(new CommandFailure(yield* Deferred.await(fatal), {
366
+ return yield* CommandFailure.from(yield* Deferred.await(fatal), {
329
367
  operation,
330
368
  outcome: "not-submitted",
331
- }));
369
+ });
332
370
  return yield* effect;
333
371
  });
334
372
  const route = (request) => Effect.gen(function* () {
@@ -345,10 +383,10 @@ export const make = (options) => Effect.gen(function* () {
345
383
  ? next
346
384
  : current;
347
385
  if (preferred === undefined)
348
- return yield* Effect.fail(new PolicyFailure("session_recovering", "No source is ready"));
386
+ return yield* PolicyFailure.refuse("SessionRecovering", "No source is ready");
349
387
  return yield* resolve(request, values, preferred.source.id, binding);
350
388
  });
351
- const sequenceError = (error) => new PolicyFailure(`sequence_${error.reason}`, `Sequence ${error.sequenceId}: ${error.reason}`);
389
+ const sequenceError = (error) => PolicyFailure.sequence(error.sequenceId, error.code);
352
390
  const prepare = (input) => Effect.gen(function* () {
353
391
  const request = yield* captureRequest(input);
354
392
  const id = `${namespace}:${++submissionSequence}`;
@@ -360,30 +398,26 @@ export const make = (options) => Effect.gen(function* () {
360
398
  // A committed submission remains a readable outcome after the handle
361
399
  // closes. Only selecting or committing fresh work needs a live owner.
362
400
  yield* guard("enqueue", Effect.void);
363
- const decision = yield* route(request).pipe(commands.withPermit);
401
+ const decision = yield* route(request).pipe(commands.withPermits(1));
364
402
  const target = slots.get(decision.owner);
365
403
  if (target === undefined)
366
- return yield* Effect.fail(new PolicyFailure("session_retired", "Selected source was retired"));
404
+ return yield* PolicyFailure.refuse("SessionRetired", "Selected source was retired");
367
405
  const sequence = request.sequence;
368
406
  active = yield* target.source.prepareRouted({ request, position: decision.position }, {
369
407
  commit: (submissionId) => commands.withPermit(guard("enqueue", Effect.gen(function* () {
370
408
  const checked = yield* route(request);
371
409
  if (checked.owner !== decision.owner ||
372
410
  checked.position !== decision.position) {
373
- return yield* Effect.fail(new PolicyFailure("route_changed", "Request ownership or insertion position changed during preparation"));
411
+ return yield* PolicyFailure.refuse("RouteChanged", "Request ownership or insertion position changed during preparation");
374
412
  }
375
413
  if (active === undefined)
376
414
  return yield* Effect.die(new Error("Source committed before returning its inert submission"));
377
415
  yield* target.register(active, Effect.gen(function* () {
378
416
  if (sequence !== undefined) {
379
- yield* affinity
380
- .bind(sequence.id, target.source.id)
381
- .pipe(Effect.mapError(sequenceError));
382
- yield* affinity
383
- .begin(sequence.id, sequence.memberId ?? submissionId)
384
- .pipe(Effect.mapError(sequenceError));
417
+ yield* affinity.bind(sequence.id, target.source.id);
418
+ yield* affinity.begin(sequence.id, sequence.memberId ?? submissionId);
385
419
  }
386
- }));
420
+ }).pipe(Effect.mapError(sequenceError)));
387
421
  }))),
388
422
  result: (submissionId, result) => Effect.gen(function* () {
389
423
  target.recordResult(result);
@@ -397,7 +431,7 @@ export const make = (options) => Effect.gen(function* () {
397
431
  yield* account.pipe(Effect.catch((cause) => Effect.gen(function* () {
398
432
  target.markIndeterminate();
399
433
  yield* affinity.retire(target.source.id);
400
- yield* fail(new ReactorError("InvalidState", "Committed sequence accounting failed", { detail: cause }));
434
+ yield* fail(ReactorError.fromCode("InvalidState", "Committed sequence accounting failed", { detail: cause }));
401
435
  })));
402
436
  }
403
437
  target.finishAccounting();
@@ -458,16 +492,20 @@ export const make = (options) => Effect.gen(function* () {
458
492
  const matches = yield* Effect.forEach([...slots.values()].filter((slot) => !slot.closed), (slot) => slot.source.state.pipe(Effect.map((state) => ({ slot, state }))));
459
493
  const found = matches.filter(({ state }) => activeIds(state).includes(id));
460
494
  if (found.length !== 1)
461
- return yield* Effect.fail(new PolicyFailure(found.length === 0 ? "not_found" : "owner_conflict", "Clip has no unique active owning session", operation));
495
+ return yield* PolicyFailure.refuse(found.length === 0 ? "NotFound" : "OwnerConflict", "Clip has no unique active owning session", operation);
462
496
  if (found[0].slot.recovering)
463
- return yield* Effect.fail(new PolicyFailure("session_recovering", "Owning session is recovering", operation));
497
+ return yield* PolicyFailure.refuse("SessionRecovering", "Owning session is recovering", operation);
464
498
  return found[0];
465
499
  });
466
500
  const engine = {
467
501
  prepare,
468
502
  enqueue: (request) => prepare(request).pipe(Effect.flatMap((submission) => submission.submit)),
469
503
  state,
470
- events: events.stream(),
504
+ events: Stream.filterMap(observations.stream(), engineOnly),
505
+ observe: (options) => observations.observeWith(state, options).pipe(Effect.map(({ initial, events }) => ({
506
+ initial,
507
+ events: Stream.filterMap(events, engineOnly),
508
+ }))),
471
509
  failure: Deferred.await(fatal),
472
510
  setAutoplay: (enabled) => commands.withPermit(guard("set_autoplay", Effect.gen(function* () {
473
511
  autoplay = enabled;
@@ -485,11 +523,11 @@ export const make = (options) => Effect.gen(function* () {
485
523
  remove: (id) => commands.withPermit(guard("pop", owner(id, "pop").pipe(Effect.flatMap(({ slot }) => slot.source.remove(id))))),
486
524
  move: (id, position, queue) => commands.withPermit(guard("move", Effect.gen(function* () {
487
525
  if (!Number.isSafeInteger(position) || position < 0)
488
- return yield* Effect.fail(new PolicyFailure("invalid_request", "Move position must be a nonnegative integer", "move"));
526
+ return yield* PolicyFailure.refuse("InvalidRequest", "Move position must be a nonnegative integer", "move");
489
527
  const selected = yield* owner(id, "move");
490
528
  const own = queue === "generation" ? generation(selected.state) : selected.state.ready;
491
529
  if (!own.some((clip) => clip.clipId === id))
492
- return yield* Effect.fail(new PolicyFailure("queue_changed", "Clip is no longer in the selected application queue", "move"));
530
+ return yield* PolicyFailure.refuse("QueueChanged", "Clip is no longer in the selected application queue", "move");
493
531
  let preceding = 0;
494
532
  for (const slot of slots.values()) {
495
533
  if (slot === selected.slot)
@@ -503,7 +541,7 @@ export const make = (options) => Effect.gen(function* () {
503
541
  }))),
504
542
  setCanvas: (canvas) => commands.withPermit(guard("set_canvas", Effect.gen(function* () {
505
543
  if (!isIdle(yield* state))
506
- return yield* Effect.fail(new PolicyFailure("busy", "Canvas can only change while every source is idle", "set_canvas"));
544
+ return yield* PolicyFailure.refuse("Busy", "Canvas can only change while every source is idle", "set_canvas");
507
545
  for (const slot of slots.values())
508
546
  if (!slot.closed)
509
547
  yield* slot.source.setCanvas(canvas);
@@ -525,18 +563,18 @@ export const make = (options) => Effect.gen(function* () {
525
563
  replacement = { _tag: "Ready", slot: result.value };
526
564
  openFailures = 0;
527
565
  yield* log("Prepared the next session for renewal");
528
- yield* observe({ _tag: "Prepared" });
566
+ yield* announce({ _tag: "Prepared" });
529
567
  }
530
568
  else {
531
569
  openFailures++;
532
570
  retryAt = clock.currentTimeMillisUnsafe() + 5000;
533
- yield* observe({
571
+ yield* announce({
534
572
  _tag: "SetupFailed",
535
573
  reason: "Could not prepare the next source",
536
574
  consecutive: openFailures,
537
575
  });
538
576
  if (openFailures >= 3)
539
- return yield* fail(new ReactorError("Disconnected", "Repeated source renewal acquisition failed"));
577
+ return yield* fail(ReactorError.fromCode("Disconnected", "Repeated source renewal acquisition failed"));
540
578
  }
541
579
  }
542
580
  }
@@ -552,7 +590,7 @@ export const make = (options) => Effect.gen(function* () {
552
590
  case "Retain":
553
591
  return;
554
592
  case "Expire":
555
- yield* replace(current, new ReactorError("TerminalSession", "Source lifetime limit reached", {
593
+ yield* replace(current, ReactorError.fromCode("TerminalSession", "Source lifetime limit reached", {
556
594
  operation: "renewal",
557
595
  }));
558
596
  return;
@@ -582,31 +620,47 @@ export const make = (options) => Effect.gen(function* () {
582
620
  }))
583
621
  return;
584
622
  const old = current;
585
- current = next;
586
- replacement = { _tag: "Absent" };
587
- yield* current.source.setAutoplay(autoplay);
588
- const activated = publishReady(current);
589
- yield* closeSlot(old);
590
- if (!activated)
591
- return;
592
- yield* observe({ _tag: "Switched", ...tail });
593
- yield* log("Switched prepared sessions at a sequence boundary");
623
+ // The switch is traced; the tick that found it is not.
624
+ yield* Effect.gen(function* () {
625
+ yield* retireOwner(old);
626
+ current = next;
627
+ yield* activateOwner(next);
628
+ replacement = { _tag: "Absent" };
629
+ yield* next.source.setAutoplay(autoplay);
630
+ const activated = publishReady(next);
631
+ yield* closeSlot(old);
632
+ if (!activated)
633
+ return;
634
+ yield* announce({ _tag: "Switched", ...tail });
635
+ yield* log("Switched prepared sessions at a sequence boundary");
636
+ }).pipe(Effect.withSpan("reactor.orchestration.renewal.switch", {
637
+ attributes: {
638
+ "reactor.session.id": next.source.id,
639
+ "reactor.session.previous_id": old.source.id,
640
+ },
641
+ }, { captureStackTrace: false }));
594
642
  }))
595
- .pipe(Effect.catch((cause) => fail(errorOf(cause, "Disconnected"))));
643
+ .pipe(Effect.catch(fail));
596
644
  yield* Effect.forever(Effect.sleep(100).pipe(Effect.andThen(tick))).pipe(Effect.forkIn(scope));
597
645
  const pressure = Effect.suspend(() => {
598
646
  if (current === undefined)
599
- return Effect.fail(new ReactorError("InvalidState", "No active media source"));
647
+ return Effect.fail(ReactorError.fromCode("InvalidState", "No active media source"));
600
648
  const owner = current;
601
- return owner.pressure.pipe(Effect.map((source) => {
649
+ return owner.pressure.pipe(Effect.flatMap((source) => {
602
650
  const queued = buffer.pressure();
603
- return {
651
+ const loss = SourceSlot.addLoss(outputLoss, SourceSlot.subtractLoss(SourceSlot.lossOf(Result.succeed(source)), ownerBaseline));
652
+ if (loss.video === null || loss.audio === null || loss.readers === null)
653
+ return Effect.fail(ReactorError.fromCode("InvalidState", "A former media owner's loss totals are unknown"));
654
+ return Effect.succeed({
604
655
  ...source,
605
656
  closed: closing,
606
657
  queuedVideo: source.queuedVideo + queued.queuedVideo,
607
658
  queuedAudio: source.queuedAudio + queued.queuedAudio,
608
659
  queuedBytes: source.queuedBytes + queued.queuedBytes,
609
- };
660
+ droppedVideo: loss.video,
661
+ droppedAudio: loss.audio,
662
+ readerOverflows: loss.readers,
663
+ });
610
664
  }));
611
665
  });
612
666
  return {
@@ -618,6 +672,7 @@ export const make = (options) => Effect.gen(function* () {
618
672
  videoFramesPerSecond: current.media.videoFramesPerSecond,
619
673
  },
620
674
  mediaState: Effect.sync(() => mediaState),
675
+ observe: (options) => observations.observeWith(Effect.map(state, (engine) => ({ engine, media: mediaState })), options),
621
676
  sessionId: Effect.sync(() => current === undefined || current.closed ? Option.none() : Option.some(current.source.id)),
622
677
  close,
623
678
  cleanup: Effect.sync(() => finalReport === undefined ? Option.none() : Option.some(finalReport)),
@@ -630,4 +685,10 @@ export const make = (options) => Effect.gen(function* () {
630
685
  },
631
686
  };
632
687
  });
688
+ /**
689
+ * The handle's `Engine`, `Media` and `Handle` services from one orchestration,
690
+ * so the three can never come from two paid session chains. Each build of the
691
+ * layer opens its own chain: bind it to a `const` and provide that one value.
692
+ */
693
+ export const layer = (options) => Layer.effectContext(Effect.map(make(options), handleContext));
633
694
  //# sourceMappingURL=renewal.js.map