Skip to content

Commit e08a325

Browse files
committed
Test: Bind to port 0
1 parent e4e7fea commit e08a325

3 files changed

Lines changed: 94 additions & 32 deletions

File tree

libs/opsqueue_python/tests/conftest.py

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,9 @@ def opsqueue() -> Generator[OpsqueueProcess, None, None]:
5050

5151
@contextmanager
5252
def opsqueue_service(
53-
*, port: int | None = None
53+
*,
54+
port: int = 0,
5455
) -> Generator[OpsqueueProcess, None, None]:
55-
global test_opsqueue_port_offset
56-
57-
if port is None:
58-
port = random_free_port()
59-
6056
# This will create a SQLite database in memory.
6157
# We need the `cache=shared` to allow sharing this DB between all threads within the same OS process.
6258
temp_dbname = "file::memory:?cache=shared"
@@ -66,43 +62,47 @@ def opsqueue_service(
6662
# will from time to time hang for **many minutes** on initializing SQLite for some reason.
6763
# temp_dbname = f"/tmp/opsqueue_tests-{uuid.uuid4()}.db"
6864

65+
read_fd, write_fd = os.pipe()
66+
6967
command = [
7068
"setpriv",
7169
"--pdeathsig=SIGKILL",
7270
str(opsqueue_bin_location()),
7371
"--port",
7472
str(port),
73+
"--report-bound-port-pipe",
74+
str(write_fd),
7575
"--database-filename",
7676
temp_dbname,
7777
]
7878
env = os.environ.copy() # We copy the env so e.g. RUST_LOG and other env vars are propagated from outside of the invocation of pytest
7979
if env.get("RUST_LOG") is None:
8080
env["RUST_LOG"] = "off"
8181

82-
with subprocess.Popen(command, cwd=PROJECT_ROOT, env=env) as process:
83-
assert process.poll() is None, "Opsqueue process failed to start"
84-
try:
85-
wrapper = OpsqueueProcess(port=port, process=process)
86-
yield wrapper
87-
assert process.poll() is None, "Opsqueue process failed during run"
88-
finally:
89-
process.terminate()
90-
91-
92-
def random_free_port() -> int:
93-
import random
94-
95-
while True:
96-
port = random.randrange(10_000, 60_000)
97-
if not is_port_in_use(port):
98-
return port
99-
100-
101-
def is_port_in_use(port: int) -> bool:
102-
import socket
103-
104-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
105-
return s.connect_ex(("localhost", port)) == 0
82+
try:
83+
with subprocess.Popen(
84+
command,
85+
cwd=PROJECT_ROOT,
86+
env=env,
87+
pass_fds=(write_fd,),
88+
) as process:
89+
os.close(write_fd)
90+
write_fd = -1
91+
92+
assert process.poll() is None, "Opsqueue process failed to start"
93+
try:
94+
actual_port = int.from_bytes(
95+
os.read(read_fd, 2), byteorder="big", signed=False
96+
)
97+
wrapper = OpsqueueProcess(port=actual_port, process=process)
98+
yield wrapper
99+
assert process.poll() is None, "Opsqueue process failed during run"
100+
finally:
101+
process.terminate()
102+
finally:
103+
if write_fd != -1:
104+
os.close(write_fd)
105+
os.close(read_fd)
106106

107107

108108
@contextmanager

opsqueue/src/config.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
//!
33
//! We make use of the excellent `clap` crate to make customizing the configuration
44
//! with command-line args easier.
5-
use std::num::NonZero;
5+
use std::{
6+
fs::File,
7+
io::{self, Write},
8+
num::NonZero,
9+
os::fd::FromRawFd,
10+
sync::{Arc, Mutex},
11+
};
612

713
use clap::Parser;
814

@@ -17,6 +23,13 @@ pub struct Config {
1723
#[arg(short, long, default_value_t = 3999)]
1824
pub port: u16,
1925

26+
/// Optional file descriptor where the final bound TCP port is written.
27+
///
28+
/// This is useful when `--port 0` is used and a parent process wants
29+
/// to receive the assigned port without filesystem IO.
30+
#[arg(long, value_parser = parse_report_bound_port_fd)]
31+
pub report_bound_port_pipe: ReportBoundPortPipe,
32+
2033
/// Name of the SQLite database file used by this opsqueue.
2134
///
2235
/// Configure this to different values when you run multiple opsqueues
@@ -83,6 +96,7 @@ impl Default for Config {
8396
fn default() -> Self {
8497
use std::str::FromStr;
8598
let port = 3999;
99+
let report_bound_port_pipe = ReportBoundPortPipe::default();
86100
let database_filename = "opsqueue.db".to_string();
87101
let reservation_expiration =
88102
humantime::Duration::from_str("10 minutes").expect("valid humantime");
@@ -94,6 +108,7 @@ impl Default for Config {
94108
let max_submission_age = humantime::Duration::from_str("1 hour").expect("valid humantime");
95109
Config {
96110
port,
111+
report_bound_port_pipe,
97112
database_filename,
98113
reservation_expiration,
99114
max_read_pool_size,
@@ -104,3 +119,39 @@ impl Default for Config {
104119
}
105120
}
106121
}
122+
123+
#[derive(Debug, Clone, Default)]
124+
pub struct ReportBoundPortPipe(Arc<Mutex<Option<BoundPortPipe>>>);
125+
126+
impl ReportBoundPortPipe {
127+
pub fn take(&self) -> Option<BoundPortPipe> {
128+
self.0.lock().expect("No poison").take()
129+
}
130+
}
131+
132+
#[derive(Debug)]
133+
pub struct BoundPortPipe(File);
134+
135+
impl BoundPortPipe {
136+
pub fn write_port(mut self, port: u16) -> io::Result<()> {
137+
self.0.write_all(&u16::to_be_bytes(port))?;
138+
self.0.flush()
139+
}
140+
}
141+
142+
fn parse_report_bound_port_fd(value: &str) -> Result<ReportBoundPortPipe, String> {
143+
let fd = value
144+
.parse::<i32>()
145+
.map_err(|err| format!("invalid file descriptor {value:?}: {err}"))?;
146+
147+
if fd < 0 {
148+
return Err(format!(
149+
"invalid file descriptor {value:?}: must be non-negative"
150+
));
151+
}
152+
153+
// SAFETY: the parent process passes ownership of this FD to us through `pass_fds`.
154+
Ok(ReportBoundPortPipe(Arc::new(Mutex::new(Some(
155+
BoundPortPipe(unsafe { File::from_raw_fd(fd) }),
156+
)))))
157+
}

opsqueue/src/server.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,18 @@ pub async fn serve_producer_and_consumer(
4545
);
4646
let listener = tokio::net::TcpListener::bind(server_addr).await?;
4747
match listener.local_addr() {
48-
Ok(addr) => tracing::info!("Server listening on {addr}"),
48+
Ok(addr) => {
49+
tracing::info!("Server listening on {addr}");
50+
if let Some(pipe) = config.report_bound_port_pipe.take() {
51+
if let Err(err) = pipe.write_port(addr.port()) {
52+
tracing::warn!(
53+
"Failed to write bound port {} to pipe: {}",
54+
addr.port(),
55+
err
56+
);
57+
}
58+
}
59+
}
4960
Err(err) => tracing::warn!(
5061
"Could not get locally bound address of the server, tried binding on {server_addr}: {err}"
5162
),

0 commit comments

Comments
 (0)