Skip to content

Commit 3cd2041

Browse files
committed
fix: improve sandbox reliability under concurrent and long-lived workloads
- Apply SQLite WAL mode, busy_timeout, and foreign_keys via SqliteConnectOptions so every pool connection gets the PRAGMAs, not just the first (fixes SQLITE_BUSY under concurrent access) - Don't consider sandboxes idle while exec sessions are active - Enable TCP keepalive on all upstream proxy connections to prevent NATs/firewalls from dropping idle SSE/streaming flows - Wrap smoltcp poll thread in catch_unwind to log panics instead of silently killing all guest networking - Log a warning when the connection limit is hit and a SYN is dropped
1 parent 9c5cb7c commit 3cd2041

9 files changed

Lines changed: 131 additions & 54 deletions

File tree

Cargo.lock

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

crates/microsandbox/lib/db/mod.rs

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::{
1212
};
1313

1414
use microsandbox_migration::{Migrator, MigratorTrait};
15-
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
15+
use sea_orm::DatabaseConnection;
1616
use tokio::sync::OnceCell;
1717

1818
use crate::MicrosandboxResult;
@@ -126,24 +126,31 @@ async fn connect_and_migrate(
126126
db_path.display()
127127
))
128128
})?;
129-
let db_url = format!("sqlite://{db_path_str}?mode=rwc");
130-
131-
let mut opts = ConnectOptions::new(&db_url);
132-
opts.max_connections(max_connections)
133-
.connect_timeout(Duration::from_secs(connect_timeout_secs))
134-
.sqlx_logging(false);
135-
136-
let conn = Database::connect(opts).await?;
137-
138-
// Enable WAL journal mode, busy timeout, and foreign key enforcement.
139-
// WAL prevents SQLITE_BUSY when multiple processes (CLI + sandbox runtimes)
140-
// access the same database concurrently.
141-
use sea_orm::ConnectionTrait;
142-
conn.execute(sea_orm::Statement::from_string(
143-
sea_orm::DatabaseBackend::Sqlite,
144-
microsandbox_utils::SQLITE_PRAGMAS,
145-
))
146-
.await?;
129+
130+
// Use SqliteConnectOptions so WAL mode, busy_timeout, and foreign keys
131+
// are applied to every connection the pool creates — not just the first.
132+
// The old approach (executing PRAGMAs on the initial connection) left
133+
// connections 2..N without busy_timeout, causing SQLITE_BUSY under
134+
// concurrent sandbox access.
135+
use sea_orm::sqlx::ConnectOptions as _;
136+
use sea_orm::sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
137+
use std::str::FromStr;
138+
139+
let sqlite_opts = SqliteConnectOptions::from_str(&format!("sqlite://{db_path_str}?mode=rwc"))
140+
.map_err(|e| crate::MicrosandboxError::Custom(format!("database url: {e}")))?
141+
.journal_mode(SqliteJournalMode::Wal)
142+
.busy_timeout(Duration::from_secs(5))
143+
.foreign_keys(true)
144+
.disable_statement_logging();
145+
146+
let pool = SqlitePoolOptions::new()
147+
.max_connections(max_connections)
148+
.acquire_timeout(Duration::from_secs(connect_timeout_secs))
149+
.connect_with(sqlite_opts)
150+
.await
151+
.map_err(|e| crate::MicrosandboxError::Custom(format!("database connect: {e}")))?;
152+
153+
let conn = sea_orm::SqlxSqliteConnector::from_sqlx_sqlite_pool(pool);
147154

148155
Migrator::up(&conn, None).await?;
149156

