reactor-effect-client 0.2.0 → 0.3.0-rc.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 (218) hide show
  1. package/README.md +62 -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 +168 -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 +210 -125
  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 +48 -0
  67. package/dist/h3/_internal/operations.d.ts.map +1 -0
  68. package/dist/h3/_internal/operations.js +230 -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 +108 -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 +208 -109
  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.map +1 -1
  203. package/dist/stats.js +4 -5
  204. package/dist/stats.js.map +1 -1
  205. package/package.json +5 -4
  206. package/notices/owned-webrtc/Effect-MIT.txt +0 -21
  207. package/notices/owned-webrtc/IETF.txt +0 -43
  208. package/notices/owned-webrtc/LICENSE +0 -21
  209. package/notices/owned-webrtc/UPSTREAM.md +0 -20
  210. package/notices/owned-webrtc/VP8-ADAPTATION.md +0 -32
  211. package/notices/owned-webrtc/VP8-AUTHORS.txt +0 -42
  212. package/notices/owned-webrtc/VP8-LICENSE.txt +0 -25
  213. package/notices/owned-webrtc/VP8-PATENTS.txt +0 -21
  214. package/notices/owned-webrtc/Werift-MIT.txt +0 -21
  215. package/notices/owned-webrtc/libvpx-v1.12.0-LICENSE.txt +0 -28
  216. package/notices/owned-webrtc/libvpx-v1.12.0-PATENTS.txt +0 -22
  217. package/notices/owned-webrtc/rfc3711.txt +0 -3139
  218. 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,21 +486,23 @@ 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");
505
+ }));
473
506
  if (self.options.autoResumeTracks ?? self.options.intent._tag === "Create")
474
507
  for (const track of capabilities.tracks)
475
508
  if (track.direction === "recvonly") {
@@ -482,7 +515,7 @@ export class Session {
482
515
  if (outcome._tag === "Failure")
483
516
  self.emit({ _tag: "Diagnostic", error: outcome.failure });
484
517
  }
485
- if (self.heartbeat !== 0)
518
+ if (Duration.isFinite(self.heartbeat))
486
519
  yield* self.background(c, Effect.gen(function* () {
487
520
  while (true) {
488
521
  yield* self.ping();
@@ -494,15 +527,17 @@ export class Session {
494
527
  ? Effect.gen(function* () {
495
528
  self.remote.allocationLost();
496
529
  const error = Cause.findError(exit.cause);
497
- self.fail(c, error._tag === "Success" && error.success instanceof ReactorError
530
+ self.fail(c, error._tag === "Success" && ReactorError.is(error.success)
498
531
  ? error.success
499
- : new ReactorError("Aborted", "connection attempt interrupted"));
532
+ : ReactorError.fromCode("Aborted", "connection attempt interrupted"));
500
533
  yield* Scope.close(c.scope, Exit.void);
501
534
  })
502
535
  : Effect.void));
503
- });
536
+ }).pipe(
537
+ // A caller-boundary span with an event per phase; the caller can cancel it.
538
+ Effect.withSpan(reconnect ? "reactor.session.reconnect" : "reactor.session.connect", { kind: "client" }, { captureStackTrace: false }));
504
539
  }
