Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion crates/gateway/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,13 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] }
# First-run STT gating reads the installer's registry choice (src/boot.rs):
# RegOpenKeyExW + RegQueryValueExW. The Windows tray backend (src/tray/) adds
# the Run-key write shim and the hidden-window message loop; main() adds the
# PerMonitorV2 DPI-awareness call.
# PerMonitorV2 DPI-awareness call. Win32_Graphics_Gdi gates the message
# window's RegisterClassW and WNDCLASSW; it must be declared here rather than
# arrive by feature unification from tray-icon's copy of windows-sys.
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { workspace = true, features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
"Win32_System_Registry",
"Win32_UI_HiDpi",
Expand Down
37 changes: 37 additions & 0 deletions crates/gateway/app/src/cli-tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,40 @@ fn the_help_text_lists_all_flags() {
assert!(help.contains(flag), "the help names {flag}: {help}");
}
}

#[test]
fn preferred_port_parses_and_reaches_the_serve_options() {
let options = AppOptions::from_args(args(&["--preferred-port", "8081"])).expect("should parse");

assert_eq!(options.serve.preferred_port, Some(8081));
}

#[test]
fn a_launch_has_no_preferred_port_by_default() {
let options = AppOptions::from_args(args(&[])).expect("the bare invocation should parse");

assert_eq!(options.serve.preferred_port, None);
}

#[test]
fn preferred_port_requires_a_value() {
assert_eq!(error_kind(&["--preferred-port"]), ErrorKind::InvalidValue);
}

#[test]
fn preferred_port_rejects_a_value_that_is_not_a_port() {
for value in ["http", "65536", "70000"] {
assert_eq!(
error_kind(&["--preferred-port", value]),
ErrorKind::ValueValidation,
"--preferred-port {value} is not a port"
);
}
}

#[test]
fn the_help_text_hides_the_preferred_port_flag() {
let help = Cli::command().render_long_help().to_string();

assert!(!help.contains("--preferred-port"), "{help}");
}
8 changes: 7 additions & 1 deletion crates/gateway/app/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ impl AppOptions {
// browser - so it wins over `--browser`.
serve: ServeOptions::new(config_path, cli.profile)
.with_browser(cli.browser && !cli.login)
.with_supervised(cli.supervised),
.with_supervised(cli.supervised)
.with_preferred_port(cli.preferred_port),
tray: !cli.no_tray,
login: cli.login,
print_url: cli.print_url,
Expand Down Expand Up @@ -133,6 +134,11 @@ struct Cli {
/// The launch is a supervised child; internal only
#[arg(long, hide = true, conflicts_with = "print_url")]
supervised: bool,

/// The port to bind when the config asks for any free port. Best-effort only,
/// if the specified port is not available it falls back to any free port.
#[arg(long, value_name = "PORT", hide = true)]
preferred_port: Option<u16>,
}

#[derive(Debug, Subcommand)]
Expand Down
31 changes: 23 additions & 8 deletions crates/gateway/app/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use gateway_config::{Config, ProfileName, Secret};
use crate::api_error::StartupError;
use crate::{AppState, ShutdownReason};

mod bind;
mod gateway;
mod thread;

Expand Down Expand Up @@ -65,59 +66,73 @@ pub struct ServeOptions {
/// profile's `.runvil` directory) and, when nothing is found,
/// first-run generation into the profile location.
pub config_path: Option<PathBuf>,

/// Optional command-line profile override.
pub profile: Option<ProfileName>,

/// Directory the `gateway.json` gateway discovery file is written to after a
/// successful bind; `None` uses the default run directory under the
/// user profile's `.runvil` directory.
pub run_dir: Option<PathBuf>,

/// Opens the Settings handoff URL in the default browser once the
/// listener is bound - the binary's `--browser`, used by the
/// installer's first run. Embedders leave this `false`.
pub browser: bool,

/// Run the gateway as the child of a supervisor that handles restarting logic.
/// `POST /admin/restart` is available.
pub supervised: bool,

/// When running under a config that allows the gateway to bind to any free
/// port, it will prefer this one.
pub preferred_port: Option<u16>,
}

impl ServeOptions {
/// Builds serve options from the config path and optional profile override.
#[must_use]
pub fn new(
config_path: Option<PathBuf>,
profile: impl Into<Option<ProfileName>>,
) -> ServeOptions {
ServeOptions {
pub fn new(config_path: Option<PathBuf>, profile: impl Into<Option<ProfileName>>) -> Self {
Self {
config_path,
profile: profile.into(),
run_dir: None,
browser: false,
supervised: false,
preferred_port: None,
}
}

/// Sets the gateway discovery file's run directory, for tests and portable
/// installs; the default is the user profile's `.runvil/run`.
#[must_use]
pub fn with_run_dir(mut self, run_dir: PathBuf) -> ServeOptions {
pub fn with_run_dir(mut self, run_dir: PathBuf) -> Self {
self.run_dir = Some(run_dir);
self
}

/// Sets whether to open the Settings page in the browser once bound.
#[must_use]
pub fn with_browser(mut self, browser: bool) -> ServeOptions {
pub fn with_browser(mut self, browser: bool) -> Self {
self.browser = browser;
self
}

Comment thread
seanpar203 marked this conversation as resolved.
/// Sets whether this gateway runs with a supervisor which can relaunch it
/// on a restart request.
#[must_use]
pub fn with_supervised(mut self, supervised: bool) -> ServeOptions {
pub fn with_supervised(mut self, supervised: bool) -> Self {
self.supervised = supervised;
self
}

/// Sets a preferred port to bind when the config asks for any free port.
/// Best-effort only, if the port is not available falls back to any free port.
#[must_use]
pub fn with_preferred_port(mut self, port: Option<u16>) -> Self {
self.preferred_port = port;
self
}
}

/// Optional admin configuration path plus the active profile name.
Expand Down
124 changes: 124 additions & 0 deletions crates/gateway/app/src/runner/bind-tests.rs
Comment thread
seanpar203 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
use std::net::SocketAddr;

use super::*;

/// A get a port that was is not in use.
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("should bind a probe")
.local_addr()
.expect("address should exist")
.port()
}

fn loopback(port: u16) -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], port))
}

/// create a tokio Runtime for a test.
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.expect("a runtime builds")
}

