ai-app: a phone interface to Claude Code and llama.cpp sessions

A Rust backend that owns the sessions and an Android app that reads them.
The server spawns and adopts CLI processes, normalises everything they emit
into one event model, keeps the transcript, and serves it over pinned TLS on
a WireGuard interface; the phone streams that, replies, sends images, and
imports conversations the machine already has.

`AGENTS.md` is the working guide -- what runs where, what has been measured,
and the faults that were expensive to find. `PLAN.md` is the design record.

History before this point was squashed away. It was a personal project's
running commentary and carried a name and a couple of machine paths that
have no business in a public repository; the tree is what mattered and the
tree is here.
This commit is contained in:
iris committed 2026-08-31 20:29:07 -04:00
commit b172c464ea
100 files changed
+31795

No files matched your search

+483
View File
@@ -0,0 +1,483 @@
//! What a session's process is, and how far this server has read it --
//! written down so a *later* run of this server can find the same process
//! rather than start a second one.
//!
//! The server deliberately outlives its own restarts badly and its
//! children well: stopping the backend must not kill a turn that is in
//! flight, so session processes are left running and adopted again on the
//! way back up. That only works if "is this still mine?" has an answer,
//! which is what this module is.
//!
//! **A pid is not an identity.** Pids are reused, so adopting one by
//! number alone eventually means treating a stranger's process as a
//! session -- never resuming the real conversation, and signalling
//! something unrelated when the session is deleted. The kernel's start
//! time for that pid is recorded beside it; the pair is unique for as long
//! as the machine has been up, which is longer than any of this lives.
//!
//! **How to reach it again belongs here too**, in the same record and the
//! same write, because it answers the other half of the same question: not
//! just "is my process still there" but "where do I pick it up". Splitting
//! them would be two files that can disagree about one process. What that
//! takes differs by driver -- a reading position into a log for one spoken
//! to over stdio, a port for one spoken to over HTTP -- so it is a typed
//! [`Detail`] rather than a union of every driver's fields.
//!
//! The record is rewritten in place as reading advances. A crash during
//! that write leaves a record that does not parse, which is read as "no
//! live process" -- so the failure is the old behaviour (start one with
//! `--resume`) rather than a wrong adoption.
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const RECORD_FILE: &str = "process.json";
/// A process this server started and expects to outlive it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Record {
pub pid: u32,
/// The kernel's start time for `pid`, in clock ticks since boot. See
/// the module comment: this is what makes the pid an identity.
pub started: u64,
/// What the driver needs in order to pick this process back up.
#[serde(flatten)]
pub detail: Detail,
}
/// How a reattaching driver reaches a process it did not start.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Detail {
/// Spoken to over stdio, which outlives the server as files in the
/// session directory. `stdout_read` is how many bytes of the stdout
/// log have already become events: everything before it is in the
/// transcript, everything after it is what a reattaching server owes
/// the conversation.
Stdio { stdout_read: u64 },
/// Spoken to over HTTP on a loopback port, which is all it takes to
/// find it again -- there is no stream to be partway through.
Http { port: u16 },
}
/// Whether a recorded process is still there.
///
/// Three answers rather than a boolean, because "I could not find out" is
/// a real one and is not the same as "no". Treating it as "no" is what
/// would start a second process against a conversation that already has
/// one -- the expensive mistake this whole module exists to prevent -- so
/// it has to be sayable.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Liveness {
Alive,
Dead,
Unknown,
}
impl Record {
/// The record for a process this server just started, or `None` when
/// the kernel will not say when it started -- which is the same
/// answer as "do not adopt this later", and the safe one.
pub fn of(pid: u32, detail: Detail) -> Option<Self> {
Some(Self {
pid,
started: stat_of(pid).ok().flatten()?.started,
detail,
})
}
/// Whether the process this describes is still the one running under
/// that pid.
pub fn liveness(&self) -> Liveness {
match stat_of(self.pid) {
// A different start time is a reused pid, which is a different
// process and so definitely not ours.
Ok(Some(stat)) if stat.started == self.started => {
if stat.exited {
Liveness::Dead
} else {
Liveness::Alive
}
}
Ok(Some(_)) | Ok(None) => Liveness::Dead,
Err(_) => Liveness::Unknown,
}
}
}
fn path(session_dir: &Path) -> PathBuf {
session_dir.join(RECORD_FILE)
}
/// The recorded process and whether it is still there, or `None` when
/// nothing usable is recorded.
///
/// A record that does not parse reads as no record: the only way to get
/// one is a crash partway through writing it, and the safe reading of that
/// is that this server has no claim on anything.
pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> {
let text = std::fs::read_to_string(path(session_dir)).ok()?;
let record: Record = serde_json::from_str(text.trim_end()).ok()?;
let liveness = record.liveness();
Some((record, liveness))
}
/// The recorded process if it is definitely still running.
///
/// One function rather than a read plus a liveness check at each caller:
/// every caller wants the same question answered, and the one that forgets
/// the second half is the one that starts a duplicate.
pub fn live(session_dir: &Path) -> Option<Record> {
match recorded(session_dir) {
Some((record, Liveness::Alive)) => Some(record),
_ => None,
}
}
/// Writes `record` where [`live`] will find it, atomically.
///
/// Written to a neighbouring file and renamed over the real name. The
/// rename is what makes this safe: a reader sees either the whole old
/// record or the whole new one, never a partial.
///
/// Writing in place would not be, and the consequence is severe rather
/// than untidy. `fs::write` truncates before it fills, so a crash inside
/// that window leaves no readable record -- and a missing record reads as
/// "nothing is running", which is the single answer that makes the next
/// launch start a *second* process against a conversation that already has
/// one. That is the fault this whole module exists to prevent, and writing
/// the record carelessly would reintroduce it at its own save point. The
/// window is not rare either: this runs on every read that makes progress,
/// so many times a second while a turn is producing output.
///
/// Errors are logged rather than returned: this runs on the reading path,
/// and a session that cannot save its position is still worth having -- it
/// just cannot be reattached to, which is what the log says.
pub fn write(session_dir: &Path, record: &Record) {
let path = path(session_dir);
let text = match serde_json::to_string(record) {
Ok(text) => text,
Err(err) => {
tracing::error!("couldn't serialize the process record: {err}");
return;
}
};
// Beside the real file so the rename stays within one filesystem,
// which is what makes it atomic.
let temp = path.with_extension("json.new");
let written = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
// Owner-only, like everything else in a session directory.
.mode(0o600)
.open(&temp)
.and_then(|mut file| {
use std::io::Write;
file.write_all(text.as_bytes())?;
file.write_all(b"\n")
})
.and_then(|()| std::fs::rename(&temp, &path));
if let Err(err) = written {
tracing::error!(
"couldn't record the session process in {}: {err}",
path.display()
);
let _ = std::fs::remove_file(&temp);
}
}
/// How many bytes `path` holds, or 0 if it is not there.
///
/// Exists so a caller wanting only the length does not have to read the
/// file to find it -- [`read_from`] with a large offset answers the
/// question, but allocates the whole file on the way.
pub fn size_of(path: &Path) -> u64 {
std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
}
/// Forgets the recorded process -- for one confirmed dead, or a session
/// being deleted. The path out for [`write`].
pub fn clear(session_dir: &Path) {
let path = path(session_dir);
if let Err(err) = std::fs::remove_file(&path)
&& err.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!("couldn't remove {}: {err}", path.display());
}
}
/// Grace period between asking a session's process to stop and killing it.
///
/// Here rather than beside each caller: it is a property of stopping one of
/// these, and two drivers plus the manager had written the same five seconds
/// down separately, which is three places for it to drift.
pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
/// Asks it to stop, then makes sure. Used where a leaked process must
/// actually end: a deleted session, or one being replaced.
///
/// SIGTERM first because the CLI writes its own session file on the way
/// out and a SIGKILL would cost whatever it had not flushed; SIGKILL after
/// the grace period because a session the phone has deleted must not still
/// be running when it looks again.
pub fn stop(record: &Record, grace: std::time::Duration) {
if record.liveness() != Liveness::Alive {
return;
}
signal(record.pid, libc::SIGTERM);
let record = record.clone();
tokio::spawn(async move {
tokio::time::sleep(grace).await;
kill_if_still_there(&record, grace);
});
}
/// Waits for processes already asked to stop, and kills whichever have
/// not, for a caller that is about to exit.
///
/// The waiting cannot be [`stop`]'s here, and that is the whole reason
/// this exists: the kill it leaves behind is a timer inside the tokio
/// runtime, and a runtime that is shutting down never runs it. That is
/// how the backend's original `shutdown_all` leaked the processes it had
/// just asked to stop -- it reported them stopped, too, which is worse
/// than not asking.
///
/// One deadline for all of them rather than one each: they were signalled
/// together, so waiting is bounded by the grace period however many there
/// are, and a server does not sit for a minute on the way out.
pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
/// How often to look. Short enough that the ordinary case -- a
/// process that goes at once -- costs nothing noticeable, and long
/// enough not to spin.
const LOOK: std::time::Duration = std::time::Duration::from_millis(20);
let deadline = std::time::Instant::now() + grace;
for record in records {
while record.liveness() == Liveness::Alive && std::time::Instant::now() < deadline {
std::thread::sleep(LOOK);
}
kill_if_still_there(record, grace);
}
}
/// The end of both paths above: a process that was asked to stop and did
/// not is killed. Written once because the two callers differ only in how
/// they wait, and a grace period that means one thing in one of them and
/// something else in the other is exactly the drift `STOP_GRACE` was
/// gathered here to prevent.
fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
if record.liveness() == Liveness::Alive {
tracing::warn!(
"session process {} did not stop within {:?}; killing it",
record.pid,
grace
);
signal(record.pid, libc::SIGKILL);
}
}
fn signal(pid: u32, signal: libc::c_int) {
// SAFETY: `kill` with a positive pid touches only that process, and
// the pid came from a record whose start time was just confirmed to
// match -- so it is still the process this server started, not a
// reused number. A failure (already gone) is nothing to act on.
unsafe {
libc::kill(pid as libc::pid_t, signal);
}
}
/// The kernel's start time for `pid`, in clock ticks since boot.
///
/// Field 22 of `/proc/<pid>/stat`, counted from the closing parenthesis of
/// field 2 rather than from the start of the line: a process's name is
/// field 2, it is wrapped in parentheses, and it may itself contain spaces
/// and parentheses. Splitting the whole line on whitespace therefore reads
/// the wrong field for anything with a space in its name.
///
/// Three outcomes, and they are not the same: `Ok(None)` is "no such
/// process", `Err` is "could not find out". Collapsing the second into the
/// first is what would let a machine without a readable `/proc` look like
/// a machine with nothing running on it. Linux-specific, like `import`'s
/// use of GNU `stat`.
/// What `/proc` says about a pid.
struct Stat {
/// The kernel's start time in clock ticks since boot -- see
/// [`Record::started`].
started: u64,
/// State `Z`: the process has ended, and the kernel is keeping its
/// entry only until somebody collects the exit status.
///
/// Read rather than ignored, because the entry it leaves behind has
/// the same pid *and* the same start time, so a process that has
/// plainly finished goes on answering "still there" for as long as
/// nothing reaps it. None of this module's callers want that answer: a
/// session whose CLI has exited is over whether or not the status has
/// been collected, and reporting it alive makes `Exited` unsayable --
/// the session shows `unknown`, its Start button never appears, and
/// stopping it says there is nothing to stop.
exited: bool,
}
fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> {
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => stat,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
// A `/proc` entry that exists but does not have the shape this reads
// is not a process that has gone away; it is a reading this code
// cannot make, which is the other thing entirely.
let unreadable =
|| std::io::Error::new(std::io::ErrorKind::InvalidData, "unreadable /proc stat");
let after_name = stat.rsplit_once(')').ok_or_else(unreadable)?.1;
// Field 3 is the first after the name, so the state is the first here
// and field 22 is the 20th.
let mut fields = after_name.split_whitespace();
let exited = fields.next().ok_or_else(unreadable)? == "Z";
let started = fields
.nth(18)
.ok_or_else(unreadable)?
.parse()
.map_err(|_| unreadable())?;
Ok(Some(Stat { started, exited }))
}
/// Reads `path` from `from`, returning what is there and where reading
/// reached. A file that has been truncated or replaced under us reads from
/// the start, since the offset no longer means anything in it.
pub fn read_from(path: &Path, from: u64) -> Result<(Vec<u8>, u64)> {
use std::io::{Read, Seek, SeekFrom};
let mut file = match std::fs::File::open(path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok((Vec::new(), from)),
Err(err) => return Err(err).with_context(|| format!("open {}", path.display())),
};
let len = file
.metadata()
.with_context(|| format!("stat {}", path.display()))?
.len();
let from = if from > len { 0 } else { from };
file.seek(SeekFrom::Start(from))
.with_context(|| format!("seek {}", path.display()))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.with_context(|| format!("read {}", path.display()))?;
let read = from + bytes.len() as u64;
Ok((bytes, read))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn this_process_is_alive_and_a_wrong_start_time_is_not() {
let mine = Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 })
.expect("this process has a start time");
assert_eq!(mine.liveness(), Liveness::Alive);
// The same pid with a different start time is a different process
// -- which is the whole reason the start time is recorded.
let recycled = Record {
started: mine.started + 1,
..mine.clone()
};
assert_eq!(recycled.liveness(), Liveness::Dead);
}
#[test]
fn a_record_round_trips_through_the_padded_file() {
let dir = tempfile::tempdir().expect("tempdir");
let mut record = Record::of(std::process::id(), Detail::Stdio { stdout_read: 4096 })
.expect("start time");
write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record.clone()));
// A shorter value must not leave a readable tail of the longer one.
record.detail = Detail::Stdio { stdout_read: 1 };
write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record.clone()));
// And the other shape round trips through the same file.
record.detail = Detail::Http { port: 8080 };
write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record));
clear(dir.path());
assert_eq!(live(dir.path()), None);
}
#[test]
fn writing_leaves_no_temporary_behind_and_stays_readable() {
let dir = tempfile::tempdir().expect("tempdir");
let mut record =
Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 }).expect("start time");
// Rewritten the way the reader rewrites it: constantly, as the
// position advances. Each one must land whole.
for read in [1u64, 4096, 2, 999_999] {
record.detail = Detail::Stdio { stdout_read: read };
write(dir.path(), &record);
assert_eq!(
live(dir.path()),
Some(record.clone()),
"after offset {read}"
);
}
// The rename is what makes it atomic; a leftover neighbour would
// mean it had not happened.
let stray: Vec<_> = std::fs::read_dir(dir.path())
.expect("read dir")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name != RECORD_FILE)
.collect();
assert!(stray.is_empty(), "left behind {stray:?}");
}
#[test]
fn a_dead_or_unreadable_record_is_not_live() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(live(dir.path()), None);
// Pid 0 is never a process we started.
write(
dir.path(),
&Record {
pid: 0,
started: 1,
detail: Detail::Stdio { stdout_read: 0 },
},
);
assert_eq!(live(dir.path()), None);
std::fs::write(dir.path().join(RECORD_FILE), "not json").expect("write");
assert_eq!(live(dir.path()), None);
}
#[test]
fn reading_resumes_from_an_offset_and_restarts_on_truncation() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("stdout.log");
std::fs::write(&path, b"hello world").expect("write");
let (bytes, read) = read_from(&path, 6).expect("read");
assert_eq!(bytes, b"world");
assert_eq!(read, 11);
// An offset past the end means the file was replaced, so the
// offset describes a file that no longer exists.
std::fs::write(&path, b"new").expect("truncate");
let (bytes, read) = read_from(&path, 11).expect("read");
assert_eq!(bytes, b"new");
assert_eq!(read, 3);
// A missing file is not an error: the process has said nothing.
let (bytes, read) = read_from(&dir.path().join("nope"), 7).expect("read");
assert!(bytes.is_empty());
assert_eq!(read, 7);
}
}