Skip to content

Commit eeaa235

Browse files
committed
feat(ie-sandbox): implement process spawning with IPC fd inheritance #12
- ChildHandle: wraps tokio::process::Child + IpcChannel with graceful shutdown (5s timeout → kill), is_alive check, Drop cleanup - spawn_child/spawn_child_with_exe: re-executes binary with --subprocess-kind arg and IE_IPC_FD env var, fd inheritance via CLOEXEC clearing - ProcessKind: as_str/parse for string conversion - child_network.rs: network process event loop handling FetchRequest, Ping, Shutdown via IPC - child_renderer.rs: renderer process stub (Ping/Shutdown only) - CLI: hidden --subprocess-kind arg, Mode::Subprocess variant - main.rs: subprocess entry point reconstructing IPC channel from fd - 9 integration tests: ping/pong, alive/shutdown, drop cleanup, fetch via IPC, error handling, sequential requests
1 parent dc6f1f1 commit eeaa235

12 files changed

Lines changed: 480 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,4 @@ async-trait = "0.1"
5757
chrono = { version = "0.4", features = ["serde"] }
5858
tempfile = "3"
5959
base64 = "0.22"
60+
libc = "0.2"

crates/ie-sandbox/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ tokio.workspace = true
1515
serde.workspace = true
1616
serde_json.workspace = true
1717
base64.workspace = true
18+
libc.workspace = true

crates/ie-sandbox/src/channel.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,28 @@ impl IpcChannel {
4242
))
4343
}
4444

