Add --file ingestion with restart-safe tailing (ticket 020)

ingest::run_file tails a path like tail -f: reads existing content,
follows appended lines, and detects file recreation/truncation (via
inode on unix, or a shrinking size) to resume tailing the new
instance from its start instead of exiting or stalling. Sequence
numbers keep incrementing across a recreation event since they're
ottyl's own line counter, not tied to the source file. stdin ingest
is unchanged and remains the default when --file is omitted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 15:08:20 +01:00
co-authored by Claude Sonnet 5
parent b3701cd2e9
commit aaa5596030
2 changed files with 230 additions and 3 deletions
+210
View File
@@ -1,8 +1,14 @@
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::engine::{EngineMessage, IngestedLine};
use crate::timestamp::Timestamp;
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
const POLL_INTERVAL: Duration = Duration::from_millis(200);
/// Reads stdin line by line, sequences and timestamps each line, and forwards it
/// to the engine task.
pub async fn run(engine: mpsc::Sender<EngineMessage>) {
@@ -23,3 +29,207 @@ pub async fn run(engine: mpsc::Sender<EngineMessage>) {
}
}
}
/// Identifies a specific file instance on disk, so a recreated path (e.g. a
/// tailed process restarting and reopening its log file) can be told apart from
/// the same file simply growing.
#[derive(PartialEq, Eq, Clone, Copy)]
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
fn file_id(meta: &std::fs::Metadata) -> FileId {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
FileId(meta.ino())
}
#[cfg(not(unix))]
{
let _ = meta;
FileId(())
}
}
async fn open(path: &Path) -> Option<(File, FileId)> {
let file = File::open(path).await.ok()?;
let meta = file.metadata().await.ok()?;
Some((file, file_id(&meta)))
}
/// Tails `path` like `tail -f`: reads existing content, then keeps reading as
/// new lines are appended. If the file is deleted and recreated at the same
/// path (or truncated in place) — e.g. the tailed process restarted and
/// reopened its log — tailing resumes against the new file from its start,
/// rather than exiting or getting stuck. `sequence` keeps incrementing across
/// a recreation event; it's ottyl's own line counter, not tied to the source
/// file.
pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
let mut sequence: u64 = 0;
'reopen: loop {
let (file, mut id) = loop {
if let Some(opened) = open(&path).await {
break opened;
}
tokio::time::sleep(POLL_INTERVAL).await;
};
let mut reader = BufReader::new(file);
let mut offset: u64 = 0;
let mut pending = String::new();
loop {
let mut chunk = String::new();
match reader.read_line(&mut chunk).await {
Ok(0) => {
// EOF: decide whether the file was truncated/recreated
// (tail the new instance from scratch) or just hasn't
// grown yet (keep polling).
match tokio::fs::metadata(&path).await {
Ok(meta) => {
let new_id = file_id(&meta);
if new_id != id || meta.len() < offset {
continue 'reopen;
}
id = new_id;
}
Err(_) => {
// Path gone (mid-recreation); wait for it to
// reappear and reopen fresh.
}
}
tokio::time::sleep(POLL_INTERVAL).await;
}
Ok(n) => {
offset += n as u64;
pending.push_str(&chunk);
if pending.ends_with('\n') {
let text = pending
.trim_end_matches(['\n', '\r'])
.to_string();
pending.clear();
let line = IngestedLine {
sequence,
timestamp: Timestamp::now(),
text,
};
sequence += 1;
if engine.send(EngineMessage::Ingest(line)).await.is_err() {
return;
}
}
// else: partial line at EOF (write in progress) — keep
// buffering and read again without treating it as final.
}
Err(_) => continue 'reopen,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tokio::sync::mpsc;
async fn recv_text(rx: &mut mpsc::Receiver<EngineMessage>) -> String {
match tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("timed out waiting for ingested line")
.expect("engine channel closed")
{
EngineMessage::Ingest(line) => line.text,
_ => panic!("expected Ingest"),
}
}
#[tokio::test]
async fn tails_existing_and_appended_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("app.log");
std::fs::write(&path, "first\n").unwrap();
let (tx, mut rx) = mpsc::channel(16);
tokio::spawn(run_file(tx, path.clone()));
assert_eq!(recv_text(&mut rx).await, "first");
let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
writeln!(f, "second").unwrap();
assert_eq!(recv_text(&mut rx).await, "second");
}
#[tokio::test]
async fn resumes_after_delete_and_recreate() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("app.log");
std::fs::write(&path, "before-restart\n").unwrap();
let (tx, mut rx) = mpsc::channel(16);
tokio::spawn(run_file(tx, path.clone()));
assert_eq!(recv_text(&mut rx).await, "before-restart");
std::fs::remove_file(&path).unwrap();
std::fs::write(&path, "after-restart\n").unwrap();
assert_eq!(recv_text(&mut rx).await, "after-restart");
}
#[tokio::test]
async fn resumes_after_truncation_in_place() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("app.log");
std::fs::write(&path, "long line before truncation\n").unwrap();
let (tx, mut rx) = mpsc::channel(16);
tokio::spawn(run_file(tx, path.clone()));
assert_eq!(recv_text(&mut rx).await, "long line before truncation");
// Truncate in place (same inode, shorter content) then write new data.
let mut f = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&path)
.unwrap();
writeln!(f, "new").unwrap();
assert_eq!(recv_text(&mut rx).await, "new");
}
#[tokio::test]
async fn sequence_keeps_incrementing_across_recreation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("app.log");
std::fs::write(&path, "one\n").unwrap();
let (tx, mut rx) = mpsc::channel(16);
tokio::spawn(run_file(tx, path.clone()));
let first = match tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.unwrap()
.unwrap()
{
EngineMessage::Ingest(line) => line.sequence,
_ => panic!("expected Ingest"),
};
assert_eq!(first, 0);
std::fs::remove_file(&path).unwrap();
std::fs::write(&path, "two\n").unwrap();
let second = match tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.unwrap()
.unwrap()
{
EngineMessage::Ingest(line) => line.sequence,
_ => panic!("expected Ingest"),
};
assert_eq!(second, 1);
}
}
+19 -2
View File
@@ -38,6 +38,12 @@ struct Args {
/// (e.g. `~/.config/ottyl/settings.json` on Linux).
#[arg(long)]
settings_path: Option<PathBuf>,
/// File to tail for log lines, instead of reading from stdin. Behaves like
/// `tail -f`: reads existing content then keeps following appended lines,
/// and keeps tailing if the file is deleted/recreated at the same path.
#[arg(long)]
file: Option<PathBuf>,
}
#[tokio::main]
@@ -86,7 +92,14 @@ async fn main() {
}
}
match args.file.clone() {
Some(path) => {
tokio::spawn(ingest::run_file(engine_tx.clone(), path));
}
None => {
tokio::spawn(ingest::run(engine_tx.clone()));
}
}
let app = api::router(engine_tx, &args.static_dir, settings, settings_path);
let listener = TcpListener::bind(("0.0.0.0", args.port))
@@ -97,9 +110,13 @@ async fn main() {
.expect("failed to read back bound address")
.port();
let source = match &args.file {
Some(path) => format!("tailing {}", path.display()),
None => "reading logs from stdin".to_string(),
};
eprintln!(
"ottyl serving on http://localhost:{} (reading logs from stdin)",
bound_port
"ottyl serving on http://localhost:{} ({})",
bound_port, source
);
axum::serve(listener, app)