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
package/Dockerfile CHANGED
@@ -15,7 +15,8 @@ RUN apt-get update \
15
15
 
16
16
  RUN rustup component add clippy rustfmt
17
17
 
18
- COPY rust/Cargo.toml rust/Cargo.lock rust/build.rs ./rust/
18
+ COPY rust-toolchain.toml ./
19
+ COPY rust/Cargo.toml rust/Cargo.lock rust/build.rs rust/clippy.toml ./rust/
19
20
  COPY rust/.cargo/config.toml ./rust/.cargo/config.toml
20
21
  COPY rust/src ./rust/src
21
22
  COPY rust/include ./rust/include
@@ -27,8 +28,9 @@ ENV CARGO_TARGET_DIR=/target \
27
28
  # reactor-webrtc-sys downloads the pinned Linux x64 libwebrtc prebuilt,
28
29
  # verifies its published SHA-256, and compiles its C++ glue with pinned LLVM 21.
29
30
  RUN cargo fmt --manifest-path rust/Cargo.toml -- --check
30
- RUN cargo test --locked --manifest-path rust/Cargo.toml -- --nocapture
31
+ RUN cargo test --locked --manifest-path rust/Cargo.toml --all-targets -- --nocapture
31
32
  RUN cargo clippy --locked --manifest-path rust/Cargo.toml --all-targets -- -D warnings
33
+ RUN RUSTDOCFLAGS="-D warnings" cargo doc --locked --manifest-path rust/Cargo.toml --no-deps --document-private-items
32
34
  RUN cargo build --locked --manifest-path rust/Cargo.toml --release
33
35
  RUN test "$(uname -m)" = "x86_64" \
34
36
  && file /target/release/libreactor_effect_native.so \
package/README.md CHANGED
@@ -10,7 +10,7 @@ This is not an official Reactor SDK. Native WebRTC dependencies are attributed i
10
10
  npm install reactor-effect-client reactor-effect-native effect@4.0.0-rc.115 @effect/platform-node@4.0.0-rc.115
11
11
  ```
12
12
 
13
- `reactor-effect-client` and Effect `4.0.0-rc.115` are exact peer dependencies. Koffi is an optional dependency; it and the shared library are loaded only when native preflight runs, so merely importing this module remains safe when the optional dependency is absent. `@effect/platform-node` supplies the Node services an application provides around its scoped operation; a consumer using that prerelease should retain the root override `"@effect/platform-node-shared": "4.0.0-rc.115"`, because the platform's caret range otherwise permits a later prerelease with a different Effect peer.
13
+ `reactor-effect-client`, Effect `4.0.0-rc.115` and `@effect/platform-node` `4.0.0-rc.115` are exact peer dependencies. Koffi is an optional dependency; it and the shared library are loaded only when `Native.layer()` is built, so merely importing this module remains safe when the optional dependency is absent. `@effect/platform-node` supplies the Node services an application provides around its scoped operation, and the [isolated host](#isolated-host) runs its child processes on it; this package loads it only when `Native.Isolated.layer()` is built. A consumer using that prerelease should retain the root override `"@effect/platform-node-shared": "4.0.0-rc.115"`, because the platform's caret range otherwise permits a later prerelease with a different Effect peer.
14
14
 
15
15
  ## Usage
16
16
 
@@ -25,7 +25,7 @@ const clientLayer = Reactor.layer().pipe(
25
25
  );
26
26
  ```
27
27
 
