Skip to content

Commit fcbce08

Browse files
committed
Survive a machine that runs out of memory
Most tests here start a real server and the harness runs one thread per core, so a small machine was asked to hold more of them than it had memory for and the kernel picked them off. Take a slot before spawning and give it back when the process is reaped, charged to the thread rather than to each server: a test that stops one and starts another holds two, and charging both would let such tests take every slot and then wait on each other. Then stop reading a killed process as a clean exit. An exit code is absent when a signal ends a process, and both runners turned that absence into zero, so a usage error the binary refuses and a production start it declines both read as accepted.
1 parent 3e20a92 commit fcbce08

1 file changed

Lines changed: 267 additions & 26 deletions

File tree

‎tests/cli.rs‎

Lines changed: 267 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ mod common;
33
use std::io::{Read, Write};
44
use std::net::{TcpListener, TcpStream};
55
use std::path::{Path, PathBuf};
6-
use std::process::{Child, Command, Stdio};
7-
use std::sync::Arc;
6+
use std::process::{Child, Command, ExitStatus, Stdio};
87
use std::sync::atomic::{AtomicBool, Ordering};
8+
use std::sync::{Arc, Condvar, Mutex};
99
use std::thread;
1010
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
1111

@@ -16,19 +16,64 @@ fn run_cli_args(args: &[&str]) -> (i32, String, String) {
1616
run_cli_args_with_env(args, &[])
1717
}
1818

