diff --git a/aimdb-sync/CHANGELOG.md b/aimdb-sync/CHANGELOG.md index c35eebd7..d3b285ae 100644 --- a/aimdb-sync/CHANGELOG.md +++ b/aimdb-sync/CHANGELOG.md @@ -86,10 +86,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 publish path is one relaxed atomic load, and a program that never attaches never installs a handler. A database the child attaches *itself* after forking is unaffected — the guard is a generation counter, not a poison flag. - The detection is entirely internal: a facade built on this crate will have the - same problem for the same reason, but none exists yet, so exposing the - stamp-and-compare pair would commit the crate in semver to a model chosen - against no real caller. + The detection itself stays internal: a facade asks `SyncProducer::check()` + rather than the generation counter, so the semver commitment is to the + question and not to the mechanism. - **A panic-freedom contract on the blocking surface.** The crate is compiled under `deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)` outside its own tests, so "a panic here is a bug, not an error channel" is checked @@ -100,6 +99,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 process dying. Documented with its two limits: `block_on` still panics if called from inside a Tokio runtime, and the guarantee stops at this crate's edge. +- **`SyncProducer::check()`.** Answers "can a publish through this producer + still reach the database?" without publishing — `Ok(())`, or + `RuntimeShutdown` / `ForkedChild`. It is the check `set()` already performs, + exposed rather than duplicated, so the answer cannot drift from what a publish + would find. It takes no lock, which is what makes it usable from a facade + whose own teardown holds the lock its `AimDbHandle` sits behind. - **`SyncError::kind()`.** Returns `aimdb_core::DbErrorKind` rather than a kind of its own, so a caller — an FFI layer above all — has one set of actions for the whole stack instead of one per crate. The `Db` arm delegates, so a buffer diff --git a/aimdb-sync/src/fork.rs b/aimdb-sync/src/fork.rs index 47381f2a..552feced 100644 --- a/aimdb-sync/src/fork.rs +++ b/aimdb-sync/src/fork.rs @@ -76,11 +76,11 @@ fn load() -> Generation { /// The generation to stamp on something being created now. /// -/// Crate-private for now. A layer built *on* this crate has the same problem — -/// an FFI door holds state of its own that a `fork` invalidates — but no such -/// layer exists yet, and exposing this would commit us in semver to the -/// stamp-and-compare model. Widen it when something real needs it, so the -/// shape can be chosen against that caller rather than guessed at. +/// Crate-private, and staying that way. A layer built *on* this crate has the +/// same problem, and is served by +/// [`SyncProducer::check`](crate::SyncProducer::check) — the question it +/// actually has ("can I still publish?"), rather than the stamp-and-compare +/// mechanism, which publishing this pair would pin us to in semver. /// /// **Arms the handler**, because otherwise it hands out a number that cannot /// change. A caller above this crate stamps its own state before any database diff --git a/aimdb-sync/src/producer.rs b/aimdb-sync/src/producer.rs index db96d928..41983f1f 100644 --- a/aimdb-sync/src/producer.rs +++ b/aimdb-sync/src/producer.rs @@ -94,6 +94,21 @@ where self.rt.upgrade().ok_or(SyncError::RuntimeShutdown) } + /// Whether a publish through this producer can still reach the database. + /// + /// `Ok(())` means [`set`](Self::set) reaches the record graph — not that it + /// succeeds; an unregistered key still fails there. Otherwise + /// [`SyncError::RuntimeShutdown`] or [`SyncError::ForkedChild`]. + /// + /// This is the check [`set`](Self::set) performs, not a second one beside + /// it, so the answer cannot drift from what a publish would find. It takes + /// no lock — a [`Weak`] upgrade and one relaxed atomic load — so a facade + /// can ask it while its own teardown holds the handle's lock. + #[inline] + pub fn check(&self) -> SyncResult<()> { + self.runtime()?.check() + } + /// Set the value, blocking until it can be sent. /// /// This call will block the current thread until the value can be sent to the runtime thread. diff --git a/aimdb-sync/tests/fork_safety_test.rs b/aimdb-sync/tests/fork_safety_test.rs index 7c4c0e68..80910480 100644 --- a/aimdb-sync/tests/fork_safety_test.rs +++ b/aimdb-sync/tests/fork_safety_test.rs @@ -56,6 +56,9 @@ fn a_forked_child_is_refused_rather_than_silently_dropped() { inherited.try_set(Reading { value: 2 }), Err(SyncError::ForkedChild) ); + // The same refusal, asked rather than provoked: `check()` has to agree + // with the two publishes above. + let check_agrees = matches!(inherited.check(), Err(SyncError::ForkedChild)); // Leak rather than free. The child is about to `_exit`, which reclaims // everything anyway, and `free` is the unsafe act here: it takes the // allocator lock, which a thread that did not survive the fork may have @@ -64,7 +67,7 @@ fn a_forked_child_is_refused_rather_than_silently_dropped() { // the destructor, so there is nothing to lose by not running it. std::mem::forget(inherited); - refused && refused_try + refused && refused_try && check_agrees }); assert_eq!(code, 0, "the child's publishes should have been refused"); diff --git a/aimdb-sync/tests/integration_test.rs b/aimdb-sync/tests/integration_test.rs index 67ae3b23..c960e8e2 100644 --- a/aimdb-sync/tests/integration_test.rs +++ b/aimdb-sync/tests/integration_test.rs @@ -246,6 +246,23 @@ fn test_runtime_shutdown_error() { assert!(matches!(result, Err(SyncError::RuntimeShutdown))); } +/// `check()` answers the same question `set()` does, without publishing. +#[test] +fn check_reports_what_a_publish_would_find() { + let (handle, producer, _consumer) = setup(BufferCfg::SpmcRing { capacity: 10 }); + + producer.check().expect("usable while attached"); + + handle.detach().expect("Failed to detach"); + + // The same verdict `set()` reaches, arrived at without sending anything. + assert!(matches!(producer.check(), Err(SyncError::RuntimeShutdown))); + assert!(matches!( + producer.set(test_value()), + Err(SyncError::RuntimeShutdown) + )); +} + /// Test error handling - runtime shutdown, non-blocking operations #[test] fn test_runtime_shutdown_error_non_blocking() {