28
- `Native.make(configuration, nativeOptions)` constructs the canonical factory with the native peer already selected, and `Native.layer(nativeOptions)` supplies the `PeerFactory` for `Reactor.layer()`. Constructing a factory makes no allocation. `nativeOptions.libraryPath` can select a staged native artifact; its caller owns artifact provenance. The separate `Native.uploadFile` helper requires the host file services when used.
28
+ `Native.layer(nativeOptions)` supplies the `PeerFactory` for `Reactor.layer()`. Building it loads Koffi and the library and verifies the staged artifact once, so a missing or invalid library fails the layer with a `Native` error, and an invalid option with `InvalidInput`, before any `Client` exists to allocate a remote session. Constructing a factory makes no allocation. `nativeOptions.libraryPath` can select a staged native artifact; its caller owns artifact provenance. `nativeOptions.shutdownTimeout`, a `Duration.Input` of 10 seconds by default, bounds how long closing a connection waits for the native owner join (see [Media path](#media-path-abi-3)). The separate `Native.uploadFile` helper requires the host file services when used.
29
29
 
30
30
  `Native.media(session)` obtains a decoded-media generation after the session connects. `media.video(name)` emits owned BGRA frames and `media.audio(name)` emits owned interleaved signed 16-bit PCM. Frame IDs and microsecond timestamps use `bigint`. Retaining a frame retains JavaScript-owned bytes, and native media has no browser track handles. Media values stay bound to their negotiated generation: a reconnect creates a new generation and existing readers end or fail with their source.
31
31
 
@@ -37,24 +37,83 @@ Native transport provides:
37
37
  - WebRTC statistics;
38
38
  - owned decoded BGRA video with Reactor frame metadata;
39
39
  - owned interleaved signed 16-bit PCM audio;
40
+ - typed failure classes;
40
41
  - immediate close fencing plus joined callback quiescence during shutdown.
41
42
 
42
43
  The current public native peer accepts at most one incoming video track and one incoming audio track. Pinned `reactor-webrtc` delivers `RemoteTrack` callbacks without the transceiver MID/native identity needed to join multiple same-kind callbacks to SDP mappings without relying on arrival order. `NativePeer.prepare` therefore rejects an ambiguous declaration with `UnsupportedCapability` before native negotiation. Multiple outgoing tracks remain distinct by their declared transceivers.
43
44
 
45
+ ## Isolated host
46
+
47
+ `Native.Isolated.layer(options)` supplies the same `PeerFactory` with each connection generation's native peer in a child process of its own, driven over Effect RPC. Use it where a native failure must end one connection rather than the application: a crash inside libwebrtc, or an owner join that never completes, then takes down only that child, and the session fails or closes as it would for a lost transport. The in-process `Native.layer` stays the default and is unchanged.
48
+
49
+ ```ts
50
+ const clientLayer = Reactor.layer().pipe(
51
+ Layer.provide(
52
+ Layer.mergeAll(Reactor.FetchHttp.layer, NodeServices.layer, Native.Isolated.layer()),
53
+ ),
54
+ );
55
+ ```
56
+
57
+ It takes the same `libraryPath` and `shutdownTimeout` options, and `Native.media(session)` reads its media as it does the in-process host's.
58
+
59
+ - **Preflight.** Building the layer forks a probe child that loads and verifies the library and opens a native peer, then shuts it down, so a missing or invalid library fails the layer with a `Native` error before any `Client` exists.
60
+ - **One child per peer.** `PeerFactory.make()` forks the peer's child at once, so it starts while the session allocates. The child is never respawned and nothing is replayed into it: when it dies, calls already handed to it fail with outcome `unknown`, later calls are refused as `not-submitted`, and the connection fails with `Native` ("native WebRTC child process exited"). A reconnect makes a new peer, and with it a new child. A child whose parent dies exits too.
61
+ - **Credentials stay in the parent.** Allocation, the session token, command correlation and termination never leave the parent process. The child sees ICE configuration, SDP, channel bytes and media, and starts with an empty environment and none of the parent's runtime flags.
62
+ - **Close.** `shutdown` asks the child to close and join its native peer and exit, within `shutdownTimeout` (10 seconds by default). On expiry the child is killed and the close reports `Shutdown` ("native child shutdown exceeded its deadline; child process killed"), which `Session.close` records in `localErrors` before it terminates the remote session. Nothing is retained, so unlike the in-process host a later peer is unaffected. `close()` retires the peer in the parent at once: no later event or frame from its child reaches the session.
63
+ - **Media.** Each track of a generation has one RPC stream from its child, opened by its first reader, and every reader of the track shares it with the same per-reader bounds as in-process. Each chunk carries one frame and the child sends the next only once the parent has acknowledged it, so each stream's credit is one frame. While the parent's channel is busy the child holds up to 8 video frames and 256 audio blocks, the native queues' depths, and evicts the oldest, counting it in `droppedVideo` or `droppedAudio` instead of `deliveredVideo` or `deliveredAudio`. Every frame keeps the admission `sequence` the native queue gave it, so an evicted frame is a gap that `recorder` reports, as it reports a frame the native queue dropped. `readerOverflows` counts the parent's readers and the child's.
64
+
65
+ It has costs the in-process host does not:
66
+
67
+ - starting a child takes about half a second (0.43–0.56 s from `make` to an open native peer on a 4-core Linux x64 runner), most of it loading Effect's RPC modules and verifying the staged library; it overlaps allocation, and the layer's probe pays it once more at build;
68
+ - every frame is copied across the IPC channel and then once more, because Node's advanced serialization delivers a message's typed arrays as views into one shared message buffer, and each frame's data must be the whole of an exact allocation of its own (a 1344x768 BGRA frame is about 4 MB);
69
+ - the parent must be Node: under Bun, building the layer fails with `UnsupportedCapability`, not submitted.
70
+
44
71
  ## Package layout
45
72
 
46
- | Path | Contents |
47
- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
48
- | `dist/` | The compiled TypeScript entry point and its declarations |
49
- | `lib/<platform>-<arch>/` | The staged shared library and its `native-identity.json` sidecar for each shipped host |
50
- | `rust/` | The `reactor-effect-native` crate: Cargo manifest and lock, build script, header, source |
51
- | `scripts/build.sh` | Builds the crate for the current host and stages it |
52
- | `scripts/stage.sh`, `stage.mjs` | Stage an already built library; `stage.mjs` is the single staging owner |
53
- | `scripts/install-linux-toolchain.sh` | Explicit root-only LLVM 21 installer for opted-in Debian/Ubuntu build environments |
73
+ | Path | Contents |
74
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
75
+ | `dist/` | The compiled TypeScript entry point and its declarations, and the isolated host's child entry |
76
+ | `lib/<platform>-<arch>/` | The staged shared library and its `native-identity.json` sidecar for each shipped host |
77
+ | `rust/` | The `reactor-effect-native` crate: Cargo manifest and lock, lint configuration, build script, header, source |
78
+ | `rust/examples/far_peer/` | Test-only libwebrtc sender for the media load tests; not packaged or staged |
79
+ | `rust-toolchain.toml` | The Rust toolchain the native scripts use, the one CI pins |
80
+ | `scripts/build.sh` | Builds the crate for the current host and stages it |
81
+ | `scripts/stage.sh`, `stage.mjs` | Stage an already built library; `stage.mjs` is the single staging owner |
82
+ | `scripts/install-linux-toolchain.sh` | Explicit root-only LLVM 21 installer for opted-in Debian/Ubuntu build environments |
83
+
84
+ The crate's `src/` follows the peer's threads. `ffi.rs` exports the C ABI and `abi.rs` mirrors the header's constants, which a test checks against the header. `peer/` holds the peer handle, its owner thread (`owner.rs`), which owns every libwebrtc object, and the libwebrtc callbacks (`callbacks.rs`, `media.rs`) that copy into the state the threads share (`shared.rs`). `protocol/` is the JSON of requests, responses and event headers, and `sync/` holds the bounded queues, the callback gate and the notifier. Unit tests sit beside the code they test; a module with a larger suite keeps it in a `tests.rs` of its own.
85
+
86
+ ## Media path (ABI 4)
87
+
88
+ The bridge owns one libwebrtc peer on a Rust thread. Every peer in a process shares one libwebrtc factory, created on the first `prepare` and never destroyed, because reactor-webrtc requires one factory per process.
54
89
 
55
- The bridge owns one libwebrtc peer on a Rust thread. libwebrtc callbacks copy their data into bounded Rust queues; they never invoke JavaScript. ABI 2 retains the exact packet selected by a size probe until it is copied or the queue is closed. Its bytes and item still count toward the queue limits. Producer pressure may evict other queued media; it cannot change the reader's packet.
90
+ libwebrtc callbacks copy each decoded frame once into a bounded, typed Rust queue and set a readiness bit. They never invoke or wait for JavaScript. The queues hold 8 video frames (333 ms at 24 fps), 256 PCM blocks (2.56 s of 10 ms blocks) and 1,024 transport events. One notifier thread per peer passes the readiness bits to a Koffi callback; it is the only native thread that ever waits on JavaScript. The callback wakes one pump fiber per queue. Each pump reads with synchronous, nonblocking takes that copy a frame straight into memory its consumer then owns, and yields between items so observers run at their own pace. No media call uses the libuv thread pool, and none is in flight when a reader is interrupted.
56
91
 
57
- `close()` fences new callback and host-call admission immediately. The host registers every operation before dispatching to Koffi and keeps that ownership until the foreign call actually returns, even when its Effect waiter is interrupted. `shutdown()` drains those active and queued foreign calls, joins the Rust owner and callback guards, then destroys the handle once. The C ABI requires other hosts to perform the same foreign-call drain before destruction. Joining the Rust owner alone cannot observe calls queued in a host executor.
92
+ Each decoded frame and PCM block is numbered on its track, from 0, as it enters its queue and before the queue can evict anything, and the number reaches JavaScript as the frame's `sequence`. A full media queue evicts its oldest item and counts it, so `media.snapshot` reports what was dropped, delivered and still queued, and each eviction is a gap in the sequences a reader receives, at its position: `recorder(stream)` from `reactor-effect-client` turns a track's frames into the frames plus a `Lost { after, count }` for each gap. The stall load test checks that those counts add up to the bridge's own drop count. A full event queue is not pruned: it retires the connection with an `Overflow` failure.
93
+
94
+ The C header, `rust/include/reactor_effect_native.h`, is the ABI contract. Readiness is a callback passed to `reactor_effect_peer_create`; `reactor_effect_peer_take_event`, `_take_video` and `_take_audio` report `BUFFER_TOO_SMALL` with the required size and keep the item queued until a take succeeds.
95
+
96
+ `close()` fences new callback and host-call admission immediately. The host registers every `call`, `send` and `shutdown` before dispatching it to Koffi, and keeps that ownership until the foreign call actually returns, even when its Effect waiter is interrupted. Running the peer's `shutdown` effect drains those active and queued foreign calls, joins the Rust owner, the callback guards and the notifier thread, then destroys the handle and unregisters the Koffi callback once. The join is an asynchronous Koffi call because the notifier may be waiting for the JavaScript thread to run its callback. The C ABI requires other hosts to perform the same foreign-call drain before destruction, and to join from a thread other than the one that runs the callback. Joining the Rust owner alone cannot observe calls queued in a host executor.
97
+
98
+ The host waits for that drain and join for at most `shutdownTimeout`; a healthy join took 13–62 ms in the media load tests on Node and Bun. On expiry the host stops waiting and the shutdown fails with `Shutdown` ("native owner join exceeded its deadline; handle retained"), which `Session.close` records in `localErrors` before it terminates the remote session. The join itself goes on: it keeps the handle and the registered Koffi callback, and destroys and unregisters them only if it completes. Until then the host holds the bridge, and because every peer of the library shares its one libwebrtc factory, preflight and peer creation fail with a `Native` error before any allocation rather than start another owner on a factory that may be wedged.
99
+
100
+ ## Failure classes
101
+
102
+ Every native failure is one of a closed set of classes, which the host maps to the `ReactorError` reason's `_tag`:
103
+
104
+ | Status | `reason._tag` | Raised when |
105
+ | ------ | --------------- | --------------------------------------------------------------------------- |
106
+ | `-1` | `InvalidInput` | the bridge rejects a request or argument |
107
+ | `-2` | `Native` | libwebrtc or the bridge fails in a way it cannot classify |
108
+ | `-3` | `Overflow` | a queue, buffer or message bound is exceeded |
109
+ | `-4` | `Protocol` | the remote peer breaks the negotiated contract, such as an undeclared track |
110
+ | `-5` | `SdpRejected` | libwebrtc refuses to create or apply an offer or answer |
111
+ | `-6` | `ChannelClosed` | a send targets a data channel that is not open |
112
+ | `3` | `Closed` | the peer is fenced or shut down |
113
+
114
+ Pinned reactor-webrtc reports every libwebrtc error as a string, so the bridge classifies a failure by the operation that produced it. That text can contain SDP, so it is never in the message and never in the error's cause chain, which exporters such as `OtlpTracer` render. It stays `Redacted` for explicit inspection: in the `Native` reason's `backendMessage` beside its `status`, or, for a classified failure, in `context.detail` as `{ status, backendMessage }`.
115
+
116
+ When the connection state reaches `failed`, the host reads its statistics once, in the pump that delivers events, so later events wait behind it and the connection scope owns the read. A read that fails, or outlasts its 2 s deadline on the fiber's `Clock`, reports `Disconnected`. A candidate pair that succeeded or was nominated means ICE worked and the DTLS or SCTP transport above it failed: `TransportFailed`. Otherwise the failure is `IceFailed`. Both reasons carry the number of candidate pairs the statistics listed, and `IceFailed` the local candidate types tried. The session reports a data channel that closes as `ChannelClosed`, naming the channel. A `disconnected` state still fails the connection as `Disconnected`, and decode failures are not reported: reactor-webrtc surfaces neither ICE connection state nor decoder errors.
58
117
 
59
118
  ## Build and stage
60
119
 
@@ -86,9 +145,25 @@ Local qualification is credential-free:
86
145
  bun run native:test # sh packages/native/scripts/test.sh
87
146
  ```
88
147
 
89
- The script checks Rust formatting, runs the Rust tests and clippy with warnings denied, then runs the JavaScript ABI/parser/session-boundary tests (Node/Vitest in `packages/native/test`) against the staged artifact; it never restages a second release library. The loopback test negotiates two local peers and exercises real libwebrtc ICE/DTLS/SCTP, both binary channels, video encode/decode, PCM audio, per-frame metadata, stream stats, direction changes, bitrate controls, and callback quiescence. It does not contact Reactor or generate paid media.
148
+ The script checks Rust formatting, then runs the Rust tests, clippy with the crate's lint set, and rustdoc, all with warnings denied, and builds the test far peer. It then runs the JavaScript suite in `packages/native/test` against the staged artifact twice, on Node and on Bun; it never restages a second release library. The Rust tests check the ABI's constants against the C header and each JSON shape against what the host parses, and drive every entry point through the C ABI, including null and undersized arguments, a caught panic and a closed peer. Loopback tests negotiate with a second local peer on the shared factory through the bridge's owner thread, and exercise real libwebrtc ICE/DTLS/SCTP, ordered binary messages on both channels, video encode/decode on two lanes with their per-frame metadata, PCM audio, stream stats, and the close fence. Other tests cover queue accounting under seeded random operations, direction and bitrate controls, readiness coalescing, callback quiescence, and a shutdown that must wait for a notifier still inside the host callback. None of it contacts Reactor or generates paid media.
149
+
150
+ The media load tests receive from `rust/examples/far_peer/`, a libwebrtc sender on the same pinned reactor-webrtc that sends 1344x768 BGRA at 24 fps with per-frame metadata, plus 48 kHz PCM, and echoes both channels. It holds its congestion controller at 8 Mbps: on loopback the estimate follows only how promptly the host schedules both processes, and on a busy runner it backs off until the encoder drops most frames.
151
+
152
+ The tests assert what the bridge owns: how many frames reach it, how many it drops, and how many it holds, queued natively or taken but not yet seen by the subscriber. End-to-end latency also carries the far peer's encoder and pacer and the receiver's jitter buffer, and received audio arrives at the pace of libwebrtc's playout clock; both follow how the host schedules the two processes, so each test prints them, with both processes' CPU use and the codec and pacing counters, without asserting them. Through Koffi and the staged library, on each runtime:
153
+
154
+ - one session must receive at least 20 frames per second for 10 s, drop at most 1% of them and hold at most two at p95; audio must keep reaching it at 20 blocks per second or more, none dropped, and control-channel round trips stay under 100 ms at p95;
155
+ - two concurrent sessions must each meet the same bounds for 10 s;
156
+ - across a 250 ms event-loop stall the native queue may evict only what overflowed its 8 frames while JavaScript was blocked, within one, so at 24 fps at most one frame; across a 2 s stall it must evict what overflows, within two, lose no audio, and drain;
157
+ - a session is renewed three times while its predecessor streams; each replacement must keep receiving at least 15 frames per second over the 4 s from the start of its predecessor's shutdown, which must take under 2 s, dropping at most one frame, with no 500 ms gap and no audio lost;
158
+ - every session above closes cleanly: its native owner joins without a `Shutdown` failure, in under a fifth of the default `shutdownTimeout`, so a deadline that would fire on a slow but healthy join fails the suite;
159
+ - a canonical `Session` over the far peer, through a coordinator stand-in that relays its signaling, receives video and closes with `localClosed`, no `localErrors` and its remote termination confirmed, under the same bound;
160
+ - an answer whose candidates point at an unreachable documentation address fails ICE, and the events pump's classification reports `IceFailed` with its candidate-pair detail. libwebrtc reports the failure once it has pruned the last timed-out pair, so that detail lists no pairs.
161
+
162
+ The C fixture in `test/session-fixture.c` implements the same header without libwebrtc, including a notifier thread. It drives the canonical session, blocks foreign calls while interrupting their Effect waiters, and checks that the Koffi callback is unregistered only after shutdown joined the notifier. Holding a shutdown join open, it checks that `Session.close` returns at the deadline with `Shutdown` in `localErrors` and the remote session terminated, that the bridge is neither destroyed nor unregistered until the join completes, and that the process admits no new peer until then. Holding a statistics call open, it checks that a failed connection's classification ends on a `TestClock` with `Disconnected`. The lifetime fixture drains queued calls while one active call remains held, verifies that shutdown/destruction have not run, then releases the final call and verifies one destruction. Its tombstone reports unsafe ordering without intentionally dereferencing freed memory. These checks establish ownership and ordering; they do not claim an observed heap-corruption incident.
163
+
164
+ `test/frame-allocation.test.ts` scripts the video queue of a minimal ABI library. Each take must hand over the one exact-size buffer native code copied the frame into, with no JavaScript copy of its pixels except the trim of a frame smaller than its predecessor, and a public media generation's Stream consumer must receive that same buffer. The frame-ownership check it shares with the client and browser suites, `reactor-effect-test-kit/frames`, also runs on the frames of the canonical far-peer session.
90
165
 
91
- Additional tests force differently sized media eviction between the size probe and copying calls, and block foreign calls while interrupting their Effect waiters. The lifetime fixture drains queued calls while one active call remains held, verifies that shutdown/destruction have not run, then releases the final call and verifies one destruction. Its tombstone reports unsafe ordering without intentionally dereferencing freed memory. These checks establish ownership and ordering; they do not claim an observed heap-corruption incident.
166
+ `test/isolated.test.ts` runs the isolated host with real child processes, on Node; on Bun it checks only that the layer refuses to build. Over the C fixture it checks that successive generations each reach their own child through one parent, that a child killed mid-call fails that call as `unknown`, refuses later calls before dispatch and is never respawned, that a child exits when its parent dies with a native call in flight, that a shutdown held past its deadline kills the child and still terminates the remote session, that events and frames a retired, shut down or killed child sends late never reach it or a later peer, and that a cancelled wait never takes a later call's reply. Over the far peer it checks that a reader that stops reading fails alone with `Overflow` while each stream holds one unacknowledged one-frame chunk, and that a canonical session's frames arrive as exact allocations after the IPC hop and the session closes cleanly. The child entry is the built one, so the suite needs `bun run build` first.
92
167
 
93
168
  `scripts/stage.sh` stages an already-built shared library under the package runtime layout, `lib/<platform>-<arch>/`. For example:
94
169
 
@@ -96,7 +171,7 @@ Additional tests force differently sized media eviction between the size probe a
96
171
  ./packages/native/scripts/stage.sh packages/native/rust/target/release/libreactor_effect_native.dylib darwin-arm64
97
172
  ```
98
173
 
99
- Linux x64 has a reproducible container build that starts from the package source, installs Rust 1.90 plus the package-owned LLVM 21 recipe and auxiliary archive tools, runs the native Rust tests and clippy, and exports only the resulting shared library. It requires an explicit Docker context so the script never changes the caller's active context:
174
+ Linux x64 has a reproducible container build that starts from the package source, installs Rust 1.90 plus the package-owned LLVM 21 recipe and auxiliary archive tools, runs the native Rust tests, clippy and rustdoc, and exports only the resulting shared library. It requires an explicit Docker context so the script never changes the caller's active context:
100
175
 
101
176
  ```sh
102
177
  DOCKER_CONTEXT=default bun run native:linux-x64
@@ -1,3 +1,15 @@
1
+ import { ReactorError } from "reactor-effect-client";
2
+ import type { ErrorContext, FailureCode } from "reactor-effect-client";
3
+ /** A status outside the ABI's classes is an unclassified native failure. */
4
+ export declare const failureCode: (status: number) => FailureCode | "Native";
5
+ /**
6
+ * A failure the native ABI returned with `status`, classified by its failure
7
+ * class. libwebrtc's own text can contain peer SDP or caller-supplied
8
+ * signaling material, so it is kept Redacted for explicit inspection: in the
9
+ * `Native` reason for an unclassified failure, and in `context.detail`, beside
10
+ * the status, for a classified one.
11
+ */
12
+ export declare const nativeFailure: (status: number, backendText: string, message: (code: FailureCode | "Native") => string, context: ErrorContext, detail?: Readonly<Record<string, unknown>>) => ReactorError;
1
13
  export declare const NativeCall: Readonly<{
2
14
  readonly Prepare: 1;
3
15
  readonly Answer: 2;
@@ -7,6 +19,12 @@ export declare const NativeCall: Readonly<{
7
19
  readonly MediaSnapshot: 6;
8
20
  }>;
9
21
  export type NativeCall = (typeof NativeCall)[keyof typeof NativeCall];
22
+ /** Readiness bits the native notifier passes to the host callback. */
23
+ export declare const Ready: Readonly<{
24
+ readonly Events: 1;
25
+ readonly Video: 2;
26
+ readonly Audio: 4;
27
+ }>;
10
28
  export declare const resolveNativeBridge: (path?: string) => Promise<string>;
11
29
  export declare const checkNativeBridge: (path?: string) => Promise<void>;
12
30
  /** Validate the staged artifact used by both source and installed-package tests. */
@@ -15,33 +33,71 @@ export interface NativePacket {
15
33
  readonly header: Readonly<Record<string, unknown>>;
16
34
  readonly payload: Uint8Array<ArrayBuffer>;
17
35
  }
18
- export type NativePoll = {
19
- readonly _tag: "Packet";
20
- readonly packet: NativePacket;
21
- } | {
22
- readonly _tag: "Again";
23
- } | {
24
- readonly _tag: "Closed";
25
- };
36
+ export interface NativeVideo {
37
+ readonly track: number;
38
+ readonly width: number;
39
+ readonly height: number;
40
+ readonly frameId: bigint;
41
+ readonly timestampMicros: bigint;
42
+ readonly sequence: bigint;
43
+ readonly data: Uint8Array<ArrayBuffer>;
44
+ readonly metadata: Uint8Array<ArrayBuffer>;
45
+ }
46
+ export interface NativeAudio {
47
+ readonly track: number;
48
+ readonly sampleRate: number;
49
+ readonly channels: number;
50
+ readonly sequence: bigint;
51
+ readonly samples: Int16Array<ArrayBuffer>;
52
+ }
53
+ /** A take: the item, `undefined` when the queue is empty, `null` once it is closed. */
54
+ export type Take<A> = A | undefined | null;
55
+ /**
56
+ * One native peer. Media and transport events never cross into JavaScript on
57
+ * their own: a native notifier thread invokes `onReady` on the JavaScript
58
+ * thread, and the host drains each named queue with synchronous takes, one
59
+ * copy per item into memory the consumer then owns.
60
+ */
26
61
  export declare class NativeBridge {
62
+ /**
63
+ * Refuse a new peer on a library that holds a retained join. Every peer of a
64
+ * loaded library shares its one libwebrtc factory, so an owner that did not
65
+ * join may have wedged the threads a new owner would need; failing here keeps
66
+ * a caller from stacking wedged owners and allocating remote sessions for them.
67
+ */
68
+ static requireUsable(path: string): void;
27
69
  private readonly api;
70
+ private readonly notify;
28
71
  private handle;
29
72
  private closed;
30
73
  private readonly active;
31
- private readonly readers;
32
74
  private requestBytes;
33
75
  private shutdownTask;
34
- constructor(path: string);
76
+ private readonly videoHeader;
77
+ private readonly audioHeader;
78
+ private readonly eventLength;
79
+ private event;
80
+ private metadata;
81
+ private nextVideo;
82
+ private nextAudio;
83
+ constructor(path: string, onReady: (ready: number) => void);
35
84
  private require;
36
85
  private withHandle;
37
86
  call(operation: NativeCall, request?: Uint8Array): Promise<unknown>;
38
87
  send(channel: "control" | "data", bytes: Uint8Array): Promise<void>;
39
- private poll;
40
- pollEvent(timeoutMs?: number): Promise<NativePoll>;
41
- pollVideo(timeoutMs?: number): Promise<NativePoll>;
42
- pollAudio(timeoutMs?: number): Promise<NativePoll>;
88
+ /** Synchronous and nonblocking; call from the readiness callback until empty. */
89
+ takeEvent(): Take<NativePacket>;
90
+ /** Synchronous and nonblocking; the returned bytes belong to the caller. */
91
+ takeVideo(): Take<NativeVideo>;
92
+ /** Synchronous and nonblocking; the returned samples belong to the caller. */
93
+ takeAudio(): Take<NativeAudio>;
43
94
  close(): void;
44
95
  shutdown(): Promise<void>;
96
+ /**
97
+ * The host stopped waiting for this bridge's join. The join still owns the
98
+ * handle: destruction and unregistration run only once it completes.
99
+ */
100
+ retain(): void;
45
101
  private finishShutdown;
46
102
  }
47
103
  export declare const encodeNativeJson: (value: unknown) => Uint8Array<ArrayBuffer>;
@@ -1 +1 @@
1
- {"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../../src/_internal/bridge.ts"],"names":[],"mappings":"AAuBA,eAAO,MAAM,UAAU;sBACZ,CAAC;qBACF,CAAC;wBACE,CAAC;yBACA,CAAC;oBACN,CAAC;4BACO,CAAC;EACP,CAAC;AAEZ,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,OAAO,UAAU,CAAC,CAAC;AA+JtE,eAAO,MAAM,mBAAmB,UAAiB,MAAM,KAAG,OAAO,CAAC,MAAM,CAmBvE,CAAC;AAEF,eAAO,MAAM,iBAAiB,UAAiB,MAAM,KAAG,OAAO,CAAC,IAAI,CAEnE,CAAC;AAkCF,oFAAoF;AACpF,eAAO,MAAM,wBAAwB,SAC7B,MAAM,KACX,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAgD3C,CAAC;AAuBF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC,WAAW,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;CAAE,GAC1D;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC1B;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAchC,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAY;IAChC,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAkC;IAC1D,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,YAAY,CAA4B;IAChD,YAAY,IAAI,EAAE,MAAM,EAQvB;IAED,OAAO,CAAC,OAAO;YAQD,UAAU;IA4BlB,IAAI,CAAC,SAAS,EAAE,UAAU,EAAE,OAAO,GAAE,UAA6B,GAAG,OAAO,CAAC,OAAO,CAAC,CAqC1F;IAEK,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAgCxE;YAEa,IAAI;IA0DlB,SAAS,CAAC,SAAS,SAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAE9C;IACD,SAAS,CAAC,SAAS,SAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAE9C;IACD,SAAS,CAAC,SAAS,SAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAE9C;IAED,KAAK,IAAI,IAAI,CAKZ;IAED,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAOxB;YAEa,cAAc;CA4B7B;AAED,eAAO,MAAM,gBAAgB,UAAW,OAAO,KAAG,UAAU,CAAC,WAAW,CACN,CAAC;AACnE,eAAO,MAAM,gBAAgB,UAAW,MAAM,KAAG,UAAU,CAAC,WAAW,CACrB,CAAC"}
1
+ {"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../../src/_internal/bridge.ts"],"names":[],"mappings":"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"}