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)
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)
Child process entry point
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)
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)
Windows sandbox (sandbox_windows.rs)
Sandbox integration
Tests
Testing sandboxes is inherently platform-specific and requires running actual child processes:
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)
Headless mode changes
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
Parent: #1
Goal
Migrate the single-process browser from Phase 1a to a multi-process architecture with OS-level sandboxing. The
NavigationServicetrait 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
NavigationServicetrait defined, headless mode and e2e tests operationalArchitecture Recap
IPC: length-prefixed JSON messages over Unix domain sockets (Linux/macOS) or named pipes (Windows).
Step 8: ie-sandbox — IPC Channel
Crate:
crates/ie-sandboxFiles:
src/{lib.rs, channel.rs, message.rs, error.rs}, modifysrc/ipc.rsIPC channel (
channel.rs)IpcChannelstruct: wraps an async read/write stream#[cfg(unix)]: backed bytokio::net::UnixStream#[cfg(windows)]: backed bytokio::net::windows::named_pipeIpcChannel::pair() -> Result<(IpcChannel, IpcChannel)>:UnixStream::pair()— creates connected socketpairasync fn send<T: Serialize>(&self, msg: &T) -> Result<()>:async fn recv<T: DeserializeOwned>(&self) -> Result<T>:IpcChannel::into_raw_fd() -> RawFd/from_raw_fd(fd: RawFd)— for passing to child processinto_raw_handle()/from_raw_handle()Message types (
message.rs)IpcMessageenum for the network process protocol:id: u64) for correlating responses to requests — the browser process may have multiple tabs navigating concurrentlyError type (
error.rs)IpcError::ConnectionClosed— peer closed the channelIpcError::MessageTooLarge(usize)— payload exceeds max sizeIpcError::SerializationError(String)IpcError::IoError(std::io::Error)Tests
IpcChannel::pair()creates two connected channelsConnectionClosedMessageTooLargeSerializationErrorStep 9: ie-sandbox — Process Spawning
Crate:
crates/ie-sandboxFiles:
src/{process.rs, lib.rs}, modifycrates/ie-shell/src/main.rsProcess spawning (
process.rs)ChildHandlestruct:spawn_child(kind: ProcessKind) -> Result<ChildHandle>:IpcChannel::pair()→ (parent_channel, child_channel)std::env::current_exe()Commandwith args:--subprocess-kind=network(orrenderer)CommandExt::pre_execto dup2 to a known fd (e.g., fd 3), set env varIE_IPC_FD=3STARTUPINFOor environment variableChildHandle { process, channel: parent_channel, kind }ChildHandle::shutdown(&mut self) -> Result<()>:IpcMessage::Shutdownon channelChildHandle::is_alive(&self) -> bool— check if process is still runningChild process entry point
ie-shell/src/main.rsCLI to accept--subprocess-kind <network|renderer>(hidden arg, not shown in help)--subprocess-kindis present, enter child process mode:IpcChannelfrom raw fd/handlerun_network_processorrun_renderer_process)run_network_process(channel: IpcChannel) -> Result<()>:ie_net::ClientIpcMessagefrom channelFetchRequest { id, url }→ spawn async fetch, sendFetchResponseorFetchErrorbackPing→ sendPongShutdown→ break loop, exit cleanlyrun_renderer_process— stub for Phase 2, just recv Shutdown and exitTests
is_alivereturns true, shutdown,is_alivereturns false--subprocess-kindvalue exits with errorStep 10: ie-sandbox — OS-Level Sandboxing
Crate:
crates/ie-sandboxFiles:
src/{sandbox.rs, sandbox_linux.rs, sandbox_macos.rs, sandbox_windows.rs}Sandbox trait and profiles (
sandbox.rs)SandboxProfileenum:fn apply_sandbox(profile: SandboxProfile) -> Result<()>— called by child process after IPC setup, before entering main loop. Platform-dispatched.Linux sandbox (
sandbox_linux.rs)seccompilerorlibseccompcrate for BPF filter generationlandlock,seccompileror equivalentmacOS sandbox (
sandbox_macos.rs)sandbox_init(deprecated but still functional) or the newersandbox_execapproachsandbox_init()— this is a C function, uselibccrateWindows sandbox (
sandbox_windows.rs)CreateJobObject+SetInformationJobObjectto restrict:JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 1(no child processes)JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE(kill child if parent dies)AssignProcessToJobObjecton the child processCreateRestrictedTokenwithDISABLE_MAX_PRIVILEGEto remove most privilegesCreateProcessAsUserorCreateProcesswithSTARTUPINFOEXwindowscrate for Win32 API bindingsSandbox integration
spawn_childinprocess.rsto:apply_sandbox(profile)early in its startup, after IPC setup but before any untrusted workTests
Testing sandboxes is inherently platform-specific and requires running actual child processes:
#[cfg(target_os = "...")]guards on all platform-specific testsStep 11: ie-shell — IPC Navigator
Crate:
crates/ie-shellFiles:
src/ipc_navigator.rs, modifysrc/app.rsandsrc/main.rsIpcNavigator implementation (
ipc_navigator.rs)IpcNavigatorstruct:NavigationService:IpcNavigatoris createdchannel.recv(), match on message typeFetchResponse→ look upidinpending, send result through oneshotFetchError→ look upidinpending, send error through oneshotBrowser process startup changes (
main.rs,app.rs)--single-processflag (default: multi-process):main()/Browser::new():--single-process: useInProcessNavigator(Phase 1a behavior)ie_sandbox::spawn_child(ProcessKind::Network), createIpcNavigatorfrom the channel, apply sandbox to childHeadless mode changes
IpcNavigatorwhen multi-process is enabled--single-processflag also works in headless modeTests
IpcNavigatorwith a mock channel (send FetchRequest, inject FetchResponse, verify NavigationResult)--single-processmode, same navigation works--single-processand default multi-process modesAcceptance Criteria
cargo run -p ie-shelllaunches in multi-process mode by defaultcargo run -p ie-shell -- --single-processfalls back to single-processps/ Task Manager)--headlessmode works in both single and multi-process