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.
- package/Dockerfile +4 -2
- package/README.md +90 -15
- package/dist/_internal/bridge.d.ts +70 -14
- package/dist/_internal/bridge.d.ts.map +1 -1
- package/dist/_internal/bridge.js +290 -145
- package/dist/_internal/bridge.js.map +1 -1
- package/dist/_internal/isolated/child.d.ts +2 -0
- package/dist/_internal/isolated/child.d.ts.map +1 -0
- package/dist/_internal/isolated/child.js +176 -0
- package/dist/_internal/isolated/child.js.map +1 -0
- package/dist/_internal/isolated/host.d.ts +147 -0
- package/dist/_internal/isolated/host.d.ts.map +1 -0
- package/dist/_internal/isolated/host.js +645 -0
- package/dist/_internal/isolated/host.js.map +1 -0
- package/dist/_internal/isolated/protocol.d.ts +399 -0
- package/dist/_internal/isolated/protocol.d.ts.map +1 -0
- package/dist/_internal/isolated/protocol.js +250 -0
- package/dist/_internal/isolated/protocol.js.map +1 -0
- package/dist/_internal/peer.d.ts +68 -13
- package/dist/_internal/peer.d.ts.map +1 -1
- package/dist/_internal/peer.js +233 -157
- package/dist/_internal/peer.js.map +1 -1
- package/dist/index.d.ts +19 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -33
- package/dist/index.js.map +1 -1
- package/dist/isolated.d.ts +25 -0
- package/dist/isolated.d.ts.map +1 -0
- package/dist/isolated.js +39 -0
- package/dist/isolated.js.map +1 -0
- package/lib/darwin-arm64/libreactor_effect_native.dylib +0 -0
- package/lib/darwin-arm64/native-identity.json +4 -3
- package/lib/linux-x64/libreactor_effect_native.so +0 -0
- package/lib/linux-x64/native-identity.json +4 -3
- package/package.json +7 -3
- package/rust/Cargo.toml +108 -2
- package/rust/build.rs +253 -114
- package/rust/clippy.toml +7 -0
- package/rust/include/reactor_effect_native.h +101 -42
- package/rust/src/abi.rs +258 -0
- package/rust/src/error.rs +149 -0
- package/rust/src/ffi/memory.rs +256 -0
- package/rust/src/ffi/tests.rs +719 -0
- package/rust/src/ffi.rs +474 -0
- package/rust/src/lib.rs +36 -2341
- package/rust/src/peer/callbacks.rs +145 -0
- package/rust/src/peer/media.rs +221 -0
- package/rust/src/peer/owner/tests.rs +188 -0
- package/rust/src/peer/owner.rs +353 -0
- package/rust/src/peer/shared.rs +323 -0
- package/rust/src/peer/tests.rs +513 -0
- package/rust/src/peer.rs +189 -0
- package/rust/src/protocol/event.rs +238 -0
- package/rust/src/protocol/request.rs +347 -0
- package/rust/src/protocol/stats.rs +356 -0
- package/rust/src/protocol.rs +71 -0
- package/rust/src/sync/gate.rs +159 -0
- package/rust/src/sync/notifier.rs +136 -0
- package/rust/src/sync/queue.rs +384 -0
- package/rust/src/sync.rs +20 -0
- package/rust/src/test_support.rs +99 -0
- package/rust-toolchain.toml +7 -0
- package/scripts/stage.mjs +41 -1
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
//! A peer driven through its handle, the way the host drives it.
|
|
2
|
+
|
|
3
|
+
use super::*;
|
|
4
|
+
use crate::protocol::{Direction, Event, Mapping};
|
|
5
|
+
use crate::sync::Taken;
|
|
6
|
+
use crate::test_support::{Defer, parse_packet, wait_until, with_candidates};
|
|
7
|
+
use reactor_webrtc::{
|
|
8
|
+
AudioFrame, AudioTrack, AudioTrackOptions, AudioTrackSource, DataChannel, DataChannelState,
|
|
9
|
+
IceCandidate, IceGatheringState, PeerConnection, PeerConnectionObserver, PeerConnectionState,
|
|
10
|
+
RtcConfiguration, SdpType, SessionDescription, TransceiverDirection, VideoFrame, VideoTrack,
|
|
11
|
+
};
|
|
12
|
+
use serde_json::{Value, json};
|
|
13
|
+
use std::collections::{HashMap, HashSet};
|
|
14
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
15
|
+
use std::time::Duration;
|
|
16
|
+
|
|
17
|
+
const TIMEOUT: Duration = Duration::from_secs(5);
|
|
18
|
+
const MEDIA_TIMEOUT: Duration = Duration::from_secs(20);
|
|
19
|
+
|
|
20
|
+
fn call(peer: &ReactorEffectPeer, operation: Operation, request: &[u8]) -> Value {
|
|
21
|
+
let response = peer
|
|
22
|
+
.call(operation as u32, request)
|
|
23
|
+
.unwrap_or_else(|error| panic!("{operation:?} failed: {error}"));
|
|
24
|
+
serde_json::from_slice(&response).expect("a JSON response")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn json_request(request: &Value) -> Vec<u8> {
|
|
28
|
+
serde_json::to_vec(request).unwrap()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#[test]
|
|
32
|
+
fn a_closed_peer_refuses_calls_and_sends_before_parsing_them() {
|
|
33
|
+
let peer = ReactorEffectPeer::create(None).expect("a peer");
|
|
34
|
+
peer.close();
|
|
35
|
+
assert_eq!(peer.call(99, b"").unwrap_err(), BridgeError::closed());
|
|
36
|
+
assert_eq!(peer.send(99, b"").unwrap_err(), BridgeError::closed());
|
|
37
|
+
peer.shutdown().expect("shutdown");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[test]
|
|
41
|
+
fn a_send_is_checked_for_channel_then_open_state() {
|
|
42
|
+
let peer = ReactorEffectPeer::create(None).expect("a peer");
|
|
43
|
+
assert_eq!(
|
|
44
|
+
peer.send(99, b"").unwrap_err(),
|
|
45
|
+
BridgeError::invalid("unknown data channel")
|
|
46
|
+
);
|
|
47
|
+
assert_eq!(
|
|
48
|
+
peer.send(Channel::Control as u32, b"early").unwrap_err(),
|
|
49
|
+
BridgeError::new(FailureClass::ChannelClosed, "control channel is not open")
|
|
50
|
+
);
|
|
51
|
+
peer.shutdown().expect("shutdown");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#[test]
|
|
55
|
+
fn shutdown_is_idempotent_and_leaves_every_queue_closed() {
|
|
56
|
+
let peer = ReactorEffectPeer::create(None).expect("a peer");
|
|
57
|
+
peer.shutdown().expect("first shutdown");
|
|
58
|
+
peer.shutdown().expect("second shutdown");
|
|
59
|
+
let shared = peer.shared();
|
|
60
|
+
assert_eq!(shared.events.take(|_| true), Taken::Closed);
|
|
61
|
+
assert_eq!(shared.video.take(|_| true), Taken::Closed);
|
|
62
|
+
assert_eq!(shared.audio.take(|_| true), Taken::Closed);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn shutdown_waits_for_a_notifier_still_inside_the_host_callback() {
|
|
67
|
+
static ENTERED: AtomicBool = AtomicBool::new(false);
|
|
68
|
+
static RELEASED: AtomicBool = AtomicBool::new(false);
|
|
69
|
+
extern "C" fn blocking_host(_ready: u32) {
|
|
70
|
+
ENTERED.store(true, Ordering::Release);
|
|
71
|
+
while !RELEASED.load(Ordering::Acquire) {
|
|
72
|
+
thread::sleep(Duration::from_millis(1));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let peer = ReactorEffectPeer::create(Some(blocking_host)).expect("a peer");
|
|
77
|
+
peer.shared().emit(&Event::Ice { candidate: None });
|
|
78
|
+
wait_until("the host callback", TIMEOUT, || {
|
|
79
|
+
ENTERED.load(Ordering::Acquire)
|
|
80
|
+
});
|
|
81
|
+
thread::scope(|scope| {
|
|
82
|
+
let release_host = Defer(|| RELEASED.store(true, Ordering::Release));
|
|
83
|
+
let shutdown = scope.spawn(|| peer.shutdown());
|
|
84
|
+
thread::sleep(Duration::from_millis(30));
|
|
85
|
+
assert!(
|
|
86
|
+
!shutdown.is_finished(),
|
|
87
|
+
"shutdown returned while the host callback could still run"
|
|
88
|
+
);
|
|
89
|
+
drop(release_host);
|
|
90
|
+
assert_eq!(shutdown.join().unwrap(), Ok(()));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// What the remote end of a loopback observed.
|
|
95
|
+
#[derive(Default)]
|
|
96
|
+
struct RemoteSignals {
|
|
97
|
+
candidates: Mutex<Vec<IceCandidate>>,
|
|
98
|
+
gathered: AtomicBool,
|
|
99
|
+
connected: AtomicBool,
|
|
100
|
+
channels: Mutex<HashMap<String, DataChannel>>,
|
|
101
|
+
/// Each message's channel, bytes and whether it was binary.
|
|
102
|
+
inbox: Mutex<Vec<(String, Vec<u8>, bool)>>,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
impl RemoteSignals {
|
|
106
|
+
/// Records what the remote peer sees. Assertions stay on the test thread:
|
|
107
|
+
/// a panic on a libwebrtc thread would abort the process.
|
|
108
|
+
fn observer(self: &Arc<Self>) -> PeerConnectionObserver {
|
|
109
|
+
PeerConnectionObserver::new()
|
|
110
|
+
.on_ice_candidate({
|
|
111
|
+
let signals = Arc::clone(self);
|
|
112
|
+
move |candidate| lock(&signals.candidates).push(candidate)
|
|
113
|
+
})
|
|
114
|
+
.on_ice_gathering_change({
|
|
115
|
+
let signals = Arc::clone(self);
|
|
116
|
+
move |state| {
|
|
117
|
+
if state == IceGatheringState::Complete {
|
|
118
|
+
signals.gathered.store(true, Ordering::Release);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
.on_connection_state_change({
|
|
123
|
+
let signals = Arc::clone(self);
|
|
124
|
+
move |state| {
|
|
125
|
+
let connected = state == PeerConnectionState::Connected;
|
|
126
|
+
signals.connected.store(connected, Ordering::Release);
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
.on_data_channel({
|
|
130
|
+
let signals = Arc::clone(self);
|
|
131
|
+
move |mut channel| {
|
|
132
|
+
let label = channel.label();
|
|
133
|
+
channel.on_message({
|
|
134
|
+
let (signals, label) = (Arc::clone(&signals), label.clone());
|
|
135
|
+
move |bytes, binary| {
|
|
136
|
+
lock(&signals.inbox).push((label.clone(), bytes.to_vec(), binary));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
lock(&signals.channels).insert(label, channel);
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// What the host has seen of the bridge through its events.
|
|
146
|
+
#[derive(Debug, Default)]
|
|
147
|
+
struct HostView {
|
|
148
|
+
connected: bool,
|
|
149
|
+
open_channels: HashSet<String>,
|
|
150
|
+
/// `(kind, name)` of each decoded event.
|
|
151
|
+
decoded: Vec<(String, String)>,
|
|
152
|
+
messages: Vec<(String, Vec<u8>)>,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// Media the remote peer publishes into the bridge's receive tracks.
|
|
156
|
+
struct Sources {
|
|
157
|
+
video_a: VideoTrack,
|
|
158
|
+
video_b: VideoTrack,
|
|
159
|
+
audio: AudioTrack,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// A bridge peer connected over loopback to a libwebrtc peer that answers
|
|
163
|
+
/// its offer, as Reactor's media server would. Fields drop in order: the
|
|
164
|
+
/// sources before the remote connection, and that before its channels.
|
|
165
|
+
struct Loopback {
|
|
166
|
+
bridge: ReactorEffectPeer,
|
|
167
|
+
mapping: Vec<Mapping>,
|
|
168
|
+
sources: Sources,
|
|
169
|
+
remote: PeerConnection,
|
|
170
|
+
signals: Arc<RemoteSignals>,
|
|
171
|
+
host: HostView,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
impl Loopback {
|
|
175
|
+
/// Prepare the bridge with two video and one audio receive track and one
|
|
176
|
+
/// send track, answer from a remote peer publishing into them, and wait
|
|
177
|
+
/// until both ends are connected with both channels open.
|
|
178
|
+
fn connect() -> Self {
|
|
179
|
+
let bridge = ReactorEffectPeer::create(None).expect("a bridge peer");
|
|
180
|
+
let prepared = call(
|
|
181
|
+
&bridge,
|
|
182
|
+
Operation::Prepare,
|
|
183
|
+
&json_request(&json!({
|
|
184
|
+
"servers": [],
|
|
185
|
+
"tracks": [
|
|
186
|
+
{ "name": "video-a", "kind": "video", "direction": "recvonly" },
|
|
187
|
+
{ "name": "video-b", "kind": "video", "direction": "recvonly" },
|
|
188
|
+
{ "name": "audio-a", "kind": "audio", "direction": "recvonly" },
|
|
189
|
+
{ "name": "outgoing-video", "kind": "video", "direction": "sendonly" },
|
|
190
|
+
],
|
|
191
|
+
})),
|
|
192
|
+
);
|
|
193
|
+
let offer = SessionDescription {
|
|
194
|
+
kind: SdpType::Offer,
|
|
195
|
+
sdp: prepared["sdp"].as_str().expect("an offer").to_owned(),
|
|
196
|
+
};
|
|
197
|
+
assert!(
|
|
198
|
+
offer.declares_frame_metadata(),
|
|
199
|
+
"the offer must negotiate frame metadata"
|
|
200
|
+
);
|
|
201
|
+
let mapping: Vec<Mapping> = serde_json::from_value(prepared["mapping"].clone()).unwrap();
|
|
202
|
+
|
|
203
|
+
// The remote peer shares the bridge's process-wide factory, as
|
|
204
|
+
// reactor-webrtc requires of every peer in one process.
|
|
205
|
+
let factory = owner::factory().expect("the process factory");
|
|
206
|
+
let signals = Arc::new(RemoteSignals::default());
|
|
207
|
+
let remote = factory
|
|
208
|
+
.create_peer_connection(&RtcConfiguration::default(), signals.observer())
|
|
209
|
+
.expect("a remote peer");
|
|
210
|
+
remote
|
|
211
|
+
.set_remote_description(&offer)
|
|
212
|
+
.expect("the remote accepts the offer");
|
|
213
|
+
let sources = Sources {
|
|
214
|
+
video_a: factory.create_video_track("fixture-video-a").unwrap(),
|
|
215
|
+
video_b: factory.create_video_track("fixture-video-b").unwrap(),
|
|
216
|
+
audio: factory
|
|
217
|
+
.create_audio_track_with_options("fixture-audio", {
|
|
218
|
+
let mut options = AudioTrackOptions::default();
|
|
219
|
+
options.source = AudioTrackSource::LocalPush;
|
|
220
|
+
options
|
|
221
|
+
})
|
|
222
|
+
.unwrap(),
|
|
223
|
+
};
|
|
224
|
+
for transceiver in remote.transceivers() {
|
|
225
|
+
let mid = transceiver.mid().expect("a negotiated MID");
|
|
226
|
+
let Some(entry) = mapping.iter().find(|entry| entry.mid == mid) else {
|
|
227
|
+
continue;
|
|
228
|
+
};
|
|
229
|
+
if entry.direction != Direction::RecvOnly {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
match entry.name.as_str() {
|
|
233
|
+
"video-a" => transceiver.set_track(&sources.video_a).unwrap(),
|
|
234
|
+
"video-b" => transceiver.set_track(&sources.video_b).unwrap(),
|
|
235
|
+
"audio-a" => transceiver.set_track(&sources.audio).unwrap(),
|
|
236
|
+
other => panic!("unexpected receive mapping {other}"),
|
|
237
|
+
}
|
|
238
|
+
transceiver
|
|
239
|
+
.set_direction(TransceiverDirection::SendOnly)
|
|
240
|
+
.unwrap();
|
|
241
|
+
}
|
|
242
|
+
let answer = remote.create_answer().expect("an answer");
|
|
243
|
+
assert!(
|
|
244
|
+
answer.declares_frame_metadata(),
|
|
245
|
+
"the answer must echo frame metadata support"
|
|
246
|
+
);
|
|
247
|
+
remote
|
|
248
|
+
.set_local_description(&answer)
|
|
249
|
+
.expect("the remote applies its answer");
|
|
250
|
+
wait_until("the remote peer to gather candidates", TIMEOUT, || {
|
|
251
|
+
signals.gathered.load(Ordering::Acquire)
|
|
252
|
+
});
|
|
253
|
+
let answer = with_candidates(&answer.sdp, &lock(&signals.candidates));
|
|
254
|
+
call(&bridge, Operation::Answer, answer.as_bytes());
|
|
255
|
+
|
|
256
|
+
let mut loopback = Self {
|
|
257
|
+
bridge,
|
|
258
|
+
mapping,
|
|
259
|
+
sources,
|
|
260
|
+
remote,
|
|
261
|
+
signals,
|
|
262
|
+
host: HostView::default(),
|
|
263
|
+
};
|
|
264
|
+
wait_until(
|
|
265
|
+
"a connection with both channels open",
|
|
266
|
+
MEDIA_TIMEOUT,
|
|
267
|
+
|| {
|
|
268
|
+
loopback.pump_events();
|
|
269
|
+
loopback.host.connected
|
|
270
|
+
&& loopback.host.open_channels.len() == 2
|
|
271
|
+
&& loopback.signals.connected.load(Ordering::Acquire)
|
|
272
|
+
&& loopback.remote_channels_open()
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
loopback
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Take the bridge's events as the host would: forward its candidates to
|
|
279
|
+
/// the remote peer and record what the events report.
|
|
280
|
+
fn pump_events(&mut self) {
|
|
281
|
+
while let Taken::Item(packet) = self.bridge.shared().events.take(|_| true) {
|
|
282
|
+
let (header, payload) = parse_packet(&packet);
|
|
283
|
+
let text = |key: &str| header[key].as_str().unwrap_or_default().to_owned();
|
|
284
|
+
match header["type"].as_str() {
|
|
285
|
+
Some("state") => self.host.connected = header["state"] == "connected",
|
|
286
|
+
Some("channel") if header["open"] == true => {
|
|
287
|
+
self.host.open_channels.insert(text("channel"));
|
|
288
|
+
}
|
|
289
|
+
Some("channel") => {
|
|
290
|
+
self.host.open_channels.remove(&text("channel"));
|
|
291
|
+
}
|
|
292
|
+
Some("ice") => {
|
|
293
|
+
if let Some(candidate) = header.get("candidate") {
|
|
294
|
+
let candidate = IceCandidate {
|
|
295
|
+
candidate: candidate["candidate"].as_str().unwrap().to_owned(),
|
|
296
|
+
sdp_mid: candidate["sdp_mid"].as_str().map(str::to_owned),
|
|
297
|
+
sdp_mline_index: candidate["sdp_mline_index"]
|
|
298
|
+
.as_u64()
|
|
299
|
+
.map(|index| u16::try_from(index).unwrap()),
|
|
300
|
+
};
|
|
301
|
+
self.remote
|
|
302
|
+
.add_ice_candidate(&candidate)
|
|
303
|
+
.expect("the remote accepts the bridge's candidate");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
Some("decoded") => self.host.decoded.push((text("kind"), text("name"))),
|
|
307
|
+
Some("message") => self.host.messages.push((text("channel"), payload.to_vec())),
|
|
308
|
+
Some("error") => panic!("the bridge failed: {header}"),
|
|
309
|
+
_ => {}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
fn remote_channels_open(&self) -> bool {
|
|
315
|
+
let channels = lock(&self.signals.channels);
|
|
316
|
+
["control", "data"].into_iter().all(|label| {
|
|
317
|
+
channels
|
|
318
|
+
.get(label)
|
|
319
|
+
.is_some_and(|channel| channel.state() == DataChannelState::Open)
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
fn remote_send(&self, label: &str, bytes: &[u8]) {
|
|
324
|
+
lock(&self.signals.channels)[label]
|
|
325
|
+
.send(bytes, true)
|
|
326
|
+
.expect("the remote sends");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/// The messages the remote peer received on `label`, which must all have
|
|
330
|
+
/// been binary.
|
|
331
|
+
fn remote_received(&self, label: &str) -> Vec<Vec<u8>> {
|
|
332
|
+
lock(&self.signals.inbox)
|
|
333
|
+
.iter()
|
|
334
|
+
.filter(|(channel, ..)| channel == label)
|
|
335
|
+
.map(|(_, bytes, binary)| {
|
|
336
|
+
assert!(binary, "bridge channels carry binary messages");
|
|
337
|
+
bytes.clone()
|
|
338
|
+
})
|
|
339
|
+
.collect()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
fn host_received(&self, label: &str) -> Vec<Vec<u8>> {
|
|
343
|
+
self.host
|
|
344
|
+
.messages
|
|
345
|
+
.iter()
|
|
346
|
+
.filter(|(channel, _)| channel == label)
|
|
347
|
+
.map(|(_, bytes)| bytes.clone())
|
|
348
|
+
.collect()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
fn track_index(&self, name: &str) -> u32 {
|
|
352
|
+
let position = self
|
|
353
|
+
.mapping
|
|
354
|
+
.iter()
|
|
355
|
+
.position(|entry| entry.name == name)
|
|
356
|
+
.expect("a mapped track");
|
|
357
|
+
u32::try_from(position).unwrap()
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// Video lanes carry distinct fills, so decoding cannot pass off one as the other.
|
|
362
|
+
const LANE_A_FILL: u8 = 0x21;
|
|
363
|
+
const LANE_B_FILL: u8 = 0x83;
|
|
364
|
+
const WIDTH: u32 = 64;
|
|
365
|
+
const HEIGHT: u32 = 48;
|
|
366
|
+
const FRAME_BYTES: usize = WIDTH as usize * HEIGHT as usize * 4;
|
|
367
|
+
|
|
368
|
+
impl Sources {
|
|
369
|
+
/// Push a frame on each video lane, with that lane's metadata, and 10 ms
|
|
370
|
+
/// of 48 kHz audio.
|
|
371
|
+
fn push(&self) {
|
|
372
|
+
let lanes = [
|
|
373
|
+
(&self.video_a, LANE_A_FILL, b"meta-a"),
|
|
374
|
+
(&self.video_b, LANE_B_FILL, b"meta-b"),
|
|
375
|
+
];
|
|
376
|
+
for (track, fill, metadata) in lanes {
|
|
377
|
+
let bgra = vec![fill; FRAME_BYTES];
|
|
378
|
+
track
|
|
379
|
+
.push_frame_with_metadata(VideoFrame::new(&bgra, WIDTH, HEIGHT), metadata)
|
|
380
|
+
.expect("the remote pushes video");
|
|
381
|
+
}
|
|
382
|
+
let pcm: Vec<i16> = (0..480_i16)
|
|
383
|
+
.map(|sample| (sample % 128 - 64) * 128)
|
|
384
|
+
.collect();
|
|
385
|
+
let block = AudioFrame {
|
|
386
|
+
pcm: &pcm,
|
|
387
|
+
sample_rate: 48_000,
|
|
388
|
+
channels: 1,
|
|
389
|
+
frames: 480,
|
|
390
|
+
};
|
|
391
|
+
self.audio
|
|
392
|
+
.push_frame(block)
|
|
393
|
+
.expect("the remote pushes audio");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#[test]
|
|
398
|
+
fn messages_cross_a_loopback_connection_in_order_both_ways() {
|
|
399
|
+
let mut loopback = Loopback::connect();
|
|
400
|
+
for message in [b"one".as_slice(), b"two", b"three"] {
|
|
401
|
+
loopback
|
|
402
|
+
.bridge
|
|
403
|
+
.send(Channel::Data as u32, message)
|
|
404
|
+
.expect("the bridge sends");
|
|
405
|
+
}
|
|
406
|
+
wait_until("three messages at the remote", TIMEOUT, || {
|
|
407
|
+
loopback.remote_received("data").len() == 3
|
|
408
|
+
});
|
|
409
|
+
assert_eq!(
|
|
410
|
+
loopback.remote_received("data"),
|
|
411
|
+
[b"one".to_vec(), b"two".to_vec(), b"three".to_vec()]
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
loopback.remote_send("control", b"alpha");
|
|
415
|
+
loopback.remote_send("control", b"beta");
|
|
416
|
+
wait_until("two control messages at the host", TIMEOUT, || {
|
|
417
|
+
loopback.pump_events();
|
|
418
|
+
loopback.host_received("control").len() == 2
|
|
419
|
+
});
|
|
420
|
+
assert_eq!(
|
|
421
|
+
loopback.host_received("control"),
|
|
422
|
+
[b"alpha".to_vec(), b"beta".to_vec()]
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#[test]
|
|
427
|
+
fn each_receive_lane_decodes_real_media_with_its_own_metadata() {
|
|
428
|
+
let mut loopback = Loopback::connect();
|
|
429
|
+
let audio_track = loopback.track_index("audio-a");
|
|
430
|
+
let shared = Arc::clone(&loopback.bridge.shared);
|
|
431
|
+
let mut video = HashMap::new();
|
|
432
|
+
let mut audio_blocks = 0;
|
|
433
|
+
wait_until("decoded media on every receive lane", MEDIA_TIMEOUT, || {
|
|
434
|
+
loopback.sources.push();
|
|
435
|
+
while let Taken::Item(frame) = shared.video.take(|_| true) {
|
|
436
|
+
assert_eq!((frame.width, frame.height), (WIDTH, HEIGHT));
|
|
437
|
+
assert_eq!(frame.bgra.len(), FRAME_BYTES);
|
|
438
|
+
video.insert(frame.track, frame);
|
|
439
|
+
}
|
|
440
|
+
while let Taken::Item(block) = shared.audio.take(|_| true) {
|
|
441
|
+
assert_eq!(block.track, audio_track);
|
|
442
|
+
assert_eq!((block.sample_rate, block.channels), (48_000, 1));
|
|
443
|
+
assert!(!block.pcm.is_empty());
|
|
444
|
+
audio_blocks += 1;
|
|
445
|
+
}
|
|
446
|
+
// Pace the pushes near real time.
|
|
447
|
+
thread::sleep(Duration::from_millis(30));
|
|
448
|
+
video.len() == 2 && audio_blocks > 0
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
let lane_a = &video[&loopback.track_index("video-a")];
|
|
452
|
+
let lane_b = &video[&loopback.track_index("video-b")];
|
|
453
|
+
// VP8 and H.264 are lossy, so exact pixels are not asserted; distinct
|
|
454
|
+
// inputs must stay distinct through the real codec path.
|
|
455
|
+
assert_ne!(
|
|
456
|
+
lane_a.bgra[0], lane_b.bgra[0],
|
|
457
|
+
"two video lanes collapsed into one"
|
|
458
|
+
);
|
|
459
|
+
assert_eq!(
|
|
460
|
+
lane_a.metadata, b"meta-a",
|
|
461
|
+
"lane A's metadata was misattributed"
|
|
462
|
+
);
|
|
463
|
+
assert_eq!(
|
|
464
|
+
lane_b.metadata, b"meta-b",
|
|
465
|
+
"lane B's metadata was misattributed"
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
loopback.pump_events();
|
|
469
|
+
let decoded: HashSet<_> = loopback.host.decoded.iter().cloned().collect();
|
|
470
|
+
let expected = [
|
|
471
|
+
("video", "video-a"),
|
|
472
|
+
("video", "video-b"),
|
|
473
|
+
("audio", "audio-a"),
|
|
474
|
+
]
|
|
475
|
+
.map(|(kind, name)| (kind.to_owned(), name.to_owned()));
|
|
476
|
+
assert_eq!(decoded, HashSet::from(expected));
|
|
477
|
+
|
|
478
|
+
wait_until("stats for the live streams", TIMEOUT, || {
|
|
479
|
+
let stats = call(&loopback.bridge, Operation::Stats, b"");
|
|
480
|
+
let entries = stats.as_array().expect("a stats array");
|
|
481
|
+
let inbound = |kind: &str| {
|
|
482
|
+
entries
|
|
483
|
+
.iter()
|
|
484
|
+
.find(|entry| entry["type"] == "inbound-rtp" && entry["kind"] == kind)
|
|
485
|
+
};
|
|
486
|
+
let decoding =
|
|
487
|
+
inbound("video").is_some_and(|entry| entry["framesDecoded"].as_u64().unwrap_or(0) > 0);
|
|
488
|
+
let pair = entries
|
|
489
|
+
.iter()
|
|
490
|
+
.any(|entry| entry["type"] == "candidate-pair");
|
|
491
|
+
decoding && inbound("audio").is_some() && pair
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#[test]
|
|
496
|
+
fn closing_a_live_connection_fences_late_frames() {
|
|
497
|
+
let loopback = Loopback::connect();
|
|
498
|
+
let shared = Arc::clone(&loopback.bridge.shared);
|
|
499
|
+
wait_until("a decoded frame", MEDIA_TIMEOUT, || {
|
|
500
|
+
loopback.sources.push();
|
|
501
|
+
thread::sleep(Duration::from_millis(30));
|
|
502
|
+
shared.video.counts().queued > 0
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Close fences admission before the remote pushes more media; after
|
|
506
|
+
// shutdown no late callback can reach a queue.
|
|
507
|
+
loopback.bridge.close();
|
|
508
|
+
loopback.sources.push();
|
|
509
|
+
loopback.bridge.shutdown().expect("shutdown");
|
|
510
|
+
assert_eq!(shared.video.take(|_| true), Taken::Closed);
|
|
511
|
+
assert_eq!(shared.audio.take(|_| true), Taken::Closed);
|
|
512
|
+
assert_eq!(shared.events.take(|_| true), Taken::Closed);
|
|
513
|
+
}
|
package/rust/src/peer.rs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
//! A native peer: the handle the host holds, its owner thread and its
|
|
2
|
+
//! notifier thread.
|
|
3
|
+
|
|
4
|
+
mod callbacks;
|
|
5
|
+
mod media;
|
|
6
|
+
mod owner;
|
|
7
|
+
mod shared;
|
|
8
|
+
|
|
9
|
+
#[cfg(test)]
|
|
10
|
+
pub(crate) use media::{AudioItem, VideoItem};
|
|
11
|
+
pub(crate) use shared::Shared;
|
|
12
|
+
|
|
13
|
+
use crate::abi::{Channel, Operation};
|
|
14
|
+
use crate::error::{BridgeError, FailureClass};
|
|
15
|
+
use crate::ffi::ReactorEffectNotify;
|
|
16
|
+
use crate::protocol;
|
|
17
|
+
use crate::sync::lock;
|
|
18
|
+
use owner::{Command, Owner, Reply};
|
|
19
|
+
use std::sync::mpsc::{self, Sender};
|
|
20
|
+
use std::sync::{Arc, Mutex};
|
|
21
|
+
use std::thread::{self, JoinHandle};
|
|
22
|
+
|
|
23
|
+
#[cfg(test)]
|
|
24
|
+
mod tests;
|
|
25
|
+
|
|
26
|
+
/// A native peer, behind the opaque `ReactorEffectPeer *` of the C ABI.
|
|
27
|
+
///
|
|
28
|
+
/// Calls and sends run one at a time on the peer's owner thread, which holds
|
|
29
|
+
/// every libwebrtc object. Closing and the takes act on the shared queues
|
|
30
|
+
/// directly, from whichever thread the host uses.
|
|
31
|
+
pub struct ReactorEffectPeer {
|
|
32
|
+
shared: Arc<Shared>,
|
|
33
|
+
commands: Sender<Command>,
|
|
34
|
+
threads: Mutex<Threads>,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The host calls entry points from several threads at once.
|
|
38
|
+
const _: () = {
|
|
39
|
+
const fn thread_safe<T: Send + Sync>() {}
|
|
40
|
+
thread_safe::<ReactorEffectPeer>();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/// The peer's threads, until shutdown joins them.
|
|
44
|
+
#[derive(Default)]
|
|
45
|
+
struct Threads {
|
|
46
|
+
owner: Option<JoinHandle<()>>,
|
|
47
|
+
notifier: Option<JoinHandle<()>>,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl ReactorEffectPeer {
|
|
51
|
+
/// Start a peer's owner thread and, when the host passed a callback, its
|
|
52
|
+
/// notifier thread. `None` when a thread cannot start.
|
|
53
|
+
pub(crate) fn create(notify: Option<ReactorEffectNotify>) -> Option<Self> {
|
|
54
|
+
let shared = Arc::new(Shared::new());
|
|
55
|
+
let (commands, received) = mpsc::channel();
|
|
56
|
+
let owner = thread::Builder::new()
|
|
57
|
+
.name("reactor-effect-native".into())
|
|
58
|
+
.spawn({
|
|
59
|
+
let shared = Arc::clone(&shared);
|
|
60
|
+
move || Owner::new(shared).run(&received)
|
|
61
|
+
})
|
|
62
|
+
.ok()?;
|
|
63
|
+
let peer = Self {
|
|
64
|
+
shared,
|
|
65
|
+
commands,
|
|
66
|
+
threads: Mutex::new(Threads {
|
|
67
|
+
owner: Some(owner),
|
|
68
|
+
notifier: None,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
if let Some(notify) = notify {
|
|
72
|
+
let shared = Arc::clone(&peer.shared);
|
|
73
|
+
// On failure `peer` drops, which joins the owner thread.
|
|
74
|
+
#[expect(
|
|
75
|
+
clippy::redundant_closure,
|
|
76
|
+
reason = "an extern \"C\" fn pointer does not implement FnMut"
|
|
77
|
+
)]
|
|
78
|
+
let notifier = thread::Builder::new()
|
|
79
|
+
.name("reactor-effect-notify".into())
|
|
80
|
+
.spawn(move || shared.notifier.run(|ready| notify(ready)))
|
|
81
|
+
.ok()?;
|
|
82
|
+
lock(&peer.threads).notifier = Some(notifier);
|
|
83
|
+
}
|
|
84
|
+
Some(peer)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pub(crate) fn shared(&self) -> &Shared {
|
|
88
|
+
&self.shared
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Run one operation. A snapshot is answered here, never queued behind a
|
|
92
|
+
/// blocking libwebrtc call on the owner thread.
|
|
93
|
+
pub(crate) fn call(&self, operation: u32, request: &[u8]) -> Result<Vec<u8>, BridgeError> {
|
|
94
|
+
self.ensure_open()?;
|
|
95
|
+
match Operation::try_from(operation)? {
|
|
96
|
+
Operation::MediaSnapshot => protocol::encode(&self.shared.snapshot()),
|
|
97
|
+
operation @ (Operation::Prepare
|
|
98
|
+
| Operation::Answer
|
|
99
|
+
| Operation::Direction
|
|
100
|
+
| Operation::MaxBitrate
|
|
101
|
+
| Operation::Stats) => self.on_owner(|reply| Command::Call {
|
|
102
|
+
operation,
|
|
103
|
+
request: request.to_vec(),
|
|
104
|
+
reply,
|
|
105
|
+
}),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Send one binary message on a bridge data channel. The C ABI has already
|
|
110
|
+
/// held it to `MAX_MESSAGE_BYTES`.
|
|
111
|
+
pub(crate) fn send(&self, channel: u32, bytes: &[u8]) -> Result<(), BridgeError> {
|
|
112
|
+
self.ensure_open()?;
|
|
113
|
+
let channel = Channel::try_from(channel)?;
|
|
114
|
+
self.on_owner(|reply| Command::Send {
|
|
115
|
+
channel,
|
|
116
|
+
bytes: bytes.to_vec(),
|
|
117
|
+
reply,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Fence callback admission and host calls at once, and discard what is
|
|
122
|
+
/// queued.
|
|
123
|
+
pub(crate) fn close(&self) {
|
|
124
|
+
self.shared.close();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Close, then join the owner thread, every admitted callback and the
|
|
128
|
+
/// notifier thread. Idempotent: holding the thread lock throughout makes
|
|
129
|
+
/// a concurrent shutdown wait for this one to finish joining.
|
|
130
|
+
pub(crate) fn shutdown(&self) -> Result<(), BridgeError> {
|
|
131
|
+
self.close();
|
|
132
|
+
let mut threads = lock(&self.threads);
|
|
133
|
+
let mut result = Ok(());
|
|
134
|
+
if let Some(owner) = threads.owner.take() {
|
|
135
|
+
#[expect(
|
|
136
|
+
clippy::let_underscore_must_use,
|
|
137
|
+
reason = "a failed send means the owner has stopped already; it is joined either way"
|
|
138
|
+
)]
|
|
139
|
+
let _ = self.commands.send(Command::Shutdown);
|
|
140
|
+
if owner.join().is_err() {
|
|
141
|
+
result = Err(BridgeError::new(
|
|
142
|
+
FailureClass::Native,
|
|
143
|
+
"native owner thread panicked",
|
|
144
|
+
));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// The notifier may be inside the host callback, waiting for the host
|
|
148
|
+
// to run it, so the host joins from a thread other than the one that
|
|
149
|
+
// runs its callback.
|
|
150
|
+
if let Some(notifier) = threads.notifier.take()
|
|
151
|
+
&& notifier.join().is_err()
|
|
152
|
+
&& result.is_ok()
|
|
153
|
+
{
|
|
154
|
+
result = Err(BridgeError::new(
|
|
155
|
+
FailureClass::Native,
|
|
156
|
+
"native notifier thread panicked",
|
|
157
|
+
));
|
|
158
|
+
}
|
|
159
|
+
result
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fn ensure_open(&self) -> Result<(), BridgeError> {
|
|
163
|
+
if self.shared.gate.is_open() {
|
|
164
|
+
Ok(())
|
|
165
|
+
} else {
|
|
166
|
+
Err(BridgeError::closed())
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Queue a command for the owner thread and wait for its reply. Either
|
|
171
|
+
/// channel failing means the owner thread has stopped: the peer is closed.
|
|
172
|
+
fn on_owner<T>(&self, command: impl FnOnce(Reply<T>) -> Command) -> Result<T, BridgeError> {
|
|
173
|
+
let (reply, response) = mpsc::sync_channel(1);
|
|
174
|
+
self.commands.send(command(reply))?;
|
|
175
|
+
response.recv()?
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
impl Drop for ReactorEffectPeer {
|
|
180
|
+
fn drop(&mut self) {
|
|
181
|
+
// A host that needs the result calls `reactor_effect_peer_shutdown`
|
|
182
|
+
// first, and this is then a no-op.
|
|
183
|
+
#[expect(
|
|
184
|
+
clippy::let_underscore_must_use,
|
|
185
|
+
reason = "destruction has no failure channel"
|
|
186
|
+
)]
|
|
187
|
+
let _ = self.shutdown();
|
|
188
|
+
}
|
|
189
|
+
}
|