505
- request(c, correlator, operation, encode, channel, timeoutMs, publication) {
540
+ request(c, correlator, operation, encode, channel, deadline, publication) {
506
541
  const self = this;
507
542
  return Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
508
543
  const pending = yield* pure(() => {
@@ -515,20 +550,26 @@ export class Session {
515
550
  if (encoded._tag === "Failure") {
516
551
  correlator.cancel(pending);
517
552
  c.pendingClaims.delete(pending.id);
518
- return yield* Effect.fail(new ReactorError(encoded.failure.code, encoded.failure.message, {
519
- ...encoded.failure.context,
553
+ return yield* new ReactorError({
554
+ reason: encoded.failure.reason,
555
+ context: {
556
+ ...encoded.failure.context,
557
+ operation,
558
+ requestId: pending.id,
559
+ generation: c.generation,
560
+ outcome: "not-submitted",
561
+ },
562
+ });
563
+ }
564
+ const failure = (error) => new ReactorError({
565
+ reason: error.reason,
566
+ context: {
567
+ ...error.context,
520
568
  operation,
521
569
  requestId: pending.id,
522
570
  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"),
571
+ outcome: error.context.outcome ?? (pending.submitted ? "unknown" : "not-submitted"),
572
+ },
532
573
  });
533
574
  const sending = pure(() => {
534
575
  self.assertCurrent(c);
@@ -549,7 +590,7 @@ export class Session {
549
590
  // native backend remains responsible for any foreign operation slot.
550
591
  yield* Effect.forkIn(sending, yield* Effect.scope, { startImmediately: true });
551
592
  return yield* Deferred.await(pending.deferred);
552
- })).pipe(Effect.interruptible, (effect) => withDeadline(effect, timeoutMs, operation), Effect.mapError(failure), Effect.onExit((exit) => Effect.sync(() => {
593
+ })).pipe(Effect.interruptible, (effect) => withDeadline(effect, deadline, operation), Effect.mapError(failure), Effect.onExit((exit) => Effect.sync(() => {
553
594
  if (!pending.submitted) {
554
595
  correlator.cancel(pending);
555
596
  c.pendingClaims.delete(pending.id);
@@ -558,29 +599,56 @@ export class Session {
558
599
  pending.waiting = false;
559
600
  // Unresolved submitted records remain bounded by maxPending. A late
560
601
  // reply or retiring this generation releases the slot; a timeout does not.
561
- })));
602
+ })),
603
+ // The span covers the owned execution, not the caller's wait: the wire has no
604
+ // cancel, so it ends with the request's own outcome even after the caller stops
605
+ // waiting. It records identity and dispatch evidence only; failure messages are
606
+ // library-written, so its Exit carries no input, reply or provider text.
607
+ Effect.onExit((exit) => {
608
+ const error = Exit.findError(exit);
609
+ return Effect.annotateCurrentSpan(Exit.isSuccess(exit)
610
+ ? { "reactor.command.outcome": "replied" }
611
+ : error._tag === "Success"
612
+ ? {
613
+ "reactor.command.outcome": error.success.context.outcome,
614
+ "error.type": error.success.reason._tag,
615
+ }
616
+ : // No typed failure: the connection scope closed, or a defect.
617
+ { "reactor.command.outcome": pending.submitted ? "unknown" : "not-submitted" });
618
+ }), Effect.withSpan(channel === "data" ? "reactor.session.command" : "reactor.session.control", {
619
+ kind: "client",
620
+ attributes: {
621
+ "reactor.operation": operation,
622
+ "reactor.request.id": pending.id,
623
+ "reactor.connection.generation": c.generation,
624
+ },
625
+ }, { captureStackTrace: false }));
562
626
  const owner = yield* Effect.forkIn(execution, c.scope, { startImmediately: true });
563
627
  return yield* restore(Fiber.join(owner));
564
628
  }));
565
629
  }
566
- command(type, data, uploads = new Map(), timeoutMs = this.commandTimeout) {
567
- return pure(() => {
568
- const c = this.currentReady(), payload = {
630
+ command(type, data, options = {}) {
631
+ return pure(() => this.currentReady()).pipe(
632
+ // The caller's name, data, uploads and deadline: a rejection is InvalidInput.
633
+ Effect.flatMap((c) => parsedInput(() => ({
634
+ c,
635
+ deadline: options.replyTimeout === undefined
636
+ ? this.replyTimeout
637
+ : replyTimeout(options.replyTimeout),
638
+ payload: {
569
639
  type: nonempty(type, "command type"),
570
640
  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, {
641
+ uploads: captureUploads(options.uploads ?? new Map()),
642
+ },
643
+ }), type)), Effect.mapError((error) => CommandFailure.from(error, {
576
644
  ...error.context,
577
645
  operation: type,
578
646
  outcome: "not-submitted",
579
- })), Effect.flatMap(({ c, payload }) => this.request(c, this.data, type, (id) => W.DataClientMessage.encode({
647
+ })), Effect.flatMap(({ c, deadline, payload }) => this.request(c, this.data, type, (id) => W.DataClientMessage.encode({
580
648
  request_id: id,
581
649
  kind: 1,
582
650
  payload: { case: "command", value: payload },
583
- }), "data", timeoutMs).pipe(Effect.mapError((error) => {
651
+ }), "data", deadline).pipe(Effect.mapError((error) => {
584
652
  const { outcome, requestId, generation } = error.context;
585
653
  if (outcome === "unknown" || outcome === "replied") {
586
654
  if (requestId === undefined || generation === undefined)
@@ -592,9 +660,9 @@ export class Session {
592
660
  requestId,
593
661
  generation,
594
662
  };
595
- return new CommandFailure(error, context);
663
+ return CommandFailure.from(error, context);
596
664
  }
597
- return new CommandFailure(error, {
665
+ return CommandFailure.from(error, {
598
666
  ...error.context,
599
667
  operation: type,
600
668
  outcome: "not-submitted",
@@ -606,13 +674,13 @@ export class Session {
606
674
  const connection = expected ?? this.currentReady();
607
675
  this.assertCurrent(connection);
608
676
  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)));
677
+ }).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
678
  }
611
679
  notification(c, payload) {
612
680
  return pure(() => {
613
681
  this.assertCurrent(c);
614
682
  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"));
683
+ }).pipe(Effect.flatMap((bytes) => this.guard(c, c.peer.send("control", bytes))), (effect) => withDeadline(effect, this.replyTimeout, "control notification"));
616
684
  }
617
685
  ping() {
618
686
  return pure(() => this.currentReady()).pipe(Effect.flatMap((c) => this.notification(c, { case: "ping", value: {} })));
@@ -620,7 +688,7 @@ export class Session {
620
688
  schema() {
621
689
  return this.controlRequest("request_schema", { case: "request_schema", value: {} }).pipe(Effect.flatMap((reply) => pure(() => {
622
690
  if (reply.case !== "model_schema")
623
- throw new ReactorError("UnexpectedReply", `schema reply was ${reply.case}`, {
691
+ throw ReactorError.fromCode("UnexpectedReply", `schema reply was ${reply.case}`, {
624
692
  outcome: "replied",
625
693
  });
626
694
  return {
@@ -634,23 +702,41 @@ export class Session {
634
702
  clip(payload) {
635
703
  return this.controlRequest(payload.case, payload).pipe(Effect.flatMap((reply) => pure(() => {
636
704
  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" });
705
+ throw new ReactorError({
706
+ reason: new Remote({
707
+ // The one regex classification of provider free text, a deliberate exception:
708
+ // ClipFailed carries only a reason string, and a caller must tell a disabled
709
+ // recorder from other clip failures. Add no others; drop this once the wire
710
+ // carries a code.
711
+ _tag: /recorder disabled|encoder crashed/i.test(reply.value.reason)
712
+ ? "RecorderDisabled"
713
+ : "Remote",
714
+ message: "clip failed",
715
+ body: reply.value.reason,
716
+ }),
717
+ context: { outcome: "replied" },
718
+ });
640
719
  if (reply.case !== "clip_ready")
641
- throw new ReactorError("UnexpectedReply", `clip reply was ${reply.case}`, {
720
+ throw ReactorError.fromCode("UnexpectedReply", `clip reply was ${reply.case}`, {
642
721
  outcome: "replied",
643
722
  });
644
- return {
645
- ...reply.value,
646
- playlist_url: new URL(reply.value.playlist_url, `${this.http.apiUrl}/`).href,
647
- };
723
+ let playlist;
724
+ try {
725
+ playlist = new URL(reply.value.playlist_url, `${this.http.apiUrl}/`);
726
+ }
727
+ catch (cause) {
728
+ throw ReactorError.fromCode("Protocol", "clip playlist URL is malformed", {
729
+ outcome: "replied",
730
+ detail: cause,
731
+ });
732
+ }
733
+ return { ...reply.value, playlist_url: playlist.href };
648
734
  })));
649
735
  }
650
736
  requestClip(seconds) {
651
737
  return pure(() => {
652
738
  if (!Number.isFinite(seconds) || seconds <= 0)
653
- throw new ReactorError("Protocol", "clip duration must be finite and positive");
739
+ throw ReactorError.fromCode("Protocol", "clip duration must be finite and positive");
654
740
  }).pipe(Effect.andThen(this.clip({ case: "request_clip", value: { duration_seconds: seconds } })));
655
741
  }
656
742
  recording() {
@@ -658,10 +744,10 @@ export class Session {
658
744
  }
659
745
  namedTrack(name, connection) {
660
746
  if (connection.peer.mediaSupported === false)
661
- throw new ReactorError("UnsupportedCapability", "This peer is data-only; track/media operations are unavailable", { outcome: "not-submitted" });
747
+ throw ReactorError.fromCode("UnsupportedCapability", "This peer is data-only; track/media operations are unavailable", { outcome: "not-submitted" });
662
748
  const track = connection.negotiated.descriptor.capabilities.tracks.find((t) => t.name === name);
663
749
  if (track === undefined)
664
- throw new ReactorError("InvalidState", `unknown track: ${name}`);
750
+ throw ReactorError.fromCode("InvalidState", `unknown track: ${name}`);
665
751
  return track;
666
752
  }
667
753
  trackOperation(name, body, expected) {
@@ -670,7 +756,7 @@ export class Session {
670
756
  this.assertCurrent(c);
671
757
  const track = this.namedTrack(name, c);
672
758
  if (c.trackBusy.has(name))
673
- throw new ReactorError("InvalidState", `another track operation is in flight: ${name}`);
759
+ throw ReactorError.fromCode("InvalidState", `another track operation is in flight: ${name}`);
674
760
  c.trackBusy.add(name);
675
761
  return { c, track };
676
762
  }), ({ c, track }) => this.guard(c, body(c, track)), ({ c }) => Effect.sync(() => {
@@ -702,17 +788,17 @@ export class Session {
702
788
  self.assertCurrent(c);
703
789
  invoked = true;
704
790
  }).pipe(Effect.andThen(Effect.suspend(() => c.peer.replace(name, clone))), Effect.timeoutOrElse({
705
- duration: self.commandTimeout,
791
+ duration: self.replyTimeout,
706
792
  orElse: () => {
707
793
  deadline = true;
708
- return Effect.fail(new ReactorError("Timeout", "sender replacement deadline; local generation retired", { operation: name, outcome: "unknown" }));
794
+ return Effect.fail(ReactorError.fromCode("Timeout", "sender replacement deadline; local generation retired", { operation: name, outcome: "unknown" }));
709
795
  },
710
796
  }));
711
797
  yield* restore(mutation).pipe(Effect.onExit((exit) => Exit.isFailure(exit)
712
798
  ? Effect.sync(() => {
713
799
  clone?.stop();
714
800
  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" }));
801
+ self.fail(c, ReactorError.fromCode(deadline ? "Timeout" : "Disconnected", "native sender replacement abandoned; reconnect explicitly before reusing publication", { operation: name, outcome: "unknown" }));
716
802
  })
717
803
  : Effect.void));
718
804
  // No interruptible boundary between successful mutation and recording its owner.
@@ -738,17 +824,15 @@ export class Session {
738
824
  const self = this;
739
825
  return this.trackOperation(name, (c, track) => Effect.gen(function* () {
740
826
  if (c.peer.nativeTracks === false)
741
- return yield* Effect.fail(new ReactorError("UnsupportedCapability", "this peer does not accept browser media tracks", { outcome: "not-submitted" }));
827
+ return yield* ReactorError.fromCode("UnsupportedCapability", "this peer does not accept browser media tracks", { outcome: "not-submitted" });
742
828
  if (track.direction !== "sendonly" ||
743
829
  source.kind !== track.kind ||
744
830
  source.readyState !== "live")
745
- return yield* Effect.fail(new ReactorError("InvalidState", "publish requires a live matching input track"));
831
+ return yield* ReactorError.fromCode("InvalidState", "publish requires a live matching input track");
746
832
  if (!c.claimed.has(name)) {
747
833
  const reply = yield* self.controlRequest("publish_track", { case: "publish_track", value: { name } }, c);
748
834
  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
- }));
835
+ return yield* ReactorError.fromCode("UnexpectedReply", "publisher claim reply mismatch", { outcome: "replied" });
752
836
  c.claimed.add(name);
753
837
  }
754
838
  yield* self.replaceSender(c, name, source);
@@ -756,7 +840,7 @@ export class Session {
756
840
  }
757
841
  unpublish(name, expected) {
758
842
  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" }))
843
+ ? Effect.fail(ReactorError.fromCode("UnsupportedCapability", "this peer does not expose browser track publication", { outcome: "not-submitted" }))
760
844
  : this.replaceSender(c, name, null).pipe(Effect.andThen(this.notification(c, { case: "unpublish_track", value: { name } })), Effect.tap(() => Effect.sync(() => {
761
845
  c.claimed.delete(name);
762
846
  }))), expected);
@@ -766,7 +850,7 @@ export class Session {
766
850
  const c = expected ?? this.currentReady();
767
851
  this.assertCurrent(c);
768
852
  if (c.peer.nativeTracks === false)
769
- throw new ReactorError("UnsupportedCapability", "this peer exposes owned decoded frames instead of browser tracks", { outcome: "not-submitted" });
853
+ throw ReactorError.fromCode("UnsupportedCapability", "this peer exposes owned decoded frames instead of browser tracks", { outcome: "not-submitted" });
770
854
  return { peer: c.peer, track: c.peer.lease(name) };
771
855
  }), ({ peer, track }) => Effect.sync(() => peer.release(track))).pipe(Effect.map(({ track }) => track));
772
856
  }
@@ -779,7 +863,7 @@ export class Session {
779
863
  const self = this;
780
864
  return Effect.gen(function* () {
781
865
  const connection = yield* pure(() => self.currentReady());
782
- const raw = yield* self.guard(connection, connection.peer.stats());
866
+ const raw = yield* self.guard(connection, connection.peer.stats);
783
867
  // Elapsed sampling time belongs to this Effect boundary, not the pure sampler.
784
868
  const atNanos = yield* Clock.monotonicTimeNanos;
785
869
  const atMs = Number(atNanos) / 1_000_000;
@@ -787,9 +871,9 @@ export class Session {
787
871
  });
788
872
  }
789
873
  rawStats() {
790
- return pure(() => this.currentReady()).pipe(Effect.flatMap((c) => this.guard(c, c.peer.stats())));
874
+ return pure(() => this.currentReady()).pipe(Effect.flatMap((c) => this.guard(c, c.peer.stats)));
791
875
  }
792
- upload(name, mimeType, bytes, timeoutMs = 60_000) {
876
+ upload(name, mimeType, bytes, options = {}) {
793
877
  const self = this;
794
878
  return Effect.suspend(() => {
795
879
  let progress = {
@@ -803,7 +887,6 @@ export class Session {
803
887
  nonempty(name, "upload name");
804
888
  nonempty(mimeType, "upload MIME type");
805
889
  positiveLimit(bytes.byteLength, "upload size", self.uploadBound);
806
- positiveLimit(timeoutMs, "upload timeout", 600_000);
807
890
  return { c, remote, copy: new Uint8Array(bytes) };
808
891
  });
809
892
  progress = { ...progress, allocation: "unknown" };
@@ -825,12 +908,22 @@ export class Session {
825
908
  progress = { ...progress, notification: "submitted" };
826
909
  return { file, transfer: "confirmed", notification: "submitted" };
827
910
  });
828
- return withDeadline(operation, timeoutMs, "upload").pipe(Effect.mapError((e) => new ReactorError("Upload", e.message, {
911
+ return pure(() => options.uploadTimeout === undefined
912
+ ? self.uploadTimeout
913
+ : uploadTimeout(options.uploadTimeout)).pipe(Effect.flatMap((deadline) => withDeadline(operation, deadline, "upload")), Effect.mapError((e) => ReactorError.fromCode("Upload", e.message, {
829
914
  ...e.context,
830
915
  operation: "upload",
831
916
  detail: { cause: e, progress: { ...progress } },
832
917
  })), Effect.ensuring(Effect.sync(() => self.emit({ _tag: "Upload", progress: Object.freeze({ ...progress }) }))));
833
- });
918
+ }).pipe(
919
+ // The MIME type and size only: never the name or the bytes.
920
+ Effect.withSpan("reactor.session.upload", {
921
+ kind: "client",
922
+ attributes: {
923
+ "reactor.upload.mime_type": mimeType,
924
+ "reactor.upload.size": bytes.byteLength,
925
+ },
926
+ }, { captureStackTrace: false }));
834
927
  }
835
928
  /** Idempotent close. Owned-session termination is attempted once; response is not terminal proof. */
836
929
  close() {
@@ -842,27 +935,33 @@ export class Session {
842
935
  self.closeGate = gate;
843
936
  return Effect.gen(function* () {
844
937
  self.lifecycle.transition("closing");
845
- Deferred.doneUnsafe(self.lifecycle.closing, Effect.fail(new ReactorError("Closed", "session closing")));
938
+ Deferred.doneUnsafe(self.lifecycle.closing, Effect.fail(ReactorError.fromCode("Closed", "session closing")));
846
939
  const report = yield* cleanupSession({
847
940
  connection: self.lifecycle.connection,
848
941
  scope: self.lifecycle.scope,
849
942
  remote: self.remote,
850
943
  http: self.http,
851
- commandTimeout: self.commandTimeout,
944
+ replyTimeout: self.replyTimeout,
852
945
  retire: (connection, error) => self.fail(connection, error),
853
946
  });
854
947
  self.closeReport = report;
855
948
  self.lifecycle.transition("closed");
856
949
  self.observations.end();
857
950
  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
- }
951
+ // Consumer callbacks cannot undo or stall cleanup.
952
+ yield* Effect.ignore(Effect.try(() => self.options.onClose?.(report)));
953
+ yield* Effect.annotateCurrentSpan({
954
+ "reactor.close.local_closed": report.localClosed,
955
+ "reactor.close.allocation": report.allocation,
956
+ ...terminationAttributes(report.remote),
957
+ });
864
958
  return report;
865
- });
959
+ }).pipe(
960
+ // Only the close that runs cleanup is traced; a later close awaits its report.
961
+ Effect.withSpan("reactor.session.close", {
962
+ kind: "client",
963
+ attributes: self.remote.id === undefined ? {} : { "reactor.session.id": self.remote.id },
964
+ }, { captureStackTrace: false }));
866
965
  }));
867
966
  }
868
967
  isKnownTerminal() {