crates/network/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ rustls = { workspace = true }
3434
rustls-native-certs = { workspace = true }
3535
rustls-pemfile = "2"
3636
serde = { workspace = true }
37+
socket2 = "0.5"
3738
smoltcp = { workspace = true, features = [
3839
"alloc",
3940
"async",

crates/network/lib/network.rs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -213,17 +213,32 @@ impl SmoltcpNetwork {
213213
std::thread::Builder::new()
214214
.name("smoltcp-poll".into())
215215
.spawn(move || {
216-
stack::smoltcp_poll_loop(
217-
shared,
218-
poll_config,
219-
network_policy,
220-
dns_config,
221-
tls_state,
222-
published_ports,
223-
max_connections,
224-
tokio_handle,
225-
egress_handle,
226-
);
216+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
217+
stack::smoltcp_poll_loop(
218+
shared,
219+
poll_config,
220+
network_policy,
221+
dns_config,
222+
tls_state,
223+
published_ports,
224+
max_connections,
225+
tokio_handle,
226+
egress_handle,
227+
);
228+
}));
229+
if let Err(e) = result {
230+
let msg = if let Some(s) = e.downcast_ref::<&str>() {
231+
s.to_string()
232+
} else if let Some(s) = e.downcast_ref::<String>() {
233+
s.clone()
234+
} else {
235+
"unknown panic".to_string()
236+
};
237+
tracing::error!(
238+
reason = %msg,
239+
"smoltcp poll thread panicked — all guest networking is dead"
240+
);
241+
}
227242
})
228243
.expect("failed to spawn smoltcp poll thread"),
229244
);

crates/network/lib/proxy.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ async fn tcp_proxy_task(
5454
shared: Arc<SharedState>,
5555
) -> io::Result<()> {
5656
let stream = TcpStream::connect(dst).await?;
57+
// Enable TCP keepalive to prevent NATs/firewalls from dropping idle flows.
58+
let sock = socket2::SockRef::from(&stream);
59+
let keepalive = socket2::TcpKeepalive::new()
60+
.with_time(std::time::Duration::from_secs(30))
61+
.with_interval(std::time::Duration::from_secs(10));
62+
let _ = sock.set_tcp_keepalive(&keepalive);
5763
let (mut server_rx, mut server_tx) = stream.into_split();
5864

5965
let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];

