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
package/dist/session.js CHANGED
@@ -6,10 +6,12 @@ import * as Exit from "effect/Exit";
6
6
  import * as Queue from "effect/Queue";
7
7
  import * as Cause from "effect/Cause";
8
8
  import * as Clock from "effect/Clock";
9
+ import * as Duration from "effect/Duration";
9
10
  import * as Stream from "effect/Stream";
10
- import { errorOf, positiveLimit, ReactorError } from "./errors.js";
11
+ import { duration } from "./duration.js";
12
+ import { parsed, parsedInput, positiveLimit, ReactorError, Remote } from "./errors.js";
11
13
  import { nonempty, uint32, structFromObject, objectFromStruct } from "./json.js";
12
- import { CoordinatorClient } from "./coordinator/_internal/client.js";
14
+ import { CoordinatorClient, terminationAttributes } from "./coordinator/_internal/client.js";
13
15
  import { Correlator } from "./correlation.js";
14
16
  import { Observations } from "./observation.js";
15
17
  import * as W from "./wire.generated.js";
@@ -19,10 +21,24 @@ import { captureUploads } from "./session/_internal/uploads.js";
19
21
  import { SessionLifecycle } from "./session/_internal/lifecycle.js";
20
22
  import { isKnownRemote, RemoteSession } from "./session/_internal/remote.js";
21
23
  import { cleanupSession } from "./session/_internal/cleanup.js";
