Skip to content

Commit 821385d

Browse files
authored
feat(cli): make sqlbuild a small forwarder to sqb (#1333)
* 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. * 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.
1 parent 1345322 commit 821385d

11 files changed

Lines changed: 554 additions & 36 deletions

File tree

‎.github/workflows/build-distributions.yml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,24 @@ jobs:
203203
"${run_as[@]}" "${smoke_scripts}/sqb" --version
204204
"${run_as[@]}" "${smoke_scripts}/sqb" --help
205205
"${run_as[@]}" "${smoke_scripts}/sqlbuild" --help
206+
"${run_as[@]}" "${smoke_scripts}/sqlbuild" --version
207+
# The sqlbuild forwarder returns sqb's own exit code; an unknown command is a usage error.
208+
code=0
209+
"${run_as[@]}" "${smoke_scripts}/sqlbuild" no-such-command > /dev/null 2>&1 || code=$?
210+
if [[ "${code}" != "2" ]]; then
211+
echo "sqlbuild no-such-command exited ${code}; sqb exits 2" >&2
212+
exit 1
213+
fi
214+
exe="${{ runner.os == 'Windows' && '.exe' || '' }}"
215+
{
216+
echo "### Sizes (${{ matrix.platform.name }})"
217+
echo
218+
echo "| File | Bytes |"
219+
echo "| --- | ---: |"
220+
for file in "${GITHUB_WORKSPACE}"/dist/*.whl "${smoke_scripts}/sqb${exe}" "${smoke_scripts}/sqlbuild${exe}"; do
221+
echo "| $(basename "${file}") | $(wc -c < "${file}" | tr -d ' ') |"
222+
done
223+
} >> "${GITHUB_STEP_SUMMARY}"
206224
if [[ "${{ inputs.prebuilt-run-id == '' }}" == "true" ]]; then
207225
cd "${GITHUB_WORKSPACE}"
208226
"${run_as[@]}" "${smoke_python}" -m tests.integration.src.sqlbuild.compiler.compiled_project.publication.wheel.helpers \

‎.github/workflows/verify.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,8 @@ jobs:
619619
-l "${RUNNER_TEMP}/postgres.log" -w start
620620
- name: Worker claim ownership, recovery and stop
621621
run: cargo test --locked -p sqlbuild-process-ownership
622+
- name: sqlbuild forwarder arguments, exit codes, streams, signals and missing sqb
623+
run: cargo test --locked -p sqlbuild-cli --test launcher
622624
- name: Control store, scheduler and recovery contracts on SQLite and PostgreSQL
623625
env:
624626
SQLBUILD_TEST_CONTROL_POSTGRES_URL: postgresql://postgres@127.0.0.1:5433/postgres
Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,37 @@
1-
//! `sqlbuild`: the native SQLBuild command line, the same program as `sqb`.
1+
//! `sqlbuild`: the same command line as `sqb`, by running the `sqb` installed beside it without linking the library.
22
3+
#[path = "../launcher/_helpers/handoff.rs"]
4+
mod handoff;
5+
6+
use std::ffi::OsString;
7+
use std::io::Write;
8+
use std::path::PathBuf;
39
use std::process::ExitCode;
410

5-
use sqlbuild_cli::launcher::main::run_launcher::run_launcher;
11+
use handoff::{beside, hand_off, process_exit};
612

713
fn main() -> ExitCode {
8-
run_launcher()
14+
let program: PathBuf = std::env::current_exe().unwrap_or_default();
15+
let sqb_name = format!("sqb{}", std::env::consts::EXE_SUFFIX);
16+
let Some(sqb) = beside(&program, &sqb_name) else {
17+
let _ = writeln!(
18+
std::io::stderr(),
19+
"sqlbuild: cannot find {sqb_name} beside {}; reinstall sqlbuild-cli",
20+
program.display()
21+
);
22+
return ExitCode::FAILURE;
23+
};
24+
let arguments: Vec<OsString> = std::env::args_os().skip(1).collect();
25+
match hand_off(&sqb, &arguments) {
26+
Ok(Some(code)) => process_exit(code),
27+
Ok(None) => ExitCode::FAILURE,
28+
Err(error) => {
29+
let _ = writeln!(
30+
std::io::stderr(),
31+
"sqlbuild: cannot start {}: {error}",
32+
sqb.display()
33+
);
34+
ExitCode::FAILURE
35+
}
36+
}
937
}

‎crates/sqlbuild-cli/src/launcher/_helpers/dispatch.rs‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ use std::ffi::OsString;
44
use std::path::{Path, PathBuf};
55
use std::time::{Instant, SystemTime};
66

7-
use crate::launcher::_helpers::handoff::{hand_off, python_cli};
7+
use crate::launcher::_helpers::handoff::{beside, hand_off};
88
use crate::launcher::_helpers::launch_key::{launch_key, launch_record_path};
9-
use crate::launcher::_helpers::messages::{print_version, report_missing_python_cli};
9+
use crate::launcher::_helpers::messages::{
10+
print_version, report_handoff_failure, report_missing_python_cli,
11+
};
1012
use crate::launcher::constants::{
11-
HANDOFF_FAILURE_EXIT_CODE, LAUNCH_RECORD_OPTION, VERSION_OPTIONS,
13+
HANDOFF_FAILURE_EXIT_CODE, LAUNCH_RECORD_OPTION, PYTHON_CLI_SCRIPT, VERSION_OPTIONS,
1214
};
1315
use crate::native_commands::main::parse_native_invocation::parse_native_invocation;
1416
use crate::native_commands::main::run_control_invocation::run_control_invocation;
@@ -64,7 +66,7 @@ pub(crate) fn launch(arguments: Vec<OsString>, started: (Instant, SystemTime)) -
6466
return code;
6567
}
6668
let program: PathBuf = std::env::current_exe().unwrap_or_default();
67-
let Some(script) = python_cli(&program) else {
69+
let Some(script) = beside(&program, PYTHON_CLI_SCRIPT) else {
6870
report_missing_python_cli(&program);
6971
return HANDOFF_FAILURE_EXIT_CODE;
7072
};
@@ -94,5 +96,12 @@ pub(crate) fn launch(arguments: Vec<OsString>, started: (Instant, SystemTime)) -
9496
handed.push(option);
9597
}
9698
handed.extend(arguments);
97-
hand_off(&script, &handed)
99+
match hand_off(&script, &handed) {
100+
Ok(Some(code)) => code,
101+
Ok(None) => HANDOFF_FAILURE_EXIT_CODE,
102+
Err(error) => {
103+
report_handoff_failure(&script, &error);
104+
HANDOFF_FAILURE_EXIT_CODE
105+
}
106+
}
98107
}
Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,41 @@
1-
//! Find the Python CLI script installed beside this program and run it with the same arguments.
1+
//! 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.
22
33
use std::ffi::OsString;
44
use std::path::{Path, PathBuf};
5-
use std::process::Command;
5+
use std::process::{Command, ExitCode};
66

7-
use crate::launcher::_helpers::messages::report_handoff_failure;
8-
use crate::launcher::constants::{HANDOFF_FAILURE_EXIT_CODE, PYTHON_CLI_SCRIPT};
9-
10-
/// The Python CLI script beside this program or beside the file it links to.
11-
pub(crate) fn python_cli(program: &Path) -> Option<PathBuf> {
7+
/// The file `name` beside this program or beside the file it links to.
8+
pub(crate) fn beside(program: &Path, name: &str) -> Option<PathBuf> {
129
let resolved = std::fs::canonicalize(program).unwrap_or_else(|_| program.to_path_buf());
1310
[program, resolved.as_path()]
1411
.into_iter()
1512
.filter_map(Path::parent)
16-
.map(|directory| directory.join(PYTHON_CLI_SCRIPT))
13+
.map(|directory| directory.join(name))
1714
.find(|candidate| candidate.is_file())
1815
}
1916

20-
fn failure(script: &Path, error: &std::io::Error) -> i32 {
21-
report_handoff_failure(script, error);
22-
HANDOFF_FAILURE_EXIT_CODE
23-
}
24-
25-
/// Replace this process with the Python CLI; returns only when it cannot start.
17+
/// Replace this process with `target`; returns only the error that kept it from starting.
2618
#[cfg(unix)]
27-
pub(crate) fn hand_off(script: &Path, arguments: &[OsString]) -> i32 {
19+
pub(crate) fn hand_off(target: &Path, arguments: &[OsString]) -> std::io::Result<Option<i32>> {
2820
use std::os::unix::process::CommandExt;
2921

30-
let error = Command::new(script).args(arguments).exec();
31-
failure(script, &error)
22+
Err(Command::new(target).args(arguments).exec())
3223
}
3324

34-
/// Run the Python CLI as a child that alone handles Ctrl+C, and return its exit code.
25+
/// Run `target` as a child that alone handles Ctrl+C and Ctrl+Break, and return its exit code.
3526
#[cfg(not(unix))]
36-
pub(crate) fn hand_off(script: &Path, arguments: &[OsString]) -> i32 {
27+
pub(crate) fn hand_off(target: &Path, arguments: &[OsString]) -> std::io::Result<Option<i32>> {
3728
let _ = ctrlc::set_handler(|| {});
38-
match Command::new(script).args(arguments).status() {
39-
Ok(status) => status.code().unwrap_or(HANDOFF_FAILURE_EXIT_CODE),
40-
Err(error) => failure(script, &error),
29+
Command::new(target)
30+
.args(arguments)
31+
.status()
32+
.map(|status| status.code())
33+
}
34+
35+
/// End the process with `code`; a code beyond one byte ends it directly.
36+
pub(crate) fn process_exit(code: i32) -> ExitCode {
37+
match u8::try_from(code) {
38+
Ok(code) => ExitCode::from(code),
39+
Err(_) => std::process::exit(code),
4140
}
4241
}
Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
1-
//! The `sqb` and `sqlbuild` executables' whole process.
1+
//! The `sqb` executable's whole process.
22
33
use std::ffi::OsString;
44
use std::process::ExitCode;
55
use std::time::{Instant, SystemTime};
66

77
use crate::launcher::_helpers::dispatch::launch;
8+
use crate::launcher::_helpers::handoff::process_exit;
89

910
/// Run this process's invocation; an exit code beyond one byte ends the process directly.
1011
pub fn run_launcher() -> ExitCode {
1112
let started = (Instant::now(), SystemTime::now());
1213
let arguments: Vec<OsString> = std::env::args_os().skip(1).collect();
13-
let code: i32 = launch(arguments, started);
14-
match u8::try_from(code) {
15-
Ok(code) => ExitCode::from(code),
16-
Err(_) => std::process::exit(code),
17-
}
14+
process_exit(launch(arguments, started))
1815
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#[path = "launcher/helpers.rs"]
2+
mod helpers;
3+
#[path = "launcher/test_forwarder.rs"]
4+
mod test_forwarder;
5+
#[path = "launcher/test_types.rs"]
6+
mod test_types;
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#[cfg(unix)]
2+
use std::io::{BufRead, BufReader};
3+
use std::path::{Path, PathBuf};
4+
#[cfg(unix)]
5+
use std::process::Child;
6+
use std::process::Command;
7+
8+
const FAKE_SQB: &str = r#"
9+
use std::io::{Read, Write};
10+
11+
fn main() {
12+
let mut out = std::io::stdout();
13+
for argument in std::env::args_os().skip(1) {
14+
writeln!(out, "arg:{:?}", argument).unwrap();
15+
}
16+
if std::env::var_os("FAKE_STDIN").is_some() {
17+
let mut input = String::new();
18+
std::io::stdin().read_to_string(&mut input).unwrap();
19+
write!(out, "stdin:{input}").unwrap();
20+
}
21+
out.flush().unwrap();
22+
eprint!("fake stderr");
23+
if std::env::var_os("FAKE_WAIT").is_some() {
24+
#[cfg(windows)]
25+
console::exit_on_break();
26+
writeln!(out, "ready:{}", std::process::id()).unwrap();
27+
out.flush().unwrap();
28+
std::thread::sleep(std::time::Duration::from_secs(30));
29+
}
30+
let code: i32 = std::env::var("FAKE_EXIT").ok().and_then(|code| code.parse().ok()).unwrap_or(0);
31+
std::process::exit(code);
32+
}
33+
34+
#[cfg(windows)]
35+
mod console {
36+
type Handler = unsafe extern "system" fn(u32) -> i32;
37+
38+
#[link(name = "kernel32")]
39+
extern "system" {
40+
fn SetConsoleCtrlHandler(handler: Option<Handler>, add: i32) -> i32;
41+
}
42+
43+
unsafe extern "system" fn on_break(_event: u32) -> i32 {
44+
std::thread::sleep(std::time::Duration::from_millis(500));
45+
std::process::exit(42)
46+
}
47+
48+
pub fn exit_on_break() {
49+
unsafe {
50+
SetConsoleCtrlHandler(Some(on_break), 1);
51+
}
52+
}
53+
}
54+
"#;
55+
56+
#[cfg(windows)]
57+
const BREAKER: &str = r#"
58+
use std::io::BufRead;
59+
use std::os::windows::process::CommandExt;
60+
use std::process::{Command, Stdio};
61+
62+
#[link(name = "kernel32")]
63+
extern "system" {
64+
fn GenerateConsoleCtrlEvent(event: u32, group: u32) -> i32;
65+
}
66+
67+
fn main() {
68+
let forwarder = std::env::args_os().nth(1).unwrap();
69+
let result = std::env::args_os().nth(2).unwrap();
70+
let mut child = Command::new(forwarder)
71+
.creation_flags(0x0000_0200)
72+
.env("FAKE_WAIT", "1")
73+
.stdout(Stdio::piped())
74+
.stderr(Stdio::null())
75+
.spawn()
76+
.unwrap();
77+
let mut ready = String::new();
78+
std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut ready).unwrap();
79+
let sent = unsafe { GenerateConsoleCtrlEvent(1, child.id()) };
80+
let status = child.wait().unwrap();
81+
std::fs::write(result, format!("{sent} {}", status.code().unwrap())).unwrap();
82+
}
83+
"#;
84+
85+
fn compile(source: &str, directory: &Path, name: &str) -> PathBuf {
86+
let path = directory.join(format!("{name}.rs"));
87+
std::fs::write(&path, source).expect("program source");
88+
let binary = directory.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
89+
let output = Command::new(std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()))
90+
.args(["--edition", "2021", "-o"])
91+
.arg(&binary)
92+
.arg(&path)
93+
.output()
94+
.expect("run rustc");
95+
assert!(
96+
output.status.success(),
97+
"{}",
98+
String::from_utf8_lossy(&output.stderr)
99+
);
100+
binary
101+
}
102+
103+
/// The file name of `sqb` on this platform.
104+
pub(crate) fn sqb_file() -> String {
105+
format!("sqb{}", std::env::consts::EXE_SUFFIX)
106+
}
107+
108+
/// A copy of the real `sqlbuild` forwarder, alone in `directory`; a copy works across volumes.
109+
pub(crate) fn forwarder_in(directory: &Path) -> PathBuf {
110+
let forwarder = directory.join(format!("sqlbuild{}", std::env::consts::EXE_SUFFIX));
111+
std::fs::copy(env!("CARGO_BIN_EXE_sqlbuild"), &forwarder).expect("copy the forwarder");
112+
forwarder
113+
}
114+
115+
/// The real forwarder with a fake `sqb` beside it that reports what it received.
116+
pub(crate) fn forwarder_with_fake_sqb(directory: &Path) -> PathBuf {
117+
compile(FAKE_SQB, directory, "sqb");
118+
forwarder_in(directory)
119+
}
120+
121+
/// The pid the waiting fake `sqb` reports on its first stdout line.
122+
#[cfg(unix)]
123+
pub(crate) fn ready_pid(child: &mut Child) -> u32 {
124+
let mut line = String::new();
125+
BufReader::new(child.stdout.take().expect("piped stdout"))
126+
.read_line(&mut line)
127+
.expect("ready line");
128+
line.trim()
129+
.strip_prefix("ready:")
130+
.and_then(|pid| pid.parse().ok())
131+
.expect("the fake sqb reports its pid")
132+
}
133+
134+
/// Ctrl+Break the forwarder's process group in a fresh console; returns `<sent> <forwarder exit code>`.
135+
#[cfg(windows)]
136+
pub(crate) fn break_forwarder(directory: &Path, forwarder: &Path) -> String {
137+
use std::os::windows::process::CommandExt;
138+
139+
let breaker = compile(BREAKER, directory, "breaker");
140+
let result = directory.join("result.txt");
141+
let status = Command::new(breaker)
142+
.arg(forwarder)
143+
.arg(&result)
144+
.creation_flags(0x0000_0010)
145+
.status()
146+
.expect("run the breaker");
147+
assert!(status.success(), "breaker failed: {status:?}");
148+
std::fs::read_to_string(result).expect("breaker result")
149+
}

0 commit comments

Comments
 (0)