@desklink/host 0.5.8 → 0.5.10

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.
@@ -703,7 +703,7 @@ pub struct OpenRequest {
703
703
  pub max_height: usize,
704
704
  pub bitrate_kbps: u32,
705
705
  pub max_fps: u32,
706
- pub ice_servers: Vec<(String, Option<String>, Option<String>)>,
706
+ pub ice_servers: Vec<(Vec<String>, Option<String>, Option<String>)>,
707
707
  pub restore_token: Option<String>,
708
708
  pub ttl: Option<Duration>,
709
709
  /// Offer a passive ICE-TCP candidate on the loopback, for a forwarded client.
@@ -714,6 +714,8 @@ pub struct OpenRequest {
714
714
  pub local_frames: bool,
715
715
  /// Capture the desktop's own system audio for `session.audio`.
716
716
  pub audio: bool,
717
+ /// Listen to the desktop's own pointer buttons, cursor and keys.
718
+ pub observe_input: bool,
717
719
  }
718
720
 
719
721
  /// Events a session raises for its consumer.
@@ -755,6 +757,11 @@ pub enum SessionEvent {
755
757
  Input {
756
758
  input: serde_json::Value,
757
759
  },
760
+ /// A pointer button or key the person pressed, observed rather than applied.
761
+ Observed {
762
+ timestamp_us: u64,
763
+ input: crate::protocol::ObservedInput,
764
+ },
758
765
  /// The far end needs a fresh reference frame and only the consumer can make
759
766
  /// one: the encoder for this track is on its side. Raised by an RTCP PLI or
760
767
  /// FIR, and once when the transport connects, because everything sent before
@@ -762,6 +769,13 @@ pub enum SessionEvent {
762
769
  KeyframeRequest {
763
770
  generation: u64,
764
771
  },
772
+ /// An ICE server URL the request asked for that the peer's configuration
773
+ /// does not carry: one the WebRTC stack cannot dial (TURN over anything
774
+ /// but UDP). Reported once per open, naming the URL, so a skipped URL is
775
+ /// a warning the consumer sees rather than a silent drop.
776
+ IceServerSkipped {
777
+ url: String,
778
+ },
765
779
  RestoreToken(String),
766
780
  CaptureStopped {
767
781
  reason: String,
@@ -821,6 +835,13 @@ struct Inner {
821
835
  /// closes its Hyprland event socket.
822
836
  #[cfg(target_os = "linux")]
823
837
  window: Mutex<Option<crate::hyprland::WindowLocator>>,
838
+ /// Which Hyprland output the agent cue helper draws on (a located window
839
+ /// share's desktop mode): the helper respawns only when a press lands
840
+ /// elsewhere. Same for the point helper.
841
+ #[cfg(target_os = "linux")]
842
+ cue_output: Mutex<Option<String>>,
843
+ #[cfg(target_os = "linux")]
844
+ point_output: Mutex<Option<String>>,
824
845
  capture: Mutex<Option<FrameSource>>,
825
846
  /// Absent for an encoded source: there is no local keyboard to translate a
826
847
  /// character through, because the client's keys are forwarded instead.
@@ -840,6 +861,9 @@ struct Inner {
840
861
  #[cfg(target_os = "linux")]
841
862
  audio: Mutex<Option<crate::audio::AudioCapture>>,
842
863
  cursor_info: serde_json::Value,
864
+ /// `session.open`'s `inputObservation` member, fixed when the session opened.
865
+ observation: serde_json::Value,
866
+ observer: Mutex<Option<crate::observe::Tap>>,
843
867
  }
844
868
 
845
869
  /// A source whose video arrives already encoded.
@@ -964,6 +988,7 @@ pub fn capabilities() -> serde_json::Value {
964
988
  }),
965
989
  ),
966
990
  };
991
+ let observe = crate::input::observe_probe();
967
992
  serde_json::json!({
968
993
  "protocol": crate::protocol::PROTOCOL_VERSION,
969
994
  "engine": format!("desklink-host/{}", env!("CARGO_PKG_VERSION")),
@@ -995,6 +1020,11 @@ pub fn capabilities() -> serde_json::Value {
995
1020
  "layout": keymap::LayoutNames::from_environment().identity(),
996
1021
  "unavailable_reason": unavailable,
997
1022
  "grant": input_grant,
1023
+ "observe": {
1024
+ "mechanism": "cgeventtap",
1025
+ "grant": if observe.is_ok() { "granted" } else { "missing-input-monitoring" },
1026
+ "unavailable_reason": observe.as_ref().err().map(|e| serde_json::json!({"reason": e.reason, "remedy": e.remedy})),
1027
+ },
998
1028
  },
999
1029
  "clipboard": { "read": true, "write": true, "mime": ["text/plain;charset=utf-8"],
1000
1030
  "maxBytes": clipboard::MAX_CLIPBOARD_BYTES },
@@ -1274,8 +1304,11 @@ impl Session {
1274
1304
  share_watch.is_some() && source.source_type.as_deref() == Some("Window");
1275
1305
  #[cfg(target_os = "linux")]
1276
1306
  if request.agent_indicator {
1307
+ // No press has landed yet, so no output is chosen: the
1308
+ // compositor places this first helper, and the first
1309
+ // located press moves it onto the landed output.
1277
1310
  indicator =
1278
- crate::indicator::Indicator::start_wayland(source_w, source_h, desktop)
1311
+ crate::indicator::Indicator::start_wayland(source_w, source_h, desktop, None)
1279
1312
  .ok();
1280
1313
  }
1281
1314
  #[cfg(target_os = "macos")]
@@ -1463,6 +1496,29 @@ impl Session {
1463
1496
  "origin": { "x": source.origin_x, "y": source.origin_y },
1464
1497
  });
1465
1498
 
1499
+ // Observation is an addition to the stream, never a condition of it: a
1500
+ // desktop that cannot be observed still streams, and the reply says why.
1501
+ let observed = request.observe_input.then(|| {
1502
+ let sink: crate::observe::Sink = {
1503
+ let events = events.clone();
1504
+ let id = id.clone();
1505
+ Arc::new(move |observation| {
1506
+ let event = match observation {
1507
+ crate::observe::Observation::Input { timestamp_us, input } => {
1508
+ SessionEvent::Observed { timestamp_us, input }
1509
+ }
1510
+ crate::observe::Observation::Cursor(position) => SessionEvent::Cursor { position },
1511
+ };
1512
+ let _ = events.send(Notice { session_id: id.clone(), event });
1513
+ })
1514
+ };
1515
+ crate::observe::start(source.node_id, (source_w, source_h), sink)
1516
+ });
1517
+ if let Some(Err(error)) = &observed {
1518
+ log::warn!("input observation unavailable: {} ({})", error.reason, error.code);
1519
+ }
1520
+ let observation = crate::observe::status(observed.as_ref());
1521
+
1466
1522
  let pipeline = Arc::new(AtomicBool::new(true));
1467
1523
  let inner = Arc::new(Inner {
1468
1524
  id,
@@ -1493,6 +1549,10 @@ impl Session {
1493
1549
  },
1494
1550
  #[cfg(target_os = "linux")]
1495
1551
  window: Mutex::new(window),
1552
+ #[cfg(target_os = "linux")]
1553
+ cue_output: Mutex::new(None),
1554
+ #[cfg(target_os = "linux")]
1555
+ point_output: Mutex::new(None),
1496
1556
  capture: Mutex::new(Some(capture)),
1497
1557
  layout: Mutex::new(Some(layout)),
1498
1558
  #[cfg(target_os = "linux")]
@@ -1505,6 +1565,8 @@ impl Session {
1505
1565
  encoded: Mutex::new(None),
1506
1566
  local_frames: request.local_frames,
1507
1567
  cursor_info,
1568
+ observation,
1569
+ observer: Mutex::new(observed.and_then(Result::ok)),
1508
1570
  });
1509
1571
 
1510
1572
  inner.notify(SessionEvent::Description {
@@ -1593,6 +1655,10 @@ impl Session {
1593
1655
  point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
1594
1656
  #[cfg(target_os = "linux")]
1595
1657
  window: Mutex::new(None),
1658
+ #[cfg(target_os = "linux")]
1659
+ cue_output: Mutex::new(None),
1660
+ #[cfg(target_os = "linux")]
1661
+ point_output: Mutex::new(None),
1596
1662
  capture: Mutex::new(None),
1597
1663
  layout: Mutex::new(None),
1598
1664
  #[cfg(target_os = "linux")]
@@ -1616,6 +1682,13 @@ impl Session {
1616
1682
  })),
1617
1683
  local_frames: false,
1618
1684
  cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
1685
+ observation: serde_json::json!({
1686
+ "observing": false,
1687
+ "code": "unsupported-source",
1688
+ "reason": "consumer-encoded video has no desktop of its own to observe",
1689
+ "remedy": "observe the device where its pixels come from",
1690
+ }),
1691
+ observer: Mutex::new(None),
1619
1692
  });
1620
1693
  inner.notify(SessionEvent::State {
1621
1694
  capture: "consented",
@@ -1681,6 +1754,10 @@ impl Session {
1681
1754
  &self.inner.cursor_info
1682
1755
  }
1683
1756
 
1757
+ pub fn observation_info(&self) -> &serde_json::Value {
1758
+ &self.inner.observation
1759
+ }
1760
+
1684
1761
  pub fn geometry(&self) -> &serde_json::Value {
1685
1762
  &self.inner.geometry
1686
1763
  }
@@ -1739,6 +1816,10 @@ impl Session {
1739
1816
  if let Some(helper) = overlay.take() {
1740
1817
  helper.stop();
1741
1818
  }
1819
+ #[cfg(target_os = "linux")]
1820
+ {
1821
+ *lock(&self.inner.point_output) = None;
1822
+ }
1742
1823
  return Ok(());
1743
1824
  }
1744
1825
  let (x, y) = params
@@ -1763,21 +1844,35 @@ impl Session {
1763
1844
  ));
1764
1845
  }
1765
1846
  // A located window's point shows where a press there would land, on
1766
- // the output holding it; the next may land on another output, so each
1767
- // gets its own helper.
1847
+ // the output holding it; the helper moves only when that output
1848
+ // changes, so repeats on one output reuse it with no new process.
1768
1849
  #[cfg(target_os = "linux")]
1769
1850
  let landed = match lock(&self.inner.window).as_ref() {
1770
1851
  Some(window) => {
1771
1852
  let (px, py) = window
1772
1853
  .locate(x, y)
1773
1854
  .map_err(|message| SessionError::new("indicator-unavailable", message))?;
1774
- if let Some(helper) = overlay.take() {
1775
- helper.stop();
1776
- }
1777
1855
  Some((px.floor() as i64, py.floor() as i64))
1778
1856
  }
1779
1857
  None => None,
1780
1858
  };
1859
+ #[cfg(target_os = "linux")]
1860
+ let landed_output: Option<String> = match landed {
1861
+ Some((px, py)) => {
1862
+ crate::hyprland::monitor_containing(px as f64, py as f64).unwrap_or(None)
1863
+ }
1864
+ None => None,
1865
+ };
1866
+ #[cfg(target_os = "linux")]
1867
+ if match (&landed, &landed_output) {
1868
+ (Some(_), output) => lock(&self.inner.point_output).clone() != *output,
1869
+ (None, _) => overlay.is_none(),
1870
+ } {
1871
+ if let Some(helper) = overlay.take() {
1872
+ helper.stop();
1873
+ }
1874
+ *lock(&self.inner.point_output) = landed_output.clone();
1875
+ }
1781
1876
  #[cfg(not(target_os = "windows"))]
1782
1877
  if overlay.is_none() {
1783
1878
  #[cfg(target_os = "linux")]
@@ -2133,12 +2228,19 @@ impl Inner {
2133
2228
  // handlers, and `shutdown` exits right after this. The encode thread
2134
2229
  // finds it gone and stops.
2135
2230
  drop(lock(&self.encoder).take());
2231
+ // Stop listening to the person's keys the moment the session ends.
2232
+ drop(lock(&self.observer).take());
2136
2233
  if let Ok(mut indicator) = self.indicator.lock() {
2137
2234
  indicator.take();
2138
2235
  }
2139
2236
  if let Some(helper) = lock(&self.point_overlay).take() {
2140
2237
  helper.stop();
2141
2238
  }
2239
+ #[cfg(target_os = "linux")]
2240
+ {
2241
+ *lock(&self.cue_output) = None;
2242
+ *lock(&self.point_output) = None;
2243
+ }
2142
2244
  // Release first, then tear down: a stuck modifier is the one failure the
2143
2245
  // user cannot undo by reconnecting.
2144
2246
  if let Ok(mut input) = self.input.lock() {
@@ -2318,6 +2420,10 @@ impl Inner {
2318
2420
  self.reject(seq, "input-unavailable", "no input backend");
2319
2421
  return;
2320
2422
  }
2423
+ // Only a located window-share press can move the cue helper: other
2424
+ // paths have no desktop point to choose an output from.
2425
+ #[cfg(target_os = "linux")]
2426
+ let located_press = located && matches!(&message, ControlMessage::Pointer { .. });
2321
2427
  let outcome = match message {
2322
2428
  ControlMessage::Pointer {
2323
2429
  phase,
@@ -2361,6 +2467,14 @@ impl Inner {
2361
2467
  };
2362
2468
  match outcome {
2363
2469
  Ok(()) => {
2470
+ #[cfg(target_os = "linux")]
2471
+ if located_press {
2472
+ // Onto the landed output before the cue is told where to
2473
+ // draw, so a cross-output tap draws there, not nowhere.
2474
+ if let Some((_, x, y)) = feedback {
2475
+ self.move_cue_to(x, y);
2476
+ }
2477
+ }
2364
2478
  if let Some((kind, x, y)) = feedback {
2365
2479
  if let Ok(mut indicator) = self.indicator.lock() {
2366
2480
  if let Some(indicator) = indicator.as_mut() {
@@ -2426,6 +2540,49 @@ impl Inner {
2426
2540
  }
2427
2541
  }
2428
2542
 
2543
+ /// Move the agent cue helper onto the output holding desktop point
2544
+ /// `(x, y)`, where a located press just landed. A press on the helper's
2545
+ /// own output reuses it with no new process; anything else respawns it
2546
+ /// with the landed origin, which selects that output through the point
2547
+ /// helper's xdg-output selection. Best-effort: when the output or the
2548
+ /// respawn is unavailable, the standing helper keeps the cue.
2549
+ #[cfg(target_os = "linux")]
2550
+ fn move_cue_to(&self, x: i64, y: i64) {
2551
+ let output = match crate::hyprland::monitor_containing(x as f64, y as f64) {
2552
+ Ok(output) => output,
2553
+ Err(error) => {
2554
+ log::warn!("agent cue stays on its output: {error}");
2555
+ return;
2556
+ }
2557
+ };
2558
+ if lock(&self.cue_output).clone() == output {
2559
+ return;
2560
+ }
2561
+ let Some(name) = output else { return };
2562
+ let mut indicator = lock(&self.indicator);
2563
+ if let Some(helper) = indicator.take() {
2564
+ helper.stop();
2565
+ }
2566
+ let (width, height) = (
2567
+ self.source.width.max(1) as usize,
2568
+ self.source.height.max(1) as usize,
2569
+ );
2570
+ match crate::indicator::Indicator::start_wayland(
2571
+ width,
2572
+ height,
2573
+ true,
2574
+ Some((x as i32, y as i32)),
2575
+ ) {
2576
+ Ok(helper) => {
2577
+ *indicator = Some(helper);
2578
+ *lock(&self.cue_output) = Some(name);
2579
+ }
2580
+ Err(error) => {
2581
+ log::warn!("agent cue stays on its output: the {name} helper did not start: {error:#}");
2582
+ }
2583
+ }
2584
+ }
2585
+
2429
2586
  fn pointer(
2430
2587
  &self,
2431
2588
  phase: PointerPhase,
@@ -2804,6 +2961,12 @@ fn auto_bitrate(width: usize, height: usize) -> u32 {
2804
2961
  const QUEUE_HIGH: Duration = Duration::from_millis(25);
2805
2962
  /// Below this the path has room again and the target may creep back up.
2806
2963
  const QUEUE_LOW: Duration = Duration::from_millis(10);
2964
+ /// The same thresholds for a TCP path. TCP never drops, so its queue is read
2965
+ /// as delay alone, and a relay's bridge adds 20-30 ms of delivery jitter that is
2966
+ /// not queueing: at 25/10 ms that jitter backs the target off to its floor
2967
+ /// with the link idle. Only a queue that stands over 100 ms is a backlog here.
2968
+ const QUEUE_HIGH_TCP: Duration = Duration::from_millis(100);
2969
+ const QUEUE_LOW_TCP: Duration = Duration::from_millis(60);
2807
2970
  /// How often queueing delay may back the target off: long enough for the last
2808
2971
  /// back-off to reach the far end and show in its feedback, short enough that a
2809
2972
  /// queue is drained in a few steps rather than a few seconds.
@@ -2825,6 +2988,8 @@ struct RateControl {
2825
2988
  last_change: Option<Instant>,
2826
2989
  /// The last queueing delay reported, while the path reports one.
2827
2990
  queue: Option<Duration>,
2991
+ queue_high: Duration,
2992
+ queue_low: Duration,
2828
2993
  }
2829
2994
 
2830
2995
  impl RateControl {
@@ -2843,9 +3008,21 @@ impl RateControl {
2843
3008
  target: ceiling,
2844
3009
  last_change: None,
2845
3010
  queue: None,
3011
+ queue_high: QUEUE_HIGH,
3012
+ queue_low: QUEUE_LOW,
2846
3013
  }
2847
3014
  }
2848
3015
 
3016
+ /// Read queueing delay against the thresholds for the transport the selected
3017
+ /// pair runs over.
3018
+ fn set_tcp(&mut self, tcp: bool) {
3019
+ (self.queue_high, self.queue_low) = if tcp {
3020
+ (QUEUE_HIGH_TCP, QUEUE_LOW_TCP)
3021
+ } else {
3022
+ (QUEUE_HIGH, QUEUE_LOW)
3023
+ };
3024
+ }
3025
+
2849
3026
  /// The new target when these loss reports (RFC 3550 fractions, /256) or
2850
3027
  /// this path report move it: down on a standing queue at most every
2851
3028
  /// `DELAY_BACKOFF_EVERY`, otherwise at most once a second.
@@ -2856,7 +3033,7 @@ impl RateControl {
2856
3033
  let since = self.last_change.map(|at| now.duration_since(at));
2857
3034
  let waited = |gap: Duration| since.is_none_or(|since| since >= gap);
2858
3035
  let floor = (self.ceiling / 8).max(500);
2859
- let next = if let Some(path) = path.filter(|path| path.queue_delay > QUEUE_HIGH) {
3036
+ let next = if let Some(path) = path.filter(|path| path.queue_delay > self.queue_high) {
2860
3037
  if !waited(DELAY_BACKOFF_EVERY) {
2861
3038
  return None;
2862
3039
  }
@@ -2868,7 +3045,7 @@ impl RateControl {
2868
3045
  return None;
2869
3046
  }
2870
3047
  let loss = worst as f64 / 256.0;
2871
- let roomy = self.queue.is_none_or(|queue| queue < QUEUE_LOW);
3048
+ let roomy = self.queue.is_none_or(|queue| queue < self.queue_low);
2872
3049
  if loss > 0.10 {
2873
3050
  ((self.target as f64 * 0.7) as u32).max(floor)
2874
3051
  } else if loss < 0.02 && roomy {
@@ -3117,6 +3294,9 @@ fn spawn_pipeline(
3117
3294
  }
3118
3295
  let reports = peer.take_loss_reports();
3119
3296
  let path = peer.take_path_report();
3297
+ if path.is_some() {
3298
+ rate.set_tcp(handle.block_on(peer.selected_pair_is_tcp()));
3299
+ }
3120
3300
  if let Some(kbps) = rate.update(&reports, path, Instant::now()) {
3121
3301
  let applied = inner.encoder.lock().map(|mut encoder| {
3122
3302
  encoder
@@ -3678,6 +3858,10 @@ mod tests {
3678
3858
  point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
3679
3859
  #[cfg(target_os = "linux")]
3680
3860
  window: Mutex::new(None),
3861
+ #[cfg(target_os = "linux")]
3862
+ cue_output: Mutex::new(None),
3863
+ #[cfg(target_os = "linux")]
3864
+ point_output: Mutex::new(None),
3681
3865
  capture: Mutex::new(None),
3682
3866
  layout: Mutex::new(None),
3683
3867
  #[cfg(target_os = "linux")]
@@ -3699,6 +3883,8 @@ mod tests {
3699
3883
  })),
3700
3884
  local_frames: false,
3701
3885
  cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
3886
+ observation: serde_json::json!({"observing": false}),
3887
+ observer: Mutex::new(None),
3702
3888
  });
3703
3889
  spawn_peer_events(&inner, peer_events_rx);
3704
3890
  spawn_encoded(&inner, feed_rx);
@@ -3757,7 +3943,7 @@ mod tests {
3757
3943
  )
3758
3944
  .await
3759
3945
  .expect("a peer connection builds without a network");
3760
- test_inner_with(events, peer, 64, 64)
3946
+ test_inner_with(events, Arc::new(peer), 64, 64)
3761
3947
  }
3762
3948
 
3763
3949
  #[cfg(target_os = "linux")]
@@ -3892,7 +4078,7 @@ mod tests {
3892
4078
 
3893
4079
  fn test_inner_with(
3894
4080
  events: tokio_mpsc::UnboundedSender<Notice>,
3895
- peer: VideoPeer,
4081
+ peer: Arc<VideoPeer>,
3896
4082
  width: usize,
3897
4083
  height: usize,
3898
4084
  ) -> (Arc<Inner>, Arc<Mutex<Vec<(i16, bool)>>>) {
@@ -3920,7 +4106,7 @@ mod tests {
3920
4106
  latest: Arc::new(Mutex::new(None)),
3921
4107
  capture_error: Arc::new(Mutex::new(None)),
3922
4108
  frame_changes: tokio::sync::watch::channel(0).1,
3923
- peer: Mutex::new(Some(Arc::new(peer))),
4109
+ peer: Mutex::new(Some(peer)),
3924
4110
  encoder: Mutex::new(Some(VideoEncoder::Vp9(
3925
4111
  Encoder::new(width, height, 1000, 30, 1).expect("an encoder"),
3926
4112
  ))),
@@ -3939,6 +4125,10 @@ mod tests {
3939
4125
  point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
3940
4126
  #[cfg(target_os = "linux")]
3941
4127
  window: Mutex::new(None),
4128
+ #[cfg(target_os = "linux")]
4129
+ cue_output: Mutex::new(None),
4130
+ #[cfg(target_os = "linux")]
4131
+ point_output: Mutex::new(None),
3942
4132
  layout: Mutex::new(Some(Layout::from_environment().expect("a keymap"))),
3943
4133
  #[cfg(target_os = "linux")]
3944
4134
  audio: Mutex::new(None),
@@ -3950,6 +4140,8 @@ mod tests {
3950
4140
  encoded: Mutex::new(None),
3951
4141
  local_frames: true,
3952
4142
  cursor_info: serde_json::json!({"mode":"embedded", "positions":false}),
4143
+ observation: serde_json::json!({"observing": false}),
4144
+ observer: Mutex::new(None),
3953
4145
  });
3954
4146
  (inner, recorded)
3955
4147
  }
@@ -4350,6 +4542,7 @@ mod tests {
4350
4542
  agent_indicator: false,
4351
4543
  local_frames: true,
4352
4544
  audio: false,
4545
+ observe_input: false,
4353
4546
  }
4354
4547
  }
4355
4548
 
@@ -4518,7 +4711,7 @@ mod tests {
4518
4711
  let codec = crate::peer::tests::h264_receiver_codec();
4519
4712
  let (peer, _) = crate::peer::tests::answered(vec![codec.clone()]).await;
4520
4713
  let (events, mut received) = tokio_mpsc::unbounded_channel();
4521
- let (inner, _) = test_inner_with(events, peer, 64, 64);
4714
+ let (inner, _) = test_inner_with(events, Arc::new(peer), 64, 64);
4522
4715
  let session = Session { inner };
4523
4716
  let mut offers = Vec::new();
4524
4717
  for _ in 0..3 {
@@ -4690,6 +4883,72 @@ mod tests {
4690
4883
  assert_eq!((later.codec, later.encoder), ("vp9", "libvpx"));
4691
4884
  }
4692
4885
 
4886
+ /// The rate target after 3 s of delivery jitter on a connected pair: a queue
4887
+ /// that alternates between 5 and 30 ms with nothing lost, and 800 kbps
4888
+ /// delivered. The pipeline runs for real; only the path reports are fed.
4889
+ async fn target_under_bridge_jitter(tcp: bool) -> u32 {
4890
+ let peers = crate::peer::tests::connected(
4891
+ if tcp {
4892
+ Vec::new()
4893
+ } else {
4894
+ vec![std::net::SocketAddr::from(([0, 0, 0, 0], 0))]
4895
+ },
4896
+ if tcp {
4897
+ vec![std::net::SocketAddr::from(([127, 0, 0, 1], 0))]
4898
+ } else {
4899
+ Vec::new()
4900
+ },
4901
+ tcp,
4902
+ )
4903
+ .await;
4904
+ let engine = Arc::clone(&peers.engine);
4905
+ assert_eq!(
4906
+ engine.selected_pair_is_tcp().await,
4907
+ tcp,
4908
+ "the selected pair must run over {}",
4909
+ if tcp { "TCP" } else { "UDP" }
4910
+ );
4911
+ let (events, _events_rx) = tokio_mpsc::unbounded_channel();
4912
+ let (inner, _recorded) = test_inner_with(events, Arc::clone(&engine), 64, 64);
4913
+ let (_frame_tx, frame_rx) = latest_frame();
4914
+ spawn_pipeline(
4915
+ &inner,
4916
+ Arc::clone(&engine),
4917
+ frame_rx,
4918
+ Arc::new(AtomicBool::new(true)),
4919
+ 30,
4920
+ 1000,
4921
+ );
4922
+ for step in 0..300 {
4923
+ let queue_delay = Duration::from_millis(if step % 2 == 0 { 5 } else { 30 });
4924
+ engine.report_path(PathReport {
4925
+ queue_delay,
4926
+ delivered_kbps: 800,
4927
+ });
4928
+ tokio::time::sleep(Duration::from_millis(10)).await;
4929
+ }
4930
+ let target = inner.metrics.lock().unwrap().target_kbps;
4931
+ drop(peers);
4932
+ target
4933
+ }
4934
+
4935
+ #[tokio::test(flavor = "multi_thread")]
4936
+ async fn a_tcp_path_with_bridge_jitter_holds_its_rate_ceiling() {
4937
+ assert_eq!(
4938
+ target_under_bridge_jitter(true).await,
4939
+ 1000,
4940
+ "a 5-30 ms jitter that is not a queue must not back a TCP path off"
4941
+ );
4942
+ }
4943
+
4944
+ #[tokio::test(flavor = "multi_thread")]
4945
+ async fn a_udp_path_with_the_same_jitter_still_backs_off() {
4946
+ assert!(
4947
+ target_under_bridge_jitter(false).await < 1000,
4948
+ "a UDP path reads the same 30 ms as a standing queue and must back off"
4949
+ );
4950
+ }
4951
+
4693
4952
  #[tokio::test]
4694
4953
  async fn a_receiver_that_asks_for_h264_is_sent_h264_from_the_first_frame() {
4695
4954
  let Some(info) = tokio::task::spawn_blocking(crate::h264::probe)
@@ -4702,7 +4961,7 @@ mod tests {
4702
4961
  let (peer, _answer) =
4703
4962
  crate::peer::tests::answered(vec![crate::peer::tests::h264_receiver_codec()]).await;
4704
4963
  let (events, _events_rx) = tokio_mpsc::unbounded_channel();
4705
- let (inner, _recorded) = test_inner_with(events, peer, 320, 240);
4964
+ let (inner, _recorded) = test_inner_with(events, Arc::new(peer), 320, 240);
4706
4965
  // The answer is real, so ICE runs and, with no candidates, never
4707
4966
  // connects; each state it reports would take back `assume_connected`.
4708
4967
  let pinned = current_peer(&inner).expect("the test's peer");
@@ -97,8 +97,8 @@ No public API changed; no signalling or wire change.
97
97
 
98
98
  Do this once upstream rtc-ice keeps the previous selected pair across a restart
99
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,
101
- since it would carry his name).
100
+ check tick (an upstream PR is a follow-up; that needs a maintainer decision first,
101
+ since the contribution would carry its author's name).
102
102
 
103
103
  ## Dev-only measurement instrumentation (not part of the patch)
104
104
 
@@ -10,7 +10,7 @@ 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):
13
+ // desklink scout trace: dev-only ICE timeline instrumentation.
14
14
  use crate::dl_trace;
15
15
  use crate::dl_trace::dl_ice;
16
16
 
@@ -38,7 +38,7 @@ use stun::xoraddr::*;
38
38
 
39
39
  use crate::candidate::candidate_peer_reflexive::CandidatePeerReflexiveConfig;
40
40
  use crate::candidate::{candidate_pair::*, *};
41
- // desklink scout trace (dl-roam-postnom-attr):
41
+ // desklink scout trace: dev-only ICE timeline instrumentation.
42
42
  use crate::dl_trace;
43
43
  use crate::dl_trace::dl_ice;
44
44
  use crate::mdns::{MulticastDnsMode, create_multicast_dns, generate_multicast_dns_name};
@@ -1,7 +1,7 @@
1
1
  //! desklink scout instrumentation (dev-only, `DESKLINK_ICE_TRACE=1`).
2
2
  //!
3
3
  //! Timestamped stderr trace of the ICE agent's restart/check/nomination timeline
4
- //! for task dl-roam-postnom-attr. Not part of the shipped patch; see
4
+ //! for ICE restart diagnosis. Not part of the shipped patch; see
5
5
  //! DESKLINK-PATCH.md. Every call site is marked `// desklink scout trace:` so the
6
6
  //! whole diff is trivially droppable.
7
7
 
@@ -58,7 +58,7 @@
58
58
  /// without adding — and version-matching — a direct `rtc-crypto` dependency.
59
59
  pub use crypto;
60
60
 
61
- // desklink scout trace: dev-only ICE timeline instrumentation (dl-roam-postnom-attr).
61
+ // desklink scout trace: dev-only ICE timeline instrumentation.
62
62
  pub(crate) mod dl_trace;
63
63
 
64
64
  pub mod agent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/host",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "ws": "8.21.3"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@desklink/host-linux-x64-gnu": "0.5.8",
42
- "@desklink/host-darwin-arm64": "0.5.8"
41
+ "@desklink/host-linux-x64-gnu": "0.5.10",
42
+ "@desklink/host-darwin-arm64": "0.5.10"
43
43
  }
44
44
  }