Skip to content

Commit 08d4a33

Browse files
authored
feat(interpreter): implement background execution with & and wait (#590)
## Summary - Background commands (`cmd &`) now properly set `$!` and track job state - `wait` builtin collects background job exit codes correctly - `wait $pid` waits for specific job by ID - Job table uses BTreeMap for ordered iteration - Added 7 integration tests Closes #559 ## Test plan - [x] 7 new integration tests (basic bg, VFS writes, multiple jobs, $!, wait PID, exit codes, mixed fg/bg) - [x] All 1928 existing tests pass - [x] Spec tests pass (background.test, wait.test) - [x] Clippy clean, formatting clean
1 parent 2d23678 commit 08d4a33

File tree

3 files changed

+305
-73
lines changed

3 files changed

+305
-73
lines changed

crates/bashkit/src/interpreter/jobs.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
//! Job table for background execution
22
//!
3-
//! Tracks background jobs and their status.
4-
//!
5-
//! Note: This module provides infrastructure for background job tracking.
6-
//! Currently, background commands run synchronously but the job table
7-
//! is available for future async execution support.
3+
//! Tracks background jobs spawned with `&` and their exit status.
4+
//! Background commands execute synchronously for deterministic output
5+
//! ordering, but their results are stored here so `wait` and `$!` work
6+
//! correctly.
87
98
#![allow(dead_code)]
109

11-
use std::collections::HashMap;
10+
use std::collections::BTreeMap;
1211
use std::sync::Arc;
1312
use tokio::sync::Mutex;
1413
use tokio::task::JoinHandle;
@@ -26,7 +25,7 @@ pub struct Job {
2625
/// Job table for tracking background jobs
2726
pub struct JobTable {
2827
/// Active jobs indexed by ID
29-
jobs: HashMap<usize, JoinHandle<ExecResult>>,
28+
jobs: BTreeMap<usize, JoinHandle<ExecResult>>,
3029
/// Next job ID to assign
3130
next_id: usize,
3231
/// Last spawned job ID (for $!)
@@ -43,7 +42,7 @@ impl JobTable {
4342
/// Create a new empty job table
4443
pub fn new() -> Self {
4544
Self {
46-
jobs: HashMap::new(),
45+
jobs: BTreeMap::new(),
4746
next_id: 1,
4847
last_job_id: None,
4948
}
@@ -84,7 +83,7 @@ impl JobTable {
8483
let mut last_exit_code = 0;
8584

8685
// Drain all jobs
87-
let jobs: Vec<_> = self.jobs.drain().collect();
86+
let jobs: Vec<_> = std::mem::take(&mut self.jobs).into_iter().collect();
8887

8988
for (_, handle) in jobs {
9089
match handle.await {
@@ -96,6 +95,19 @@ impl JobTable {
9695
last_exit_code
9796
}
9897

98+
/// Wait for all jobs and return their results (preserving output).
99+
pub async fn wait_all_results(&mut self) -> Vec<ExecResult> {
100+
let jobs: Vec<_> = std::mem::take(&mut self.jobs).into_iter().collect();
101+
let mut results = Vec::new();
102+
for (_, handle) in jobs {
103+
match handle.await {
104+
Ok(result) => results.push(result),
105+
Err(_) => results.push(ExecResult::err("job panicked".to_string(), 1)),
106+
}
107+
}
108+
results
109+
}
110+
99111
/// Check if there are any active jobs
100112
pub fn has_jobs(&self) -> bool {
101113
!self.jobs.is_empty()

crates/bashkit/src/interpreter/mod.rs

Lines changed: 182 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,8 @@ pub struct Interpreter {
271271
limits: ExecutionLimits,
272272
/// Execution counters for resource tracking
273273
counters: ExecutionCounters,
274-
/// Job table for background execution
275-
#[allow(dead_code)]
276-
jobs: JobTable,
274+
/// Job table for background execution (shared for wait builtin access)
275+
jobs: SharedJobTable,
277276
/// Shell options (set -e, set -x, etc.)
278277
options: ShellOptions,
279278
/// Current line number for $LINENO
@@ -545,7 +544,7 @@ impl Interpreter {
545544
call_stack: Vec::new(),
546545
limits: ExecutionLimits::default(),
547546
counters: ExecutionCounters::new(),
548-
jobs: JobTable::new(),
547+
jobs: jobs::new_shared_job_table(),
549548
options: ShellOptions::default(),
550549
current_line: 1,
551550
#[cfg(feature = "http_client")]
@@ -3404,42 +3403,104 @@ impl Interpreter {
34043403
Ok(last_result)
34053404
}
34063405

3406+
/// Check if a command is the empty sentinel produced by the parser for trailing `&`.
3407+
fn is_empty_sentinel(cmd: &Command) -> bool {
3408+
if let Command::Simple(sc) = cmd {
3409+
let name_is_empty = sc.name.parts.len() == 1
3410+
&& matches!(&sc.name.parts[0], WordPart::Literal(s) if s.is_empty());
3411+
name_is_empty
3412+
&& sc.args.is_empty()
3413+
&& sc.redirects.is_empty()
3414+
&& sc.assignments.is_empty()
3415+
} else {
3416+
false
3417+
}
3418+
}
3419+
3420+
/// Run a command as a "background" job.
3421+
///
3422+
/// Executes the command synchronously (deterministic in virtual env) but
3423+
/// stores the result in the job table so `wait` and `$!` work correctly.
3424+
/// The command's stdout is emitted immediately (like real bash terminal output).
3425+
async fn spawn_in_background(
3426+
&mut self,
3427+
cmd: &Command,
3428+
parent_stdout: &mut String,
3429+
parent_stderr: &mut String,
3430+
) -> Result<()> {
3431+
// Execute the command synchronously
3432+
let emit_before = self.output_emit_count;
3433+
let result = self.execute_command(cmd).await?;
3434+
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
3435+
3436+
// Emit output immediately (background output goes to terminal in real bash)
3437+
parent_stdout.push_str(&result.stdout);
3438+
parent_stderr.push_str(&result.stderr);
3439+
3440+
// Store only the exit code in the job table (output already emitted)
3441+
let exit_code = result.exit_code;
3442+
let job_result = ExecResult::with_code(String::new(), exit_code);
3443+
let handle = tokio::spawn(async move { job_result });
3444+
let job_id = self.jobs.lock().await.spawn(handle);
3445+
self.variables
3446+
.insert("_LAST_BG_PID".to_string(), job_id.to_string());
3447+
3448+
// Background commands always return exit code 0 to the parent
3449+
self.last_exit_code = 0;
3450+
// But store the real exit code for $? after wait
3451+
self.variables
3452+
.insert("_BG_EXIT_CODE".to_string(), exit_code.to_string());
3453+
Ok(())
3454+
}
3455+
34073456
/// Execute a command list (cmd1 && cmd2 || cmd3)
3457+
#[allow(unused_assignments)] // control_flow may be set but overwritten
34083458
async fn execute_list(&mut self, list: &CommandList) -> Result<ExecResult> {
34093459
let mut stdout = String::new();
34103460
let mut stderr = String::new();
34113461
let mut exit_code;
3412-
let emit_before = self.output_emit_count;
3413-
let result = self.execute_command(&list.first).await?;
3414-
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
3415-
stdout.push_str(&result.stdout);
3416-
stderr.push_str(&result.stderr);
3417-
exit_code = result.exit_code;
3418-
self.last_exit_code = exit_code;
3419-
let mut control_flow = result.control_flow;
3462+
let mut control_flow;
34203463

3421-
// If first command signaled control flow, return immediately
3422-
if control_flow != ControlFlow::None {
3423-
return Ok(ExecResult {
3424-
stdout,
3425-
stderr,
3426-
exit_code,
3427-
control_flow,
3428-
});
3429-
}
3464+
// Determine if the first command should run in the background.
3465+
// The `&` terminator for first appears as rest[0].op == Background.
3466+
let first_is_bg = matches!(list.rest.first(), Some((ListOperator::Background, _)));
34303467

3431-
// Check if first command in a semicolon-separated list failed => ERR trap
3432-
// Only fire if the first rest operator is semicolon (not &&/||)
3433-
let first_op_is_semicolon = list
3434-
.rest
3435-
.first()
3436-
.is_some_and(|(op, _)| matches!(op, ListOperator::Semicolon));
3437-
if exit_code != 0 && first_op_is_semicolon {
3438-
self.run_err_trap(&mut stdout, &mut stderr).await;
3468+
if first_is_bg {
3469+
self.spawn_in_background(&list.first, &mut stdout, &mut stderr)
3470+
.await?;
3471+
exit_code = 0;
3472+
control_flow = ControlFlow::None;
3473+
} else {
3474+
let emit_before = self.output_emit_count;
3475+
let result = self.execute_command(&list.first).await?;
3476+
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
3477+
stdout.push_str(&result.stdout);
3478+
stderr.push_str(&result.stderr);
3479+
exit_code = result.exit_code;
3480+
self.last_exit_code = exit_code;
3481+
control_flow = result.control_flow;
3482+
3483+
// If first command signaled control flow, return immediately
3484+
if control_flow != ControlFlow::None {
3485+
return Ok(ExecResult {
3486+
stdout,
3487+
stderr,
3488+
exit_code,
3489+
control_flow,
3490+
});
3491+
}
3492+
3493+
// Check if first command in a semicolon-separated list failed => ERR trap
3494+
let first_op_is_semicolon = list
3495+
.rest
3496+
.first()
3497+
.is_some_and(|(op, _)| matches!(op, ListOperator::Semicolon));
3498+
if exit_code != 0 && first_op_is_semicolon {
3499+
self.run_err_trap(&mut stdout, &mut stderr).await;
3500+
}
34393501
}
34403502

34413503
// Track if the list contains any && or || operators
3442-
// If so, failures within the list are "handled" by those operators
34433504
let has_conditional_operators = list
34443505
.rest
34453506
.iter()
@@ -3449,17 +3510,27 @@ impl Interpreter {
34493510
let mut just_exited_conditional_chain = false;
34503511

34513512
for (i, (op, cmd)) in list.rest.iter().enumerate() {
3513+
// Skip empty sentinel commands (produced by trailing `&`)
3514+
if Self::is_empty_sentinel(cmd) {
3515+
continue;
3516+
}
3517+
34523518
// Check if next operator (if any) is && or ||
34533519
let next_op = list.rest.get(i + 1).map(|(op, _)| op);
34543520
let current_is_conditional = matches!(op, ListOperator::And | ListOperator::Or);
34553521
let next_is_conditional =
34563522
matches!(next_op, Some(ListOperator::And) | Some(ListOperator::Or));
34573523

3524+
// Determine if THIS command should be backgrounded.
3525+
// A command is backgrounded when the NEXT separator is Background
3526+
// (the `&` terminates the current command).
3527+
let should_background =
3528+
matches!(list.rest.get(i + 1), Some((ListOperator::Background, _)));
3529+
34583530
// Check errexit before executing if:
34593531
// - We just exited a conditional chain (and current op is semicolon)
34603532
// - OR: current op is semicolon and previous wasn't in a conditional chain
34613533
// - Exit code is non-zero
3462-
// But NOT if we're about to enter/continue a conditional chain
34633534
let should_check_errexit = matches!(op, ListOperator::Semicolon)
34643535
&& !just_exited_conditional_chain
34653536
&& self.is_errexit_enabled()
@@ -3477,55 +3548,51 @@ impl Interpreter {
34773548
// Reset the flag
34783549
just_exited_conditional_chain = false;
34793550

3480-
// Mark that we're exiting a conditional chain if:
3481-
// - Current is conditional (&&/||) and next is not conditional (;/end)
3551+
// Mark that we're exiting a conditional chain
34823552
if current_is_conditional && !next_is_conditional {
34833553
just_exited_conditional_chain = true;
34843554
}
34853555

34863556
let should_execute = match op {
34873557
ListOperator::And => exit_code == 0,
34883558
ListOperator::Or => exit_code != 0,
3489-
ListOperator::Semicolon => true,
3490-
ListOperator::Background => {
3491-
// Background (&) runs command synchronously in virtual mode.
3492-
// True process backgrounding requires OS process spawning which
3493-
// is excluded from the sandboxed virtual environment by design.
3494-
true
3495-
}
3559+
ListOperator::Semicolon | ListOperator::Background => true,
34963560
};
34973561

34983562
if should_execute {
3499-
let emit_before = self.output_emit_count;
3500-
let result = self.execute_command(cmd).await?;
3501-
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
3502-
stdout.push_str(&result.stdout);
3503-
stderr.push_str(&result.stderr);
3504-
exit_code = result.exit_code;
3505-
self.last_exit_code = exit_code;
3506-
control_flow = result.control_flow;
3507-
3508-
// If command signaled control flow, return immediately
3509-
if control_flow != ControlFlow::None {
3510-
return Ok(ExecResult {
3511-
stdout,
3512-
stderr,
3513-
exit_code,
3514-
control_flow,
3515-
});
3516-
}
3563+
if should_background {
3564+
self.spawn_in_background(cmd, &mut stdout, &mut stderr)
3565+
.await?;
3566+
exit_code = 0;
3567+
} else {
3568+
let emit_before = self.output_emit_count;
3569+
let result = self.execute_command(cmd).await?;
3570+
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
3571+
stdout.push_str(&result.stdout);
3572+
stderr.push_str(&result.stderr);
3573+
exit_code = result.exit_code;
3574+
self.last_exit_code = exit_code;
3575+
control_flow = result.control_flow;
3576+
3577+
// If command signaled control flow, return immediately
3578+
if control_flow != ControlFlow::None {
3579+
return Ok(ExecResult {
3580+
stdout,
3581+
stderr,
3582+
exit_code,
3583+
control_flow,
3584+
});
3585+
}
35173586

3518-
// ERR trap: fire on non-zero exit after semicolon commands (not &&/||)
3519-
if exit_code != 0 && !current_is_conditional {
3520-
self.run_err_trap(&mut stdout, &mut stderr).await;
3587+
// ERR trap: fire on non-zero exit after semicolon commands
3588+
if exit_code != 0 && !current_is_conditional {
3589+
self.run_err_trap(&mut stdout, &mut stderr).await;
3590+
}
35213591
}
35223592
}
35233593
}
35243594

35253595
// Final errexit check for the last command
3526-
// Don't check if:
3527-
// - The list had conditional operators (failures are "handled" by && / ||)
3528-
// - OR we're in/just exited a conditional chain
35293596
let should_final_errexit_check =
35303597
!has_conditional_operators && self.is_errexit_enabled() && exit_code != 0;
35313598

@@ -4034,6 +4101,11 @@ impl Interpreter {
40344101
return self.execute_caller_builtin(&args, &command.redirects).await;
40354102
}
40364103

4104+
// Handle `wait` - needs direct access to job table
4105+
if name == "wait" {
4106+
return self.execute_wait_builtin(&args, &command.redirects).await;
4107+
}
4108+
40374109
// Handle `mapfile`/`readarray` - needs direct access to arrays
40384110
if name == "mapfile" || name == "readarray" {
40394111
return self.execute_mapfile(&args, stdin.as_deref()).await;
@@ -4821,6 +4893,52 @@ impl Interpreter {
48214893
self.apply_redirections(result, redirects).await
48224894
}
48234895

4896+
/// Execute the `wait` builtin with direct access to the job table.
4897+
///
4898+
/// Merges background job stdout/stderr into the result so callers
4899+
/// see the output produced by waited-for jobs.
4900+
async fn execute_wait_builtin(
4901+
&mut self,
4902+
args: &[String],
4903+
redirects: &[Redirect],
4904+
) -> Result<ExecResult> {
4905+
let mut last_exit_code = 0i32;
4906+
let mut stdout = String::new();
4907+
let mut stderr = String::new();
4908+
4909+
if args.is_empty() {
4910+
// Wait for all background jobs, collecting their output
4911+
let results = self.jobs.lock().await.wait_all_results().await;
4912+
for r in results {
4913+
stdout.push_str(&r.stdout);
4914+
stderr.push_str(&r.stderr);
4915+
last_exit_code = r.exit_code;
4916+
}
4917+
} else {
4918+
// Wait for specific job IDs
4919+
for arg in args {
4920+
if let Ok(job_id) = arg.parse::<usize>()
4921+
&& let Some(r) = self.jobs.lock().await.wait_for(job_id).await
4922+
{
4923+
stdout.push_str(&r.stdout);
4924+
stderr.push_str(&r.stderr);
4925+
last_exit_code = r.exit_code;
4926+
}
4927+
// If job not found or not parseable, that's ok
4928+
}
4929+
}
4930+
4931+
self.last_exit_code = last_exit_code;
4932+
let mut result = ExecResult {
4933+
stdout,
4934+
stderr,
4935+
exit_code: last_exit_code,
4936+
control_flow: ControlFlow::None,
4937+
};
4938+
result = self.apply_redirections(result, redirects).await?;
4939+
Ok(result)
4940+
}
4941+
48244942
/// Execute the `alias` builtin. Needs direct access to self.aliases.
48254943
///
48264944
/// Usage:

0 commit comments

Comments
 (0)