diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a8cf5..7872c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.3.0] - 2026-09-18 -crates.io release matching GitHub milestone **v0.3.0 — Spikenaut SNN runtime**. -This crate was last published as **0.1.2** (April 2026, `neuromod` 0.4.0). There -is no 0.2.0 crates.io line; that GitHub milestone was stub-quality work. +Pending crates.io publication for GitHub milestone **v0.3.0 — Spikenaut SNN runtime**. +In-tree package version is **0.3.0**; `cargo publish` has not run. Last published +crate remains **0.1.2** (April 2026, `neuromod` 0.4.0). There is no 0.2.0 +crates.io line; that GitHub milestone was stub-quality work. ### Added @@ -23,6 +24,10 @@ is no 0.2.0 crates.io line; that GitHub milestone was stub-quality work. - Smoke coverage that a dropped/reconstructed Thalamic producer keeps hardware-safety state process-local while Brainstem still ticks a loaded checkpoint (`thalamic_restart_keeps_hardware_safety_out_of_brainstem`). +- OS-process Thalamic restart smoke: a child process starts with a clean + safety flag; the parent's thermal fault is absent from the published + `IpcMessage` JSON and from Brainstem `HealthSnapshot` + (`thalamic_os_process_restart_does_not_leak_safety`). - Distinct liveness, readiness, recoverable degradation, and sticky fatal health (`src/health/`) with a fake-clock state-machine test for every transition and recovery path. Optional `control_bind` listener serves `/livez`, `/readyz`, @@ -111,10 +116,12 @@ is no 0.2.0 crates.io line; that GitHub milestone was stub-quality work. - Binary now logs the active backend mode (`🔌 stub` / `📡 ZMQ corpus-ipc`). - `decode_inputs` now accepts `&IngressPacket` (with explicit `None` modulator fallback). - All direct `corpus_ipc` / `zmq` usage is now feature-gated (except the compatibility `CORPUS_IPC_READOUT_ENV` const). -- Package version **0.3.0** (`Cargo.toml` / `Cargo.lock`). Install and - dependency snippets use `brainstem-daemon = "0.3.0"`. This PR does not - create a Git tag or GitHub Release; `cargo publish` remains a maintainer - step after merge. +- Package version **0.3.0** (`Cargo.toml` / `Cargo.lock`). README install and + dependency snippets are **post-publication** (`brainstem-daemon = "0.3.0"` + once the crate is on crates.io). Default vs `corpus-ipc` examples are + separate tables so they cannot be pasted as a duplicate TOML key. This + changelog section is pending registry publication; `cargo publish`, a Git + tag, and a GitHub Release remain maintainer steps. ### Removed diff --git a/README.md b/README.md index 25540f0..f4dfb5d 100644 --- a/README.md +++ b/README.md @@ -57,19 +57,34 @@ Thalamic can be stopped and started again without Brainstem taking over hardware ## Install -From crates.io (binary): +**0.3.0 is prepared in-tree but is not on crates.io yet.** `cargo publish` has +not run; the last published crate is **0.1.2**. Until publication, +`brainstem-daemon = "0.3.0"` does not resolve from the registry (use a git or +path dependency for development). After 0.3.0 is published: + +From crates.io (binary) — pick **one**: ```bash cargo install brainstem-daemon +``` + +```bash # Optional ZeroMQ / corpus-ipc backend (needs a C/C++ toolchain; system libzmq is optional): cargo install brainstem-daemon --features corpus-ipc ``` -As a library dependency (crates.io, not a git pin): +As a library dependency (crates.io, not a git pin) — pick **one** table; do not +paste both keys into the same `Cargo.toml`: + +Default stub backend (no ZeroMQ): ```toml brainstem-daemon = "0.3.0" -# Optional ZeroMQ backend: +``` + +Optional ZeroMQ / `corpus-ipc` backend: + +```toml brainstem-daemon = { version = "0.3.0", features = ["corpus-ipc"] } ``` @@ -255,7 +270,7 @@ Live restore copies LIF weights, membrane, `last_spike`, `decay_rate`, and `thre CPU-only integration coverage (no GPU) lives in `tests/thalamic_brainstem_smoke.rs` and is gated on `--features corpus-ipc` so default stub tests never need `libzmq`. -The Thalamic fixture (`tests/fixtures/thalamic_producer.rs`) produces `IpcMessage::Stimuli(StimulusBatch)` from simulated telemetry. It does not import `neuromod` or own a `SpikingNetwork`. Brainstem restores a Distill sidecar JSON checkpoint before ticking, preserves `valid_mask` and `session_id` across the wire, and rejects incompatible schema/JSON loudly. A separate assertion keeps the fixture's safety flag healthy when Brainstem/transport is absent, and a later publish cannot clobber a thermal fault. Dropping the producer and constructing a new one (Thalamic restart) resets only that process-local safety flag; Brainstem health stays liveness/readiness/checkpoint and never inherits thermal/power duty. +The Thalamic fixture (`tests/fixtures/thalamic_producer.rs`) produces `IpcMessage::Stimuli(StimulusBatch)` from simulated telemetry. It does not import `neuromod` or own a `SpikingNetwork`. Brainstem restores a Distill sidecar JSON checkpoint before ticking, preserves `valid_mask` and `session_id` across the wire, and rejects incompatible schema/JSON loudly. A separate assertion keeps the fixture's safety flag healthy when Brainstem/transport is absent, and a later publish cannot clobber a thermal fault. Dropping the producer and constructing a new one (in-process restart) resets only that process-local safety flag. A child OS process (`thalamic_os_process_restart_does_not_leak_safety`) starts healthy, publishes a wire frame with no thermal/safety keys, and Brainstem `HealthSnapshot` still has no thermal/power fields. ```bash CC=gcc CXX=g++ cargo test --locked --features corpus-ipc --test thalamic_brainstem_smoke diff --git a/tests/thalamic_brainstem_smoke.rs b/tests/thalamic_brainstem_smoke.rs index 9c2a458..65ec185 100644 --- a/tests/thalamic_brainstem_smoke.rs +++ b/tests/thalamic_brainstem_smoke.rs @@ -10,12 +10,15 @@ mod thalamic; use std::collections::VecDeque; use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::Duration; use anyhow::Result; use brainstem_daemon::daemon::{BrainstemDaemon, DaemonConfig, RuntimeMode}; use brainstem_daemon::ingress::{IngressConfig, IngressPolicy, accept_ipc_json}; -use brainstem_daemon::{BackendPair, CollectingSpikeSink, IngressPacket, StimulusSource}; +use brainstem_daemon::{ + BackendPair, CollectingSpikeSink, HealthSnapshot, IngressPacket, StimulusSource, +}; use corpus_ipc::{IpcMessage, StimulusBatch}; use thalamic::{StimulusTransport, ThalamicProducer}; @@ -363,3 +366,122 @@ fn thalamic_restart_keeps_hardware_safety_out_of_brainstem() { restarted.safety_tick(false); assert!(!restarted.safety_healthy); } + +const OS_RESTART_CHILD_ENV: &str = "BRAINSTEM_THALAMIC_OS_RESTART_CHILD"; +const OS_RESTART_FRAME_ENV: &str = "BRAINSTEM_THALAMIC_OS_RESTART_FRAME"; +/// Parent-only token so an inherited empty/`1` env var cannot enter child mode. +const OS_RESTART_CHILD_TOKEN: &str = "brainstem-thalamic-os-restart-v1"; + +/// True if `key` appears anywhere in a JSON object tree (map keys only). +fn json_has_key(value: &serde_json::Value, key: &str) -> bool { + match value { + serde_json::Value::Object(map) => { + map.contains_key(key) || map.values().any(|v| json_has_key(v, key)) + } + serde_json::Value::Array(items) => items.iter().any(|v| json_has_key(v, key)), + _ => false, + } +} + +fn assert_no_hardware_safety_keys(value: &serde_json::Value, where_: &str) { + for key in [ + "safety_healthy", + "thermal", + "nvml", + "gpu_power", + "power_limit", + ] { + assert!( + !json_has_key(value, key), + "{where_} must not carry hardware-safety key `{key}`: {value}" + ); + } +} + +fn os_restart_child_frame_path() -> Option { + match std::env::var(OS_RESTART_CHILD_ENV) { + Ok(token) if token == OS_RESTART_CHILD_TOKEN => { + std::env::var_os(OS_RESTART_FRAME_ENV).map(PathBuf::from) + } + _ => None, + } +} + +fn write_os_restart_child_frame(frame_path: &Path) { + let child = ThalamicProducer::new(); + assert!( + child.safety_healthy, + "new OS process must not inherit the parent's thermal fault" + ); + let bytes = child + .encode_frame(&child.simulate_telemetry(11, CHANNELS)) + .expect("child encode"); + std::fs::write(frame_path, bytes).expect("write child frame"); +} + +fn spawn_os_restart_child(frame_path: &Path) -> std::process::Output { + let argv0 = std::env::args_os().next().expect("argv0"); + Command::new(argv0) + .env(OS_RESTART_CHILD_ENV, OS_RESTART_CHILD_TOKEN) + .env(OS_RESTART_FRAME_ENV, frame_path) + .args([ + "thalamic_os_process_restart_does_not_leak_safety", + "--exact", + ]) + .output() + .expect("spawn Thalamic child process") +} + +fn tick_brainstem_on_child_frame(sidecar: PathBuf, bytes: &[u8]) -> brainstem_daemon::RuntimeStats { + // Child startup + CI scheduling can exceed 1s; this test is about safety + // isolation, not ingress freshness. + let policy = IngressPolicy::new(CHANNELS, Some(Duration::from_secs(60))); + let packet = accept_ipc_json(bytes, &policy, now_ns()).unwrap(); + let source = QueuedStimulusSource { + packets: VecDeque::from([Ok(packet)]), + }; + let pair = BackendPair { + source: Box::new(source), + sink: Box::new(CollectingSpikeSink::new()), + }; + let daemon = BrainstemDaemon::try_with_backend(smoke_config(sidecar), pair).unwrap(); + let snap: HealthSnapshot = daemon.health().snapshot(); + let snap_json = serde_json::to_value(&snap).expect("snapshot json"); + assert_no_hardware_safety_keys(&snap_json, "Brainstem HealthSnapshot"); + daemon.run_for_ticks(1).unwrap() +} + +#[test] +fn thalamic_os_process_restart_does_not_leak_safety() { + if let Some(frame_path) = os_restart_child_frame_path() { + write_os_restart_child_frame(&frame_path); + return; + } + + let dir = TempDir::new("brainstem-smoke-os-restart"); + let sidecar = write_smoke_sidecar(dir.path()); + let frame_path = dir.path().join("child_frame.json"); + + let mut parent = ThalamicProducer::new(); + parent.safety_tick(false); + assert!(!parent.safety_healthy); + + let output = spawn_os_restart_child(&frame_path); + assert!( + output.status.success(), + "child failed status={:?} stdout={} stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!parent.safety_healthy); + + let bytes = std::fs::read(&frame_path).expect("child wrote a frame"); + let wire: serde_json::Value = serde_json::from_slice(&bytes).expect("child JSON"); + assert_no_hardware_safety_keys(&wire, "child IpcMessage"); + + let stats = tick_brainstem_on_child_frame(sidecar, &bytes); + assert_eq!(stats.ticks, 1); + assert_eq!(stats.accepted_batches, 1); + assert!(stats.loaded_checkpoint.is_some()); +}