nedb_engine/exit.rs
1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! Durable-mode auto-flush-on-exit.
6//!
7//! A durable [`Db`] stages id-index updates in an in-memory WAL (`write_buf`) and
8//! only makes them durable in `flush_all()` — normally driven by the manifest
9//! ticker or by `Drop`. But `Drop` "only fires once every owning handle is gone",
10//! and a ticker thread (or a server that blocks forever in `serve()`) holds an
11//! `Arc<Db>` for the whole process lifetime — so on a hard exit (`Ctrl+C`,
12//! `SIGTERM` from an orchestrator, `kill`) `Drop` NEVER runs and the writes staged
13//! since the last tick are lost. That is the gap this module closes.
14//!
15//! [`Db::install_exit_flush`] registers a durable database to be flushed when the
16//! process receives `SIGINT` or `SIGTERM`. It flushes exactly once, on the way
17//! out — NOT on every put — so the hot write path stays hot.
18//!
19//! # Design
20//!
21//! - **Opt-in.** A *library* that unilaterally seizes signal handlers would
22//! trample a host application's own shutdown logic, so the core never installs
23//! this implicitly. The napi (`nedb-node`) and pyo3 (`nedb-py`) `open()` paths
24//! call it for durable databases — so Node and Python embedders get
25//! flush-on-exit for free — and Rust applications call it explicitly. `nedbd`
26//! keeps its own tokio graceful-shutdown handler and does not use this.
27//!
28//! - **Async-signal-safe.** The installed handler does exactly one thing: a
29//! non-blocking `write(2)` of the signal number to a self-pipe (`write` is on
30//! the POSIX async-signal-safe list). A dedicated reader thread blocks on the
31//! read end; when woken it runs `flush_all()` on every registered database from
32//! a *normal* thread context (locks, allocation and file I/O are all safe
33//! there), restores the signal's default disposition, and re-raises it so the
34//! process terminates with the correct `128 + signum` status.
35//!
36//! - **Idempotent.** The handler and reader thread are installed once per
37//! process; each subsequent call just registers another database. In-memory
38//! (`:memory:`) databases are ignored — there is nothing to flush.
39//!
40//! - **Weak references.** The registry holds `Weak<Db>`, so a registered database
41//! that is otherwise dropped is not kept alive (no leak) and is pruned on the
42//! next flush pass.
43
44use std::sync::{Arc, Mutex, OnceLock, Weak};
45#[cfg(unix)]
46use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
47
48use crate::db::Db;
49
50/// Registered durable databases to flush on exit. Weak so we never keep a `Db`
51/// alive past its owner.
52static REGISTRY: OnceLock<Mutex<Vec<Weak<Db>>>> = OnceLock::new();
53
54fn registry() -> &'static Mutex<Vec<Weak<Db>>> {
55 REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
56}
57
58/// Flush every still-live registered database. Runs on the reader thread (normal
59/// context) — safe to take locks and do I/O. Prunes dead weak refs as it goes.
60fn flush_all_registered() {
61 if let Some(reg) = REGISTRY.get() {
62 let mut guard = match reg.lock() {
63 Ok(g) => g,
64 Err(poisoned) => poisoned.into_inner(), // a panicked writer must not stop the flush
65 };
66 guard.retain(|w| w.strong_count() > 0);
67 for w in guard.iter() {
68 if let Some(db) = w.upgrade() {
69 db.flush_all();
70 }
71 }
72 }
73}
74
75impl Db {
76 /// Flush this durable database's buffered state on `SIGINT`/`SIGTERM`
77 /// (`Ctrl+C`, `kill`, orchestrator shutdown) — the flush-on-close contract
78 /// extended to hard exits that never run `Drop`.
79 ///
80 /// Call once, after the database is wrapped in an `Arc` (the registry holds a
81 /// `Weak`, so this never keeps the `Db` alive). Idempotent; safe to call from
82 /// multiple databases. A no-op for in-memory (`:memory:`) databases.
83 ///
84 /// ```no_run
85 /// # use std::sync::Arc;
86 /// # use nedb_engine::Db;
87 /// let db = Arc::new(Db::open(std::path::Path::new("/data/mydb"), None)?);
88 /// Db::install_exit_flush(Arc::clone(&db)); // durable across Ctrl+C / SIGTERM
89 /// # Ok::<(), anyhow::Error>(())
90 /// ```
91 pub fn install_exit_flush(self_arc: Arc<Db>) {
92 // Nothing to flush for an in-memory database.
93 if self_arc.root == std::path::PathBuf::from(":memory:") {
94 return;
95 }
96 // Register (dedup by pointer identity so repeated calls don't stack).
97 {
98 let mut reg = match registry().lock() {
99 Ok(g) => g,
100 Err(poisoned) => poisoned.into_inner(),
101 };
102 let already = reg
103 .iter()
104 .any(|w| w.upgrade().is_some_and(|a| Arc::ptr_eq(&a, &self_arc)));
105 if !already {
106 reg.push(Arc::downgrade(&self_arc));
107 }
108 }
109 install_signal_handler_once();
110 }
111}
112
113// ── Unix: self-pipe + sigaction, dependency-free (libc). ─────────────────────
114
115#[cfg(unix)]
116static INSTALLED: AtomicBool = AtomicBool::new(false);
117/// Write end of the self-pipe, read by the signal handler. `-1` until installed.
118#[cfg(unix)]
119static PIPE_WRITE_FD: AtomicI32 = AtomicI32::new(-1);
120
121/// The signal handler. MUST be async-signal-safe: it does nothing but write the
122/// signal number to the self-pipe (non-blocking, so it can never stall the
123/// interrupted thread). All real work happens on the reader thread.
124#[cfg(unix)]
125extern "C" fn handler(sig: libc::c_int) {
126 let fd = PIPE_WRITE_FD.load(Ordering::SeqCst);
127 if fd >= 0 {
128 let byte = [sig as u8];
129 // write(2) is async-signal-safe; ignore the result (EAGAIN if the pipe is
130 // already full means a signal is already pending — which is all we need).
131 unsafe {
132 let _ = libc::write(fd, byte.as_ptr() as *const libc::c_void, 1);
133 }
134 }
135}
136
137#[cfg(unix)]
138fn install_signal_handler_once() {
139 // Exactly one handler + reader thread per process.
140 if INSTALLED.swap(true, Ordering::SeqCst) {
141 return;
142 }
143 unsafe {
144 // Self-pipe. write end non-blocking so the handler never blocks.
145 let mut fds = [0i32; 2];
146 if libc::pipe(fds.as_mut_ptr()) != 0 {
147 INSTALLED.store(false, Ordering::SeqCst); // let a later call retry
148 return;
149 }
150 let (read_fd, write_fd) = (fds[0], fds[1]);
151 let flags = libc::fcntl(write_fd, libc::F_GETFL);
152 if flags != -1 {
153 libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
154 }
155 PIPE_WRITE_FD.store(write_fd, Ordering::SeqCst);
156
157 // Install the handler for SIGINT (Ctrl+C) and SIGTERM (kill / orchestrator).
158 let mut sa: libc::sigaction = std::mem::zeroed();
159 sa.sa_sigaction = handler as extern "C" fn(libc::c_int) as libc::sighandler_t;
160 libc::sigemptyset(&mut sa.sa_mask);
161 sa.sa_flags = libc::SA_RESTART;
162 libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut());
163 libc::sigaction(libc::SIGTERM, &sa, std::ptr::null_mut());
164
165 // Reader thread: block on the pipe, flush, restore default, re-raise.
166 std::thread::Builder::new()
167 .name("nedb-exit-flush".into())
168 .spawn(move || {
169 reader_loop(read_fd);
170 })
171 .ok();
172 }
173}
174
175/// Block on the self-pipe. On the first signal: flush every registered database,
176/// restore that signal's default disposition, and re-raise so the process
177/// terminates with the correct status. Never returns.
178#[cfg(unix)]
179fn reader_loop(read_fd: i32) -> ! {
180 let mut buf = [0u8; 1];
181 loop {
182 let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
183 if n <= 0 {
184 continue; // EINTR / spurious wakeup — keep waiting
185 }
186 let sig = buf[0] as libc::c_int;
187
188 flush_all_registered();
189
190 // Restore default disposition and re-raise: preserves 128+signum exit
191 // status and lets the OS terminate us the way the sender intended.
192 unsafe {
193 let mut sa: libc::sigaction = std::mem::zeroed();
194 sa.sa_sigaction = libc::SIG_DFL;
195 libc::sigemptyset(&mut sa.sa_mask);
196 libc::sigaction(sig, &sa, std::ptr::null_mut());
197 libc::raise(sig);
198 }
199 // If raise somehow returns, fall back to a clean exit after flushing.
200 std::process::exit(128 + sig);
201 }
202}
203
204// ── Non-Unix: no POSIX signals. Durability on exit relies on `Drop` / an
205// explicit `flush()`. Documented, honest no-op. ────────────────────────────
206
207#[cfg(not(unix))]
208fn install_signal_handler_once() {
209 // Windows has no SIGTERM; SIGINT semantics differ. Embedders on non-Unix
210 // should flush explicitly on shutdown (or rely on `Drop` for short-lived
211 // handles). Left as a no-op rather than pretending to install a handler.
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 /// In-memory databases have nothing to flush → must never be registered.
219 /// Checks this specific handle (not the registry length), so it stays correct
220 /// under parallel test runs that share the process-global registry.
221 #[test]
222 fn in_memory_is_not_registered() {
223 let db = Arc::new(Db::in_memory());
224 Db::install_exit_flush(Arc::clone(&db));
225 let found = registry()
226 .lock()
227 .unwrap()
228 .iter()
229 .any(|w| w.upgrade().is_some_and(|a| Arc::ptr_eq(&a, &db)));
230 assert!(!found, ":memory: db must not be registered");
231 }
232
233 /// Registering the same durable db twice adds exactly one registry entry.
234 #[test]
235 fn durable_registration_is_idempotent() {
236 let dir = tempfile::tempdir().unwrap();
237 let db = Arc::new(Db::open(dir.path(), None).unwrap());
238 let count = || {
239 registry()
240 .lock()
241 .unwrap()
242 .iter()
243 .filter(|w| w.upgrade().is_some_and(|a| Arc::ptr_eq(&a, &db)))
244 .count()
245 };
246 Db::install_exit_flush(Arc::clone(&db));
247 assert_eq!(count(), 1, "first install registers exactly once");
248 Db::install_exit_flush(Arc::clone(&db));
249 assert_eq!(count(), 1, "second install does not duplicate");
250 }
251
252 /// The flush path the reader thread runs makes staged writes durable: write,
253 /// flush via the registry, reopen from disk, and the doc is present. Exercises
254 /// everything the signal path does except the raise-and-die tail (which cannot
255 /// be asserted in-process — see tests/exit_flush_signal.rs for the
256 /// child-process end-to-end proof).
257 #[test]
258 fn registered_flush_makes_writes_durable_on_reopen() {
259 let dir = tempfile::tempdir().unwrap();
260 let path = dir.path().to_path_buf();
261 {
262 let db = Arc::new(Db::open(&path, None).unwrap());
263 Db::install_exit_flush(Arc::clone(&db));
264 db.put("k", "v1", serde_json::json!({ "n": 1 }), vec![], None, None)
265 .unwrap();
266 flush_all_registered(); // what the reader thread does on SIGTERM (no raise)
267 }
268 let reopened = Db::open(&path, None).unwrap();
269 let got = reopened.get("k", "v1");
270 assert!(got.is_some(), "write must survive flush + reopen");
271 assert_eq!(got.unwrap().data.get("n").and_then(|v| v.as_i64()), Some(1));
272 }
273}