Skip to content
Closed
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: 8 additions & 0 deletions crates/gateway-api-discovery/src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ impl GatewayDiscoveryFile {
write_atomic_owner_only(&path, &bytes)
.map_err(|source| SidecarError::Write { path, source })
}

/// Builds a string containing the local address the gateway is running on,
/// for example if the gateway is running on port `12345` this would
/// give `http://127.0.0.1:12345`
#[must_use]
pub fn local_addr(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
}

/// Removes the gateway discovery file in `run_dir` when it still belongs to
Expand Down
7 changes: 2 additions & 5 deletions crates/gateway-api-discovery/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
//! includes a boot identity. Anything else is stale and the file is
//! deleted. [`ValidatedConnection`] holds that point-in-time proof without
//! exposing a forgeable constructor.
//! 3. Launch races take [`launch_or_attach`]: the `gateway.json.lock`
//! 3. Launch races take [`LaunchDecision::decide_launch`]: the `gateway.json.lock`
//! advisory lock elects one parent launcher; losers attach to the winner.
//! 4. Every Gateway process holds [`GatewayInstanceLease`] from before
//! startup side effects until process exit, independently of that parent
Expand Down Expand Up @@ -50,10 +50,7 @@ pub use crate::cancellation::CancellationToken;
pub use crate::error::SidecarError;
pub use crate::file::{GatewayDiscoveryFile, remove_if_mine};
pub use crate::health::{HealthError, ProbeError, wait_for_health, wait_for_health_cancellable};
pub use crate::lock::{
GatewayInstanceLease, LaunchDecision, LaunchLock, launch_or_attach,
launch_or_attach_cancellable,
};
pub use crate::lock::{GatewayInstanceLease, LaunchDecision, LaunchLock};
pub use crate::paths::{
GATEWAY_DISCOVERY_FILE_NAME, GATEWAY_LOG_FILE_NAME, INSTANCE_LOCK_FILE_NAME, LOCK_FILE_NAME,
default_run_dir, gateway_discovery_file_path, gateway_log_path, instance_lock_file_path,
Expand Down
62 changes: 32 additions & 30 deletions crates/gateway-api-discovery/src/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,50 +77,52 @@ impl GatewayInstanceLease {
}
}

/// What [`launch_or_attach`] decided.
/// Represents the decision to launch a new Gateway or attach to a running one
#[derive(Debug)]
#[non_exhaustive]
pub enum LaunchDecision {
/// This caller holds the launch lock and no live gateway exists:
/// launch one. The lock stays held until the returned guard drops, so
/// a concurrent racer attaches to the launched gateway instead of
/// launching its own.
Launch(LaunchLock),

/// A live gateway already exists: attach to it.
Attach(GatewayDiscoveryFile),
}

