Skip to content

Commit f303e7b

Browse files
committed
feat(ie-shell): implement IPC navigator for multi-process architecture #14
IpcNavigator sends fetch requests to sandboxed network child process via IPC instead of making HTTP calls in-process: - Writer task forwards messages to IPC channel - Reader task dispatches FetchResponse/FetchError to pending requests - Request ID correlation for concurrent navigations - 30s timeout per navigation with pending cleanup - Response validation: status range, URL parsing, body size - HTTPS-only enforcement in browser process Multi-process is now the default mode: - --single-process flag falls back to InProcessNavigator - GUI mode spawns network child, stores ChildHandle for cleanup - Headless mode supports both modes for dump-source/dump-status/interactive - IpcChannel gains into_halves() -> (IpcSender, IpcReceiver) - ChildHandle gains take_channel() for IpcNavigator ownership transfer All 200+ tests pass in multi-process mode (default).
1 parent bdc9d56 commit f303e7b

8 files changed

Lines changed: 351 additions & 33 deletions

File tree

crates/ie-sandbox/src/channel.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ pub struct IpcChannel {
2323
writer: WriteHalf,
2424
}
2525

26+
pub struct IpcSender {
27+
writer: WriteHalf,
28+
}
29+
30+
pub struct IpcReceiver {
31+
reader: BufReader<ReadHalf>,
32+
}
33+
2634
impl IpcChannel {
2735
/// Create a connected pair of IPC channels.
2836
#[cfg(unix)]
@@ -78,6 +86,18 @@ impl IpcChannel {
7886
})
7987
}
8088

89+
/// Split into separate sender and receiver halves.
90+
pub fn into_halves(self) -> (IpcSender, IpcReceiver) {
91+
(
92+
IpcSender {
93+
writer: self.writer,
94+
},
95+
IpcReceiver {
96+
reader: self.reader,
97+
},
98+
)
99+
}
100+
81101
/// Send a serializable message with length prefix.
82102
pub async fn send<T: Serialize>(&mut self, msg: &T) -> Result<(), IpcError> {
83103
let payload =
@@ -138,6 +158,48 @@ impl IpcChannel {
138158
}
139159
}
140160

