Skip to content

Phase 1b: Multi-process architecture and sandboxing #3

@thomasnemer

Description

@thomasnemer

Parent: #1

Goal

Migrate the single-process browser from Phase 1a to a multi-process architecture with OS-level sandboxing. The NavigationService trait boundary defined in Phase 1a makes this a mechanical change — the browser shell code doesn't change, only the trait implementation swaps from in-process to IPC.

Prerequisites

Architecture Recap

┌──────────────────────────────────────────────────────────────┐
│ Browser Process (ie-shell)                                   │
│ - Window management, tabs, bookmarks, keyboard UI            │
│ - Communicates with child processes via IPC                   │
│ - NOT sandboxed (needs window, filesystem for bookmarks)     │
├──────────────────────────────────────────────────────────────┤
│ Network Process (singleton)                                  │
│ - All HTTP/TLS traffic                                       │
│ - Sandboxed: network access only, no filesystem              │
│ - ie-net::Client runs here                                   │
├──────────────────────────────────────────────────────────────┤
│ Renderer Process (per tab, Phase 2)                          │
│ - HTML parsing, CSS, layout, JS, painting                    │
│ - Sandboxed: no network, no filesystem, minimal syscalls     │
│ - Receives page data from browser process via IPC            │
└──────────────────────────────────────────────────────────────┘

IPC: length-prefixed JSON messages over Unix domain sockets (Linux/macOS) or named pipes (Windows).


Step 8: ie-sandbox — IPC Channel

Crate: crates/ie-sandbox
Files: src/{lib.rs, channel.rs, message.rs, error.rs}, modify src/ipc.rs

IPC channel (channel.rs)

  • IpcChannel struct: wraps an async read/write stream
  • Platform abstraction:
    • #[cfg(unix)]: backed by tokio::net::UnixStream
    • #[cfg(windows)]: backed by tokio::net::windows::named_pipe
  • IpcChannel::pair() -> Result<(IpcChannel, IpcChannel)>:
    • Unix: UnixStream::pair() — creates connected socketpair
    • Windows: create a uniquely named pipe, connect both ends
    • Returns two channels: one for parent, one to pass to child via fd inheritance / handle inheritance
  • Wire format: 4-byte big-endian u32 length prefix + JSON payload
  • async fn send<T: Serialize>(&self, msg: &T) -> Result<()>:
    • Serialize to JSON bytes
    • Write length prefix (4 bytes, big-endian)
    • Write JSON payload
    • Flush
  • async fn recv<T: DeserializeOwned>(&self) -> Result<T>:
    • Read 4-byte length prefix
    • Allocate buffer of that size (with a max size limit, e.g., 64MB, to prevent OOM)
    • Read exactly that many bytes
    • Deserialize from JSON
  • IpcChannel::into_raw_fd() -> RawFd / from_raw_fd(fd: RawFd) — for passing to child process
    • Windows equivalent: into_raw_handle() / from_raw_handle()
  • Error handling: connection reset, unexpected EOF, message too large, deserialization failure

Message types (message.rs)

  • Expand IpcMessage enum for the network process protocol:
    #[derive(Serialize, Deserialize)]
    #[serde(tag = "type")]
    enum IpcMessage {
        // Browser → Network
        FetchRequest { id: u64, url: String },
        // Network → Browser
        FetchResponse { id: u64, status: u16, headers: HashMap<String, String>, body: Vec<u8>, final_url: String },
        FetchError { id: u64, error: String },
        // Lifecycle
        Shutdown,
        Ping,
        Pong,
    }
  • Request ID (id: u64) for correlating responses to requests — the browser process may have multiple tabs navigating concurrently

Error type (error.rs)

  • IpcError::ConnectionClosed — peer closed the channel
  • IpcError::MessageTooLarge(usize) — payload exceeds max size
  • IpcError::SerializationError(String)
  • IpcError::IoError(std::io::Error)

Tests

  • IpcChannel::pair() creates two connected channels
  • Send string message from A → B, recv on B matches
  • Send complex struct (FetchRequest), recv and verify all fields
  • Send FetchResponse with large body (~1MB), verify integrity
  • Concurrent: spawn two tasks, one sends N messages, other recvs N messages, all match
  • Send from A, drop A, recv on B returns ConnectionClosed
  • Message exceeding max size limit returns MessageTooLarge
  • Send malformed JSON bytes directly, recv returns SerializationError
  • Ping/Pong round-trip latency benchmark (not a pass/fail test, just a measurement)

Step 9: ie-sandbox — Process Spawning

Crate: crates/ie-sandbox
Files: src/{process.rs, lib.rs}, modify crates/ie-shell/src/main.rs

