diff --git a/crates/noa-grid/src/osc.rs b/crates/noa-grid/src/osc.rs index 9be44749..fc2d3e48 100644 --- a/crates/noa-grid/src/osc.rs +++ b/crates/noa-grid/src/osc.rs @@ -579,8 +579,8 @@ pub(crate) fn hostname_matches_local(host: &str, local_hostname: &str) -> bool { } fn host_is_local(host: &str) -> bool { - match local_hostname() { - Some(local) => hostname_matches_local(host, &local), + match local_hostnames() { + Some(locals) => hostname_matches_any_local(host, locals.iter().map(String::as_str)), // Fail open (REQ-OSC-2): a false accept only risks showing a stale // proxy icon, while a false reject would break the shipped sidebar // cwd feature on a machine where hostname resolution is unavailable. @@ -588,12 +588,53 @@ fn host_is_local(host: &str) -> bool { } } -/// Cached across the process lifetime: the local hostname cannot change -/// without a reboot, so there's no need to re-syscall on every OSC 7 -/// sequence. -fn local_hostname() -> Option { - static LOCAL_HOSTNAME: std::sync::OnceLock> = std::sync::OnceLock::new(); - LOCAL_HOSTNAME.get_or_init(query_local_hostname).clone() +/// [`hostname_matches_local`] against every name this machine has gone by +/// during the process lifetime (see [`local_hostnames`]). Pure, for tests. +pub(crate) fn hostname_matches_any_local<'a>( + host: &str, + local_hostnames: impl IntoIterator, +) -> bool { + let mut locals = local_hostnames.into_iter().peekable(); + if locals.peek().is_none() { + return hostname_matches_local(host, ""); + } + locals.any(|local| hostname_matches_local(host, local)) +} + +/// Upper bound on remembered local hostnames (see [`local_hostnames`]). +/// Well above the handful of names a laptop cycles through in one session, +/// and small enough that the per-OSC-7 scan is free. +const OBSERVED_HOSTNAMES_CAP: usize = 8; + +/// Every hostname this process has seen `gethostname(2)` return, most +/// recently observed last, bounded by [`OBSERVED_HOSTNAMES_CAP`] (least +/// recently observed evicted). +/// +/// The hostname is *not* fixed for the process lifetime: macOS rewrites it on +/// every network change (DHCP/reverse-DNS name while online, `.local` +/// while offline), and a shell's `$HOST` is frozen at *its* startup. So a +/// single cached value rejects every OSC 7 from shells started after a +/// network change (or, if queried fresh each time, from shells started before +/// one), silently breaking new-tab cwd inheritance. Re-querying on each OSC 7 +/// is a cheap sysctl, and keeping every name observed accepts both shells that +/// predate and shells that postdate a change — fail-toward-accept, as the +/// REQ-OSC-2 gate intends. +fn local_hostnames() -> Option> { + static OBSERVED: std::sync::Mutex> = + std::sync::Mutex::new(std::collections::VecDeque::new()); + let current = query_local_hostname()?; + let mut observed = OBSERVED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // LRU: a re-observed name moves to the back, so a name the machine keeps + // returning to is never the one evicted when the cap is hit. + if let Some(pos) = observed.iter().position(|seen| *seen == current) { + observed.remove(pos); + } else if observed.len() == OBSERVED_HOSTNAMES_CAP { + observed.pop_front(); + } + observed.push_back(current); + Some(observed.iter().cloned().collect()) } fn query_local_hostname() -> Option { diff --git a/crates/noa-grid/src/tests/osc.rs b/crates/noa-grid/src/tests/osc.rs index a356840c..cdfc60ab 100644 --- a/crates/noa-grid/src/tests/osc.rs +++ b/crates/noa-grid/src/tests/osc.rs @@ -20,7 +20,16 @@ fn explicit_agent_status_is_bounded_and_separate_from_notifications() { assert_eq!(clear.take_pending_agent_status(), Some(None)); let source = format!("\x1b]777;noa-agent;input;{}\x1b\\", "あ".repeat(300)); let mut bounded = run(source.as_bytes()); - assert_eq!(bounded.take_pending_agent_status().unwrap().unwrap().detail.chars().count(), 160); + assert_eq!( + bounded + .take_pending_agent_status() + .unwrap() + .unwrap() + .detail + .chars() + .count(), + 160 + ); } #[test] @@ -320,6 +329,28 @@ fn hostname_matches_local_accepts_case_insensitive_full_or_short_label_shapes() )); } +#[test] +fn hostname_matches_any_local_accepts_names_the_machine_went_by_earlier() { + // macOS renames the host on every network change (DHCP reverse-DNS name + // online, `.local` offline) while a shell's `$HOST` is frozen at + // its own startup. A shell started under either name must still be + // treated as local, or new-tab cwd inheritance silently breaks. + use crate::osc::hostname_matches_any_local; + + let seen = [ + "MacBook-Air.local", + "ip-192-168-0-64.ap-northeast-1.compute.internal", + ]; + assert!(hostname_matches_any_local("MacBook-Air.local", seen)); + assert!(hostname_matches_any_local("macbook-air", seen)); + assert!(hostname_matches_any_local("ip-192-168-0-64", seen)); + assert!(hostname_matches_any_local("localhost", seen)); + assert!(!hostname_matches_any_local("build-box.example.com", seen)); + // No observed name at all still honours the empty/localhost shortcuts. + assert!(hostname_matches_any_local("", [])); + assert!(!hostname_matches_any_local("MacBook-Air.local", [])); +} + #[test] fn osc133_prompt_marks_record_cursor_positions_and_exit_status() { let t = run(b"\x1b]133;A\x07$ \x1b]133;B\x07cmd\x1b]133;C\x07\x1b]133;D;7\x07"); @@ -514,10 +545,7 @@ fn osc9_4_error_and_pause_accept_optional_percentages() { fn osc9_4_state_four_is_paused() { let mut t = run(b"\x1b]9;4;4;20\x07"); let progress = t.progress().unwrap(); - assert!(matches!( - progress, - crate::TerminalProgress::Paused(Some(_)) - )); + assert!(matches!(progress, crate::TerminalProgress::Paused(Some(_)))); assert_eq!(progress.value().unwrap().get(), 20); assert_eq!( t.take_pending_progress_update(),