19-
fn run_cli_args_with_env(args: &[&str], envs: &[(&str, &str)]) -> (i32, String, String) {
20-
let cwd = exe_temp_path("empty-cwd");
21-
std::fs::create_dir_all(&cwd).expect("empty cwd should create");
22-
let output = cli_command(args, envs, &cwd)
23-
.output()
24-
.expect("codetrial should exit");
25-
let _ = std::fs::remove_dir_all(cwd);
19+
/// Written by `wait_for_http` and read by `spawn_server`; the pair is pinned
20+
/// in `only_sigkill_is_retryable`.
21+
const SIGKILL_PREFIX: &str = "SIGKILL: ";
2622

27-
(
28-
output.status.code().unwrap_or_default(),
29-
String::from_utf8(output.stdout).expect("stdout should be UTF-8"),
30-
String::from_utf8(output.stderr).expect("stderr should be UTF-8"),
31-
)
23+
fn was_sigkill(status: &ExitStatus) -> bool {
24+
#[cfg(unix)]
25+
{
26+
use std::os::unix::process::ExitStatusExt;
27+
28+
status.signal() == Some(9)
29+
}
30+
#[cfg(not(unix))]
31+
{
32+
let _ = status;
33+
false
34+
}
35+
}
36+
37+
#[cfg(unix)]
38+
#[test]
39+
fn only_sigkill_is_retryable() {
40+
use std::os::unix::process::ExitStatusExt;
41+
42+
assert!(was_sigkill(&ExitStatus::from_raw(9)));
43+
assert!(!was_sigkill(&ExitStatus::from_raw(11)));
44+
}
45+
46+
fn run_cli_args_with_env(args: &[&str], envs: &[(&str, &str)]) -> (i32, String, String) {
47+
// Retried for the one reason a run says nothing about the binary: the
48+
// machine ran out of memory and the kernel chose this process. `code()` is
49+
// `None` for a death by signal, and defaulting that to 0 handed every
50+
// caller a successful exit status for a process that never ran, so
51+
// `reject_usage_errors_with_exit_two` failed reporting 0 against 2 and read
52+
// as the binary having accepted a usage error it had refused.
53+
for attempt in 0..5 {
54+
let cwd = exe_temp_path("empty-cwd");
55+
std::fs::create_dir_all(&cwd).expect("empty cwd should create");
56+
let output = cli_command(args, envs, &cwd)
57+
.output()
58+
.expect("codetrial should exit");
59+
let _ = std::fs::remove_dir_all(cwd);
60+
61+
let Some(code) = output.status.code() else {
62+
assert!(
63+
was_sigkill(&output.status),
64+
"{args:?}: killed by an unexpected signal: {}",
65+
output.status
66+
);
67+
assert!(attempt < 4, "{args:?}: killed by a signal five times over");
68+
continue;
69+
};
70+
return (
71+
code,
72+
String::from_utf8(output.stdout).expect("stdout should be UTF-8"),
73+
String::from_utf8(output.stderr).expect("stderr should be UTF-8"),
74+
);
75+
}
76+
unreachable!("the loop returns or asserts")
3277
}
3378

3479
/// The same invocation, but the process is required to stop by itself.
@@ -41,6 +86,19 @@ fn run_cli_args_with_env(args: &[&str], envs: &[(&str, &str)]) -> (i32, String,
4186
/// CI as a 33-second timeout. Bounded, so a guard that stopped guarding is a
4287
/// red test.
4388
fn run_cli_until_exit(args: &[&str], envs: &[(&str, &str)]) -> (i32, String, String) {
89+
// Retried on a kill for the same reason the other runner is: the machine
90+
// running out of memory says nothing about the binary, and this helper's
91+
// whole point is that a refusal is observed rather than assumed.
92+
for attempt in 0..5 {
93+
if let Some(result) = run_cli_until_exit_once(args, envs) {
94+
return result;
95+
}
96+
assert!(attempt < 4, "{args:?}: killed by a signal five times over");
97+
}
98+
unreachable!("the loop returns or asserts")
99+
}
100+
101+
fn run_cli_until_exit_once(args: &[&str], envs: &[(&str, &str)]) -> Option<(i32, String, String)> {
44102
// Generous for a loaded runner, and well inside the timeout `cargo mutants`
45103
// derives from the baseline (33s when this was written). A bound above that
46104
// would score a caught mutant as a timeout again, which is the failure this
@@ -92,7 +150,17 @@ fn run_cli_until_exit(args: &[&str], envs: &[(&str, &str)]) -> (i32, String, Str
92150
let stderr = err.join().expect("stderr reader should finish");
93151
let _ = std::fs::remove_dir_all(&cwd);
94152

95-
(status.code().unwrap_or_default(), stdout, stderr)
153+
// Not `unwrap_or_default`: `code()` is `None` for a death by signal, and
154+
// zero is the one value that reads as a clean exit. An out-of-memory kill
155+
// reported that way turned "the binary refused" into "the binary accepted".
156+
let Some(code) = status.code() else {
157+
assert!(
158+
was_sigkill(&status),
159+
"codetrial {args:?} died of an unexpected signal: {status}"
160+
);
161+
return None;
162+
};
163+
Some((code, stdout, stderr))
96164
}
97165

98166
/// One place builds the invocation, so the two runners cannot drift about which
@@ -249,8 +317,13 @@ fn wait_for_http(addr: &str, child: &mut Child) -> Result<(), String> {
249317
if let Some(stream) = child.stderr.as_mut() {
250318
let _ = stream.read_to_string(&mut stderr);
251319
}
320+
let cause = if was_sigkill(&status) {
321+
SIGKILL_PREFIX
322+
} else {
323+
""
324+
};
252325
return Err(format!(
253-
"server exited before listening: {status}; stderr={stderr}"
326+
"{cause}server exited before listening: {status}; stderr={stderr}"
254327
));
255328
}
256329
thread::sleep(Duration::from_millis(50));
@@ -262,16 +335,30 @@ fn wait_for_http(addr: &str, child: &mut Child) -> Result<(), String> {
262335
/// its listener, so another socket can take it first. Retry on that specific
263336
/// loss instead of reporting it as the behavior under test failing.
264337
fn spawn_server(build: impl Fn(&str) -> Command) -> (String, ServerProcess) {
338+
let mut last = String::new();
265339
for _ in 0..5 {
266340
let addr = free_addr();
267341
let mut child = ServerProcess::spawn(&mut build(&addr));
268342
match wait_for_http(&addr, &mut child) {
269343
Ok(()) => return (addr, child),
270-
Err(error) if error.contains("Address already in use") => continue,
344+
345+
// Two startup losses that say nothing about the binary. The port
346+
// was taken between `free_addr` closing its listener and the child
347+
// binding it. Or the machine ran out of memory and the kernel chose
348+
// this process: `MAX_LIVE_SERVERS` is what keeps that rare, and a
349+
// suite that reported it as the behavior under test failing would
350+
// be reporting the size of the machine it ran on.
351+
Err(error)
352+
if error.contains("Address already in use")
353+
|| error.starts_with(SIGKILL_PREFIX) =>
354+
{
355+
last = error;
356+
continue;
357+
}
271358
Err(error) => panic!("{error}"),
272359
}
273360
}
274-
panic!("could not start the server on a free port");
361+
panic!("no attempt started a server; the last one said: {last}");
275362
}
276363

277364
/// A cold-start server: the binary beside `dir`, with no config file on any
@@ -326,10 +413,160 @@ fn http_request(addr: &str, request: &str) -> String {
326413
response
327414
}
328415

416+
/// How many servers may be live at once.
417+
///
418+
/// Most tests in this file start a real `codetrial web`, and the test harness
419+
/// runs one thread per core, so the default is as many live servers as the
420+
/// machine has cores. Each is a whole binary with the WebRTC stack linked in,
421+
/// which on a small machine is more resident memory than there is to give: the
422+
/// kernel picks servers off with SIGKILL, and `wait_for_http` reports the death
423+
/// as the behavior under test failing. Retrying would not help, because the
424+
/// next attempt asks the same machine for the same thing.
425+
///
426+
/// Four rather than a number derived from the hardware. The cap only has to be
427+
/// below what the smallest machine running this suite can hold, and a bound
428+
/// that varies by host is a suite that is flaky on exactly the hosts nobody
429+
/// tests on. Tests that need no server never queue here.
430+
const MAX_LIVE_SERVERS: usize = 4;
431+
432+
fn server_slots() -> &'static (Mutex<usize>, Condvar) {
433+
static SLOTS: std::sync::OnceLock<(Mutex<usize>, Condvar)> = std::sync::OnceLock::new();
434+
SLOTS.get_or_init(|| (Mutex::new(MAX_LIVE_SERVERS), Condvar::new()))
435+
}
436+
437+
// How many servers this thread is already holding, so the slot is charged to
438+
// the test rather than to each server it starts.
439+
//
440+
// `stop_child` kills a server without dropping the `ServerProcess` that owns
441+
// it, so a test that stops one and starts another holds two at once. Charging
442+
// both would let enough such tests take every slot and then wait on each other
443+
// for the second one they each need, which is a hung suite rather than a slow
444+
// one. A test pays once and keeps paying until it ends.
445+
thread_local! {
446+
static SERVERS_HELD: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
447+
}
448+
449+
// How many times this thread has handed the shared slot back, so a test can say
450+
// when the release happened rather than only that the count moved. Per thread
451+
// because the release is, which keeps it clear of every other test taking and
452+
// returning slots at the same time.
453+
thread_local! {
454+
static SLOTS_RELEASED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
455+
}
456+
457+
/// Held for as long as the server is alive, because the memory is held that
458+
/// long. Taken before the spawn and returned by `ServerProcess`'s `Drop`, which
459+
/// already owns reaping the child, so a panicking test releases it by unwinding
460+
/// rather than leaving the suite one slot poorer for the rest of the run.
461+
struct ServerSlot {
462+
/// Whether this one took the shared slot, or joined the one its thread
463+
/// already holds.
464+
owns: bool,
465+
}
466+
467+
impl ServerSlot {
468+
fn take() -> Self {
469+
let owns = SERVERS_HELD.with(|held| {
470+
let depth = held.get();
471+
held.set(depth + 1);
472+
depth == 0
473+
});
474+
if owns {
475+
let (free, released) = server_slots();
476+
let mut free = free.lock().unwrap_or_else(|error| error.into_inner());
477+
while *free == 0 {
478+
free = released
479+
.wait(free)
480+
.unwrap_or_else(|error| error.into_inner());
481+
}
482+
*free -= 1;
483+
}
484+
Self { owns }
485+
}
486+
}
487+
488+
impl Drop for ServerSlot {
489+
fn drop(&mut self) {
490+
// Released when the thread's last server goes, not when the one that
491+
// took the slot goes. A test holding two and dropping the first-created
492+
// one first would otherwise hand the slot back while its second server
493+
// is still running, and the cap this exists to hold would be one short.
494+
let last = SERVERS_HELD.with(|held| {
495+
let depth = held.get().saturating_sub(1);
496+
held.set(depth);
497+
depth == 0
498+
});
499+
if !last {
500+
return;
501+
}
502+
SLOTS_RELEASED.with(|count| count.set(count.get() + 1));
503+
let (free, released) = server_slots();
504+
*free.lock().unwrap_or_else(|error| error.into_inner()) += 1;
505+
released.notify_one();
506+
}
507+
}
508+
509+
/// Asserted on the flags rather than on the free count, because every other
510+
/// test in this file is taking and returning slots while this one runs and the
511+
/// count is theirs too.
512+
/// Drop order is the test author's, not this file's: a test can stop one
513+
/// server and drop it before the one it started afterwards. The slot belongs to
514+
/// the thread for as long as any of its servers is alive, so the release has to
515+
/// wait for the last of them rather than for the one that took it.
516+
#[test]
517+
fn the_slot_outlives_a_server_dropped_out_of_order() {
518+
let before = SLOTS_RELEASED.with(|count| count.get());
519+
let first = ServerSlot::take();
520+
let second = ServerSlot::take();
521+
assert!(first.owns);
522+
assert!(!second.owns);
523+
524+
drop(first);
525+
assert_eq!(
526+
SLOTS_RELEASED.with(|count| count.get()),
527+
before,
528+
"the slot went back while this thread still held a server"
529+
);
530+
531+
drop(second);
532+
assert_eq!(
533+
SLOTS_RELEASED.with(|count| count.get()),
534+
before + 1,
535+
"the last server went and the slot did not go back"
536+
);
537+
}
538+
539+
#[test]
540+
fn a_thread_running_two_servers_pays_for_one_slot() {
541+
let outer = ServerSlot::take();
542+
let inner = ServerSlot::take();
543+
assert!(
544+
outer.owns,
545+
"the first server on a thread takes the shared slot"
546+
);
547+
assert!(
548+
!inner.owns,
549+
"a second server on the same thread joins that slot rather than waiting \
550+
for one the thread it is waiting on cannot give back"
551+
);
552+
drop(inner);
553+
drop(outer);
554+
assert_eq!(
555+
SERVERS_HELD.with(|held| held.get()),
556+
0,
557+
"both are given back"
558+
);
559+
}
560+
329561
/// Kills the spawned server on drop. Without this a failing assertion unwinds
330562
/// past `stop_child` and leaves a `codetrial web` process listening for the
331563
/// rest of the machine's uptime; several accumulated during development.
332-
struct ServerProcess(Child);
564+
struct ServerProcess {
565+
child: Child,
566+
/// Never read. It is here so the slot is given back when the server it
567+
/// stands for is reaped, and not one statement sooner.
568+
_slot: ServerSlot,
569+
}
333570

334571
impl ServerProcess {
335572
/// stderr is piped here rather than left to each caller, because
@@ -341,32 +578,36 @@ impl ServerProcess {
341578
/// `binary_web_does_not_require_github_oauth_config` did: the one spawning
342579
/// test that did not pipe was the one whose retry never fired.
343580
fn spawn(command: &mut Command) -> Self {
344-
Self(
345-
command
581+
// The slot is taken before the spawn, not after, or the bound would be
582+
// on servers this suite admits to rather than on servers running.
583+
let slot = ServerSlot::take();
584+
Self {
585+
child: command
346586
.stderr(Stdio::piped())
347587
.spawn()
348588
.expect("codetrial web should start"),
349-
)
589+
_slot: slot,
590+
}
350591
}
351592
}
352593

353594
impl std::ops::Deref for ServerProcess {
354595
type Target = Child;
355596
fn deref(&self) -> &Child {
356-
&self.0
597+
&self.child
357598
}
358599
}
359600

360601
impl std::ops::DerefMut for ServerProcess {
361602
fn deref_mut(&mut self) -> &mut Child {
362-
&mut self.0
603+
&mut self.child
363604
}
364605
}
365606

366607
impl Drop for ServerProcess {
367608
fn drop(&mut self) {
368-
let _ = self.0.kill();
369-
let _ = self.0.wait();
609+
let _ = self.child.kill();
610+
let _ = self.child.wait();
370611
}
371612
}
372613

0 commit comments

Comments
 (0)