reactor-effect-native 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 (63) hide show
  1. package/Dockerfile +4 -2
  2. package/README.md +90 -15
  3. package/dist/_internal/bridge.d.ts +70 -14
  4. package/dist/_internal/bridge.d.ts.map +1 -1
  5. package/dist/_internal/bridge.js +290 -145
  6. package/dist/_internal/bridge.js.map +1 -1
  7. package/dist/_internal/isolated/child.d.ts +2 -0
  8. package/dist/_internal/isolated/child.d.ts.map +1 -0
  9. package/dist/_internal/isolated/child.js +176 -0
  10. package/dist/_internal/isolated/child.js.map +1 -0
  11. package/dist/_internal/isolated/host.d.ts +147 -0
  12. package/dist/_internal/isolated/host.d.ts.map +1 -0
  13. package/dist/_internal/isolated/host.js +645 -0
  14. package/dist/_internal/isolated/host.js.map +1 -0
  15. package/dist/_internal/isolated/protocol.d.ts +399 -0
  16. package/dist/_internal/isolated/protocol.d.ts.map +1 -0
  17. package/dist/_internal/isolated/protocol.js +250 -0
  18. package/dist/_internal/isolated/protocol.js.map +1 -0
  19. package/dist/_internal/peer.d.ts +68 -13
  20. package/dist/_internal/peer.d.ts.map +1 -1
  21. package/dist/_internal/peer.js +233 -157
  22. package/dist/_internal/peer.js.map +1 -1
  23. package/dist/index.d.ts +19 -10
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +37 -33
  26. package/dist/index.js.map +1 -1
  27. package/dist/isolated.d.ts +25 -0
  28. package/dist/isolated.d.ts.map +1 -0
  29. package/dist/isolated.js +39 -0
  30. package/dist/isolated.js.map +1 -0
  31. package/lib/darwin-arm64/libreactor_effect_native.dylib +0 -0
  32. package/lib/darwin-arm64/native-identity.json +4 -3
  33. package/lib/linux-x64/libreactor_effect_native.so +0 -0
  34. package/lib/linux-x64/native-identity.json +4 -3
  35. package/package.json +7 -3
  36. package/rust/Cargo.toml +108 -2
  37. package/rust/build.rs +253 -114
  38. package/rust/clippy.toml +7 -0
  39. package/rust/include/reactor_effect_native.h +101 -42
  40. package/rust/src/abi.rs +258 -0
  41. package/rust/src/error.rs +149 -0
  42. package/rust/src/ffi/memory.rs +256 -0
  43. package/rust/src/ffi/tests.rs +719 -0
  44. package/rust/src/ffi.rs +474 -0
  45. package/rust/src/lib.rs +36 -2341
  46. package/rust/src/peer/callbacks.rs +145 -0
  47. package/rust/src/peer/media.rs +221 -0
  48. package/rust/src/peer/owner/tests.rs +188 -0
  49. package/rust/src/peer/owner.rs +353 -0
  50. package/rust/src/peer/shared.rs +323 -0
  51. package/rust/src/peer/tests.rs +513 -0
  52. package/rust/src/peer.rs +189 -0
  53. package/rust/src/protocol/event.rs +238 -0
  54. package/rust/src/protocol/request.rs +347 -0
  55. package/rust/src/protocol/stats.rs +356 -0
  56. package/rust/src/protocol.rs +71 -0
  57. package/rust/src/sync/gate.rs +159 -0
  58. package/rust/src/sync/notifier.rs +136 -0
  59. package/rust/src/sync/queue.rs +384 -0
  60. package/rust/src/sync.rs +20 -0
  61. package/rust/src/test_support.rs +99 -0
  62. package/rust-toolchain.toml +7 -0
  63. package/scripts/stage.mjs +41 -1
@@ -1,9 +1,13 @@
1
+ import * as Duration from "effect/Duration";
1
2
  import * as Effect from "effect/Effect";
3
+ import * as Predicate from "effect/Predicate";
4
+ import * as Queue from "effect/Queue";
5
+ import * as Result from "effect/Result";
2
6
  import * as Schema from "effect/Schema";
3
7
  import * as Stream from "effect/Stream";
4
- import { ReactorError, ErrorCode } from "reactor-effect-client";
8
+ import { IceFailed, Mapping, ReactorError, TransportFailed } from "reactor-effect-client";
5
9
  import { Observations, errorOf } from "reactor-effect-client/host";
