From dd678c02eee792d2b76c9fdd2a19ee1fe9ac8ae0 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:11:39 +0000 Subject: [PATCH 1/2] feat: make sqlbuild a small forwarder to sqb sqlbuild was a second full copy of the sqb launcher. It now finds the sqb installed beside it and hands over the same arguments and streams: exec on Unix, a child that alone handles Ctrl+C and Ctrl+Break on Windows. Without sqb beside it, it names the missing program and exits 1. The bin compiles only the launcher hand-off file, so it stays at about 0.37 MB stripped instead of 6.4 MB when linking the library. Validated locally on Linux: forwarder integration tests, clippy, fensu, and Windows clippy of the bin and tests. Windows tests run in CI. --- .github/workflows/build-distributions.yml | 18 ++ .github/workflows/verify.yml | 2 + crates/sqlbuild-cli/src/bin/sqlbuild.rs | 34 ++- .../src/launcher/_helpers/dispatch.rs | 19 +- .../src/launcher/_helpers/handoff.rs | 43 ++- .../src/launcher/main/run_launcher.rs | 9 +- crates/sqlbuild-cli/tests/launcher.rs | 6 + crates/sqlbuild-cli/tests/launcher/helpers.rs | 149 ++++++++++ .../tests/launcher/test_forwarder.rs | 261 ++++++++++++++++++ .../sqlbuild-cli/tests/launcher/test_types.rs | 39 +++ fensu.toml | 10 + 11 files changed, 554 insertions(+), 36 deletions(-) create mode 100644 crates/sqlbuild-cli/tests/launcher.rs create mode 100644 crates/sqlbuild-cli/tests/launcher/helpers.rs create mode 100644 crates/sqlbuild-cli/tests/launcher/test_forwarder.rs create mode 100644 crates/sqlbuild-cli/tests/launcher/test_types.rs diff --git a/.github/workflows/build-distributions.yml b/.github/workflows/build-distributions.yml index e2c5b8e127..95df438e5a 100644 --- a/.github/workflows/build-distributions.yml +++ b/.github/workflows/build-distributions.yml @@ -189,6 +189,24 @@ jobs: "${run_as[@]}" "${smoke_scripts}/sqb" --version "${run_as[@]}" "${smoke_scripts}/sqb" --help "${run_as[@]}" "${smoke_scripts}/sqlbuild" --help + "${run_as[@]}" "${smoke_scripts}/sqlbuild" --version + # The sqlbuild forwarder returns sqb's own exit code; an unknown command is a usage error. + code=0 + "${run_as[@]}" "${smoke_scripts}/sqlbuild" no-such-command > /dev/null 2>&1 || code=$? + if [[ "${code}" != "2" ]]; then + echo "sqlbuild no-such-command exited ${code}; sqb exits 2" >&2 + exit 1 + fi + exe="${{ runner.os == 'Windows' && '.exe' || '' }}" + { + echo "### Sizes (${{ matrix.platform.name }})" + echo + echo "| File | Bytes |" + echo "| --- | ---: |" + for file in dist/*.whl "${smoke_scripts}/sqb${exe}" "${smoke_scripts}/sqlbuild${exe}"; do + echo "| $(basename "${file}") | $(wc -c < "${file}" | tr -d ' ') |" + done + } >> "${GITHUB_STEP_SUMMARY}" if [[ "${{ inputs.prebuilt-run-id == '' }}" == "true" ]]; then cd "${GITHUB_WORKSPACE}" "${run_as[@]}" "${smoke_python}" -m tests.integration.src.sqlbuild.compiler.compiled_project.publication.wheel.helpers \ diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index d0ebefd0b3..552ba82f0b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -606,6 +606,8 @@ jobs: -l "${RUNNER_TEMP}/postgres.log" -w start - name: Worker claim ownership, recovery and stop run: cargo test --locked -p sqlbuild-process-ownership + - name: sqlbuild forwarder arguments, exit codes, streams, signals and missing sqb + run: cargo test --locked -p sqlbuild-cli --test launcher - name: Control store, scheduler and recovery contracts on SQLite and PostgreSQL env: SQLBUILD_TEST_CONTROL_POSTGRES_URL: postgresql://postgres@127.0.0.1:5433/postgres diff --git a/crates/sqlbuild-cli/src/bin/sqlbuild.rs b/crates/sqlbuild-cli/src/bin/sqlbuild.rs index 7a0aa9b7ae..ca32528190 100644 --- a/crates/sqlbuild-cli/src/bin/sqlbuild.rs +++ b/crates/sqlbuild-cli/src/bin/sqlbuild.rs @@ -1,9 +1,37 @@ -//! `sqlbuild`: the native SQLBuild command line, the same program as `sqb`. +//! `sqlbuild`: the same command line as `sqb`, by running the `sqb` installed beside it without linking the library. +#[path = "../launcher/_helpers/handoff.rs"] +mod handoff; + +use std::ffi::OsString; +use std::io::Write; +use std::path::PathBuf; use std::process::ExitCode; -use sqlbuild_cli::launcher::main::run_launcher::run_launcher; +use handoff::{beside, hand_off, process_exit}; fn main() -> ExitCode { - run_launcher() + let program: PathBuf = std::env::current_exe().unwrap_or_default(); + let sqb_name = format!("sqb{}", std::env::consts::EXE_SUFFIX); + let Some(sqb) = beside(&program, &sqb_name) else { + let _ = writeln!( + std::io::stderr(), + "sqlbuild: cannot find {sqb_name} beside {}; reinstall sqlbuild-cli", + program.display() + ); + return ExitCode::FAILURE; + }; + let arguments: Vec = std::env::args_os().skip(1).collect(); + match hand_off(&sqb, &arguments) { + Ok(Some(code)) => process_exit(code), + Ok(None) => ExitCode::FAILURE, + Err(error) => { + let _ = writeln!( + std::io::stderr(), + "sqlbuild: cannot start {}: {error}", + sqb.display() + ); + ExitCode::FAILURE + } + } } diff --git a/crates/sqlbuild-cli/src/launcher/_helpers/dispatch.rs b/crates/sqlbuild-cli/src/launcher/_helpers/dispatch.rs index 4b4165481c..b27b9e2f55 100644 --- a/crates/sqlbuild-cli/src/launcher/_helpers/dispatch.rs +++ b/crates/sqlbuild-cli/src/launcher/_helpers/dispatch.rs @@ -4,11 +4,13 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::time::{Instant, SystemTime}; -use crate::launcher::_helpers::handoff::{hand_off, python_cli}; +use crate::launcher::_helpers::handoff::{beside, hand_off}; use crate::launcher::_helpers::launch_key::{launch_key, launch_record_path}; -use crate::launcher::_helpers::messages::{print_version, report_missing_python_cli}; +use crate::launcher::_helpers::messages::{ + print_version, report_handoff_failure, report_missing_python_cli, +}; use crate::launcher::constants::{ - HANDOFF_FAILURE_EXIT_CODE, LAUNCH_RECORD_OPTION, VERSION_OPTIONS, + HANDOFF_FAILURE_EXIT_CODE, LAUNCH_RECORD_OPTION, PYTHON_CLI_SCRIPT, VERSION_OPTIONS, }; use crate::native_commands::main::parse_native_invocation::parse_native_invocation; use crate::native_commands::main::run_control_invocation::run_control_invocation; @@ -64,7 +66,7 @@ pub(crate) fn launch(arguments: Vec, started: (Instant, SystemTime)) - return code; } let program: PathBuf = std::env::current_exe().unwrap_or_default(); - let Some(script) = python_cli(&program) else { + let Some(script) = beside(&program, PYTHON_CLI_SCRIPT) else { report_missing_python_cli(&program); return HANDOFF_FAILURE_EXIT_CODE; }; @@ -94,5 +96,12 @@ pub(crate) fn launch(arguments: Vec, started: (Instant, SystemTime)) - handed.push(option); } handed.extend(arguments); - hand_off(&script, &handed) + match hand_off(&script, &handed) { + Ok(Some(code)) => code, + Ok(None) => HANDOFF_FAILURE_EXIT_CODE, + Err(error) => { + report_handoff_failure(&script, &error); + HANDOFF_FAILURE_EXIT_CODE + } + } } diff --git a/crates/sqlbuild-cli/src/launcher/_helpers/handoff.rs b/crates/sqlbuild-cli/src/launcher/_helpers/handoff.rs index d6c001178b..405f82a5b5 100644 --- a/crates/sqlbuild-cli/src/launcher/_helpers/handoff.rs +++ b/crates/sqlbuild-cli/src/launcher/_helpers/handoff.rs @@ -1,42 +1,41 @@ -//! Find the Python CLI script installed beside this program and run it with the same arguments. +//! Find a program installed beside this one and run it with the same arguments and streams; the `sqlbuild` forwarder compiles this file into its own binary. use std::ffi::OsString; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, ExitCode}; -use crate::launcher::_helpers::messages::report_handoff_failure; -use crate::launcher::constants::{HANDOFF_FAILURE_EXIT_CODE, PYTHON_CLI_SCRIPT}; - -/// The Python CLI script beside this program or beside the file it links to. -pub(crate) fn python_cli(program: &Path) -> Option { +/// The file `name` beside this program or beside the file it links to. +pub(crate) fn beside(program: &Path, name: &str) -> Option { let resolved = std::fs::canonicalize(program).unwrap_or_else(|_| program.to_path_buf()); [program, resolved.as_path()] .into_iter() .filter_map(Path::parent) - .map(|directory| directory.join(PYTHON_CLI_SCRIPT)) + .map(|directory| directory.join(name)) .find(|candidate| candidate.is_file()) } -fn failure(script: &Path, error: &std::io::Error) -> i32 { - report_handoff_failure(script, error); - HANDOFF_FAILURE_EXIT_CODE -} - -/// Replace this process with the Python CLI; returns only when it cannot start. +/// Replace this process with `target`; returns only the error that kept it from starting. #[cfg(unix)] -pub(crate) fn hand_off(script: &Path, arguments: &[OsString]) -> i32 { +pub(crate) fn hand_off(target: &Path, arguments: &[OsString]) -> std::io::Result> { use std::os::unix::process::CommandExt; - let error = Command::new(script).args(arguments).exec(); - failure(script, &error) + Err(Command::new(target).args(arguments).exec()) } -/// Run the Python CLI as a child that alone handles Ctrl+C, and return its exit code. +/// Run `target` as a child that alone handles Ctrl+C and Ctrl+Break, and return its exit code. #[cfg(not(unix))] -pub(crate) fn hand_off(script: &Path, arguments: &[OsString]) -> i32 { +pub(crate) fn hand_off(target: &Path, arguments: &[OsString]) -> std::io::Result> { let _ = ctrlc::set_handler(|| {}); - match Command::new(script).args(arguments).status() { - Ok(status) => status.code().unwrap_or(HANDOFF_FAILURE_EXIT_CODE), - Err(error) => failure(script, &error), + Command::new(target) + .args(arguments) + .status() + .map(|status| status.code()) +} + +/// End the process with `code`; a code beyond one byte ends it directly. +pub(crate) fn process_exit(code: i32) -> ExitCode { + match u8::try_from(code) { + Ok(code) => ExitCode::from(code), + Err(_) => std::process::exit(code), } } diff --git a/crates/sqlbuild-cli/src/launcher/main/run_launcher.rs b/crates/sqlbuild-cli/src/launcher/main/run_launcher.rs index cc1f26c7b2..4db160c179 100644 --- a/crates/sqlbuild-cli/src/launcher/main/run_launcher.rs +++ b/crates/sqlbuild-cli/src/launcher/main/run_launcher.rs @@ -1,18 +1,15 @@ -//! The `sqb` and `sqlbuild` executables' whole process. +//! The `sqb` executable's whole process. use std::ffi::OsString; use std::process::ExitCode; use std::time::{Instant, SystemTime}; use crate::launcher::_helpers::dispatch::launch; +use crate::launcher::_helpers::handoff::process_exit; /// Run this process's invocation; an exit code beyond one byte ends the process directly. pub fn run_launcher() -> ExitCode { let started = (Instant::now(), SystemTime::now()); let arguments: Vec = std::env::args_os().skip(1).collect(); - let code: i32 = launch(arguments, started); - match u8::try_from(code) { - Ok(code) => ExitCode::from(code), - Err(_) => std::process::exit(code), - } + process_exit(launch(arguments, started)) } diff --git a/crates/sqlbuild-cli/tests/launcher.rs b/crates/sqlbuild-cli/tests/launcher.rs new file mode 100644 index 0000000000..2b54f56ccf --- /dev/null +++ b/crates/sqlbuild-cli/tests/launcher.rs @@ -0,0 +1,6 @@ +#[path = "launcher/helpers.rs"] +mod helpers; +#[path = "launcher/test_forwarder.rs"] +mod test_forwarder; +#[path = "launcher/test_types.rs"] +mod test_types; diff --git a/crates/sqlbuild-cli/tests/launcher/helpers.rs b/crates/sqlbuild-cli/tests/launcher/helpers.rs new file mode 100644 index 0000000000..df13e8a50b --- /dev/null +++ b/crates/sqlbuild-cli/tests/launcher/helpers.rs @@ -0,0 +1,149 @@ +#[cfg(unix)] +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::process::Child; +use std::process::Command; + +const FAKE_SQB: &str = r#" +use std::io::{Read, Write}; + +fn main() { + let mut out = std::io::stdout(); + for argument in std::env::args_os().skip(1) { + writeln!(out, "arg:{:?}", argument).unwrap(); + } + if std::env::var_os("FAKE_STDIN").is_some() { + let mut input = String::new(); + std::io::stdin().read_to_string(&mut input).unwrap(); + write!(out, "stdin:{input}").unwrap(); + } + out.flush().unwrap(); + eprint!("fake stderr"); + if std::env::var_os("FAKE_WAIT").is_some() { + #[cfg(windows)] + console::exit_on_break(); + writeln!(out, "ready:{}", std::process::id()).unwrap(); + out.flush().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(30)); + } + let code: i32 = std::env::var("FAKE_EXIT").ok().and_then(|code| code.parse().ok()).unwrap_or(0); + std::process::exit(code); +} + +#[cfg(windows)] +mod console { + type Handler = unsafe extern "system" fn(u32) -> i32; + + #[link(name = "kernel32")] + extern "system" { + fn SetConsoleCtrlHandler(handler: Option, add: i32) -> i32; + } + + unsafe extern "system" fn on_break(_event: u32) -> i32 { + std::thread::sleep(std::time::Duration::from_millis(500)); + std::process::exit(42) + } + + pub fn exit_on_break() { + unsafe { + SetConsoleCtrlHandler(Some(on_break), 1); + } + } +} +"#; + +#[cfg(windows)] +const BREAKER: &str = r#" +use std::io::BufRead; +use std::os::windows::process::CommandExt; +use std::process::{Command, Stdio}; + +#[link(name = "kernel32")] +extern "system" { + fn GenerateConsoleCtrlEvent(event: u32, group: u32) -> i32; +} + +fn main() { + let forwarder = std::env::args_os().nth(1).unwrap(); + let result = std::env::args_os().nth(2).unwrap(); + let mut child = Command::new(forwarder) + .creation_flags(0x0000_0200) + .env("FAKE_WAIT", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut ready = String::new(); + std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut ready).unwrap(); + let sent = unsafe { GenerateConsoleCtrlEvent(1, child.id()) }; + let status = child.wait().unwrap(); + std::fs::write(result, format!("{sent} {}", status.code().unwrap())).unwrap(); +} +"#; + +fn compile(source: &str, directory: &Path, name: &str) -> PathBuf { + let path = directory.join(format!("{name}.rs")); + std::fs::write(&path, source).expect("program source"); + let binary = directory.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + let output = Command::new(std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into())) + .args(["--edition", "2021", "-o"]) + .arg(&binary) + .arg(&path) + .output() + .expect("run rustc"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + binary +} + +/// The file name of `sqb` on this platform. +pub(crate) fn sqb_file() -> String { + format!("sqb{}", std::env::consts::EXE_SUFFIX) +} + +/// The real `sqlbuild` forwarder, alone in `directory`. +pub(crate) fn forwarder_in(directory: &Path) -> PathBuf { + let forwarder = directory.join(format!("sqlbuild{}", std::env::consts::EXE_SUFFIX)); + std::fs::hard_link(env!("CARGO_BIN_EXE_sqlbuild"), &forwarder).expect("link the forwarder"); + forwarder +} + +/// The real forwarder with a fake `sqb` beside it that reports what it received. +pub(crate) fn forwarder_with_fake_sqb(directory: &Path) -> PathBuf { + compile(FAKE_SQB, directory, "sqb"); + forwarder_in(directory) +} + +/// The pid the waiting fake `sqb` reports on its first stdout line. +#[cfg(unix)] +pub(crate) fn ready_pid(child: &mut Child) -> u32 { + let mut line = String::new(); + BufReader::new(child.stdout.take().expect("piped stdout")) + .read_line(&mut line) + .expect("ready line"); + line.trim() + .strip_prefix("ready:") + .and_then(|pid| pid.parse().ok()) + .expect("the fake sqb reports its pid") +} + +/// Ctrl+Break the forwarder's process group in a fresh console; returns ` `. +#[cfg(windows)] +pub(crate) fn break_forwarder(directory: &Path, forwarder: &Path) -> String { + use std::os::windows::process::CommandExt; + + let breaker = compile(BREAKER, directory, "breaker"); + let result = directory.join("result.txt"); + let status = Command::new(breaker) + .arg(forwarder) + .arg(&result) + .creation_flags(0x0000_0010) + .status() + .expect("run the breaker"); + assert!(status.success(), "breaker failed: {status:?}"); + std::fs::read_to_string(result).expect("breaker result") +} diff --git a/crates/sqlbuild-cli/tests/launcher/test_forwarder.rs b/crates/sqlbuild-cli/tests/launcher/test_forwarder.rs new file mode 100644 index 0000000000..392bc0d082 --- /dev/null +++ b/crates/sqlbuild-cli/tests/launcher/test_forwarder.rs @@ -0,0 +1,261 @@ +//! `sqlbuild` behaves exactly like the `sqb` installed beside it and fails loudly without one. + +use std::io::Write; +use std::process::{Command, Stdio}; + +#[cfg(windows)] +use crate::helpers::break_forwarder; +#[cfg(unix)] +use crate::helpers::ready_pid; +use crate::helpers::{forwarder_in, forwarder_with_fake_sqb, sqb_file}; +#[cfg(windows)] +use crate::test_types::ForwardedBreakTestCase; +#[cfg(unix)] +use crate::test_types::ForwardedSignalTestCase; +use crate::test_types::{ + ForwardedArgumentsTestCase, ForwardedExitTestCase, ForwardedStreamsTestCase, MissingSqbTestCase, +}; + +#[test] +fn given_arguments_when_running_sqlbuild_then_sqb_receives_them_unchanged() { + let test_cases = [ + ForwardedArgumentsTestCase { + description: "no arguments", + arguments: &[], + expected_lines: &[], + }, + ForwardedArgumentsTestCase { + description: "spaces, quotes, unicode, empty and dash-leading arguments", + arguments: &[ + "--project-dir", + "orders shop", + "say \"hi\"", + "commandé", + "", + "--", + "-x", + ], + expected_lines: &[ + r#"arg:"--project-dir""#, + r#"arg:"orders shop""#, + r#"arg:"say \"hi\"""#, + r#"arg:"commandé""#, + r#"arg:"""#, + r#"arg:"--""#, + r#"arg:"-x""#, + ], + }, + ]; + let directory = tempfile::tempdir().expect("isolated install"); + let forwarder = forwarder_with_fake_sqb(directory.path()); + for test_case in test_cases { + let output = Command::new(&forwarder) + .args(test_case.arguments) + .output() + .expect("run sqlbuild"); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout"); + assert_eq!( + stdout.lines().collect::>(), + test_case.expected_lines, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_sqb_exit_code_when_running_sqlbuild_then_sqlbuild_exits_with_the_same_code() { + let test_cases = [ + ForwardedExitTestCase { + description: "success", + exit: 0, + expected_code: 0, + }, + ForwardedExitTestCase { + description: "usage error", + exit: 2, + expected_code: 2, + }, + ForwardedExitTestCase { + description: "largest portable code", + exit: 255, + expected_code: 255, + }, + #[cfg(windows)] + ForwardedExitTestCase { + description: "Windows status beyond one byte", + exit: -1_073_741_510, + expected_code: -1_073_741_510, + }, + ]; + let directory = tempfile::tempdir().expect("isolated install"); + let forwarder = forwarder_with_fake_sqb(directory.path()); + for test_case in test_cases { + let status = Command::new(&forwarder) + .env("FAKE_EXIT", test_case.exit.to_string()) + .stderr(Stdio::null()) + .status() + .expect("run sqlbuild"); + assert_eq!( + status.code(), + Some(test_case.expected_code), + "{}", + test_case.description + ); + } +} + +#[test] +fn given_input_when_running_sqlbuild_then_sqb_shares_stdin_stdout_and_stderr() { + let test_cases = [ + ForwardedStreamsTestCase { + description: "multi-line input", + stdin: "orders\nline two\n", + expected_stdout: "stdin:orders\nline two\n", + expected_stderr: "fake stderr", + }, + ForwardedStreamsTestCase { + description: "empty input", + stdin: "", + expected_stdout: "stdin:", + expected_stderr: "fake stderr", + }, + ]; + let directory = tempfile::tempdir().expect("isolated install"); + let forwarder = forwarder_with_fake_sqb(directory.path()); + for test_case in test_cases { + let mut child = Command::new(&forwarder) + .env("FAKE_STDIN", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("run sqlbuild"); + child + .stdin + .take() + .expect("stdin") + .write_all(test_case.stdin.as_bytes()) + .expect("write stdin"); + let output = child.wait_with_output().expect("sqlbuild output"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + test_case.expected_stdout, + "{}", + test_case.description + ); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + test_case.expected_stderr, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_no_sqb_beside_it_when_running_sqlbuild_then_it_fails_and_names_the_missing_program() { + let test_cases = [ + MissingSqbTestCase { + description: "version request", + arguments: &["--version"], + expected_code: Some(1), + expected_stdout: "", + expected_stderr_start: format!("sqlbuild: cannot find {} beside ", sqb_file()), + }, + MissingSqbTestCase { + description: "command", + arguments: &["build", "--select", "orders"], + expected_code: Some(1), + expected_stdout: "", + expected_stderr_start: format!("sqlbuild: cannot find {} beside ", sqb_file()), + }, + ]; + let directory = tempfile::tempdir().expect("isolated install"); + let forwarder = forwarder_in(directory.path()); + for test_case in test_cases { + let output = Command::new(&forwarder) + .args(test_case.arguments) + .output() + .expect("run sqlbuild"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + test_case.expected_code, + "{}", + test_case.description + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + test_case.expected_stdout, + "{}", + test_case.description + ); + assert!( + stderr.starts_with(&test_case.expected_stderr_start), + "{}: {stderr}", + test_case.description + ); + } +} + +#[cfg(unix)] +#[test] +fn given_a_signal_when_sqb_runs_through_sqlbuild_then_sqb_is_the_same_process_and_receives_it() { + use std::os::unix::process::ExitStatusExt; + + let test_cases = [ + ForwardedSignalTestCase { + description: "termination", + signal: "TERM", + expected_signal: Some(15), + }, + ForwardedSignalTestCase { + description: "interrupt", + signal: "INT", + expected_signal: Some(2), + }, + ]; + let directory = tempfile::tempdir().expect("isolated install"); + let forwarder = forwarder_with_fake_sqb(directory.path()); + for test_case in test_cases { + let mut child = Command::new(&forwarder) + .env("FAKE_WAIT", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("run sqlbuild"); + let pid = ready_pid(&mut child); + assert_eq!(pid, child.id(), "{}", test_case.description); + let sent = Command::new("kill") + .args([format!("-{}", test_case.signal), pid.to_string()]) + .status() + .expect("send the signal"); + assert!(sent.success(), "{}", test_case.description); + assert_eq!( + child.wait().expect("wait").signal(), + test_case.expected_signal, + "{}", + test_case.description + ); + } +} + +#[cfg(windows)] +#[test] +fn given_ctrl_break_when_sqb_runs_through_sqlbuild_then_only_sqb_handles_it_and_its_code_returns() { + let test_cases = [ForwardedBreakTestCase { + description: "sqb exits 42 half a second after Ctrl+Break while sqlbuild waits", + expected_result: "1 42", + }]; + let directory = tempfile::tempdir().expect("isolated install"); + let forwarder = forwarder_with_fake_sqb(directory.path()); + for test_case in test_cases { + assert_eq!( + break_forwarder(directory.path(), &forwarder), + test_case.expected_result, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-cli/tests/launcher/test_types.rs b/crates/sqlbuild-cli/tests/launcher/test_types.rs new file mode 100644 index 0000000000..3bae20586c --- /dev/null +++ b/crates/sqlbuild-cli/tests/launcher/test_types.rs @@ -0,0 +1,39 @@ +pub(crate) struct ForwardedArgumentsTestCase { + pub(crate) description: &'static str, + pub(crate) arguments: &'static [&'static str], + pub(crate) expected_lines: &'static [&'static str], +} + +pub(crate) struct ForwardedExitTestCase { + pub(crate) description: &'static str, + pub(crate) exit: i32, + pub(crate) expected_code: i32, +} + +pub(crate) struct ForwardedStreamsTestCase { + pub(crate) description: &'static str, + pub(crate) stdin: &'static str, + pub(crate) expected_stdout: &'static str, + pub(crate) expected_stderr: &'static str, +} + +pub(crate) struct MissingSqbTestCase { + pub(crate) description: &'static str, + pub(crate) arguments: &'static [&'static str], + pub(crate) expected_code: Option, + pub(crate) expected_stdout: &'static str, + pub(crate) expected_stderr_start: String, +} + +#[cfg(unix)] +pub(crate) struct ForwardedSignalTestCase { + pub(crate) description: &'static str, + pub(crate) signal: &'static str, + pub(crate) expected_signal: Option, +} + +#[cfg(windows)] +pub(crate) struct ForwardedBreakTestCase { + pub(crate) description: &'static str, + pub(crate) expected_result: &'static str, +} diff --git a/fensu.toml b/fensu.toml index 54ddd88f0a..015b749f96 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1755,6 +1755,16 @@ rule = "FPRSH002" path = "crates/sqlbuild-process-ownership/src/ownership/_helpers/windows.rs" reason = "The workspace allows unsafe code only in sqlbuild-process-ownership, for the platform calls that prove which process owns a worker claim; each call has an item-level allow and a SAFETY comment." +[[targets.rust.rule_exceptions]] +rule = "FPRSR701" +path = "crates/sqlbuild-cli/src/bin/sqlbuild.rs" +reason = "The sqlbuild forwarder compiles only the launcher hand-off file instead of linking the sqlbuild-cli library: linking the library grows the stripped binary from about 0.37 MB to 6.4 MB in every wheel." + +[[targets.rust.rule_exceptions]] +rule = "FPRSR702" +path = "crates/sqlbuild-cli/src/bin/sqlbuild.rs" +reason = "The sqlbuild forwarder compiles only the launcher hand-off file instead of linking the sqlbuild-cli library: linking the library grows the stripped binary from about 0.37 MB to 6.4 MB in every wheel." + [targets.rust.rule_options.FPRSL102] packages = [] restricted_paths = ["crates/sqlbuild-rules/src/rules"] From cad95893b67ce94647e4f1d3fb5051bc74e9e1ea Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:23:21 +0000 Subject: [PATCH 2/2] test: run the forwarder tests across volumes and report wheel sizes Windows runners keep the temporary directory and the build on different drives, where hard links fail, so the tests copy the forwarder instead. The size summary reads the wheels from the workspace, because the smoke step has already changed directory. Validated locally on Linux: forwarder tests, fensu, and the summary block under bash -eo pipefail. --- .github/workflows/build-distributions.yml | 2 +- crates/sqlbuild-cli/tests/launcher/helpers.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-distributions.yml b/.github/workflows/build-distributions.yml index 95df438e5a..37beede233 100644 --- a/.github/workflows/build-distributions.yml +++ b/.github/workflows/build-distributions.yml @@ -203,7 +203,7 @@ jobs: echo echo "| File | Bytes |" echo "| --- | ---: |" - for file in dist/*.whl "${smoke_scripts}/sqb${exe}" "${smoke_scripts}/sqlbuild${exe}"; do + for file in "${GITHUB_WORKSPACE}"/dist/*.whl "${smoke_scripts}/sqb${exe}" "${smoke_scripts}/sqlbuild${exe}"; do echo "| $(basename "${file}") | $(wc -c < "${file}" | tr -d ' ') |" done } >> "${GITHUB_STEP_SUMMARY}" diff --git a/crates/sqlbuild-cli/tests/launcher/helpers.rs b/crates/sqlbuild-cli/tests/launcher/helpers.rs index df13e8a50b..dd34f67289 100644 --- a/crates/sqlbuild-cli/tests/launcher/helpers.rs +++ b/crates/sqlbuild-cli/tests/launcher/helpers.rs @@ -105,10 +105,10 @@ pub(crate) fn sqb_file() -> String { format!("sqb{}", std::env::consts::EXE_SUFFIX) } -/// The real `sqlbuild` forwarder, alone in `directory`. +/// A copy of the real `sqlbuild` forwarder, alone in `directory`; a copy works across volumes. pub(crate) fn forwarder_in(directory: &Path) -> PathBuf { let forwarder = directory.join(format!("sqlbuild{}", std::env::consts::EXE_SUFFIX)); - std::fs::hard_link(env!("CARGO_BIN_EXE_sqlbuild"), &forwarder).expect("link the forwarder"); + std::fs::copy(env!("CARGO_BIN_EXE_sqlbuild"), &forwarder).expect("copy the forwarder"); forwarder }