Process spawning (process.rs)

  • ChildHandle struct:
    pub struct ChildHandle {
        pub process: tokio::process::Child,
        pub channel: IpcChannel,
        pub kind: ProcessKind,
    }
  • spawn_child(kind: ProcessKind) -> Result<ChildHandle>:
    • Create IpcChannel::pair() → (parent_channel, child_channel)
    • Get current executable path via std::env::current_exe()
    • Build Command with args: --subprocess-kind=network (or renderer)
    • Unix: pass child channel's fd via CommandExt::pre_exec to dup2 to a known fd (e.g., fd 3), set env var IE_IPC_FD=3
    • Windows: pass handle via STARTUPINFO or environment variable
    • Spawn the child process
    • Return ChildHandle { process, channel: parent_channel, kind }
  • ChildHandle::shutdown(&mut self) -> Result<()>:
    • Send IpcMessage::Shutdown on channel
    • Wait for process exit with timeout (5s)
    • If timeout, kill the process
  • ChildHandle::is_alive(&self) -> bool — check if process is still running

Child process entry point

  • Modify ie-shell/src/main.rs CLI to accept --subprocess-kind <network|renderer> (hidden arg, not shown in help)
  • When --subprocess-kind is present, enter child process mode:
    • Read IPC fd/handle from environment
    • Reconstruct IpcChannel from raw fd/handle
    • Call the appropriate child loop (run_network_process or run_renderer_process)
  • run_network_process(channel: IpcChannel) -> Result<()>:
    • Create ie_net::Client
    • Loop: recv IpcMessage from channel
      • FetchRequest { id, url } → spawn async fetch, send FetchResponse or FetchError back
      • Ping → send Pong
      • Shutdown → break loop, exit cleanly
    • Graceful shutdown: finish in-flight requests before exiting
  • run_renderer_process — stub for Phase 2, just recv Shutdown and exit

Tests

  • Spawn network child process, send Ping, recv Pong
  • Spawn network child, send FetchRequest for local test server URL, recv FetchResponse with correct status/body
  • Spawn child, send Shutdown, verify process exits cleanly (exit code 0)
  • Spawn child, kill parent channel (drop), verify child detects disconnection and exits
  • Spawn child, is_alive returns true, shutdown, is_alive returns false
  • Multiple concurrent FetchRequests: send 5 requests, recv 5 responses, match by id
  • Child process with invalid --subprocess-kind value exits with error

Step 10: ie-sandbox — OS-Level Sandboxing

Crate: crates/ie-sandbox
Files: src/{sandbox.rs, sandbox_linux.rs, sandbox_macos.rs, sandbox_windows.rs}

Sandbox trait and profiles (sandbox.rs)

  • SandboxProfile enum:
    enum SandboxProfile {
        Network,   // allow: network, DNS. deny: filesystem (except /etc/resolv.conf), no new processes
        Renderer,  // deny: network, filesystem, no new processes, minimal syscalls
    }
  • fn apply_sandbox(profile: SandboxProfile) -> Result<()> — called by child process after IPC setup, before entering main loop. Platform-dispatched.

Linux sandbox (sandbox_linux.rs)

  • Landlock (filesystem restriction, Linux 5.13+):
    • Network profile: no filesystem access rules (deny all)
    • Renderer profile: no filesystem access rules (deny all)
    • Graceful fallback if landlock not available (log warning, continue without — do NOT fail)
  • seccomp-bpf (syscall filtering):
    • Network profile: allow syscalls for network I/O (socket, connect, read, write, poll, epoll, etc.), memory management, thread management. Deny: open/openat (except via already-open fds), exec, ptrace
    • Renderer profile: stricter — allow only: read, write, mmap, mprotect, futex, clock_gettime, close, exit_group. Deny everything else.
    • Use the seccompiler or libseccomp crate for BPF filter generation
  • prctl(PR_SET_NO_NEW_PRIVS) — always applied before seccomp
  • Add appropriate crate dependencies: landlock, seccompiler or equivalent

macOS sandbox (sandbox_macos.rs)

  • Use sandbox_init (deprecated but still functional) or the newer sandbox_exec approach
  • Network profile SBPL (Sandbox Profile Language):
    (version 1)
    (deny default)
    (allow network*)
    (allow system-socket)
    (allow mach-lookup (global-name "com.apple.system.logger"))
  • Renderer profile SBPL:
    (version 1)
    (deny default)
    (allow mach-lookup (global-name "com.apple.system.logger"))
  • Apply via FFI to sandbox_init() — this is a C function, use libc crate
  • Note: Apple's sandbox is process-wide and irreversible once applied