#[test]
fn a_port_zero_config_binds_the_preferred_port() {
let runtime = runtime();

let preferred_port = free_port();

let listener =
bind_listener(loopback(0), Some(preferred_port), runtime.handle()).expect("should bind");

let listener_port = listener
.local_addr()
.expect("should have an address")
.port();

assert_eq!(listener_port, preferred_port);
}

#[test]
fn a_configured_port_wins_over_the_preferred_port() {
let runtime = runtime();

let configured_port = free_port();
let preferred_port = free_port();

let listener = bind_listener(
loopback(configured_port),
Some(preferred_port),
runtime.handle(),
)
.expect("should bind");

let listener_port = listener
.local_addr()
.expect("should have an address")
.port();

assert_eq!(listener_port, configured_port);
}

#[test]
fn a_taken_preferred_port_falls_back_to_a_free_one() {
let runtime = runtime();

let holder = std::net::TcpListener::bind("127.0.0.1:0").expect("should bind");
let used_port = holder.local_addr().expect("should have an address").port();

let listener =
bind_listener(loopback(0), Some(used_port), runtime.handle()).expect("should bind");

let listener_port = listener
.local_addr()
.expect("should have an address")
.port();

assert_ne!(listener_port, used_port);
}

#[test]
fn no_preferred_port_binds_a_free_port() {
let runtime = runtime();

let listener = bind_listener(loopback(0), None, runtime.handle()).expect("binds");

let listener_port = listener
.local_addr()
.expect("should have an address")
.port();

assert_ne!(listener_port, 0);
}

/// The server side closing a connection first leaves it in `TIME_WAIT`,
/// which is what a gateway shutting down with clients attached does.
#[tokio::test]
async fn a_released_port_rebinds_while_its_connection_is_in_time_wait() {
let runtime = tokio::runtime::Handle::current();

let listener = bind_listener(loopback(0), None, &runtime).expect("should bind");

let port = listener
.local_addr()
.expect("should have an address")
.port();

let client = tokio::net::TcpStream::connect(loopback(port))
.await
.expect("should connect");
let (served, _) = listener.accept().await.expect("should accept");
drop(served);
drop(listener);
drop(client);

let rebound = bind_listener(loopback(0), Some(port), &runtime).expect("should rebind");

assert_eq!(
rebound.local_addr().expect("should have an address").port(),
port
);
}
72 changes: 72 additions & 0 deletions crates/gateway/app/src/runner/bind.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//! Binding the gateway's listener to:
//! - The configured address, if it is set
//! - The preferred port if it is set and available
//! - Any available port

use std::net::SocketAddr;

use tokio::{
net::{TcpListener, TcpSocket},
runtime::EnterGuard,
};

const MAX_BACKLOG_CONNECTIONS: u32 = 1024;

/// Binds the gateway's listener to `addr`.
///
/// When `addr` asks for any free port (port 0) and `preferred_port` names one,
/// that port is tried first, enabling a gateway relaunched by its supervisor to
/// come back on the address its clients already hold. If the preferred port is
/// taken, a free port is bound instead and a warning is logged. `preferred_port` is
/// ignored if `addr` asks for a port other than 0.
pub(super) fn bind_listener(
addr: SocketAddr,
preferred_port: Option<u16>,
runtime: &tokio::runtime::Handle,
) -> std::io::Result<TcpListener> {
let context = runtime.enter();

if addr.port() == 0
&& let Some(preferred_port) = preferred_port
{
let preferred_addr = SocketAddr::new(addr.ip(), preferred_port);

match bind_addr(preferred_addr, &context) {
Ok(listener) => return Ok(listener),
Err(error) => tracing::warn!(
"could not bind the preferred port {preferred_port}: {error}; binding a free port instead"
),
}
}

bind_addr(addr, &context)
}

/// Binds `address`, reusing a port even if its previous connections are still in
/// `TIME_WAIT`, so a restarted gateway can take back the port it just released.
///
/// Must run inside a tokio runtime context, which the caller supplies via the
/// `context` param
fn bind_addr(address: SocketAddr, _context: &EnterGuard<'_>) -> std::io::Result<TcpListener> {
let socket = if address.is_ipv4() {
TcpSocket::new_v4()?
} else {
TcpSocket::new_v6()?
};

// Unix only: on Windows `SO_REUSEADDR` lets a second socket take a port that is in use
#[cfg(unix)]
socket.set_reuseaddr(true)?;

socket.bind(address)?;

// backlog connections defines the maximum number of pending connections
// queued by the operating system at any given time. Connection are removed
// from the queue with `TcpListener::accept`. When the queue is full, the
// operating-system will start rejecting connections.
socket.listen(MAX_BACKLOG_CONNECTIONS)
}

#[cfg(test)]
#[path = "bind-tests.rs"]
mod tests;
Loading
Loading