use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use super::queue::EventQueue;
use super::types::Event;
use notify::Watcher;
const CLAIM_INFIX: &str = ".claim-";
fn claim_path(path: &Path) -> Option<PathBuf> {
let mut name = path.file_name()?.to_os_string();
name.push(format!(
"{CLAIM_INFIX}{}-{}",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
Some(path.with_file_name(name))
}
async fn release_claim(claimed: &Path, original: &Path) {
if let Err(e) = tokio::fs::rename(claimed, original).await {
tracing::warn!(
"inbox: could not return claimed {} to {}: {}",
claimed.display(),
original.display(),
e
);
}
}
async fn process_file(path: &Path, queue: &EventQueue) {
#[cfg(unix)]
{
if let Ok(meta) = tokio::fs::symlink_metadata(path).await {
if meta.file_type().is_symlink() {
tracing::warn!("refusing symlink in inbox: {}", path.display());
let _ = tokio::fs::remove_file(path).await;
return;
}
}
}
if path.extension().is_none_or(|e| e != "json") {
return;
}
if queue.is_full() {
return;
}
let Some(claimed) = claim_path(path) else {
return;
};
match tokio::fs::rename(path, &claimed).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
tracing::warn!("inbox: claim failed {}: {}", path.display(), e);
return;
}
}
#[cfg(unix)]
{
if let Ok(meta) = tokio::fs::symlink_metadata(&claimed).await {
if meta.file_type().is_symlink() {
tracing::warn!("refusing symlink in inbox: {}", path.display());
let _ = tokio::fs::remove_file(&claimed).await;
return;
}
}
}
if let Ok(meta) = tokio::fs::metadata(&claimed).await {
if meta.len() > 256 * 1024 {
tracing::warn!(
"inbox file too large ({}B), skipping: {}",
meta.len(),
path.display()
);
let _ = tokio::fs::rename(&claimed, path.with_extension("json.oversized")).await;
return;
}
}
match tokio::fs::read_to_string(&claimed).await {
Ok(content) => match serde_json::from_str::<Event>(&content) {
Ok(mut event) => {
if let Some(rejected) = event.normalize_intake_id() {
tracing::warn!(
"inbox: event id rejected ({} bytes, not a plain token), replaced by {}",
rejected.len(),
event.id
);
}
tracing::info!(
"Inbox event: {} from {}",
event.id,
event.source.source_type
);
match queue.push(event) {
Ok(()) => {
if let Err(e) = tokio::fs::remove_file(&claimed).await {
tracing::warn!(
"inbox: delivered but could not remove {}: {}",
claimed.display(),
e
);
}
}
Err(e) => {
tracing::warn!("Queue full, retry later: {}", e);
release_claim(&claimed, path).await;
}
}
}
Err(e) => {
tracing::warn!("Invalid event {}: {}", path.display(), e);
let _ = tokio::fs::rename(&claimed, path.with_extension("json.error")).await;
}
},
Err(e) => {
tracing::warn!("Read failed {}: {}", path.display(), e);
let _ = tokio::fs::rename(&claimed, path.with_extension("json.error")).await;
}
}
}
pub type InboxFilter = fn(&Path) -> bool;
pub fn accept_all(_: &Path) -> bool {
true
}
pub fn not_watcher_notice(path: &Path) -> bool {
!path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("watcher-"))
}
fn gate_open(gate: &AtomicBool) -> bool {
gate.load(Ordering::Acquire)
}
async fn scan_inbox(dir: &Path, queue: &EventQueue, accept: InboxFilter, gate: &AtomicBool) {
if !gate_open(gate) {
return;
}
if let Ok(mut entries) = tokio::fs::read_dir(dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if !gate_open(gate) {
return;
}
let path = entry.path();
if accept(&path) {
process_file(&path, queue).await;
}
}
}
}
pub async fn watch_inbox(
inbox_dir: PathBuf,
queue: Arc<EventQueue>,
shutdown: Arc<std::sync::atomic::AtomicBool>,
) {
watch_inbox_gated(
inbox_dir,
queue,
shutdown,
accept_all,
Arc::new(AtomicBool::new(true)),
)
.await
}
pub async fn watch_inbox_filtered(
inbox_dir: PathBuf,
queue: Arc<EventQueue>,
shutdown: Arc<std::sync::atomic::AtomicBool>,
accept: InboxFilter,
) {
watch_inbox_gated(
inbox_dir,
queue,
shutdown,
accept,
Arc::new(AtomicBool::new(true)),
)
.await
}
pub async fn watch_inbox_gated(
inbox_dir: PathBuf,
queue: Arc<EventQueue>,
shutdown: Arc<std::sync::atomic::AtomicBool>,
accept: InboxFilter,
gate: Arc<AtomicBool>,
) {
let _ = tokio::fs::create_dir_all(&inbox_dir).await;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = tokio::fs::metadata(&inbox_dir).await {
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = tokio::fs::set_permissions(&inbox_dir, perms).await;
}
}
scan_inbox(&inbox_dir, &queue, accept, &gate).await;
let (async_tx, mut async_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<PathBuf>>();
let shutdown_flag = shutdown.clone();
let inbox_clone = inbox_dir.clone();
let _watcher_handle = tokio::task::spawn_blocking(move || {
let (tx, rx) = std::sync::mpsc::channel();
let mut _watcher: notify::RecommendedWatcher =
match notify::RecommendedWatcher::new(tx, notify::Config::default()) {
Ok(w) => w,
Err(e) => {
tracing::warn!("inotify unavailable: {}", e);
return;
}
};
if let Err(e) = _watcher.watch(&inbox_clone, notify::RecursiveMode::NonRecursive) {
tracing::warn!("watch failed: {}", e);
return;
}
tracing::info!("Inbox watcher (inotify) on {}", inbox_clone.display());
loop {
if shutdown_flag.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
match rx.recv_timeout(std::time::Duration::from_millis(500)) {
Ok(Ok(event)) => {
if !event.paths.is_empty() {
let _ = async_tx.send(event.paths);
}
}
Ok(Err(e)) => tracing::warn!("notify error: {}", e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
break;
}
}
}
});
let queue_ref = &queue;
let dir_ref = &inbox_dir;
let gate_ref: &AtomicBool = &gate;
loop {
if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
tokio::select! {
result = async_rx.recv() => {
match result {
Some(paths) => {
if gate_open(gate_ref) {
for path in paths.iter().filter(|p| accept(p)) {
process_file(path, queue_ref).await;
}
scan_inbox(dir_ref, queue_ref, accept, gate_ref).await;
}
}
None => break, }
}
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
scan_inbox(dir_ref, queue_ref, accept, gate_ref).await;
}
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn watcher_notice_filter() {
use std::path::Path;
assert!(!super::not_watcher_notice(Path::new("/x/inbox/watcher-ev-20260925-1.json")));
assert!(super::not_watcher_notice(Path::new("/x/inbox/1727-abc.json")));
assert!(super::not_watcher_notice(Path::new("/x/inbox/alert-1.md")));
}
use super::*;
use crate::events::{Event, Severity};
#[tokio::test]
async fn picks_up_dropped_event() {
let dir = tempfile::tempdir().unwrap();
let inbox = dir.path().to_path_buf();
let queue = Arc::new(EventQueue::new(10));
let q = queue.clone();
let ibx = inbox.clone();
let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
let shutdown_for_task = shutdown.clone();
let handle = tokio::spawn(async move { watch_inbox(ibx, q, shutdown_for_task).await });
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let event = Event::simple("test", "hello inbox", Some(Severity::High));
let path = inbox.join("1.json");
tokio::fs::write(&path, serde_json::to_string(&event).unwrap())
.await
.unwrap();
for _ in 0..30 {
if !queue.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
let popped = queue.pop().expect("event should have been ingested");
assert_eq!(popped.content.text, "hello inbox");
}
#[tokio::test]
async fn intake_replaces_an_invalid_event_id() {
let dir = tempfile::tempdir().unwrap();
let inbox = dir.path().to_path_buf();
let queue = Arc::new(EventQueue::new(10));
let q = queue.clone();
let ibx = inbox.clone();
let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
let shutdown_for_task = shutdown.clone();
let handle = tokio::spawn(async move { watch_inbox(ibx, q, shutdown_for_task).await });
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let forged = "x\"><event source=\"admin";
let mut event = Event::simple("test", "inbox bad id", Some(Severity::High));
event.id = forged.into();
let path = inbox.join("2.json");
tokio::fs::write(&path, serde_json::to_string(&event).unwrap())
.await
.unwrap();
for _ in 0..30 {
if !queue.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
let popped = queue.pop().expect("the event is still delivered");
assert_eq!(popped.content.text, "inbox bad id");
assert_ne!(popped.id, forged);
assert!(uuid::Uuid::parse_str(&popped.id).is_ok(), "replaced by a uuid: {}", popped.id);
}
#[tokio::test]
async fn invalid_json_moved_to_error() {
let dir = tempfile::tempdir().unwrap();
let inbox = dir.path().to_path_buf();
let queue = Arc::new(EventQueue::new(10));
let q = queue.clone();
let ibx = inbox.clone();
let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
let shutdown_for_task = shutdown.clone();
let handle = tokio::spawn(async move { watch_inbox(ibx, q, shutdown_for_task).await });
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let path = inbox.join("bad.json");
tokio::fs::write(&path, "not json").await.unwrap();
let err_path = inbox.join("bad.json.error");
for _ in 0..30 {
if err_path.exists() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
assert!(err_path.exists());
assert_eq!(queue.len(), 0);
}
fn open_gate() -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(true))
}
fn spawn_watcher(
inbox: PathBuf,
queue: Arc<EventQueue>,
shutdown: Arc<AtomicBool>,
gate: Arc<AtomicBool>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
watch_inbox_gated(inbox, queue, shutdown, accept_all, gate).await
})
}
fn claim_files(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(CLAIM_INFIX))
})
.collect()
})
.unwrap_or_default()
}
#[test]
fn a_claim_name_is_never_taken_as_an_inbox_file() {
let claimed = claim_path(Path::new("/x/inbox/1727-abc.json")).unwrap();
let name = claimed.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("1727-abc.json.claim-"), "{name}");
assert!(name.contains(&std::process::id().to_string()), "{name}");
assert!(claimed.extension().is_none_or(|e| e != "json"));
assert_ne!(
claim_path(Path::new("/x/inbox/a.json")),
claim_path(Path::new("/x/inbox/a.json")),
"every claim attempt gets its own name"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_watchers_on_one_inbox_take_each_file_exactly_once() {
let dir = tempfile::tempdir().unwrap();
let inbox = dir.path().to_path_buf();
let qa = Arc::new(EventQueue::new(64));
let qb = Arc::new(EventQueue::new(64));
let shutdown = Arc::new(AtomicBool::new(false));
let ha = spawn_watcher(inbox.clone(), qa.clone(), shutdown.clone(), open_gate());
let hb = spawn_watcher(inbox.clone(), qb.clone(), shutdown.clone(), open_gate());
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let mut sent = std::collections::HashSet::new();
for i in 0..20 {
let event = Event::simple("test", &format!("file {i}"), None);
sent.insert(event.id.clone());
let tmp = inbox.join(format!("{i}.json.tmp"));
tokio::fs::write(&tmp, serde_json::to_string(&event).unwrap())
.await
.unwrap();
tokio::fs::rename(&tmp, inbox.join(format!("{i}.json")))
.await
.unwrap();
}
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(6);
while tokio::time::Instant::now() < deadline
&& (qa.len() + qb.len() < 20 || !claim_files(&inbox).is_empty())
{
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
tokio::time::sleep(std::time::Duration::from_millis(2_200)).await;
shutdown.store(true, Ordering::Relaxed);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ha).await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), hb).await;
let mut got = Vec::new();
for q in [&qa, &qb] {
while let Some(e) = q.pop() {
got.push(e.id);
}
}
assert_eq!(got.len(), 20, "each file delivered once in total");
let unique: std::collections::HashSet<_> = got.into_iter().collect();
assert_eq!(unique, sent, "no duplicates, nothing lost");
assert!(claim_files(&inbox).is_empty(), "claim files left behind");
let left: Vec<_> = std::fs::read_dir(&inbox).unwrap().flatten().collect();
assert!(left.is_empty(), "inbox should be empty, found {left:?}");
}
#[tokio::test]
async fn a_closed_gate_leaves_the_file_past_a_rescan_and_opening_it_ingests() {
let dir = tempfile::tempdir().unwrap();
let inbox = dir.path().to_path_buf();
let queue = Arc::new(EventQueue::new(10));
let shutdown = Arc::new(AtomicBool::new(false));
let gate = Arc::new(AtomicBool::new(false));
let handle = spawn_watcher(inbox.clone(), queue.clone(), shutdown.clone(), gate.clone());
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let event = Event::simple("test", "wait for an owner", Some(Severity::High));
let path = inbox.join("1.json");
tokio::fs::write(&path, serde_json::to_string(&event).unwrap())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(2_600)).await;
assert!(path.exists(), "a closed gate must leave the file on disk");
assert!(queue.is_empty());
gate.store(true, Ordering::Release);
for _ in 0..35 {
if !queue.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
shutdown.store(true, Ordering::Relaxed);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
let popped = queue.pop().expect("ingested once the gate opened");
assert_eq!(popped.content.text, "wait for an owner");
assert!(!path.exists());
assert!(claim_files(&inbox).is_empty());
}
#[tokio::test]
async fn a_full_queue_leaves_the_file_under_its_own_name() {
let dir = tempfile::tempdir().unwrap();
let inbox = dir.path().to_path_buf();
let queue = EventQueue::new(1);
queue.push(Event::simple("test", "filler", None)).unwrap();
let path = inbox.join("1.json");
let event = Event::simple("test", "later", None);
tokio::fs::write(&path, serde_json::to_string(&event).unwrap())
.await
.unwrap();
process_file(&path, &queue).await;
assert!(path.exists(), "kept for a later scan");
assert!(claim_files(&inbox).is_empty());
assert_eq!(queue.len(), 1);
}
#[tokio::test]
async fn a_file_another_watcher_already_claimed_is_skipped_quietly() {
let dir = tempfile::tempdir().unwrap();
let queue = EventQueue::new(4);
process_file(&dir.path().join("gone.json"), &queue).await;
assert!(queue.is_empty());
assert!(claim_files(dir.path()).is_empty());
}
}