Windows sandbox (sandbox_windows.rs)

  • Use Job Objects for resource limits:
    • CreateJobObject + SetInformationJobObject to restrict:
      • JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 1 (no child processes)
      • JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE (kill child if parent dies)
    • AssignProcessToJobObject on the child process
  • Use Restricted Tokens:
    • CreateRestrictedToken with DISABLE_MAX_PRIVILEGE to remove most privileges
    • Create child process with restricted token via CreateProcessAsUser or CreateProcess with STARTUPINFOEX
  • Network profile: restricted token + job object, keep network access
  • Renderer profile: restricted token + job object + deny network (use Windows Filtering Platform or restricted token SIDs)
  • Use the windows crate for Win32 API bindings

Sandbox integration

  • Modify spawn_child in process.rs to:
    1. Spawn child process
    2. (Windows) Apply job object to child from parent side
    3. Child process calls apply_sandbox(profile) early in its startup, after IPC setup but before any untrusted work
  • Sandbox application is irreversible — once applied, it cannot be relaxed

Tests

Testing sandboxes is inherently platform-specific and requires running actual child processes:

  • Linux: spawn sandboxed network child, verify it can make HTTP requests to a local test server
  • Linux: spawn sandboxed network child, verify it CANNOT open files (send a command that tries to read a file, expect failure)
  • Linux: spawn sandboxed renderer child, verify it CANNOT make network connections
  • macOS: equivalent tests to Linux above
  • Windows: equivalent tests to Linux above
  • Graceful fallback: on Linux < 5.13 (no landlock), sandbox applies seccomp only, logs warning
  • Sandbox applied before main loop: verify that the child cannot perform restricted operations even during startup
  • #[cfg(target_os = "...")] guards on all platform-specific tests

Step 11: ie-shell — IPC Navigator

Crate: crates/ie-shell
Files: src/ipc_navigator.rs, modify src/app.rs and src/main.rs

IpcNavigator implementation (ipc_navigator.rs)

  • IpcNavigator struct:
    pub struct IpcNavigator {
        channel: Arc<IpcChannel>,
        next_request_id: AtomicU64,
        pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Result<NavigationResult>>>>>,
    }
  • Implements NavigationService:
    async fn navigate(&self, url: &Url) -> Result<NavigationResult> {
        let id = self.next_request_id.fetch_add(1, Ordering::SeqCst);
        let (tx, rx) = oneshot::channel();
        self.pending.lock().await.insert(id, tx);
        self.channel.send(&IpcMessage::FetchRequest {
            id,
            url: url.to_string(),
        }).await?;
        rx.await?
    }
  • Background response dispatcher task:
    • Spawned when IpcNavigator is created
    • Loops: channel.recv(), match on message type
    • FetchResponse → look up id in pending, send result through oneshot
    • FetchError → look up id in pending, send error through oneshot
    • Handles channel closure by completing all pending requests with error

Browser process startup changes (main.rs, app.rs)

  • Add --single-process flag (default: multi-process):
    #[arg(long, help = "Run everything in a single process (no sandboxing)")]
    single_process: bool,
  • In main() / Browser::new():
    • If --single-process: use InProcessNavigator (Phase 1a behavior)
    • Otherwise: spawn network child process via ie_sandbox::spawn_child(ProcessKind::Network), create IpcNavigator from the channel, apply sandbox to child
  • On browser exit: send Shutdown to all child processes, wait for clean exit

Headless mode changes

  • Interactive headless mode: same JSON protocol, but internally uses IpcNavigator when multi-process is enabled
  • --single-process flag also works in headless mode

Tests

  • Unit test: IpcNavigator with a mock channel (send FetchRequest, inject FetchResponse, verify NavigationResult)
  • Integration test: spawn browser in multi-process mode, navigate via headless protocol, verify response
  • Integration test: spawn in --single-process mode, same navigation works
  • Integration test: multiple concurrent navigations (open 3 tabs, navigate all, verify all complete)
  • Graceful shutdown: start multi-process browser, quit, verify network process exits cleanly (no zombie)
  • Network process crash recovery: kill network child, verify browser reports error on next navigation (not hang)
  • E2E: all Phase 1a e2e tests pass in both --single-process and default multi-process modes

Acceptance Criteria

  • All Phase 1a tests still pass (no regressions)
  • cargo run -p ie-shell launches in multi-process mode by default
  • cargo run -p ie-shell -- --single-process falls back to single-process
  • Network process runs in a separate OS process (visible in ps / Task Manager)
  • Network process is sandboxed (cannot access filesystem on Linux/macOS)
  • Renderer process stub exists and is sandboxed (ready for Phase 2)
  • Browser gracefully shuts down child processes on exit
  • --headless mode works in both single and multi-process
  • All e2e tests pass in both modes

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