Skip to content

Commit 5122966

Browse files
bartlomiejuclaude
andauthored
test: add timeout support to spec tests (#32292)
## Summary - Adds a `timeout` field (in seconds) to spec test `__test__.jsonc` files - When a test step's process exceeds the timeout, it is killed and the test fails with a "Test command timed out" panic - Timeout can be set at the multi-test level (propagates to all contained tests) or per individual test - No default timeout — the watchdog thread is only spawned when `timeout` is explicitly configured - Reduces CI test job timeout from 240 minutes to 30 minutes Example usage in `__test__.jsonc`: ```jsonc { "timeout": 60, "args": "run long_running.ts", "output": "expected.out" } ``` --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 578cb26 commit 5122966

File tree

6 files changed

+97
-13
lines changed

6 files changed

+97
-13
lines changed

.github/workflows/ci.generate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,7 @@ const buildJobs = buildItems.map((rawBuildItem) => {
989989
`test ${testMatrix.test_crate} ${testMatrix.shard_label}${buildItem.profile} ${buildItem.os}-${buildItem.arch}`,
990990
needs: [buildJob],
991991
runsOn: buildItem.testRunner ?? buildItem.runner,
992-
timeoutMinutes: 240,
992+
timeoutMinutes: 30,
993993
defaults,
994994
env,
995995
strategy: {

.github/workflows/ci.yml

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ jobs:
352352
needs:
353353
- build-debug-macos-x86_64
354354
runs-on: macos-15-intel
355-
timeout-minutes: 240
355+
timeout-minutes: 30
356356
defaults:
357357
run:
358358
shell: bash
@@ -766,7 +766,7 @@ jobs:
766766
needs:
767767
- build-release-macos-x86_64
768768
runs-on: '${{ !contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'' && ''ubuntu-24.04'' || ''macos-15-intel'' }}'
769-
timeout-minutes: 240
769+
timeout-minutes: 30
770770
defaults:
771771
run:
772772
shell: bash
@@ -1062,7 +1062,7 @@ jobs:
10621062
needs:
10631063
- build-debug-macos-aarch64
10641064
runs-on: macos-14
1065-
timeout-minutes: 240
1065+
timeout-minutes: 30
10661066
defaults:
10671067
run:
10681068
shell: bash
@@ -1613,7 +1613,7 @@ jobs:
16131613
needs:
16141614
- build-release-macos-aarch64
16151615
runs-on: '${{ !contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'' && ''ubuntu-24.04'' || ''macos-14'' }}'
1616-
timeout-minutes: 240
1616+
timeout-minutes: 30
16171617
defaults:
16181618
run:
16191619
shell: bash
@@ -1906,7 +1906,7 @@ jobs:
19061906
needs:
19071907
- build-debug-windows-x86_64
19081908
runs-on: windows-2022
1909-
timeout-minutes: 240
1909+
timeout-minutes: 30
19101910
defaults:
19111911
run:
19121912
shell: bash
@@ -2429,7 +2429,7 @@ jobs:
24292429
needs:
24302430
- build-release-windows-x86_64
24312431
runs-on: '${{ !contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'' && ''ubuntu-24.04'' || ''windows-2022'' }}'
2432-
timeout-minutes: 240
2432+
timeout-minutes: 30
24332433
defaults:
24342434
run:
24352435
shell: bash
@@ -2699,7 +2699,7 @@ jobs:
26992699
needs:
27002700
- build-debug-windows-aarch64
27012701
runs-on: windows-11-arm
2702-
timeout-minutes: 240
2702+
timeout-minutes: 30
27032703
defaults:
27042704
run:
27052705
shell: bash
@@ -3127,7 +3127,7 @@ jobs:
31273127
needs:
31283128
- build-release-windows-aarch64
31293129
runs-on: '${{ !contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'' && ''ubuntu-24.04'' || ''windows-11-arm'' }}'
3130-
timeout-minutes: 240
3130+
timeout-minutes: 30
31313131
defaults:
31323132
run:
31333133
shell: bash
@@ -3581,7 +3581,7 @@ jobs:
35813581
needs:
35823582
- build-release-linux-x86_64
35833583
runs-on: ubuntu-24.04
3584-
timeout-minutes: 240
3584+
timeout-minutes: 30
35853585
defaults:
35863586
run:
35873587
shell: bash
@@ -4161,7 +4161,7 @@ jobs:
41614161
needs:
41624162
- build-debug-linux-x86_64
41634163
runs-on: ubuntu-24.04
4164-
timeout-minutes: 240
4164+
timeout-minutes: 30
41654165
defaults:
41664166
run:
41674167
shell: bash
@@ -4637,7 +4637,7 @@ jobs:
46374637
needs:
46384638
- build-debug-linux-aarch64
46394639
runs-on: ubuntu-24.04-arm
4640-
timeout-minutes: 240
4640+
timeout-minutes: 30
46414641
defaults:
46424642
run:
46434643
shell: bash
@@ -5214,7 +5214,7 @@ jobs:
52145214
needs:
52155215
- build-release-linux-aarch64
52165216
runs-on: '${{ !contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'' && ''ubuntu-24.04'' || ''ubuntu-24.04-arm'' }}'
5217-
timeout-minutes: 240
5217+
timeout-minutes: 30
52185218
defaults:
52195219
run:
52205220
shell: bash

tests/specs/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ struct MultiTestMetaData {
6262
pub ignore: bool,
6363
#[serde(default)]
6464
pub variants: BTreeMap<String, JsonMap>,
65+
/// Timeout in seconds for each step. Defaults to 300 (5 minutes).
66+
#[serde(default)]
67+
pub timeout: Option<u64>,
6568
}
6669

6770
impl MultiTestMetaData {
@@ -96,6 +99,11 @@ impl MultiTestMetaData {
9699
}
97100
}
98101
}
102+
if let Some(timeout) = multi_test_meta_data.timeout
103+
&& !value.contains_key("timeout")
104+
{
105+
value.insert("timeout".to_string(), timeout.into());
106+
}
99107
if multi_test_meta_data.ignore && !value.contains_key("ignore") {
100108
value.insert("ignore".to_string(), true.into());
101109
}
@@ -179,6 +187,9 @@ struct MultiStepMetaData {
179187
pub steps: Vec<StepMetaData>,
180188
#[serde(default)]
181189
pub ignore: bool,
190+
/// Timeout in seconds for each step. Defaults to 300 (5 minutes).
191+
#[serde(default)]
192+
pub timeout: Option<u64>,
182193
#[serde(default)]
183194
pub variants: BTreeMap<String, JsonMap>,
184195
}
@@ -203,6 +214,9 @@ struct SingleTestMetaData {
203214
#[allow(dead_code)]
204215
#[serde(default)]
205216
pub variants: BTreeMap<String, JsonMap>,
217+
/// Timeout in seconds for each step. Defaults to 300 (5 minutes).
218+
#[serde(default)]
219+
pub timeout: Option<u64>,
206220
}
207221

208222
impl SingleTestMetaData {
@@ -220,6 +234,7 @@ impl SingleTestMetaData {
220234
steps: vec![self.step],
221235
ignore: self.ignore,
222236
variants: self.variants,
237+
timeout: self.timeout,
223238
}
224239
}
225240
}
@@ -584,6 +599,10 @@ fn run_step(
584599
}
585600
None => command,
586601
};
602+
let command = match metadata.timeout {
603+
Some(secs) => command.timeout(std::time::Duration::from_secs(secs)),
604+
None => command,
605+
};
587606
let output = command.run();
588607

589608
let step_output = {

tests/specs/schema.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@
111111
"repeat": {
112112
"type": "number"
113113
},
114+
"timeout": {
115+
"type": "number",
116+
"description": "Timeout in seconds for each step. Defaults to 300 (5 minutes)."
117+
},
114118
"steps": {
115119
"type": "array",
116120
"items": {
@@ -154,6 +158,10 @@
154158
},
155159
"repeat": {
156160
"type": "number"
161+
},
162+
"timeout": {
163+
"type": "number",
164+
"description": "Timeout in seconds for each step. Defaults to 300 (5 minutes)."
157165
}
158166
}
159167
}, {
@@ -179,6 +187,10 @@
179187
"repeat": {
180188
"type": "number"
181189
},
190+
"timeout": {
191+
"type": "number",
192+
"description": "Timeout in seconds for each step. Defaults to 300 (5 minutes)."
193+
},
182194
"tests": {
183195
"type": "object",
184196
"additionalProperties": {

tests/specs/task/signals/__test__.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"timeout": 60,
23
// signals don't really exist on windows
34
"if": "unix",
45
// this runs a deno task

tests/util/lib/builders.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,20 @@ impl TestContext {
383383
}
384384
}
385385

386+
fn kill_process(pid: u32) {
387+
#[cfg(unix)]
388+
// SAFETY: We're sending SIGKILL to a process we spawned.
389+
unsafe {
390+
libc::kill(pid as i32, libc::SIGKILL);
391+
}
392+
#[cfg(not(unix))]
393+
{
394+
let _ = std::process::Command::new("taskkill")
395+
.args(["/F", "/T", "/PID", &pid.to_string()])
396+
.output();
397+
}
398+
}
399+
386400
fn curl_fetch(url: &str) -> Vec<u8> {
387401
let output = std::process::Command::new("curl")
388402
.args(["--fail", "--silent", "--show-error", "--location", url])
@@ -447,6 +461,7 @@ pub struct TestCommandBuilder {
447461
args_vec: Vec<String>,
448462
split_output: bool,
449463
show_output: bool,
464+
timeout: Option<std::time::Duration>,
450465
}
451466

452467
impl TestCommandBuilder {
@@ -467,6 +482,7 @@ impl TestCommandBuilder {
467482
args_text: "".to_string(),
468483
args_vec: Default::default(),
469484
show_output: false,
485+
timeout: None,
470486
}
471487
}
472488

@@ -553,6 +569,11 @@ impl TestCommandBuilder {
553569
self
554570
}
555571

572+
pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
573+
self.timeout = Some(timeout);
574+
self
575+
}
576+
556577
pub fn stdin<T: Into<Stdio>>(mut self, cfg: T) -> Self {
557578
self.stdin = Some(StdioContainer::new(cfg.into()));
558579
self
@@ -753,11 +774,42 @@ impl TestCommandBuilder {
753774
// and dropping it closes them.
754775
drop(command);
755776

777+
// Set up a watchdog thread that kills the process if it exceeds the timeout.
778+
// Uses a channel: dropping the sender signals the watchdog to stop. If
779+
// recv_timeout hits the deadline first, the process is killed.
780+
let timeout_handle = self.timeout.map(|timeout| {
781+
let (cancel_tx, cancel_rx) = std::sync::mpsc::channel::<()>();
782+
let pid = process.id();
783+
let handle =
784+
spawn_thread(move || match cancel_rx.recv_timeout(timeout) {
785+
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
786+
false
787+
}
788+
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
789+
eprintln!(
790+
"Test command timed out after {:?}, killing pid {}",
791+
timeout, pid
792+
);
793+
kill_process(pid);
794+
true
795+
}
796+
});
797+
(cancel_tx, handle)
798+
});
799+
756800
let combined = combined_reader.map(|pipe| {
757801
sanitize_output(read_pipe_to_string(pipe, self.show_output), &args)
758802
});
759803

760804
let status = process.wait().unwrap();
805+
// Drop the sender to cancel the watchdog, then check if it timed out
806+
if let Some((cancel_tx, handle)) = timeout_handle {
807+
drop(cancel_tx);
808+
let timed_out = handle.join().unwrap_or(true);
809+
if timed_out {
810+
panic!("Test command timed out");
811+
}
812+
}
761813
let std_out_err = std_out_err_handle.map(|(stdout, stderr)| {
762814
(
763815
sanitize_output(stdout.join().unwrap(), &args),

0 commit comments

Comments
 (0)