Skip to content

Commit c923e30

Browse files
authored
fix(pool): force-evict hung sessions so zombie slots cannot exhaust the pool (#1300)
* refactor(pool): extract idle and eviction classification helpers Pure helpers for cleanup_idle stale detection and get_or_create eviction candidate selection, with characterization tests for current behavior. * feat(pool): add lock-free session activity tracking SessionActivity exposes last-active and in-flight state without the connection mutex; pool state mirrors activity handles alongside cancel handles. * fix(pool): force-evict hung sessions past hard-timeout grace cleanup_idle detects in-flight sessions stuck beyond prompt_hard_timeout_secs + hung_grace_secs and evicts them lock-free. * test(pool): cover hung classification and eviction bookkeeping Extract classify_hung and apply_hung_eviction helpers with unit tests; add SessionActivity touch/in-flight tests. * docs(config): document pool.hung_grace_secs Document the hung-session grace period and apply rustfmt to touched acp files. * fix(pool)!: make hung eviction reset-like and harden cancel path Hung sessions are purged from suspended/persisted (not suspended for resume): the old streaming task may still own an in-flight turn, so resuming the same session id is unsafe. Eviction uses the connection handle captured at classification time via remove_if_same_handle, and session/cancel is sent from a detached 5s-timeout task outside the state lock. SessionActivity gains Release/Acquire ordering, a saturating age() helper, and a test-only last_active setter. * feat(pool): wire hung threshold from config into SessionPool::new SessionPool::new now takes hung_threshold_secs; main.rs passes prompt_hard_timeout_secs + hung_grace_secs and the builder setter is removed. Document hung_grace_secs in config.toml.example. * fix(pool): never await existing connection mutex under the creating gate Awaiting a hung connection's mutex in get_or_create while holding the per-thread creating gate would permanently jam all future messages for that thread. try_lock and treat lock-held as busy/alive (F1). * fix(pool): use monotonic clock for session activity timestamps A wall-clock step (NTP, manual change) could make an active session look hours stale and trigger a false hung eviction. Track activity as milliseconds since a process-boot Instant instead of SystemTime (F2). * fix(pool): self-heal stale in_flight flag during cleanup sweep If cleanup_idle can try_lock the connection, no turn is streaming, so a true in_flight flag means the turn aborted without prompt_done. Reset it to prevent a future false hung classification (F3). * fix(pool): kill hung agent process group after eviction The hung task never unwinds, so AcpConnection::Drop never fires and the ACP child would leak forever. Track child pgids lock-free in PoolState and, after the 5s session/cancel attempt, SIGTERM then SIGKILL the process group from the detached task (F4). Also enrich the eviction warn with session id, age_secs and threshold_secs (F7). * fix(acp): bound stdin writes in send_raw with a 10s timeout A hung agent can stop draining stdin; an unbounded write_all would then block the caller (and any mutex it holds) forever (F5). * fix(pool): keep the creating gate when purging a hung session The creating gate is concurrency control, not session state: removing it while a holder still owns the old gate Arc lets a concurrent get_or_create mint a fresh gate and run two creations for the same key (F6). * test(dispatch): derive hung threshold from config defaults Replace the magic 30*60+120 literal with the actual serde default fns so the test constant cannot drift from config defaults (F8).
1 parent db98646 commit c923e30

7 files changed

Lines changed: 488 additions & 31 deletions

File tree

config.toml.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ session_ttl_hours = 24
180180
# prompt_hard_timeout_secs = 1800
181181
# Liveness-check cadence (sec) for the recv loop; see #732. Default: 30.
182182
# liveness_check_secs = 30
183+
# Grace (sec) past prompt_hard_timeout_secs before a hung in-flight session is
184+
# force-evicted from the pool. Default: 120.
185+
# hung_grace_secs = 120
183186

184187
[markdown]
185188
tables = "code" # "code" (default) | "bullets" | "off"

crates/openab-core/src/acp/connection.rs

Lines changed: 126 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::acp::protocol::{
44
use anyhow::{anyhow, Result};
55
use serde_json::{json, Value};
66
use std::collections::HashMap;
7-
use std::sync::atomic::{AtomicU64, Ordering};
7+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
88
use std::sync::Arc;
99
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
1010
use tokio::process::{Child, ChildStdin};
@@ -107,6 +107,68 @@ impl ContentBlock {
107107
}
108108
}
109109

110+
/// Lock-free view of session activity, readable without the connection mutex.
111+
pub struct SessionActivity {
112+
/// Milliseconds since process boot (monotonic) of the last observed activity.
113+
last_active_ms: AtomicU64,
114+
/// True while a prompt turn is in flight (mutex likely held).
115+
prompt_in_flight: AtomicBool,
116+
}
117+
118+
impl Default for SessionActivity {
119+
fn default() -> Self {
120+
Self::new()
121+
}
122+
}
123+
124+
impl SessionActivity {
125+
pub fn new() -> Self {
126+
Self {
127+
last_active_ms: AtomicU64::new(Self::now_ms()),
128+
prompt_in_flight: AtomicBool::new(false),
129+
}
130+
}
131+
132+
/// Monotonic milliseconds since first use (process boot). SystemTime is
133+
/// unsuitable here: a wall-clock step (NTP, manual change) could make an
134+
/// active session look hours stale and trigger a false hung eviction.
135+
fn now_ms() -> u64 {
136+
use std::sync::OnceLock;
137+
static BOOT: OnceLock<std::time::Instant> = OnceLock::new();
138+
BOOT.get_or_init(std::time::Instant::now)
139+
.elapsed()
140+
.as_millis() as u64
141+
}
142+
143+
pub fn touch(&self) {
144+
self.last_active_ms.store(Self::now_ms(), Ordering::Release);
145+
}
146+
147+
pub fn set_in_flight(&self, in_flight: bool) {
148+
self.prompt_in_flight.store(in_flight, Ordering::Release);
149+
}
150+
151+
/// Milliseconds since process boot of the last observed activity.
152+
pub fn last_active_ms(&self) -> u64 {
153+
self.last_active_ms.load(Ordering::Acquire)
154+
}
155+
156+
/// Elapsed time since the last observed activity (saturating at zero).
157+
pub fn age(&self) -> std::time::Duration {
158+
let last = self.last_active_ms.load(Ordering::Acquire);
159+
std::time::Duration::from_millis(Self::now_ms().saturating_sub(last))
160+
}
161+
162+
pub fn in_flight(&self) -> bool {
163+
self.prompt_in_flight.load(Ordering::Acquire)
164+
}
165+
166+
#[cfg(test)]
167+
pub(crate) fn set_last_active_ms(&self, ms: u64) {
168+
self.last_active_ms.store(ms, Ordering::Release);
169+
}
170+
}
171+
110172
pub struct AcpConnection {
111173
_proc: Child,
112174
/// PID of the direct child, used as the process group ID for cleanup.
@@ -119,6 +181,7 @@ pub struct AcpConnection {
119181
pub supports_load_session: bool,
120182
pub config_options: Vec<ConfigOption>,
121183
pub last_active: Instant,
184+
pub activity: Arc<SessionActivity>,
122185
pub session_reset: bool,
123186
_reader_handle: JoinHandle<()>,
124187
_stderr_handle: Option<JoinHandle<()>>,
@@ -371,7 +434,8 @@ impl AcpConnection {
371434
Ok(_) => {
372435
let trimmed = line.trim();
373436
if !trimmed.is_empty() {
374-
let sanitized: String = trimmed.chars()
437+
let sanitized: String = trimmed
438+
.chars()
375439
.filter(|c| !c.is_control() || *c == '\t')
376440
.collect();
377441
if !sanitized.is_empty() {
@@ -399,6 +463,8 @@ impl AcpConnection {
399463
notify_tx.clone(),
400464
));
401465

466+
let activity = Arc::new(SessionActivity::new());
467+
402468
Ok(Self {
403469
_proc: proc,
404470
child_pgid,
@@ -410,6 +476,7 @@ impl AcpConnection {
410476
supports_load_session: false,
411477
config_options: Vec::new(),
412478
last_active: Instant::now(),
479+
activity,
413480
session_reset: false,
414481
_reader_handle: reader_handle,
415482
_stderr_handle: stderr_handle,
@@ -422,10 +489,17 @@ impl AcpConnection {
422489

423490
pub(crate) async fn send_raw(&self, data: &str) -> Result<()> {
424491
debug!(data = data.trim(), "acp_send");
425-
let mut w = self.stdin.lock().await;
426-
w.write_all(data.as_bytes()).await?;
427-
w.write_all(b"\n").await?;
428-
w.flush().await?;
492+
// A hung agent can stop draining stdin; bound the write so callers
493+
// (and the mutexes they hold) can never block on it indefinitely.
494+
tokio::time::timeout(std::time::Duration::from_secs(10), async {
495+
let mut w = self.stdin.lock().await;
496+
w.write_all(data.as_bytes()).await?;
497+
w.write_all(b"\n").await?;
498+
w.flush().await?;
499+
Ok::<(), anyhow::Error>(())
500+
})
501+
.await
502+
.map_err(|_| anyhow!("stdin write timeout"))??;
429503
Ok(())
430504
}
431505

@@ -572,6 +646,8 @@ impl AcpConnection {
572646
content_blocks: Vec<ContentBlock>,
573647
) -> Result<(mpsc::UnboundedReceiver<JsonRpcMessage>, u64)> {
574648
self.last_active = Instant::now();
649+
self.activity.touch();
650+
self.activity.set_in_flight(true);
575651

576652
let session_id = self
577653
.acp_session_id
@@ -606,6 +682,8 @@ impl AcpConnection {
606682
/// Call after prompt streaming is done to clean up subscriber.
607683
pub async fn prompt_done(&mut self) {
608684
*self.notify_tx.lock().await = None;
685+
self.activity.touch();
686+
self.activity.set_in_flight(false);
609687
self.last_active = Instant::now();
610688
}
611689

@@ -634,6 +712,15 @@ impl AcpConnection {
634712
Arc::clone(&self.stdin)
635713
}
636714

715+
pub fn activity_handle(&self) -> Arc<SessionActivity> {
716+
Arc::clone(&self.activity)
717+
}
718+
719+
/// Process-group id of the agent child, readable without any lock state.
720+
pub fn child_pgid(&self) -> Option<i32> {
721+
self.child_pgid
722+
}
723+
637724
pub fn alive(&self) -> bool {
638725
!self._reader_handle.is_finished()
639726
}
@@ -872,13 +959,10 @@ mod reader_loop_tests {
872959
agent_stdout_writer.write_all(stale).await.unwrap();
873960
agent_stdout_writer.flush().await.unwrap();
874961

875-
let forwarded = tokio::time::timeout(
876-
std::time::Duration::from_secs(2),
877-
sub_rx.recv(),
878-
)
879-
.await
880-
.expect("subscriber should receive stale message before timeout")
881-
.expect("subscriber channel should not be closed");
962+
let forwarded = tokio::time::timeout(std::time::Duration::from_secs(2), sub_rx.recv())
963+
.await
964+
.expect("subscriber should receive stale message before timeout")
965+
.expect("subscriber channel should not be closed");
882966
assert_eq!(forwarded.id, Some(42));
883967
assert!(pending.lock().await.is_empty());
884968

@@ -934,4 +1018,33 @@ mod reader_loop_tests {
9341018
drop(agent_stdout_writer);
9351019
handle.await.unwrap();
9361020
}
1021+
1022+
#[test]
1023+
fn session_activity_touch_advances_last_active() {
1024+
let activity = SessionActivity::new();
1025+
// Warm the monotonic clock past zero so a backdated value is older.
1026+
std::thread::sleep(std::time::Duration::from_millis(10));
1027+
activity.set_last_active_ms(0);
1028+
let before = activity.last_active_ms();
1029+
activity.touch();
1030+
assert!(activity.last_active_ms() > before);
1031+
// Backdated last_active yields a positive age; touch resets it near zero.
1032+
activity.set_last_active_ms(0);
1033+
assert!(activity.age() >= std::time::Duration::from_millis(10));
1034+
activity.touch();
1035+
assert!(activity.age() < std::time::Duration::from_secs(60));
1036+
// A future timestamp must not underflow: age saturates at zero.
1037+
activity.set_last_active_ms(u64::MAX);
1038+
assert_eq!(activity.age(), std::time::Duration::ZERO);
1039+
}
1040+
1041+
#[test]
1042+
fn session_activity_set_in_flight_round_trips() {
1043+
let activity = SessionActivity::new();
1044+
assert!(!activity.in_flight());
1045+
activity.set_in_flight(true);
1046+
assert!(activity.in_flight());
1047+
activity.set_in_flight(false);
1048+
assert!(!activity.in_flight());
1049+
}
9371050
}

0 commit comments

Comments
 (0)