45+
/// Create a pair suitable for process spawning: returns (parent_channel, child_raw_fd).
46+
/// The child fd is kept open (not CLOEXEC) so it survives fork+exec.
47+
#[cfg(unix)]
48+
pub fn pair_for_spawn() -> Result<(IpcChannel, std::os::unix::io::RawFd), IpcError> {
49+
use std::os::unix::io::IntoRawFd;
50+
let (std_a, std_b) = std::os::unix::net::UnixStream::pair()?;
51+
let child_fd = std_b.into_raw_fd();
52+
// Clear CLOEXEC on child fd so it survives exec
53+
unsafe {
54+
let flags = libc::fcntl(child_fd, libc::F_GETFD);
55+
libc::fcntl(child_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
56+
}
57+
std_a.set_nonblocking(true)?;
58+
let tok_a = tokio::net::UnixStream::from_std(std_a)?;
59+
let (a_read, a_write) = tok_a.into_split();
60+
let parent_channel = IpcChannel {
61+
reader: BufReader::new(a_read),
62+
writer: a_write,
63+
};
64+
Ok((parent_channel, child_fd))
65+
}
66+
4567
/// Reconstruct a channel from a raw file descriptor (for child processes).
4668
#[cfg(unix)]
4769
pub fn from_raw_fd(fd: std::os::unix::io::RawFd) -> Result<Self, IpcError> {

crates/ie-sandbox/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ pub mod process;
1919
pub use channel::IpcChannel;
2020
pub use error::IpcError;
2121
pub use message::IpcMessage;
22-
pub use process::{ProcessKind, spawn_child};
22+
pub use process::{ChildHandle, ProcessKind, spawn_child, spawn_child_with_exe};

crates/ie-sandbox/src/process.rs

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
use anyhow::Result;
1+
use std::time::Duration;
2+
3+
use tokio::process::Command;
4+
use tokio::time::timeout;
5+
6+
use crate::channel::IpcChannel;
7+
use crate::message::IpcMessage;
28

39
#[derive(Debug, Clone, Copy, PartialEq)]
410
pub enum ProcessKind {
@@ -7,6 +13,149 @@ pub enum ProcessKind {
713
Network,
814
}
915

10-
pub fn spawn_child(_kind: ProcessKind) -> Result<()> {
11-
todo!("Spawn sandboxed child process")
16+
impl ProcessKind {
17+
pub fn as_str(&self) -> &'static str {
18+
match self {
19+
ProcessKind::Browser => "browser",
20+
ProcessKind::Renderer => "renderer",
21+
ProcessKind::Network => "network",
22+
}
23+
}
24+
25+
pub fn parse(s: &str) -> Option<Self> {
26+
match s {
27+
"browser" => Some(Self::Browser),
28+
"renderer" => Some(Self::Renderer),
29+
"network" => Some(Self::Network),
30+
_ => None,
31+
}
32+
}
33+
}
34+
35+
pub struct ChildHandle {
36+
process: tokio::process::Child,
37+
channel: IpcChannel,
38+
kind: ProcessKind,
39+
}
40+
41+
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
42+
43+
impl ChildHandle {
44+
pub fn channel(&mut self) -> &mut IpcChannel {
45+
&mut self.channel
46+
}
47+
48+
pub fn kind(&self) -> ProcessKind {
49+
self.kind
50+
}
51+
52+
pub fn process_id(&self) -> u32 {
53+
self.process.id().unwrap_or(0)
54+
}
55+
56+
pub fn is_alive(&mut self) -> bool {
57+
matches!(self.process.try_wait(), Ok(None))
58+
}
59+
60+
/// Graceful shutdown: send Shutdown, wait, kill if timeout.
61+
pub async fn shutdown(&mut self) -> anyhow::Result<()> {
62+
let _ = self.channel.send(&IpcMessage::Shutdown).await;
63+
match timeout(SHUTDOWN_TIMEOUT, self.process.wait()).await {
64+
Ok(Ok(status)) => {
65+
tracing::info!("{:?} process exited with status: {}", self.kind, status);
66+
Ok(())
67+
}
68+
Ok(Err(e)) => {
69+
tracing::error!("{:?} process wait error: {e}", self.kind);
70+
Err(e.into())
71+
}
72+
Err(_) => {
73+
tracing::warn!(
74+
"{:?} process did not exit within {:?}, killing",
75+
self.kind,
76+
SHUTDOWN_TIMEOUT
77+
);
78+
self.process.kill().await?;
79+
Ok(())
80+
}
81+
}
82+
}
83+
84+
pub async fn kill(&mut self) -> anyhow::Result<()> {
85+
self.process.kill().await?;
86+
Ok(())
87+
}
88+
}
89+
90+
impl Drop for ChildHandle {
91+
fn drop(&mut self) {
92+
// Best-effort kill to avoid zombies
93+
let _ = self.process.start_kill();
94+
}
95+
}
96+
97+
/// Spawn a child process that communicates via IPC.
98+
/// Uses the current executable by default.
99+
#[cfg(unix)]
100+
pub async fn spawn_child(kind: ProcessKind) -> anyhow::Result<ChildHandle> {
101+
spawn_child_with_exe(kind, std::env::current_exe()?).await
102+
}
103+
104+
/// Spawn a child process using a specific executable path.
105+
#[cfg(unix)]
106+
pub async fn spawn_child_with_exe(
107+
kind: ProcessKind,
108+
exe_path: std::path::PathBuf,
109+
) -> anyhow::Result<ChildHandle> {
110+
let (parent_channel, child_fd) = IpcChannel::pair_for_spawn()?;
111+
112+
let mut cmd = Command::new(exe_path);
113+
cmd.arg("--subprocess-kind").arg(kind.as_str());
114+
cmd.env("IE_IPC_FD", child_fd.to_string());
115+
// Inherit stderr for child tracing output, null stdin/stdout
116+
cmd.stdin(std::process::Stdio::null());
117+
cmd.stdout(std::process::Stdio::null());
118+
cmd.stderr(std::process::Stdio::inherit());
119+
120+
// Ensure child fd survives exec (clear CLOEXEC was done in pair_for_spawn)
121+
// After spawn, close child fd in parent
122+
unsafe {
123+
cmd.pre_exec(move || {
124+
// The fd is already non-CLOEXEC from pair_for_spawn, nothing more needed
125+
Ok(())
126+
});
127+
}
128+
129+
let process = cmd.spawn()?;
130+
131+
// Close the child fd in the parent process
132+
unsafe {
133+
libc::close(child_fd);
134+
}
135+
136+
Ok(ChildHandle {
137+
process,
138+
channel: parent_channel,
139+
kind,
140+
})
141+
}
142+
143+
// Spawn tests live in crates/ie-shell/tests/subprocess.rs because
144+
// spawn_child re-executes the binary which needs --subprocess-kind CLI support.
145+
146+
#[cfg(test)]
147+
mod tests {
148+
use super::*;
149+
150+
#[test]
151+
fn process_kind_round_trip() {
152+
for kind in [
153+
ProcessKind::Browser,
154+
ProcessKind::Renderer,
155+
ProcessKind::Network,
156+
] {
157+
assert_eq!(ProcessKind::parse(kind.as_str()), Some(kind));
158+
}
159+
assert_eq!(ProcessKind::parse("invalid"), None);
160+
}
12161
}

crates/ie-shell/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ http-body-util.workspace = true
3232
hyper.workspace = true
3333
hyper-util.workspace = true
3434
serde_json.workspace = true
35+
libc.workspace = true
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
use anyhow::Result;
2+
use ie_sandbox::IpcChannel;
3+
use ie_sandbox::message::IpcMessage;
4+
use url::Url;
5+
6+
pub async fn run_network_process(mut channel: IpcChannel) -> Result<()> {
7+
let client = ie_net::Client::new()?.with_https_only(false);
8+
tracing::info!("network process started");
9+
10+
loop {
11+
let msg: IpcMessage = channel.recv().await?;
12+
match msg {
13+
IpcMessage::FetchRequest { id, url } => match Url::parse(&url) {
14+
Ok(parsed_url) => match client.get(&parsed_url).await {
15+
Ok(response) => {
16+
channel
17+
.send(&IpcMessage::FetchResponse {
18+
id,
19+
status: response.status,
20+
headers: response.headers,
21+
body: response.body,
22+
final_url: response.url.to_string(),
23+
})
24+
.await?;
25+
}
26+
Err(e) => {
27+
channel
28+
.send(&IpcMessage::FetchError {
29+
id,
30+
error: e.to_string(),
31+
})
32+
.await?;
33+
}
34+
},
35+
Err(e) => {
36+
channel
37+
.send(&IpcMessage::FetchError {
38+
id,
39+
error: format!("invalid URL: {e}"),
40+
})
41+
.await?;
42+
}
43+
},
44+
IpcMessage::Ping => {
45+
channel.send(&IpcMessage::Pong).await?;
46+
}
47+
IpcMessage::Shutdown => {
48+
tracing::info!("network process shutting down");
49+
break;
50+
}
51+
other => {
52+
tracing::warn!("network process received unexpected message: {other:?}");
53+
}
54+
}
55+
}
56+
Ok(())
57+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
use anyhow::Result;
2+
use ie_sandbox::IpcChannel;
3+
use ie_sandbox::message::IpcMessage;
4+
5+
pub async fn run_renderer_process(mut channel: IpcChannel) -> Result<()> {
6+
tracing::info!("renderer process started (stub)");
7+
loop {
8+
let msg: IpcMessage = channel.recv().await?;
9+
match msg {
10+
IpcMessage::Ping => channel.send(&IpcMessage::Pong).await?,
11+
IpcMessage::Shutdown => {
12+
tracing::info!("renderer process shutting down");
13+
break;
14+
}
15+
other => tracing::warn!("renderer received unhandled message: {other:?}"),
16+
}
17+
}
18+
Ok(())
19+
}

crates/ie-shell/src/cli.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ pub struct Cli {
3131
/// Override data directory (bookmarks, etc.)
3232
#[arg(long)]
3333
pub data_dir: Option<String>,
34+
35+
/// Internal: subprocess kind (not shown in help)
36+
#[arg(long, hide = true)]
37+
pub subprocess_kind: Option<String>,
3438
}
3539

3640
#[derive(Debug, PartialEq)]
@@ -42,6 +46,9 @@ pub enum Mode {
4246
url: Option<Url>,
4347
action: HeadlessAction,
4448
},
49+
Subprocess {
50+
kind: ie_sandbox::ProcessKind,
51+
},
4552
}
4653

4754
#[derive(Debug, PartialEq)]
@@ -53,6 +60,12 @@ pub enum HeadlessAction {
5360

5461
impl Cli {
5562
pub fn mode(&self) -> Result<Mode> {
63+
if let Some(kind_str) = &self.subprocess_kind {
64+
let kind = ie_sandbox::ProcessKind::parse(kind_str)
65+
.ok_or_else(|| anyhow::anyhow!("invalid subprocess kind: {kind_str}"))?;
66+
return Ok(Mode::Subprocess { kind });
67+
}
68+
5669
let url = self
5770
.url
5871
.as_deref()

0 commit comments

Comments
 (0)