@desklink/host 0.5.4 → 0.5.7

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.
@@ -2930,6 +2930,212 @@ fn test_handle_inbound_request_defers_failing_connectivity_check() -> Result<()>
2930
2930
  Ok(())
2931
2931
  }
2932
2932
 
2933
+ // Regression test for the desklink patch that nominates off the check pass
2934
+ // (shape 2 in DESKLINK-PATCH.md) instead of the `check_interval` tick.
2935
+ //
2936
+ // Nomination is decided in `contact_candidates`, which the driver reaches on a
2937
+ // `check_interval` tick or when an inbound Binding request arrives while a valid pair
2938
+ // already exists. A peer that runs no checks of its own leaves the second path empty, so
2939
+ // a pair that becomes valid just after a tick waited a whole tick for its
2940
+ // USE-CANDIDATE: measured at 197 ms in the routed roam leg, the entire engine-side cost
2941
+ // between the pair becoming valid and the nomination leaving. The pass that nominates has
2942
+ // to be the one the validating response asked for.
2943
+ //
2944
+ // The agent's clock is driven by hand here and never reaches a tick, so the only thing
2945
+ // that can produce the nomination is the response itself.
2946
+ #[test]
2947
+ fn test_nominates_on_the_response_that_validates_the_pair() -> Result<()> {
2948
+ use sansio::Protocol as _;
2949
+
2950
+ let base = Instant::now();
2951
+ let mut a = Agent::new(
2952
+ base,
2953
+ Arc::new(AgentConfig {
2954
+ is_controlling: true,
2955
+ ..Default::default()
2956
+ }),
2957
+ test_crypto_provider(),
2958
+ )?;
2959
+
2960
+ let local = CandidateHostConfig {
2961
+ base_config: CandidateConfig {
2962
+ network: "udp".to_owned(),
2963
+ address: "192.168.0.2".to_owned(),
2964
+ port: 777,
2965
+ component: 1,
2966
+ ..Default::default()
2967
+ },
2968
+ ..Default::default()
2969
+ }
2970
+ .new_candidate_host()?;
2971
+ a.add_local_candidate(local)?;
2972
+
2973
+ let remote = CandidateHostConfig {
2974
+ base_config: CandidateConfig {
2975
+ network: "udp".to_owned(),
2976
+ address: "172.17.0.3".to_owned(),
2977
+ port: 999,
2978
+ component: 1,
2979
+ ..Default::default()
2980
+ },
2981
+ ..Default::default()
2982
+ }
2983
+ .new_candidate_host()?;
2984
+ let remote_addr = remote.addr();
2985
+ a.add_remote_candidate(remote)?;
2986
+ assert_eq!(a.candidate_pairs.len(), 1, "one pair to check");
2987
+
2988
+ a.start_connectivity_checks(base, true, "remoteUfrag".to_owned(), "remotePwd".to_owned())?;
2989
+
2990
+ // The first pass pings the pair. Nothing has validated it yet, so nothing is
2991
+ // nominated and no USE-CANDIDATE leaves.
2992
+ a.handle_timeout(base)?;
2993
+ let mut check = Message {
2994
+ raw: a
2995
+ .poll_write()
2996
+ .expect("the agent checks the pair")
2997
+ .message
2998
+ .to_vec(),
2999
+ ..Default::default()
3000
+ };
3001
+ check.decode()?;
3002
+ assert_eq!(check.typ, MessageType::new(METHOD_BINDING, CLASS_REQUEST));
3003
+ assert!(
3004
+ !check.contains(ATTR_USE_CANDIDATE),
3005
+ "a check that has not been answered must not nominate"
3006
+ );
3007
+ assert!(a.nominated_pair.is_none());
3008
+
3009
+ // The peer answers that check. Nothing else arrives: no inbound request (the test
3010
+ // delivers no such message) and no tick (the clock moves by a millisecond).
3011
+ let now = base + Duration::from_millis(1);
3012
+ let mut response = Message::new();
3013
+ response.build(&[
3014
+ Box::new(check.clone()),
3015
+ Box::new(BINDING_SUCCESS),
3016
+ Box::new(XorMappedAddress {
3017
+ ip: remote_addr.ip(),
3018
+ port: remote_addr.port(),
3019
+ }),
3020
+ Box::new(MessageIntegrity::new_short_term_integrity_with_provider(
3021
+ "remotePwd".to_owned(),
3022
+ test_crypto_provider().crypto(),
3023
+ )),
3024
+ Box::new(FINGERPRINT),
3025
+ ])?;
3026
+ a.handle_inbound(now, &mut response, 0, remote_addr)?;
3027
+ assert_eq!(
3028
+ a.candidate_pairs[0].state,
3029
+ CandidatePairState::Succeeded,
3030
+ "the response validates the pair"
3031
+ );
3032
+
3033
+ // The pair is valid, so the pass that nominates it is due now. Before the patch the
3034
+ // deadline here was the next tick, a whole `check_interval` away.
3035
+ let deadline = a
3036
+ .poll_timeout()
3037
+ .expect("a validated pair asks for a check pass");
3038
+ assert!(
3039
+ deadline <= now,
3040
+ "the validating response must ask for a pass now, not at the next tick: deadline {deadline:?}, now {now:?}"
3041
+ );
3042
+
3043
+ a.handle_timeout(now)?;
3044
+ assert!(
3045
+ a.nominated_pair.is_some(),
3046
+ "the valid pair must be nominated on the pass the response asked for"
3047
+ );
3048
+ let mut nomination = Message {
3049
+ raw: a
3050
+ .poll_write()
3051
+ .expect("the nomination leaves")
3052
+ .message
3053
+ .to_vec(),
3054
+ ..Default::default()
3055
+ };
3056
+ nomination.decode()?;
3057
+ assert!(
3058
+ nomination.contains(ATTR_USE_CANDIDATE),
3059
+ "the nomination is a Binding request carrying USE-CANDIDATE"
3060
+ );
3061
+
3062
+ a.close()?;
3063
+ Ok(())
3064
+ }
3065
+
3066
+ // A peer that never answers a check must still retire the pair on the configured budget,
3067
+ // and the shape-2 patch must not change that budget: it only ever asks for a pass when a
3068
+ // response made a pair valid, so a silent peer never earns one. The agent's clock is driven
3069
+ // by hand, so this pins the pass count exactly instead of waiting for it.
3070
+ #[test]
3071
+ fn test_a_silent_peer_retires_the_pair_on_the_check_budget() -> Result<()> {
3072
+ use sansio::Protocol as _;
3073
+
3074
+ let base = Instant::now();
3075
+ let mut a = Agent::new(
3076
+ base,
3077
+ Arc::new(AgentConfig {
3078
+ is_controlling: true,
3079
+ ..Default::default()
3080
+ }),
3081
+ test_crypto_provider(),
3082
+ )?;
3083
+
3084
+ let local = CandidateHostConfig {
3085
+ base_config: CandidateConfig {
3086
+ network: "udp".to_owned(),
3087
+ address: "192.168.0.2".to_owned(),
3088
+ port: 777,
3089
+ component: 1,
3090
+ ..Default::default()
3091
+ },
3092
+ ..Default::default()
3093
+ }
3094
+ .new_candidate_host()?;
3095
+ a.add_local_candidate(local)?;
3096
+
3097
+ let remote = CandidateHostConfig {
3098
+ base_config: CandidateConfig {
3099
+ network: "udp".to_owned(),
3100
+ address: "172.17.0.3".to_owned(),
3101
+ port: 999,
3102
+ component: 1,
3103
+ ..Default::default()
3104
+ },
3105
+ ..Default::default()
3106
+ }
3107
+ .new_candidate_host()?;
3108
+ a.add_remote_candidate(remote)?;
3109
+ a.start_connectivity_checks(base, true, "remoteUfrag".to_owned(), "remotePwd".to_owned())?;
3110
+
3111
+ // `ping_all_candidates` retires a pair once `binding_request_count` passes
3112
+ // `max_binding_requests`, so the first pass that retires it is one after the last
3113
+ // request it sends: `max_binding_requests + 2` passes from checking.
3114
+ let last_sending_pass = usize::from(DEFAULT_MAX_BINDING_REQUESTS) + 1;
3115
+ let retiring_pass = last_sending_pass + 1;
3116
+ let mut now = base;
3117
+ for pass in 1..=retiring_pass {
3118
+ now += Duration::from_millis(200);
3119
+ a.handle_timeout(now)?;
3120
+ while a.poll_write().is_some() {}
3121
+ let deadline = a.poll_timeout().expect("the agent stays on its timers");
3122
+ assert!(
3123
+ deadline > now,
3124
+ "pass {pass}: an unanswered peer must not ask for an extra pass (deadline {deadline:?}, now {now:?})"
3125
+ );
3126
+ let failed = a.candidate_pairs[0].state == CandidatePairState::Failed;
3127
+ assert_eq!(
3128
+ failed,
3129
+ pass >= retiring_pass,
3130
+ "pass {pass}: pair must retire on the budget, not before or after"
3131
+ );
3132
+ }
3133
+ assert_eq!(a.connection_state, ConnectionState::Checking);
3134
+
3135
+ a.close()?;
3136
+ Ok(())
3137
+ }
3138
+
2933
3139
  // Safari (and Chrome) expose ICE host candidates behind an mDNS "<uuid>.local"
