Skip to content

Implement shell command execution with PATH search for /bin/sh#892

Merged
dburkart merged 3 commits into
mainfrom
m880-command-execution
May 5, 2026
Merged

Implement shell command execution with PATH search for /bin/sh#892
dburkart merged 3 commits into
mainfrom
m880-command-execution

Conversation

@dburkart
Copy link
Copy Markdown
Owner

@dburkart dburkart commented May 5, 2026

Closes #880

Summary

  • Add base/sh/src/exec.rs — the core shell executor that takes the parser AST and runs commands
  • Implement simple command dispatch: variable expansion, field splitting, globbing, then builtin or fork+execve
  • Implement $PATH search via access(X_OK) on each candidate path
  • Implement pipeline execution with pipe/fork/dup2/wait for multi-stage pipelines
  • Implement list execution with short-circuit evaluation for && / || and sequential ;
  • Implement subshell execution via fork+wait
  • Track and propagate exit status ($?) correctly, including the EXIT_REQUESTED sentinel for the exit builtin
  • Handle command-not-found errors (diagnostic to stderr, exit status 127)
  • Update main.rs to wire the executor into a functional shell with stdin line-reading and sh -c support
  • Add 27 host unit tests covering all executor paths

Test plan

  • cargo test in base/sh/ — 366 tests pass (including 27 new exec tests)
  • cargo xtask build — kernel + userspace builds clean
  • cargo xtask test — all host unit tests pass; only pre-existing rwlock_concurrent_readers QEMU flake

Add exec.rs — the core shell executor that bridges the parser AST to
runtime behavior. The executor handles:

- Simple command dispatch: expand words (variables, field splitting,
  globs), check for builtins, otherwise fork+execve external commands
- PATH search: iterate $PATH directories, access(X_OK) each candidate,
  exec the first executable match
- Pipeline execution: create pipes between stages, fork each stage,
  wire stdin/stdout via dup2, wait for all children
- List execution: sequential (;), AND (&&), OR (||) with short-circuit
  evaluation per POSIX
- Subshell execution: fork, execute list in child, wait
- Variable assignments: bare assignments update the environment
- Exit status ($?) propagation through pipelines and lists
- Command-not-found handling: diagnostic to stderr, exit status 127
- EXIT_REQUESTED sentinel preservation so `exit N` works correctly

Update main.rs to wire the executor into a functional shell with:
- stdin line-reading mode
- sh -c "command" support
- Environment variable import from the process environment

Includes 27 host unit tests covering all execution paths.

Closes #880

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2ec7269f-88d9-4976-a4f9-6e433f592ed6

📥 Commits

Reviewing files that changed from the base of the PR and between bd923a6 and 64b272f.

📒 Files selected for processing (1)
  • base/sh/src/main.rs
✅ Files skipped from review due to trivial changes (1)
  • base/sh/src/main.rs

📝 Walkthrough

Walkthrough

Adds a POSIX-style shell executor and CLI front-end: implements execute_list (lists, pipelines, subshells, builtins, external exec with PATH search and errno mapping), multi-stage pipeline wiring (fork/pipe/dup2/wait), many unit tests, and a main that seeds the environment and supports -c and stdin execution.

Changes

Shell Executor & Frontend

