Closes the gap in #289: nunciod previously installed no signal handler and never called DatabaseEngine::close(), so every real exit was an OS kill racing the WAL checkpoint that close() forces. This adds:
A nunciod::lifecycle module: ShutdownController/ShutdownSignal built on tokio::sync::watch (no new dependency; tokio_util is not a direct workspace dependency). install_signal_handlers awaits Ctrl+C, and on Unix also SIGTERM via tokio::signal::unix, then calls ShutdownController::trigger, which flips the shared signal AND calls EventBus::process_command(CoreCommand::Shutdown) -- finally exercising the existing CoreCommand::Shutdown/CoreEvent::ShuttingDown path in production instead of only from tests.
nunciod::grpc::serve_with_shutdown / serve_on_listener_with_shutdown / serve_on_listener_with_overrides_and_shutdown: new functions that call tonic's serve_with_incoming_shutdown. The existing serve/serve_on_listener/serve_on_listener_with_overrides signatures are untouched -- they now delegate to the shutdown-aware variant with a std::future::pending() shutdown future, so no existing test call site needed to change.
main.rs: one ShutdownSignal clone passed to the real-sync command consumer, the outbox worker, and the update-check loop, each rewritten as tokio::select! between the signal and its unit of work (a select! branch runs its body -- an in-flight DB write -- to completion once chosen, so nothing is aborted mid-write). After the gRPC server drains (bounded by a 10s grace period via tokio::select! against a timer that starts once shutdown fires), background tasks get the same bounded grace period to join, then DatabaseEngine::close() runs unconditionally before the process exits.
Test plan
New crates/nunciod/tests/graceful_shutdown_test.rs (2 tests, deterministic -- shutdown is triggered directly via ShutdownController::trigger(), never a real OS signal):
shutdown_signal_stops_grpc_server_exits_background_worker_and_closes_the_database: proves (a) the gRPC server accepts a call, then stops accepting connections once shutdown fires (bounded join, then a follow-up dial fails); (b) a select!-based worker ticks at least once, then exits its loop on the same signal (bounded join, not aborted); (c) DatabaseEngine::close() runs and a subsequent query fails with a pool-closed error.
controller_trigger_is_observed_by_every_clone_of_the_signal: multiple ShutdownSignal clones (taken before AND after the trigger) all observe it.
Plus 3 new unit tests in crates/nunciod/src/lifecycle.rs covering trigger/event-bus wiring, late-clone observation, and idempotence.
## Summary
Closes the gap in #289: `nunciod` previously installed no signal handler and never called `DatabaseEngine::close()`, so every real exit was an OS kill racing the WAL checkpoint that `close()` forces. This adds:
- A `nunciod::lifecycle` module: `ShutdownController`/`ShutdownSignal` built on `tokio::sync::watch` (no new dependency; `tokio_util` is not a direct workspace dependency). `install_signal_handlers` awaits Ctrl+C, and on Unix also SIGTERM via `tokio::signal::unix`, then calls `ShutdownController::trigger`, which flips the shared signal AND calls `EventBus::process_command(CoreCommand::Shutdown)` -- finally exercising the existing `CoreCommand::Shutdown`/`CoreEvent::ShuttingDown` path in production instead of only from tests.
- `nunciod::grpc::serve_with_shutdown` / `serve_on_listener_with_shutdown` / `serve_on_listener_with_overrides_and_shutdown`: new functions that call tonic's `serve_with_incoming_shutdown`. The existing `serve`/`serve_on_listener`/`serve_on_listener_with_overrides` signatures are untouched -- they now delegate to the shutdown-aware variant with a `std::future::pending()` shutdown future, so no existing test call site needed to change.
- `main.rs`: one `ShutdownSignal` clone passed to the real-sync command consumer, the outbox worker, and the update-check loop, each rewritten as `tokio::select!` between the signal and its unit of work (a `select!` branch runs its body -- an in-flight DB write -- to completion once chosen, so nothing is aborted mid-write). After the gRPC server drains (bounded by a 10s grace period via `tokio::select!` against a timer that starts once shutdown fires), background tasks get the same bounded grace period to join, then `DatabaseEngine::close()` runs unconditionally before the process exits.
## Test plan
New `crates/nunciod/tests/graceful_shutdown_test.rs` (2 tests, deterministic -- shutdown is triggered directly via `ShutdownController::trigger()`, never a real OS signal):
- `shutdown_signal_stops_grpc_server_exits_background_worker_and_closes_the_database`: proves (a) the gRPC server accepts a call, then stops accepting connections once shutdown fires (bounded join, then a follow-up dial fails); (b) a `select!`-based worker ticks at least once, then exits its loop on the same signal (bounded join, not aborted); (c) `DatabaseEngine::close()` runs and a subsequent query fails with a pool-closed error.
- `controller_trigger_is_observed_by_every_clone_of_the_signal`: multiple `ShutdownSignal` clones (taken before AND after the trigger) all observe it.
Plus 3 new unit tests in `crates/nunciod/src/lifecycle.rs` covering trigger/event-bus wiring, late-clone observation, and idempotence.
- [x] `cargo fmt --all -- --check`
- [x] `cargo check-all` (clippy `--all-targets --workspace -D warnings`) -- 0 warnings
- [x] `cargo test-all` -- 0 failed, 0 ignored (nunciod unit: 126 passed; nunciod integration suites: 21 passed across 9 files including the 2 new graceful-shutdown tests; full workspace green)
Refs #289
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Closes the gap in #289:
nunciodpreviously installed no signal handler and never calledDatabaseEngine::close(), so every real exit was an OS kill racing the WAL checkpoint thatclose()forces. This adds:nunciod::lifecyclemodule:ShutdownController/ShutdownSignalbuilt ontokio::sync::watch(no new dependency;tokio_utilis not a direct workspace dependency).install_signal_handlersawaits Ctrl+C, and on Unix also SIGTERM viatokio::signal::unix, then callsShutdownController::trigger, which flips the shared signal AND callsEventBus::process_command(CoreCommand::Shutdown)-- finally exercising the existingCoreCommand::Shutdown/CoreEvent::ShuttingDownpath in production instead of only from tests.nunciod::grpc::serve_with_shutdown/serve_on_listener_with_shutdown/serve_on_listener_with_overrides_and_shutdown: new functions that call tonic'sserve_with_incoming_shutdown. The existingserve/serve_on_listener/serve_on_listener_with_overridessignatures are untouched -- they now delegate to the shutdown-aware variant with astd::future::pending()shutdown future, so no existing test call site needed to change.main.rs: oneShutdownSignalclone passed to the real-sync command consumer, the outbox worker, and the update-check loop, each rewritten astokio::select!between the signal and its unit of work (aselect!branch runs its body -- an in-flight DB write -- to completion once chosen, so nothing is aborted mid-write). After the gRPC server drains (bounded by a 10s grace period viatokio::select!against a timer that starts once shutdown fires), background tasks get the same bounded grace period to join, thenDatabaseEngine::close()runs unconditionally before the process exits.Test plan
New
crates/nunciod/tests/graceful_shutdown_test.rs(2 tests, deterministic -- shutdown is triggered directly viaShutdownController::trigger(), never a real OS signal):shutdown_signal_stops_grpc_server_exits_background_worker_and_closes_the_database: proves (a) the gRPC server accepts a call, then stops accepting connections once shutdown fires (bounded join, then a follow-up dial fails); (b) aselect!-based worker ticks at least once, then exits its loop on the same signal (bounded join, not aborted); (c)DatabaseEngine::close()runs and a subsequent query fails with a pool-closed error.controller_trigger_is_observed_by_every_clone_of_the_signal: multipleShutdownSignalclones (taken before AND after the trigger) all observe it.Plus 3 new unit tests in
crates/nunciod/src/lifecycle.rscovering trigger/event-bus wiring, late-clone observation, and idempotence.cargo fmt --all -- --checkcargo check-all(clippy--all-targets --workspace -D warnings) -- 0 warningscargo test-all-- 0 failed, 0 ignored (nunciod unit: 126 passed; nunciod integration suites: 21 passed across 9 files including the 2 new graceful-shutdown tests; full workspace green)Refs #289