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
@@ -4,12 +4,18 @@ import { readFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import process from "node:process";
6
6
  import { fileURLToPath } from "node:url";
7
- import * as Schema from "effect/Schema";
8
- import { ReactorError, ErrorCode } from "reactor-effect-client";
9
- const ABI_VERSION = 2;
7
+ import * as Redacted from "effect/Redacted";
8
+ import { Native, ReactorError } from "reactor-effect-client";
9
+ const ABI_VERSION = 4;
10
10
  const CALL_BUFFER_BYTES = 4 * 1024 * 1024;
11
- const ERROR_BUFFER_BYTES = 4096;
12
- const MAX_PACKET_BYTES = 96 * 1024 * 1024;
11
+ const FAILURE_BYTES = 1024;
12
+ const VIDEO_HEADER_BYTES = 48;
13
+ const AUDIO_HEADER_BYTES = 24;
14
+ const EVENT_BUFFER_BYTES = 64 * 1024;
15
+ // The native queues' byte bounds: no single item can exceed them.
16
+ const MAX_EVENT_BYTES = 16 * 1024 * 1024;
17
+ const MAX_VIDEO_BYTES = 64 * 1024 * 1024;
18
+ const MAX_AUDIO_SAMPLES = 2 * 1024 * 1024;
13
19
  const MAX_IN_FLIGHT_CALLS = 128;
14
20
  const MAX_IN_FLIGHT_REQUEST_BYTES = 16 * 1024 * 1024;
15
21
  const MAX_REQUEST_BYTES = 1024 * 1024;
@@ -18,6 +24,38 @@ const STATUS_OK = 0;
18
24
  const STATUS_AGAIN = 1;
19
25
  const STATUS_BUFFER_TOO_SMALL = 2;
20
26
  const STATUS_CLOSED = 3;
27
+ /** The C ABI's closed set of failure classes, keyed by status. */
28
+ const FAILURE_CODES = new Map([
29
+ [STATUS_CLOSED, "Closed"],
30
+ [-1, "InvalidInput"],
31
+ [-2, "Native"],
32
+ [-3, "Overflow"],
33
+ [-4, "Protocol"],
34
+ [-5, "SdpRejected"],
35
+ [-6, "ChannelClosed"],
36
+ ]);
37
+ /** A status outside the ABI's classes is an unclassified native failure. */
38
+ export const failureCode = (status) => FAILURE_CODES.get(status) ?? "Native";
39
+ /**
40
+ * A failure the native ABI returned with `status`, classified by its failure
41
+ * class. libwebrtc's own text can contain peer SDP or caller-supplied
42
+ * signaling material, so it is kept Redacted for explicit inspection: in the
43
+ * `Native` reason for an unclassified failure, and in `context.detail`, beside
44
+ * the status, for a classified one.
45
+ */
46
+ export const nativeFailure = (status, backendText, message, context, detail = {}) => {
47
+ const code = failureCode(status);
48
+ const backendMessage = Redacted.make(backendText);
49
+ return code === "Native"
50
+ ? new ReactorError({
51
+ reason: new Native({ message: message(code), status, backendMessage }),
52
+ context: Object.keys(detail).length === 0 ? context : { ...context, detail },
53
+ })
54
+ : ReactorError.fromCode(code, message(code), {
55
+ ...context,
56
+ detail: { status, backendMessage, ...detail },
57
+ });
58
+ };
21
59
  export const NativeCall = Object.freeze({
22
60
  Prepare: 1,
23
61
  Answer: 2,
@@ -26,7 +64,11 @@ export const NativeCall = Object.freeze({
26
64
  Stats: 5,
27
65
  MediaSnapshot: 6,
28
66
  });
67
+ /** Readiness bits the native notifier passes to the host callback. */
68
+ export const Ready = Object.freeze({ Events: 1, Video: 2, Audio: 4 });
29
69
  const APIs = new Map();
70
+ // Koffi type names are process-global; one anonymous prototype serves every library.
71
+ let notifyType;
30
72
  const libraryName = () => {
31
73
  switch (process.platform) {
32
74
  case "darwin":
@@ -46,7 +88,7 @@ const defaultPaths = () => {
46
88
  };
47
89
  const asAsync = (value, symbol) => {
48
90
  if (typeof value !== "function" || !("async" in value)) {
49
- throw new ReactorError("Native", `native library symbol ${symbol} does not support asynchronous calls`);
91
+ throw ReactorError.fromCode("Native", `native library symbol ${symbol} does not support asynchronous calls`);
50
92
  }
51
93
  return value;
52
94
  };
@@ -56,7 +98,7 @@ const importKoffi = async () => {
56
98
  return module.default;
57
99
  }
58
100
  catch (cause) {
59
- throw new ReactorError("UnsupportedHost", "the native export requires the optional koffi dependency", { detail: cause, outcome: "not-submitted" });
101
+ throw ReactorError.fromCode("UnsupportedHost", "the native export requires the optional koffi dependency", { detail: cause, outcome: "not-submitted" });
60
102
  }
61
103
  };
62
104
  const loadAt = async (path) => {
@@ -73,7 +115,7 @@ const loadAt = async (path) => {
73
115
  library = koffi.load(path);
74
116
  }
75
117
  catch (cause) {
76
- throw new ReactorError("Native", `could not load native WebRTC bridge at ${path}`, {
118
+ throw ReactorError.fromCode("Native", `could not load native WebRTC bridge at ${path}`, {
77
119
  detail: cause,
78
120
  outcome: "not-submitted",
79
121
  });
@@ -83,25 +125,29 @@ const loadAt = async (path) => {
83
125
  return library.func(prototype);
84
126
  }
85
127
  catch (cause) {
86
- throw new ReactorError("Native", `native WebRTC bridge is incompatible: missing ${prototype.split("(")[0] ?? prototype}`, { detail: cause, outcome: "not-submitted" });
128
+ throw ReactorError.fromCode("Native", `native WebRTC bridge is incompatible: missing ${prototype.split("(")[0] ?? prototype}`, { detail: cause, outcome: "not-submitted" });
87
129
  }
88
130
  };
89
131
  const abi = symbol("uint32_t reactor_effect_abi_version(void)");
90
132
  const actual = abi();
91
133
  if (actual !== ABI_VERSION)
92
- throw new ReactorError("Native", `native WebRTC ABI mismatch: expected ${ABI_VERSION}, received ${actual}`, { outcome: "not-submitted" });
134
+ throw ReactorError.fromCode("Native", `native WebRTC ABI mismatch: expected ${ABI_VERSION}, received ${actual}`, { outcome: "not-submitted" });
135
+ notifyType ??= koffi.pointer(koffi.proto("void", ["uint32_t"]));
136
+ const notify = notifyType;
93
137
  const api = {
94
138
  library,
95
139
  binarySha256,
96
140
  buildIdentity: symbol("const char *reactor_effect_build_identity(void)")(),
97
- create: symbol("void *reactor_effect_peer_create(void)"),
98
- call: asAsync(symbol("int reactor_effect_peer_call(void *peer, uint32_t operation, const uint8_t *request, size_t request_len, _Out_ uint8_t *response, size_t response_cap, _Out_ size_t *response_len)"), "reactor_effect_peer_call"),
99
- send: asAsync(symbol("int reactor_effect_peer_send(void *peer, uint32_t channel, const uint8_t *data, size_t data_len, _Out_ uint8_t *error, size_t error_cap, _Out_ size_t *error_len)"), "reactor_effect_peer_send"),
100
- pollEvent: asAsync(symbol("int reactor_effect_peer_poll_event(void *peer, uint32_t timeout_ms, _Out_ uint8_t *out, size_t out_cap, _Out_ size_t *out_len)"), "reactor_effect_peer_poll_event"),
101
- pollVideo: asAsync(symbol("int reactor_effect_peer_poll_video(void *peer, uint32_t timeout_ms, _Out_ uint8_t *out, size_t out_cap, _Out_ size_t *out_len)"), "reactor_effect_peer_poll_video"),
102
- pollAudio: asAsync(symbol("int reactor_effect_peer_poll_audio(void *peer, uint32_t timeout_ms, _Out_ uint8_t *out, size_t out_cap, _Out_ size_t *out_len)"), "reactor_effect_peer_poll_audio"),
141
+ register: (callback) => koffi.register(callback, notify),
142
+ unregister: (callback) => koffi.unregister(callback),
143
+ create: symbol("void *reactor_effect_peer_create(void *notify)"),
144
+ call: asAsync(symbol("int reactor_effect_peer_call(void *peer, uint32_t operation, const uint8_t *request, size_t request_len, _Out_ uint8_t *response, size_t response_cap, _Out_ size_t *response_len, _Out_ uint8_t *failure)"), "reactor_effect_peer_call"),
145
+ send: asAsync(symbol("int reactor_effect_peer_send(void *peer, uint32_t channel, const uint8_t *data, size_t data_len, _Out_ uint8_t *failure)"), "reactor_effect_peer_send"),
146
+ takeEvent: symbol("int reactor_effect_peer_take_event(void *peer, _Out_ uint8_t *out, size_t out_cap, _Out_ size_t *out_len)"),
147
+ takeVideo: symbol("int reactor_effect_peer_take_video(void *peer, _Out_ uint8_t *header, _Out_ uint8_t *bgra, size_t bgra_cap, _Out_ uint8_t *metadata, size_t metadata_cap)"),
148
+ takeAudio: symbol("int reactor_effect_peer_take_audio(void *peer, _Out_ uint8_t *header, _Out_ int16_t *pcm, size_t pcm_cap)"),
103
149
  close: symbol("void reactor_effect_peer_close(void *peer)"),
104
- shutdown: asAsync(symbol("int reactor_effect_peer_shutdown(void *peer, _Out_ uint8_t *error, size_t error_cap, _Out_ size_t *error_len)"), "reactor_effect_peer_shutdown"),
150
+ shutdown: asAsync(symbol("int reactor_effect_peer_shutdown(void *peer, _Out_ uint8_t *failure)"), "reactor_effect_peer_shutdown"),
105
151
  destroy: symbol("void reactor_effect_peer_destroy(void *peer)"),
106
152
  };
107
153
  APIs.set(path, api);
@@ -122,7 +168,7 @@ export const resolveNativeBridge = async (path) => {
122
168
  failures.push(cause instanceof Error ? cause.message : String(cause));
123
169
  }
124
170
  }
125
- throw new ReactorError("Native", "native WebRTC bridge is not staged or its identity is invalid; run bun run native:build or provide an explicit library path", { detail: failures, outcome: "not-submitted" });
171
+ throw ReactorError.fromCode("Native", "native WebRTC bridge is not staged or its identity is invalid; run bun run native:build or provide an explicit library path", { detail: failures, outcome: "not-submitted" });
126
172
  };
127
173
  export const checkNativeBridge = async (path) => {
128
174
  await resolveNativeBridge(path);
@@ -130,13 +176,13 @@ export const checkNativeBridge = async (path) => {
130
176
  const checked = (path) => {
131
177
  const api = APIs.get(path);
132
178
  if (api === undefined)
133
- throw new ReactorError("InvalidState", "native WebRTC bridge was not preflighted; run PeerFactory.check before make", { outcome: "not-submitted" });
179
+ throw ReactorError.fromCode("InvalidState", "native WebRTC bridge was not loaded; build Native.layer before making a peer", { outcome: "not-submitted" });
134
180
  return api;
135
181
  };
136
182
  const toNumber = (value, name) => {
137
183
  const number = typeof value === "bigint" ? Number(value) : value;
138
184
  if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0)
139
- throw new ReactorError("Protocol", `native ${name} is outside the safe integer range`);
185
+ throw ReactorError.fromCode("Protocol", `native ${name} is outside the safe integer range`);
140
186
  return number;
141
187
  };
142
188
  const jsonRecord = (bytes, operation) => {
@@ -145,12 +191,12 @@ const jsonRecord = (bytes, operation) => {
145
191
  value = JSON.parse(new TextDecoder().decode(bytes));
146
192
  }
147
193
  catch (cause) {
148
- throw new ReactorError("Protocol", `native ${operation} returned invalid JSON`, {
194
+ throw ReactorError.fromCode("Protocol", `native ${operation} returned invalid JSON`, {
149
195
  detail: cause,
150
196
  });
151
197
  }
152
198
  if (value === null || typeof value !== "object" || Array.isArray(value))
153
- throw new ReactorError("Protocol", `native ${operation} returned a non-object response`);
199
+ throw ReactorError.fromCode("Protocol", `native ${operation} returned a non-object response`);
154
200
  return value;
155
201
  };
156
202
  /** Validate the staged artifact used by both source and installed-package tests. */
@@ -165,79 +211,134 @@ export const verifyStagedNativeBridge = async (path) => {
165
211
  manifest.platform !== `${process.platform}-${process.arch}` ||
166
212
  manifest.library !== libraryName() ||
167
213
  manifest.sha256 !== createHash("sha256").update(binary).digest("hex")) {
168
- throw new ReactorError("Native", "native artifact does not match its staged identity");
214
+ throw ReactorError.fromCode("Native", "native artifact does not match its staged identity");
169
215
  }
170
216
  const api = await loadAt(path);
171
217
  if (api.binarySha256 !== manifest.sha256) {
172
- throw new ReactorError("Native", "staged native artifact changed after this process loaded it");
218
+ throw ReactorError.fromCode("Native", "staged native artifact changed after this process loaded it");
173
219
  }
174
220
  const prefix = "reactor-effect-native:build-identity:", suffix = ":end";
175
221
  if (!api.buildIdentity.startsWith(prefix) || !api.buildIdentity.endsWith(suffix)) {
176
- throw new ReactorError("Native", "loaded native artifact omitted its source/build identity");
222
+ throw ReactorError.fromCode("Native", "loaded native artifact omitted its source/build identity");
177
223
  }
178
224
  const build = jsonRecord(Buffer.from(api.buildIdentity.slice(prefix.length, -suffix.length)), "build identity");
179
225
  if (build.abiVersion !== ABI_VERSION ||
180
226
  build.profile !== "release" ||
181
227
  JSON.stringify(build) !== JSON.stringify(manifest.build)) {
182
- throw new ReactorError("Native", "loaded native build identity differs from its staged artifact");
228
+ throw ReactorError.fromCode("Native", "loaded native build identity differs from its staged artifact");
183
229
  }
184
230
  return Object.freeze(manifest);
185
231
  }
186
232
  catch (cause) {
187
- throw new ReactorError("Native", "native staged artifact verification failed", {
233
+ throw ReactorError.fromCode("Native", "native staged artifact verification failed", {
188
234
  detail: cause,
189
235
  outcome: "not-submitted",
190
236
  });
191
237
  }
192
238
  };
193
- const isErrorCode = Schema.is(ErrorCode);
194
- const operationError = (status, bytes, operation) => {
195
- const response = bytes.length === 0 ? {} : jsonRecord(bytes, operation);
196
- const rawCode = typeof response.code === "string" ? response.code : undefined;
197
- const code = isErrorCode(rawCode) ? rawCode : status === STATUS_CLOSED ? "Closed" : "Native";
198
- // A native status/code does not establish whether a side effect executed.
199
- // Keep backend messages for explicit inspection rather than diagnostics: a
200
- // libwebrtc error can contain peer SDP or caller-supplied signaling material.
201
- return new ReactorError(code, `native ${operation} failed (${code})`, {
202
- operation,
203
- outcome: "unknown",
204
- detail: response,
205
- });
239
+ const failureOf = (status, failure, operation, detail = {}) => {
240
+ const length = Math.min(new DataView(failure.buffer, failure.byteOffset, failure.byteLength).getUint32(0, true), failure.byteLength - 4);
241
+ // A native status does not establish whether a side effect executed.
242
+ return nativeFailure(status, new TextDecoder().decode(failure.subarray(4, 4 + length)), (code) => `native ${operation} failed (${code})`, { operation, outcome: "unknown" }, detail);
206
243
  };
207
244
  const asyncStatus = (fn, args) => new Promise((resolve, reject) => {
208
- fn.async(...args, (error, result) => (error == null ? resolve(result) : reject(error)));
245
+ fn.async(...args, (error, result) => error == null
246
+ ? resolve(result)
247
+ : reject(error instanceof Error
248
+ ? error
249
+ : new Error("native async call failed", { cause: error })));
209
250
  });
210
251
  const parsePacket = (bytes) => {
211
252
  if (bytes.length < 4)
212
- throw new ReactorError("Protocol", "native packet omitted its header length");
253
+ throw ReactorError.fromCode("Protocol", "native packet omitted its header length");
213
254
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
214
255
  const headerLength = view.getUint32(0, true);
215
256
  if (headerLength > bytes.length - 4)
216
- throw new ReactorError("Protocol", "native packet header length exceeds packet size");
257
+ throw ReactorError.fromCode("Protocol", "native packet header length exceeds packet size");
217
258
  const header = jsonRecord(bytes.subarray(4, 4 + headerLength), "packet");
218
- const payload = Uint8Array.from(bytes.subarray(4 + headerLength));
219
- return { header: Object.freeze(header), payload };
259
+ return { header: Object.freeze(header), payload: bytes.slice(4 + headerLength) };
220
260
  };
261
+ const overSized = (what, size) => ReactorError.fromCode("Protocol", `native ${what} of ${size} exceeds the native queue bound`);
262
+ /**
263
+ * Bridges whose owner join outlived the host's shutdown deadline. The pending
264
+ * join still owns the handle and needs the registered callback until the
265
+ * notifier is joined; holding the bridge here keeps both reachable whatever
266
+ * its peer's owner drops. A bridge leaves once its join completes and has
267
+ * destroyed the handle; one whose join fails stays.
268
+ */
269
+ const retained = new Set();
270
+ /**
271
+ * One native peer. Media and transport events never cross into JavaScript on
272
+ * their own: a native notifier thread invokes `onReady` on the JavaScript
273
+ * thread, and the host drains each named queue with synchronous takes, one
274
+ * copy per item into memory the consumer then owns.
275
+ */
221
276
  export class NativeBridge {
277
+ /**
278
+ * Refuse a new peer on a library that holds a retained join. Every peer of a
279
+ * loaded library shares its one libwebrtc factory, so an owner that did not
280
+ * join may have wedged the threads a new owner would need; failing here keeps
281
+ * a caller from stacking wedged owners and allocating remote sessions for them.
282
+ */
283
+ static requireUsable(path) {
284
+ const api = checked(path);
285
+ for (const bridge of retained)
286
+ if (bridge.api === api)
287
+ throw ReactorError.fromCode("Native", "native WebRTC runtime is degraded: an earlier peer's owner join exceeded its shutdown deadline and is still retained", { outcome: "not-submitted" });
288
+ }
222
289
  api;
290
+ notify;
223
291
  handle;
224
292
  closed = false;
225
293
  active = new Set();
226
- readers = new Set();
227
294
  requestBytes = 0;
228
295
  shutdownTask;
229
- constructor(path) {
296
+ videoHeader = new Uint8Array(VIDEO_HEADER_BYTES);
297
+ audioHeader = new Uint8Array(AUDIO_HEADER_BYTES);
298
+ eventLength = [0];
299
+ event = new Uint8Array(EVENT_BUFFER_BYTES);
300
+ metadata = new Uint8Array(256);
301
+ // The buffer the next take copies into. Frames keep their size, so each is
302
+ // allocated once at the previous frame's size and handed to its consumer.
303
+ nextVideo = new Uint8Array(0);
304
+ nextAudio = new Int16Array(0);
305
+ constructor(path, onReady) {
306
+ NativeBridge.requireUsable(path);
230
307
  this.api = checked(path);
231
- const handle = this.api.create();
308
+ // Koffi throws its own errors from these calls: a native failure, not a bug.
309
+ const allocationFailed = (cause) => ReactorError.fromCode("Native", "native WebRTC peer allocation failed", {
310
+ outcome: "not-submitted",
311
+ detail: cause,
312
+ });
313
+ try {
314
+ this.notify = this.api.register((ready) => {
315
+ if (!this.closed)
316
+ onReady(ready);
317
+ });
318
+ }
319
+ catch (cause) {
320
+ throw allocationFailed(cause);
321
+ }
322
+ let handle = null;
323
+ try {
324
+ handle = this.api.create(this.notify);
325
+ }
326
+ catch (cause) {
327
+ throw allocationFailed(cause);
328
+ }
329
+ finally {
330
+ if (handle === null)
331
+ this.api.unregister(this.notify);
332
+ }
232
333
  if (handle === null)
233
- throw new ReactorError("Native", "native WebRTC peer allocation failed", {
334
+ throw ReactorError.fromCode("Native", "native WebRTC peer allocation failed", {
234
335
  outcome: "not-submitted",
235
336
  });
236
337
  this.handle = handle;
237
338
  }
238
339
  require() {
239
340
  if (this.closed || this.handle === undefined)
240
- throw new ReactorError("Closed", "native WebRTC peer is closed", {
341
+ throw ReactorError.fromCode("Closed", "native WebRTC peer is closed", {
241
342
  outcome: "not-submitted",
242
343
  });
243
344
  return this.handle;
@@ -246,7 +347,7 @@ export class NativeBridge {
246
347
  const handle = this.require();
247
348
  if (this.active.size >= MAX_IN_FLIGHT_CALLS ||
248
349
  this.requestBytes + bytes > MAX_IN_FLIGHT_REQUEST_BYTES) {
249
- throw new ReactorError("Overflow", "native foreign-call admission bound exceeded", {
350
+ throw ReactorError.fromCode("Overflow", "native foreign-call admission bound exceeded", {
250
351
  outcome: "not-submitted",
251
352
  });
252
353
  }
@@ -256,7 +357,7 @@ export class NativeBridge {
256
357
  });
257
358
  // Register before dispatching to Koffi. Effect interruption may abandon the
258
359
  // waiter, but the lease belongs to actual native completion, including time
259
- // spent queued on Koffi's executor and both halves of a packet poll.
360
+ // spent queued on Koffi's executor.
260
361
  this.active.add(lease);
261
362
  this.requestBytes += bytes;
262
363
  try {
@@ -270,11 +371,11 @@ export class NativeBridge {
270
371
  }
271
372
  async call(operation, request = new Uint8Array()) {
272
373
  if (request.byteLength > MAX_REQUEST_BYTES)
273
- throw new ReactorError("Overflow", "native request exceeds 1 MiB", {
374
+ throw ReactorError.fromCode("Overflow", "native request exceeds 1 MiB", {
274
375
  outcome: "not-submitted",
275
376
  });
276
377
  return this.withHandle(CALL_BUFFER_BYTES + request.byteLength, async (handle) => {
277
- const response = Buffer.allocUnsafe(CALL_BUFFER_BYTES), responseLength = [0];
378
+ const response = Buffer.allocUnsafe(CALL_BUFFER_BYTES), responseLength = [0], failure = Buffer.alloc(FAILURE_BYTES);
278
379
  const input = Buffer.from(request);
279
380
  let status;
280
381
  try {
@@ -286,35 +387,38 @@ export class NativeBridge {
286
387
  response,
287
388
  response.byteLength,
288
389
  responseLength,
390
+ failure,
289
391
  ]);
290
392
  }
291
393
  catch (cause) {
292
- throw new ReactorError("Native", "native WebRTC call completion failed", {
394
+ throw ReactorError.fromCode("Native", "native WebRTC call completion failed", {
293
395
  detail: cause,
294
396
  outcome: "unknown",
295
397
  });
296
398
  }
399
+ if (status !== STATUS_OK)
400
+ throw failureOf(status, failure, `call:${operation}`);
297
401
  const length = toNumber(responseLength[0], "call response length");
298
402
  if (length > response.byteLength)
299
- throw new ReactorError("Protocol", "native call response exceeded its declared buffer");
300
- const bytes = Uint8Array.from(response.subarray(0, length));
301
- if (status !== STATUS_OK)
302
- throw operationError(status, bytes, `call:${operation}`);
403
+ throw ReactorError.fromCode("Protocol", "native call response exceeded its declared buffer");
303
404
  try {
304
- return JSON.parse(new TextDecoder().decode(bytes));
405
+ const reply = JSON.parse(new TextDecoder().decode(response.subarray(0, length)));
406
+ return reply;
305
407
  }
306
408
  catch (cause) {
307
- throw new ReactorError("Protocol", "native call returned invalid JSON", { detail: cause });
409
+ throw ReactorError.fromCode("Protocol", "native call returned invalid JSON", {
410
+ detail: cause,
411
+ });
308
412
  }
309
413
  });
310
414
  }
311
415
  async send(channel, bytes) {
312
416
  if (bytes.byteLength > MAX_MESSAGE_BYTES)
313
- throw new ReactorError("Overflow", "native data channel message exceeds 262144 bytes", {
417
+ throw ReactorError.fromCode("Overflow", "native data channel message exceeds 262144 bytes", {
314
418
  outcome: "not-submitted",
315
419
  });
316
- return this.withHandle(ERROR_BUFFER_BYTES + bytes.byteLength, async (handle) => {
317
- const error = Buffer.allocUnsafe(ERROR_BUFFER_BYTES), errorLength = [0];
420
+ return this.withHandle(FAILURE_BYTES + bytes.byteLength, async (handle) => {
421
+ const failure = Buffer.alloc(FAILURE_BYTES);
318
422
  const input = Buffer.from(bytes);
319
423
  let status;
320
424
  try {
@@ -323,89 +427,116 @@ export class NativeBridge {
323
427
  channel === "control" ? 0 : 1,
324
428
  input,
325
429
  input.byteLength,
326
- error,
327
- error.byteLength,
328
- errorLength,
430
+ failure,
329
431
  ]);
330
432
  }
331
433
  catch (cause) {
332
- throw new ReactorError("Native", `native ${channel} send completion failed`, {
434
+ throw ReactorError.fromCode("Native", `native ${channel} send completion failed`, {
333
435
  detail: cause,
334
436
  outcome: "unknown",
335
437
  });
336
438
  }
337
- const length = toNumber(errorLength[0], "send error length");
338
- if (length > error.byteLength)
339
- throw new ReactorError("Protocol", "native send error exceeded its declared buffer");
340
439
  if (status !== STATUS_OK)
341
- throw operationError(status, Uint8Array.from(error.subarray(0, length)), `send:${channel}`);
440
+ throw failureOf(status, failure, `send:${channel}`, { channel });
342
441
  });
343
442
  }
344
- async poll(fn, timeoutMs) {
345
- if (this.closed)
346
- return { _tag: "Closed" };
347
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 0xffffffff) {
348
- throw new ReactorError("InvalidInput", "native poll timeout must be an unsigned 32-bit integer", { outcome: "not-submitted" });
349
- }
350
- if (this.readers.has(fn))
351
- throw new ReactorError("AlreadyReading", "native packet queue already has a reader", {
352
- outcome: "not-submitted",
353
- });
354
- this.readers.add(fn);
355
- try {
356
- return await this.withHandle(0, async (handle) => {
357
- const needed = [0];
358
- let status;
359
- try {
360
- status = await asyncStatus(fn, [handle, timeoutMs, null, 0, needed]);
361
- }
362
- catch (cause) {
363
- throw new ReactorError("Native", "native event poll could not execute", {
364
- detail: cause,
365
- });
366
- }
367
- if (this.closed)
368
- return { _tag: "Closed" };
369
- if (status === STATUS_AGAIN)
370
- return { _tag: "Again" };
371
- if (status === STATUS_CLOSED)
372
- return { _tag: "Closed" };
373
- if (status !== STATUS_BUFFER_TOO_SMALL)
374
- throw new ReactorError("Native", `native event poll failed with status ${status}`);
375
- const length = toNumber(needed[0], "packet length");
376
- if (length < 4 || length > MAX_PACKET_BYTES)
377
- throw new ReactorError("Overflow", `native packet size ${length} exceeds the local bound`);
378
- const output = Buffer.allocUnsafe(length), actual = [0];
379
- try {
380
- status = await asyncStatus(fn, [handle, 0, output, output.byteLength, actual]);
381
- }
382
- catch (cause) {
383
- throw new ReactorError("Native", "native packet copy could not execute", {
384
- detail: cause,
385
- });
386
- }
387
- if (this.closed || status === STATUS_CLOSED)
388
- return { _tag: "Closed" };
389
- if (status !== STATUS_OK)
390
- throw new ReactorError("Native", `native packet copy failed with status ${status}`);
391
- const actualLength = toNumber(actual[0], "copied packet length");
392
- if (actualLength !== length)
393
- throw new ReactorError("Protocol", "native packet changed between size and copy polls");
394
- return { _tag: "Packet", packet: parsePacket(Uint8Array.from(output)) };
395
- });
396
- }
397
- finally {
398
- this.readers.delete(fn);
443
+ /** Synchronous and nonblocking; call from the readiness callback until empty. */
444
+ takeEvent() {
445
+ const handle = this.handle;
446
+ if (this.closed || handle === undefined)
447
+ return null;
448
+ for (;;) {
449
+ const status = this.api.takeEvent(handle, this.event, this.event.byteLength, this.eventLength);
450
+ if (status === STATUS_AGAIN)
451
+ return undefined;
452
+ if (status === STATUS_CLOSED)
453
+ return null;
454
+ const length = toNumber(this.eventLength[0], "event length");
455
+ if (status === STATUS_OK) {
456
+ if (length > this.event.byteLength)
457
+ throw ReactorError.fromCode("Protocol", "native event exceeded its declared buffer");
458
+ return parsePacket(this.event.subarray(0, length));
459
+ }
460
+ if (status !== STATUS_BUFFER_TOO_SMALL)
461
+ throw ReactorError.fromCode("Native", `native event take failed with status ${status}`);
462
+ if (length <= this.event.byteLength || length > MAX_EVENT_BYTES)
463
+ throw overSized("event", length);
464
+ this.event = new Uint8Array(length);
399
465
  }
400
466
  }
401
- pollEvent(timeoutMs = 100) {
402
- return this.poll(this.api.pollEvent, timeoutMs);
403
- }
404
- pollVideo(timeoutMs = 100) {
405
- return this.poll(this.api.pollVideo, timeoutMs);
467
+ /** Synchronous and nonblocking; the returned bytes belong to the caller. */
468
+ takeVideo() {
469
+ const handle = this.handle;
470
+ if (this.closed || handle === undefined)
471
+ return null;
472
+ const header = new DataView(this.videoHeader.buffer);
473
+ for (;;) {
474
+ const status = this.api.takeVideo(handle, this.videoHeader, this.nextVideo, this.nextVideo.byteLength, this.metadata, this.metadata.byteLength);
475
+ if (status === STATUS_AGAIN)
476
+ return undefined;
477
+ if (status === STATUS_CLOSED)
478
+ return null;
479
+ const dataLength = header.getUint32(8, true), metadataLength = header.getUint32(12, true);
480
+ if (status === STATUS_OK) {
481
+ const data = this.nextVideo;
482
+ this.nextVideo = new Uint8Array(dataLength);
483
+ return {
484
+ track: header.getUint32(32, true),
485
+ width: header.getUint32(0, true),
486
+ height: header.getUint32(4, true),
487
+ frameId: header.getBigUint64(16, true),
488
+ timestampMicros: header.getBigUint64(24, true),
489
+ sequence: header.getBigUint64(40, true),
490
+ // A smaller frame than its predecessor leaves slack; never expose it.
491
+ data: dataLength === data.byteLength ? data : data.slice(0, dataLength),
492
+ metadata: this.metadata.slice(0, metadataLength),
493
+ };
494
+ }
495
+ if (status !== STATUS_BUFFER_TOO_SMALL)
496
+ throw ReactorError.fromCode("Native", `native video take failed with status ${status}`);
497
+ if (dataLength + metadataLength > MAX_VIDEO_BYTES)
498
+ throw overSized("video frame", dataLength + metadataLength);
499
+ const growData = dataLength > this.nextVideo.byteLength, growMetadata = metadataLength > this.metadata.byteLength;
500
+ if (!growData && !growMetadata)
501
+ throw ReactorError.fromCode("Protocol", "native video take refused a fitting frame");
502
+ if (growData)
503
+ this.nextVideo = new Uint8Array(dataLength);
504
+ if (growMetadata)
505
+ this.metadata = new Uint8Array(metadataLength);
506
+ }
406
507
  }
407
- pollAudio(timeoutMs = 100) {
408
- return this.poll(this.api.pollAudio, timeoutMs);
508
+ /** Synchronous and nonblocking; the returned samples belong to the caller. */
509
+ takeAudio() {
510
+ const handle = this.handle;
511
+ if (this.closed || handle === undefined)
512
+ return null;
513
+ const header = new DataView(this.audioHeader.buffer);
514
+ for (;;) {
515
+ const status = this.api.takeAudio(handle, this.audioHeader, this.nextAudio, this.nextAudio.length);
516
+ if (status === STATUS_AGAIN)
517
+ return undefined;
518
+ if (status === STATUS_CLOSED)
519
+ return null;
520
+ const samples = header.getUint32(8, true);
521
+ if (status === STATUS_OK) {
522
+ const pcm = this.nextAudio;
523
+ this.nextAudio = new Int16Array(samples);
524
+ return {
525
+ sampleRate: header.getUint32(0, true),
526
+ channels: header.getUint32(4, true),
527
+ track: header.getUint32(12, true),
528
+ sequence: header.getBigUint64(16, true),
529
+ samples: samples === pcm.length ? pcm : pcm.slice(0, samples),
530
+ };
531
+ }
532
+ if (status !== STATUS_BUFFER_TOO_SMALL)
533
+ throw ReactorError.fromCode("Native", `native audio take failed with status ${status}`);
534
+ if (samples > MAX_AUDIO_SAMPLES)
535
+ throw overSized("audio block", samples);
536
+ if (samples <= this.nextAudio.length)
537
+ throw ReactorError.fromCode("Protocol", "native audio take refused a fitting block");
538
+ this.nextAudio = new Int16Array(samples);
539
+ }
409
540
  }
410
541
  close() {
411
542
  if (this.closed)
@@ -425,26 +556,40 @@ export class NativeBridge {
425
556
  this.shutdownTask = this.finishShutdown(handle);
426
557
  return this.shutdownTask;
427
558
  }
559
+ /**
560
+ * The host stopped waiting for this bridge's join. The join still owns the
561
+ * handle: destruction and unregistration run only once it completes.
562
+ */
563
+ retain() {
564
+ const task = this.shutdownTask;
565
+ if (task === undefined || retained.has(this))
566
+ return;
567
+ retained.add(this);
568
+ void task.then(() => retained.delete(this), () => undefined);
569
+ }
428
570
  async finishShutdown(handle) {
429
- // close() fences new host admission and wakes native polls. Drain all host
430
- // foreign calls before joining and destroying the native owner. A native
431
- // owner join cannot see work that is still queued in Koffi.
571
+ // close() fences new host admission. Drain all host foreign calls before
572
+ // joining and destroying the native owner. A native owner join cannot see
573
+ // work that is still queued in Koffi.
432
574
  await Promise.all(this.active);
433
- const error = Buffer.allocUnsafe(ERROR_BUFFER_BYTES), errorLength = [0];
575
+ const failure = Buffer.alloc(FAILURE_BYTES);
434
576
  let status;
435
577
  try {
436
- status = await asyncStatus(this.api.shutdown, [handle, error, error.byteLength, errorLength]);
578
+ // Asynchronous on purpose: the join waits for a notifier that may be
579
+ // blocked until this thread runs its readiness callback.
580
+ status = await asyncStatus(this.api.shutdown, [handle, failure]);
437
581
  }
438
582
  catch (cause) {
439
- throw new ReactorError("Shutdown", "native WebRTC owner join could not execute; handle retained", { detail: cause });
583
+ throw ReactorError.fromCode("Shutdown", "native WebRTC owner join could not execute; handle retained", { detail: cause });
584
+ }
585
+ if (status !== STATUS_OK) {
586
+ const error = failureOf(status, failure, "shutdown");
587
+ throw ReactorError.fromCode("Shutdown", error.message, error.context);
440
588
  }
441
- const length = toNumber(errorLength[0], "shutdown error length");
442
- if (length > error.byteLength)
443
- throw new ReactorError("Shutdown", "native shutdown error exceeded its declared buffer");
444
- if (status !== STATUS_OK)
445
- throw new ReactorError("Shutdown", operationError(status, Uint8Array.from(error.subarray(0, length)), "shutdown").message);
446
589
  this.api.destroy(handle);
447
590
  this.handle = undefined;
591
+ // The notifier thread is joined: nothing can invoke the callback again.
592
+ this.api.unregister(this.notify);
448
593
  }
449
594
  }
450
595
  export const encodeNativeJson = (value) => Uint8Array.from(new TextEncoder().encode(JSON.stringify(value)));