Layer / File(s) Summary
Execution entrypoint / control flow
base/sh/src/exec.rs
Adds pub fn execute_list(list: &List, env: &mut Environment) -> i32 and implements top-level list operator semantics (;, &&, `
Pipeline dispatch
base/sh/src/exec.rs
Adds execute_pipeline and execute_pipeline_multi to run single-stage and multi-stage pipelines; multi-stage implementation forks stages, creates pipes, dup2s fds, and waits for children (test builds stubbed).
Command dispatch
base/sh/src/exec.rs
Adds execute_command dispatch for Simple and Subshell variants; execute_subshell forks, applies redirects in child, runs the subshell list, and returns child status (tests stub inline).
Simple-command handling
base/sh/src/exec.rs
Implements word expansion (variables, splitting, globbing), assignment-only commands (update env), builtin detection and in-process invocation with temporary pre-assignment and redirection save/restore.
External execution & PATH search
base/sh/src/exec.rs
Adds execute_external and resolve_command (search $PATH, default /bin:/usr/bin), forks and execve's with built argv/envp, maps errno to exit codes (127/126) and prints diagnostics; is_executable and errno_message helpers included (syscall behavior stubbed in tests).
Helpers & restore logic
base/sh/src/exec.rs
Adds restore_vars and other internal helpers for temporary variable restoration, PATH lookup, and errno-to-message mapping.
Tests
base/sh/src/exec.rs (tests)
Adds extensive unit tests for builtins, assignment/expansion, command-not-found codes, list operator semantics, subshells, PATH resolution, and EXIT_REQUESTED behavior.
Shell front-end / CLI
base/sh/src/main.rs
Replaces demo main with shell frontend: initialize Environment (seed PATH, shell_pid, arg0, import env vars), support -c <command> with optional positional setup, run stdin loop and run_input helper (parse errors printed as sh: {e} and set env.last_status = 2).
Manifest
Cargo.toml
Manifest lines changed (reported in summary).
sequenceDiagram
    actor User
    participant main as main.rs
    participant Parser
    participant Executor as exec.rs
    participant Builtin as builtins
    participant OS as Kernel/OS

    User->>main: provide input ("cmd1 | cmd2")
    main->>Parser: parse(input)
    Parser-->>main: AST List / Pipeline
    main->>Executor: execute_list(list, env)
    Executor->>Executor: execute_pipeline(pipeline)
    Executor->>OS: pipe(), fork() [stage 1]
    OS-->>Executor: child_pid_1
    Executor->>OS: fork() [stage 2]
    OS-->>Executor: child_pid_2
    Executor->>OS: dup2(pipe_fds) to wire stdin/stdout

    rect rgba(100,150,200,0.5)
        Note over OS,Builtin: Stage 1 may run builtin or exec external
        OS->>Builtin: run_builtin? (if builtin)
        Builtin-->>OS: produce output (to pipe)
    end

    rect rgba(150,100,200,0.5)
        Note over Executor,OS: Stage 2 resolves command via $PATH and execve
        Executor->>OS: resolve_command("cmd2")
        OS->>OS: execve(...)
    end

    Executor->>OS: waitpid(child_pid_1)
    Executor->>OS: waitpid(child_pid_2)
    OS-->>Executor: exit_status (last stage)
    Executor-->>main: return status
    main-->>User: exit code / output
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I fork, I pipe, I hop with glee,

Words expand like leaves on a tree,
PATHs I search, and builtins sing,
Exit codes tumble — what joy they bring,
A rabbit’s shell, snug as can be.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title directly describes the main implementation—shell command execution with PATH search—which is the core objective of the PR and matches the primary code changes.
Description check ✅ Passed The description comprehensively covers all major changes: executor implementation, command dispatch, PATH search, pipeline/list/subshell execution, status tracking, error handling, main.rs integration, and test results.
Linked Issues check ✅ Passed The PR implements all objectives from #880: simple command execution, $PATH search, pipeline/list/subshell execution, exit status tracking, command-not-found handling, and includes integration tests as required.
Out of Scope Changes check ✅ Passed All changes are in-scope: new executor module with command dispatch and pipeline logic, main.rs integration with stdin/sh -c support, and unit tests—all directly tied to #880 requirements.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch m880-command-execution

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
base/sh/src/exec.rs (1)

203-213: 💤 Low value

Consider handling signal termination in wait status extraction.

The current extraction assumes the child exited normally. Per POSIX, if the child was killed by a signal, the exit status should be 128 + signal_number. The same pattern appears at lines 266 and 521.

This is acceptable for initial implementation, but worth tracking.

♻️ Optional: Add signal handling
     for (i, &pid) in child_pids.iter().enumerate() {
         let mut wstatus: i32 = 0;
         unsafe {
             wait4(pid, &mut wstatus, 0, std::ptr::null());
         }
-        let exit_code = ((wstatus as u32) >> 8) & 0xFF;
+        let exit_code = if (wstatus & 0x7f) == 0 {
+            // Normal exit: WEXITSTATUS
+            ((wstatus as u32) >> 8) & 0xFF
+        } else {
+            // Killed by signal: 128 + signal_number
+            128 + (wstatus & 0x7f) as u32
+        };
         if i == child_pids.len() - 1 {
             last_status = exit_code as i32;
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@base/sh/src/exec.rs` around lines 203 - 213, The loop that reads wait4
statuses (the child_pids iteration) assumes normal exit by extracting exit_code
with a right shift; instead detect termination method using libc helpers (e.g.,
WIFEXITED, WEXITSTATUS, WIFSIGNALED, WTERMSIG) and set last_status to
WEXITSTATUS when WIFEXITED is true or to (128 + WTERMSIG) when WIFSIGNALED is
true; apply the same fix to the analogous wait/status extraction sites mentioned
(the similar blocks around the current code and at the other occurrences) so
signal-terminated children produce 128+signal_number rather than a bogus value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@base/sh/src/main.rs`:
- Around line 85-87: The code in run_input unconditionally assigns
env.last_status = status after calling execute_list, which clobbers the real
exit code set by the exit builtin (which returns EXIT_REQUESTED). Change the
assignment so env.last_status is only updated when status != EXIT_REQUESTED (or
equivalently preserve env.last_status if status == EXIT_REQUESTED); update the
block around execute_list / env.last_status in run_input to conditionally set
env.last_status to avoid overwriting the exit code written by exit.

---

Nitpick comments:
In `@base/sh/src/exec.rs`:
- Around line 203-213: The loop that reads wait4 statuses (the child_pids
iteration) assumes normal exit by extracting exit_code with a right shift;
instead detect termination method using libc helpers (e.g., WIFEXITED,
WEXITSTATUS, WIFSIGNALED, WTERMSIG) and set last_status to WEXITSTATUS when
WIFEXITED is true or to (128 + WTERMSIG) when WIFSIGNALED is true; apply the
same fix to the analogous wait/status extraction sites mentioned (the similar
blocks around the current code and at the other occurrences) so
signal-terminated children produce 128+signal_number rather than a bogus value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 01c28125-ff97-4181-a655-36154b9a9dcf

📥 Commits

Reviewing files that changed from the base of the PR and between c5246a2 and 8bbdbb2.

📒 Files selected for processing (2)
  • base/sh/src/exec.rs
  • base/sh/src/main.rs

Comment thread base/sh/src/main.rs
run_input() was unconditionally overwriting env.last_status with the
return value from execute_list(), which clobbers the real exit code
(e.g. 42) with the EXIT_REQUESTED sentinel (-1) when the exit builtin
is used. Guard the assignment so env.last_status is only updated for
non-sentinel return values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@base/sh/src/main.rs`:
- Around line 52-72: The stdin loop in main() currently returns 0 on EOF,
dropping the last command's status; after the loop ends (the for line in
stdin.lock().lines() loop that calls run_input and checks for EXIT_REQUESTED),
call std::process::exit(env.last_status) so the process exits with the last
command status instead of falling through to 0; ensure this happens only after
the loop completes (and not inside the Err(_) break), preserving existing
EXIT_REQUESTED behavior and use of env.last_status.
- Around line 39-40: When handling the result of run_input in main (the variable
status), detect the EXIT_REQUESTED sentinel and, if seen, exit using
env.last_status instead of the raw sentinel; otherwise exit(status) as before —
i.e., change the exit logic after run_input(input, &mut env) to mirror the stdin
branch: if status == EXIT_REQUESTED { std::process::exit(env.last_status as i32)
} else { std::process::exit(status) } (referencing run_input, EXIT_REQUESTED,
and env.last_status).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 04988c76-5b30-4290-ba28-f382848030a7

📥 Commits

Reviewing files that changed from the base of the PR and between 8bbdbb2 and bd923a6.

📒 Files selected for processing (1)
  • base/sh/src/main.rs

Comment thread base/sh/src/main.rs
Comment thread base/sh/src/main.rs
Address two CodeRabbit review comments:
1. In -c mode, if the command calls `exit`, the sentinel EXIT_REQUESTED
   was passed directly to std::process::exit instead of the actual exit
   code stored in env.last_status.
2. On stdin EOF the shell fell through to implicit exit(0), discarding
   the last command's exit status. Now exits with env.last_status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dburkart
Copy link
Copy Markdown
Owner Author

dburkart commented May 5, 2026

Addressed both remaining CodeRabbit review comments in 64b272f:

  1. EXIT_REQUESTED in -c mode: Now checks for the sentinel and exits with env.last_status instead of the raw sentinel value.
  2. Preserve final status on stdin EOF: Added std::process::exit(env.last_status) after the stdin loop so the shell exits with the last command's status instead of implicitly returning 0.

@dburkart dburkart merged commit f21c3f3 into main May 5, 2026
15 of 27 checks passed
@dburkart dburkart deleted the m880-command-execution branch May 5, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement shell command execution with PATH search for /bin/sh

2 participants