use std::path::{Path, PathBuf};
use std::time::Duration;
use interprocess::local_socket::tokio::prelude::*;
use interprocess::local_socket::traits::Stream as _;
use interprocess::local_socket::{GenericFilePath, ListenerOptions, Name};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tracing::{debug, warn};
use crate::db::{ReadonlyRows, Value};
pub(crate) const IPC_ROW_LIMIT: usize = 10_000;
#[cfg(not(windows))]
const IPC_SOCKET_FILE_NAME: &str = "mahbot-debug.sock";
#[cfg(windows)]
const IPC_PIPE_PREFIX: &str = r"\\.\pipe\mahbot-debug-";
const IPC_BOUND_TIMEOUT_SECS: u64 = 15;
pub(crate) fn ipc_bound_timeout() -> std::time::Duration {
crate::util::env_duration_secs("MAHBOT_IPC_BOUND_TIMEOUT_SECS", IPC_BOUND_TIMEOUT_SECS)
}
const IPC_RETRY_BACKOFF_MS: [u64; 6] = [50, 100, 200, 400, 800, 1600];
const FRAME_LEN: usize = std::mem::size_of::<u32>();
const MAX_FRAME_LEN: usize = 64 * 1024 * 1024;
#[must_use]
fn socket_path(storage_root: &Path) -> PathBuf {
#[cfg(windows)]
{
use sha2::{Digest, Sha256};
let resolved =
std::fs::canonicalize(storage_root).unwrap_or_else(|_| storage_root.to_path_buf());
let digest = crate::util::hex_string(&Sha256::digest(
resolved.to_string_lossy().to_lowercase().as_bytes(),
));
PathBuf::from(format!("{IPC_PIPE_PREFIX}{}", &digest[..16]))
}
#[cfg(not(windows))]
{
storage_root.join(IPC_SOCKET_FILE_NAME)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "t", content = "v", rename_all = "snake_case")]
pub(crate) enum WireValue {
Null,
Integer(i64),
Real(#[serde(deserialize_with = "de_real")] f64),
Text(String),
Blob(String),
}
fn de_real<'de, D>(d: D) -> std::result::Result<f64, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = <Option<f64> as serde::Deserialize>::deserialize(d)?;
Ok(v.unwrap_or(f64::NAN))
}
fn decode_blob(b: &str) -> Vec<u8> {
crate::util::base64_decode(b).unwrap_or_default()
}
impl WireValue {
pub(crate) fn from_turso(v: &Value) -> Self {
match v {
Value::Null => WireValue::Null,
Value::Integer(i) => WireValue::Integer(*i),
Value::Real(f) => WireValue::Real(*f),
Value::Text(s) => WireValue::Text(s.clone()),
Value::Blob(b) => WireValue::Blob(crate::util::base64_encode(b)),
}
}
pub(crate) fn to_turso(&self) -> Value {
match self {
WireValue::Null => Value::Null,
WireValue::Integer(i) => Value::Integer(*i),
WireValue::Real(f) => Value::Real(*f),
WireValue::Text(s) => Value::Text(s.clone()),
WireValue::Blob(b) => Value::Blob(decode_blob(b)),
}
}
#[must_use]
pub(crate) fn format(&self) -> String {
crate::db::format_cell(&self.to_turso())
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct IpcRequest {
pub store: String, pub sql: String,
#[serde(default)]
pub params: Vec<WireValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub call: Option<CallRequest>,
}
pub(crate) const CALL_STORE: &str = "cli2";
pub(crate) const CALL_STORE_BASE: &str = "cli";
pub(crate) const CALL_STORES: &[&str] = &[CALL_STORE, CALL_STORE_BASE];
const ISSUE_OPERATION: &str = "__issue";
const ISSUE_BOUND: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct CallRequest {
pub tool: String,
pub args: serde_json::Value,
#[serde(default)]
pub secrets: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_dir: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CallOutcome {
Done,
Partial,
Refused,
NotAccepted,
Unavailable,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct CallReply {
pub outcome: CallOutcome,
pub answer: String,
}
impl CallReply {
pub(crate) fn done(answer: impl Into<String>) -> Self {
Self {
outcome: CallOutcome::Done,
answer: answer.into(),
}
}
pub(crate) fn partial(answer: impl Into<String>) -> Self {
Self {
outcome: CallOutcome::Partial,
answer: answer.into(),
}
}
pub(crate) fn refused(answer: impl Into<String>) -> Self {
Self {
outcome: CallOutcome::Refused,
answer: answer.into(),
}
}
pub(crate) fn not_accepted(answer: impl Into<String>) -> Self {
Self {
outcome: CallOutcome::NotAccepted,
answer: answer.into(),
}
}
pub(crate) fn unavailable(answer: impl Into<String>) -> Self {
Self {
outcome: CallOutcome::Unavailable,
answer: answer.into(),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct QueryResponse {
pub columns: Vec<String>,
pub rows: Vec<Vec<WireValue>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default)]
pub truncated: bool,
}
async fn write_frame<S>(stream: &mut S, payload: &[u8]) -> std::io::Result<()>
where
S: AsyncWrite + Unpin,
{
let len = u32::try_from(payload.len())
.map_err(|_| std::io::Error::other("payload too large for IPC frame"))?;
stream.write_all(&len.to_le_bytes()).await?;
stream.write_all(payload).await?;
stream.flush().await
}
async fn read_frame<S>(stream: &mut S) -> std::io::Result<Vec<u8>>
where
S: AsyncRead + Unpin,
{
let mut len_buf = [0u8; FRAME_LEN];
stream.read_exact(&mut len_buf).await?;
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_FRAME_LEN {
return Err(std::io::Error::other(format!(
"IPC frame too large: {len} bytes (max {MAX_FRAME_LEN})"
)));
}
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload).await?;
Ok(payload)
}
fn response_from_rows(rows: ReadonlyRows) -> QueryResponse {
QueryResponse {
columns: rows.columns,
rows: rows
.rows
.into_iter()
.map(|row| row.iter().map(WireValue::from_turso).collect())
.collect(),
error: None,
truncated: rows.truncated,
}
}
fn response_error(msg: &str) -> QueryResponse {
QueryResponse {
columns: Vec::new(),
rows: Vec::new(),
error: Some(msg.to_string()),
truncated: false,
}
}
async fn write_json_frame<S, T>(stream: &mut S, message: &T) -> std::io::Result<()>
where
S: AsyncWrite + Unpin,
T: serde::Serialize,
{
let payload = serde_json::to_vec(message).map_err(std::io::Error::other)?;
write_frame(stream, &payload).await
}
fn malformed_response(e: impl std::fmt::Display) -> QueryResponse {
response_error(&format!("malformed IPC request: {e}"))
}
const UNRESTRICTED_SOCKET_ANSWER: &str = concat!(
"the command-line channel could not be restricted to this machine's own user, so ",
"operations are not served through it (diagnostic queries are unaffected)"
);
async fn handle_connection(
mut stream: LocalSocketStream,
log_store: std::sync::Arc<crate::logs::LogStore>,
serve_calls: bool,
) -> anyhow::Result<()> {
let payload = read_frame(&mut stream).await?;
let req: IpcRequest = match serde_json::from_slice(&payload) {
Ok(req) => req,
Err(e) => {
let resp = malformed_response(e);
write_json_frame(&mut stream, &resp).await?;
return Ok(());
}
};
if CALL_STORES.contains(&req.store.as_str()) {
let Some(call) = req.call else {
let resp = malformed_response(format!(
"store \"{}\" carries no `call` to operate",
req.store
));
write_json_frame(&mut stream, &resp).await?;
return Ok(());
};
let _flight = serve_calls.then(crate::cli::instance::hold_flight);
let reply = if !serve_calls {
CallReply::unavailable(UNRESTRICTED_SOCKET_ANSWER)
} else if call.tool == ISSUE_OPERATION {
file_issue_note(&log_store, &call.args).await
} else {
crate::cli::instance::execute(call).await
};
write_json_frame(&mut stream, &reply).await?;
return Ok(());
}
let conn = match req.store.as_str() {
"logs" => Some(log_store.conn.clone()),
"core" => crate::db::DOMAIN_CONN.get().cloned(),
other => {
let resp = response_error(&format!(
"unknown store '{other}' (expected \"core\" or \"logs\")"
));
write_json_frame(&mut stream, &resp).await?;
return Ok(());
}
};
let resp = match conn {
Some(conn) => {
let params: Vec<Value> = req.params.iter().map(WireValue::to_turso).collect();
match conn.query_readonly(&req.sql, params, IPC_ROW_LIMIT).await {
Ok(rows) => response_from_rows(rows),
Err(e) => response_error(&format!("{e}")),
}
}
None => response_error("the requested store is not initialized"),
};
write_json_frame(&mut stream, &resp).await?;
Ok(())
}
async fn file_issue_note(log_store: &crate::logs::LogStore, args: &serde_json::Value) -> CallReply {
let (Some(message), Some(reason), Some(target)) = (
args.get("message").and_then(serde_json::Value::as_str),
args.get("reason").and_then(serde_json::Value::as_str),
args.get("target").and_then(serde_json::Value::as_str),
) else {
return CallReply::refused("an issue note carries `message`, `reason` and `target`");
};
let fields = args
.get("fields")
.cloned()
.unwrap_or(serde_json::Value::Null);
match crate::logs::record_issue_once_in(log_store, message, reason, target, fields).await {
crate::logs::IssueWrite::NotRecorded => {
CallReply::unavailable("the instance could not record this issue")
}
_ => CallReply::done("the issue is in the Issues view"),
}
}
pub(crate) async fn ipc_file_issue(
storage_root: &Path,
message: &str,
reason: &str,
target: &str,
fields: serde_json::Value,
) -> bool {
let call = CallRequest {
tool: ISSUE_OPERATION.to_string(),
args: serde_json::json!({
"message": message,
"reason": reason,
"target": target,
"fields": fields,
}),
secrets: Vec::new(),
output_dir: None,
};
matches!(
tokio::time::timeout(ISSUE_BOUND, ipc_call_once(storage_root, &call, CALL_STORE)).await,
Ok(CallAttempt::Answered(reply)) if reply.outcome == CallOutcome::Done
)
}
pub async fn run_ipc_listener(
storage_root: &std::path::Path,
log_store: std::sync::Arc<crate::logs::LogStore>,
) {
let socket = socket_path(storage_root);
let name: Name<'static> = match socket.as_path().to_fs_name::<GenericFilePath>() {
Ok(name) => name.into_owned(),
Err(e) => {
warn!(
error = %e,
socket = %socket.display(),
"ipc: cannot build socket name; debug IPC disabled"
);
return;
}
};
let mut opts = ListenerOptions::new();
opts = opts.name(name).try_overwrite(true);
let listener = match opts.create_tokio() {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
warn!(
error = %e,
socket = %socket.display(),
"ipc: socket already in use (another instance?); debug IPC disabled"
);
return;
}
Err(e) => {
warn!(
error = %e,
socket = %socket.display(),
"ipc: cannot bind debug socket; debug IPC disabled"
);
return;
}
};
#[cfg(unix)]
let serve_calls = {
use std::os::unix::fs::PermissionsExt;
match std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o600)) {
Ok(()) => true,
Err(e) => {
warn!(
error = %e,
socket = %socket.display(),
"ipc: failed to restrict debug socket perms — diagnostics keep working, \
command-line operations are refused"
);
false
}
}
};
#[cfg(not(unix))]
let serve_calls = true;
debug!(socket = %socket.display(), "ipc: debug query listener running");
let shutdown = crate::shutdown::shutdown_token();
loop {
tokio::select! {
() = shutdown.cancelled() => {
debug!("ipc: shutdown — closing debug query listener");
break;
}
accepted = listener.accept() => {
match accepted {
Ok(stream) => {
let store = log_store.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, store, serve_calls).await {
debug!(error = %e, "ipc: connection handling error");
}
});
}
Err(e) => {
warn!(error = %e, "ipc: accept error");
}
}
}
}
}
}
enum Attempt {
Answered(QueryResponse),
NotDelivered(String),
NoAnswer,
Broken(String),
}
async fn ipc_query_once(
root: &Path,
req: &IpcRequest,
read: crate::db::read_limit::Read<'_>,
) -> Attempt {
let socket = socket_path(root);
let name = match socket.as_path().to_fs_name::<GenericFilePath>() {
Ok(name) => name,
Err(e) => return Attempt::NotDelivered(e.to_string()),
};
let mut stream = match LocalSocketStream::connect(name).await {
Ok(stream) => stream,
Err(e) => return Attempt::NotDelivered(e.to_string()),
};
let payload = match serde_json::to_vec(req) {
Ok(payload) => payload,
Err(e) => return Attempt::Broken(e.to_string()),
};
let wait = read.remaining();
if wait.is_zero() {
return Attempt::NoAnswer;
}
match tokio::time::timeout(wait, async {
write_frame(&mut stream, &payload).await?;
let p = read_frame(&mut stream).await?;
Ok::<QueryResponse, anyhow::Error>(serde_json::from_slice(&p)?)
})
.await
{
Err(_elapsed) => Attempt::NoAnswer,
Ok(Err(e)) => Attempt::Broken(format!("{e:#}")),
Ok(Ok(resp)) => Attempt::Answered(resp),
}
}
pub(crate) fn retry_backoff(attempt: usize) -> Duration {
Duration::from_millis(IPC_RETRY_BACKOFF_MS[attempt.min(IPC_RETRY_BACKOFF_MS.len() - 1)])
}
fn channel_unreachable(socket: &Path) -> String {
format!(
"a mahbot instance is running against this storage location and holds its stores, but its \
debug channel at {} could not be reached — the live store was left untouched",
socket.display()
)
}
fn unreachable(socket: &Path, detail: &str) -> anyhow::Error {
anyhow::anyhow!("{}: {detail}", channel_unreachable(socket))
}
fn not_attempted(undelivered: Option<&str>, spent: anyhow::Error, socket: &Path) -> anyhow::Error {
undelivered.map_or(spent, |detail| unreachable(socket, detail))
}
enum Outcome {
Answer(QueryResponse),
Resend(String),
Report(anyhow::Error),
}
fn classify(attempt: Attempt, read: crate::db::read_limit::Read<'_>, socket: &Path) -> Outcome {
match attempt {
Attempt::Answered(resp) => {
if read.remaining().is_zero() {
Outcome::Report(read.timeout())
} else {
Outcome::Answer(resp)
}
}
Attempt::NotDelivered(e) => {
if read.remaining().is_zero() {
Outcome::Report(unreachable(socket, &e))
} else {
Outcome::Resend(e)
}
}
Attempt::NoAnswer => Outcome::Report(read.timeout()),
Attempt::Broken(e) => Outcome::Report(unreachable(socket, &e)),
}
}
pub(crate) async fn ipc_query_with_wait(
storage_root: &Path,
req: &IpcRequest,
read: crate::db::read_limit::Read<'_>,
) -> anyhow::Result<QueryResponse> {
let socket = socket_path(storage_root);
let mut undelivered: Option<String> = None;
let mut attempt = 0usize;
loop {
if let Err(spent) = read.check() {
return Err(not_attempted(undelivered.as_deref(), spent, &socket));
}
match classify(ipc_query_once(storage_root, req, read).await, read, &socket) {
Outcome::Answer(resp) => return Ok(resp),
Outcome::Resend(e) => {
undelivered = Some(e);
tokio::time::sleep(retry_backoff(attempt).min(read.remaining())).await;
attempt += 1;
}
Outcome::Report(e) => return Err(e),
}
}
}
pub(crate) fn ipc_query_sync(
storage_root: &Path,
req: &IpcRequest,
read: crate::db::read_limit::Read<'_>,
) -> anyhow::Result<QueryResponse> {
let socket = socket_path(storage_root);
let mut undelivered: Option<String> = None;
let mut attempt = 0usize;
loop {
if let Err(spent) = read.check() {
return Err(not_attempted(undelivered.as_deref(), spent, &socket));
}
match classify(ipc_query_sync_once(storage_root, req, read), read, &socket) {
Outcome::Answer(resp) => return Ok(resp),
Outcome::Resend(e) => {
undelivered = Some(e);
std::thread::sleep(retry_backoff(attempt).min(read.remaining()));
attempt += 1;
}
Outcome::Report(e) => return Err(e),
}
}
}
fn ipc_query_sync_once(
storage_root: &Path,
req: &IpcRequest,
read: crate::db::read_limit::Read<'_>,
) -> Attempt {
let socket = socket_path(storage_root);
let name = match socket.as_path().to_fs_name::<GenericFilePath>() {
Ok(name) => name,
Err(e) => return Attempt::NotDelivered(e.to_string()),
};
let mut stream = match <interprocess::local_socket::Stream as interprocess::local_socket::traits::Stream>::connect(
name,
) {
Ok(stream) => stream,
Err(e) => return Attempt::NotDelivered(e.to_string()),
};
let wait = read.remaining();
if wait.is_zero() {
return Attempt::NoAnswer;
}
let _ = stream.set_recv_timeout(Some(wait));
let _ = stream.set_send_timeout(Some(wait));
let payload = match serde_json::to_vec(req) {
Ok(payload) => payload,
Err(e) => return Attempt::Broken(e.to_string()),
};
if let Err(e) = write_frame_sync(&mut stream, &payload) {
return if timed_out(&e) {
Attempt::NoAnswer
} else {
Attempt::Broken(e.to_string())
};
}
match read_frame_sync(&mut stream) {
Ok(payload) => match serde_json::from_slice(&payload) {
Ok(resp) => Attempt::Answered(resp),
Err(e) => Attempt::Broken(e.to_string()),
},
Err(e) if timed_out(&e) => Attempt::NoAnswer,
Err(e) => Attempt::Broken(e.to_string()),
}
}
fn timed_out(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
)
}
fn write_frame_sync(stream: &mut impl std::io::Write, payload: &[u8]) -> std::io::Result<()> {
let len = u32::try_from(payload.len())
.map_err(|_| std::io::Error::other("payload too large for IPC frame"))?;
stream.write_all(&len.to_le_bytes())?;
stream.write_all(payload)?;
stream.flush()
}
fn read_frame_sync(stream: &mut impl std::io::Read) -> std::io::Result<Vec<u8>> {
let mut len_buf = [0u8; FRAME_LEN];
stream.read_exact(&mut len_buf)?;
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_FRAME_LEN {
return Err(std::io::Error::other(format!(
"IPC frame too large: {len} bytes (max {MAX_FRAME_LEN})"
)));
}
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload)?;
Ok(payload)
}
pub(crate) enum CallAttempt {
Answered(CallReply),
NotServed(String),
TooOld,
Severed(anyhow::Error),
}
fn classify_reply(payload: &[u8], protocol: &str) -> CallAttempt {
if let Ok(reply) = serde_json::from_slice::<CallReply>(payload) {
return match reply.outcome {
CallOutcome::NotAccepted => CallAttempt::NotServed(reply.answer),
_ => CallAttempt::Answered(reply),
};
}
if let Ok(resp) = serde_json::from_slice::<QueryResponse>(payload)
&& resp
.error
.as_deref()
.is_some_and(|e| e.contains(&format!("unknown store '{protocol}'")))
{
return CallAttempt::TooOld;
}
CallAttempt::Severed(anyhow::anyhow!(
"the instance answered in a form this command cannot read"
))
}
fn call_not_served(socket: &Path, detail: &str) -> String {
format!(
"the running product's command-line channel at {} did not take the operation on: \
{detail} — nothing ran, so it may be tried again once the instance is up",
socket.display()
)
}
async fn ipc_call_once(storage_root: &Path, call: &CallRequest, protocol: &str) -> CallAttempt {
let socket = socket_path(storage_root);
let name = match socket.as_path().to_fs_name::<GenericFilePath>() {
Ok(name) => name,
Err(e) => {
return CallAttempt::NotServed(call_not_served(&socket, &e.to_string()));
}
};
let mut stream = match LocalSocketStream::connect(name).await {
Ok(stream) => stream,
Err(e) => return CallAttempt::NotServed(call_not_served(&socket, &e.to_string())),
};
let req = IpcRequest {
store: protocol.to_string(),
sql: String::new(),
params: Vec::new(),
call: Some(call.clone()),
};
let payload = match serde_json::to_vec(&req) {
Ok(payload) => payload,
Err(e) => return CallAttempt::NotServed(call_not_served(&socket, &e.to_string())),
};
if let Err(e) = write_frame(&mut stream, &payload).await {
return CallAttempt::NotServed(call_not_served(&socket, &e.to_string()));
}
match read_frame(&mut stream).await {
Ok(payload) => classify_reply(&payload, protocol),
Err(e) => CallAttempt::Severed(
anyhow::Error::new(e).context("the answer could not be read off the exchange"),
),
}
}
pub(crate) async fn ipc_call_with_wait(
storage_root: &Path,
call: &CallRequest,
protocol: &str,
) -> CallAttempt {
let deadline = std::time::Instant::now() + ipc_bound_timeout();
let mut attempt = 0usize;
loop {
let reason = match ipc_call_once(storage_root, call, protocol).await {
CallAttempt::NotServed(reason) => reason,
other => return other,
};
if std::time::Instant::now() >= deadline {
return CallAttempt::NotServed(reason);
}
tokio::time::sleep(retry_backoff(attempt)).await;
attempt += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_two_request_shapes_share_the_frame() {
let query = serde_json::to_value(IpcRequest {
store: "logs".to_string(),
sql: "SELECT 1".to_string(),
params: Vec::new(),
call: None,
})
.unwrap();
assert!(
query.get("call").is_none(),
"a diagnostic query must omit `call`: {query}"
);
let operation = serde_json::to_value(IpcRequest {
store: CALL_STORE_BASE.to_string(),
sql: String::new(),
params: Vec::new(),
call: Some(CallRequest {
tool: "workspace".to_string(),
args: serde_json::json!({ "action": "list" }),
secrets: Vec::new(),
output_dir: None,
}),
})
.unwrap();
assert_eq!(operation["store"], CALL_STORE_BASE);
assert_eq!(operation["call"]["tool"], "workspace");
}
#[serial_test::serial(ipc_bound)]
#[tokio::test]
async fn call_refusal_names_the_channel_instead_of_the_os_error() {
let _env = crate::util::test::set_env_var("MAHBOT_IPC_BOUND_TIMEOUT_SECS", Some("0"));
let dir = tempfile::TempDir::new().unwrap();
let call = CallRequest {
tool: "workspace".to_string(),
args: serde_json::json!({ "action": "list" }),
secrets: Vec::new(),
output_dir: None,
};
match crate::db::ipc::ipc_call_with_wait(dir.path(), &call, CALL_STORE_BASE).await {
CallAttempt::NotServed(reason) => {
assert!(
reason.contains(&socket_path(dir.path()).display().to_string()),
"the refusal must name the endpoint: {reason}"
);
assert!(
reason.contains("command-line channel"),
"the refusal must name the channel that did not take it: {reason}"
);
}
_ => panic!("a call to a non-existent endpoint must be NotServed"),
}
}
#[test]
fn replies_classify_as_answered_retryable_or_too_old() {
let retryable = serde_json::to_vec(&CallReply::not_accepted("still starting")).unwrap();
assert!(matches!(
classify_reply(&retryable, CALL_STORE),
CallAttempt::NotServed(reason) if reason == "still starting"
));
let final_reply = serde_json::to_vec(&CallReply::unavailable("no channel")).unwrap();
assert!(matches!(
classify_reply(&final_reply, CALL_STORE),
CallAttempt::Answered(reply) if reply.outcome == CallOutcome::Unavailable
));
let old = serde_json::to_vec(&serde_json::json!({
"columns": [],
"rows": [],
"error": format!("unknown store '{CALL_STORE}' (expected \"core\" or \"logs\")"),
"truncated": false,
}))
.unwrap();
assert!(
matches!(classify_reply(&old, CALL_STORE), CallAttempt::TooOld),
"an unknown-store refusal is TooOld"
);
let other_error = serde_json::to_vec(&serde_json::json!({
"columns": [],
"rows": [],
"error": "the requested store is not initialized",
"truncated": false,
}))
.unwrap();
match classify_reply(&other_error, CALL_STORE) {
CallAttempt::Severed(e) => assert!(
e.to_string().contains("cannot read"),
"the severed answer names the unreadable reply: {e}"
),
_ => panic!("an unreadable reply must be severed"),
}
}
#[test]
fn wire_value_round_trips() {
use crate::db::Value;
for (value, wire) in [
(Value::Integer(42), WireValue::Integer(42)),
(Value::Real(1.5), WireValue::Real(1.5)),
(
Value::Text("hi".to_string()),
WireValue::Text("hi".to_string()),
),
(Value::Null, WireValue::Null),
] {
assert_eq!(WireValue::from_turso(&value), wire, "from_turso");
assert_eq!(wire.to_turso(), value, "to_turso");
}
let blob = vec![0x00u8, 0xDE, 0xAD];
let wire = WireValue::from_turso(&Value::Blob(blob.clone()));
assert_eq!(wire.to_turso(), Value::Blob(blob));
assert_eq!(wire.format(), "00dead");
assert_eq!(WireValue::Null.format(), "");
}
#[test]
fn wire_value_round_trips_non_finite_real() {
let payload = serde_json::to_vec(&WireValue::Real(f64::NAN)).unwrap();
match serde_json::from_slice::<WireValue>(&payload).unwrap() {
WireValue::Real(f) => {
assert!(f.is_nan(), "non-finite REAL must round-trip as NaN");
}
other => panic!("expected Real, got {other:?}"),
}
}
#[serial_test::serial(read_limit)]
#[tokio::test]
async fn ipc_query_serves_readonly_queries_end_to_end() {
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let instance_conn = store.conn.clone();
let store_arc = std::sync::Arc::new(store);
store_arc
.conn
.execute_batch(
"INSERT INTO logs (timestamp, level, target, message) \
VALUES ('2026-01-01T00:00:00Z', 'INFO', 'test', 'hello');",
)
.await
.unwrap();
let root = dir.path().to_path_buf();
let listener_root = root.clone();
let listener = tokio::spawn(async move {
crate::db::ipc::run_ipc_listener(&listener_root, store_arc).await;
});
let req = IpcRequest {
store: "logs".to_string(),
sql: "SELECT COUNT(*) FROM logs".to_string(),
params: Vec::new(),
call: None,
};
let budget = crate::db::read_limit::Budget::start();
let read = crate::db::read_limit::Read::over_channel("logs", &budget);
let resp = crate::db::ipc::ipc_query_with_wait(&root, &req, read)
.await
.expect("IPC query must reach the listener");
assert!(resp.error.is_none(), "no error expected: {:?}", resp.error);
assert_eq!(resp.columns, vec!["COUNT(*)"]);
assert_eq!(resp.rows, vec![vec![WireValue::Integer(1)]]);
assert!(!resp.truncated);
let write_req = IpcRequest {
store: "logs".to_string(),
sql: "CREATE TABLE nope (id INTEGER)".to_string(),
params: Vec::new(),
call: None,
};
let write_resp = crate::db::ipc::ipc_query_with_wait(&root, &write_req, read)
.await
.expect("IPC write attempt must reach the listener");
assert!(
write_resp.error.is_some(),
"a write statement must be rejected by query_only=ON"
);
let checkpoint_req = IpcRequest {
store: "logs".to_string(),
sql: "PRAGMA wal_checkpoint(TRUNCATE)".to_string(),
params: Vec::new(),
call: None,
};
let checkpoint_resp = crate::db::ipc::ipc_query_with_wait(&root, &checkpoint_req, read)
.await
.expect("IPC checkpoint attempt must reach the listener");
assert!(
checkpoint_resp
.error
.as_deref()
.is_some_and(|e| e.contains("refused on a read-only connection")),
"the compaction must be refused on the writing-connection path too: {:?}",
checkpoint_resp.error
);
let instance_write = instance_conn
.execute_batch(
"INSERT INTO logs (timestamp, level, target, message) \
VALUES ('2026-01-01T00:00:01Z', 'INFO', 'test', 'after the read-only query');",
)
.await;
assert!(
instance_write.is_ok(),
"the read-only query must leave the connection able to write: {instance_write:?}"
);
listener.abort();
let _ = listener.await;
}
fn slow_listener(
root: &Path,
answer_after: Option<Duration>,
) -> (
std::thread::JoinHandle<()>,
std::sync::mpsc::Sender<()>,
std::sync::Arc<std::sync::atomic::AtomicUsize>,
) {
use interprocess::local_socket::traits::Listener as _;
let socket = socket_path(root);
let accepted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let accepted_in_server = accepted.clone();
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
let server = std::thread::spawn(move || {
let name = socket
.as_path()
.to_fs_name::<GenericFilePath>()
.expect("socket name");
let mut opts = ListenerOptions::new();
opts = opts.name(name).try_overwrite(true);
let listener = opts.create_sync().expect("bind the client-test listener");
listener
.set_nonblocking(interprocess::local_socket::ListenerNonblockingMode::Accept)
.expect("nonblocking accept");
let mut held = Vec::new();
loop {
match listener.accept() {
Ok(mut stream) => {
accepted_in_server.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let _ = read_frame_sync(&mut stream);
if let Some(after) = answer_after {
std::thread::sleep(after);
let body = serde_json::to_vec(&QueryResponse {
columns: vec!["COUNT(*)".to_string()],
rows: vec![vec![WireValue::Integer(1)]],
error: None,
truncated: false,
})
.expect("serialize the answer");
let _ = write_frame_sync(&mut stream, &body);
}
held.push(stream);
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if stop_rx.recv_timeout(Duration::from_millis(10)).is_ok() {
break;
}
}
Err(e) => panic!("accept failed: {e}"),
}
}
});
(server, stop_tx, accepted)
}
#[serial_test::serial(read_limit)]
#[tokio::test]
async fn a_spent_budget_never_reaches_the_instance() {
let _env = crate::util::test::set_env_var(crate::db::read_limit::ENV, Some("0"));
let dir = tempfile::TempDir::new().expect("temp dir for the socket");
let (server, stop, accepted) = slow_listener(dir.path(), None);
let budget = crate::db::read_limit::Budget::start();
let read = crate::db::read_limit::Read::over_channel("logs", &budget);
let req = IpcRequest {
store: "logs".to_string(),
sql: "SELECT COUNT(*) FROM logs".to_string(),
params: Vec::new(),
call: None,
};
let err = crate::db::ipc::ipc_query_with_wait(dir.path(), &req, read)
.await
.expect_err("a spent budget must fail the read");
let text = err.to_string();
assert!(
text.contains("'logs'"),
"the failure names the target: {text}"
);
assert!(
text.contains("0s read limit"),
"the failure names the limit: {text}"
);
let _ = stop.send(());
server.join().expect("the listener thread must end");
assert_eq!(
accepted.load(std::sync::atomic::Ordering::SeqCst),
0,
"a spent budget must not send the query at all"
);
}
#[serial_test::serial(read_limit)]
#[tokio::test]
async fn a_slow_but_alive_instance_answers_inside_the_read_limit() {
let _env = crate::util::test::set_env_var(crate::db::read_limit::ENV, Some("5"));
let dir = tempfile::TempDir::new().expect("temp dir for the socket");
let (server, stop, _accepted) = slow_listener(dir.path(), Some(Duration::from_millis(700)));
let budget = crate::db::read_limit::Budget::start();
let read = crate::db::read_limit::Read::over_channel("logs", &budget);
let req = IpcRequest {
store: "logs".to_string(),
sql: "SELECT COUNT(*) FROM logs".to_string(),
params: Vec::new(),
call: None,
};
let resp = crate::db::ipc::ipc_query_with_wait(dir.path(), &req, read)
.await
.expect("an answer that arrives inside the limit must be returned");
assert!(resp.error.is_none(), "no error expected: {:?}", resp.error);
assert_eq!(resp.rows, vec![vec![WireValue::Integer(1)]]);
let _ = stop.send(());
server.join().expect("the listener thread must end");
}
#[serial_test::serial(read_limit)]
#[tokio::test]
async fn a_query_a_silent_instance_took_fails_with_the_read_limit_and_is_not_resent() {
let _env = crate::util::test::set_env_var(crate::db::read_limit::ENV, Some("1"));
let dir = tempfile::TempDir::new().expect("temp dir for the socket");
let (server, stop, accepted) = slow_listener(dir.path(), None);
let budget = crate::db::read_limit::Budget::start();
let read = crate::db::read_limit::Read::over_channel("logs", &budget);
let req = IpcRequest {
store: "logs".to_string(),
sql: "SELECT COUNT(*) FROM logs".to_string(),
params: Vec::new(),
call: None,
};
let err = crate::db::ipc::ipc_query_with_wait(dir.path(), &req, read)
.await
.expect_err("a query the instance took but never answered must fail");
let text = err.to_string();
assert!(
text.contains("logs"),
"the failure names the target: {text}"
);
assert!(
text.contains("1s read limit"),
"the failure names the limit: {text}"
);
assert!(
text.contains(crate::db::read_limit::ENV),
"the failure names the knob: {text}"
);
assert!(
!text.contains("could not be reached"),
"a taken query must not be reported as an unreachable channel: {text}"
);
let _ = stop.send(());
server.join().expect("the silent listener thread must end");
assert_eq!(
accepted.load(std::sync::atomic::Ordering::SeqCst),
1,
"a query the instance took must never be sent again"
);
}
#[cfg(unix)]
#[serial_test::serial(read_limit)]
#[test]
fn a_silent_instance_fails_the_sync_config_read_with_the_read_limit() {
let _env = crate::util::test::set_env_var(crate::db::read_limit::ENV, Some("1"));
let dir = tempfile::TempDir::new().expect("temp dir for the socket");
let (server, stop, accepted) = slow_listener(dir.path(), None);
let budget = crate::db::read_limit::Budget::start();
let read = crate::db::read_limit::Read::over_channel("config", &budget);
let req = IpcRequest {
store: "core".to_string(),
sql: "SELECT value FROM config_kv WHERE key = 'worker_model'".to_string(),
params: Vec::new(),
call: None,
};
let err = crate::db::ipc::ipc_query_sync(dir.path(), &req, read)
.expect_err("a silent instance must fail the config read");
let text = format!("{err:#}");
assert!(
text.contains("'config'"),
"the failure names the target: {text}"
);
assert!(
text.contains("1s read limit"),
"the failure names the limit: {text}"
);
assert!(
!text.contains("could not be reached"),
"a taken query must not be reported as an unreachable channel: {text}"
);
let _ = stop.send(());
server.join().expect("the silent listener thread must end");
assert_eq!(
accepted.load(std::sync::atomic::Ordering::SeqCst),
1,
"a query the instance took must never be sent again"
);
}
#[serial_test::serial(ipc_bound)]
#[tokio::test]
async fn ipc_call_serves_an_operation_frame_end_to_end() {
crate::cli::mark_ready();
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let root = dir.path().to_path_buf();
let listener_root = root.clone();
let listener = tokio::spawn(async move {
crate::db::ipc::run_ipc_listener(&listener_root, std::sync::Arc::new(store)).await;
});
let call = CallRequest {
tool: "no_such_tool".to_string(),
args: serde_json::json!({}),
secrets: Vec::new(),
output_dir: None,
};
match crate::db::ipc::ipc_call_with_wait(&root, &call, CALL_STORE).await {
CallAttempt::Answered(reply) => {
assert_eq!(
reply.outcome,
CallOutcome::Unavailable,
"answer: {}",
reply.answer
);
assert!(
reply.answer.contains("no_such_tool"),
"the refusal must name the tool: {}",
reply.answer
);
assert!(
reply.answer.contains("restart it from this build"),
"a product that cannot take the operation on says to restart: {}",
reply.answer
);
}
_ => panic!("the operation frame must be served as an operation"),
}
listener.abort();
let _ = listener.await;
}
#[tokio::test]
async fn ipc_call_files_a_storeless_clients_issue_once() {
const MESSAGE: &str = "a command-line browser step left its tab group wrong";
const REASON: &str = "chrome-tabs:test-note";
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let root = dir.path().to_path_buf();
let probe = store.clone();
let listener_root = root.clone();
let listener = tokio::spawn(async move {
crate::db::ipc::run_ipc_listener(&listener_root, std::sync::Arc::new(store)).await;
});
for _ in 0..500 {
if socket_path(&root).exists() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(
socket_path(&root).exists(),
"the listener never bound its socket"
);
for _ in 0..2 {
assert!(
crate::db::ipc::ipc_file_issue(
&root,
MESSAGE,
REASON,
"chrome-tabs",
serde_json::json!({ "detail": "group 7's live state could not be read" }),
)
.await,
"the instance must take the note"
);
}
assert!(
probe
.has_reason(MESSAGE, REASON)
.await
.expect("the filed row must be readable"),
"the fact must reach the Issues view"
);
let rows = probe
.conn
.query(
"SELECT fields FROM logs WHERE message = ?1",
crate::db::params![MESSAGE],
)
.await
.expect("the filed rows must be readable");
assert_eq!(
rows.len(),
1,
"one fact is one row, however often it is filed"
);
listener.abort();
let _ = listener.await;
}
#[serial_test::serial(ipc_bound, provider, channel_registry, drain)]
#[tokio::test]
async fn ipc_call_serves_the_one_way_send_end_to_end() {
crate::util::test::init_management_test_stores().await;
crate::CHANNEL_REGISTRY.get_or_init(crate::ChannelRegistry::default);
crate::cli::mark_ready();
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let root = dir.path().to_path_buf();
let listener_root = root.clone();
let listener = tokio::spawn(async move {
crate::db::ipc::run_ipc_listener(&listener_root, std::sync::Arc::new(store)).await;
});
let assistant = crate::session::assistant_agent_id(crate::users::ADMIN_USER_NAME);
let mut rx = crate::agent::message_router::register_agent(&assistant);
let pending_before: std::collections::HashSet<String> =
crate::jobs::list_pending_jobs(&crate::session::store().conn)
.await
.expect("list pending")
.into_iter()
.map(|row| row.id)
.collect();
let call = CallRequest {
tool: "send".to_string(),
args: serde_json::json!({ "message": "from the command line" }),
secrets: Vec::new(),
output_dir: None,
};
match crate::db::ipc::ipc_call_with_wait(&root, &call, CALL_STORE).await {
CallAttempt::Answered(reply) => {
assert_eq!(reply.outcome, CallOutcome::Done, "answer: {}", reply.answer);
assert!(
reply.answer.contains("assistant"),
"the answer names the handover: {}",
reply.answer
);
}
_ => panic!("the send frame must be served as an operation"),
}
let job = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("the handover must reach the assistant")
.expect("the consumer must still be registered");
assert_eq!(job.content, "from the command line");
assert_eq!(job.channel, crate::cli::CHANNEL);
let created: Vec<crate::jobs::PendingJobRow> =
crate::jobs::list_pending_jobs(&crate::session::store().conn)
.await
.expect("list pending")
.into_iter()
.filter(|row| !pending_before.contains(&row.id))
.collect();
let mine: Vec<&crate::jobs::PendingJobRow> = created
.iter()
.filter(|row| row.envelope.contains("from the command line"))
.collect();
assert_eq!(
mine.len(),
1,
"exactly the handover's own durable row: {created:?}"
);
let row = mine[0];
assert_eq!(
row.target_agent_id, assistant,
"the row is addressed to the assistant"
);
let workspace = crate::users::personal_workspace_name(crate::users::ADMIN_USER_NAME);
let entries = crate::channels::chat_history::store()
.load_conversation(crate::users::ADMIN_USER_NAME, &workspace)
.await
.unwrap();
assert!(
entries.iter().any(|entry| {
entry.direction == crate::ChatDirection::User
&& entry.content == "from the command line"
}),
"the message is persisted as an incoming user message: {entries:?}"
);
crate::jobs::delete_pending_job(&crate::session::store().conn, &row.id)
.await
.expect("reclaim the handover's row");
let message_id = entries
.iter()
.find(|entry| {
entry.direction == crate::ChatDirection::User
&& entry.content == "from the command line"
})
.expect("the conversation row's own entry")
.message_id
.clone();
crate::channels::chat_history::store()
.conn
.execute(
"DELETE FROM chat_history WHERE message_id = ?1",
crate::db::params![message_id],
)
.await
.expect("reclaim the conversation row");
crate::agent::message_router::unregister_agent(&assistant);
listener.abort();
let _ = listener.await;
}
}