use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use tokio::time::sleep;
use crate::turso as turso_mod;
use crate::wal_guard;
const ROW_LIMIT: usize = 10_000;
const BLOCKLIST: &[&str] = &[
"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "REPLACE", "BEGIN", "COMMIT",
"ROLLBACK", "VACUUM", "REINDEX", "GRANT", "REVOKE", "ATTACH", "DETACH", "ANALYZE",
];
const SQL_PUNCTUATION: &[char] = &[
'(', ')', ';', ',', '.', '*', '+', '-', '/', '=', '<', '>', '!', '|', '&', '~', '\'', '"', '[',
']', '{', '}', ':',
];
const SAFE_PRAGMAS: &[&str] = &[
"quick_check",
"integrity_check",
"table_info",
"table_xinfo",
"index_info",
"index_list",
"index_xinfo",
"foreign_key_check",
"database_list",
"compile_options",
"page_count",
"freelist_count",
"page_size",
"encoding",
"user_version",
"schema_version",
"collation_list",
"function_list",
"module_list",
"pragma_list",
"table_list",
"stats",
];
const TORN_FRAME_SIGNATURES: &[&str] = &[
"short read on wal frame",
"short read on page",
"invalid page type",
"wal frame page mismatch",
"checksum mismatch",
];
const MAX_OPEN_ATTEMPTS: usize = 5;
const OPEN_RETRY_BACKOFF_SECS: [u64; MAX_OPEN_ATTEMPTS - 1] = [1, 2, 4, 8];
pub async fn run_debug() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
run_debug_with_args(args, None).await
}
#[derive(Debug)]
pub struct GateRefusal;
impl std::fmt::Display for GateRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"another process holds the mahbot instance lock; retry later"
)
}
}
impl std::error::Error for GateRefusal {}
const GATE_TIMEOUT_SECS: u64 = 15;
const GATE_FREE_OBSERVATIONS: u32 = 2;
#[cfg(unix)]
fn probe_tshm_byte0_pid(tshm_path: &Path) -> Option<i32> {
use std::os::unix::io::AsRawFd;
let file = std::fs::File::open(tshm_path).ok()?;
let mut fl: libc::flock = unsafe { std::mem::zeroed() };
fl.l_type = libc::F_WRLCK;
fl.l_whence = 0; fl.l_start = 0;
fl.l_len = 0;
fl.l_pid = 0;
let ret = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETLK, &mut fl) };
(ret != -1 && fl.l_type != libc::F_UNLCK).then_some(fl.l_pid)
}
#[cfg(windows)]
fn probe_tshm_byte0_pid(_tshm_path: &Path) -> Option<i32> {
None }
fn probe_flock_free(lock_path: &Path) -> bool {
take_flock(lock_path).is_some()
}
fn take_flock(lock_path: &Path) -> Option<File> {
use std::fs::OpenOptions;
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(lock_path)
.ok()?;
match crate::lock_utils::try_flock(&file) {
Ok(true) => Some(file),
_ => None,
}
}
async fn flock_gate(root: &Path, db_names: &[String]) -> Result<Option<File>> {
flock_gate_with_timeout(root, db_names, Duration::from_secs(GATE_TIMEOUT_SECS)).await
}
async fn flock_gate_with_timeout(
root: &Path,
db_names: &[String],
timeout: Duration,
) -> Result<Option<File>> {
if cfg!(not(unix)) {
return Ok(None);
}
let lock_path = crate::lock_utils::lock_file_path(root);
if !lock_path.exists() {
return Ok(None);
}
let tshm_paths: Vec<PathBuf> = db_names
.iter()
.map(|n| turso_mod::store_sidecars(&turso_mod::store_db_path(root, n)).tshm)
.filter(|p| p.exists())
.collect();
if tshm_paths.is_empty() {
return Ok(None); }
let deadline = tokio::time::Instant::now() + timeout;
let mut consecutive_free = 0u32;
let mut handoff = false;
let mut handoff_pids: Vec<Option<i32>> = Vec::new();
let mut saw_busy = false;
loop {
let byte0_pids: Vec<Option<i32>> =
tshm_paths.iter().map(|p| probe_tshm_byte0_pid(p)).collect();
let all_byte0_free = byte0_pids.iter().all(Option::is_none);
let flock_free = if handoff {
if all_byte0_free {
handoff = false; probe_flock_free(&lock_path)
} else if byte0_pids
.iter()
.zip(&handoff_pids)
.any(|(cur, prev)| cur.is_some() && cur != prev)
{
return Ok(None);
} else {
true }
} else {
probe_flock_free(&lock_path)
};
if !flock_free {
saw_busy = true;
if all_byte0_free {
handoff = false;
consecutive_free = 0;
} else {
return Ok(None);
}
} else if all_byte0_free {
consecutive_free += 1;
if consecutive_free >= GATE_FREE_OBSERVATIONS {
if saw_busy {
if let Some(guard) = take_flock(&lock_path) {
return Ok(Some(guard));
}
} else {
return Ok(None);
}
consecutive_free = 0;
}
} else {
if !handoff {
handoff = true;
handoff_pids = byte0_pids;
}
consecutive_free = 0;
}
if tokio::time::Instant::now() >= deadline {
return Err(anyhow!(GateRefusal).context(format!(
"timed out waiting for the mahbot instance lock ({}s)",
timeout.as_secs()
)));
}
sleep(Duration::from_secs(1)).await;
}
}
fn resolve_home(home_override: Option<PathBuf>) -> Result<PathBuf> {
match home_override {
Some(home) => Ok(home),
None => crate::config::default_config_dir(),
}
}
fn write_stdout(text: &str) -> Result<()> {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
out.write_all(text.as_bytes())
.and_then(|()| out.flush())
.map_err(|e| anyhow!("{STDOUT_WRITE_ERROR_PREFIX}{e}"))
}
fn print_line(args: std::fmt::Arguments<'_>) -> Result<()> {
write_stdout(&format!("{args}\n"))
}
fn parse_db_flag(args: &[String], subcommand: &str) -> Result<Option<String>> {
match args.get(3).map(String::as_str) {
Some("--db") => match args.get(4) {
Some(name) => Ok(Some(name.clone())),
None => bail!("expected: mahbot debug {subcommand} --db <name>"),
},
Some(other) => bail!("invalid {subcommand} argument '{other}'"),
None => Ok(None),
}
}
fn validate_store_name(name: &str, all_valid: bool) -> Result<()> {
let names = turso_mod::store_names();
if names.contains(&name) {
return Ok(());
}
let hint = names.join(", ") + if all_valid { ", all" } else { "" };
bail!("invalid database name '{name}'. Valid names: {hint}");
}
fn run_debug_detect(args: &[String], home_override: Option<PathBuf>) -> Result<()> {
let mahbot_home = resolve_home(home_override)?;
let selected = match parse_db_flag(args, "detect")? {
Some(name) => {
validate_store_name(&name, false)?;
vec![name]
}
None => turso_mod::store_names()
.into_iter()
.map(String::from)
.collect(),
};
let mut failures = 0usize;
for name in &selected {
let status = wal_guard::inspect_store_at(
&turso_mod::store_db_path(&mahbot_home, name),
wal_guard::StoreFds::none(),
);
let blocking = status.class.blocks_checkpoint();
print_line(format_args!(
"{}\t{}\twal_size={}\tblocking={}",
name,
status.class.label(),
status.wal_size,
blocking,
))?;
if blocking {
failures += 1;
}
}
if failures > 0 {
bail!("{failures} store(s) in a checkpoint-blocking coordination state — see above");
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum FamilyKind {
Quarantine,
PreReindex,
}
impl FamilyKind {
#[must_use]
fn label(self) -> &'static str {
match self {
Self::Quarantine => "quarantine",
Self::PreReindex => "pre-reindex",
}
}
}
#[derive(Debug)]
struct FamilyInfo {
id: String,
store: String,
kind: FamilyKind,
stamp: String,
size: u64,
class: FamilyClass,
files: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FamilyClass {
Complete,
Partial,
BadHeader,
SidecarOnly,
}
impl FamilyClass {
#[must_use]
fn label(self) -> &'static str {
match self {
Self::Complete => "complete",
Self::Partial => "partial",
Self::BadHeader => "bad-header",
Self::SidecarOnly => "sidecar-only",
}
}
}
#[derive(Debug)]
pub(crate) struct FamilyMeta {
pub(crate) store: String,
pub(crate) kind: FamilyKind,
pub(crate) stamp: String,
}
pub(crate) fn parse_family_name(name: &str) -> Option<FamilyMeta> {
let (kind, marker) = if name.contains(".quarantine-") {
(FamilyKind::Quarantine, ".quarantine-")
} else if name.contains(".pre-reindex-") {
(FamilyKind::PreReindex, ".pre-reindex-")
} else {
return None;
};
let (prefix, tail) = name.split_once(marker)?;
let store = prefix.strip_suffix(".db")?;
if store.is_empty() || store.contains('/') || store.contains('\\') {
return None;
}
let (stamp, pid_rest) = tail.split_once('-')?;
if !is_family_stamp(stamp) {
return None;
}
let (pid, seq) = match pid_rest.split_once('-') {
Some((pid, seq)) => (pid, seq),
None => (pid_rest, ""),
};
if pid.is_empty() || pid_rest.ends_with('-') || !pid.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if kind == FamilyKind::PreReindex && !seq.is_empty() {
return None;
}
if !seq.is_empty() && !seq.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(FamilyMeta {
store: store.to_string(),
kind,
stamp: stamp.to_string(),
})
}
fn is_family_stamp(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 16
&& b[8] == b'T'
&& b[15] == b'Z'
&& b[..8].iter().all(u8::is_ascii_digit)
&& b[9..15].iter().all(u8::is_ascii_digit)
}
fn list_families(root: &Path) -> Result<Vec<FamilyInfo>> {
let db_dir = root.join("db");
let entries = match std::fs::read_dir(&db_dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(e)
.with_context(|| format!("failed to read store directory: {}", db_dir.display()));
}
};
let names: Vec<String> = entries
.map(|e| {
e.map(|e| e.file_name().to_string_lossy().into_owned())
.with_context(|| format!("failed to read entry in {}", db_dir.display()))
})
.collect::<Result<_>>()?;
let mut bases: std::collections::BTreeMap<String, FamilyMeta> =
std::collections::BTreeMap::new();
for name in &names {
if let Some(meta) = parse_family_name(name) {
bases.insert(name.clone(), meta);
}
}
for name in &names {
for suffix in ["-wal", "-shm", "-tshm"] {
if let Some(base) = name.strip_suffix(suffix) {
if !bases.contains_key(base)
&& let Some(meta) = parse_family_name(base)
{
bases.insert(base.to_string(), meta);
}
break;
}
}
}
let mut families: Vec<FamilyInfo> = bases
.into_iter()
.map(|(id, meta)| classify_family(root, id, meta))
.collect();
families.sort_by(|a, b| (&a.store, a.kind, &a.stamp).cmp(&(&b.store, b.kind, &b.stamp)));
Ok(families)
}
fn classify_family(root: &Path, id: String, meta: FamilyMeta) -> FamilyInfo {
let db_path = root.join("db").join(&id);
let expected: &[(&str, &'static str)] = match meta.kind {
FamilyKind::Quarantine => &[("", "db"), ("-wal", "wal"), ("-tshm", "tshm")],
FamilyKind::PreReindex => &[("", "db"), ("-wal", "wal")],
};
let mut members: Vec<&'static str> = Vec::new();
let mut size: u64 = 0;
for (suffix, label) in expected {
let path = db_path.with_file_name(format!("{id}{suffix}"));
if let Ok(md) = std::fs::metadata(&path)
&& md.is_file()
{
members.push(label);
size += md.len();
}
}
let shm = db_path.with_file_name(format!("{id}-shm"));
if let Ok(md) = std::fs::metadata(&shm)
&& md.is_file()
{
members.push("shm");
size += md.len();
}
let class = if !members.contains(&"db") {
FamilyClass::SidecarOnly
} else if !db_header_ok(&db_path) {
FamilyClass::BadHeader
} else if expected.iter().all(|(_, label)| members.contains(label)) {
FamilyClass::Complete
} else {
FamilyClass::Partial
};
FamilyInfo {
id,
store: meta.store,
kind: meta.kind,
stamp: meta.stamp,
size,
class,
files: members.join(","),
}
}
fn db_header_ok(db_path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(db_path) else {
return false;
};
if meta.len() < wal_guard::DB_HEADER_MIN_SIZE {
return false; }
wal_guard::read_db_header(db_path).is_some_and(|h| wal_guard::db_header_valid(&h))
}
fn run_debug_families(args: &[String], home_override: Option<PathBuf>) -> Result<()> {
let mahbot_home = resolve_home(home_override)?;
let mut families = list_families(&mahbot_home)?;
let filter = parse_db_flag(args, "families")?;
if let Some(store) = filter {
families.retain(|fam| fam.store == store);
}
for fam in &families {
print_line(format_args!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
fam.id,
fam.store,
fam.kind.label(),
fam.stamp,
fam.size,
fam.class.label(),
fam.files
))?;
}
Ok(())
}
struct TempFamily {
_dir: tempfile::TempDir,
db_path: std::path::PathBuf,
}
impl TempFamily {
fn create(db_path: &Path) -> Result<Self> {
let name = db_path
.file_name()
.with_context(|| format!("family path must have a file name: {}", db_path.display()))?;
let dir = tempfile::Builder::new()
.prefix("mahbot-debug-family-")
.tempdir()
.with_context(|| "failed to create family query temp dir")?;
let copy = dir.path().join(name);
copy_file(db_path, ©, "database")?;
let wal = turso_mod::store_sidecars(db_path).wal;
if wal.exists() {
copy_file(
&wal,
&dir.path().join(format!("{}-wal", name.to_string_lossy())),
"WAL",
)?;
}
Ok(Self {
db_path: copy,
_dir: dir,
})
}
#[must_use]
fn db_path(&self) -> &Path {
&self.db_path
}
}
fn copy_file(src: &Path, dst: &Path, what: &str) -> Result<()> {
std::fs::copy(src, dst)
.with_context(|| format!("failed to copy family {what} to {}", dst.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dst, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
fn execute_family_query(family_id: &str, sql: &str, root: &Path) -> Result<String> {
parse_family_name(family_id).with_context(|| {
format!("invalid family id '{family_id}' — list valid ids with `mahbot debug families`")
})?;
let db_path = root.join("db").join(family_id);
if !db_path.exists() {
let any_member = ["-wal", "-shm", "-tshm"]
.iter()
.any(|s| db_path.with_file_name(format!("{family_id}{s}")).exists());
if any_member {
bail!(
"forensic family '{family_id}' has no main database file — \
the family cannot be queried"
);
}
bail!(
"forensic family '{family_id}' not found — list valid ids with \
`mahbot debug families`"
);
}
let md = std::fs::symlink_metadata(&db_path)
.with_context(|| format!("cannot stat forensic family '{family_id}'"))?;
if !md.file_type().is_file() {
bail!("forensic family '{family_id}' main file is not a regular file — refusing to open");
}
let sidecars = turso_mod::store_sidecars(&db_path);
let temp = if sidecars.tshm.exists() {
Some(TempFamily::create(&db_path)?)
} else {
None
};
let target = temp.as_ref().map_or(db_path.as_path(), |t| t.db_path());
let result = guard_panics(|| {
let (io, db) = open_readonly(target, &db_path, turso_mod::family_database_opts())?;
connect_execute(&io, &db, sql, &db_path)
});
drop(temp);
match result {
Ok(output) => Ok(output),
Err(e) if is_engine_panic_error(&e) => {
bail!("forensic family '{family_id}' could not be read — {e:#}")
}
Err(e) if is_torn_frame_error(&e) => {
bail!(
"forensic family '{family_id}' data is corrupt or internally inconsistent \
(the read produced a WAL/page error) — the family is a static snapshot; \
restore it from the original store if a healthy copy exists"
)
}
Err(e) => {
Err(e).with_context(|| format!("forensic family '{family_id}' could not be read"))
}
}
}
fn query_family(family_id: &str, sql: &str, root: &Path) -> Result<()> {
let output = execute_family_query(family_id, sql, root)?;
write_stdout(&output)
}
async fn run_debug_with_args(args: Vec<String>, home_override: Option<PathBuf>) -> Result<()> {
let tail: Vec<&str> = args.iter().skip(2).map(String::as_str).collect();
if tail.contains(&"--help") || tail.contains(&"-h") {
print_usage();
return Ok(());
}
if args.get(2).is_some_and(|a| a == "detect") {
return run_debug_detect(&args, home_override);
}
if args.get(2).is_some_and(|a| a == "families") {
return run_debug_families(&args, home_override);
}
if args.get(2).is_some_and(|a| a == "--family") {
if args.len() < 5 {
print_usage();
bail!("expected: mahbot debug --family <id> \"SQL query\"");
}
let mahbot_home = resolve_home(home_override)?;
let sql = &args[4];
validate_read_only(sql)?;
return query_family(&args[3], sql, &mahbot_home);
}
if args.len() < 4 {
print_usage();
bail!("expected: mahbot debug --db <name> [\"SQL query\"]");
}
if args[2] != "--db" {
eprintln!("Error: expected --db flag, got '{}'", args[2]);
print_usage();
bail!("expected --db flag");
}
let db_name = &args[3];
let sql = args.get(4).map(String::as_str);
let mahbot_home = resolve_home(home_override)?;
if let Some(sql) = sql {
validate_read_only(sql)?;
}
let db_list = resolve_db_list(db_name, &mahbot_home)?;
let flock_guard = flock_gate(
&mahbot_home,
&db_list.iter().map(|(l, _)| l.clone()).collect::<Vec<_>>(),
)
.await?;
let mut failures = 0usize;
for (label, file_path) in &db_list {
if db_name == "all" {
print_line(format_args!("=== {label} ==="))?;
}
if !file_path.exists() {
if db_name == "all" {
eprintln!(
"Warning: database not found, skipping: {}",
file_path.display()
);
failures += 1;
continue;
}
bail!("database file not found: {}", file_path.display());
}
let result = match sql {
Some(sql) => query_one_store(file_path, sql, &mahbot_home, flock_guard.as_ref()).await,
None => dump_one_store(file_path, label, &mahbot_home, flock_guard.as_ref()).await,
};
match result {
Ok(()) => {}
Err(e) => {
if db_name == "all" {
eprintln!("Error: {e:#}");
failures += 1;
continue;
}
return Err(e);
}
}
}
if db_name == "all" && failures > 0 {
bail!(
"{failures} of {} store(s) failed — see the per-store errors above",
db_list.len()
);
}
Ok(())
}
async fn open_with_retry(
file_path: &Path,
root: &Path,
flock_guard: Option<&File>,
open_fn: impl Fn(&Path, &Path, Option<&File>) -> Result<()>,
) -> Result<()> {
let is_live = turso_mod::store_sidecars(file_path).tshm.exists();
if let Some(artifact_msg) = wait_out_artifact(file_path, is_live).await {
bail!("{artifact_msg}");
}
let mut attempt = 0usize;
loop {
match open_fn(file_path, root, flock_guard) {
Ok(()) => return Ok(()),
Err(e) => {
if is_engine_panic_error(&e) {
bail!(
"database '{}' could not be read — {e:#}",
file_path.display()
);
}
if !is_torn_frame_error(&e) {
return Err(e);
}
if is_live && attempt < MAX_OPEN_ATTEMPTS - 1 {
sleep(Duration::from_secs(OPEN_RETRY_BACKOFF_SECS[attempt])).await;
attempt += 1;
continue;
}
if let Some(artifact_msg) = detect_live_artifact(file_path) {
eprintln!(
"Note: after {attempt} retries, the final attempt still hit a torn-frame read."
);
bail!("{artifact_msg}");
}
let msg = corruption_error_message(file_path, attempt);
bail!("{msg}");
}
}
}
}
async fn query_one_store(
file_path: &Path,
sql: &str,
root: &Path,
flock_guard: Option<&File>,
) -> Result<()> {
open_with_retry(file_path, root, flock_guard, |path, root_path, guard| {
open_and_query_readonly(path, sql, root_path, guard)
})
.await
}
async fn dump_one_store(
file_path: &Path,
label: &str,
root: &Path,
flock_guard: Option<&File>,
) -> Result<()> {
open_with_retry(file_path, root, flock_guard, |path, root_path, guard| {
open_and_dump_readonly(path, label, root_path, guard)
})
.await
}
async fn wait_out_artifact(db_path: &Path, is_live: bool) -> Option<String> {
let mut attempt = 0usize;
while let Some(msg) = detect_live_artifact(db_path) {
if is_live && attempt < MAX_OPEN_ATTEMPTS - 1 {
sleep(Duration::from_secs(OPEN_RETRY_BACKOFF_SECS[attempt])).await;
attempt += 1;
continue;
}
return Some(msg);
}
None
}
fn detect_live_artifact(db_path: &Path) -> Option<String> {
let status = wal_guard::inspect_store_at(db_path, wal_guard::StoreFds::none());
if status.class.blocks_checkpoint() {
Some(artifact_error_message(db_path, status.class.label()))
} else {
None
}
}
fn artifact_error_message(db_path: &Path, class: &str) -> String {
format!(
"live instance artifact ({class}): cannot read '{}' safely.\n\
The on-disk `-wal`/`.tshm` coordination state is inconsistent with the \
daemon's live WAL (foreign standard-SQLite activity likely removed or \
replaced the `-wal` files under the daemon). Query a snapshot \
copy instead. Never delete or recreate `-wal`/`-shm`/`-tshm` files \
while the daemon runs.",
db_path.display()
)
}
fn corruption_error_message(db_path: &Path, retries: usize) -> String {
let tshm_path = turso_mod::store_sidecars(db_path).tshm;
if tshm_path.exists() {
format!(
"database corruption/inconsistency: cannot read '{}' — the read \
produced a WAL/page error even after {retries} retries. This is a \
live store: query a snapshot copy instead (copy `db` + `-wal`, no \
`-tshm`, while the daemon runs), or retry during a quiet window.",
db_path.display(),
)
} else {
format!(
"database corruption/inconsistency: cannot read '{}' — the copied \
main DB file (or its WAL) is corrupt or internally inconsistent. \
No `-tshm` was present, so this is a snapshot copy, not a live \
store. Re-copy the store files (a copy taken mid-checkpoint can be \
inconsistent); if a fresh copy still fails, the store's on-disk \
data itself is corrupt.",
db_path.display()
)
}
}
const ENGINE_PANIC_PREFIX: &str = "the database engine panicked while reading the store: ";
const STDOUT_WRITE_ERROR_PREFIX: &str = "failed writing to stdout: ";
fn guard_panics<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
std::panic::set_hook(prev);
match result {
Ok(r) => r,
Err(payload) => Err(anyhow!(
"{ENGINE_PANIC_PREFIX}{}",
crate::util::panic_message(&*payload)
)),
}
}
fn is_engine_panic_error(err: &anyhow::Error) -> bool {
format!("{err:#}").starts_with(ENGINE_PANIC_PREFIX)
}
pub(crate) fn open_readonly(
open_path: &Path,
display_path: &Path,
opts: turso::core::DatabaseOpts,
) -> Result<(
std::sync::Arc<dyn turso::core::IO>,
std::sync::Arc<turso::core::Database>,
)> {
let path_str = open_path
.to_str()
.with_context(|| format!("database path must be UTF-8: {}", open_path.display()))?;
let io: std::sync::Arc<dyn turso::core::IO> =
std::sync::Arc::new(turso::core::PlatformIO::new()?);
let db = turso::core::Database::open_file_with_flags(
io.clone(),
path_str,
turso::core::OpenFlags::ReadOnly | turso::core::OpenFlags::NoLock,
opts,
None,
)
.map_err(|e| {
anyhow!(
"failed to open database '{}' read-only: {e}",
display_path.display()
)
})?;
Ok((io, db))
}
pub(crate) fn connect_readonly(
db: &std::sync::Arc<turso::core::Database>,
db_path: &Path,
) -> Result<std::sync::Arc<turso::core::Connection>> {
let conn = db
.connect()
.map_err(|e| anyhow!("failed to connect to database '{}': {e}", db_path.display()))?;
conn.execute("PRAGMA temp_store = MEMORY").map_err(|e| {
anyhow!(
"failed to set in-memory temp storage on '{}': {e}",
db_path.display()
)
})?;
Ok(conn)
}
fn connect_execute(
io: &std::sync::Arc<dyn turso::core::IO>,
db: &std::sync::Arc<turso::core::Database>,
sql: &str,
db_path: &Path,
) -> Result<String> {
let conn = connect_readonly(db, db_path)?;
execute_query_readonly(io, &conn, sql, db_path)
}
fn connect_execute_print(
io: &std::sync::Arc<dyn turso::core::IO>,
db: &std::sync::Arc<turso::core::Database>,
sql: &str,
db_path: &Path,
) -> Result<()> {
let output = connect_execute(io, db, sql, db_path)?;
write_stdout(&output)
}
fn open_and_query_readonly(
file_path: &Path,
sql: &str,
root: &Path,
flock_guard: Option<&File>,
) -> Result<()> {
open_and_run_readonly(file_path, root, flock_guard, |io, db, path| {
connect_execute_print(io, db, sql, path)
})
}
fn open_and_run_readonly<T>(
file_path: &Path,
root: &Path,
flock_guard: Option<&File>,
runner: impl FnOnce(
&std::sync::Arc<dyn turso::core::IO>,
&std::sync::Arc<turso::core::Database>,
&Path,
) -> Result<T>,
) -> Result<T> {
guard_panics(|| {
let (io, db) = open_readonly(
file_path,
file_path,
turso_mod::experimental_database_opts(),
)?;
#[cfg(unix)]
if flock_guard.is_none() {
let tshm = turso_mod::store_sidecars(file_path).tshm;
if tshm.exists() && probe_tshm_byte0_pid(&tshm).is_none() {
let lock_path = crate::lock_utils::lock_file_path(root);
if lock_path.exists() && !probe_flock_free(&lock_path) {
bail!(
"live daemon lock-drop detected after open on '{}' — the open \
classified Exclusive, aborting (retry later or query a snapshot copy)",
file_path.display()
);
}
}
}
runner(&io, &db, file_path)
})
}
fn open_and_dump_readonly(
file_path: &Path,
label: &str,
root: &Path,
flock_guard: Option<&File>,
) -> Result<()> {
open_and_run_readonly(file_path, root, flock_guard, |io, db, path| {
let dump = dump_schema(io, db, path, label)?;
write_stdout(&dump)
})
}
fn execute_query_readonly(
io: &std::sync::Arc<dyn turso::core::IO>,
conn: &std::sync::Arc<turso::core::Connection>,
sql: &str,
db_path: &Path,
) -> Result<String> {
let mut stmt = conn
.query(sql)
.map_err(|e| anyhow!("SQL query failed on '{}': {e}", db_path.display()))?
.ok_or_else(|| anyhow!("query produced no statement on '{}'", db_path.display()))?;
let col_count = stmt.num_columns();
if col_count == 0 {
return Ok(String::new());
}
let mut out = String::new();
let column_names: Vec<String> = (0..col_count)
.map(|i| stmt.get_column_name(i).into_owned())
.collect();
out.push_str(&column_names.join("|"));
out.push('\n');
let mut row_count = 0usize;
let mut has_more = false;
loop {
match stmt
.step()
.map_err(|e| anyhow!("SQL query failed on '{}': {e}", db_path.display()))?
{
turso::core::StepResult::Done => break,
turso::core::StepResult::IO | turso::core::StepResult::Yield => {
io.step()
.map_err(|e| anyhow!("SQL query failed on '{}': {e}", db_path.display()))?;
}
turso::core::StepResult::Row => {
if row_count >= ROW_LIMIT {
has_more = true;
break;
}
let row = stmt
.row()
.ok_or_else(|| anyhow!("row missing after StepResult::Row"))?;
out.push_str(&format_core_row(row, col_count));
out.push('\n');
row_count += 1;
}
turso::core::StepResult::Interrupt => {
bail!("query interrupted on '{}'", db_path.display())
}
turso::core::StepResult::Busy => {
bail!("database busy on '{}'; try again later", db_path.display())
}
}
}
if has_more {
out.push_str(&format_truncation_row(col_count));
out.push('\n');
}
Ok(out)
}
const USER_TABLES_SQL: &str = "SELECT name, sql FROM sqlite_master \
WHERE type = 'table' AND sql IS NOT NULL AND {filter} \
ORDER BY name";
fn dump_schema(
io: &std::sync::Arc<dyn turso::core::IO>,
db: &std::sync::Arc<turso::core::Database>,
db_path: &Path,
label: &str,
) -> Result<String> {
use std::fmt::Write as _;
let conn = connect_readonly(db, db_path)?;
let tables_sql = USER_TABLES_SQL.replace("{filter}", turso_mod::USER_OBJECT_FILTER);
let tables = collect_rows(io, &conn, &tables_sql, db_path, |row| {
let name = format_core_value(row.get_value(0));
let sql = format_core_value(row.get_value(1));
Ok((name, sql))
})?;
let mut out = format!("== schema dump: {label} ==\n");
for (name, sql) in tables {
let count_sql = format!("SELECT COUNT(*) FROM {}", quote_ident(&name));
let counts = collect_rows(io, &conn, &count_sql, db_path, |row| {
Ok(format_core_value(row.get_value(0)))
})?;
let count = counts.first().ok_or_else(|| {
anyhow!(
"row count query returned no rows on '{}'",
db_path.display()
)
})?;
write!(out, "\n[table] {name}\n{sql}\nrows: {count}\n")
.expect("writing to a String cannot fail");
}
Ok(out)
}
fn collect_rows<T>(
io: &std::sync::Arc<dyn turso::core::IO>,
conn: &std::sync::Arc<turso::core::Connection>,
sql: &str,
db_path: &Path,
mut collect: impl FnMut(&turso::core::Row) -> Result<T>,
) -> Result<Vec<T>> {
let mut stmt = conn
.query(sql)
.map_err(|e| anyhow!("SQL query failed on '{}': {e}", db_path.display()))?
.ok_or_else(|| anyhow!("query produced no statement on '{}'", db_path.display()))?;
let mut rows = Vec::new();
loop {
match stmt
.step()
.map_err(|e| anyhow!("SQL query failed on '{}': {e}", db_path.display()))?
{
turso::core::StepResult::Done => break,
turso::core::StepResult::IO | turso::core::StepResult::Yield => {
io.step()
.map_err(|e| anyhow!("SQL query failed on '{}': {e}", db_path.display()))?;
}
turso::core::StepResult::Row => {
let row = stmt
.row()
.ok_or_else(|| anyhow!("row missing after StepResult::Row"))?;
rows.push(collect(row)?);
}
turso::core::StepResult::Interrupt => {
bail!("query interrupted on '{}'", db_path.display())
}
turso::core::StepResult::Busy => {
bail!("database busy on '{}'; try again later", db_path.display())
}
}
}
Ok(rows)
}
fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn is_torn_frame_error(err: &anyhow::Error) -> bool {
let msg = format!("{err:#}").to_lowercase();
TORN_FRAME_SIGNATURES.iter().any(|sig| msg.contains(sig))
}
fn resolve_db_list(name: &str, root: &Path) -> Result<Vec<(String, PathBuf)>> {
if name == "all" {
let names = turso_mod::store_names();
return Ok(names
.iter()
.map(|n| (n.to_string(), turso_mod::store_db_path(root, n)))
.collect());
}
validate_store_name(name, true)?;
Ok(vec![(
name.to_string(),
turso_mod::store_db_path(root, name),
)])
}
fn validate_read_only(sql: &str) -> Result<()> {
let tokens = tokenize_sql(sql);
for (idx, token) in tokens.iter().enumerate() {
let upper = token.to_uppercase();
if BLOCKLIST.contains(&upper.as_str()) {
bail!("query rejected: contains blocked keyword '{token}'");
}
if upper == "PRAGMA" {
let Some(name) = tokens.get(idx + 1) else {
bail!("query rejected: incomplete PRAGMA statement");
};
if !SAFE_PRAGMAS.contains(&name.to_lowercase().as_str()) {
bail!(
"query rejected: PRAGMA '{name}' is not on the read-only allowlist \
(mutating PRAGMAs are blocked; the connection is read-only)"
);
}
}
}
Ok(())
}
fn tokenize_sql(sql: &str) -> Vec<String> {
sql.split(|c: char| c.is_whitespace() || SQL_PUNCTUATION.contains(&c))
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}
fn format_core_row(row: &turso::core::Row, column_count: usize) -> String {
let parts: Vec<String> = (0..column_count)
.map(|idx| format_core_value(row.get_value(idx)))
.collect();
parts.join("|")
}
fn format_core_value(val: &turso::core::Value) -> String {
match val {
turso::core::Value::Null => String::new(),
turso::core::Value::Numeric(turso::core::Numeric::Integer(i)) => i.to_string(),
turso::core::Value::Numeric(turso::core::Numeric::Float(fl)) => {
let f: f64 = (*fl).into();
f.to_string()
}
turso::core::Value::Text(t) => t.as_str().to_string(),
turso::core::Value::Blob(b) => crate::util::hex_string(b),
}
}
fn format_truncation_row(column_count: usize) -> String {
let parts: Vec<&str> = match column_count {
1 => vec!["truncated"],
2 => vec!["truncated", "truncated"],
_ => {
let mut parts = vec!["..."];
parts.extend(std::iter::repeat_n("truncated", column_count - 2));
parts.push("...");
parts
}
};
parts.join("|")
}
fn print_usage() {
eprintln!("Usage: mahbot debug --db <name> [\"SQL query\"]");
eprintln!(" mahbot debug detect [--db <name>]");
eprintln!(" mahbot debug families [--db <name>]");
eprintln!(" mahbot debug --family <id> \"SQL query\"");
let names = turso_mod::store_names().join(" | ");
eprintln!(" -h, --help print this help and exit 0");
eprintln!(" --db <name> {names} | all");
eprintln!(" with a SQL argument: read-only query, pipe-delimited output");
eprintln!(" without one: schema dump — one block per user table");
eprintln!(" (`[table] <name>` / DDL / `rows: N`); `all` dumps every live");
eprintln!(" database in per-store sections (per-store errors; exit 1 if");
eprintln!(" any store failed)");
eprintln!(" SQL query read-only SQL, quoted as a single argument");
eprintln!(" detect classify coordination state without opening stores");
eprintln!(" families list quarantine/pre-reindex forensic families (--db filters by");
eprintln!(" store name; a name matching nothing prints an empty list)");
eprintln!(" --family <id> read-only SQL against one forensic family (id from `families`)");
eprintln!();
eprintln!("Examples:");
eprintln!(" mahbot debug --db board");
eprintln!(" mahbot debug --db all");
eprintln!(" mahbot debug --db board \"SELECT phase, COUNT(*) FROM tickets GROUP BY phase\"");
eprintln!(" mahbot debug --db all \"SELECT name FROM sqlite_master WHERE type='table'\"");
eprintln!(" mahbot debug detect");
eprintln!(" mahbot debug families");
eprintln!(
" mahbot debug --family board.db.quarantine-20260812T120000Z-1234 \"SELECT COUNT(*) FROM tickets\""
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocklist_rejects_mutation_keywords() {
for sql in [
"DROP TABLE tickets",
"DELETE FROM logs",
"INSERT INTO logs VALUES (1)",
"UPDATE users SET name='x'",
"VACUUM",
"BEGIN",
"PRAGMA wal_checkpoint(TRUNCATE)",
] {
assert!(validate_read_only(sql).is_err(), "should reject: {sql}");
}
}
#[test]
fn safe_queries_pass_validation() {
for sql in [
"SELECT * FROM tickets",
"SELECT created_at FROM logs LIMIT 10",
"PRAGMA quick_check",
"PRAGMA integrity_check(1)",
"PRAGMA table_info(tickets)",
"SELECT COUNT(*) FROM sqlite_master WHERE type='table'",
] {
assert!(validate_read_only(sql).is_ok(), "should accept: {sql}");
}
}
#[test]
fn mutating_pragmas_are_rejected() {
for sql in [
"PRAGMA wal_checkpoint",
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=OFF",
"PRAGMA auto_vacuum=INCREMENTAL",
] {
assert!(validate_read_only(sql).is_err(), "should reject: {sql}");
}
}
#[test]
fn tokenizer_splits_sql_punctuation() {
let tokens = tokenize_sql("SELECT a, b FROM t WHERE x='y'");
assert!(tokens.contains(&"SELECT".to_string()));
assert!(tokens.contains(&"b".to_string()));
assert!(tokens.contains(&"y".to_string()));
assert!(!tokens.contains(&String::new()));
}
#[test]
fn torn_frame_signatures_match_engine_errors() {
let cases = [
"I/O error: short read on WAL frame at offset 1466752: expected 4096 bytes, got 0",
"I/O error: short read on page 12: expected 4096 bytes, got 512",
"Invalid page type: 0",
"WAL frame page mismatch at frame 3: expected page 5, got 9",
"Checksum mismatch on page 7: expected 123, got 456",
];
for msg in cases {
let err = anyhow!("{msg}");
assert!(is_torn_frame_error(&err), "should classify: {msg}");
}
let unrelated = anyhow!("SQL error: no such table: foo");
assert!(!is_torn_frame_error(&unrelated));
let unrelated = anyhow!("SQL error: no such table: torn_table");
assert!(!is_torn_frame_error(&unrelated));
}
#[test]
fn artifact_error_message_is_actionable() {
let msg = artifact_error_message(Path::new("/tmp/x/board.db"), "orphaned-wal");
assert!(msg.contains("live instance artifact"));
assert!(msg.contains("snapshot"));
assert!(msg.contains("Never delete or recreate"));
}
#[tokio::test]
#[serial_test::serial(family)]
async fn run_debug_queries_a_real_store_read_only() {
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"logs".to_string(),
"SELECT COUNT(*) FROM logs".to_string(),
];
let result = run_debug_with_args(args, Some(dir.path().to_path_buf())).await;
assert!(
result.is_ok(),
"read-only query on a real store must succeed: {result:?}"
);
let db_dir = dir.path().join("db");
let names: Vec<String> = std::fs::read_dir(&db_dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert!(
names.iter().any(|n| n == "logs.db"),
"store db must exist: {names:?}"
);
let tshm_count = names.iter().filter(|n| n.ends_with("-tshm")).count();
assert_eq!(tshm_count, 1, "no new -tshm file may be created: {names:?}");
}
#[ignore = "burns the full 15 s torn-frame retry backoff on a crafted artifact state; runs only when explicitly invoked"]
#[tokio::test]
#[serial_test::serial(family)]
async fn run_debug_reports_artifact_instead_of_torn_frame() {
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let db_dir = dir.path().join("db");
let tshm_path = db_dir.join("logs.db-tshm");
let wal_path = db_dir.join("logs.db-wal");
std::fs::write(&wal_path, []).unwrap();
let mut tshm = std::fs::read(&tshm_path).unwrap();
assert!(tshm.len() >= 64, "tshm header must cover max_frame");
tshm[56..64].copy_from_slice(&356u64.to_le_bytes());
std::fs::write(&tshm_path, &tshm).unwrap();
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"logs".to_string(),
"SELECT COUNT(*) FROM logs".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("artifact state must fail with the explicit artifact error");
let msg = format!("{err:#}");
assert!(
msg.contains("live instance artifact"),
"must be the explicit artifact error, got: {msg}"
);
assert!(
!msg.to_lowercase().contains("short read"),
"must not leak raw torn-frame output: {msg}"
);
}
#[test]
fn corruption_message_distinguishes_live_and_snapshot() {
let dir = std::env::temp_dir().join(format!("debug_corrupt_msg_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let snap = dir.join("logs.db");
std::fs::write(&snap, b"garbage").unwrap();
let msg = corruption_error_message(&snap, 0);
assert!(msg.contains("corruption"), "got: {msg}");
assert!(
!msg.contains("live instance artifact"),
"a snapshot must not be reported as a live artifact: {msg}"
);
assert!(
!msg.to_lowercase().contains("invalid page type"),
"raw torn-frame text must not leak: {msg}"
);
std::fs::write(dir.join("logs.db-tshm"), b"x").unwrap();
let msg_live = corruption_error_message(&snap, 4);
assert!(msg_live.contains("live store"), "got: {msg_live}");
assert!(msg_live.contains("4 retries"), "got: {msg_live}");
let _ = std::fs::remove_dir_all(&dir);
}
#[ignore = "burns the full 15 s torn-frame retry backoff on a crafted artifact state; runs only when explicitly invoked"]
#[tokio::test]
#[serial_test::serial(family)]
async fn run_debug_all_reports_failure_summary() {
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let db_dir = dir.path().join("db");
let tshm_path = db_dir.join("logs.db-tshm");
let wal_path = db_dir.join("logs.db-wal");
std::fs::write(&wal_path, []).unwrap();
let mut tshm = std::fs::read(&tshm_path).unwrap();
assert!(tshm.len() >= 64, "tshm header must cover max_frame");
tshm[56..64].copy_from_slice(&7u64.to_le_bytes());
std::fs::write(&tshm_path, &tshm).unwrap();
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"all".to_string(),
"SELECT COUNT(*) FROM logs".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("--db all must fail when any store fails");
let msg = format!("{err:#}");
assert!(
msg.contains("store(s) failed"),
"expected a failure summary, got: {msg}"
);
assert!(
!msg.to_lowercase().contains("short read"),
"summary must not leak raw torn-frame text: {msg}"
);
let _ = std::fs::remove_dir_all(dir.path());
}
#[tokio::test]
#[serial_test::serial(family)]
async fn schema_dump_prints_user_tables_with_row_counts() {
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
store
.conn
.execute(
"INSERT INTO logs (timestamp, level, target, message) \
VALUES ('2026-01-01T00:00:00Z', 'INFO', 'test', 'hello')",
turso_mod::params![],
)
.await
.expect("insert a log row");
let db_path = dir.path().join("db").join("logs.db");
let dump = open_and_run_readonly(&db_path, dir.path(), None, |io, db, path| {
dump_schema(io, db, path, "logs")
})
.expect("dump must succeed on a real store");
assert!(
dump.starts_with("== schema dump: logs ==\n"),
"dump must open with the store header: {dump}"
);
for table in ["logs", "tool_calls", "llm_requests"] {
assert!(
dump.contains(&format!("\n[table] {table}\n")),
"user table block missing for '{table}': {dump}"
);
}
assert!(
dump.contains("CREATE TABLE logs ("),
"table DDL must be included: {dump}"
);
assert!(
dump.contains("\nrows: 1\n"),
"row count must reflect the live row: {dump}"
);
assert!(
!dump.contains("__turso_internal_"),
"internal turso artifacts must be excluded: {dump}"
);
assert!(
!dump.contains("sqlite_sequence"),
"sqlite_sequence must be excluded: {dump}"
);
}
#[tokio::test]
#[serial_test::serial(family)]
async fn run_debug_dumps_schema_without_sql() {
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"logs".to_string(),
];
let result = run_debug_with_args(args, Some(dir.path().to_path_buf())).await;
assert!(
result.is_ok(),
"schema dump without SQL must succeed: {result:?}"
);
let db_dir = dir.path().join("db");
let names: Vec<String> = std::fs::read_dir(&db_dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
let tshm_count = names.iter().filter(|n| n.ends_with("-tshm")).count();
assert_eq!(tshm_count, 1, "no new -tshm file may be created: {names:?}");
}
#[tokio::test]
#[serial_test::serial(family)]
async fn run_debug_dump_all_reports_missing_stores() {
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"all".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("--db all with missing stores must report a failure summary");
let msg = format!("{err:#}");
let total = turso_mod::store_names().len();
assert!(
msg.contains(&format!("{} of {total} store(s) failed", total - 1)),
"must summarize per-store failures, got: {msg}"
);
}
#[test]
fn family_name_parser_round_trips() {
let q = parse_family_name("board.db.quarantine-20260812T120000Z-1234").unwrap();
assert_eq!(q.store, "board");
assert_eq!(q.kind, FamilyKind::Quarantine);
assert_eq!(q.stamp, "20260812T120000Z");
assert!(parse_family_name("board.db.quarantine-20260812T120000Z-1234-2").is_some());
assert_eq!(
parse_family_name("stats.db.quarantine-20260812T120000Z-7")
.unwrap()
.store,
"stats"
);
let p = parse_family_name("logs.db.pre-reindex-20260812T120000Z-99").unwrap();
assert_eq!(p.kind, FamilyKind::PreReindex);
for bad in [
"board.db.quarantine-20260812T120000Z", "board.db.quarantine-20260812T120000Z-abc", "board.db.quarantine-20260812T120000Z-1234-", "board.db.pre-reindex-20260812T120000Z-99-1", "board.db.quarantine-12T34-1", ] {
assert!(parse_family_name(bad).is_none(), "must reject: {bad}");
}
}
fn valid_db_bytes() -> Vec<u8> {
let mut b = vec![0u8; 128];
b[..16].copy_from_slice(b"SQLite format 3\0");
b[16] = 0x10; b[17] = 0x00;
b
}
#[test]
fn list_families_classifies_file_sets() {
let dir = tempfile::TempDir::new().unwrap();
let db_dir = dir.path().join("db");
std::fs::create_dir_all(&db_dir).unwrap();
let c = "board.db.quarantine-20260812T120000Z-100";
std::fs::write(db_dir.join(c), valid_db_bytes()).unwrap();
for s in ["-wal", "-tshm"] {
std::fs::write(db_dir.join(format!("{c}{s}")), b"x").unwrap();
}
let p = "logs.db.pre-reindex-20260812T120000Z-200";
std::fs::write(db_dir.join(p), valid_db_bytes()).unwrap();
std::fs::write(db_dir.join(format!("{p}-wal")), b"x").unwrap();
let s = "sessions.db.quarantine-20260812T120000Z-300";
std::fs::write(db_dir.join(format!("{s}-wal")), b"x").unwrap();
std::fs::write(db_dir.join(format!("{s}-tshm")), b"x").unwrap();
let pa = "users.db.quarantine-20260812T120000Z-400";
std::fs::write(db_dir.join(pa), valid_db_bytes()).unwrap();
std::fs::write(db_dir.join(format!("{pa}-wal")), b"x").unwrap();
let bh = "config.db.quarantine-20260812T120000Z-500";
std::fs::write(db_dir.join(bh), vec![b'x'; 128]).unwrap();
std::fs::write(db_dir.join(".DS_Store"), b"").unwrap();
std::fs::write(db_dir.join("board.db"), valid_db_bytes()).unwrap();
let families = list_families(dir.path()).unwrap();
let by_id: std::collections::BTreeMap<&str, &FamilyInfo> =
families.iter().map(|f| (f.id.as_str(), f)).collect();
assert_eq!(by_id.len(), 5, "families: {families:?}");
assert_eq!(by_id[c].class, FamilyClass::Complete);
assert_eq!(by_id[c].files, "db,wal,tshm");
assert_eq!(by_id[p].class, FamilyClass::Complete);
assert_eq!(by_id[p].files, "db,wal");
assert_eq!(by_id[s].class, FamilyClass::SidecarOnly);
assert_eq!(by_id[s].files, "wal,tshm");
assert_eq!(by_id[pa].class, FamilyClass::Partial);
assert_eq!(by_id[bh].class, FamilyClass::BadHeader);
let stores: Vec<&str> = families.iter().map(|f| f.store.as_str()).collect();
assert_eq!(stores, ["board", "config", "logs", "sessions", "users"]);
}
fn move_family_aside(db_dir: &Path, base: &Path, fam: &str) -> Vec<String> {
let sidecars = turso_mod::store_sidecars(base);
let mut moved = Vec::new();
for (src, suffix) in [
(base, ""),
(&sidecars.wal, "-wal"),
(&sidecars.shm, "-shm"),
(&sidecars.tshm, "-tshm"),
] {
if src.exists() {
std::fs::rename(src, db_dir.join(format!("{fam}{suffix}"))).unwrap();
moved.push(format!("{fam}{suffix}"));
}
}
moved
}
fn file_state(path: &Path) -> (u64, std::time::SystemTime) {
let md = std::fs::metadata(path).unwrap();
(md.len(), md.modified().unwrap())
}
#[tokio::test]
#[serial_test::serial(family)]
async fn run_debug_queries_a_quarantined_family() {
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let db_dir = dir.path().join("db");
let fam = "logs.db.quarantine-20260812T120000Z-4242";
store
.flush_batch(
"j1",
"Engineer",
"ws1",
&[crate::ToolCallRecord {
tool_name: "read".to_string(),
arguments: "{}".to_string(),
duration_ms: 1,
success: true,
error_message: None,
}],
)
.await
.unwrap();
let moved = move_family_aside(&db_dir, &db_dir.join("logs.db"), fam);
std::fs::write(db_dir.join(format!("{fam}-tshm")), vec![0xAB; 64]).unwrap();
let before: Vec<((u64, std::time::SystemTime), String)> = moved
.iter()
.map(|name| (file_state(&db_dir.join(name)), name.clone()))
.collect();
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--family".to_string(),
fam.to_string(),
"SELECT COUNT(*) FROM tool_calls".to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect("quarantined family (corrupt tshm) must be queryable via the temp copy");
for (state, name) in &before {
assert_eq!(
file_state(&db_dir.join(name)),
*state,
"family file must be unchanged: {name}"
);
}
let after_names: Vec<String> = std::fs::read_dir(&db_dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
after_names.len(),
moved.len(),
"no new files beside the family: {after_names:?}"
);
let out = execute_family_query(fam, "SELECT COUNT(*) FROM tool_calls", dir.path()).unwrap();
assert_eq!(
out, "COUNT(*)\n1\n",
"temp-copy query must return the committed row"
);
}
#[tokio::test]
#[serial_test::serial(family)]
async fn run_debug_family_error_paths_report_clear_errors() {
let dir = tempfile::TempDir::new().unwrap();
let db_dir = dir.path().join("db");
std::fs::create_dir_all(&db_dir).unwrap();
let fam = "logs.db.quarantine-20260812T120000Z-4242";
let run = |sql: &str| {
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--family".to_string(),
fam.to_string(),
sql.to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
};
std::fs::write(db_dir.join(format!("{fam}-wal")), b"x").unwrap();
let err = run("SELECT 1").await.expect_err("sidecar-only must fail");
let msg = format!("{err:#}");
assert!(msg.contains("no main database file"), "got: {msg}");
std::fs::remove_file(db_dir.join(format!("{fam}-wal"))).unwrap();
std::fs::write(db_dir.join(fam), vec![0x42; 4096]).unwrap();
let err = run("SELECT 1").await.expect_err("garbage db must fail");
let msg = format!("{err:#}");
assert!(msg.contains(fam), "error must name the family: {msg}");
}
#[test]
#[serial_test::serial(family)]
fn guard_panics_converts_panic_to_error() {
let err = guard_panics(|| -> Result<()> { panic!("boom: pager index OOB") })
.expect_err("a panic must surface as an error");
let msg = format!("{err:#}");
assert!(
msg.contains("panicked while reading the store"),
"got: {msg}"
);
assert!(msg.contains("boom: pager index OOB"), "got: {msg}");
assert!(!is_torn_frame_error(&err), "panic must not be torn-frame");
assert!(is_engine_panic_error(&err), "panic must be engine-panic");
}
#[tokio::test]
#[serial_test::serial(family)]
async fn run_debug_queries_pre_reindex_family_in_place() {
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let db_dir = dir.path().join("db");
let fam = "logs.db.pre-reindex-20260812T120000Z-4242";
store
.flush_batch(
"j1",
"Engineer",
"ws1",
&[crate::ToolCallRecord {
tool_name: "read".to_string(),
arguments: "{}".to_string(),
duration_ms: 1,
success: true,
error_message: None,
}],
)
.await
.unwrap();
move_family_aside(&db_dir, &db_dir.join("logs.db"), fam);
let _ = std::fs::remove_file(db_dir.join(format!("{fam}-shm")));
let _ = std::fs::remove_file(db_dir.join(format!("{fam}-tshm")));
assert!(
std::fs::metadata(db_dir.join(format!("{fam}-wal")))
.unwrap()
.len()
> 0,
"pre-reindex wal must hold committed frames"
);
let before: Vec<((u64, std::time::SystemTime), String)> = std::fs::read_dir(&db_dir)
.unwrap()
.map(|e| {
let path = e.unwrap().path();
(
file_state(&path),
path.file_name().unwrap().to_string_lossy().into_owned(),
)
})
.collect();
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--family".to_string(),
fam.to_string(),
"SELECT COUNT(*) FROM tool_calls".to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect("pre-reindex family must be queryable in place");
let after_names: Vec<String> = std::fs::read_dir(&db_dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
after_names.len(),
before.len(),
"in-place family query must not create files beside the family"
);
for (state, name) in &before {
assert_eq!(
file_state(&db_dir.join(name)),
*state,
"family file must be unchanged: {name}"
);
}
let out = execute_family_query(fam, "SELECT COUNT(*) FROM tool_calls", dir.path()).unwrap();
assert_eq!(
out, "COUNT(*)\n1\n",
"in-place query must read the wal-only row"
);
}
#[tokio::test]
async fn run_debug_family_rejects_invalid_id() {
let dir = tempfile::TempDir::new().unwrap();
for bad in [
"../../etc/passwd",
"board.db",
"board.db.quarantine-20260812T120000Z-1-wal",
] {
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--family".to_string(),
bad.to_string(),
"SELECT 1".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("invalid family id must be rejected");
let msg = format!("{err:#}");
assert!(msg.contains("invalid family id"), "got: {msg}");
}
}
#[tokio::test]
async fn run_debug_help_after_verb_prints_usage() {
let dir = tempfile::TempDir::new().unwrap();
for tail in [
vec!["--help"],
vec!["-h"],
vec!["families", "--help"],
vec!["detect", "--help"],
vec!["families", "-h"],
vec!["detect", "-h"],
vec!["--family", "--help"],
vec!["--family", "-h"],
vec!["families", "--db", "--help"],
vec!["detect", "--db", "--help"],
vec!["--db", "--help"],
vec!["--db", "-h"],
vec!["--family", "--help", "extra"],
vec!["--family", "-h", "extra"],
vec![
"--family",
"logs.db.quarantine-20260812T120000Z-4242",
"--help",
],
vec!["--db", "board", "--help"],
vec!["--db", "board", "-h"],
vec!["--db", "board", "--help", "extra"],
vec!["--db", "board", "-h", "extra"],
vec!["families", "--db", "board", "--help"],
vec!["detect", "--db", "board", "--help"],
vec!["detect", "--help", "extra"],
] {
let mut args = vec!["mahbot".to_string(), "debug".to_string()];
args.extend(tail.into_iter().map(str::to_owned));
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.unwrap_or_else(|e| panic!("help must print usage and exit 0: {e:#}"));
}
}
#[tokio::test]
async fn run_debug_families_filters_and_reports_missing_family() {
let dir = tempfile::TempDir::new().unwrap();
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"families".to_string(),
"--db".to_string(),
"nonexistent".to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect("families --db <matching-nothing> must print nothing and exit 0");
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--family".to_string(),
"logs.db.quarantine-20260812T120000Z-4242".to_string(),
"SELECT 1".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("a well-formed but missing family must fail");
let msg = format!("{err:#}");
assert!(msg.contains("not found"), "got: {msg}");
assert!(!msg.contains("sidecar-only"), "got: {msg}");
let db_dir = dir.path().join("db");
std::fs::create_dir_all(&db_dir).unwrap();
let legacy = "stats.db.quarantine-20260812T120000Z-7";
std::fs::write(db_dir.join(legacy), valid_db_bytes()).unwrap();
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"families".to_string(),
"--db".to_string(),
"stats".to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect("legacy store family must be filterable by --db");
}
fn write_tshm_only(root: &Path, name: &str) {
let db_path = turso_mod::store_db_path(root, name);
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
let mut bytes = vec![0u8; 116];
bytes[0..8].copy_from_slice(crate::wal_guard::TSHM_MAGIC.as_slice());
bytes[8..12].copy_from_slice(&1u32.to_le_bytes());
bytes[12..16].copy_from_slice(&64u32.to_le_bytes());
std::fs::write(turso_mod::store_sidecars(&db_path).tshm, bytes).unwrap();
}
fn touch_lock(lock_path: &Path) {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(lock_path)
.unwrap();
}
fn hold_flock(lock_path: &Path) -> std::fs::File {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(lock_path)
.unwrap();
assert!(
crate::lock_utils::try_flock(&file).unwrap(),
"test must hold the flock"
);
file
}
#[cfg(unix)]
fn hold_byte0_perl(tshm: &Path) -> std::process::Child {
let perl = format!(
"use Fcntl qw(F_SETLK F_WRLCK); open(my $f, \"+<\", $ARGV[0]) or die $!; \
my $buf = pack(\"q< q< l< s< s<\", 0, 0, $$, {}, 0); \
fcntl($f, F_SETLK, $buf) or die $!; sleep 30;",
libc::F_WRLCK,
);
std::process::Command::new("perl")
.args(["-e", &perl, tshm.to_str().unwrap()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn perl byte-0 holder")
}
#[ignore = "exercises real cross-process fcntl locks with multi-second waits; runs only when explicitly invoked"]
#[tokio::test]
#[serial_test::serial(flock_gate)]
async fn flock_gate_passes_without_lock_file() {
let dir = tempfile::TempDir::new().unwrap();
write_tshm_only(dir.path(), "board");
assert!(
flock_gate_with_timeout(dir.path(), &["board".into()], Duration::from_secs(1))
.await
.is_ok()
);
}
#[ignore = "exercises real cross-process fcntl locks with multi-second waits; runs only when explicitly invoked"]
#[tokio::test]
#[cfg(unix)]
#[serial_test::serial(flock_gate)]
async fn flock_gate_proceeds_when_daemon_alive() {
let dir = tempfile::TempDir::new().unwrap();
write_tshm_only(dir.path(), "board");
let _flock = hold_flock(&crate::lock_utils::lock_file_path(dir.path()));
let tshm = turso_mod::store_sidecars(&turso_mod::store_db_path(dir.path(), "board")).tshm;
let mut child = hold_byte0_perl(&tshm);
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(
probe_tshm_byte0_pid(&tshm).is_some(),
"child must hold the byte-0 lock"
);
let guard = flock_gate_with_timeout(dir.path(), &["board".into()], Duration::from_secs(1))
.await
.expect("flock held + byte-0 held must proceed");
assert!(
guard.is_none(),
"a live daemon holds the flock — the gate must not take it"
);
let _ = child.kill();
let _ = child.wait();
}
#[ignore = "exercises real cross-process fcntl locks with multi-second waits; runs only when explicitly invoked"]
#[tokio::test]
#[serial_test::serial(flock_gate)]
async fn flock_gate_takes_flock_in_crash_recovery() {
let dir = tempfile::TempDir::new().unwrap();
write_tshm_only(dir.path(), "board");
let lock_path = crate::lock_utils::lock_file_path(dir.path());
touch_lock(&lock_path);
let flock = hold_flock(&lock_path);
let dir_path = dir.path().to_path_buf();
let names = vec!["board".to_string()];
let gate = tokio::spawn(async move {
flock_gate_with_timeout(&dir_path, &names, Duration::from_secs(4)).await
});
tokio::time::sleep(Duration::from_millis(500)).await;
drop(flock); let guard = gate
.await
.expect("gate task must not panic")
.expect("flock free + byte-0 free after busy must proceed");
assert!(
!probe_flock_free(&lock_path),
"the returned guard must hold the flock (probe sees it busy)"
);
drop(guard);
let release_deadline = tokio::time::Instant::now() + Duration::from_millis(500);
while !probe_flock_free(&lock_path) {
assert!(
tokio::time::Instant::now() < release_deadline,
"dropping the guard must release the flock"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[ignore = "exercises real cross-process fcntl locks with multi-second waits; runs only when explicitly invoked"]
#[tokio::test]
#[cfg(unix)]
#[serial_test::serial(flock_gate)]
async fn flock_gate_never_takes_flock_during_handoff() {
let dir = tempfile::TempDir::new().unwrap();
write_tshm_only(dir.path(), "board");
let lock_path = crate::lock_utils::lock_file_path(dir.path());
touch_lock(&lock_path);
let tshm = turso_mod::store_sidecars(&turso_mod::store_db_path(dir.path(), "board")).tshm;
let mut child = hold_byte0_perl(&tshm);
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(
probe_tshm_byte0_pid(&tshm).is_some(),
"child must hold byte-0"
);
let err = flock_gate_with_timeout(dir.path(), &["board".into()], Duration::from_secs(1))
.await
.expect_err("flock free + byte-0 held must wait and time out");
assert!(
err.downcast_ref::<GateRefusal>().is_some(),
"must be a GateRefusal (exit code 2), got: {err:#}"
);
assert!(
probe_flock_free(&lock_path),
"the gate must never take the flock during the handoff"
);
let _ = child.kill();
let _ = child.wait();
}
#[ignore = "exercises real cross-process fcntl locks with multi-second waits; runs only when explicitly invoked"]
#[tokio::test]
#[cfg(unix)]
#[serial_test::serial(flock_gate)]
async fn flock_gate_proceeds_after_handoff_pid_change() {
let dir = tempfile::TempDir::new().unwrap();
write_tshm_only(dir.path(), "board");
write_tshm_only(dir.path(), "chat_history");
let lock_path = crate::lock_utils::lock_file_path(dir.path());
touch_lock(&lock_path);
let tshm_a = turso_mod::store_sidecars(&turso_mod::store_db_path(dir.path(), "board")).tshm;
let tshm_b =
turso_mod::store_sidecars(&turso_mod::store_db_path(dir.path(), "chat_history")).tshm;
let mut old = hold_byte0_perl(&tshm_a);
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(
probe_tshm_byte0_pid(&tshm_a).is_some() && probe_tshm_byte0_pid(&tshm_b).is_none(),
"old daemon holds board; chat_history starts unheld"
);
let dir_path = dir.path().to_path_buf();
let names = vec!["board".to_string(), "chat_history".to_string()];
let gate = tokio::spawn(async move {
flock_gate_with_timeout(&dir_path, &names, Duration::from_secs(5)).await
});
tokio::time::sleep(Duration::from_millis(100)).await;
let _ = old.kill();
let _ = old.wait();
let mut new = hold_byte0_perl(&tshm_b);
let deadline = tokio::time::Instant::now() + Duration::from_millis(600);
while probe_tshm_byte0_pid(&tshm_b).is_none() {
assert!(
tokio::time::Instant::now() < deadline,
"new daemon must hold byte-0 before the gate's next observation"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
let guard = gate
.await
.expect("gate task must not panic")
.expect("pid change must exit the handoff");
assert!(
guard.is_none(),
"the new daemon holds the flock — the gate must not take it"
);
assert!(
probe_flock_free(&lock_path),
"the flock must never be taken"
);
let _ = new.kill();
let _ = new.wait();
}
#[ignore = "exercises real cross-process fcntl locks with multi-second waits; runs only when explicitly invoked"]
#[tokio::test]
#[serial_test::serial(flock_gate)]
async fn flock_gate_refuses_in_lock_drop_state() {
let dir = tempfile::TempDir::new().unwrap();
write_tshm_only(dir.path(), "board");
let _flock = hold_flock(&crate::lock_utils::lock_file_path(dir.path()));
let err = flock_gate_with_timeout(dir.path(), &["board".into()], Duration::from_secs(1))
.await
.expect_err("flock held + byte-0 free must refuse");
assert!(
err.downcast_ref::<GateRefusal>().is_some(),
"must be a GateRefusal (exit code 2), got: {err:#}"
);
}
#[test]
fn debug_detect_reports_non_healthy_state() {
let dir = tempfile::TempDir::new().unwrap();
write_tshm_only(dir.path(), "board");
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"detect".to_string(),
"--db".to_string(),
"board".to_string(),
];
assert!(run_debug_detect(&args, Some(dir.path().to_path_buf())).is_ok());
let tshm_path =
turso_mod::store_sidecars(&turso_mod::store_db_path(dir.path(), "board")).tshm;
let mut bytes = std::fs::read(&tshm_path).unwrap();
bytes[56..64].copy_from_slice(&5u64.to_le_bytes());
std::fs::write(&tshm_path, bytes).unwrap();
let err = run_debug_detect(&args, Some(dir.path().to_path_buf()))
.expect_err("orphaned state must fail detect");
assert!(format!("{err:#}").contains("blocking"), "got: {err:#}");
}
}