core: review fixes, init builds the agent, console without a terminal
Activation path (review T6R): a broken plugin or a stray file in /plugins blocks no activation; candidates replace their own entries; discovery has a 10 s deadline and its process is stopped after the answer; an unchanged hash keeps plugin.prev.wasm; activations run in their own task; the watcher is not recursive; no duplicate services; rpc.retired waits for live processes of its version; reads and hashes on blocking threads. Guest I/O and per-call paths: a stdin write error is EOF, stdout and stderr are drained before the status; stderr is lossy UTF-8 and goes to the journal from its own reader; compiled components are freed; shutdown starts no new processes; lock queues drop stopping processes; compile before the admission gate; CommandPre per component; core.unlock in order; null errors are successes; the journal thread survives SQLite errors. sausage init builds the agent with cargo.build; sausage without a command creates ./world. The console reads plain lines without a terminal. PROTOCOL.md and DECISIONS.md describe the decided rules. The flake tests the whole workspace and checks the packaged cargo.build.
This commit is contained in:
+87
-82
@@ -1,4 +1,4 @@
|
||||
//! A dedicated SQLite writer batches entries and serves ordered journal queries.
|
||||
//! A dedicated SQLite writer batches entries and serves ordered queries without propagating disk failures into the host.
|
||||
use rusqlite::{Connection, params};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
@@ -7,7 +7,6 @@ use std::{
|
||||
sync::{Mutex, mpsc},
|
||||
};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
struct Entry {
|
||||
id: String,
|
||||
pid: String,
|
||||
@@ -19,31 +18,27 @@ enum Command {
|
||||
Write(Entry),
|
||||
Query(Value, oneshot::Sender<Value>),
|
||||
}
|
||||
|
||||
pub(crate) struct Journal {
|
||||
sender: mpsc::Sender<Command>,
|
||||
warned: Mutex<HashSet<String>>,
|
||||
}
|
||||
impl Journal {
|
||||
/// Open SQLite on its owning thread and wait for its schema to become ready.
|
||||
pub(crate) async fn open(path: PathBuf) -> anyhow::Result<Self> {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let (ready, initialized) = oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
let opened = open_database(path);
|
||||
match opened {
|
||||
Ok(mut db) => {
|
||||
let _ = ready.send(Ok(()));
|
||||
while let Ok(first) = receiver.recv() {
|
||||
let mut batch = vec![first];
|
||||
batch.extend(receiver.try_iter().take(255));
|
||||
write_batch(&mut db, batch);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = ready.send(Err(error));
|
||||
std::thread::spawn(move || match open_database(path) {
|
||||
Ok(mut db) => {
|
||||
let _ = ready.send(Ok(()));
|
||||
while let Ok(first) = receiver.recv() {
|
||||
let mut batch = vec![first];
|
||||
batch.extend(receiver.try_iter().take(255));
|
||||
write_batch(&mut db, batch);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("journal open failed: {error}");
|
||||
let _ = ready.send(Err(error));
|
||||
}
|
||||
});
|
||||
initialized.await??;
|
||||
Ok(Self {
|
||||
@@ -51,7 +46,6 @@ impl Journal {
|
||||
warned: Mutex::new(HashSet::new()),
|
||||
})
|
||||
}
|
||||
/// Queue an entry without file operations or SQLite work on the caller's thread.
|
||||
pub(crate) fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) {
|
||||
let entry = Entry {
|
||||
id: uuid::Uuid::now_v7().to_string(),
|
||||
@@ -60,99 +54,110 @@ impl Journal {
|
||||
stream: stream.into(),
|
||||
line: line.into(),
|
||||
};
|
||||
self.sender
|
||||
.send(Command::Write(entry))
|
||||
.expect("journal writer stopped");
|
||||
if self.sender.send(Command::Write(entry)).is_err() {
|
||||
eprintln!("journal writer stopped");
|
||||
}
|
||||
}
|
||||
/// Queue one warning for the first non-blank discarded line of a process.
|
||||
pub(crate) fn warning(&self, pid: &str, plugin: &str) {
|
||||
if self.warned.lock().unwrap().insert(pid.into()) {
|
||||
self.write(pid, plugin, "core", "warning: discarded stdout");
|
||||
}
|
||||
}
|
||||
/// Queue a query after all entries that the caller already submitted.
|
||||
pub(crate) fn forget(&self, pid: &str) {
|
||||
self.warned.lock().unwrap().remove(pid);
|
||||
}
|
||||
pub(crate) fn query_later(&self, filters: Value) -> oneshot::Receiver<Value> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.sender
|
||||
.send(Command::Query(filters, tx))
|
||||
.expect("journal writer stopped");
|
||||
if self.sender.send(Command::Query(filters, tx)).is_err() {
|
||||
eprintln!("journal writer stopped");
|
||||
}
|
||||
rx
|
||||
}
|
||||
/// Wait asynchronously for the journal thread to execute a query.
|
||||
pub(crate) async fn query(&self, filters: Value) -> Value {
|
||||
self.query_later(filters)
|
||||
.await
|
||||
.expect("journal writer stopped")
|
||||
.unwrap_or_else(|_| json!([]))
|
||||
}
|
||||
}
|
||||
fn open_database(path: PathBuf) -> anyhow::Result<Connection> {
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let db = Connection::open(path)?;
|
||||
db.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
db.execute_batch("PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
CREATE TABLE IF NOT EXISTS journal (
|
||||
id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT);
|
||||
CREATE INDEX IF NOT EXISTS journal_pid ON journal(pid);
|
||||
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);",
|
||||
)?;
|
||||
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);")?;
|
||||
Ok(db)
|
||||
}
|
||||
/// Commit queued writes together before answering the batch's queries.
|
||||
fn write_batch(db: &mut Connection, batch: Vec<Command>) {
|
||||
let transaction = db.transaction().expect("journal transaction");
|
||||
for command in &batch {
|
||||
if let Command::Write(entry) = command {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO journal (id,time,pid,plugin,stream,line)
|
||||
VALUES (?1,strftime('%Y-%m-%dT%H:%M:%fZ','now'),?2,?3,?4,?5)",
|
||||
params![entry.id, entry.pid, entry.plugin, entry.stream, entry.line],
|
||||
)
|
||||
.expect("write journal");
|
||||
let write = (|| -> rusqlite::Result<()> {
|
||||
let transaction = db.transaction()?;
|
||||
for command in &batch {
|
||||
if let Command::Write(e) = command {
|
||||
transaction.execute("INSERT INTO journal (id,time,pid,plugin,stream,line) VALUES (?1,strftime('%Y-%m-%dT%H:%M:%fZ','now'),?2,?3,?4,?5)",params![e.id,e.pid,e.plugin,e.stream,e.line])?;
|
||||
}
|
||||
}
|
||||
transaction.commit()
|
||||
})();
|
||||
if let Err(error) = write {
|
||||
eprintln!("journal write failed: {error}");
|
||||
}
|
||||
transaction.commit().expect("commit journal");
|
||||
for command in batch {
|
||||
if let Command::Query(filters, response) = command {
|
||||
let _ = response.send(query_database(db, &filters));
|
||||
let value = query_database(db, &filters).unwrap_or_else(|error| {
|
||||
eprintln!("journal query failed: {error}");
|
||||
json!([])
|
||||
});
|
||||
let _ = response.send(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn query_database(db: &Connection, filters: &Value) -> Value {
|
||||
let mut statement = db
|
||||
.prepare(
|
||||
"SELECT id,time,pid,plugin,stream,line FROM journal
|
||||
WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2)
|
||||
AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4)
|
||||
AND (?5 IS NULL OR instr(line,?5)>0)
|
||||
ORDER BY id DESC LIMIT ?6",
|
||||
)
|
||||
.expect("prepare journal query");
|
||||
let rows = statement
|
||||
.query_map(
|
||||
params![
|
||||
filters["plugin"].as_str(),
|
||||
filters["pid"].as_str(),
|
||||
filters["since"].as_str(),
|
||||
filters["until"].as_str(),
|
||||
filters["grep"].as_str(),
|
||||
filters["limit"].as_i64().unwrap_or(100)
|
||||
],
|
||||
|row| {
|
||||
Ok(json!({
|
||||
"id":row.get::<_,String>(0)?,
|
||||
"time":row.get::<_,String>(1)?,
|
||||
"pid":row.get::<_,String>(2)?,
|
||||
"plugin":row.get::<_,String>(3)?,
|
||||
"stream":row.get::<_,String>(4)?,
|
||||
"line":row.get::<_,String>(5)?,
|
||||
}))
|
||||
},
|
||||
)
|
||||
.expect("query journal");
|
||||
let mut entries: Vec<_> = rows.map(Result::unwrap).collect();
|
||||
fn query_database(db: &Connection, filters: &Value) -> rusqlite::Result<Value> {
|
||||
let mut statement = db.prepare(
|
||||
"SELECT id,time,pid,plugin,stream,line FROM journal
|
||||
WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2)
|
||||
AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4)
|
||||
AND (?5 IS NULL OR instr(line,?5)>0) ORDER BY id DESC LIMIT ?6",
|
||||
)?;
|
||||
let rows=statement.query_map(params![filters["plugin"].as_str(),filters["pid"].as_str(),filters["since"].as_str(),filters["until"].as_str(),filters["grep"].as_str(),filters["limit"].as_i64().unwrap_or(100)],|row| Ok(json!({
|
||||
"id":row.get::<_,String>(0)?,"time":row.get::<_,String>(1)?,"pid":row.get::<_,String>(2)?,"plugin":row.get::<_,String>(3)?,"stream":row.get::<_,String>(4)?,"line":row.get::<_,String>(5)?,
|
||||
})))?;
|
||||
let mut entries: Vec<_> = rows.collect::<rusqlite::Result<_>>()?;
|
||||
entries.reverse();
|
||||
json!(entries)
|
||||
Ok(json!(entries))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// PROTOCOL 7: completed processes do not leave warning identities in memory.
|
||||
#[tokio::test]
|
||||
async fn completion_forgets_warning_identity() {
|
||||
let folder = std::env::temp_dir().join(format!("sausage-warning-{}", uuid::Uuid::now_v7()));
|
||||
crate::world::World::create(&folder).unwrap();
|
||||
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
|
||||
.await
|
||||
.unwrap();
|
||||
host.register_native(
|
||||
"warn",
|
||||
std::sync::Arc::new(|ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.host.output(&ctx.pid, "not JSON".into());
|
||||
Ok(json!(ctx.pid))
|
||||
})
|
||||
}),
|
||||
);
|
||||
let pid = host.call("warn.once", json!({})).await.unwrap();
|
||||
host.call("core.stop", json!({"pid":pid})).await.unwrap();
|
||||
let retained = host
|
||||
.0
|
||||
.journal
|
||||
.warned
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(pid.as_str().unwrap());
|
||||
host.stop().await;
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
assert!(!retained);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user