/// Settles a launch race in `run_dir`: returns [`LaunchDecision::Launch`]
/// with the held lock when no live gateway exists, or
/// [`LaunchDecision::Attach`] when one does - either already running, or
/// launched by the race winner while this caller waited. A loser waits up
/// to `timeout` for the winner's gateway to become attachable, and takes
/// the lock itself when the winner dies without writing one.
///
/// # Errors
/// Returns [`SidecarError::CreateDir`] when the run directory cannot be
/// created, [`SidecarError::Lock`] when the lock file cannot be opened or
/// an unexpected lock error occurs, [`SidecarError::LaunchTimeout`] when
/// no winner became attachable within `timeout`, and the [`crate::resolve`]
/// errors when the lock holder's own re-validation fails.
pub fn launch_or_attach(run_dir: &Path, timeout: Duration) -> Result<LaunchDecision, SidecarError> {
launch_or_attach_named(run_dir, GATEWAY_IMAGE_NAME, timeout)
}
impl LaunchDecision {
/// Settles a launch race in `run_dir`: returns [`Self::Launch`]
/// with the held lock when no live gateway exists, or
/// [`Self::Attach`] when one does - either already running, or
/// launched by the race winner while this caller waited. A loser waits up
/// to `timeout` for the winner's gateway to become attachable, and takes
/// the lock itself when the winner dies without writing one.
///
/// # Errors
/// Returns [`SidecarError::CreateDir`] when the run directory cannot be
/// created, [`SidecarError::Lock`] when the lock file cannot be opened or
/// an unexpected lock error occurs, [`SidecarError::LaunchTimeout`] when
/// no winner became attachable within `timeout`, and the [`crate::resolve`]
/// errors when the lock holder's own re-validation fails.
pub fn decide_launch(run_dir: &Path, timeout: Duration) -> Result<Self, SidecarError> {
launch_or_attach_named(run_dir, GATEWAY_IMAGE_NAME, timeout)
}

/// Settles a launch race while observing caller cancellation.
///
/// # Errors
/// Returns [`SidecarError::Cancelled`] when cancellation wins, plus the
/// file, lock, and timeout failures documented by [`launch_or_attach`].
pub fn launch_or_attach_cancellable(
run_dir: &Path,
timeout: Duration,
cancellation: &CancellationToken,
) -> Result<LaunchDecision, SidecarError> {
launch_or_attach_named_cancellable(run_dir, GATEWAY_IMAGE_NAME, timeout, cancellation)
/// Settles a launch race while observing caller cancellation.
///
/// # Errors
/// Returns [`SidecarError::Cancelled`] when cancellation wins, plus the
/// file, lock, and timeout failures documented by [`Self::decide_launch`].
pub fn decide_launch_cancellable(
run_dir: &Path,
timeout: Duration,
cancellation: &CancellationToken,
) -> Result<Self, SidecarError> {
launch_or_attach_named_cancellable(run_dir, GATEWAY_IMAGE_NAME, timeout, cancellation)
}
}

/// [`launch_or_attach`] against a caller-named process image, so tests
/// [`LaunchDecision::decide_launch`] against a caller-named process image, so tests
/// can run the full liveness gauntlet from a test binary, which is never
/// named `runvil-gateway`.
fn launch_or_attach_named(
Expand Down
9 changes: 5 additions & 4 deletions crates/gateway-api-discovery/tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use std::time::Duration;

use gateway_api_discovery::{
GatewayDiscoveryFile, GatewayInstanceLease, LaunchDecision, Resolution, SidecarError,
StaleReason, gateway_discovery_file_path, instance_lock_file_path, launch_or_attach,
lock_file_path, resolve, wait_for_health,
StaleReason, gateway_discovery_file_path, instance_lock_file_path, lock_file_path, resolve,
wait_for_health,
};

const LEASE_CHILD_RUN_DIR: &str = "RUNVIL_LEASE_CHILD_RUN_DIR";
Expand Down Expand Up @@ -175,7 +175,8 @@ fn resolve_cleans_a_dead_pid_file() {
#[test]
fn launch_or_attach_elects_one_launcher_and_times_out_the_loser() {
let dir = tempfile::TempDir::new().expect("tempdir");
let decision = launch_or_attach(dir.path(), Duration::from_secs(5)).expect("the race settles");
let decision = LaunchDecision::decide_launch(dir.path(), Duration::from_secs(5))
.expect("the race settles");
assert!(
matches!(decision, LaunchDecision::Launch(_)),
"an empty run dir elects a launcher"
Expand All @@ -185,7 +186,7 @@ fn launch_or_attach_elects_one_launcher_and_times_out_the_loser() {
"the lock file was created"
);

let error = launch_or_attach(dir.path(), Duration::from_millis(150))
let error = LaunchDecision::decide_launch(dir.path(), Duration::from_millis(150))
.expect_err("a held lock with no live file starves the loser");
assert!(
matches!(error, SidecarError::LaunchTimeout { .. }),
Expand Down
52 changes: 25 additions & 27 deletions crates/gateway/app/src/cli-tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ fn args(items: &[&str]) -> Vec<OsString> {

/// The kind of the parse error for `items`; panics if they parse.
fn error_kind(items: &[&str]) -> ErrorKind {
AppOptions::from_args(args(items))
parse_args(args(items))
.expect_err("the arguments are rejected")
.kind()
}
Expand Down Expand Up @@ -58,7 +58,7 @@ fn neither_path_set_defers_to_boot_discovery() {

#[test]
fn the_root_invocation_serves_with_discovery() {
let invocation = AppOptions::from_args(args(&[])).expect("the bare invocation parses");
let invocation = parse_args(args(&[])).expect("the bare invocation parses");
assert_eq!(
invocation.serve.config_path, None,
"no --config defers to boot discovery"
Expand Down Expand Up @@ -93,7 +93,7 @@ fn a_positional_config_path_is_rejected() {

#[test]
fn the_config_flag_sets_the_path() {
let invocation = AppOptions::from_args(args(&["--config", "gateway.toml"])).expect("parse");
let invocation = parse_args(args(&["--config", "gateway.toml"])).expect("parse");
assert_eq!(
invocation.serve.config_path,
Some(PathBuf::from("gateway.toml"))
Expand All @@ -115,8 +115,8 @@ fn the_config_flag_is_given_once() {

#[test]
fn parses_path_and_profile() {
let invocation = AppOptions::from_args(args(&["--config", "gateway.toml", "--profile", "dev"]))
.expect("parse");
let invocation =
parse_args(args(&["--config", "gateway.toml", "--profile", "dev"])).expect("parse");
assert_eq!(
invocation.serve.profile.as_ref().map(ProfileName::as_str),
Some("dev")
Expand All @@ -129,14 +129,14 @@ fn parses_path_and_profile() {

#[test]
fn the_tray_is_default_and_login_is_off() {
let invocation = AppOptions::from_args(args(&["--config", "gateway.toml"])).expect("parse");
let invocation = parse_args(args(&["--config", "gateway.toml"])).expect("parse");
assert!(invocation.tray, "the tray is the default main loop");
assert!(!invocation.login);
}

#[test]
fn no_tray_selects_the_headless_loop() {
let invocation = AppOptions::from_args(args(&["--no-tray"])).expect("parse");
let invocation = parse_args(args(&["--no-tray"])).expect("parse");
assert!(!invocation.tray);
assert!(!invocation.login);
}
Expand All @@ -145,14 +145,14 @@ fn no_tray_selects_the_headless_loop() {
fn the_autostart_command_line_parses() {
// The Run-key entry is `"<exe>" --login`; a login launch must
// never fail on its own command line.
let invocation = AppOptions::from_args(args(&["--login"])).expect("parse");
let invocation = parse_args(args(&["--login"])).expect("parse");
assert!(invocation.login);
assert!(invocation.tray, "a login launch still shows the tray");
}

#[test]
fn print_url_parses_and_leaves_the_other_flags_alone() {
let invocation = AppOptions::from_args(args(&["--print-url"])).expect("parse");
let invocation = parse_args(args(&["--print-url"])).expect("parse");
assert!(invocation.print_url);
assert!(
invocation.tray,
Expand All @@ -163,7 +163,7 @@ fn print_url_parses_and_leaves_the_other_flags_alone() {

#[test]
fn print_url_combines_with_no_tray_and_a_config_path() {
let invocation = AppOptions::from_args(args(&[
let invocation = parse_args(args(&[
"--config",
"gateway.toml",
"--no-tray",
Expand All @@ -180,7 +180,7 @@ fn print_url_combines_with_no_tray_and_a_config_path() {

#[test]
fn browser_parses_and_reaches_the_serve_options() {
let invocation = AppOptions::from_args(args(&["--browser"])).expect("parse");
let invocation = parse_args(args(&["--browser"])).expect("parse");
assert!(
invocation.serve.browser,
"the flag reaches the spawn hook through ServeOptions"
Expand All @@ -190,7 +190,7 @@ fn browser_parses_and_reaches_the_serve_options() {

#[test]
fn login_wins_over_browser() {
let invocation = AppOptions::from_args(args(&["--login", "--browser"])).expect("parse");
let invocation = parse_args(args(&["--login", "--browser"])).expect("parse");
assert!(
!invocation.serve.browser,
"a login launch never opens a browser"
Expand All @@ -199,7 +199,7 @@ fn login_wins_over_browser() {

#[test]
fn missing_profile_defers_to_environment_or_state() {
let invocation = AppOptions::from_args(args(&["--config", "gateway.toml"])).expect("parse");
let invocation = parse_args(args(&["--config", "gateway.toml"])).expect("parse");
assert!(invocation.serve.profile.is_none());
}

Expand Down Expand Up @@ -227,7 +227,7 @@ fn rejects_an_unknown_argument() {
#[test]
fn help_is_recognized() {
for flag in ["--help", "-h"] {
let error = AppOptions::from_args(args(&[flag])).unwrap_err();
let error = parse_args(args(&[flag])).unwrap_err();
assert_eq!(error.kind(), ErrorKind::DisplayHelp, "{flag}");
assert!(!error.use_stderr(), "{flag} prints to stdout and succeeds");
}
Expand All @@ -236,7 +236,7 @@ fn help_is_recognized() {
#[test]
fn version_is_recognized() {
for flag in ["--version", "-V"] {
let error = AppOptions::from_args(args(&[flag])).unwrap_err();
let error = parse_args(args(&[flag])).unwrap_err();
assert_eq!(error.kind(), ErrorKind::DisplayVersion, "{flag}");
assert!(!error.use_stderr(), "{flag} prints to stdout and succeeds");
}
Expand All @@ -252,17 +252,17 @@ fn rejects_unknown_flag() {

#[test]
fn diagnostics_is_the_only_subcommand() {
let invocation = AppOptions::from_args(args(&["diagnostics"])).expect("parses");
let invocation = parse_args(args(&["diagnostics"])).expect("parses");
assert_eq!(invocation.command, LaunchCommand::Diagnostics);
assert_eq!(invocation.serve.config_path, None);
let invocation = AppOptions::from_args(args(&[])).expect("the bare invocation parses");
let invocation = parse_args(args(&[])).expect("the bare invocation parses");
assert_eq!(invocation.command, LaunchCommand::Serve);
}

#[test]
fn diagnostics_accepts_a_config_path() {
let invocation =
AppOptions::from_args(args(&["diagnostics", "--config", "gateway.toml"])).expect("parses");
parse_args(args(&["diagnostics", "--config", "gateway.toml"])).expect("parses");
assert_eq!(invocation.command, LaunchCommand::Diagnostics);
assert_eq!(
invocation.serve.config_path,
Expand Down Expand Up @@ -292,8 +292,7 @@ fn diagnostics_after_a_flag_is_not_a_subcommand() {

#[test]
fn supervised_option_parses_and_reaches_the_serve_options() {
let invocation =
AppOptions::from_args(args(&["--supervised", "--no-tray"])).expect("should parse");
let invocation = parse_args(args(&["--supervised", "--no-tray"])).expect("should parse");

assert!(invocation.serve.supervised);
assert!(
Expand All @@ -304,12 +303,11 @@ fn supervised_option_parses_and_reaches_the_serve_options() {

#[test]
fn supervised_option_does_not_override_no_tray_option() {
let invocation = AppOptions::from_args(args(&["--supervised"])).expect("should parse");
let invocation = parse_args(args(&["--supervised"])).expect("should parse");

assert!(invocation.tray, "--supervised should not set tray to false");

let invocation =
AppOptions::from_args(args(&["--supervised", "--no-tray"])).expect("should parse");
let invocation = parse_args(args(&["--supervised", "--no-tray"])).expect("should parse");

assert!(
!invocation.tray,
Expand All @@ -319,7 +317,7 @@ fn supervised_option_does_not_override_no_tray_option() {

#[test]
fn a_launch_is_not_supervised_by_default() {
let invocation = AppOptions::from_args(args(&[])).expect("should parse");
let invocation = parse_args(args(&[])).expect("should parse");

assert!(!invocation.serve.supervised);
}
Expand All @@ -338,7 +336,7 @@ fn supervised_and_print_url_options_cannot_be_used_together() {

#[test]
fn diagnostics_has_its_own_help() {
let error = AppOptions::from_args(args(&["diagnostics", "--help"])).unwrap_err();
let error = parse_args(args(&["diagnostics", "--help"])).unwrap_err();
assert_eq!(error.kind(), ErrorKind::DisplayHelp);
let help = error.to_string();
assert!(help.contains("--config"), "{help}");
Expand Down Expand Up @@ -369,14 +367,14 @@ fn the_help_text_lists_all_flags() {

#[test]
fn preferred_port_parses_and_reaches_the_serve_options() {
let options = AppOptions::from_args(args(&["--preferred-port", "8081"])).expect("should parse");
let options = parse_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");
let options = parse_args(args(&[])).expect("the bare invocation should parse");

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