···1717 Other(String),
1818}
19192020+impl Error {
2121+ /// True if this error indicates the database is in an unrecoverable state.
2222+ ///
2323+ /// `fjall::Error::Poisoned` is fjall's signal that a prior flush/commit
2424+ /// failed and subsequent writes can't be trusted — fjall's own docs say
2525+ /// to crash the application. `Unrecoverable` is a similar terminal state.
2626+ /// Callers should force-exit the process rather than attempt graceful
2727+ /// shutdown, since the blocking thread pool may be stuck on the same
2828+ /// underlying failure.
2929+ pub fn is_db_fatal(&self) -> bool {
3030+ matches!(
3131+ self,
3232+ Error::Storage(StorageError::Fjall(
3333+ fjall::Error::Poisoned | fjall::Error::Unrecoverable
3434+ ))
3535+ )
3636+ }
3737+}
3838+2039pub type Result<T> = std::result::Result<T, Error>;
+40-6
src/main.rs
···121121 max_deep_crawl_workers: usize,
122122}
123123124124-#[tokio::main]
125125-async fn main() -> Result<()> {
124124+fn main() {
126125 rustls::crypto::aws_lc_rs::default_provider()
127126 .install_default()
128127 .expect("failed to install rustls crypto provider");
···131130 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
132131 .init();
133132133133+ let rt = tokio::runtime::Builder::new_multi_thread()
134134+ .enable_all()
135135+ .build()
136136+ .expect("failed to build tokio runtime");
137137+138138+ let result = rt.block_on(run());
139139+140140+ // Force-shutdown the runtime after a bounded wait. Without this, a
141141+ // `spawn_blocking` task genuinely stuck in fjall (e.g. after Poisoned)
142142+ // holds a blocking-pool thread, and since those threads are non-daemon
143143+ // they'd prevent process exit. `shutdown_timeout` detaches any remaining
144144+ // tasks after the deadline; the explicit `process::exit` below then
145145+ // guarantees we don't wait for detached blocking threads either.
146146+ rt.shutdown_timeout(Duration::from_secs(10));
147147+148148+ match result {
149149+ Ok(()) => std::process::exit(0),
150150+ Err(e) => {
151151+ eprintln!("fatal: {e}");
152152+ std::process::exit(1);
153153+ }
154154+ }
155155+}
156156+157157+async fn run() -> Result<()> {
134158 let args = Args::parse();
135159136160 let subscribe_host = args
···377401378402/// Flatten a task join result into an optional error.
379403/// Panics (JoinError) are treated as errors.
404404+///
405405+/// If the error indicates an unrecoverable database state
406406+/// ([`Error::is_db_fatal`]), this immediately force-exits the process rather
407407+/// than returning. Graceful shutdown isn't safe in that state because other
408408+/// tasks may be stuck in blocking fjall calls that will never return.
380409fn into_error(r: std::result::Result<Result<()>, tokio::task::JoinError>) -> Option<Error> {
381381- match r {
382382- Ok(Ok(())) => None,
383383- Ok(Err(e)) => Some(e),
384384- Err(e) => Some(Error::TaskPanic(e)),
410410+ let err = match r {
411411+ Ok(Ok(())) => return None,
412412+ Ok(Err(e)) => e,
413413+ Err(e) => Error::TaskPanic(e),
414414+ };
415415+ if err.is_db_fatal() {
416416+ eprintln!("FATAL: database poisoned, force-exiting: {err}");
417417+ std::process::exit(2);
385418 }
419419+ Some(err)
386420}
387421388422fn install_metrics(addr: SocketAddr) -> Result<()> {