2934
3140
  // hostname by default. A QueryOnly agent (the default) must RESOLVE such a
2935
3141
  // candidate -- i.e. issue an mDNS query, which surfaces as an outbound packet to
@@ -38,6 +38,9 @@ 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):
42
+ use crate::dl_trace;
43
+ use crate::dl_trace::dl_ice;
41
44
  use crate::mdns::{MulticastDnsMode, create_multicast_dns, generate_multicast_dns_name};
42
45
  use crate::network_type::NetworkType;
43
46
  use crate::rand::*;
@@ -520,6 +523,17 @@ impl Agent {
520
523
  }
521
524
  }
522
525
 
526
+ // desklink scout trace: a remote candidate landed and an
527
+ // immediate check pass was requested for it.
528
+ if dl_trace::enabled() {
529
+ dl_ice!(
530
+ "{} remote_candidate_added {} pairs={} force_check",
531
+ self.get_name(),
532
+ self.remote_candidates[remote_index].addr(),
533
+ self.candidate_pairs.len(),
534
+ );
535
+ }
536
+
523
537
  self.request_connectivity_check();
524
538
  }
525
539
  }
@@ -724,6 +738,18 @@ impl Agent {
724
738
  /// pair, and restarts the agent's timers. With nothing staged, the current credentials are
725
739
  /// kept and only the session is restarted.
726
740
  pub fn apply_restart(&mut self, now: Instant, keep_local_candidates: bool) -> Result<()> {
741
+ // desklink scout trace: restart applied — the new generation begins.
742
+ if dl_trace::enabled() {
743
+ dl_ice!(
744
+ "{} APPLY_RESTART keep_local={keep_local_candidates} role={} state={:?} local_cands={} remote_cands={} pairs={}",
745
+ self.get_name(),
746
+ if self.is_controlling { "controlling" } else { "controlled" },
747
+ self.connection_state,
748
+ self.local_candidates.len(),
749
+ self.remote_candidates.len(),
750
+ self.candidate_pairs.len(),
751
+ );
752
+ }
727
753
  // desklink patch: remember the pair media is on before the teardown
728
754
  // below clears it, so the ICE transport keeps sending and receiving on
729
755
  // it until the restarted generation nominates a pair (see
@@ -784,6 +810,20 @@ impl Agent {
784
810
  }
785
811
 
786
812
  fn contact(&mut self, now: Instant) {
813
+ // desklink scout trace: one line per agent contact() pass, so the tick
814
+ // cadence (and any forced pass) is measurable.
815
+ if dl_trace::enabled() {
816
+ dl_ice!(
817
+ "{} tick_begin state={:?} sel={:?} nom={:?} pairs={} outstanding={} force={}",
818
+ self.get_name(),
819
+ self.connection_state,
820
+ self.selected_pair,
821
+ self.nominated_pair,
822
+ self.candidate_pairs.len(),
823
+ self.pending_binding_requests.len(),
824
+ self.force_candidate_contact,
825
+ );
826
+ }
787
827
  // Consume any pending deferred-check request now that we are running one.
788
828
  // Reset before the early returns below so a failed/settled agent does not
789
829
  // keep asking `poll_timeout` for an immediate wake-up.
@@ -826,6 +866,19 @@ impl Agent {
826
866
  new_state: ConnectionState,
827
867
  ) {
828
868
  if self.connection_state != new_state {
869
+ // desklink scout trace: every connection-state transition, with the
870
+ // selection context that explains it.
871
+ if dl_trace::enabled() {
872
+ dl_ice!(
873
+ "{} state {:?} -> {:?} sel={:?} nom={:?} pairs={}",
874
+ self.get_name(),
875
+ self.connection_state,
876
+ new_state,
877
+ self.selected_pair,
878
+ self.nominated_pair,
879
+ self.candidate_pairs.len(),
880
+ );
881
+ }
829
882
  // Connection has gone to failed, release all gathered candidates
830
883
  if new_state == ConnectionState::Failed {
831
884
  self.set_selected_pair(now, None);
@@ -861,6 +914,19 @@ impl Agent {
861
914
  self.candidate_pairs[pair_index]
862
915
  );
863
916
 
917
+ // desklink scout trace: nomination landed on a pair — the moment the
918
+ // agent moves to Connected.
919
+ if dl_trace::enabled() {
920
+ let p = &self.candidate_pairs[pair_index];
921
+ dl_ice!(
922
+ "{} SELECTED_PAIR local={} remote={} pairs={}",
923
+ self.get_name(),
924
+ self.local_candidates[p.local_index].addr(),
925
+ self.remote_candidates[p.remote_index].addr(),
926
+ self.candidate_pairs.len(),
927
+ );
928
+ }
929
+
864
930
  self.candidate_pairs[pair_index].nominated = true;
865
931
  self.selected_pair = Some(pair_index);
866
932
  // desklink patch: the restarted generation now has a nominated pair,
@@ -1101,6 +1167,20 @@ impl Agent {
1101
1167
  self.remote_candidates[remote_index],
1102
1168
  );
1103
1169
 
1170
+ // desklink scout trace: every outbound connectivity check / nomination,
1171
+ // with the transaction id, so the relay's wire log can be matched to it.
1172
+ if dl_trace::enabled() {
1173
+ dl_ice!(
1174
+ "{} SEND_REQUEST txn={:?} use_candidate={} local={} remote={} outstanding={}",
1175
+ self.get_name(),
1176
+ m.transaction_id,
1177
+ m.contains(ATTR_USE_CANDIDATE),
1178
+ self.local_candidates[local_index].addr(),
1179
+ self.remote_candidates[remote_index].addr(),
1180
+ self.pending_binding_requests.len(),
1181
+ );
1182
+ }
1183
+
1104
1184
  self.invalidate_pending_binding_requests(now);
1105
1185
 
1106
1186
  self.pending_binding_requests.push(BindingRequest {
@@ -1125,6 +1205,17 @@ impl Agent {
1125
1205
  local_index: usize,
1126
1206
  remote_index: usize,
1127
1207
  ) {
1208
+ // desklink scout trace: the answer to an inbound connectivity check, and
1209
+ // whether the request it answered carried the nomination.
1210
+ if dl_trace::enabled() {
1211
+ dl_ice!(
1212
+ "{} SEND_SUCCESS role={} txn={:?} use_candidate={}",
1213
+ self.get_name(),
1214
+ if self.is_controlling { "controlling" } else { "controlled" },
1215
+ m.transaction_id,
1216
+ m.contains(ATTR_USE_CANDIDATE),
1217
+ );
1218
+ }
1128
1219
  let addr = self.remote_candidates[remote_index].addr();
1129
1220
  let (ip, port) = (addr.ip(), addr.port());
1130
1221
  let local_pwd = self.ufrag_pwd.local_credentials.pwd.clone();
@@ -0,0 +1,49 @@
1
+ //! desklink scout instrumentation (dev-only, `DESKLINK_ICE_TRACE=1`).
2
+ //!
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
5
+ //! DESKLINK-PATCH.md. Every call site is marked `// desklink scout trace:` so the
6
+ //! whole diff is trivially droppable.
7
+
8
+ use std::fmt::Arguments;
9
+ use std::sync::OnceLock;
10
+ use std::time::{SystemTime, UNIX_EPOCH};
11
+
12
+ /// Whether tracing is on; cached, so the hot path is one atomic load.
13
+ pub(crate) fn enabled() -> bool {
14
+ static ON: OnceLock<bool> = OnceLock::new();
15
+ *ON.get_or_init(|| flag("DESKLINK_ICE_TRACE"))
16
+ }
17
+
18
+ /// A dev-only scout switch, read fresh each time (these are experiments).
19
+ pub(crate) fn flag(name: &str) -> bool {
20
+ match std::env::var(name) {
21
+ Ok(v) => v != "0" && !v.is_empty(),
22
+ Err(_) => false,
23
+ }
24
+ }
25
+
26
+ /// Microseconds since the Unix epoch; the vendored crate, the engine and the
27
+ /// browser/relay side all stamp this way (ms on the JS side), so lines from
28
+ /// every observer sort into one timeline.
29
+ pub(crate) fn t_us() -> u128 {
30
+ SystemTime::now()
31
+ .duration_since(UNIX_EPOCH)
32
+ .map(|d| d.as_micros())
33
+ .unwrap_or(0)
34
+ }
35
+
36
+ /// Emit one trace line.
37
+ pub(crate) fn write(line: Arguments<'_>) {
38
+ eprintln!("[ice {}] {}", t_us(), line);
39
+ }
40
+
41
+ macro_rules! dl_ice {
42
+ ($($arg:tt)*) => {{
43
+ if $crate::dl_trace::enabled() {
44
+ $crate::dl_trace::write(format_args!($($arg)*));
45
+ }
46
+ }};
47
+ }
48
+
49
+ pub(crate) use dl_ice;
@@ -58,6 +58,9 @@
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).
62
+ pub(crate) mod dl_trace;
63
+
61
64
  pub mod agent;
62
65
  /// The ICE-specific STUN attributes carried in connectivity checks.
63
66
  pub mod attributes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/host",
3
- "version": "0.5.4",
3
+ "version": "0.5.7",
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.4",
42
- "@desklink/host-darwin-arm64": "0.5.4"
41
+ "@desklink/host-linux-x64-gnu": "0.5.7",
42
+ "@desklink/host-darwin-arm64": "0.5.7"
43
43
  }
44
44
  }