22
- const pure = (body) => Effect.try({ try: body, catch: errorOf });
23
- const withDeadline = (effect, ms, operation) => effect.pipe(Effect.timeoutOrElse({
24
- duration: ms,
25
- orElse: () => Effect.fail(new ReactorError("Timeout", `${operation}: deadline`)),
24
+ /** A provider error reply: its code routes, its text stays in `body` for inspection. */
25
+ const remoteError = (value, context) => new ReactorError({
26
+ reason: new Remote({
27
+ _tag: "Remote",
28
+ message: `remote command error ${value.code}`,
29
+ remoteCode: value.code,
30
+ body: value.message,
31
+ }),
32
+ context,
33
+ });
34
+ const pure = (body) => parsed(body);
35
+ /** Mark a connect phase on the current span, as SqlClient marks a transaction's. */
36
+ const phase = (name) => Effect.currentSpan.pipe(Effect.flatMap((span) => Clock.currentTimeNanos.pipe(Effect.map((now) => span.event(`reactor.connect.${name}`, now)))), Effect.ignore);
37
+ const replyTimeout = (input) => duration(input, "reply timeout", { maximum: "10 minutes" });
38
+ const uploadTimeout = (input) => duration(input, "upload timeout", { maximum: "10 minutes" });
39
+ const withDeadline = (effect, deadline, operation) => effect.pipe(Effect.timeoutOrElse({
40
+ duration: deadline,
41
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", `${operation}: deadline`)),
26
42
  }));
27
43
  /** The canonical Reactor state and wire engine for both public host exports.
28
44
  * Platform and peer dependencies are supplied by the configured client factory. */
@@ -40,7 +56,8 @@ export class Session {
40
56
  closeReport;
41
57
  received = new Set();
42
58
  bitrates = new Map();
43
- commandTimeout;
59
+ replyTimeout;
60
+ uploadTimeout;
44
61
  connectTimeout;
45
62
  readyTimeout;
46
63
  heartbeat;
@@ -57,12 +74,18 @@ export class Session {
57
74
  else
58
75
  nonempty(options.intent.model.name, "model name");
59
76
  this.http = http;
60
- this.commandTimeout = positiveLimit(options.commandTimeoutMs ?? 10_000, "command timeout", 600_000);
61
- this.connectTimeout = positiveLimit(options.connectTimeoutMs ?? 180_000, "connect timeout", 600_000);
62
- this.readyTimeout = positiveLimit(options.readyTimeoutMs ?? 30_000, "ready timeout", 600_000);
63
- this.heartbeat = options.heartbeatMs ?? 10_000;
64
- if (this.heartbeat !== 0)
65
- positiveLimit(this.heartbeat, "heartbeat interval", 600_000);
77
+ this.replyTimeout = replyTimeout(options.replyTimeout ?? "10 seconds");
78
+ this.uploadTimeout = uploadTimeout(options.uploadTimeout ?? "60 seconds");
79
+ this.connectTimeout = duration(options.connectTimeout ?? "3 minutes", "connect timeout", {
80
+ maximum: "10 minutes",
81
+ });
82
+ this.readyTimeout = duration(options.readyTimeout ?? "30 seconds", "ready timeout", {
83
+ maximum: "10 minutes",
84
+ });
85
+ this.heartbeat = duration(options.heartbeatInterval ?? "10 seconds", "heartbeat interval", {
86
+ maximum: "10 minutes",
87
+ allowInfinite: true,
88
+ });
66
89
  this.uploadBound = positiveLimit(options.maxUploadBytes ?? 16_777_216, "upload byte bound", 64 * 1024 * 1024);
67
90
  this.data = new Correlator("data", options.maxPending ?? 128, options.requestNamespace);
68
91
  this.control = new Correlator("ctrl", options.maxPending ?? 128, options.requestNamespace);
@@ -162,7 +185,7 @@ export class Session {
162
185
  switch (event.type) {
163
186
  case "state":
164
187
  if (event.state === "failed" || event.state === "disconnected" || event.state === "closed")
165
- this.fail(c, new ReactorError("Disconnected", `peer state ${event.state}`, {
188
+ this.fail(c, ReactorError.fromCode("Disconnected", `peer state ${event.state}`, {
166
189
  generation: c.generation,
167
190
  }));
168
191
  else {
@@ -172,7 +195,10 @@ export class Session {
172
195
  break;
173
196
  case "channel":
174
197
  if (!event.open) {
175
- this.fail(c, new ReactorError("Disconnected", `${event.channel} channel closed`));
198
+ this.fail(c, ReactorError.fromCode("ChannelClosed", `${event.channel} channel closed`, {
199
+ generation: c.generation,
200
+ detail: { channel: event.channel },
201
+ }));
176
202
  break;
177
203
  }
178
204
  if (event.channel === "control")
@@ -187,7 +213,7 @@ export class Session {
187
213
  else {
188
214
  c.iceBytes += event.candidate.candidate.length * 2;
189
215
  if (c.iceBuffer.length >= 256 || c.iceBytes > 262_144 || c.finalSent) {
190
- this.fail(c, new ReactorError("Overflow", "ICE buffer bound or candidate after final batch"));
216
+ this.fail(c, ReactorError.fromCode("Overflow", "ICE buffer bound or candidate after final batch"));
191
217
  break;
192
218
  }
193
219
  c.iceBuffer.push(event.candidate);
@@ -215,8 +241,7 @@ export class Session {
215
241
  if (channel === "data") {
216
242
  const message = W.DataServerMessage.decode(bytes), payload = message.payload;
217
243
  if (payload?.case === "error") {
218
- const error = new ReactorError("Remote", payload.value.message, {
219
- remoteCode: payload.value.code,
244
+ const error = remoteError(payload.value, {
220
245
  requestId: message.request_id,
221
246
  generation: c.generation,
222
247
  outcome: "replied",
@@ -260,13 +285,12 @@ export class Session {
260
285
  if (payload === undefined) {
261
286
  this.emit({
262
287
  _tag: "Diagnostic",
263
- error: new ReactorError("Protocol", "bodyless control response does not resolve a request", { requestId: message.request_id }),
288
+ error: ReactorError.fromCode("Protocol", "bodyless control response does not resolve a request", { requestId: message.request_id }),
264
289
  }, bytes.length);
265
290
  return;
266
291
  }
267
292
  const result = payload.case === "error"
268
- ? Effect.fail(new ReactorError("Remote", payload.value.message, {
269
- remoteCode: payload.value.code,
293
+ ? Effect.fail(remoteError(payload.value, {
270
294
  requestId: message.request_id,
271
295
  outcome: "replied",
272
296
  detail: message,
@@ -283,7 +307,7 @@ export class Session {
283
307
  else if (payload.case === "error")
284
308
  c.pendingClaims.delete(message.request_id);
285
309
  else {
286
- this.fail(c, new ReactorError("UnexpectedReply", "publisher claim reply did not identify the requested track", {
310
+ this.fail(c, ReactorError.fromCode("UnexpectedReply", "publisher claim reply did not identify the requested track", {
287
311
  operation: "publish_track",
288
312
  requestId: message.request_id,
289
313
  outcome: "unknown",
@@ -297,7 +321,11 @@ export class Session {
297
321
  }
298
322
  }
299
323
  catch (cause) {
300
- this.emit({ _tag: "Diagnostic", error: errorOf(cause, "Protocol") }, bytes.length);
324
+ // Decoding rejects a malformed reply with a ReactorError; anything else it
325
+ // throws is a bug and propagates to the peer rather than becoming typed.
326
+ if (!ReactorError.is(cause))
327
+ throw cause;
328
+ this.emit({ _tag: "Diagnostic", error: cause }, bytes.length);
301
329
  }
302
330
  }
303
331
  flushIce(c) {
@@ -319,9 +347,9 @@ export class Session {
319
347
  background(c, body) {
320
348
  return Effect.forkIn(body.pipe(Effect.raceFirst(Deferred.await(c.failed)), Effect.catchCause((cause) => Effect.sync(() => {
321
349
  const failure = Cause.findError(cause);
322
- this.fail(c, failure._tag === "Success" && failure.success instanceof ReactorError
350
+ this.fail(c, failure._tag === "Success" && ReactorError.is(failure.success)
323
351
  ? failure.success
324
- : new ReactorError("Protocol", "session task failed", {
352
+ : ReactorError.fromCode("Protocol", "session task failed", {
325
353
  detail: cause,
326
354
  generation: c.generation,
327
355
  }));
@@ -340,12 +368,14 @@ export class Session {
340
368
  });
341
369
  yield* Scope.addFinalizer(c.scope, Effect.gen(function* () {
342
370
  if (c.failure === undefined)
343
- self.fail(c, new ReactorError("Aborted", "connection scope closed", { generation: c.generation }));
371
+ self.fail(c, ReactorError.fromCode("Aborted", "connection scope closed", {
372
+ generation: c.generation,
373
+ }));
344
374
  if (c.peer.shutdown !== undefined)
345
- yield* c.peer.shutdown().pipe(Effect.orDie);
375
+ yield* c.peer.shutdown.pipe(Effect.orDie);
346
376
  }));
347
377
  if (previous !== undefined) {
348
- self.fail(previous, new ReactorError("Disconnected", "connection retired for reconnect"));
378
+ self.fail(previous, ReactorError.fromCode("Disconnected", "connection retired for reconnect"));
349
379
  yield* Scope.close(previous.scope, Exit.void);
350
380
  }
351
381
  return c;
@@ -376,9 +406,7 @@ export class Session {
376
406
  const connection = this.currentReady();
377
407
  const media = connection.peer.rawMedia;
378
408
  if (media === undefined)
379
- throw new ReactorError("UnsupportedCapability", "peer has no decoded media capability", {
380
- outcome: "not-submitted",
381
- });
409
+ throw ReactorError.fromCode("UnsupportedCapability", "peer has no decoded media capability", { outcome: "not-submitted" });
382
410
  const owned = (source) => Stream.transformPull(source, (pull) => Effect.succeed(pure(() => this.assertCurrent(connection)).pipe(Effect.andThen(pull), Effect.raceFirst(Deferred.await(connection.failed)), Effect.tap(() => pure(() => this.assertCurrent(connection))))));
383
411
  return Object.freeze({
384
412
  generation: connection.generation,
@@ -394,9 +422,7 @@ export class Session {
394
422
  return pure(() => {
395
423
  const connection = this.currentReady();
396
424
  if (connection.peer.nativeTracks !== true) {
397
- throw new ReactorError("UnsupportedCapability", "peer has no browser track capability", {
398
- outcome: "not-submitted",
399
- });
425
+ throw ReactorError.fromCode("UnsupportedCapability", "peer has no browser track capability", { outcome: "not-submitted" });
400
426
  }
401
427
  return Object.freeze({
402
428
  generation: connection.generation,
@@ -421,19 +447,22 @@ export class Session {
421
447
  const self = this;
422
448
  return Effect.gen(function* () {
423
449
  const c = yield* self.begin(reconnect);
450
+ yield* Effect.annotateCurrentSpan("reactor.connection.generation", c.generation);
424
451
  const work = Effect.gen(function* () {
425
452
  if (!reconnect)
426
453
  yield* self.guard(c, self.allocate());
427
454
  self.assertCurrent(c);
428
455
  self.lifecycle.transition("waiting");
429
456
  const remote = self.currentRemote();
457
+ yield* Effect.annotateCurrentSpan("reactor.session.id", remote.id);
430
458
  const descriptor = yield* self.guard(c, self.http.ready(remote.id, reconnect ? undefined : remote.descriptor));
431
459
  remote.descriptor = descriptor;
460
+ yield* phase("described");
432
461
  const capabilities = descriptor.capabilities, transport = descriptor.selected_transport;
433
462
  if (capabilities === undefined || transport === undefined)
434
- return yield* Effect.fail(new ReactorError("Protocol", "missing ready capabilities/transport"));
463
+ return yield* ReactorError.fromCode("Protocol", "missing ready capabilities/transport");
435
464
  if (transport.protocol !== "webrtc" || transport.version !== "1.0")
436
- return yield* Effect.fail(new ReactorError("VersionMismatch", `unsupported transport ${transport.protocol}/${transport.version}`));
465
+ return yield* ReactorError.fromCode("VersionMismatch", `unsupported transport ${transport.protocol}/${transport.version}`);
437
466
  const readyDescriptor = Object.freeze({
438
467
  ...descriptor,
439
468
  capabilities,
@@ -442,10 +471,12 @@ export class Session {
442
471
  const servers = yield* self.guard(c, self.http.iceServers(remote.id));
443
472
  const prepared = yield* self.guard(c, Scope.provide(c.peer.prepare(servers, capabilities.tracks, (event) => self.onPeer(c, event)), c.scope));
444
473
  c.mapping = prepared.mapping;
474
+ yield* phase("prepared");
445
475
  const previousId = remote.connectionId;
446
476
  const cid = previousId ?? (yield* self.guard(c, self.http.register(remote.id)));
447
477
  c.connectionId = cid;
448
478
  remote.connectionId = cid;
479
+ yield* phase("registered");
449
480
  // Source order: registration -> buffered ICE (including empty final) -> offer -> poll answer.
450
481
  yield* self.flushIce(c);
451
482
  yield* self.background(c, Effect.gen(function* () {
@@ -455,22 +486,28 @@ export class Session {
455
486
  }
456
487
  }));
457
488
  yield* self.guard(c, self.http.offer(remote.id, cid, prepared.sdp, prepared.mapping, reconnect && previousId !== undefined));
489
+ yield* phase("offered");
458
490
  const answer = yield* self.guard(c, self.http.answer(remote.id, cid));
459
491
  if (answer.connection_id !== undefined) {
460
492
  c.connectionId = answer.connection_id;
461
493
  remote.connectionId = answer.connection_id;
462
494
  }
463
495
  yield* self.guard(c, c.peer.answer(answer.sdp_answer));
496
+ yield* phase("answered");
464
497
  yield* withDeadline(self.guard(c, Deferred.await(c.ready)), self.readyTimeout, "peer and both channels ready");
465
498
  self.assertCurrent(c);
466
- c.negotiated = Object.freeze({
499
+ yield* phase("ready");
500
+ self.lifecycle.ready(c, Object.freeze({
467
501
  ownership: remote.ownership,
468
502
  sessionId: remote.id,
469
503
  descriptor: readyDescriptor,
470
504
  connectionId: c.connectionId,
471
- });
472
- self.lifecycle.transition("ready");
473
- if (self.options.autoResumeTracks ?? self.options.intent._tag === "Create")
505
+ }));
506
+ // Each connection subscribes to its own receive-only tracks, an attached
507
+ // one as much as the one that created the session: hosted Reactor holds
508
+ // a connection's media until that connection resumes them. A data-only
509
+ // peer has no tracks to resume.
510
+ if ((self.options.autoResumeTracks ?? true) && c.peer.mediaSupported !== false)
474
511
  for (const track of capabilities.tracks)
475
512
  if (track.direction === "recvonly") {
476
513
  const outcome = yield* Effect.result(self.setTrackActive(track.name, true));
@@ -482,7 +519,7 @@ export class Session {
482
519
  if (outcome._tag === "Failure")
483
520
  self.emit({ _tag: "Diagnostic", error: outcome.failure });
484
521
  }
485
- if (self.heartbeat !== 0)
522
+ if (Duration.isFinite(self.heartbeat))
486
523
  yield* self.background(c, Effect.gen(function* () {
487
524
  while (true) {
488
525
  yield* self.ping();
@@ -494,15 +531,17 @@ export class Session {
494
531
  ? Effect.gen(function* () {
495
532
  self.remote.allocationLost();
496
533
  const error = Cause.findError(exit.cause);
497
- self.fail(c, error._tag === "Success" && error.success instanceof ReactorError
534
+ self.fail(c, error._tag === "Success" && ReactorError.is(error.success)
498
535
  ? error.success
499
- : new ReactorError("Aborted", "connection attempt interrupted"));
536
+ : ReactorError.fromCode("Aborted", "connection attempt interrupted"));
500
537
  yield* Scope.close(c.scope, Exit.void);
501
538
  })
502
539
  : Effect.void));
503
- });
540
+ }).pipe(
541
+ // A caller-boundary span with an event per phase; the caller can cancel it.
542
+ Effect.withSpan(reconnect ? "reactor.session.reconnect" : "reactor.session.connect", { kind: "client" }, { captureStackTrace: false }));
504
543
  }
505
- request(c, correlator, operation, encode, channel, timeoutMs, publication) {
544
+ request(c, correlator, operation, encode, channel, deadline, publication) {
506
545
  const self = this;
507
546
  return Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
508
547
  const pending = yield* pure(() => {
@@ -515,20 +554,26 @@ export class Session {
515
554
  if (encoded._tag === "Failure") {
516
555
  correlator.cancel(pending);
517
556
  c.pendingClaims.delete(pending.id);
518
- return yield* Effect.fail(new ReactorError(encoded.failure.code, encoded.failure.message, {
519
- ...encoded.failure.context,
557
+ return yield* new ReactorError({
558
+ reason: encoded.failure.reason,
559
+ context: {
560
+ ...encoded.failure.context,
561
+ operation,
562
+ requestId: pending.id,
563
+ generation: c.generation,
564
+ outcome: "not-submitted",
565
+ },
566
+ });
567
+ }
568
+ const failure = (error) => new ReactorError({
569
+ reason: error.reason,
570
+ context: {
571
+ ...error.context,
520
572
  operation,
521
573
  requestId: pending.id,
522
574
  generation: c.generation,
523
- outcome: "not-submitted",
524
- }));
525
- }
526
- const failure = (error) => new ReactorError(error.code, error.message, {
527
- ...error.context,
528
- operation,
529
- requestId: pending.id,
530
- generation: c.generation,
531
- outcome: error.context.outcome ?? (pending.submitted ? "unknown" : "not-submitted"),
575
+ outcome: error.context.outcome ?? (pending.submitted ? "unknown" : "not-submitted"),
576
+ },
532
577
  });
533
578
  const sending = pure(() => {
534
579
  self.assertCurrent(c);
@@ -549,7 +594,7 @@ export class Session {
549
594
  // native backend remains responsible for any foreign operation slot.
550
595
  yield* Effect.forkIn(sending, yield* Effect.scope, { startImmediately: true });
551
596
  return yield* Deferred.await(pending.deferred);
552
- })).pipe(Effect.interruptible, (effect) => withDeadline(effect, timeoutMs, operation), Effect.mapError(failure), Effect.onExit((exit) => Effect.sync(() => {
597
+ })).pipe(Effect.interruptible, (effect) => withDeadline(effect, deadline, operation), Effect.mapError(failure), Effect.onExit((exit) => Effect.sync(() => {
553
598
  if (!pending.submitted) {
554
599
  correlator.cancel(pending);
555
600
  c.pendingClaims.delete(pending.id);
@@ -558,29 +603,56 @@ export class Session {
558
603
  pending.waiting = false;
559
604
  // Unresolved submitted records remain bounded by maxPending. A late
560
605
  // reply or retiring this generation releases the slot; a timeout does not.
561
- })));
606
+ })),
607
+ // The span covers the owned execution, not the caller's wait: the wire has no
608
+ // cancel, so it ends with the request's own outcome even after the caller stops
609
+ // waiting. It records identity and dispatch evidence only; failure messages are
610
+ // library-written, so its Exit carries no input, reply or provider text.
611
+ Effect.onExit((exit) => {
612
+ const error = Exit.findError(exit);
613
+ return Effect.annotateCurrentSpan(Exit.isSuccess(exit)
614
+ ? { "reactor.command.outcome": "replied" }
615
+ : error._tag === "Success"
616
+ ? {
617
+ "reactor.command.outcome": error.success.context.outcome,
618
+ "error.type": error.success.reason._tag,
619
+ }
620
+ : // No typed failure: the connection scope closed, or a defect.
621
+ { "reactor.command.outcome": pending.submitted ? "unknown" : "not-submitted" });
622
+ }), Effect.withSpan(channel === "data" ? "reactor.session.command" : "reactor.session.control", {
623
+ kind: "client",
624
+ attributes: {
625
+ "reactor.operation": operation,
626
+ "reactor.request.id": pending.id,
627
+ "reactor.connection.generation": c.generation,
628
+ },
629
+ }, { captureStackTrace: false }));
562
630
  const owner = yield* Effect.forkIn(execution, c.scope, { startImmediately: true });
563
631
  return yield* restore(Fiber.join(owner));
564
632
  }));
565
633
  }
566
- command(type, data, uploads = new Map(), timeoutMs = this.commandTimeout) {
567
- return pure(() => {
568
- const c = this.currentReady(), payload = {
634
+ command(type, data, options = {}) {
635
+ return pure(() => this.currentReady()).pipe(
636
+ // The caller's name, data, uploads and deadline: a rejection is InvalidInput.
637
+ Effect.flatMap((c) => parsedInput(() => ({
638
+ c,
639
+ deadline: options.replyTimeout === undefined
640
+ ? this.replyTimeout
641
+ : replyTimeout(options.replyTimeout),
642
+ payload: {
569
643
  type: nonempty(type, "command type"),
570
644
  data: structFromObject(data),
571
- uploads: captureUploads(uploads),
572
- };
573
- positiveLimit(timeoutMs, "command timeout", 600_000);
574
- return { c, payload };
575
- }).pipe(Effect.mapError((error) => new CommandFailure(error, {
645
+ uploads: captureUploads(options.uploads ?? new Map()),
646
+ },
647
+ }), type)), Effect.mapError((error) => CommandFailure.from(error, {
576
648
  ...error.context,
577
649
  operation: type,
578
650
  outcome: "not-submitted",
579
- })), Effect.flatMap(({ c, payload }) => this.request(c, this.data, type, (id) => W.DataClientMessage.encode({
651
+ })), Effect.flatMap(({ c, deadline, payload }) => this.request(c, this.data, type, (id) => W.DataClientMessage.encode({
580
652
  request_id: id,
581
653
  kind: 1,
582
654
  payload: { case: "command", value: payload },
583
- }), "data", timeoutMs).pipe(Effect.mapError((error) => {
655
+ }), "data", deadline).pipe(Effect.mapError((error) => {
584
656
  const { outcome, requestId, generation } = error.context;
585
657
  if (outcome === "unknown" || outcome === "replied") {
586
658
  if (requestId === undefined || generation === undefined)
@@ -592,9 +664,9 @@ export class Session {
592
664
  requestId,
593
665
  generation,
594
666
  };
595
- return new CommandFailure(error, context);
667
+ return CommandFailure.from(error, context);
596
668
  }
597
- return new CommandFailure(error, {
669
+ return CommandFailure.from(error, {
598
670
  ...error.context,
599
671
  operation: type,
600
672
  outcome: "not-submitted",
@@ -606,13 +678,13 @@ export class Session {
606
678
  const connection = expected ?? this.currentReady();
607
679
  this.assertCurrent(connection);
608
680
  return connection;
609
- }).pipe(Effect.flatMap((c) => this.request(c, this.control, operation, (id) => W.ControlClientMessage.encode({ request_id: id, kind: 1, payload }), "control", this.commandTimeout, payload.case === "publish_track" ? payload.value.name : undefined)));
681
+ }).pipe(Effect.flatMap((c) => this.request(c, this.control, operation, (id) => W.ControlClientMessage.encode({ request_id: id, kind: 1, payload }), "control", this.replyTimeout, payload.case === "publish_track" ? payload.value.name : undefined)));
610
682
  }
611
683
  notification(c, payload) {
612
684
  return pure(() => {
613
685
  this.assertCurrent(c);
614
686
  return W.ControlClientMessage.encode({ request_id: "", kind: 3, payload });
615
- }).pipe(Effect.flatMap((bytes) => this.guard(c, c.peer.send("control", bytes))), (effect) => withDeadline(effect, this.commandTimeout, "control notification"));
687
+ }).pipe(Effect.flatMap((bytes) => this.guard(c, c.peer.send("control", bytes))), (effect) => withDeadline(effect, this.replyTimeout, "control notification"));
616
688
  }
617
689
  ping() {
618
690
  return pure(() => this.currentReady()).pipe(Effect.flatMap((c) => this.notification(c, { case: "ping", value: {} })));
@@ -620,7 +692,7 @@ export class Session {
620
692
  schema() {
621
693
  return this.controlRequest("request_schema", { case: "request_schema", value: {} }).pipe(Effect.flatMap((reply) => pure(() => {
622
694
  if (reply.case !== "model_schema")
623
- throw new ReactorError("UnexpectedReply", `schema reply was ${reply.case}`, {
695
+ throw ReactorError.fromCode("UnexpectedReply", `schema reply was ${reply.case}`, {
624
696
  outcome: "replied",
625
697
  });
626
698
  return {
@@ -634,23 +706,41 @@ export class Session {
634
706
  clip(payload) {
635
707
  return this.controlRequest(payload.case, payload).pipe(Effect.flatMap((reply) => pure(() => {
636
708
  if (reply.case === "clip_failed")
637
- throw new ReactorError(/recorder disabled|encoder crashed/i.test(reply.value.reason)
638
- ? "RecorderDisabled"
639
- : "Remote", reply.value.reason, { outcome: "replied" });
709
+ throw new ReactorError({
710
+ reason: new Remote({
711
+ // The one regex classification of provider free text, a deliberate exception:
712
+ // ClipFailed carries only a reason string, and a caller must tell a disabled
713
+ // recorder from other clip failures. Add no others; drop this once the wire
714
+ // carries a code.
715
+ _tag: /recorder disabled|encoder crashed/i.test(reply.value.reason)
716
+ ? "RecorderDisabled"
717
+ : "Remote",
718
+ message: "clip failed",
719
+ body: reply.value.reason,
720
+ }),
721
+ context: { outcome: "replied" },
722
+ });
640
723
  if (reply.case !== "clip_ready")
641
- throw new ReactorError("UnexpectedReply", `clip reply was ${reply.case}`, {
724
+ throw ReactorError.fromCode("UnexpectedReply", `clip reply was ${reply.case}`, {
642
725
  outcome: "replied",
643
726
  });
644
- return {
645
- ...reply.value,
646
- playlist_url: new URL(reply.value.playlist_url, `${this.http.apiUrl}/`).href,
647
- };
727
+ let playlist;
728
+ try {
729
+ playlist = new URL(reply.value.playlist_url, `${this.http.apiUrl}/`);
730
+ }
731
+ catch (cause) {
732
+ throw ReactorError.fromCode("Protocol", "clip playlist URL is malformed", {
733
+ outcome: "replied",
734
+ detail: cause,
735
+ });
736
+ }
737
+ return { ...reply.value, playlist_url: playlist.href };
648
738
  })));
649
739
  }
650
740
  requestClip(seconds) {
651
741
  return pure(() => {
652
742
  if (!Number.isFinite(seconds) || seconds <= 0)
653
- throw new ReactorError("Protocol", "clip duration must be finite and positive");
743
+ throw ReactorError.fromCode("Protocol", "clip duration must be finite and positive");
654
744
  }).pipe(Effect.andThen(this.clip({ case: "request_clip", value: { duration_seconds: seconds } })));
655
745
  }
656
746
  recording() {
@@ -658,10 +748,10 @@ export class Session {
658
748
  }
659
749
  namedTrack(name, connection) {
660
750
  if (connection.peer.mediaSupported === false)
661
- throw new ReactorError("UnsupportedCapability", "This peer is data-only; track/media operations are unavailable", { outcome: "not-submitted" });
751
+ throw ReactorError.fromCode("UnsupportedCapability", "This peer is data-only; track/media operations are unavailable", { outcome: "not-submitted" });
662
752
  const track = connection.negotiated.descriptor.capabilities.tracks.find((t) => t.name === name);
663
753
  if (track === undefined)
664
- throw new ReactorError("InvalidState", `unknown track: ${name}`);
754
+ throw ReactorError.fromCode("InvalidState", `unknown track: ${name}`);
665
755
  return track;
666
756
  }
667
757
  trackOperation(name, body, expected) {
@@ -670,7 +760,7 @@ export class Session {
670
760
  this.assertCurrent(c);
671
761
  const track = this.namedTrack(name, c);
672
762
  if (c.trackBusy.has(name))
673
- throw new ReactorError("InvalidState", `another track operation is in flight: ${name}`);
763
+ throw ReactorError.fromCode("InvalidState", `another track operation is in flight: ${name}`);
674
764
  c.trackBusy.add(name);
675
765
  return { c, track };
676
766
  }), ({ c, track }) => this.guard(c, body(c, track)), ({ c }) => Effect.sync(() => {
@@ -702,17 +792,17 @@ export class Session {
702
792
  self.assertCurrent(c);
703
793
  invoked = true;
704
794
  }).pipe(Effect.andThen(Effect.suspend(() => c.peer.replace(name, clone))), Effect.timeoutOrElse({
705
- duration: self.commandTimeout,
795
+ duration: self.replyTimeout,
706
796
  orElse: () => {
707
797
  deadline = true;
708
- return Effect.fail(new ReactorError("Timeout", "sender replacement deadline; local generation retired", { operation: name, outcome: "unknown" }));
798
+ return Effect.fail(ReactorError.fromCode("Timeout", "sender replacement deadline; local generation retired", { operation: name, outcome: "unknown" }));
709
799
  },
710
800
  }));
711
801
  yield* restore(mutation).pipe(Effect.onExit((exit) => Exit.isFailure(exit)
712
802
  ? Effect.sync(() => {
713
803
  clone?.stop();
714
804
  if (invoked && (deadline || Cause.hasInterrupts(exit.cause)))
715
- self.fail(c, new ReactorError(deadline ? "Timeout" : "Disconnected", "native sender replacement abandoned; reconnect explicitly before reusing publication", { operation: name, outcome: "unknown" }));
805
+ self.fail(c, ReactorError.fromCode(deadline ? "Timeout" : "Disconnected", "native sender replacement abandoned; reconnect explicitly before reusing publication", { operation: name, outcome: "unknown" }));
716
806
  })
717
807
  : Effect.void));
718
808
  // No interruptible boundary between successful mutation and recording its owner.
@@ -738,17 +828,15 @@ export class Session {
738
828
  const self = this;
739
829
  return this.trackOperation(name, (c, track) => Effect.gen(function* () {
740
830
  if (c.peer.nativeTracks === false)
741
- return yield* Effect.fail(new ReactorError("UnsupportedCapability", "this peer does not accept browser media tracks", { outcome: "not-submitted" }));
831
+ return yield* ReactorError.fromCode("UnsupportedCapability", "this peer does not accept browser media tracks", { outcome: "not-submitted" });
742
832
  if (track.direction !== "sendonly" ||
743
833
  source.kind !== track.kind ||
744
834
  source.readyState !== "live")
745
- return yield* Effect.fail(new ReactorError("InvalidState", "publish requires a live matching input track"));
835
+ return yield* ReactorError.fromCode("InvalidState", "publish requires a live matching input track");
746
836
  if (!c.claimed.has(name)) {
747
837
  const reply = yield* self.controlRequest("publish_track", { case: "publish_track", value: { name } }, c);
748
838
  if (reply.case !== "publish_track" || reply.value.name !== name)
749
- return yield* Effect.fail(new ReactorError("UnexpectedReply", "publisher claim reply mismatch", {
750
- outcome: "replied",
751
- }));
839
+ return yield* ReactorError.fromCode("UnexpectedReply", "publisher claim reply mismatch", { outcome: "replied" });
752
840
  c.claimed.add(name);
753
841
  }
754
842
  yield* self.replaceSender(c, name, source);
@@ -756,7 +844,7 @@ export class Session {
756
844
  }
757
845
  unpublish(name, expected) {
758
846
  return this.trackOperation(name, (c) => c.peer.nativeTracks === false
759
- ? Effect.fail(new ReactorError("UnsupportedCapability", "this peer does not expose browser track publication", { outcome: "not-submitted" }))
847
+ ? Effect.fail(ReactorError.fromCode("UnsupportedCapability", "this peer does not expose browser track publication", { outcome: "not-submitted" }))
760
848
  : this.replaceSender(c, name, null).pipe(Effect.andThen(this.notification(c, { case: "unpublish_track", value: { name } })), Effect.tap(() => Effect.sync(() => {
761
849
  c.claimed.delete(name);
762
850
  }))), expected);
@@ -766,7 +854,7 @@ export class Session {
766
854
  const c = expected ?? this.currentReady();
767
855
  this.assertCurrent(c);
768
856
  if (c.peer.nativeTracks === false)
769
- throw new ReactorError("UnsupportedCapability", "this peer exposes owned decoded frames instead of browser tracks", { outcome: "not-submitted" });
857
+ throw ReactorError.fromCode("UnsupportedCapability", "this peer exposes owned decoded frames instead of browser tracks", { outcome: "not-submitted" });
770
858
  return { peer: c.peer, track: c.peer.lease(name) };
771
859
  }), ({ peer, track }) => Effect.sync(() => peer.release(track))).pipe(Effect.map(({ track }) => track));
772
860
  }
@@ -779,7 +867,7 @@ export class Session {
779
867
  const self = this;
780
868
  return Effect.gen(function* () {
781
869
  const connection = yield* pure(() => self.currentReady());
782
- const raw = yield* self.guard(connection, connection.peer.stats());
870
+ const raw = yield* self.guard(connection, connection.peer.stats);
783
871
  // Elapsed sampling time belongs to this Effect boundary, not the pure sampler.
784
872
  const atNanos = yield* Clock.monotonicTimeNanos;
785
873
  const atMs = Number(atNanos) / 1_000_000;
@@ -787,9 +875,9 @@ export class Session {
787
875
  });
788
876
  }
789
877
  rawStats() {
790
- return pure(() => this.currentReady()).pipe(Effect.flatMap((c) => this.guard(c, c.peer.stats())));
878
+ return pure(() => this.currentReady()).pipe(Effect.flatMap((c) => this.guard(c, c.peer.stats)));
791
879
  }
792
- upload(name, mimeType, bytes, timeoutMs = 60_000) {
880
+ upload(name, mimeType, bytes, options = {}) {
793
881
  const self = this;
794
882
  return Effect.suspend(() => {
795
883
  let progress = {
@@ -803,7 +891,6 @@ export class Session {
803
891
  nonempty(name, "upload name");
804
892
  nonempty(mimeType, "upload MIME type");
805
893
  positiveLimit(bytes.byteLength, "upload size", self.uploadBound);
806
- positiveLimit(timeoutMs, "upload timeout", 600_000);
807
894
  return { c, remote, copy: new Uint8Array(bytes) };
808
895
  });
809
896
  progress = { ...progress, allocation: "unknown" };
@@ -825,12 +912,22 @@ export class Session {
825
912
  progress = { ...progress, notification: "submitted" };
826
913
  return { file, transfer: "confirmed", notification: "submitted" };
827
914
  });
828
- return withDeadline(operation, timeoutMs, "upload").pipe(Effect.mapError((e) => new ReactorError("Upload", e.message, {
915
+ return pure(() => options.uploadTimeout === undefined
916
+ ? self.uploadTimeout
917
+ : uploadTimeout(options.uploadTimeout)).pipe(Effect.flatMap((deadline) => withDeadline(operation, deadline, "upload")), Effect.mapError((e) => ReactorError.fromCode("Upload", e.message, {
829
918
  ...e.context,
830
919
  operation: "upload",
831
920
  detail: { cause: e, progress: { ...progress } },
832
921
  })), Effect.ensuring(Effect.sync(() => self.emit({ _tag: "Upload", progress: Object.freeze({ ...progress }) }))));
833
- });
922
+ }).pipe(
923
+ // The MIME type and size only: never the name or the bytes.
924
+ Effect.withSpan("reactor.session.upload", {
925
+ kind: "client",
926
+ attributes: {
927
+ "reactor.upload.mime_type": mimeType,
928
+ "reactor.upload.size": bytes.byteLength,
929
+ },
930
+ }, { captureStackTrace: false }));
834
931
  }
835
932
  /** Idempotent close. Owned-session termination is attempted once; response is not terminal proof. */
836
933
  close() {
@@ -842,27 +939,33 @@ export class Session {
842
939
  self.closeGate = gate;
843
940
  return Effect.gen(function* () {
844
941
  self.lifecycle.transition("closing");
845
- Deferred.doneUnsafe(self.lifecycle.closing, Effect.fail(new ReactorError("Closed", "session closing")));
942
+ Deferred.doneUnsafe(self.lifecycle.closing, Effect.fail(ReactorError.fromCode("Closed", "session closing")));
846
943
  const report = yield* cleanupSession({
847
944
  connection: self.lifecycle.connection,
848
945
  scope: self.lifecycle.scope,
849
946
  remote: self.remote,
850
947
  http: self.http,
851
- commandTimeout: self.commandTimeout,
948
+ replyTimeout: self.replyTimeout,
852
949
  retire: (connection, error) => self.fail(connection, error),
853
950
  });
854
951
  self.closeReport = report;
855
952
  self.lifecycle.transition("closed");
856
953
  self.observations.end();
857
954
  Deferred.doneUnsafe(gate, Effect.succeed(report));
858
- try {
859
- self.options.onClose?.(report);
860
- }
861
- catch {
862
- /* Consumer callbacks cannot undo or stall cleanup. */
863
- }
955
+ // Consumer callbacks cannot undo or stall cleanup.
956
+ yield* Effect.ignore(Effect.try(() => self.options.onClose?.(report)));
957
+ yield* Effect.annotateCurrentSpan({
958
+ "reactor.close.local_closed": report.localClosed,
959
+ "reactor.close.allocation": report.allocation,
960
+ ...terminationAttributes(report.remote),
961
+ });
864
962
  return report;
865
- });
963
+ }).pipe(
964
+ // Only the close that runs cleanup is traced; a later close awaits its report.
965
+ Effect.withSpan("reactor.session.close", {
966
+ kind: "client",
967
+ attributes: self.remote.id === undefined ? {} : { "reactor.session.id": self.remote.id },
968
+ }, { captureStackTrace: false }));
866
969
  }));
867
970
  }
868
971
  isKnownTerminal() {