crates/network/lib/stack.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,14 @@ pub fn smoltcp_poll_loop(
273273
.evaluate_egress(dst, Protocol::Tcp)
274274
.is_allow(),
275275
};
276-
if allow && !conn_tracker.has_socket_for(&src, &dst) {
277-
conn_tracker.create_tcp_socket(src, dst, &mut sockets);
276+
if allow
277+
&& !conn_tracker.has_socket_for(&src, &dst)
278+
&& !conn_tracker.create_tcp_socket(src, dst, &mut sockets)
279+
{
280+
tracing::warn!(
281+
%src, %dst,
282+
"connection limit reached, dropping SYN (guest will see RST)"
283+
);
278284
}
279285
// Let smoltcp process — matching socket completes
280286
// handshake, no socket means auto-RST.

crates/network/lib/tls/proxy.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,10 @@ async fn connect_upstream(dst: SocketAddr, sni: &str) -> io::Result<TcpStream> {
150150
let mut last_err = None;
151151
for addr in &addrs {
152152
match TcpStream::connect(addr).await {
153-
Ok(stream) => return Ok(stream),
153+
Ok(stream) => {
154+
set_tcp_keepalive(&stream);
155+
return Ok(stream);
156+
}
154157
Err(e) => {
155158
tracing::debug!(addr = %addr, sni = %sni, error = %e, "upstream connect failed, trying next");
156159
last_err = Some(e);
@@ -163,7 +166,23 @@ async fn connect_upstream(dst: SocketAddr, sni: &str) -> io::Result<TcpStream> {
163166
}
164167

165168
// Fallback: use original destination IP from the guest's connection.
166-
TcpStream::connect(dst).await
169+
let stream = TcpStream::connect(dst).await?;
170+
set_tcp_keepalive(&stream);
171+
Ok(stream)
172+
}
173+
174+
/// Enable TCP keepalive on an upstream connection.
175+
///
176+
/// Keeps long-lived connections (SSE streams, chunked responses) alive
177+
/// through NATs and firewalls that silently drop idle TCP flows.
178+
fn set_tcp_keepalive(stream: &TcpStream) {
179+
let sock = socket2::SockRef::from(stream);
180+
let keepalive = socket2::TcpKeepalive::new()
181+
.with_time(std::time::Duration::from_secs(30))
182+
.with_interval(std::time::Duration::from_secs(10));
183+
if let Err(e) = sock.set_tcp_keepalive(&keepalive) {
184+
tracing::debug!(error = %e, "failed to set TCP keepalive on upstream");
185+
}
167186
}
168187

169188
/// Bypass mode: plain TCP splice, no TLS termination.

crates/runtime/lib/heartbeat.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,22 @@ impl HeartbeatReader {
4242

4343
/// Check whether the sandbox is idle based on the heartbeat.
4444
///
45-
/// Returns `true` if `last_activity` is older than `timeout_secs`.
45+
/// Returns `true` if `last_activity` is older than `timeout_secs` **and**
46+
/// no exec sessions are running. A sandbox with active exec sessions is
47+
/// never considered idle, even if the host hasn't sent serial data recently
48+
/// (the guest may be making outbound API calls without host interaction).
49+
///
4650
/// Returns `false` if the heartbeat file doesn't exist (agent still booting).
4751
pub fn is_idle(&self, timeout_secs: u64) -> bool {
4852
let heartbeat = match self.read() {
4953
Some(hb) => hb,
5054
None => return false,
5155
};
5256

57+
if heartbeat.active_sessions > 0 {
58+
return false;
59+
}
60+
5361
let elapsed = Utc::now()
5462
.signed_duration_since(heartbeat.last_activity)
5563
.num_seconds();

crates/runtime/lib/vm.rs

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use std::time::Duration;
1414
use microsandbox_db::entity::run as run_entity;
1515
use microsandbox_filesystem::{DynFileSystem, PassthroughConfig, PassthroughFs};
1616
use msb_krun::VmBuilder;
17-
use sea_orm::{ColumnTrait, ConnectOptions, Database, DatabaseConnection, EntityTrait, Set};
17+
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, Set};
1818
use serde::Serialize;
1919

2020
use crate::console::{AgentConsoleBackend, ConsoleSharedState};
@@ -684,25 +684,29 @@ async fn connect_db(
684684
db_path: &std::path::Path,
685685
connect_timeout_secs: u64,
686686
) -> RuntimeResult<DatabaseConnection> {
687+
use sea_orm::sqlx::ConnectOptions as _;
688+
use sea_orm::sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
689+
use std::str::FromStr;
690+
687691
let url = format!("sqlite://{}?mode=rwc", db_path.display());
688-
let opts = ConnectOptions::new(url)
692+
693+
// Use SqliteConnectOptions so WAL mode, busy_timeout, and foreign keys
694+
// are set on every connection (not just the first in the pool).
695+
let sqlite_opts = SqliteConnectOptions::from_str(&url)
696+
.map_err(|e| RuntimeError::Custom(format!("database url: {e}")))?
697+
.journal_mode(SqliteJournalMode::Wal)
698+
.busy_timeout(Duration::from_secs(5))
699+
.foreign_keys(true)
700+
.disable_statement_logging();
701+
702+
let pool = SqlitePoolOptions::new()
689703
.max_connections(1)
690-
.connect_timeout(Duration::from_secs(connect_timeout_secs))
691-
.sqlx_logging(false)
692-
.to_owned();
693-
let db = Database::connect(opts)
704+
.acquire_timeout(Duration::from_secs(connect_timeout_secs))
705+
.connect_with(sqlite_opts)
694706
.await
695707
.map_err(|e| RuntimeError::Custom(format!("database connect: {e}")))?;
696708

697-
use sea_orm::ConnectionTrait;
698-
db.execute(sea_orm::Statement::from_string(
699-
sea_orm::DatabaseBackend::Sqlite,
700-
microsandbox_utils::SQLITE_PRAGMAS,
701-
))
702-
.await
703-
.map_err(|e| RuntimeError::Custom(format!("database pragmas: {e}")))?;
704-
705-
Ok(db)
709+
Ok(sea_orm::SqlxSqliteConnector::from_sqlx_sqlite_pool(pool))
706710
}
707711

708712
/// Insert a run record into the database.

0 commit comments

Comments
 (0)