6
- import { encodeNativeJson, encodeNativeText, NativeBridge, NativeCall, } from "./bridge.js";
10
+ import { encodeNativeJson, encodeNativeText, NativeBridge, NativeCall, nativeFailure, Ready, } from "./bridge.js";
7
11
  const stateValues = new Set([
8
12
  "new",
9
13
  "connecting",
@@ -12,7 +16,6 @@ const stateValues = new Set([
12
16
  "failed",
13
17
  "closed",
14
18
  ]);
15
- const isErrorCode = Schema.is(ErrorCode);
16
19
  const statsBigInts = new Set([
17
20
  "bytesSent",
18
21
  "bytesReceived",
@@ -21,61 +24,66 @@ const statsBigInts = new Set([
21
24
  "retransmittedPacketsSent",
22
25
  "priority",
23
26
  ]);
24
- const validateNativeTracks = (tracks) => {
27
+ // Bound on reading statistics to classify a failed connection.
28
+ const CLASSIFY_TIMEOUT_MS = 2000;
29
+ /**
30
+ * How long closing a peer waits for its native owner join. A healthy join under
31
+ * real libwebrtc took 13-62 ms in the media load tests on Node and Bun, which
32
+ * require it under 2 s; the default leaves five times that bound.
33
+ */
34
+ export const defaultShutdownTimeout = Duration.seconds(10);
35
+ export const validateNativeTracks = (tracks) => {
25
36
  const incomingVideo = tracks.filter((track) => track.direction === "recvonly" && track.kind === "video").length;
26
37
  const incomingAudio = tracks.filter((track) => track.direction === "recvonly" && track.kind === "audio").length;
27
38
  if (incomingVideo > 1 || incomingAudio > 1) {
28
- throw new ReactorError("UnsupportedCapability", "native WebRTC currently supports at most one incoming video and one incoming audio track because reactor-webrtc does not expose the remote track MID to its observer", { outcome: "not-submitted" });
39
+ throw ReactorError.fromCode("UnsupportedCapability", "native WebRTC currently supports at most one incoming video and one incoming audio track because reactor-webrtc does not expose the remote track MID to its observer", { outcome: "not-submitted" });
29
40
  }
30
41
  };
31
42
  const record = (value, what) => {
32
- if (value === null || typeof value !== "object" || Array.isArray(value))
33
- throw new ReactorError("Protocol", `native ${what} is not an object`);
43
+ if (!Predicate.isObject(value))
44
+ throw ReactorError.fromCode("Protocol", `native ${what} is not an object`);
34
45
  return value;
35
46
  };
36
47
  const string = (value, what) => {
37
48
  if (typeof value !== "string")
38
- throw new ReactorError("Protocol", `native ${what} is not a string`);
49
+ throw ReactorError.fromCode("Protocol", `native ${what} is not a string`);
39
50
  return value;
40
51
  };
41
52
  const integer = (value, what) => {
42
53
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
43
- throw new ReactorError("Protocol", `native ${what} is not a nonnegative safe integer`);
54
+ throw ReactorError.fromCode("Protocol", `native ${what} is not a nonnegative safe integer`);
44
55
  return value;
45
56
  };
46
57
  const bigint = (value, what) => {
47
58
  if (typeof value !== "string" || !/^[0-9]+$/.test(value))
48
- throw new ReactorError("Protocol", `native ${what} is not an unsigned integer string`);
59
+ throw ReactorError.fromCode("Protocol", `native ${what} is not an unsigned integer string`);
49
60
  return BigInt(value);
50
61
  };
51
- const nativeError = (cause, operation) => cause instanceof ReactorError ? cause : errorOf(cause, "Native", operation);
62
+ const nativeError = (cause, operation) => ReactorError.is(cause) ? cause : errorOf(cause, "Native", operation);
63
+ const decodeMappings = Schema.decodeUnknownResult(Schema.Array(Mapping).check(Schema.isMaxLength(64)));
64
+ /** The client's own `Mapping` schema, so the host and the session agree on every rule. */
52
65
  const parseMapping = (value) => {
53
- if (!Array.isArray(value) || value.length > 64)
54
- throw new ReactorError("Protocol", "native prepare returned an invalid mapping list");
55
- return Object.freeze(value.map((item) => {
56
- const entry = record(item, "mapping");
57
- const name = string(entry.name, "mapping.name"), kind = string(entry.kind, "mapping.kind"), direction = string(entry.direction, "mapping.direction"), mid = string(entry.mid, "mapping.mid");
58
- if (kind !== "audio" && kind !== "video")
59
- throw new ReactorError("Protocol", "native mapping has an unknown track kind");
60
- if (direction !== "recvonly" && direction !== "sendonly")
61
- throw new ReactorError("Protocol", "native mapping has an unknown direction");
62
- return Object.freeze({ name, kind, direction, mid });
63
- }));
66
+ const decoded = decodeMappings(value);
67
+ if (Result.isFailure(decoded))
68
+ throw ReactorError.fromCode("Protocol", "native prepare returned an invalid mapping list", {
69
+ detail: decoded.failure,
70
+ });
71
+ return Object.freeze(decoded.success.map((entry) => Object.freeze({ ...entry })));
64
72
  };
65
73
  const parsePrepared = (value) => {
66
74
  const response = record(value, "prepare response"), sdp = string(response.sdp, "prepare.sdp"), mapping = parseMapping(response.mapping);
67
75
  if (sdp.length === 0)
68
- throw new ReactorError("Protocol", "native prepare returned an empty SDP offer");
76
+ throw ReactorError.fromCode("Protocol", "native prepare returned an empty SDP offer");
69
77
  return Object.freeze({ sdp, mapping });
70
78
  };
71
- const iceServers = (servers) => servers.map((server) => {
79
+ export const iceServers = (servers) => servers.map((server) => {
72
80
  const urls = typeof server.urls === "string" ? [server.urls] : [...server.urls];
73
81
  if (urls.length === 0 || urls.some((url) => typeof url !== "string" || url.length === 0))
74
- throw new ReactorError("InvalidInput", "native ICE server has no usable URL", {
82
+ throw ReactorError.fromCode("InvalidInput", "native ICE server has no usable URL", {
75
83
  outcome: "not-submitted",
76
84
  });
77
85
  if (server.credential !== undefined && typeof server.credential !== "string")
78
- throw new ReactorError("UnsupportedCapability", "native ICE supports password credentials, not OAuth credential objects", { outcome: "not-submitted" });
86
+ throw ReactorError.fromCode("UnsupportedCapability", "native ICE supports password credentials, not OAuth credential objects", { outcome: "not-submitted" });
79
87
  return Object.freeze({
80
88
  urls: Object.freeze(urls),
81
89
  username: server.username ?? "",
@@ -89,21 +97,21 @@ const parseEvent = (packet) => {
89
97
  case "state": {
90
98
  const state = string(header.state, "state");
91
99
  if (!stateValues.has(state))
92
- throw new ReactorError("Protocol", `native peer reported unknown state ${state}`);
100
+ throw ReactorError.fromCode("Protocol", `native peer reported unknown state ${state}`);
93
101
  return { type: "state", state: state };
94
102
  }
95
103
  case "channel": {
96
104
  const channel = string(header.channel, "channel");
97
105
  if (channel !== "control" && channel !== "data")
98
- throw new ReactorError("Protocol", "native peer reported an unknown data channel");
106
+ throw ReactorError.fromCode("Protocol", "native peer reported an unknown data channel");
99
107
  if (typeof header.open !== "boolean")
100
- throw new ReactorError("Protocol", "native channel event omitted its open state");
108
+ throw ReactorError.fromCode("Protocol", "native channel event omitted its open state");
101
109
  return { type: "channel", channel, open: header.open };
102
110
  }
103
111
  case "message": {
104
112
  const channel = string(header.channel, "message.channel");
105
113
  if (channel !== "control" && channel !== "data")
106
- throw new ReactorError("Protocol", "native peer message named an unknown channel");
114
+ throw ReactorError.fromCode("Protocol", "native peer message named an unknown channel");
107
115
  return { type: "message", channel, bytes: packet.payload };
108
116
  }
109
117
  case "ice": {
@@ -127,7 +135,7 @@ const parseEvent = (packet) => {
127
135
  case "decoded": {
128
136
  const kind = string(header.kind, "decoded.kind");
129
137
  if (kind !== "video" && kind !== "audio")
130
- throw new ReactorError("Protocol", "native decoded event has unknown media kind");
138
+ throw ReactorError.fromCode("Protocol", "native decoded event has unknown media kind");
131
139
  return {
132
140
  type: "decoded",
133
141
  kind,
@@ -136,63 +144,62 @@ const parseEvent = (packet) => {
136
144
  };
137
145
  }
138
146
  case "error": {
139
- const rawCode = string(header.code, "error.code"), code = isErrorCode(rawCode) ? rawCode : "Native";
140
- string(header.message, "error.message");
147
+ const status = header.status;
148
+ if (typeof status !== "number" || !Number.isSafeInteger(status))
149
+ throw ReactorError.fromCode("Protocol", "native error event omitted its failure class");
141
150
  return {
142
151
  type: "error",
143
- error: new ReactorError(code, `native peer failed (${code})`, { detail: header }),
152
+ error: nativeFailure(status, string(header.message, "error.message"), (code) => `native peer failed (${code})`, {}),
144
153
  };
145
154
  }
146
155
  default:
147
- throw new ReactorError("Protocol", `native peer emitted unknown event type ${type}`);
156
+ throw ReactorError.fromCode("Protocol", `native peer emitted unknown event type ${type}`);
148
157
  }
149
158
  };
150
- const parseVideo = (packet) => {
151
- const h = packet.header;
152
- if (h.type !== "video" || h.format !== "BGRA")
153
- throw new ReactorError("Protocol", "native video packet has an unsupported format");
154
- const width = integer(h.width, "video.width"), height = integer(h.height, "video.height"), dataLength = integer(h.dataLength, "video.dataLength"), metadataLength = integer(h.metadataLength, "video.metadataLength");
155
- if (width === 0 ||
156
- height === 0 ||
157
- dataLength !== width * height * 4 ||
158
- dataLength + metadataLength !== packet.payload.length)
159
- throw new ReactorError("Protocol", "native BGRA frame dimensions do not match its payload");
159
+ /** The native track index is the position of the track in the prepare request. */
160
+ const receiving = (tracks, index, kind) => {
161
+ const track = tracks[index];
162
+ if (track?.direction !== "recvonly" || track.kind !== kind)
163
+ throw ReactorError.fromCode("Protocol", `native ${kind} was delivered without its declared receive mapping`);
164
+ return track.name;
165
+ };
166
+ const videoFrame = (tracks, taken) => {
167
+ const track = receiving(tracks, taken.track, "video");
168
+ if (taken.width === 0 ||
169
+ taken.height === 0 ||
170
+ taken.data.byteLength !== taken.width * taken.height * 4)
171
+ throw ReactorError.fromCode("Protocol", "native BGRA frame dimensions do not match its payload");
160
172
  return Object.freeze({
161
173
  _tag: "VideoFrame",
162
- track: string(h.track, "video.track"),
163
- width,
164
- height,
165
- frameId: bigint(h.frameId, "video.frameId"),
166
- timestampMicros: bigint(h.timestampMicros, "video.timestampMicros"),
167
- data: Uint8Array.from(packet.payload.subarray(0, dataLength)),
168
- metadata: Uint8Array.from(packet.payload.subarray(dataLength)),
174
+ track,
175
+ format: "BGRA",
176
+ width: taken.width,
177
+ height: taken.height,
178
+ frameId: taken.frameId,
179
+ timestampMicros: taken.timestampMicros,
180
+ sequence: taken.sequence,
181
+ data: taken.data,
182
+ metadata: taken.metadata,
169
183
  });
170
184
  };
171
- const parseAudio = (packet) => {
172
- const h = packet.header;
173
- if (h.type !== "audio" || h.format !== "s16le")
174
- throw new ReactorError("Protocol", "native audio packet has an unsupported format");
175
- const sampleRate = integer(h.sampleRate, "audio.sampleRate"), channels = integer(h.channels, "audio.channels"), samples = integer(h.samples, "audio.samples");
176
- if (sampleRate === 0 ||
177
- channels === 0 ||
178
- samples % channels !== 0 ||
179
- packet.payload.length !== samples * 2)
180
- throw new ReactorError("Protocol", "native PCM format does not match its payload");
181
- const view = new DataView(packet.payload.buffer, packet.payload.byteOffset, packet.payload.byteLength), pcm = new Int16Array(samples);
182
- for (let index = 0; index < samples; index++)
183
- pcm[index] = view.getInt16(index * 2, true);
185
+ const audioFrame = (tracks, taken) => {
186
+ const track = receiving(tracks, taken.track, "audio");
187
+ if (taken.sampleRate === 0 || taken.channels === 0 || taken.samples.length % taken.channels)
188
+ throw ReactorError.fromCode("Protocol", "native PCM format does not match its payload");
184
189
  return Object.freeze({
185
190
  _tag: "AudioFrame",
186
- track: string(h.track, "audio.track"),
187
- sampleRate,
188
- channels,
189
- samples: pcm,
191
+ track,
192
+ sampleRate: taken.sampleRate,
193
+ channels: taken.channels,
194
+ sequence: taken.sequence,
195
+ samples: taken.samples,
190
196
  });
191
197
  };
198
+ /** The native snapshot's transport counters; the host adds its own reader overflows. */
192
199
  const parseSnapshot = (value) => {
193
200
  const s = record(value, "media snapshot");
194
201
  if (typeof s.closed !== "boolean")
195
- throw new ReactorError("Protocol", "native media snapshot omitted closed state");
202
+ throw ReactorError.fromCode("Protocol", "native media snapshot omitted closed state");
196
203
  return Object.freeze({
197
204
  closed: s.closed,
198
205
  queuedControl: integer(s.queuedControl, "snapshot.queuedControl"),
@@ -219,23 +226,57 @@ const statsValue = (value) => {
219
226
  : statsValue(entry);
220
227
  return output;
221
228
  };
222
- const pollPacket = (poll) => bridgeEffect("poll native WebRTC", async () => {
223
- const result = await poll();
224
- return result._tag === "Packet" ? result.packet : result._tag === "Closed" ? null : undefined;
225
- });
229
+ /**
230
+ * Classify a failed connection from its candidate pairs. reactor-webrtc does
231
+ * not report ICE connection state, but a pair that succeeded or was nominated
232
+ * shows ICE worked and the DTLS/SCTP transport above it failed.
233
+ */
234
+ const connectionFailure = (stats) => {
235
+ const entries = stats.filter(Predicate.isObject);
236
+ const pairs = entries.filter((entry) => entry.type === "candidate-pair");
237
+ if (pairs.some((pair) => pair.state === "succeeded" || pair.nominated === true))
238
+ return new ReactorError({
239
+ reason: new TransportFailed({
240
+ message: "native peer failed after ICE connectivity succeeded",
241
+ pairs: pairs.length,
242
+ }),
243
+ });
244
+ const candidateTypes = [
245
+ ...new Set(entries
246
+ .filter((entry) => entry.type === "local-candidate")
247
+ .map((entry) => entry.candidateType)
248
+ .filter((type) => typeof type === "string")),
249
+ ];
250
+ return new ReactorError({
251
+ reason: new IceFailed({
252
+ message: "native peer found no working ICE candidate pair",
253
+ pairs: pairs.length,
254
+ candidateTypes,
255
+ }),
256
+ });
257
+ };
258
+ /** One synchronous, nonblocking take from a native queue, as a pump step. */
259
+ const drain = (step) => Effect.try({ try: step, catch: (cause) => nativeError(cause, "drain native WebRTC") });
226
260
  export class NativePeer {
261
+ shutdownTimeout;
227
262
  nativeTracks = false;
228
263
  rawMedia;
229
264
  bridge;
230
265
  video = new Map();
231
266
  audio = new Map();
232
267
  incoming = new Map();
268
+ tracks = [];
269
+ // Readiness wakes one pump per native queue; a pending wake coalesces.
270
+ wakeEvents = Effect.runSync(Queue.dropping(1));
271
+ wakeVideo = Effect.runSync(Queue.dropping(1));
272
+ wakeAudio = Effect.runSync(Queue.dropping(1));
233
273
  emit;
234
274
  closed = false;
235
275
  failureEmitted = false;
236
276
  failure;
237
- constructor(libraryPath) {
238
- this.bridge = new NativeBridge(libraryPath);
277
+ constructor(libraryPath, shutdownTimeout = defaultShutdownTimeout) {
278
+ this.shutdownTimeout = shutdownTimeout;
279
+ this.bridge = new NativeBridge(libraryPath, (ready) => this.wake(ready));
239
280
  this.rawMedia = Object.freeze({
240
281
  video: (name) => Stream.unwrap(Effect.try({
241
282
  try: () => {
@@ -256,7 +297,16 @@ export class NativePeer {
256
297
  }
257
298
  requireIncoming(name, kind) {
258
299
  if (this.incoming.get(name) !== kind)
259
- throw new ReactorError("InvalidInput", "native media requires a declared receive track of the requested kind", { outcome: "not-submitted" });
300
+ throw ReactorError.fromCode("InvalidInput", "native media requires a declared receive track of the requested kind", { outcome: "not-submitted" });
301
+ }
302
+ /** Readers failed for falling behind, across this peer's video and audio tracks. */
303
+ readerOverflows() {
304
+ let overflows = 0n;
305
+ for (const feed of this.video.values())
306
+ overflows += feed.overflowCount;
307
+ for (const feed of this.audio.values())
308
+ overflows += feed.overflowCount;
309
+ return overflows;
260
310
  }
261
311
  videoFeed(name) {
262
312
  let feed = this.video.get(name);
@@ -299,78 +349,86 @@ export class NativePeer {
299
349
  this.close();
300
350
  }
301
351
  }
302
- pumpEvents() {
352
+ /** The native readiness callback, on the JavaScript thread: it only wakes pumps. */
353
+ wake(ready) {
354
+ if (ready & Ready.Events)
355
+ Queue.offerUnsafe(this.wakeEvents, undefined);
356
+ if (ready & Ready.Video)
357
+ Queue.offerUnsafe(this.wakeVideo, undefined);
358
+ if (ready & Ready.Audio)
359
+ Queue.offerUnsafe(this.wakeAudio, undefined);
360
+ }
361
+ /**
362
+ * Drain one native queue with synchronous takes whenever readiness wakes
363
+ * it. Yielding after every item lets observers run between emits, so a
364
+ * backlog released by a stalled event loop reaches bounded observation
365
+ * queues at their readers' pace rather than all at once. Yielding once per
366
+ * batch of half a subscriber's capacity overflowed an observation queue on
367
+ * Bun under CPU contention, so each item costs one event-loop turn.
368
+ */
369
+ pump(wake, step) {
303
370
  const self = this;
304
371
  return Effect.gen(function* () {
305
372
  while (!self.closed) {
306
- const packet = yield* pollPacket(() => self.bridge.pollEvent());
307
- if (self.closed)
308
- return;
309
- if (packet === null)
310
- return;
311
- if (packet === undefined)
312
- continue;
313
- const event = yield* Effect.try({
314
- try: () => parseEvent(packet),
315
- catch: (cause) => nativeError(cause, "decode native event"),
316
- });
317
- if (event.type === "error") {
318
- self.fail(event.error);
319
- return;
373
+ yield* Queue.take(wake);
374
+ while (!self.closed && (yield* step)) {
375
+ yield* Effect.yieldNow;
320
376
  }
321
- self.emit?.(event);
322
377
  }
323
378
  }).pipe(Effect.catch((error) => Effect.sync(() => self.fail(error))));
324
379
  }
325
- pumpVideo() {
326
- const self = this;
327
- return Effect.gen(function* () {
328
- while (!self.closed) {
329
- const packet = yield* pollPacket(() => self.bridge.pollVideo());
330
- if (self.closed)
331
- return;
332
- if (packet === null)
333
- return;
334
- if (packet === undefined)
335
- continue;
336
- const frame = yield* Effect.try({
337
- try: () => {
338
- const frame = parseVideo(packet);
339
- if (self.incoming.get(frame.track) !== "video")
340
- throw new ReactorError("Protocol", "native video was delivered without its declared receive mapping");
341
- return frame;
342
- },
343
- catch: (cause) => nativeError(cause, "decode native video"),
344
- });
345
- self
346
- .videoFeed(frame.track)
347
- .emit(frame, frame.data.byteLength + frame.metadata.byteLength + frame.track.length * 2);
348
- }
349
- }).pipe(Effect.catch((error) => Effect.sync(() => self.fail(error))));
380
+ /**
381
+ * Deliver one event; false once the queue is empty or closed. A failed
382
+ * connection is classified here before the pump takes another event, so its
383
+ * classification decides the error; later events describe the same teardown.
384
+ */
385
+ get stepEvent() {
386
+ return drain(() => {
387
+ const packet = this.bridge.takeEvent();
388
+ if (packet === undefined || packet === null)
389
+ return false;
390
+ const event = parseEvent(packet);
391
+ if (event.type === "state" && event.state === "failed")
392
+ return "failed";
393
+ if (event.type === "error")
394
+ this.fail(event.error);
395
+ else
396
+ this.emit?.(event);
397
+ return true;
398
+ }).pipe(Effect.filterOrElse((taken) => taken !== "failed", () => this.classify.pipe(Effect.as(true))));
350
399
  }
351
- pumpAudio() {
352
- const self = this;
353
- return Effect.gen(function* () {
354
- while (!self.closed) {
355
- const packet = yield* pollPacket(() => self.bridge.pollAudio());
356
- if (self.closed)
357
- return;
358
- if (packet === null)
359
- return;
360
- if (packet === undefined)
361
- continue;
362
- const frame = yield* Effect.try({
363
- try: () => {
364
- const frame = parseAudio(packet);
365
- if (self.incoming.get(frame.track) !== "audio")
366
- throw new ReactorError("Protocol", "native audio was delivered without its declared receive mapping");
367
- return frame;
368
- },
369
- catch: (cause) => nativeError(cause, "decode native audio"),
370
- });
371
- self.audioFeed(frame.track).emit(frame, frame.samples.byteLength + frame.track.length * 2);
372
- }
373
- }).pipe(Effect.catch((error) => Effect.sync(() => self.fail(error))));
400
+ get stepVideo() {
401
+ return drain(() => {
402
+ const taken = this.bridge.takeVideo();
403
+ if (taken === undefined || taken === null)
404
+ return false;
405
+ const frame = videoFrame(this.tracks, taken);
406
+ this.videoFeed(frame.track).emit(frame, frame.data.byteLength + frame.metadata.byteLength + frame.track.length * 2);
407
+ return true;
408
+ });
409
+ }
410
+ get stepAudio() {
411
+ return drain(() => {
412
+ const taken = this.bridge.takeAudio();
413
+ if (taken === undefined || taken === null)
414
+ return false;
415
+ const frame = audioFrame(this.tracks, taken);
416
+ this.audioFeed(frame.track).emit(frame, frame.samples.byteLength + frame.track.length * 2);
417
+ return true;
418
+ });
419
+ }
420
+ /**
421
+ * Report a failed connection as IceFailed or TransportFailed rather than a
422
+ * bare state. The statistics read runs in the events pump, so the connection
423
+ * scope owns it and its deadline runs on the fiber's Clock.
424
+ */
425
+ get classify() {
426
+ return bridgeEffect("classify native failure", () => this.bridge.call(NativeCall.Stats)).pipe(Effect.map((stats) => Array.isArray(stats)
427
+ ? connectionFailure(stats)
428
+ : ReactorError.fromCode("Disconnected", "peer state failed")), Effect.timeoutOrElse({
429
+ duration: CLASSIFY_TIMEOUT_MS,
430
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "native failure classification timed out")),
431
+ }), Effect.catch((cause) => Effect.succeed(ReactorError.fromCode("Disconnected", "peer state failed", { detail: cause }))), Effect.flatMap((error) => drain(() => this.fail(error))));
374
432
  }
375
433
  prepare(servers, tracks, emit) {
376
434
  const self = this;
@@ -379,6 +437,7 @@ export class NativePeer {
379
437
  try: () => validateNativeTracks(tracks),
380
438
  catch: (cause) => nativeError(cause, "validate native tracks"),
381
439
  });
440
+ self.tracks = Object.freeze([...tracks]);
382
441
  for (const track of tracks)
383
442
  if (track.direction === "recvonly")
384
443
  self.incoming.set(track.name, track.kind);
@@ -391,9 +450,9 @@ export class NativePeer {
391
450
  try: () => parsePrepared(value),
392
451
  catch: (cause) => nativeError(cause, "decode native prepare"),
393
452
  })));
394
- yield* Effect.forkScoped(self.pumpEvents());
395
- yield* Effect.forkScoped(self.pumpVideo());
396
- yield* Effect.forkScoped(self.pumpAudio());
453
+ yield* Effect.forkScoped(self.pump(self.wakeEvents, self.stepEvent));
454
+ yield* Effect.forkScoped(self.pump(self.wakeVideo, self.stepVideo));
455
+ yield* Effect.forkScoped(self.pump(self.wakeAudio, self.stepAudio));
397
456
  return prepared;
398
457
  });
399
458
  }
@@ -408,16 +467,14 @@ export class NativePeer {
408
467
  }
409
468
  maxBitrate(name, bitsPerSecond) {
410
469
  if (!Number.isSafeInteger(bitsPerSecond) || bitsPerSecond < 1 || bitsPerSecond > 0x7fffffff)
411
- return Effect.fail(new ReactorError("InvalidInput", "native max bitrate must be an integer in 1..2147483647", {
412
- outcome: "not-submitted",
413
- }));
470
+ return Effect.fail(ReactorError.fromCode("InvalidInput", "native max bitrate must be an integer in 1..2147483647", { outcome: "not-submitted" }));
414
471
  return bridgeEffect("set native sender bitrate", () => this.bridge.call(NativeCall.MaxBitrate, encodeNativeJson({ name, bitsPerSecond }))).pipe(Effect.asVoid);
415
472
  }
416
- stats() {
473
+ get stats() {
417
474
  return bridgeEffect("native WebRTC statistics", () => this.bridge.call(NativeCall.Stats)).pipe(Effect.flatMap((value) => Effect.try({
418
475
  try: () => {
419
476
  if (!Array.isArray(value))
420
- throw new ReactorError("Protocol", "native stats response is not an array");
477
+ throw ReactorError.fromCode("Protocol", "native stats response is not an array");
421
478
  return Object.freeze(statsValue(value));
422
479
  },
423
480
  catch: (cause) => nativeError(cause, "decode native stats"),
@@ -425,18 +482,18 @@ export class NativePeer {
425
482
  }
426
483
  mediaSnapshot() {
427
484
  return bridgeEffect("native media snapshot", () => this.bridge.call(NativeCall.MediaSnapshot)).pipe(Effect.flatMap((value) => Effect.try({
428
- try: () => parseSnapshot(value),
485
+ try: () => Object.freeze({ ...parseSnapshot(value), readerOverflows: this.readerOverflows() }),
429
486
  catch: (cause) => nativeError(cause, "decode native media snapshot"),
430
487
  })));
431
488
  }
432
489
  lease() {
433
- throw new ReactorError("UnsupportedCapability", "native WebRTC exposes owned decoded samples, not browser MediaStreamTrack leases", { outcome: "not-submitted" });
490
+ throw ReactorError.fromCode("UnsupportedCapability", "native WebRTC exposes owned decoded samples, not browser MediaStreamTrack leases", { outcome: "not-submitted" });
434
491
  }
435
492
  release() {
436
493
  /* lease never succeeds on this host. */
437
494
  }
438
495
  replace() {
439
- return Effect.fail(new ReactorError("UnsupportedCapability", "native WebRTC does not accept browser MediaStreamTrack publication", { outcome: "not-submitted" }));
496
+ return Effect.fail(ReactorError.fromCode("UnsupportedCapability", "native WebRTC does not accept browser MediaStreamTrack publication", { outcome: "not-submitted" }));
440
497
  }
441
498
  close() {
442
499
  if (this.closed)
@@ -444,6 +501,9 @@ export class NativePeer {
444
501
  this.closed = true;
445
502
  this.emit = undefined;
446
503
  this.bridge.close();
504
+ // Let waiting pumps observe the close and exit.
505
+ for (const wake of [this.wakeEvents, this.wakeVideo, this.wakeAudio])
506
+ Queue.offerUnsafe(wake, undefined);
447
507
  for (const feed of this.video.values()) {
448
508
  if (this.failure === undefined)
449
509
  feed.end();
@@ -457,23 +517,39 @@ export class NativePeer {
457
517
  feed.fail(this.failure);
458
518
  }
459
519
  }
460
- shutdown() {
520
+ /**
521
+ * Close, then wait for the native owner join. Only the wait is bounded: the
522
+ * deadline races the interruptible wait inside the uninterruptible region, so
523
+ * expiry stops waiting while the join keeps the handle and callback, and the
524
+ * bridge is retained until the join completes.
525
+ */
526
+ get shutdown() {
461
527
  return bridgeEffect("shutdown native WebRTC", () => {
462
528
  this.close();
463
529
  return this.bridge.shutdown();
464
- }).pipe(Effect.mapError((error) => error.code === "Shutdown"
530
+ }).pipe(
531
+ // A failed shutdown is a Shutdown failure; the native failure it came
532
+ // from stays in `detail` for inspection.
533
+ Effect.mapError((error) => error.reason._tag === "Shutdown"
465
534
  ? error
466
- : new ReactorError("Shutdown", error.message, error.context)), Effect.uninterruptible);
535
+ : ReactorError.fromCode("Shutdown", error.message, { ...error.context, detail: error })), Effect.timeoutOrElse({
536
+ duration: this.shutdownTimeout,
537
+ orElse: () => Effect.suspend(() => {
538
+ this.bridge.retain();
539
+ return Effect.fail(ReactorError.fromCode("Shutdown", "native owner join exceeded its deadline; handle retained", { operation: "shutdown native WebRTC" }));
540
+ }),
541
+ }), Effect.uninterruptible);
467
542
  }
468
543
  }
469
544
  /** Internal test surface. This module is not a package export. */
470
545
  export const nativePeerTesting = Object.freeze({
471
546
  parsePrepared,
472
547
  parseEvent,
473
- parseVideo,
474
- parseAudio,
548
+ videoFrame,
549
+ audioFrame,
475
550
  parseSnapshot,
476
551
  statsValue,
477
552
  validateNativeTracks,
553
+ connectionFailure,
478
554
  });
479
555
  //# sourceMappingURL=peer.js.map