@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.
@@ -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": false,
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.inner
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
@@ -15,6 +15,7 @@ pub fn capabilities() -> Value {
15
15
  "formats": [],
16
16
  "cursor": "unavailable",
17
17
  "cursor_modes": [],
18
+ // No Windows capture path exists to carry audio from; out of scope.
18
19
  "audio": false,
19
20
  "displays": crate::portal::displays(),
20
21
  "grant": "unavailable",
@@ -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 (an upstream PR is a follow-up; it needs the captain's word first,
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,