161+
impl IpcSender {
162+
pub async fn send<T: Serialize>(&mut self, msg: &T) -> Result<(), IpcError> {
163+
let payload =
164+
serde_json::to_vec(msg).map_err(|e| IpcError::SerializationError(e.to_string()))?;
165+
if payload.len() > MAX_MESSAGE_SIZE {
166+
return Err(IpcError::MessageTooLarge(payload.len(), MAX_MESSAGE_SIZE));
167+
}
168+
self.writer
169+
.write_all(&(payload.len() as u32).to_be_bytes())
170+
.await?;
171+
self.writer.write_all(&payload).await?;
172+
self.writer.flush().await?;
173+
Ok(())
174+
}
175+
}
176+
177+
impl IpcReceiver {
178+
pub async fn recv<T: DeserializeOwned>(&mut self) -> Result<T, IpcError> {
179+
let mut len_buf = [0u8; LENGTH_PREFIX_SIZE];
180+
match self.reader.read_exact(&mut len_buf).await {
181+
Ok(_) => {}
182+
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
183+
return Err(IpcError::ConnectionClosed);
184+
}
185+
Err(e) => return Err(IpcError::Io(e)),
186+
}
187+
let size = u32::from_be_bytes(len_buf) as usize;
188+
if size > MAX_MESSAGE_SIZE {
189+
return Err(IpcError::MessageTooLarge(size, MAX_MESSAGE_SIZE));
190+
}
191+
let mut buf = vec![0u8; size];
192+
match self.reader.read_exact(&mut buf).await {
193+
Ok(_) => {}
194+
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
195+
return Err(IpcError::ConnectionClosed);
196+
}
197+
Err(e) => return Err(IpcError::Io(e)),
198+
}
199+
serde_json::from_slice(&buf).map_err(|e| IpcError::DeserializationError(e.to_string()))
200+
}
201+
}
202+
141203
#[cfg(test)]
142204
mod tests {
143205
use std::collections::HashMap;

crates/ie-sandbox/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub mod sandbox_linux;
2121
#[cfg(target_os = "macos")]
2222
pub mod sandbox_macos;
2323

24-
pub use channel::IpcChannel;
24+
pub use channel::{IpcChannel, IpcReceiver, IpcSender};
2525
pub use error::IpcError;
2626
pub use message::IpcMessage;
2727
pub use process::{ChildHandle, ProcessKind, spawn_child, spawn_child_with_exe};

crates/ie-sandbox/src/process.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,20 @@ impl ProcessKind {
3434

3535
pub struct ChildHandle {
3636
process: tokio::process::Child,
37-
channel: IpcChannel,
37+
channel: Option<IpcChannel>,
3838
kind: ProcessKind,
3939
}
4040

4141
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
4242

4343
impl ChildHandle {
4444
pub fn channel(&mut self) -> &mut IpcChannel {
45-
&mut self.channel
45+
self.channel.as_mut().expect("channel already taken")
46+
}
47+
48+
/// Take ownership of the IPC channel (for IpcNavigator).
49+
pub fn take_channel(&mut self) -> IpcChannel {
50+
self.channel.take().expect("channel already taken")
4651
}
4752

4853
pub fn kind(&self) -> ProcessKind {
@@ -59,7 +64,9 @@ impl ChildHandle {
5964

6065
/// Graceful shutdown: send Shutdown, wait, kill if timeout.
6166
pub async fn shutdown(&mut self) -> anyhow::Result<()> {
62-
let _ = self.channel.send(&IpcMessage::Shutdown).await;
67+
if let Some(channel) = &mut self.channel {
68+
let _ = channel.send(&IpcMessage::Shutdown).await;
69+
}
6370
match timeout(SHUTDOWN_TIMEOUT, self.process.wait()).await {
6471
Ok(Ok(status)) => {
6572
tracing::info!("{:?} process exited with status: {}", self.kind, status);
@@ -135,7 +142,7 @@ pub async fn spawn_child_with_exe(
135142

136143
Ok(ChildHandle {
137144
process,
138-
channel: parent_channel,
145+
channel: Some(parent_channel),
139146
kind,
140147
})
141148
}

crates/ie-shell/src/app.rs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,57 @@ pub struct Browser {
3030
overlay: OverlayState,
3131
navigator: Arc<dyn NavigationService + Send + Sync>,
3232
tokio_runtime: tokio::runtime::Runtime,
33+
_network_child: Option<ie_sandbox::ChildHandle>,
3334
modifiers: ModifiersState,
3435
event_loop_proxy: EventLoopProxy<UserEvent>,
3536
}
3637

3738
impl Browser {
38-
pub fn new(url: Option<Url>, allow_http: bool, proxy: EventLoopProxy<UserEvent>) -> Self {
39-
let navigator = InProcessNavigator::new()
40-
.expect("failed to create navigator")
41-
.with_https_only(!allow_http);
39+
pub fn new(
40+
url: Option<Url>,
41+
allow_http: bool,
42+
single_process: bool,
43+
proxy: EventLoopProxy<UserEvent>,
44+
) -> Self {
45+
let tokio_runtime = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
46+
47+
let (navigator, network_child): (
48+
Arc<dyn NavigationService + Send + Sync>,
49+
Option<ie_sandbox::ChildHandle>,
50+
) = if single_process {
51+
let nav = InProcessNavigator::new()
52+
.expect("failed to create navigator")
53+
.with_https_only(!allow_http);
54+
(Arc::new(nav), None)
55+
} else {
56+
let (nav, child) = tokio_runtime.block_on(async {
57+
let mut child = ie_sandbox::spawn_child(ie_sandbox::ProcessKind::Network)
58+
.await
59+
.expect("failed to spawn network process");
60+
let channel = child.take_channel();
61+
(
62+
crate::ipc_navigator::IpcNavigator::new(channel, !allow_http),
63+
child,
64+
)
65+
});
66+
(Arc::new(nav), Some(child))
67+
};
68+
4269
let data_dir = dirs_data_dir().unwrap_or_else(|| PathBuf::from("."));
4370
let bookmark_store = BookmarkStore::new(&data_dir).unwrap_or_else(|e| {
4471
tracing::warn!("failed to load bookmarks: {e}");
4572
BookmarkStore::new(&std::env::temp_dir()).expect("failed to create bookmark store")
4673
});
47-
let tokio_runtime = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
4874

4975
let mut browser = Self {
5076
window: None,
5177
surface: None,
5278
tab_manager: TabManager::new(),
5379
bookmark_store,
5480
overlay: OverlayState::None,
55-
navigator: Arc::new(navigator),
81+
navigator,
5682
tokio_runtime,
83+
_network_child: network_child,
5784
modifiers: ModifiersState::empty(),
5885
event_loop_proxy: proxy,
5986
};

crates/ie-shell/src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ pub struct Cli {
3232
#[arg(long)]
3333
pub data_dir: Option<String>,
3434

35+
/// Run everything in a single process (no sandboxing, no IPC)
36+
#[arg(long)]
37+
pub single_process: bool,
38+
3539
/// Internal: subprocess kind (not shown in help)
3640
#[arg(long, hide = true)]
3741
pub subprocess_kind: Option<String>,

crates/ie-shell/src/headless.rs

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::path::PathBuf;
2+
use std::sync::Arc;
23

34
use anyhow::Result;
45
use serde::{Deserialize, Serialize};
@@ -7,6 +8,7 @@ use url::Url;
78

89
use crate::bookmarks::BookmarkStore;
910
use crate::cli::HeadlessAction;
11+
use crate::ipc_navigator::IpcNavigator;
1012
use crate::navigation::{InProcessNavigator, NavigationService};
1113
use crate::tab::{TabId, TabManager, TabState};
1214

@@ -15,20 +17,50 @@ pub fn run_headless(
1517
action: HeadlessAction,
1618
allow_http: bool,
1719
data_dir: Option<String>,
20+
single_process: bool,
1821
) -> Result<()> {
1922
let rt = tokio::runtime::Runtime::new()?;
2023
rt.block_on(async {
2124
match action {
22-
HeadlessAction::DumpSource => run_dump_source(url.unwrap(), allow_http).await,
23-
HeadlessAction::DumpStatus => run_dump_status(url.unwrap(), allow_http).await,
24-
HeadlessAction::Interactive => run_interactive(allow_http, data_dir).await,
25+
HeadlessAction::DumpSource => {
26+
run_dump_source(url.unwrap(), allow_http, single_process).await
27+
}
28+
HeadlessAction::DumpStatus => {
29+
run_dump_status(url.unwrap(), allow_http, single_process).await
30+
}
31+
HeadlessAction::Interactive => {
32+
run_interactive(allow_http, data_dir, single_process).await
33+
}
2534
}
2635
})
2736
}
2837

29-
async fn run_dump_source(url: Url, allow_http: bool) -> Result<()> {
30-
let navigator = InProcessNavigator::new()?.with_https_only(!allow_http);
31-
match navigator.navigate(&url).await {
38+
struct NavigatorHandle {
39+
navigator: Arc<dyn NavigationService + Send + Sync>,
40+
_child: Option<ie_sandbox::ChildHandle>,
41+
}
42+
43+
async fn create_navigator(allow_http: bool, single_process: bool) -> Result<NavigatorHandle> {
44+
if single_process {
45+
let nav = InProcessNavigator::new()?.with_https_only(!allow_http);
46+
Ok(NavigatorHandle {
47+
navigator: Arc::new(nav),
48+
_child: None,
49+
})
50+
} else {
51+
let mut child = ie_sandbox::spawn_child(ie_sandbox::ProcessKind::Network).await?;
52+
let channel = child.take_channel();
53+
let nav = IpcNavigator::new(channel, !allow_http);
54+
Ok(NavigatorHandle {
55+
navigator: Arc::new(nav),
56+
_child: Some(child),
57+
})
58+
}
59+
}
60+
61+
async fn run_dump_source(url: Url, allow_http: bool, single_process: bool) -> Result<()> {
62+
let handle = create_navigator(allow_http, single_process).await?;
63+
match handle.navigator.navigate(&url).await {
3264
Ok(result) => {
3365
let text = String::from_utf8_lossy(&result.body);
3466
print!("{text}");
@@ -41,9 +73,9 @@ async fn run_dump_source(url: Url, allow_http: bool) -> Result<()> {
4173
}
4274
}
4375

44-
async fn run_dump_status(url: Url, allow_http: bool) -> Result<()> {
45-
let navigator = InProcessNavigator::new()?.with_https_only(!allow_http);
46-
match navigator.navigate(&url).await {
76+
async fn run_dump_status(url: Url, allow_http: bool, single_process: bool) -> Result<()> {
77+
let handle = create_navigator(allow_http, single_process).await?;
78+
match handle.navigator.navigate(&url).await {
4779
Ok(result) => {
4880
println!("{}", result.status);
4981
Ok(())
@@ -122,20 +154,22 @@ impl Response {
122154
struct HeadlessSession {
123155
tab_manager: TabManager,
124156
bookmark_store: BookmarkStore,
125-
navigator: InProcessNavigator,
157+
navigator: Arc<dyn NavigationService + Send + Sync>,
158+
_child: Option<ie_sandbox::ChildHandle>,
126159
}
127160

128161
impl HeadlessSession {
129-
fn new(allow_http: bool, data_dir: Option<String>) -> Result<Self> {
130-
let navigator = InProcessNavigator::new()?.with_https_only(!allow_http);
162+
async fn new(allow_http: bool, data_dir: Option<String>, single_process: bool) -> Result<Self> {
163+
let handle = create_navigator(allow_http, single_process).await?;
131164
let bookmark_path = data_dir
132165
.map(PathBuf::from)
133166
.unwrap_or_else(|| std::env::temp_dir().join("ie-headless"));
134167
let bookmark_store = BookmarkStore::new(&bookmark_path)?;
135168
Ok(Self {
136169
tab_manager: TabManager::new(),
137170
bookmark_store,
138-
navigator,
171+
navigator: handle.navigator,
172+
_child: handle._child,
139173
})
140174
}
141175

@@ -169,7 +203,6 @@ impl HeadlessSession {
169203
tab.url = Some(url.clone());
170204
}
171205

172-
// Sequential: navigate blocks until complete
173206
match self.navigator.navigate(&url).await {
174207
Ok(result) => {
175208
let source = String::from_utf8(result.body).ok();
@@ -303,10 +336,14 @@ impl HeadlessSession {
303336
}
304337
}
305338

306-
async fn run_interactive(allow_http: bool, data_dir: Option<String>) -> Result<()> {
339+
async fn run_interactive(
340+
allow_http: bool,
341+
data_dir: Option<String>,
342+
single_process: bool,
343+
) -> Result<()> {
307344
let stdin = BufReader::new(tokio::io::stdin());
308345
let mut stdout = tokio::io::stdout();
309-
let mut session = HeadlessSession::new(allow_http, data_dir)?;
346+
let mut session = HeadlessSession::new(allow_http, data_dir, single_process).await?;
310347

311348
let mut lines = stdin.lines();
312349
while let Ok(Some(line)) = lines.next_line().await {

0 commit comments

Comments
 (0)