Refactor path suppression handling in ChangeProcessor and ApiService

- Updated the `suppressed_paths` structure in `ChangeProcessor` and `ApiService` to store counts for both remove and create/write events, enhancing the accuracy of event suppression.
- Refactored methods for suppressing and unsuppressing paths to accommodate the new structure, ensuring that the system correctly manages VFS events based on the type of operation.
- Improved documentation and comments to clarify the purpose and behavior of the suppression logic, contributing to better maintainability and understanding of the code.
This commit is contained in:
Joe 2026-02-06 13:01:07 -06:00
parent 2336671292
commit 67537070db
6 changed files with 137 additions and 95 deletions

View file

@ -53,7 +53,7 @@ impl ChangeProcessor {
vfs: Arc<Vfs>, vfs: Arc<Vfs>,
message_queue: Arc<MessageQueue<AppliedPatchSet>>, message_queue: Arc<MessageQueue<AppliedPatchSet>>,
tree_mutation_receiver: Receiver<PatchSet>, tree_mutation_receiver: Receiver<PatchSet>,
suppressed_paths: Arc<Mutex<std::collections::HashMap<PathBuf, usize>>>, suppressed_paths: Arc<Mutex<std::collections::HashMap<PathBuf, (usize, usize)>>>,
) -> Self { ) -> Self {
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1); let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
let vfs_receiver = vfs.event_receiver(); let vfs_receiver = vfs.event_receiver();
@ -130,8 +130,8 @@ struct JobThreadContext {
pending_recovery: Mutex<Vec<(PathBuf, Instant)>>, pending_recovery: Mutex<Vec<(PathBuf, Instant)>>,
/// Paths recently written by the API's syncback. Events for these paths /// Paths recently written by the API's syncback. Events for these paths
/// are suppressed to avoid redundant re-snapshots. Values are counts. /// are suppressed to avoid redundant re-snapshots. Values are `(remove_count, create_write_count)`.
suppressed_paths: Arc<Mutex<std::collections::HashMap<PathBuf, usize>>>, suppressed_paths: Arc<Mutex<std::collections::HashMap<PathBuf, (usize, usize)>>>,
} }
impl JobThreadContext { impl JobThreadContext {
@ -160,12 +160,9 @@ impl JobThreadContext {
None None
} }
/// Suppress a single VFS event for the given path. Prevents the /// Canonicalize a path for use as a suppression map key.
/// ChangeProcessor from re-processing a file-watcher event that we fn suppression_key(path: &Path) -> PathBuf {
/// triggered ourselves (renames, writes). if let Ok(canonical) = std::fs::canonicalize(path) {
fn suppress_path(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = if let Ok(canonical) = std::fs::canonicalize(path) {
canonical canonical
} else if let Some(parent) = path.parent() { } else if let Some(parent) = path.parent() {
if let Ok(canonical_parent) = std::fs::canonicalize(parent) { if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
@ -179,33 +176,45 @@ impl JobThreadContext {
} }
} else { } else {
path.to_path_buf() path.to_path_buf()
}; }
*suppressed.entry(key).or_insert(0) += 1;
} }
/// Remove a suppression previously added by [`suppress_path`]. Called when /// Suppress the next Create/Write VFS event for the given path.
/// the filesystem operation that warranted the suppression failed, so that /// Prevents re-processing a file-watcher event we triggered ourselves.
/// future VFS events for that path are not incorrectly swallowed. fn suppress_path(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
suppressed.entry(key).or_insert((0, 0)).1 += 1;
}
/// Suppress the next Remove VFS event for the given path.
fn suppress_path_remove(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
suppressed.entry(key).or_insert((0, 0)).0 += 1;
}
/// Remove a Create/Write suppression previously added by [`suppress_path`].
/// Called when the filesystem operation failed, so that future VFS events
/// for that path are not incorrectly swallowed.
fn unsuppress_path(&self, path: &Path) { fn unsuppress_path(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap(); let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = if let Ok(canonical) = std::fs::canonicalize(path) { let key = Self::suppression_key(path);
canonical if let Some(counts) = suppressed.get_mut(&key) {
} else if let Some(parent) = path.parent() { counts.1 = counts.1.saturating_sub(1);
if let Ok(canonical_parent) = std::fs::canonicalize(parent) { if counts.0 == 0 && counts.1 == 0 {
if let Some(file_name) = path.file_name() { suppressed.remove(&key);
canonical_parent.join(file_name)
} else {
path.to_path_buf()
} }
} else {
path.to_path_buf()
} }
} else { }
path.to_path_buf()
}; /// Remove a Remove suppression previously added by [`suppress_path_remove`].
if let Some(count) = suppressed.get_mut(&key) { fn unsuppress_path_remove(&self, path: &Path) {
*count -= 1; let mut suppressed = self.suppressed_paths.lock().unwrap();
if *count == 0 { let key = Self::suppression_key(path);
if let Some(counts) = suppressed.get_mut(&key) {
counts.0 = counts.0.saturating_sub(1);
if counts.0 == 0 && counts.1 == 0 {
suppressed.remove(&key); suppressed.remove(&key);
} }
} }
@ -287,8 +296,10 @@ impl JobThreadContext {
} }
// Check if this event should be suppressed (one-shot, from API syncback). // Check if this event should be suppressed (one-shot, from API syncback).
// Try both the raw event path and its canonical form since suppress_path // Suppressions are event-type-aware: a Remove suppression only matches
// stores whichever form it could resolve at insertion time. // Remove events, and a Create/Write suppression only matches Create/Write
// events. This prevents a Remove suppression from consuming a Create event
// (which can happen on macOS when FSEvents coalesces delete+recreate).
let event_path = match &event { let event_path = match &event {
VfsEvent::Create(p) | VfsEvent::Write(p) | VfsEvent::Remove(p) => Some(p.clone()), VfsEvent::Create(p) | VfsEvent::Write(p) | VfsEvent::Remove(p) => Some(p.clone()),
_ => None, _ => None,
@ -310,12 +321,22 @@ impl JobThreadContext {
} }
}); });
if let Some(key) = matched_key { if let Some(key) = matched_key {
if let Some(count) = suppressed.get_mut(&key) { if let Some(counts) = suppressed.get_mut(&key) {
*count = count.saturating_sub(1); let should_suppress = match &event {
if *count == 0 { VfsEvent::Remove(_) if counts.0 > 0 => {
counts.0 -= 1;
true
}
VfsEvent::Create(_) | VfsEvent::Write(_) if counts.1 > 0 => {
counts.1 -= 1;
true
}
_ => false,
};
if counts.0 == 0 && counts.1 == 0 {
suppressed.remove(&key); suppressed.remove(&key);
} }
} if should_suppress {
drop(suppressed); drop(suppressed);
// Still commit so VFS stays consistent, but skip patching. // Still commit so VFS stays consistent, but skip patching.
self.vfs self.vfs
@ -328,6 +349,8 @@ impl JobThreadContext {
return; return;
} }
} }
}
}
// Update the VFS immediately with the event. // Update the VFS immediately with the event.
self.vfs self.vfs
@ -604,12 +627,12 @@ impl JobThreadContext {
dir_path.display(), dir_path.display(),
new_dir_path.display() new_dir_path.display()
); );
self.suppress_path(dir_path); self.suppress_path_remove(dir_path);
self.suppress_path(&new_dir_path); self.suppress_path(&new_dir_path);
if let Err(err) = if let Err(err) =
fs::rename(dir_path, &new_dir_path) fs::rename(dir_path, &new_dir_path)
{ {
self.unsuppress_path(dir_path); self.unsuppress_path_remove(dir_path);
self.unsuppress_path(&new_dir_path); self.unsuppress_path(&new_dir_path);
log::error!( log::error!(
"Failed to rename directory {:?} to {:?}: {}", "Failed to rename directory {:?} to {:?}: {}",
@ -631,12 +654,14 @@ impl JobThreadContext {
"{}.meta.json5", "{}.meta.json5",
encoded_new_name encoded_new_name
)); ));
self.suppress_path(&old_meta); self.suppress_path_remove(&old_meta);
self.suppress_path(&new_meta); self.suppress_path(&new_meta);
if fs::rename(&old_meta, &new_meta) if fs::rename(&old_meta, &new_meta)
.is_err() .is_err()
{ {
self.unsuppress_path(&old_meta); self.unsuppress_path_remove(
&old_meta,
);
self.unsuppress_path(&new_meta); self.unsuppress_path(&new_meta);
} }
} }
@ -688,10 +713,10 @@ impl JobThreadContext {
path.display(), path.display(),
new_path.display() new_path.display()
); );
self.suppress_path(path); self.suppress_path_remove(path);
self.suppress_path(&new_path); self.suppress_path(&new_path);
if let Err(err) = fs::rename(path, &new_path) { if let Err(err) = fs::rename(path, &new_path) {
self.unsuppress_path(path); self.unsuppress_path_remove(path);
self.unsuppress_path(&new_path); self.unsuppress_path(&new_path);
log::error!( log::error!(
"Failed to rename {:?} to {:?}: {}", "Failed to rename {:?} to {:?}: {}",
@ -708,11 +733,11 @@ impl JobThreadContext {
"{}.meta.json5", "{}.meta.json5",
encoded_new_name encoded_new_name
)); ));
self.suppress_path(&old_meta); self.suppress_path_remove(&old_meta);
self.suppress_path(&new_meta); self.suppress_path(&new_meta);
if fs::rename(&old_meta, &new_meta).is_err() if fs::rename(&old_meta, &new_meta).is_err()
{ {
self.unsuppress_path(&old_meta); self.unsuppress_path_remove(&old_meta);
self.unsuppress_path(&new_meta); self.unsuppress_path(&new_meta);
} }
} }
@ -827,12 +852,12 @@ impl JobThreadContext {
actual_file.display(), actual_file.display(),
new_path.display() new_path.display()
); );
self.suppress_path(&actual_file); self.suppress_path_remove(&actual_file);
self.suppress_path(&new_path); self.suppress_path(&new_path);
if let Err(err) = if let Err(err) =
fs::rename(&actual_file, &new_path) fs::rename(&actual_file, &new_path)
{ {
self.unsuppress_path(&actual_file); self.unsuppress_path_remove(&actual_file);
self.unsuppress_path(&new_path); self.unsuppress_path(&new_path);
log::error!( log::error!(
"Failed to rename {:?} to {:?}: {}", "Failed to rename {:?} to {:?}: {}",

View file

@ -1,4 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
#[cfg(windows)]
use std::process::Command;
use anyhow::Context; use anyhow::Context;
use clap::Parser; use clap::Parser;

View file

@ -89,9 +89,10 @@ pub struct ServeSession {
/// Paths recently written by the API's syncback. The ChangeProcessor /// Paths recently written by the API's syncback. The ChangeProcessor
/// checks this map and suppresses the file watcher echo for these paths /// checks this map and suppresses the file watcher echo for these paths
/// to avoid redundant re-snapshots and WebSocket messages. /// to avoid redundant re-snapshots and WebSocket messages.
/// Values are counts — each API write increments, each suppressed VFS event decrements. /// Values are `(remove_count, create_write_count)` — each API write increments
/// the appropriate counter, each suppressed VFS event decrements it.
#[allow(dead_code)] #[allow(dead_code)]
suppressed_paths: Arc<Mutex<std::collections::HashMap<std::path::PathBuf, usize>>>, suppressed_paths: Arc<Mutex<std::collections::HashMap<std::path::PathBuf, (usize, usize)>>>,
} }
impl ServeSession { impl ServeSession {
@ -180,7 +181,7 @@ impl ServeSession {
#[allow(dead_code)] #[allow(dead_code)]
pub fn suppressed_paths( pub fn suppressed_paths(
&self, &self,
) -> Arc<Mutex<std::collections::HashMap<std::path::PathBuf, usize>>> { ) -> Arc<Mutex<std::collections::HashMap<std::path::PathBuf, (usize, usize)>>> {
Arc::clone(&self.suppressed_paths) Arc::clone(&self.suppressed_paths)
} }

View file

@ -5,6 +5,9 @@ use std::{
sync::atomic::{AtomicUsize, Ordering}, sync::atomic::{AtomicUsize, Ordering},
}; };
#[cfg(windows)]
use std::{thread, time::Duration};
use memofs::Vfs; use memofs::Vfs;
use rayon::prelude::*; use rayon::prelude::*;

View file

@ -157,7 +157,7 @@ pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>)
pub struct ApiService { pub struct ApiService {
serve_session: Arc<ServeSession>, serve_session: Arc<ServeSession>,
suppressed_paths: Arc<Mutex<HashMap<PathBuf, usize>>>, suppressed_paths: Arc<Mutex<HashMap<PathBuf, (usize, usize)>>>,
} }
/// Derives the directory name from a standalone script's filesystem path. /// Derives the directory name from a standalone script's filesystem path.
@ -219,17 +219,9 @@ impl ApiService {
} }
} }
/// Register a path in the suppression map so that the ChangeProcessor /// Canonicalize a path for use as a suppression map key.
/// ignores the next VFS event for it (counter-based echo suppression). fn suppression_key(path: &Path) -> PathBuf {
/// Each call increments the counter; each suppressed VFS event decrements it. if let Ok(canonical) = std::fs::canonicalize(path) {
///
/// Inserts exactly ONE entry per call — canonical form preferred, raw as
/// fallback — to avoid leaking the unmatched variant.
fn suppress_path(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
// Try to canonicalize (adds \\?\ prefix on Windows, resolves symlinks).
// For files that don't exist yet, canonicalize the parent and join.
let key = if let Ok(canonical) = std::fs::canonicalize(path) {
canonical canonical
} else if let Some(parent) = path.parent() { } else if let Some(parent) = path.parent() {
if let Ok(canonical_parent) = std::fs::canonicalize(parent) { if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
@ -243,8 +235,21 @@ impl ApiService {
} }
} else { } else {
path.to_path_buf() path.to_path_buf()
}; }
*suppressed.entry(key).or_insert(0) += 1; }
/// Suppress the next Create/Write VFS event for the given path.
fn suppress_path(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
suppressed.entry(key).or_insert((0, 0)).1 += 1;
}
/// Suppress the next Remove VFS event for the given path.
fn suppress_path_remove(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
suppressed.entry(key).or_insert((0, 0)).0 += 1;
} }
/// Get a summary of information about the server /// Get a summary of information about the server
@ -386,7 +391,7 @@ impl ApiService {
continue; continue;
} }
if is_dir { if is_dir {
self.suppress_path(&path); self.suppress_path_remove(&path);
if let Err(err) = fs::remove_dir_all(&path) { if let Err(err) = fs::remove_dir_all(&path) {
log::warn!( log::warn!(
"Failed to remove directory {:?} for instance {:?}: {}", "Failed to remove directory {:?} for instance {:?}: {}",
@ -399,7 +404,7 @@ impl ApiService {
actually_removed.push(id); actually_removed.push(id);
} }
} else { } else {
self.suppress_path(&path); self.suppress_path_remove(&path);
if let Err(err) = fs::remove_file(&path) { if let Err(err) = fs::remove_file(&path) {
log::warn!( log::warn!(
"Failed to remove file {:?} for instance {:?}: {}", "Failed to remove file {:?} for instance {:?}: {}",
@ -427,7 +432,7 @@ impl ApiService {
let meta_path = let meta_path =
parent_dir.join(format!("{}.meta.json5", base_name)); parent_dir.join(format!("{}.meta.json5", base_name));
if meta_path.exists() { if meta_path.exists() {
self.suppress_path(&meta_path); self.suppress_path_remove(&meta_path);
let _ = fs::remove_file(&meta_path); let _ = fs::remove_file(&meta_path);
log::info!( log::info!(
"Syncback: Removed adjacent meta file at {}", "Syncback: Removed adjacent meta file at {}",
@ -915,7 +920,7 @@ impl ApiService {
// Remove the old standalone file // Remove the old standalone file
if existing_path.exists() && existing_path != init_path { if existing_path.exists() && existing_path != init_path {
self.suppress_path(existing_path); self.suppress_path_remove(existing_path);
fs::remove_file(existing_path).with_context(|| { fs::remove_file(existing_path).with_context(|| {
format!( format!(
"Failed to remove old standalone script: {}", "Failed to remove old standalone script: {}",
@ -1038,7 +1043,7 @@ impl ApiService {
// Remove the old standalone file // Remove the old standalone file
if standalone_path.exists() && standalone_path != init_path { if standalone_path.exists() && standalone_path != init_path {
self.suppress_path(standalone_path); self.suppress_path_remove(standalone_path);
fs::remove_file(standalone_path).with_context(|| { fs::remove_file(standalone_path).with_context(|| {
format!( format!(
"Failed to remove old standalone script: {}", "Failed to remove old standalone script: {}",
@ -1051,7 +1056,7 @@ impl ApiService {
let meta_path = containing_dir.join(format!("{}.meta.json5", dir_name)); let meta_path = containing_dir.join(format!("{}.meta.json5", dir_name));
if meta_path.exists() { if meta_path.exists() {
let init_meta_path = new_dir.join("init.meta.json5"); let init_meta_path = new_dir.join("init.meta.json5");
self.suppress_path(&meta_path); self.suppress_path_remove(&meta_path);
self.suppress_path(&init_meta_path); self.suppress_path(&init_meta_path);
fs::rename(&meta_path, &init_meta_path).with_context(|| { fs::rename(&meta_path, &init_meta_path).with_context(|| {
format!( format!(
@ -1227,7 +1232,7 @@ impl ApiService {
// Remove the old standalone file // Remove the old standalone file
if standalone_path.exists() { if standalone_path.exists() {
self.suppress_path(standalone_path); self.suppress_path_remove(standalone_path);
fs::remove_file(standalone_path).with_context(|| { fs::remove_file(standalone_path).with_context(|| {
format!( format!(
"Failed to remove old standalone file: {}", "Failed to remove old standalone file: {}",
@ -1303,13 +1308,13 @@ impl ApiService {
// Delete the file or directory // Delete the file or directory
if instance_path.is_dir() { if instance_path.is_dir() {
self.suppress_path(instance_path); self.suppress_path_remove(instance_path);
fs::remove_dir_all(instance_path).with_context(|| { fs::remove_dir_all(instance_path).with_context(|| {
format!("Failed to remove directory: {}", instance_path.display()) format!("Failed to remove directory: {}", instance_path.display())
})?; })?;
log::info!("Syncback: Removed directory at {}", instance_path.display()); log::info!("Syncback: Removed directory at {}", instance_path.display());
} else if instance_path.is_file() { } else if instance_path.is_file() {
self.suppress_path(instance_path); self.suppress_path_remove(instance_path);
fs::remove_file(instance_path) fs::remove_file(instance_path)
.with_context(|| format!("Failed to remove file: {}", instance_path.display()))?; .with_context(|| format!("Failed to remove file: {}", instance_path.display()))?;
log::info!("Syncback: Removed file at {}", instance_path.display()); log::info!("Syncback: Removed file at {}", instance_path.display());
@ -1538,12 +1543,12 @@ impl ApiService {
added.name added.name
); );
if old_path.exists() { if old_path.exists() {
self.suppress_path(old_path); self.suppress_path_remove(old_path);
let _ = fs::remove_file(old_path); let _ = fs::remove_file(old_path);
} }
let meta_path = parent_dir.join(format!("{}.meta.json5", encoded_name)); let meta_path = parent_dir.join(format!("{}.meta.json5", encoded_name));
if meta_path.exists() { if meta_path.exists() {
self.suppress_path(&meta_path); self.suppress_path_remove(&meta_path);
let _ = fs::remove_file(&meta_path); let _ = fs::remove_file(&meta_path);
} }
} }
@ -1590,12 +1595,12 @@ impl ApiService {
added.name added.name
); );
if old_path.exists() { if old_path.exists() {
self.suppress_path(old_path); self.suppress_path_remove(old_path);
let _ = fs::remove_file(old_path); let _ = fs::remove_file(old_path);
} }
let meta_path = parent_dir.join(format!("{}.meta.json5", encoded_name)); let meta_path = parent_dir.join(format!("{}.meta.json5", encoded_name));
if meta_path.exists() { if meta_path.exists() {
self.suppress_path(&meta_path); self.suppress_path_remove(&meta_path);
let _ = fs::remove_file(&meta_path); let _ = fs::remove_file(&meta_path);
} }
} }
@ -1642,12 +1647,12 @@ impl ApiService {
added.name added.name
); );
if old_path.exists() { if old_path.exists() {
self.suppress_path(old_path); self.suppress_path_remove(old_path);
let _ = fs::remove_file(old_path); let _ = fs::remove_file(old_path);
} }
let meta_path = parent_dir.join(format!("{}.meta.json5", encoded_name)); let meta_path = parent_dir.join(format!("{}.meta.json5", encoded_name));
if meta_path.exists() { if meta_path.exists() {
self.suppress_path(&meta_path); self.suppress_path_remove(&meta_path);
let _ = fs::remove_file(&meta_path); let _ = fs::remove_file(&meta_path);
} }
} }
@ -1804,7 +1809,7 @@ impl ApiService {
added.name added.name
); );
if old_path.exists() { if old_path.exists() {
self.suppress_path(old_path); self.suppress_path_remove(old_path);
let _ = fs::remove_file(old_path); let _ = fs::remove_file(old_path);
} }
} }

View file

@ -38,6 +38,14 @@ pub fn run_serve_test(test_name: &str, callback: impl FnOnce(TestServeSession, R
let mut session = TestServeSession::new(test_name); let mut session = TestServeSession::new(test_name);
let info = session.wait_to_come_online(); let info = session.wait_to_come_online();
// On macOS, the FSEvents-backed file watcher (via notify-debouncer-full)
// may not have fully populated its internal cache by the time the HTTP
// server reports as online. Without a warm cache, the debouncer cannot
// correctly classify events (e.g., it may miss a Remove if the file was
// not yet in the cache). Give it time to complete its initial scan.
#[cfg(target_os = "macos")]
thread::sleep(Duration::from_secs(1));
redactions.intern(info.session_id); redactions.intern(info.session_id);
redactions.intern(info.root_instance_id); redactions.intern(info.root_instance_id);
@ -91,11 +99,9 @@ impl TestServeSession {
// This is an ugly workaround for FSEvents sometimes reporting events // This is an ugly workaround for FSEvents sometimes reporting events
// for the above copy operations, similar to this Stack Overflow question: // for the above copy operations, similar to this Stack Overflow question:
// https://stackoverflow.com/questions/47679298/howto-avoid-receiving-old-events-in-fseventstream-callback-fsevents-framework-o // https://stackoverflow.com/questions/47679298/howto-avoid-receiving-old-events-in-fseventstream-callback-fsevents-framework-o
// We'll hope that 100ms is enough for FSEvents to get whatever it is // 500ms gives FSEvents time to flush any stale events from the copy.
// out of its system.
// TODO: find a better way to avoid processing these spurious events.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
std::thread::sleep(Duration::from_millis(100)); std::thread::sleep(Duration::from_millis(500));
let port = get_port_number(); let port = get_port_number();
let port_string = port.to_string(); let port_string = port.to_string();
@ -324,8 +330,8 @@ fn deserialize_msgpack<'a, T: Deserialize<'a>>(
/// reads back the assigned port, then drops the listener. The brief /// reads back the assigned port, then drops the listener. The brief
/// TOCTOU window before Rojo rebinds is negligible on localhost. /// TOCTOU window before Rojo rebinds is negligible on localhost.
fn get_port_number() -> usize { fn get_port_number() -> usize {
let listener = std::net::TcpListener::bind("127.0.0.1:0") let listener =
.expect("Failed to bind ephemeral port for test"); std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind ephemeral port for test");
let port = listener.local_addr().unwrap().port() as usize; let port = listener.local_addr().unwrap().port() as usize;
drop(listener); drop(listener);
port port