@desklink/host 0.5.4 → 0.5.6
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/README.md +8 -2
- package/dist/bridge.js +3 -3
- package/dist/bridge.js.map +1 -1
- package/dist/engineProcess.d.ts.map +1 -1
- package/dist/engineProcess.js +1 -0
- package/dist/engineProcess.js.map +1 -1
- package/dist/protocol.d.ts +39 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/docs/BOUNDARY.md +4 -2
- package/docs/PROTOCOL.md +70 -7
- package/engine/Cargo.lock +1 -1
- package/engine/Cargo.toml +1 -1
- package/engine/src/audio.rs +398 -0
- package/engine/src/mac.rs +1 -0
- package/engine/src/main.rs +22 -0
- package/engine/src/peer.rs +73 -0
- package/engine/src/protocol.rs +22 -0
- package/engine/src/session.rs +176 -3
- package/engine/src/win.rs +1 -0
- package/engine/vendor/rtc-ice/DESKLINK-PATCH.md +53 -1
- package/engine/vendor/rtc-ice/src/agent/agent_selector.rs +81 -0
- package/engine/vendor/rtc-ice/src/agent/agent_test.rs +206 -0
- package/engine/vendor/rtc-ice/src/agent/mod.rs +91 -0
- package/engine/vendor/rtc-ice/src/dl_trace.rs +49 -0
- package/engine/vendor/rtc-ice/src/lib.rs +3 -0
- package/package.json +3 -3
package/engine/src/session.rs
CHANGED
|
@@ -93,6 +93,10 @@ pub struct Metrics {
|
|
|
93
93
|
/// Control messages forwarded back to the consumer that owns the device,
|
|
94
94
|
/// instead of being applied to a desktop this engine holds.
|
|
95
95
|
pub input_forwarded: u64,
|
|
96
|
+
pub audio_frames: u64,
|
|
97
|
+
pub audio_bytes: u64,
|
|
98
|
+
pub audio_dropped_bytes: u64,
|
|
99
|
+
pub audio_gap_micros: u64,
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
fn tile_hashes(raw: &[u8], width: usize, height: usize) -> Vec<u64> {
|
|
@@ -708,6 +712,8 @@ pub struct OpenRequest {
|
|
|
708
712
|
/// Keep the latest frame and its tile hashes for `session.frame` and
|
|
709
713
|
/// `session.frame.changed`; off, a frame is converted once, for the encoder.
|
|
710
714
|
pub local_frames: bool,
|
|
715
|
+
/// Capture the desktop's own system audio for `session.audio`.
|
|
716
|
+
pub audio: bool,
|
|
711
717
|
}
|
|
712
718
|
|
|
713
719
|
/// Events a session raises for its consumer.
|
|
@@ -736,6 +742,14 @@ pub enum SessionEvent {
|
|
|
736
742
|
seq: u64,
|
|
737
743
|
damage: Vec<[usize; 4]>,
|
|
738
744
|
},
|
|
745
|
+
/// One chunk was captured, even if the full queue dropped it, on the same
|
|
746
|
+
/// clock `session.frame.changed` uses.
|
|
747
|
+
Audio {
|
|
748
|
+
seq: u64,
|
|
749
|
+
timestamp_us: u64,
|
|
750
|
+
bytes: u64,
|
|
751
|
+
gap_micros: u64,
|
|
752
|
+
},
|
|
739
753
|
/// One control-channel message from the client, for a source whose device
|
|
740
754
|
/// the consumer drives. The engine applied nothing locally.
|
|
741
755
|
Input {
|
|
@@ -817,6 +831,9 @@ struct Inner {
|
|
|
817
831
|
encoded: Mutex<Option<Encoded>>,
|
|
818
832
|
/// `OpenRequest::local_frames`: whether `latest` is kept at all.
|
|
819
833
|
local_frames: bool,
|
|
834
|
+
/// Dropped on close, stopping the PipeWire stream.
|
|
835
|
+
#[cfg(target_os = "linux")]
|
|
836
|
+
audio: Mutex<Option<crate::audio::AudioCapture>>,
|
|
820
837
|
cursor_info: serde_json::Value,
|
|
821
838
|
}
|
|
822
839
|
|
|
@@ -869,6 +886,9 @@ pub fn capabilities() -> serde_json::Value {
|
|
|
869
886
|
.map(|desktop| serde_json::json!([desktop.screen_size().0, desktop.screen_size().1]))
|
|
870
887
|
.unwrap_or(serde_json::Value::Null);
|
|
871
888
|
let clipboard = wayland_clipboard_available();
|
|
889
|
+
// Asked, not taken: the probe connects to this session's PipeWire, reads the
|
|
890
|
+
// registry and leaves. No portal, no prompt, no capture.
|
|
891
|
+
let audio = crate::audio::probe();
|
|
872
892
|
let (input, grant_state, unavailable) = match crate::input::probe() {
|
|
873
893
|
Ok(()) => (serde_json::json!(true), "granted", serde_json::Value::Null),
|
|
874
894
|
Err(unavailable) => (
|
|
@@ -896,7 +916,9 @@ pub fn capabilities() -> serde_json::Value {
|
|
|
896
916
|
"cursor": "embedded",
|
|
897
917
|
"cursor_modes": ["embedded", "hidden"],
|
|
898
918
|
"cursor_positions": {"x11": true, "wayland": "ext-image-copy-capture-v1"},
|
|
899
|
-
"audio":
|
|
919
|
+
"audio": audio.is_some(),
|
|
920
|
+
"audio_formats": crate::audio::FORMATS,
|
|
921
|
+
"audio_source": audio.map(|source| source.name),
|
|
900
922
|
},
|
|
901
923
|
"encode": encode_capabilities(),
|
|
902
924
|
"input": {
|
|
@@ -949,6 +971,7 @@ pub fn capabilities() -> serde_json::Value {
|
|
|
949
971
|
"formats": ["bgra"],
|
|
950
972
|
"cursor": "embedded",
|
|
951
973
|
"cursor_modes": ["embedded"],
|
|
974
|
+
// ScreenCaptureKit audio is not wired up; macOS is out of scope.
|
|
952
975
|
"audio": false,
|
|
953
976
|
"displays": crate::mac::displays(),
|
|
954
977
|
"grant": if capture_granted { "granted" } else { "missing-screen-recording" },
|
|
@@ -1007,6 +1030,13 @@ impl Session {
|
|
|
1007
1030
|
request: OpenRequest,
|
|
1008
1031
|
events: tokio_mpsc::UnboundedSender<Notice>,
|
|
1009
1032
|
) -> std::result::Result<Self, SessionError> {
|
|
1033
|
+
#[cfg(not(target_os = "linux"))]
|
|
1034
|
+
if request.audio {
|
|
1035
|
+
return Err(SessionError::new(
|
|
1036
|
+
"audio-unavailable",
|
|
1037
|
+
"system audio capture requires Linux PipeWire",
|
|
1038
|
+
));
|
|
1039
|
+
}
|
|
1010
1040
|
if request.cursor == crate::protocol::CursorMode::Hidden {
|
|
1011
1041
|
#[cfg(not(target_os = "linux"))]
|
|
1012
1042
|
return Err(SessionError::new(
|
|
@@ -1370,6 +1400,14 @@ impl Session {
|
|
|
1370
1400
|
SessionError::new("input", format!("no keyboard layout: {error:#}"))
|
|
1371
1401
|
})?;
|
|
1372
1402
|
|
|
1403
|
+
// Refuse before allocating the peer; never silently downgrade.
|
|
1404
|
+
#[cfg(target_os = "linux")]
|
|
1405
|
+
let audio = if request.audio {
|
|
1406
|
+
Some(start_audio(&id, &events)?)
|
|
1407
|
+
} else {
|
|
1408
|
+
None
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1373
1411
|
// H.264 is offered beside VP9 only when an encoder for it starts on
|
|
1374
1412
|
// this machine; the answer then decides which one is coded.
|
|
1375
1413
|
let codec = if !crate::h264::fits(width, height) {
|
|
@@ -1433,6 +1471,8 @@ impl Session {
|
|
|
1433
1471
|
},
|
|
1434
1472
|
capture: Mutex::new(Some(capture)),
|
|
1435
1473
|
layout: Mutex::new(Some(layout)),
|
|
1474
|
+
#[cfg(target_os = "linux")]
|
|
1475
|
+
audio: Mutex::new(audio),
|
|
1436
1476
|
last_seq: Mutex::new(0),
|
|
1437
1477
|
control_open: AtomicBool::new(false),
|
|
1438
1478
|
closed: AtomicBool::new(false),
|
|
@@ -1474,6 +1514,12 @@ impl Session {
|
|
|
1474
1514
|
height: usize,
|
|
1475
1515
|
events: tokio_mpsc::UnboundedSender<Notice>,
|
|
1476
1516
|
) -> std::result::Result<Self, SessionError> {
|
|
1517
|
+
if request.audio {
|
|
1518
|
+
return Err(SessionError::new(
|
|
1519
|
+
"operation",
|
|
1520
|
+
"an encoded source carries video the consumer already encoded; there is no local desktop audio to capture",
|
|
1521
|
+
));
|
|
1522
|
+
}
|
|
1477
1523
|
if width == 0 || height == 0 || width > 7680 || height > 4320 {
|
|
1478
1524
|
return Err(SessionError::new(
|
|
1479
1525
|
"source",
|
|
@@ -1523,6 +1569,8 @@ impl Session {
|
|
|
1523
1569
|
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
1524
1570
|
capture: Mutex::new(None),
|
|
1525
1571
|
layout: Mutex::new(None),
|
|
1572
|
+
#[cfg(target_os = "linux")]
|
|
1573
|
+
audio: Mutex::new(None),
|
|
1526
1574
|
last_seq: Mutex::new(0),
|
|
1527
1575
|
control_open: AtomicBool::new(false),
|
|
1528
1576
|
closed: AtomicBool::new(false),
|
|
@@ -1850,11 +1898,92 @@ impl Session {
|
|
|
1850
1898
|
}
|
|
1851
1899
|
|
|
1852
1900
|
pub fn metrics(&self) -> Metrics {
|
|
1853
|
-
self
|
|
1901
|
+
let mut metrics = self
|
|
1902
|
+
.inner
|
|
1854
1903
|
.metrics
|
|
1855
1904
|
.lock()
|
|
1856
1905
|
.map(|m| m.clone())
|
|
1857
|
-
.unwrap_or_default()
|
|
1906
|
+
.unwrap_or_default();
|
|
1907
|
+
#[cfg(target_os = "linux")]
|
|
1908
|
+
if let Some(audio) = lock(&self.inner.audio).as_ref() {
|
|
1909
|
+
let (frames, bytes, dropped_bytes, gap_micros) = audio.totals();
|
|
1910
|
+
metrics.audio_frames = frames;
|
|
1911
|
+
metrics.audio_bytes = bytes;
|
|
1912
|
+
metrics.audio_dropped_bytes = dropped_bytes;
|
|
1913
|
+
metrics.audio_gap_micros = gap_micros;
|
|
1914
|
+
}
|
|
1915
|
+
metrics
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
pub fn audio_info(&self) -> Option<serde_json::Value> {
|
|
1919
|
+
#[cfg(target_os = "linux")]
|
|
1920
|
+
{
|
|
1921
|
+
lock(&self.inner.audio).as_ref()?; // start() accepted the fixed format.
|
|
1922
|
+
return Some(serde_json::json!({
|
|
1923
|
+
"source": "pipewire-monitor", "format": crate::audio::FORMAT,
|
|
1924
|
+
"sample_rate": crate::audio::SAMPLE_RATE, "channels": crate::audio::CHANNELS,
|
|
1925
|
+
}));
|
|
1926
|
+
}
|
|
1927
|
+
#[cfg(not(target_os = "linux"))]
|
|
1928
|
+
None
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/// Consume one queued PCM chunk; samples never travel through JSON.
|
|
1932
|
+
#[cfg(target_os = "linux")]
|
|
1933
|
+
pub fn audio(
|
|
1934
|
+
&self,
|
|
1935
|
+
path: &str,
|
|
1936
|
+
max_bytes: usize,
|
|
1937
|
+
) -> std::result::Result<serde_json::Value, SessionError> {
|
|
1938
|
+
let no_audio = if lock(&self.inner.encoded).is_some() {
|
|
1939
|
+
"this session forwards an encoded stream and has no local audio"
|
|
1940
|
+
} else {
|
|
1941
|
+
"this session was opened without audio"
|
|
1942
|
+
};
|
|
1943
|
+
let held = lock(&self.inner.audio);
|
|
1944
|
+
let capture = held
|
|
1945
|
+
.as_ref()
|
|
1946
|
+
.ok_or_else(|| SessionError::new("operation", no_audio))?;
|
|
1947
|
+
let frame_bytes = crate::audio::BYTES_PER_FRAME as usize;
|
|
1948
|
+
if max_bytes < frame_bytes {
|
|
1949
|
+
return Err(SessionError::new(
|
|
1950
|
+
"malformed",
|
|
1951
|
+
"max_bytes must hold at least one sample frame",
|
|
1952
|
+
));
|
|
1953
|
+
}
|
|
1954
|
+
let chunk = capture
|
|
1955
|
+
.take()
|
|
1956
|
+
.ok_or_else(|| SessionError::new("audio", "no audio chunk has arrived yet"))?;
|
|
1957
|
+
let wanted = (max_bytes / frame_bytes * frame_bytes).min(chunk.bytes.len());
|
|
1958
|
+
let written = if path.is_empty() { 0 } else { wanted };
|
|
1959
|
+
let result = serde_json::json!({
|
|
1960
|
+
"seq": chunk.seq,
|
|
1961
|
+
"timestamp_us": crate::protocol::timestamp_us(chunk.at),
|
|
1962
|
+
"frames": written as u64 / crate::audio::BYTES_PER_FRAME,
|
|
1963
|
+
"bytes": written,
|
|
1964
|
+
"format": crate::audio::FORMAT,
|
|
1965
|
+
"sample_rate": crate::audio::SAMPLE_RATE,
|
|
1966
|
+
"channels": crate::audio::CHANNELS,
|
|
1967
|
+
"gap_ms": chunk.gap_micros / 1000,
|
|
1968
|
+
"written": !path.is_empty(),
|
|
1969
|
+
});
|
|
1970
|
+
if written > 0 {
|
|
1971
|
+
std::fs::write(path, &chunk.bytes[..written])
|
|
1972
|
+
.map_err(|error| SessionError::new("audio", error.to_string()))?;
|
|
1973
|
+
}
|
|
1974
|
+
Ok(result)
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
#[cfg(not(target_os = "linux"))]
|
|
1978
|
+
pub fn audio(
|
|
1979
|
+
&self,
|
|
1980
|
+
_path: &str,
|
|
1981
|
+
_max_bytes: usize,
|
|
1982
|
+
) -> std::result::Result<serde_json::Value, SessionError> {
|
|
1983
|
+
Err(SessionError::new(
|
|
1984
|
+
"operation",
|
|
1985
|
+
"this platform has no system audio capture",
|
|
1986
|
+
))
|
|
1858
1987
|
}
|
|
1859
1988
|
|
|
1860
1989
|
pub async fn accept_answer(&self, sdp: String) -> Result<()> {
|
|
@@ -1987,6 +2116,8 @@ impl Inner {
|
|
|
1987
2116
|
// out of scope, so the consumer can truthfully say the desktop stopped.
|
|
1988
2117
|
let capture = self.capture.lock().ok().and_then(|mut held| held.take());
|
|
1989
2118
|
drop(capture);
|
|
2119
|
+
#[cfg(target_os = "linux")]
|
|
2120
|
+
drop(self.audio.lock().ok().and_then(|mut held| held.take()));
|
|
1990
2121
|
self.notify(SessionEvent::State {
|
|
1991
2122
|
capture: "ended",
|
|
1992
2123
|
transport: String::from("closed"),
|
|
@@ -2112,6 +2243,15 @@ impl Inner {
|
|
|
2112
2243
|
self.forward(message);
|
|
2113
2244
|
return;
|
|
2114
2245
|
}
|
|
2246
|
+
// A shared window's place on the desktop is unknown (the portal reports
|
|
2247
|
+
// no origin for it), while the pointer device spans the whole desktop:
|
|
2248
|
+
// a tap would land somewhere the person never pointed.
|
|
2249
|
+
if self.source.source_type.as_deref() == Some("Window")
|
|
2250
|
+
&& matches!(&message, ControlMessage::Pointer { phase, .. } if !matches!(phase, PointerPhase::Cancel))
|
|
2251
|
+
{
|
|
2252
|
+
self.reject(seq, "input-unavailable", WINDOW_POINTER_REFUSAL);
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2115
2255
|
if !matches!(
|
|
2116
2256
|
&message,
|
|
2117
2257
|
ControlMessage::ClipboardRead { .. } | ControlMessage::ClipboardWrite { .. }
|
|
@@ -2445,6 +2585,27 @@ fn frame_interval(max_fps: u32) -> Duration {
|
|
|
2445
2585
|
Duration::from_secs_f64(1.0 / max_fps.max(1) as f64)
|
|
2446
2586
|
}
|
|
2447
2587
|
|
|
2588
|
+
#[cfg(target_os = "linux")]
|
|
2589
|
+
fn start_audio(
|
|
2590
|
+
id: &str,
|
|
2591
|
+
events: &tokio_mpsc::UnboundedSender<Notice>,
|
|
2592
|
+
) -> std::result::Result<crate::audio::AudioCapture, SessionError> {
|
|
2593
|
+
let events = events.clone();
|
|
2594
|
+
let id = id.to_owned();
|
|
2595
|
+
crate::audio::AudioCapture::start(Box::new(move |notice| {
|
|
2596
|
+
let _ = events.send(Notice {
|
|
2597
|
+
session_id: id.clone(),
|
|
2598
|
+
event: SessionEvent::Audio {
|
|
2599
|
+
seq: notice.seq,
|
|
2600
|
+
timestamp_us: crate::protocol::timestamp_us(notice.at),
|
|
2601
|
+
bytes: notice.bytes,
|
|
2602
|
+
gap_micros: notice.gap_micros,
|
|
2603
|
+
},
|
|
2604
|
+
});
|
|
2605
|
+
}))
|
|
2606
|
+
.map_err(|reason| SessionError::new("audio-unavailable", reason))
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2448
2609
|
/// The newest captured frame and when it was captured, handed from the capture
|
|
2449
2610
|
/// thread to the encoder. A newer frame replaces one the encoder has not taken,
|
|
2450
2611
|
/// so however far behind it falls, what it codes next is the desktop as it is
|
|
@@ -3243,6 +3404,11 @@ fn spawn_peer_events(inner: &Arc<Inner>, mut events: tokio_mpsc::UnboundedReceiv
|
|
|
3243
3404
|
first_frame: inner.metrics.lock().map(|m| m.encoded_frames).unwrap_or(0)
|
|
3244
3405
|
> 0,
|
|
3245
3406
|
});
|
|
3407
|
+
// desklink scout trace: the session's transport emit, one
|
|
3408
|
+
// line after the peer event that caused it.
|
|
3409
|
+
if crate::peer::dl_trace_on() {
|
|
3410
|
+
crate::peer::dl_eng!("session_transport_emit {}", format!("{state}").to_lowercase());
|
|
3411
|
+
}
|
|
3246
3412
|
}
|
|
3247
3413
|
PeerEvent::ControlOpen => {
|
|
3248
3414
|
inner.control_open.store(true, Ordering::SeqCst);
|
|
@@ -3301,6 +3467,8 @@ fn available_parallelism() -> usize {
|
|
|
3301
3467
|
.unwrap_or(4)
|
|
3302
3468
|
}
|
|
3303
3469
|
|
|
3470
|
+
const WINDOW_POINTER_REFUSAL: &str = "a shared window cannot take taps: its place on the desktop is unknown; share the whole screen to point and click";
|
|
3471
|
+
|
|
3304
3472
|
/// Encoded-surface pixels to the source's own pixels. The client aims at what it
|
|
3305
3473
|
/// sees; the applier is sized and clamped to the source, so the layout origin
|
|
3306
3474
|
/// must not be added here.
|
|
@@ -3417,6 +3585,8 @@ mod tests {
|
|
|
3417
3585
|
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
3418
3586
|
capture: Mutex::new(None),
|
|
3419
3587
|
layout: Mutex::new(None),
|
|
3588
|
+
#[cfg(target_os = "linux")]
|
|
3589
|
+
audio: Mutex::new(None),
|
|
3420
3590
|
last_seq: Mutex::new(0),
|
|
3421
3591
|
control_open: AtomicBool::new(false),
|
|
3422
3592
|
closed: AtomicBool::new(false),
|
|
@@ -3673,6 +3843,8 @@ mod tests {
|
|
|
3673
3843
|
#[cfg(target_os = "linux")]
|
|
3674
3844
|
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
3675
3845
|
layout: Mutex::new(Some(Layout::from_environment().expect("a keymap"))),
|
|
3846
|
+
#[cfg(target_os = "linux")]
|
|
3847
|
+
audio: Mutex::new(None),
|
|
3676
3848
|
last_seq: Mutex::new(0),
|
|
3677
3849
|
control_open: AtomicBool::new(true),
|
|
3678
3850
|
closed: AtomicBool::new(false),
|
|
@@ -4080,6 +4252,7 @@ mod tests {
|
|
|
4080
4252
|
loopback_tcp: false,
|
|
4081
4253
|
agent_indicator: false,
|
|
4082
4254
|
local_frames: true,
|
|
4255
|
+
audio: false,
|
|
4083
4256
|
}
|
|
4084
4257
|
}
|
|
4085
4258
|
|
package/engine/src/win.rs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# desklink patch to vendored `rtc-ice` 0.21.0
|
|
2
2
|
|
|
3
|
+
Two shapes, both for the roam-persistence goal: keep media flowing across an
|
|
4
|
+
ICE restart, and nominate the restarted generation without waiting for a check
|
|
5
|
+
tick.
|
|
6
|
+
|
|
3
7
|
## What
|
|
4
8
|
|
|
9
|
+
### 1. Media keeps flowing on the previous pair across a restart
|
|
10
|
+
|
|
5
11
|
`Agent::apply_restart` (src/agent/mod.rs) no longer interrupts media that is
|
|
6
12
|
already flowing on the current selected ICE pair. When a restart is applied, the
|
|
7
13
|
pair media was using is remembered (`restart_media_pair`), and
|
|
@@ -11,6 +17,19 @@ local credentials are installed, remote credentials are dropped, binding
|
|
|
11
17
|
requests and candidate pairs are cleared, the agent re-checks, and the
|
|
12
18
|
connection state goes to `Checking`.
|
|
13
19
|
|
|
20
|
+
### 2. The nomination does not wait for the next check tick
|
|
21
|
+
|
|
22
|
+
`ControllingSelector::handle_success_response` (src/agent/agent_selector.rs)
|
|
23
|
+
requests an immediate check pass when the response makes a pair `Succeeded` and
|
|
24
|
+
that pair is the one the next pass would nominate. Nomination is decided in
|
|
25
|
+
`contact_candidates`, which the driver only reaches on a `check_interval` tick
|
|
26
|
+
(200 ms) or when an inbound Binding request happens to arrive while a valid pair
|
|
27
|
+
already exists; a pair that becomes valid just after a tick therefore waits a
|
|
28
|
+
full tick for its `USE-CANDIDATE`. The request uses
|
|
29
|
+
`Agent::request_connectivity_check()`, the deferred-check mechanism added for
|
|
30
|
+
issue #88, so `contact()` is not called from the read path and `poll_timeout`
|
|
31
|
+
reports an already-due deadline instead.
|
|
32
|
+
|
|
14
33
|
Every changed spot is marked with a `// desklink patch:` comment.
|
|
15
34
|
|
|
16
35
|
## Why
|
|
@@ -33,8 +52,20 @@ The behavior is pinned by the engine integration test
|
|
|
33
52
|
`a_restart_keeps_media_flowing_on_the_selected_pair_until_the_new_one_nominates`
|
|
34
53
|
in `packages/desktop-host/engine/src/peer.rs`.
|
|
35
54
|
|
|
55
|
+
Shape 2 exists because the tick is the engine's largest single bounded cost
|
|
56
|
+
between a restart offer and `transport=connected`: in the routed roam leg the
|
|
57
|
+
engine's first pair became valid 332 ms after the answer and its `USE-CANDIDATE`
|
|
58
|
+
left 197 ms later — one 200 ms tick. A peer that runs no connectivity checks of
|
|
59
|
+
its own leaves an inbound request unavailable, and the nomination then waits for
|
|
60
|
+
the tick. The engine integration test asserts the reconnect bound; the loopback
|
|
61
|
+
harness cannot grade sub-tick timing on its own (its client answers checks and
|
|
62
|
+
sends triggered checks in the same burst, so an inbound request is available
|
|
63
|
+
almost immediately), which is why the field measurement is the reference.
|
|
64
|
+
|
|
36
65
|
## Scope of the change
|
|
37
66
|
|
|
67
|
+
### Shape 1
|
|
68
|
+
|
|
38
69
|
- `Agent` gains `restart_media_pair: Option<(Candidate, Candidate)>`, initialised
|
|
39
70
|
to `None`.
|
|
40
71
|
- `apply_restart` clones the current `(local, remote)` into it before the
|
|
@@ -45,6 +76,16 @@ in `packages/desktop-host/engine/src/peer.rs`.
|
|
|
45
76
|
- `update_connection_state` clears it on `Failed` / `Closed`, so a restart that
|
|
46
77
|
never nominates does not keep media pointed at an unreachable pair.
|
|
47
78
|
|
|
79
|
+
### Shape 2
|
|
80
|
+
|
|
81
|
+
- `ControllingSelector::handle_success_response` calls
|
|
82
|
+
`request_connectivity_check()` after a pair becomes `Succeeded`, when no pair is
|
|
83
|
+
selected or nominated yet, this pair is the best valid pair, and both of its
|
|
84
|
+
candidates have passed their acceptance wait (`is_nominatable`).
|
|
85
|
+
- Nothing else changes: the same `contact_candidates` pass decides the
|
|
86
|
+
nomination, the acceptance waits still gate it, and a nomination already in
|
|
87
|
+
flight is not re-requested, so the extra pass cannot repeat per response.
|
|
88
|
+
|
|
48
89
|
No public API changed; no signalling or wire change.
|
|
49
90
|
|
|
50
91
|
## How to drop the patch
|
|
@@ -55,5 +96,16 @@ No public API changed; no signalling or wire change.
|
|
|
55
96
|
3. Update `packages/desktop-host/engine/Cargo.lock` (`cargo update -p rtc-ice`).
|
|
56
97
|
|
|
57
98
|
Do this once upstream rtc-ice keeps the previous selected pair across a restart
|
|
58
|
-
on its own
|
|
99
|
+
on its own and nominates a freshly validated pair without waiting for the next
|
|
100
|
+
check tick (an upstream PR is a follow-up; it needs the captain's word first,
|
|
59
101
|
since it would carry his name).
|
|
102
|
+
|
|
103
|
+
## Dev-only measurement instrumentation (not part of the patch)
|
|
104
|
+
|
|
105
|
+
`src/dl_trace.rs` and its call sites — marked `// desklink scout trace:` — are
|
|
106
|
+
a private-lab instrument, not a shipped behaviour change. They are off unless
|
|
107
|
+
`DESKLINK_ICE_TRACE=1` is set, and when on they only write `[ice <us>]` lines to
|
|
108
|
+
stderr, which the bridge forwards untouched. To drop them: delete
|
|
109
|
+
`src/dl_trace.rs`, remove `pub(crate) mod dl_trace;` from `src/lib.rs`, and
|
|
110
|
+
remove the marked blocks from `src/agent/mod.rs` and
|
|
111
|
+
`src/agent/agent_selector.rs` (grep `desklink scout trace`).
|
|
@@ -10,6 +10,9 @@ use stun::textattrs::*;
|
|
|
10
10
|
|
|
11
11
|
use crate::attributes::{control::*, priority::*, use_candidate::*};
|
|
12
12
|
use crate::candidate::{candidate_pair::*, *};
|
|
13
|
+
// desklink scout trace (dl-roam-postnom-attr):
|
|
14
|
+
use crate::dl_trace;
|
|
15
|
+
use crate::dl_trace::dl_ice;
|
|
13
16
|
|
|
14
17
|
trait ControllingSelector {
|
|
15
18
|
fn start(&mut self, now: Instant);
|
|
@@ -133,6 +136,20 @@ impl Agent {
|
|
|
133
136
|
};
|
|
134
137
|
|
|
135
138
|
if let Some((msg, local, remote)) = result {
|
|
139
|
+
// desklink scout trace: a USE-CANDIDATE (nomination) request going
|
|
140
|
+
// out — the first transmission, or a tick-gated retransmission
|
|
141
|
+
// while the previous one is still unacknowledged.
|
|
142
|
+
if dl_trace::enabled() {
|
|
143
|
+
dl_ice!(
|
|
144
|
+
"{} NOMINATE txn={:?} local={} remote={} nominated_pair={:?} outstanding={}",
|
|
145
|
+
self.get_name(),
|
|
146
|
+
msg.transaction_id,
|
|
147
|
+
self.local_candidates[local].addr(),
|
|
148
|
+
self.remote_candidates[remote].addr(),
|
|
149
|
+
self.nominated_pair,
|
|
150
|
+
self.pending_binding_requests.len(),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
136
153
|
self.send_binding_request(now, &msg, local, remote);
|
|
137
154
|
}
|
|
138
155
|
}
|
|
@@ -222,6 +239,27 @@ impl ControllingSelector for Agent {
|
|
|
222
239
|
|
|
223
240
|
let nominated_pair_is_some = self.nominated_pair.is_some();
|
|
224
241
|
|
|
242
|
+
// desklink scout trace: which nomination branch this contact() pass took.
|
|
243
|
+
if dl_trace::enabled() {
|
|
244
|
+
let best = self
|
|
245
|
+
.get_best_valid_candidate_pair()
|
|
246
|
+
.map(|i| {
|
|
247
|
+
let p = &self.candidate_pairs[i];
|
|
248
|
+
format!(
|
|
249
|
+
"{}<->{}",
|
|
250
|
+
self.local_candidates[p.local_index].addr(),
|
|
251
|
+
self.remote_candidates[p.remote_index].addr()
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
.unwrap_or_else(|| "-none-valid-".to_string());
|
|
255
|
+
dl_ice!(
|
|
256
|
+
"{} contact_branch selected={:?} nominated={nominated_pair_is_some} best_valid={best} pairs={}",
|
|
257
|
+
self.get_name(),
|
|
258
|
+
self.selected_pair,
|
|
259
|
+
self.candidate_pairs.len(),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
225
263
|
if self.get_selected_pair().is_some() {
|
|
226
264
|
if self.validate_selected_pair(now) {
|
|
227
265
|
self.check_keepalive(now);
|
|
@@ -336,7 +374,39 @@ impl ControllingSelector for Agent {
|
|
|
336
374
|
rtt.as_millis()
|
|
337
375
|
);
|
|
338
376
|
if pending_request.is_use_candidate && selected_pair_is_none {
|
|
377
|
+
// desklink scout trace: the browser's answer to a nomination
|
|
378
|
+
// request — this is what converts nominated into selected.
|
|
379
|
+
if dl_trace::enabled() {
|
|
380
|
+
dl_ice!(
|
|
381
|
+
"{} NOMINATION_ACK txn={:?} rtt_us={} local={} remote={}",
|
|
382
|
+
self.get_name(),
|
|
383
|
+
m.transaction_id,
|
|
384
|
+
rtt.as_micros(),
|
|
385
|
+
self.local_candidates[local_index].addr(),
|
|
386
|
+
self.remote_candidates[remote_index].addr(),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
339
389
|
self.set_selected_pair(Some(now), Some(pair_index));
|
|
390
|
+
} else {
|
|
391
|
+
// desklink patch: nomination is decided in `contact_candidates`,
|
|
392
|
+
// and that only runs on a `check_interval` tick, so a pair that
|
|
393
|
+
// becomes valid just after a tick waits a full tick for its
|
|
394
|
+
// USE-CANDIDATE. Ask for the check pass now, when this is the pair
|
|
395
|
+
// that tick would nominate. `request_connectivity_check` is the
|
|
396
|
+
// deferred-check request (issue #88): it makes `poll_timeout`
|
|
397
|
+
// report an already-due deadline, so the driver runs `contact()`
|
|
398
|
+
// at once instead of at the next tick. See DESKLINK-PATCH.md.
|
|
399
|
+
if self.get_selected_pair().is_none()
|
|
400
|
+
&& self.nominated_pair.is_none()
|
|
401
|
+
&& self.get_best_valid_candidate_pair() == Some(pair_index)
|
|
402
|
+
{
|
|
403
|
+
let p = &self.candidate_pairs[pair_index];
|
|
404
|
+
if self.is_nominatable(now, p.local_index, true)
|
|
405
|
+
&& self.is_nominatable(now, p.remote_index, false)
|
|
406
|
+
{
|
|
407
|
+
self.request_connectivity_check();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
340
410
|
}
|
|
341
411
|
} else {
|
|
342
412
|
// This shouldn't happen
|
|
@@ -367,6 +437,17 @@ impl ControllingSelector for Agent {
|
|
|
367
437
|
let p = &self.candidate_pairs[pair_index];
|
|
368
438
|
let nominated_pair_is_none = self.nominated_pair.is_none();
|
|
369
439
|
|
|
440
|
+
// desklink scout trace: an inbound binding request on the
|
|
441
|
+
// controlling side — the browser's own connectivity check.
|
|
442
|
+
if dl_trace::enabled() {
|
|
443
|
+
dl_ice!(
|
|
444
|
+
"{} RECV_REQUEST controlling use_candidate={} pair_state={:?} nominated={nominated_pair_is_none}",
|
|
445
|
+
self.get_name(),
|
|
446
|
+
m.contains(ATTR_USE_CANDIDATE),
|
|
447
|
+
p.state,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
370
451
|
trace!(
|
|
371
452
|
"controllingSelector: after findPair {}, p.state: {}, {}",
|
|
372
453
|
p,
|