Skip to content

Commit 659475a

Browse files
committed
fix(workspace): avoid blocking attach during snapshot refresh
Move provider session enumeration out of the global DB lock during workspace snapshot and attach flows so refresh no longer hangs after a session starts. Also tighten session runtime binding lock ordering to prevent stale terminal cleanup from reintroducing lock contention.
1 parent c4be532 commit 659475a

4 files changed

Lines changed: 253 additions & 58 deletions

File tree

apps/server/Cargo.lock

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

apps/server/src/infra/db.rs

Lines changed: 163 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,136 @@ fn load_mounted_session_ids_from_conn(conn: &Connection, workspace_id: &str) ->
13501350
.unwrap_or_default()
13511351
}
13521352

1353+
struct SnapshotBase {
1354+
workspace: WorkspaceSummary,
1355+
view_state: WorkspaceViewState,
1356+
visible_session_ids: Vec<String>,
1357+
terminals: Vec<TerminalInfo>,
1358+
}
1359+
1360+
fn load_snapshot_base_from_conn(
1361+
conn: &Connection,
1362+
workspace_id: &str,
1363+
) -> Result<SnapshotBase, String> {
1364+
let workspace = row_to_workspace_summary(load_workspace_row(conn, workspace_id)?);
1365+
let view_state = match load_view_state_from_conn(conn, workspace_id) {
1366+
Ok(value) => value,
1367+
Err(_) => default_view_state(DEFAULT_SESSION_SLOT_ID.to_string()),
1368+
};
1369+
let visible_session_ids = ordered_visible_session_ids(&view_state);
1370+
let terminals: Vec<TerminalInfo> = load_persisted_terminals_from_conn(conn, workspace_id)?
1371+
.into_iter()
1372+
.map(|row| TerminalInfo {
1373+
id: row.terminal_id,
1374+
output: row.output,
1375+
recoverable: row.recoverable,
1376+
})
1377+
.collect();
1378+
Ok(SnapshotBase {
1379+
workspace,
1380+
view_state,
1381+
visible_session_ids,
1382+
terminals,
1383+
})
1384+
}
1385+
1386+
fn apply_provider_sessions_to_snapshot_base(
1387+
mut base: SnapshotBase,
1388+
provider_sessions: &[ProviderWorkspaceSession],
1389+
) -> (WorkspaceSnapshot, bool) {
1390+
let mut sessions = Vec::new();
1391+
let mut binding_snapshots_changed = false;
1392+
1393+
for session_id in &base.visible_session_ids {
1394+
let Some(binding_index) = base
1395+
.view_state
1396+
.session_bindings
1397+
.iter()
1398+
.position(|binding| binding.session_id == *session_id)
1399+
else {
1400+
continue;
1401+
};
1402+
let binding = base.view_state.session_bindings[binding_index].clone();
1403+
if let Some(provider_session) = provider_session_for_binding(provider_sessions, &binding) {
1404+
if base.view_state.session_bindings[binding_index].title_snapshot != provider_session.title
1405+
|| base.view_state.session_bindings[binding_index].last_seen_at
1406+
!= provider_session.last_active_at
1407+
{
1408+
base.view_state.session_bindings[binding_index].title_snapshot =
1409+
provider_session.title.clone();
1410+
base.view_state.session_bindings[binding_index].last_seen_at =
1411+
provider_session.last_active_at;
1412+
binding_snapshots_changed = true;
1413+
}
1414+
}
1415+
sessions.push(resolve_bound_session_from_binding(
1416+
&base.view_state.session_bindings[binding_index],
1417+
provider_sessions,
1418+
));
1419+
}
1420+
1421+
(
1422+
WorkspaceSnapshot {
1423+
workspace: base.workspace,
1424+
sessions,
1425+
view_state: base.view_state,
1426+
terminals: base.terminals,
1427+
},
1428+
binding_snapshots_changed,
1429+
)
1430+
}
1431+
1432+
fn refresh_binding_snapshots_from_conn(
1433+
conn: &Connection,
1434+
workspace_id: &str,
1435+
updated_view_state: &WorkspaceViewState,
1436+
) -> Result<(), String> {
1437+
let mut latest_view_state = match load_view_state_from_conn(conn, workspace_id) {
1438+
Ok(value) => value,
1439+
Err(_) => default_view_state(DEFAULT_SESSION_SLOT_ID.to_string()),
1440+
};
1441+
let mut changed = false;
1442+
1443+
for updated_binding in &updated_view_state.session_bindings {
1444+
let Some(latest_binding) = latest_view_state
1445+
.session_bindings
1446+
.iter_mut()
1447+
.find(|binding| binding.session_id == updated_binding.session_id)
1448+
else {
1449+
continue;
1450+
};
1451+
if latest_binding.title_snapshot != updated_binding.title_snapshot {
1452+
latest_binding.title_snapshot = updated_binding.title_snapshot.clone();
1453+
changed = true;
1454+
}
1455+
if latest_binding.last_seen_at != updated_binding.last_seen_at {
1456+
latest_binding.last_seen_at = updated_binding.last_seen_at;
1457+
changed = true;
1458+
}
1459+
}
1460+
1461+
if changed {
1462+
save_view_state_to_conn(conn, workspace_id, &latest_view_state)?;
1463+
}
1464+
Ok(())
1465+
}
1466+
1467+
pub(crate) fn build_snapshot_outside_db_lock(
1468+
state: State<'_, AppState>,
1469+
workspace_id: &str,
1470+
) -> Result<WorkspaceSnapshot, String> {
1471+
let base = with_db(state, |conn| load_snapshot_base_from_conn(conn, workspace_id))?;
1472+
let provider_sessions = list_provider_workspace_sessions(&base.workspace.project_path)?;
1473+
let (snapshot, binding_snapshots_changed) =
1474+
apply_provider_sessions_to_snapshot_base(base, &provider_sessions);
1475+
if binding_snapshots_changed {
1476+
with_db(state, |conn| {
1477+
refresh_binding_snapshots_from_conn(conn, workspace_id, &snapshot.view_state)
1478+
})?;
1479+
}
1480+
Ok(snapshot)
1481+
}
1482+
13531483
pub(crate) fn build_snapshot_from_conn(
13541484
conn: &Connection,
13551485
workspace_id: &str,
@@ -1394,7 +1524,7 @@ pub(crate) fn build_snapshot_from_conn(
13941524
if binding_snapshots_changed {
13951525
save_view_state_to_conn(conn, workspace_id, &view_state)?;
13961526
}
1397-
let terminals = load_persisted_terminals_from_conn(conn, workspace_id)?
1527+
let terminals: Vec<TerminalInfo> = load_persisted_terminals_from_conn(conn, workspace_id)?
13981528
.into_iter()
13991529
.map(|row| TerminalInfo {
14001530
id: row.terminal_id,
@@ -1606,17 +1736,17 @@ pub(crate) fn workspace_snapshot(
16061736
state: State<'_, AppState>,
16071737
workspace_id: &str,
16081738
) -> Result<WorkspaceSnapshot, String> {
1609-
with_db(state, |conn| build_snapshot_from_conn(conn, workspace_id))
1739+
build_snapshot_outside_db_lock(state, workspace_id)
16101740
}
16111741

16121742
pub(crate) fn load_workspace_slot_session(
16131743
state: State<'_, AppState>,
16141744
workspace_id: &str,
16151745
session_id: &str,
16161746
) -> Result<SessionInfo, String> {
1617-
with_db(state, |conn| {
1747+
let (workspace, view_state, binding_index) = with_db(state, |conn| {
16181748
let workspace = load_workspace_row(conn, workspace_id)?;
1619-
let mut view_state = load_view_state_from_conn(conn, workspace_id).or_else(|_| {
1749+
let view_state = load_view_state_from_conn(conn, workspace_id).or_else(|_| {
16201750
Ok::<WorkspaceViewState, String>(default_view_state(
16211751
DEFAULT_SESSION_SLOT_ID.to_string(),
16221752
))
@@ -1628,25 +1758,36 @@ pub(crate) fn load_workspace_slot_session(
16281758
else {
16291759
return Err("session_not_found".to_string());
16301760
};
1631-
let provider_sessions = list_provider_workspace_sessions(&workspace.root_path)?;
1632-
let binding = view_state.session_bindings[binding_index].clone();
1633-
if let Some(provider_session) = provider_session_for_binding(&provider_sessions, &binding) {
1634-
if view_state.session_bindings[binding_index].title_snapshot != provider_session.title
1635-
|| view_state.session_bindings[binding_index].last_seen_at
1636-
!= provider_session.last_active_at
1637-
{
1638-
view_state.session_bindings[binding_index].title_snapshot =
1639-
provider_session.title.clone();
1640-
view_state.session_bindings[binding_index].last_seen_at =
1641-
provider_session.last_active_at;
1642-
save_view_state_to_conn(conn, workspace_id, &view_state)?;
1643-
}
1761+
Ok((workspace, view_state, binding_index))
1762+
})?;
1763+
1764+
let provider_sessions = list_provider_workspace_sessions(&workspace.root_path)?;
1765+
let binding = view_state.session_bindings[binding_index].clone();
1766+
let mut updated_view_state = view_state.clone();
1767+
let mut binding_snapshot_changed = false;
1768+
if let Some(provider_session) = provider_session_for_binding(&provider_sessions, &binding) {
1769+
if updated_view_state.session_bindings[binding_index].title_snapshot != provider_session.title
1770+
|| updated_view_state.session_bindings[binding_index].last_seen_at
1771+
!= provider_session.last_active_at
1772+
{
1773+
updated_view_state.session_bindings[binding_index].title_snapshot =
1774+
provider_session.title.clone();
1775+
updated_view_state.session_bindings[binding_index].last_seen_at =
1776+
provider_session.last_active_at;
1777+
binding_snapshot_changed = true;
16441778
}
1645-
Ok(resolve_bound_session_from_binding(
1646-
&binding,
1647-
&provider_sessions,
1648-
))
1649-
})
1779+
}
1780+
1781+
if binding_snapshot_changed {
1782+
with_db(state, |conn| {
1783+
refresh_binding_snapshots_from_conn(conn, workspace_id, &updated_view_state)
1784+
})?;
1785+
}
1786+
1787+
Ok(resolve_bound_session_from_binding(
1788+
&updated_view_state.session_bindings[binding_index],
1789+
&provider_sessions,
1790+
))
16501791
}
16511792

16521793
pub(crate) fn upsert_workspace_session_binding(

apps/server/src/services/session_runtime.rs

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,39 @@ pub(crate) fn bind_session_runtime(
5959
state: State<'_, AppState>,
6060
) -> Result<(), String> {
6161
let key = session_runtime_key(workspace_id, session_id);
62-
let mut terminal_bindings = state
63-
.terminal_runtime_bindings
64-
.lock()
65-
.map_err(|e| e.to_string())?;
66-
let mut session_bindings = state
67-
.session_runtime_bindings
68-
.lock()
69-
.map_err(|e| e.to_string())?;
62+
// Lock in order: terminal_runtimes -> terminal_runtime_bindings -> session_runtime_bindings.
63+
// Do not acquire terminals/db while holding the binding locks, otherwise we can
64+
// deadlock with terminal_close (terminals -> terminal_runtime_bindings).
65+
drop(
66+
state
67+
.terminal_runtimes
68+
.lock()
69+
.map_err(|e| e.to_string())?,
70+
);
71+
let stale_terminal_id = {
72+
let mut terminal_bindings = state
73+
.terminal_runtime_bindings
74+
.lock()
75+
.map_err(|e| e.to_string())?;
76+
let mut session_bindings = state
77+
.session_runtime_bindings
78+
.lock()
79+
.map_err(|e| e.to_string())?;
7080

71-
if let Some(existing_terminal_id) = session_bindings.get(&key).copied() {
72-
terminal_bindings.remove(&existing_terminal_id);
81+
let stale_terminal_id = session_bindings.get(&key).copied();
82+
if let Some(existing_terminal_id) = stale_terminal_id {
83+
terminal_bindings.remove(&existing_terminal_id);
84+
}
85+
if let Some(existing_key) = terminal_bindings.get(&terminal_id).cloned() {
86+
session_bindings.remove(&existing_key);
87+
}
88+
89+
session_bindings.insert(key.clone(), terminal_id);
90+
terminal_bindings.insert(terminal_id, key);
91+
stale_terminal_id
92+
};
93+
94+
if let Some(existing_terminal_id) = stale_terminal_id {
7395
let stale_terminal_key = terminal_key(workspace_id, existing_terminal_id);
7496
let stale_terminal_is_live = state
7597
.terminals
@@ -80,19 +102,20 @@ pub(crate) fn bind_session_runtime(
80102
let _ = crate::delete_workspace_terminal(state, workspace_id, existing_terminal_id);
81103
}
82104
}
83-
if let Some(existing_key) = terminal_bindings.get(&terminal_id).cloned() {
84-
session_bindings.remove(&existing_key);
85-
}
86105

87-
session_bindings.insert(key.clone(), terminal_id);
88-
terminal_bindings.insert(terminal_id, key);
89106
Ok(())
90107
}
91108

92109
pub(crate) fn unbind_session_runtime_by_terminal(
93110
terminal_id: u64,
94111
state: State<'_, AppState>,
95112
) -> Result<Option<String>, String> {
113+
drop(
114+
state
115+
.terminal_runtimes
116+
.lock()
117+
.map_err(|e| e.to_string())?,
118+
);
96119
let session_key = state
97120
.terminal_runtime_bindings
98121
.lock()
@@ -114,6 +137,12 @@ pub(crate) fn unbind_session_runtime_by_session(
114137
state: State<'_, AppState>,
115138
) -> Result<(), String> {
116139
let key = session_runtime_key(workspace_id, session_id);
140+
drop(
141+
state
142+
.terminal_runtimes
143+
.lock()
144+
.map_err(|e| e.to_string())?,
145+
);
117146
let terminal_id = state
118147
.session_runtime_bindings
119148
.lock()
@@ -169,7 +198,7 @@ pub(crate) fn collect_workspace_session_runtime_bindings(
169198
) -> Result<Vec<SessionRuntimeBindingInfo>, String> {
170199
let prefix = format!("{workspace_id}:");
171200
let runtime_registry = state.terminal_runtimes.lock().map_err(|e| e.to_string())?;
172-
let bindings = state
201+
let bindings: Vec<SessionRuntimeBindingInfo> = state
173202
.session_runtime_bindings
174203
.lock()
175204
.map_err(|e| e.to_string())?
@@ -311,15 +340,6 @@ pub(crate) fn session_runtime_start(
311340
state: State<'_, AppState>,
312341
) -> Result<SessionRuntimeStartResult, String> {
313342
let binding_key = session_runtime_key(&params.workspace_id, &params.session_id);
314-
let existing_terminal_id = {
315-
state
316-
.session_runtime_bindings
317-
.lock()
318-
.map_err(|e| e.to_string())?
319-
.get(&binding_key)
320-
.copied()
321-
};
322-
323343
let (workspace_cwd, workspace_target) = workspace_access_context(state, &params.workspace_id)?;
324344
let session = crate::services::workspace::resolve_session_for_slot(
325345
state,
@@ -347,13 +367,31 @@ pub(crate) fn session_runtime_start(
347367
)
348368
})?;
349369
let settings = load_or_default_app_settings(state)?;
370+
371+
// Check if this session already has a running terminal (fast path).
372+
// We lock terminal_runtimes FIRST, then session_runtime_bindings to avoid
373+
// a 3-way lock cycle with:
374+
// - bootstrap: terminal_runtimes -> session_runtime_bindings
375+
// - PTY reader: terminal_runtime_bindings -> terminal_runtimes
376+
// - session_runtime_start: session_runtime_bindings -> (bind_session_runtime needs terminal_runtime_bindings)
377+
// By following terminal_runtimes -> session_runtime_bindings, we avoid holding
378+
// session_runtime_bindings when bind_session_runtime needs terminal_runtime_bindings.
350379
let existing_runtime = state
351380
.terminal_runtimes
352381
.lock()
353382
.map_err(|e| e.to_string())?
354383
.by_session(&params.workspace_id, &params.session_id)
355384
.cloned();
356385

386+
let existing_terminal_id = {
387+
state
388+
.session_runtime_bindings
389+
.lock()
390+
.map_err(|e| e.to_string())?
391+
.get(&binding_key)
392+
.copied()
393+
};
394+
357395
if let Some(existing_terminal_id) = existing_terminal_id {
358396
let terminal_key = terminal_key(&params.workspace_id, existing_terminal_id);
359397
let is_live = state

0 commit comments

Comments
 (0)