fix(exec): forward signals to child and exit silently on failure#339
fix(exec): forward signals to child and exit silently on failure#339
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request refines the error handling for subprocess execution within the Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
On Unix, replace the fnox process with the child via exec(2) so there's no wrapper process. This ensures signals are delivered directly to the child and removes the noisy "Command exited with status" error. On non-Unix, fall back to spawning and silently exiting with the child's exit code. Also removes the now-unused `CommandExitFailed` error variant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2e4cdbe to
26a1f1d
Compare
There was a problem hiding this comment.
Code Review
This pull request aims to silence the error message when a subprocess executed via fnox exec fails, and instead exit with the subprocess's exit code. However, the current implementation uses std::process::exit directly, which bypasses Rust's cleanup mechanisms (destructors). This introduces a critical security vulnerability where temporary files containing sensitive secrets are not deleted from the disk upon failure. A detailed review comment provides an explanation of this issue and recommends propagating the exit code to the main function for a clean shutdown, ensuring proper resource cleanup.
| // Persist temp files so they survive exec (drop would delete them) | ||
| for tf in _temp_files { | ||
| tf.into_temp_path().keep().ok(); | ||
| } | ||
| let err = cmd.exec(); | ||
| return Err(FnoxError::CommandExecutionFailed { |
There was a problem hiding this comment.
The use of std::process::exit here prevents the execution of Rust's Drop implementation for local variables, specifically the _temp_files vector. This means temporary files containing sensitive secrets will not be deleted from the disk, leading to a potential security vulnerability where sensitive information could be leaked. A safer approach is to propagate the exit code to main for a clean shutdown, ensuring proper resource cleanup. Alternatively, ensure _temp_files is explicitly dropped before exiting.
| // Persist temp files so they survive exec (drop would delete them) | |
| for tf in _temp_files { | |
| tf.into_temp_path().keep().ok(); | |
| } | |
| let err = cmd.exec(); | |
| return Err(FnoxError::CommandExecutionFailed { | |
| if !status.success() { | |
| // Exit silently with the subprocess's exit code — the child | |
| // already printed its own error, so an additional fnox error | |
| // message would just be noise. | |
| let code = status.code().unwrap_or(1); | |
| drop(_temp_files); | |
| std::process::exit(code); | |
| } |
Greptile SummaryThis PR changes Note: Despite the PR title and description claiming the use of Key issues found:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant fnox as fnox (parent)
participant OS as Terminal / OS
participant Child as Child Process
fnox->>Child: spawn()
fnox->>fnox: register SIGINT/SIGTERM handlers (Unix)
User->>OS: Ctrl-C
OS->>fnox: SIGINT (process group broadcast)
OS->>Child: SIGINT (process group broadcast) ← first delivery
fnox->>Child: kill(child_pid, SIGINT) ← second delivery (bug)
Child-->>fnox: exits (wait() returns)
fnox->>fnox: drop(_temp_files)
alt child succeeded
fnox-->>User: Ok(())
else child failed
fnox-->>User: process::exit(code)
end
Last reviewed commit: faea52c |
|
bugbot run |
Replace exec(2) with spawn+wait so destructors run and as_file temp files are cleaned up. Forward SIGINT/SIGTERM to the child process so Ctrl-C reaches it. Exit silently with the child's exit code (128+signal for signal deaths) to avoid noisy fnox error messages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
26f32c3 to
faea52c
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
| // Forward SIGINT/SIGTERM to the child so Ctrl-C and `kill` reach it. | ||
| #[cfg(unix)] | ||
| { | ||
| let child_pid = nix::unistd::Pid::from_raw(child.id() as i32); | ||
| unsafe { | ||
| // Ignore signals in the parent — the child handles them. | ||
| // When the child exits we propagate its exit code below. | ||
| signal_hook::low_level::register(signal_hook::consts::SIGINT, move || { | ||
| nix::sys::signal::kill(child_pid, nix::sys::signal::SIGINT).ok(); | ||
| }) | ||
| .ok(); | ||
| signal_hook::low_level::register(signal_hook::consts::SIGTERM, move || { | ||
| nix::sys::signal::kill(child_pid, nix::sys::signal::SIGTERM).ok(); | ||
| }) | ||
| .ok(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Double signal delivery to child process
By default, std::process::Command::spawn() leaves the child in the same process group as the parent. When the user presses Ctrl-C, the terminal sends SIGINT to the entire foreground process group — so both the parent and the child receive it simultaneously. The parent's signal handler then forwards the signal to the child a second time, causing the child to receive the signal twice.
For many programs this is harmless, but programs that trap SIGINT for graceful shutdown (e.g. Python REPLs, long-running daemons with custom handlers, or tools that treat a second SIGINT as a hard kill) may behave incorrectly.
The standard fix is to move the child into its own process group before the signal handlers are registered, so the terminal signal only reaches the parent, which then forwards it exactly once:
use std::os::unix::process::CommandExt;
// Inside the cfg(unix) block, before spawn:
cmd.process_group(0); // child gets its own process group (PGID = child PID)With process_group(0) set on cmd, the child is isolated from the terminal's broadcast, and the parent is the sole forwarder.
| let mut child = cmd.spawn().map_err(|e| FnoxError::CommandExecutionFailed { | ||
| command: self.command.join(" "), | ||
| source: e, | ||
| })?; | ||
|
|
||
| // Forward SIGINT/SIGTERM to the child so Ctrl-C and `kill` reach it. | ||
| #[cfg(unix)] | ||
| { | ||
| let child_pid = nix::unistd::Pid::from_raw(child.id() as i32); | ||
| unsafe { | ||
| // Ignore signals in the parent — the child handles them. | ||
| // When the child exits we propagate its exit code below. | ||
| signal_hook::low_level::register(signal_hook::consts::SIGINT, move || { | ||
| nix::sys::signal::kill(child_pid, nix::sys::signal::SIGINT).ok(); | ||
| }) | ||
| .ok(); | ||
| signal_hook::low_level::register(signal_hook::consts::SIGTERM, move || { | ||
| nix::sys::signal::kill(child_pid, nix::sys::signal::SIGTERM).ok(); | ||
| }) | ||
| .ok(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Race condition between spawn and signal handler registration
Signal handlers are registered after cmd.spawn() returns. There is a small but real window between the moment the child process starts and the moment the handlers are armed. If SIGINT or SIGTERM arrives during that window (e.g., rapid Ctrl-C), the default parent action (terminate the parent) fires before the forwarding handler is in place — the child is left running without its expected signal, and fnox exits without waiting for it.
Moving handler registration to before cmd.spawn() (using a AtomicBool / OnceLock to store the PID after spawn) or switching to signal_hook's process-group-based approach would close this window.
### 🚀 Features - **(cloudflare)** add Cloudflare API token lease backend by [@jdx](https://github.com/jdx) in [#335](#335) - **(fido2)** bump demand to v2, mask PIN during typing by [@jdx](https://github.com/jdx) in [#334](#334) - **(init)** add -f as short alias for --force by [@jdx](https://github.com/jdx) in [#329](#329) - **(lease)** add --all flag, default to creating all leases by [@jdx](https://github.com/jdx) in [#337](#337) - **(lease)** add GitHub App installation token lease backend by [@jdx](https://github.com/jdx) in [#342](#342) ### 🐛 Bug Fixes - **(config)** fix directory locations to follow XDG spec by [@jdx](https://github.com/jdx) in [#336](#336) - **(exec)** use unix exec and exit silently on subprocess failure by [@jdx](https://github.com/jdx) in [#339](#339) - **(fido2)** remove duplicate touch prompt by [@jdx](https://github.com/jdx) in [#332](#332) - **(set)** write to lowest-priority existing config file by [@jdx](https://github.com/jdx) in [#331](#331) - **(tui)** skip providers requiring interactive auth by [@jdx](https://github.com/jdx) in [#333](#333) ### 🛡️ Security - **(ci)** retry lint step to handle transient pkl fetch failures by [@jdx](https://github.com/jdx) in [#341](#341) - **(mcp)** add MCP server for secret-gated AI agent access by [@jdx](https://github.com/jdx) in [#343](#343) - add guide for fnox sync by [@jdx](https://github.com/jdx) in [#328](#328) ### 🔍 Other Changes - share Rust cache across CI jobs by [@jdx](https://github.com/jdx) in [#340](#340)
Summary
exec(2)withspawn+waitso Rust destructors run andas_filetemp files are properly cleaned upsignal-hook+nixso Ctrl-C andkillreach itsignal-hookandnix(signal, process features) as dependenciesTest plan
test/file_secrets.batspasses — temp files are cleaned up after execfnox exec -- sleep 60kills the child🤖 Generated with Claude Code
Note
Medium Risk
Changes process lifecycle and Unix signal handling for
fnox exec, which can affect termination semantics and exit codes across platforms.Overview
fnox execnow spawns the subprocess and, on Unix, forwardsSIGINT/SIGTERMto the child so Ctrl-C/killbehave as expected.On subprocess failure,
execno longer returns aFnoxError; it exits silently with the child’s exit status (including128+signalwhen terminated by a signal), and theCommandExitFailederror variant is removed.Adds
signal-hookandnix(signal/process features) dependencies and updatesCargo.lockaccordingly.Written by Cursor Bugbot for commit faea52c. This will update automatically on new commits. Configure here.