Skip to content

Step 9: ie-sandbox — Process spawning #12

@thomasnemer

Description

@thomasnemer

Parent: #3

Goal

Implement child process spawning: the browser process re-executes itself with special arguments to become a network or renderer child process. The child inherits an IPC channel fd/handle and enters its role-specific event loop.

Prerequisites

  • Step 8 (IPC channel — IpcChannel::pair() and send/recv working)

File Changes

  • crates/ie-sandbox/src/process.rs — full rewrite: ChildHandle, spawn_child() implementation
  • crates/ie-sandbox/src/lib.rs — update re-exports
  • crates/ie-shell/src/cli.rs — add hidden --subprocess-kind arg
  • crates/ie-shell/src/main.rs — detect subprocess mode, branch to child entry point
  • crates/ie-shell/src/child_network.rs — new file, network process event loop
  • crates/ie-shell/src/child_renderer.rs — new file, renderer process stub

Implementation

ChildHandle (process.rs)

  • ChildHandle struct:
    pub struct ChildHandle {
        process: tokio::process::Child,
        channel: IpcChannel,
        kind: ProcessKind,
    }
  • ChildHandle::channel(&mut self) -> &mut IpcChannel — access the parent's end of the IPC channel
  • ChildHandle::is_alive(&mut self) -> boolself.process.try_wait() returns Ok(None)
  • ChildHandle::shutdown(&mut self) -> Result<()>:
    1. Send IpcMessage::Shutdown via channel
    2. Wait for process exit with 5-second timeout (tokio::time::timeout)
    3. If timeout expires, self.process.kill().await
    4. Log whether shutdown was clean or forced
  • ChildHandle::kill(&mut self) -> Result<()> — immediate kill, no graceful shutdown
  • impl Drop for ChildHandle — best-effort kill if process still alive (don't leave zombies)

spawn_child (process.rs)

  • pub async fn spawn_child(kind: ProcessKind) -> Result<ChildHandle>:

    Unix implementation (#[cfg(unix)]):

    1. Create IpcChannel::pair()(parent_channel, child_channel)
    2. Extract raw fd from child_channel (the fd that will be inherited by the child)
    3. Get current exe path: std::env::current_exe()?
    4. Build tokio::process::Command:
      let mut cmd = Command::new(exe_path);
      cmd.arg("--subprocess-kind").arg(kind.as_str());
      cmd.env("IE_IPC_FD", child_fd.to_string());
      // Ensure the child fd is NOT closed on exec:
      // Use pre_exec to clear FD_CLOEXEC on the child fd
      unsafe {
          cmd.pre_exec(move || {
              let flags = libc::fcntl(child_fd, libc::F_GETFD);
              libc::fcntl(child_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
              Ok(())
          });
      }
    5. Set stdin/stdout/stderr: inherit stderr (for child tracing), null stdin/stdout (child uses IPC only)
    6. Spawn: cmd.spawn()?
    7. Close the child's fd in the parent process (parent only uses parent_channel)
    8. Return ChildHandle { process, channel: parent_channel, kind }

    Windows implementation (#[cfg(windows)]):

    1. Create named pipe with unique name: \\.\pipe\ie-ipc-{uuid}
    2. Create server end of pipe (parent)
    3. Build Command with --subprocess-kind arg and IE_IPC_PIPE=<pipe_name> env var
    4. Spawn child
    5. Child connects to pipe by name
    6. Return ChildHandle

ProcessKind updates (process.rs)

  • Add string conversion:
    impl ProcessKind {
        pub fn as_str(&self) -> &'static str {
            match self {
                ProcessKind::Browser => "browser",
                ProcessKind::Renderer => "renderer",
                ProcessKind::Network => "network",
            }
        }
        pub fn from_str(s: &str) -> Option<Self> {
            match s {
                "browser" => Some(Self::Browser),
                "renderer" => Some(Self::Renderer),
                "network" => Some(Self::Network),
                _ => None,
            }
        }
    }

CLI changes (cli.rs)

  • Add hidden subprocess argument:
    /// Internal: subprocess kind (not shown in help)
    #[arg(long, hide = true)]
    pub subprocess_kind: Option<String>,
  • Cli::mode() update: if subprocess_kind is set, return a new Mode::Subprocess { kind } variant

Main entry point changes (main.rs)

  • Add Mode::Subprocess { kind } branch:
    Mode::Subprocess { kind } => run_subprocess(kind),
  • fn run_subprocess(kind: ProcessKind) -> Result<()>:
    1. Reconstruct IPC channel from environment:
      • Unix: IpcChannel::from_raw_fd(env::var("IE_IPC_FD")?.parse()?)?
      • Windows: IpcChannel::from_named_pipe(&env::var("IE_IPC_PIPE")?)?
    2. Match on kind:
      • Networkrun_network_process(channel).await
      • Rendererrun_renderer_process(channel).await
      • Browser → error (browser is never a subprocess)

Network process event loop (child_network.rs)

  • pub async fn run_network_process(mut channel: IpcChannel) -> Result<()>:
    let client = ie_net::Client::new()?;
    tracing::info!("network process started");
    
    loop {
        let msg: IpcMessage = channel.recv().await?;
        match msg {
            IpcMessage::FetchRequest { id, url } => {
                let client = client.clone(); // client needs Clone or Arc
                // For now, handle sequentially. Concurrent handling in optimization pass.
                match Url::parse(&url) {
                    Ok(url) => match client.get(&url).await {
                        Ok(response) => {
                            channel.send(&IpcMessage::FetchResponse {
                                id,
                                status: response.status,
                                headers: response.headers,
                                body: response.body,
                                final_url: response.url.to_string(),
                            }).await?;
                        }
                        Err(e) => {
                            channel.send(&IpcMessage::FetchError {
                                id,
                                error: e.to_string(),
                            }).await?;
                        }
                    }
                    Err(e) => {
                        channel.send(&IpcMessage::FetchError {
                            id,
                            error: format!("invalid URL: {e}"),
                        }).await?;
                    }
                }
            }
            IpcMessage::Ping => {
                channel.send(&IpcMessage::Pong).await?;
            }
            IpcMessage::Shutdown => {
                tracing::info!("network process shutting down");
                break;
            }
            other => {
                tracing::warn!("network process received unexpected message: {:?}", other);
            }
        }
    }
    Ok(())
  • Note: sequential request handling is fine for v1. Concurrent handling (spawn per request) is an optimization for later.

Renderer process stub (child_renderer.rs)

  • pub async fn run_renderer_process(mut channel: IpcChannel) -> Result<()>:
    tracing::info!("renderer process started (stub)");
    loop {
        let msg: IpcMessage = channel.recv().await?;
        match msg {
            IpcMessage::Ping => channel.send(&IpcMessage::Pong).await?,
            IpcMessage::Shutdown => {
                tracing::info!("renderer process shutting down");
                break;
            }
            _ => tracing::warn!("renderer received unhandled message"),
        }
    }
    Ok(())

Tests

spawn_child tests

  • Spawn network process, ping/pong: spawn_child(Network), send Ping, recv Pong, shutdown
  • Spawn renderer process, ping/pong: same for Renderer
  • is_alive: spawn child, is_alive() returns true. Shutdown. is_alive() returns false.
  • Graceful shutdown: spawn, send Shutdown, wait. Process exits with code 0.
  • Forced kill on timeout: spawn network process, don't send Shutdown, call shutdown() with a very short timeout (simulate unresponsive child by sending a long-running request). Verify process is killed.
  • Drop cleanup: spawn child, drop ChildHandle, verify process is eventually killed (no zombie)
  • Invalid subprocess-kind: run binary with --subprocess-kind=invalid, verify exits with error code

Network process tests

  • Fetch via IPC: spawn network child. Start local test server. Send FetchRequest { id: 1, url: "http://127.0.0.1:PORT/hello" }. Recv FetchResponse { id: 1, status: 200, ... }. Verify body matches server response.
  • Fetch error: send FetchRequest with URL pointing to nothing. Recv FetchError { id: 1, error: "..." }.
  • Invalid URL: send FetchRequest with empty URL. Recv FetchError.
  • Multiple sequential requests: send 3 FetchRequests with ids 1, 2, 3. Recv 3 FetchResponses, match by id.
  • Request correlation: send requests with specific ids, verify responses have matching ids (not reordered).
  • Shutdown with no pending requests: send Shutdown, child exits cleanly.

Renderer stub tests

  • Spawn and shutdown: spawn renderer, send Shutdown, exits cleanly
  • Ping/pong: spawn renderer, Ping → Pong

IPC fd inheritance test (Unix)

  • Spawn child, verify it successfully reconstructs IpcChannel from IE_IPC_FD env var (this is implicitly tested by the ping/pong tests above)

Acceptance Criteria

  • cargo test -p ie-sandbox — all tests pass
  • cargo test -p ie-shell — subprocess-related tests pass
  • cargo clippy --workspace -- -D warnings — no warnings
  • Network child process can fetch from a real HTTP server
  • Child processes exit cleanly on Shutdown
  • No zombie processes left after tests

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions