use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use algocline_app::pool::registry::with_registry_lock;
use algocline_app::pool::{PoolError, PoolRegistry, PoolRequest, PoolResponse, PoolResponseData};
use algocline_app::AppConfig;
use algocline_core::QueryId;
use algocline_engine::{Executor, FeedResult, FileCardStore, JsonFileStore, SessionRegistry};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::net::UnixListener;
use tokio::signal::unix::SignalKind;
const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 1800;
enum WorkerPhase {
Idle,
Paused { session_id: String },
Finished,
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
async fn idle_check_future(last_activity: Arc<AtomicI64>, timeout_secs: u64) {
if timeout_secs == 0 {
std::future::pending::<()>().await;
return;
}
let check_interval = tokio::time::Duration::from_secs(1);
let timeout_ms = (timeout_secs as i64).saturating_mul(1000);
loop {
tokio::time::sleep(check_interval).await;
let elapsed_ms = now_ms().saturating_sub(last_activity.load(Ordering::Relaxed));
if elapsed_ms >= timeout_ms {
return;
}
}
}
async fn graceful_shutdown(sid: &str, reg_path: &Path, lock_path: &Path) -> Result<(), PoolError> {
let sid = sid.to_owned();
let reg_path = reg_path.to_path_buf();
let lock_path = lock_path.to_path_buf();
tokio::task::spawn_blocking(move || {
with_registry_lock(&lock_path, || {
let mut reg = PoolRegistry::load_or_default(®_path)?;
let removed = reg.remove(&sid);
if removed {
reg.save(®_path)?;
tracing::info!(worker_sid = %sid, "removed self from registry.json");
} else {
tracing::warn!(
worker_sid = %sid,
"self-remove: entry not found in registry.json (already removed?)"
);
}
Ok(())
})
})
.await
.map_err(|e| PoolError::RegistryCorrupted(format!("spawn_blocking panicked: {e}")))?
}
pub async fn run(sid: String, sock: PathBuf) -> anyhow::Result<()> {
let listener = UnixListener::bind(&sock)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600))
.map_err(|e| anyhow::anyhow!("failed to set permissions on {}: {e}", sock.display()))?;
}
tracing::info!(worker_sid = %sid, sock = %sock.display(), "pool worker starting");
let timeout_secs: u64 = match std::env::var("ALC_POOL_IDLE_TIMEOUT")
.unwrap_or_else(|_| DEFAULT_IDLE_TIMEOUT_SECS.to_string())
.parse::<u64>()
{
Ok(v) => v,
Err(e) => {
tracing::warn!(
worker_sid = %sid,
error = %e,
default_secs = DEFAULT_IDLE_TIMEOUT_SECS,
"ALC_POOL_IDLE_TIMEOUT is not a valid u64 — using default"
);
DEFAULT_IDLE_TIMEOUT_SECS
}
};
tracing::debug!(
worker_sid = %sid,
timeout_secs = timeout_secs,
"idle timeout configured"
);
let mut sigterm = tokio::signal::unix::signal(SignalKind::terminate())?;
let last_activity: Arc<AtomicI64> = Arc::new(AtomicI64::new(now_ms()));
let config = AppConfig::from_env();
let app_dir = config.app_dir();
let pool_state_dir = app_dir.root().join("state").join("pool");
let reg_path = pool_state_dir.join("registry.json");
let lock_path = pool_state_dir.join("registry.lock");
let state_store = Arc::new(JsonFileStore::new(app_dir.state_dir()));
let card_store = Arc::new(FileCardStore::new(app_dir.cards_dir()));
let scenarios_dir = app_dir.scenarios_dir();
let executor = Arc::new(Executor::new(resolve_lib_paths()).await?);
tracing::debug!(worker_sid = %sid, "executor ready");
let registry = SessionRegistry::new();
let mut phase = WorkerPhase::Idle;
'outer: loop {
tracing::debug!(worker_sid = %sid, "waiting for client connection");
let last_activity_clone = Arc::clone(&last_activity);
let idle_future = idle_check_future(last_activity_clone, timeout_secs);
tokio::pin!(idle_future);
let stream = tokio::select! {
biased;
_ = sigterm.recv() => {
tracing::info!(worker_sid = %sid, "SIGTERM received — initiating graceful shutdown");
if let Err(e) = graceful_shutdown(&sid, ®_path, &lock_path).await {
tracing::error!(
worker_sid = %sid,
error = %e,
"graceful shutdown self-remove failed (orphan entry will be GC'd)"
);
}
break 'outer;
}
_ = &mut idle_future => {
tracing::info!(
worker_sid = %sid,
timeout_secs = timeout_secs,
"idle timeout elapsed — initiating graceful shutdown"
);
if let Err(e) = graceful_shutdown(&sid, ®_path, &lock_path).await {
tracing::error!(
worker_sid = %sid,
error = %e,
"graceful shutdown self-remove failed (orphan entry will be GC'd)"
);
}
break 'outer;
}
result = listener.accept() => {
match result {
Ok((stream, _peer)) => stream,
Err(e) => {
tracing::error!(worker_sid = %sid, error = %e, "accept() failed — exiting");
break 'outer;
}
}
}
};
tracing::debug!(worker_sid = %sid, "client connected");
last_activity.store(now_ms(), Ordering::Relaxed);
let (reader, writer) = tokio::io::split(stream);
let mut lines = BufReader::new(reader);
let mut out = BufWriter::new(writer);
let mut should_exit = false;
loop {
let mut line = String::new();
let n = lines.read_line(&mut line).await?;
if n == 0 {
tracing::info!(worker_sid = %sid, "client disconnected (EOF)");
break;
}
last_activity.store(now_ms(), Ordering::Relaxed);
let req: PoolRequest = match serde_json::from_str(line.trim()) {
Ok(r) => r,
Err(e) => {
tracing::warn!(worker_sid = %sid, error = %e, "malformed request");
write_response(
&mut out,
PoolResponse::failure(format!("malformed request: {e}")),
)
.await?;
continue;
}
};
let is_shutdown = matches!(req, PoolRequest::Shutdown);
let resp = dispatch(
&req,
&sid,
&mut phase,
®istry,
&executor,
&state_store,
&card_store,
&scenarios_dir,
)
.await;
write_response(&mut out, resp).await?;
if is_shutdown {
tracing::info!(worker_sid = %sid, "shutdown received — exiting");
if let Err(e) = graceful_shutdown(&sid, ®_path, &lock_path).await {
tracing::error!(
worker_sid = %sid,
error = %e,
"graceful shutdown self-remove failed on Shutdown request"
);
}
should_exit = true;
break;
}
}
if should_exit {
break 'outer;
}
match &phase {
WorkerPhase::Paused { .. } => {
tracing::info!(
worker_sid = %sid,
"connection dropped but session still paused — waiting for reconnect"
);
}
WorkerPhase::Idle | WorkerPhase::Finished => {
tracing::info!(worker_sid = %sid, "session not paused after connection drop — exiting");
break 'outer;
}
}
}
tracing::info!(worker_sid = %sid, "pool worker exiting");
Ok(())
}
async fn write_response(
out: &mut BufWriter<tokio::io::WriteHalf<tokio::net::UnixStream>>,
resp: PoolResponse,
) -> anyhow::Result<()> {
let mut json =
serde_json::to_string(&resp).map_err(|e| anyhow::anyhow!("response serialize: {e}"))?;
json.push('\n');
out.write_all(json.as_bytes()).await?;
out.flush().await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn dispatch(
req: &PoolRequest,
sid: &str,
phase: &mut WorkerPhase,
registry: &SessionRegistry,
executor: &Arc<Executor>,
state_store: &Arc<JsonFileStore>,
card_store: &Arc<FileCardStore>,
scenarios_dir: &Path,
) -> PoolResponse {
match req {
PoolRequest::Handshake { version } => {
let worker_version = env!("CARGO_PKG_VERSION");
if version != worker_version {
tracing::warn!(
worker_sid = %sid,
client_version = %version,
server_version = %worker_version,
"version mismatch"
);
return PoolResponse::failure(format!(
"version mismatch: client={version}, server={worker_version}"
));
}
PoolResponse::success(PoolResponseData::Handshake {
version: worker_version.to_string(),
})
}
PoolRequest::Run {
code,
ctx,
lib_paths: extra_lib_paths,
} => {
if !matches!(phase, WorkerPhase::Idle) {
tracing::warn!(worker_sid = %sid, "Run received but worker is not idle");
return PoolResponse::failure("worker already has an active session");
}
let ctx_value = ctx.clone().unwrap_or(serde_json::Value::Null);
let session = match executor
.start_session(
code.clone(),
ctx_value,
extra_lib_paths.clone(),
vec![], Arc::clone(state_store),
Arc::clone(card_store),
scenarios_dir.to_path_buf(),
)
.await
{
Ok(s) => s,
Err(e) => {
tracing::warn!(worker_sid = %sid, error = %e, "start_session failed");
return PoolResponse::failure(format!("session start failed: {e}"));
}
};
let (session_id, feed_result) = match registry.start_execution(session).await {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(worker_sid = %sid, error = %e, "start_execution failed");
return PoolResponse::failure(format!("execution start failed: {e}"));
}
};
let is_paused = matches!(feed_result, FeedResult::Paused { .. });
let feed_json = match serde_json::to_value(&feed_result) {
Ok(v) => v,
Err(e) => {
tracing::warn!(worker_sid = %sid, error = %e, "FeedResult serialize failed");
return PoolResponse::failure(format!("result serialize failed: {e}"));
}
};
*phase = if is_paused {
WorkerPhase::Paused {
session_id: session_id.clone(),
}
} else {
WorkerPhase::Finished
};
PoolResponse::success(PoolResponseData::Feed {
session_id,
feed_result: feed_json,
})
}
PoolRequest::Continue {
sid: _req_sid,
response,
query_id,
usage,
} => {
let session_id = match phase {
WorkerPhase::Paused { session_id } => session_id.clone(),
_ => {
return PoolResponse::failure("no paused session to continue");
}
};
let qid = QueryId::parse(query_id.as_deref().unwrap_or("q-0"));
let feed_result = match registry
.feed_response(&session_id, &qid, response.clone(), usage.as_ref())
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(worker_sid = %sid, error = %e, "feed_response failed");
return PoolResponse::failure(format!("continue failed: {e}"));
}
};
let is_paused = matches!(feed_result, FeedResult::Paused { .. });
let feed_json = match serde_json::to_value(&feed_result) {
Ok(v) => v,
Err(e) => {
tracing::warn!(worker_sid = %sid, error = %e, "FeedResult serialize failed");
return PoolResponse::failure(format!("result serialize failed: {e}"));
}
};
if !is_paused {
*phase = WorkerPhase::Finished;
}
PoolResponse::success(PoolResponseData::Feed {
session_id,
feed_result: feed_json,
})
}
PoolRequest::Status { include_history } => {
let (has_session, session_id) = match phase {
WorkerPhase::Idle => (false, None),
WorkerPhase::Paused { session_id } => (true, Some(session_id.clone())),
WorkerPhase::Finished => (false, None),
};
let conversation_history = if *include_history {
if let Some(ref active_sid) = session_id {
let snaps = registry.list_snapshots(None, true).await;
snaps.get(active_sid).map(|s| {
s.get("metrics")
.and_then(|m| m.get("conversation_history"))
.cloned()
.unwrap_or_else(|| serde_json::json!([]))
})
} else {
None
}
} else {
None
};
PoolResponse::success(PoolResponseData::Status {
has_session,
session_id,
conversation_history,
})
}
PoolRequest::Shutdown => PoolResponse::success(PoolResponseData::Shutdown),
}
}
fn resolve_lib_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(env_paths) = std::env::var("ALC_PACKAGES_PATH") {
for p in env_paths.split(':') {
let path = PathBuf::from(p);
if path.is_dir() {
paths.push(path);
}
}
}
if let Some(home) = dirs::home_dir() {
let packages = home.join(".algocline").join("packages");
if packages.is_dir() {
paths.push(packages);
}
}
paths
}
#[cfg(test)]
mod tests {
use super::*;
use algocline_app::pool::{PoolRegistry, PoolSessionEntry};
use std::sync::atomic::AtomicI64;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_pool_worker_idle_timeout_self_exit() {
let dir = tempfile::tempdir().expect("tempdir");
let reg_path = dir.path().join("state").join("pool").join("registry.json");
let lock_path = dir.path().join("state").join("pool").join("registry.lock");
let sock_path = dir.path().join("worker.sock");
let alc_home = dir.path().to_path_buf();
let sid = "idle-timeout-test-sid";
let exe = std::env::current_exe().expect("current_exe");
let target_dir = exe
.parent() .and_then(|p| p.parent()) .expect("target dir");
let alc_bin = target_dir.join("alc");
if !alc_bin.exists() {
eprintln!(
"Skipping test_pool_worker_idle_timeout_self_exit: binary {:?} not found. \
Run `cargo build` first.",
alc_bin
);
return;
}
let mut child = tokio::process::Command::new(&alc_bin)
.arg("pool-worker")
.arg("--sid")
.arg(sid)
.arg("--sock")
.arg(sock_path.to_str().expect("sock path utf8"))
.env("ALC_POOL_IDLE_TIMEOUT", "2")
.env("ALC_HOME", alc_home.to_str().expect("alc home utf8"))
.spawn()
.expect("spawn worker");
let worker_pid: u32 = child.id().expect("child PID");
std::fs::create_dir_all(reg_path.parent().expect("reg parent"))
.expect("create registry dir");
with_registry_lock(&lock_path, || {
let mut reg = PoolRegistry::load_or_default(®_path)?;
reg.add(PoolSessionEntry::new(
sid,
worker_pid,
sock_path.clone(),
"0.30.0",
));
reg.save(®_path)
})
.expect("register worker");
tokio::time::sleep(tokio::time::Duration::from_secs(4)).await;
let status = child.try_wait().expect("try_wait");
assert!(
status.is_some(),
"worker should have exited after idle timeout (pid={worker_pid})"
);
let final_reg = PoolRegistry::load_or_default(®_path).expect("load final registry");
let _ = final_reg;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_pool_worker_sigterm_graceful_shutdown() {
let dir = tempfile::tempdir().expect("tempdir");
let reg_path = dir.path().join("state").join("pool").join("registry.json");
let lock_path = dir.path().join("state").join("pool").join("registry.lock");
let sock_path = dir.path().join("sigterm-worker.sock");
let alc_home = dir.path().to_path_buf();
let sid = "sigterm-test-sid";
let exe = std::env::current_exe().expect("current_exe");
let target_dir = exe.parent().and_then(|p| p.parent()).expect("target dir");
let alc_bin = target_dir.join("alc");
if !alc_bin.exists() {
eprintln!(
"Skipping test_pool_worker_sigterm_graceful_shutdown: binary {:?} not found.",
alc_bin
);
return;
}
let mut child = tokio::process::Command::new(&alc_bin)
.arg("pool-worker")
.arg("--sid")
.arg(sid)
.arg("--sock")
.arg(sock_path.to_str().expect("sock path utf8"))
.env("ALC_POOL_IDLE_TIMEOUT", "0") .env("ALC_HOME", alc_home.to_str().expect("alc home utf8"))
.spawn()
.expect("spawn worker");
let worker_pid: u32 = child.id().expect("child PID");
std::fs::create_dir_all(reg_path.parent().expect("reg parent"))
.expect("create registry dir");
with_registry_lock(&lock_path, || {
let mut reg = PoolRegistry::load_or_default(®_path)?;
reg.add(PoolSessionEntry::new(
sid,
worker_pid,
sock_path.clone(),
"0.30.0",
));
reg.save(®_path)
})
.expect("register worker");
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
let pid_i32 = i32::try_from(worker_pid)
.expect("PID fits in i32");
let rc = unsafe { libc::kill(pid_i32, libc::SIGTERM) };
assert_eq!(rc, 0, "kill(SIGTERM) must succeed");
let mut exited = false;
for _ in 0..30 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if let Some(_status) = child.try_wait().expect("try_wait") {
exited = true;
break;
}
}
assert!(
exited,
"worker should exit after SIGTERM (pid={worker_pid})"
);
let final_reg = PoolRegistry::load_or_default(®_path).expect("load final registry");
assert!(
final_reg.find(sid).is_none(),
"registry entry must be removed after graceful shutdown"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn test_atomic_i64_last_activity_concurrent_update() {
let last_activity = Arc::new(AtomicI64::new(0));
let n_tasks = 8usize;
let n_iters = 1000usize;
let mut handles = Vec::new();
for _ in 0..n_tasks {
let la = Arc::clone(&last_activity);
handles.push(tokio::spawn(async move {
for _ in 0..n_iters {
la.store(now_ms(), Ordering::Relaxed);
tokio::task::yield_now().await;
}
}));
}
for h in handles {
h.await.expect("task did not panic");
}
let final_val = last_activity.load(Ordering::Relaxed);
assert!(
final_val > 0,
"last_activity must be positive after concurrent stores"
);
}
#[tokio::test]
async fn test_idle_check_future_disabled() {
let last_activity = Arc::new(AtomicI64::new(now_ms()));
let result = tokio::time::timeout(
tokio::time::Duration::from_millis(100),
idle_check_future(Arc::clone(&last_activity), 0),
)
.await;
assert!(
result.is_err(),
"idle_check_future(0) must not resolve (returns pending())"
);
}
#[tokio::test]
async fn test_idle_check_future_fires() {
let last_activity = Arc::new(AtomicI64::new(0));
let result = tokio::time::timeout(
tokio::time::Duration::from_secs(2),
idle_check_future(Arc::clone(&last_activity), 1),
)
.await;
assert!(
result.is_ok(),
"idle_check_future(1) must resolve when activity is stale"
);
}
}