From 3d9a27ef1612c7a5a50053309fa1cf2592c37160 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 21:45:21 +0000 Subject: [PATCH] =?UTF-8?q?osm/lance:=20Phase=20B=20observational=20surfac?= =?UTF-8?q?e=20=E2=80=94=20OsmArtifactManager=20+=20/api/osm/status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive-only serving-state visibility, landed as the "observational parts" the plan scoped for Phase B — the OnceLock-backed slab/books/chains serving path in osm_features.rs is untouched; that swap needs real hydration content and is Phase C's job. New: crates/cockpit-server/src/osm_artifact_manager.rs - OsmArtifactManager: two independently atomic-swappable snapshots — ArcSwap (real today, starts absent()) and ArcSwapOption (deliberately an empty marker; Phase C fills it with the RowSlab/books/chains bundle, rotated as ONE unit — replacing four independent OnceLocks that can each drift to a different version). Falsifier proven: a reader holding an Arc from current()/artifact() before a publish_* call keeps observing the old value after the call returns. - cgroup v2 memory telemetry (parse_cgroup_current/parse_cgroup_max pure, read_cgroup_memory Linux-gated with an honest empty fallback elsewhere, per .claude/rules/cross-platform.md). main.rs: AppState gains osm_manager (constructed absent() at startup, no I/O — verified the listener/`/health` independence the plan asked for was already true, not something to fix); new GET /api/osm/status route + handler, with a JSON projection of Lifecycle/Operation/ImportFailure since osm_lifecycle.rs stays serde-free by design. cockpit-server/Cargo.toml: arc-swap = "1.9" promoted from an existing transitive dependency (via lance-namespace/goosefs) to a direct one — no new dependency tree, confirmed via Cargo.lock's one-line diff. 11 new tests (osm_artifact_manager), 160/160 total in cockpit-server (0 regressions). clippy -p cockpit-server --no-deps --all-targets -D warnings: zero findings in the new code, confirmed by grepping the full run for the new file/function names (no matches) — all reported findings are in files this diff never touches. Plan: claude-notes/plans/2026-08-15-osm-lance-lifecycle.md (Phase B section updated; "phase deltas" memory tracking named as a deferred gap rather than attempted without a real phase transition to measure across). --- Cargo.lock | 1 + .../plans/2026-08-15-osm-lance-lifecycle.md | 49 ++- crates/cockpit-server/Cargo.toml | 5 + crates/cockpit-server/src/main.rs | 89 ++++- .../src/osm_artifact_manager.rs | 315 ++++++++++++++++++ 5 files changed, 446 insertions(+), 13 deletions(-) create mode 100644 crates/cockpit-server/src/osm_artifact_manager.rs diff --git a/Cargo.lock b/Cargo.lock index ba13f620d..8afbc323c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1780,6 +1780,7 @@ name = "cockpit-server" version = "0.1.0" dependencies = [ "aiwar-ingest", + "arc-swap", "arrow", "askama", "async-stream", diff --git a/claude-notes/plans/2026-08-15-osm-lance-lifecycle.md b/claude-notes/plans/2026-08-15-osm-lance-lifecycle.md index 2901c2b92..c5510256d 100644 --- a/claude-notes/plans/2026-08-15-osm-lance-lifecycle.md +++ b/claude-notes/plans/2026-08-15-osm-lance-lifecycle.md @@ -1,7 +1,9 @@ # OSM/Lance lifecycle — three clocks, one lineage per region -**Status:** Phase A implemented, corrected once, tests re-run. Phases B–F not -started (Phase B observational surface not begun this session either). +**Status:** Phase A merged (PR #133). Phase B observational surface landed +(`OsmArtifactManager`, `/api/osm/status`, cgroup memory) — the `OnceLock` +serving path in `osm_features.rs` is untouched; that swap is Phase C. Phases +C–F not started. **Operator directive:** 2026-08-15, this session. **Upstream contracts:** `AdaWorldAPI/lance-graph` PRs **#912** (baseline) and **#913** (the five semantic gates). #911 is historical context and is @@ -294,10 +296,12 @@ these paths. ## Phases (PR-sized) - **A.** Pure lifecycle types, state machine, manifests, storage resolution, - falsifiers. ← *this session* + falsifiers. ← *merged, PR #133* - **B.** `OsmArtifactManager`, fast listener startup, status endpoint, memory - telemetry. ← *observational parts only, if cleanly separable* -- **C.** Versioned local import with origin/import seals and reconciliation. + telemetry. ← *observational surface landed this session; does not replace + the `OnceLock` serving path* +- **C.** Versioned local import with origin/import seals and reconciliation, + AND the `osm_features.rs` OnceLock→`OsmArtifactManager` migration. - **D.** Incremental S3 lineage mirroring, publication pointer, recovery, retention. - **E.** Native Lance column layout + Morton-shard updates; removes the single @@ -371,13 +375,34 @@ actually observe the real system lands it. - [x] **correction pass** (2026-08-15, same session) — see § Correction pass; re-ran the full `osm_lifecycle` test module after -### Phase B (observational only this session — not started) -- [ ] `OsmArtifactManager` with atomically replaceable `Arc` -- [ ] `/api/osm/status` -- [ ] cgroup memory split + phase deltas -- [ ] listener/`/health` independent of hydration -- [ ] a real `MaintenanceProvenance::LanceHistoryVerified` resolver, OR name it - as still deferred to Phase C at the status-endpoint level +### Phase B (observational only — landed this session) +- [x] `OsmArtifactManager` with atomically replaceable `Arc` + (`crates/cockpit-server/src/osm_artifact_manager.rs`) — two independent + swaps: `ArcSwap` (real today) and `ArcSwapOption` + (deliberately an empty marker; Phase C fills it). **Purely additive**: + wired into `AppState` and constructed `absent()` at startup (no I/O), but + does NOT replace the four `OnceLock`s in `osm_features.rs` + (`SLAB_MMAP`/`BOOKS`/`CHAINS`/`SLAB_DIGEST`) — that swap needs real + hydration content to publish and is Phase C's job. Falsifier proven: a + reader holding an `Arc` from `current()`/`artifact()` before a + `publish_*` call keeps observing the old value after the call. +- [x] `/api/osm/status` — new route, reports the manager's lifecycle/artifact + snapshot + cgroup memory as JSON. Reads no map data; independent of the + existing `/api/osm/health` (which still reports the OnceLock state). +- [x] cgroup memory split — `read_cgroup_memory()` (Linux-gated per + `.claude/rules/cross-platform.md`, honest `None` fallback elsewhere), + pure `parse_cgroup_current`/`parse_cgroup_max` helpers. +- [ ] **phase deltas** — deferred, not attempted. Tracking a memory delta + across a real phase transition needs a real transition to measure + across; there isn't one until Phase C's hydration exists. Recorded as + a named gap, not silently dropped. +- [x] listener/`/health` independent of hydration — **already true**, verified + rather than fixed: `health_handler` is static JSON with no OSM + reference, and OSM's OnceLocks are lazy (first-request-triggered), so + nothing on the startup/listener path touches map data either way. +- [ ] a real `MaintenanceProvenance::LanceHistoryVerified` resolver — still + deferred to Phase C, as originally planned; nothing in this session + claims otherwise. ### Deferred, named - [ ] braid epic — **the `braid` CLI is not installed in this container**; the diff --git a/crates/cockpit-server/Cargo.toml b/crates/cockpit-server/Cargo.toml index 046a652d4..3518d106e 100644 --- a/crates/cockpit-server/Cargo.toml +++ b/crates/cockpit-server/Cargo.toml @@ -78,6 +78,11 @@ object_store = { version = "0.13.2", features = ["aws"] } sha2 = "0.10" hex = "0.4" futures = "0.3" +# `osm_artifact_manager`: the atomically-replaceable-Arc serving mechanism +# (Phase B of the OSM/Lance lifecycle plan). Already in the lockfile +# transitively at 1.9 via lance-namespace/goosefs, so this adds no new +# dependency tree — it only promotes an existing version to a direct dep. +arc-swap = "1.9" # `osm_lance`: convert the hydrated `.soa` slab into a Lance dataset on the # same persistent volume (the `soa_to_lance` recipe, see that module's doc). # Both are ALREADY locked transitively at these exact versions via diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index 6743bf17e..841fe120d 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -42,6 +42,7 @@ mod mock_driver; mod osint_classview; mod osm_tiles; mod osm; +mod osm_artifact_manager; mod osm_features; mod osm_lance; mod osm_lifecycle; @@ -119,6 +120,12 @@ struct AppState { tx: broadcast::Sender, /// Shared scene state for /v1/shader/stream + /v1/shader/status. scene_state: shader_stream::SharedSceneState, + /// The OSM/Lance lifecycle serving state (Phase B). Constructed + /// `absent()` at startup — no I/O, so it never delays the listener — + /// and stays that way until Phase C wires real hydration through + /// `publish_lifecycle`/`publish_artifact`. `/api/osm/status` reads it; + /// nothing on the existing OnceLock-backed OSM serving path does yet. + osm_manager: Arc, } // Shader handlers extract `State>` and read `state.scene_state` @@ -240,7 +247,12 @@ async fn main() { let (tx, _rx) = broadcast::channel::(256); let scene_state = shader_stream::new_scene_state(); - let state = Arc::new(AppState { tx, scene_state }); + let osm_manager = Arc::new(osm_artifact_manager::OsmArtifactManager::absent()); + let state = Arc::new(AppState { + tx, + scene_state, + osm_manager, + }); // OpenAI-compatible model API state let openai_state = Arc::new(tokio::sync::Mutex::new(openai::OpenAiState::new())); @@ -316,6 +328,11 @@ async fn main() { // working map on every other signal (200s, full tiles, correct // geometry) while drawing grey and untagged. .route("/api/osm/health", get(osm_features::osm_health_handler)) + // Phase B of the OSM/Lance lifecycle plan: the OsmArtifactManager's + // lifecycle/artifact snapshot + cgroup memory, observational only — + // does not read map data and does not touch the OnceLock-backed + // serving path /api/osm/health reports on. + .route("/api/osm/status", get(osm_status_handler)) .route("/api/osm/locate", get(osm_tiles::osm_locate_handler)) .route("/api/osm/tile/:z/:x/:y", get(osm_tiles::osm_tile_meta_handler)) .route("/api/osm/features/:z/:x/:y", get(osm_features::osm_features_handler)) @@ -1406,6 +1423,76 @@ async fn health_handler() -> Json { })) } +// ── OSM/Lance lifecycle status (Phase B) ─────────────────────────────────────── + +fn import_failure_json(error: &osm_lifecycle::ImportFailure) -> serde_json::Value { + use osm_lifecycle::ImportFailure::{ + IdentityConflict, S3Publication, Source, Validation, Write, + }; + match error { + Source(message) => serde_json::json!({ "kind": "source", "message": message }), + Write(message) => serde_json::json!({ "kind": "write", "message": message }), + Validation(message) => serde_json::json!({ "kind": "validation", "message": message }), + S3Publication(message) => { + serde_json::json!({ "kind": "s3_publication", "message": message }) + } + IdentityConflict => serde_json::json!({ "kind": "identity_conflict" }), + } +} + +fn lifecycle_status_json(lifecycle: &osm_lifecycle::Lifecycle) -> serde_json::Value { + let serving = lifecycle.serving.map(|s| { + serde_json::json!({ + "active": s.active.to_key(), + "previous": s.previous.map(|p| p.to_key()), + }) + }); + let operation = match &lifecycle.operation { + osm_lifecycle::Operation::Idle => serde_json::json!({ "state": "idle" }), + osm_lifecycle::Operation::Importing { + candidate, + generation, + } => serde_json::json!({ + "state": "importing", + "candidate": candidate.to_key(), + "generation": generation.0, + }), + osm_lifecycle::Operation::Failed { + candidate, + generation, + error, + } => serde_json::json!({ + "state": "failed", + "candidate": candidate.to_key(), + "generation": generation.0, + "error": import_failure_json(error), + }), + }; + serde_json::json!({ "serving": serving, "operation": operation }) +} + +/// Phase B observational endpoint: the [`osm_artifact_manager::OsmArtifactManager`]'s +/// current lifecycle/artifact snapshot plus cgroup memory. Deliberately does +/// NOT read any map data (slab/books/chains) — that stays on the existing +/// `/api/osm/health` path, unaffected by this endpoint's presence. +async fn osm_status_handler(State(state): State>) -> Json { + let lifecycle = state.osm_manager.current(); + let mem = osm_artifact_manager::read_cgroup_memory(); + Json(serde_json::json!({ + "lifecycle": lifecycle_status_json(&lifecycle), + "artifact_present": state.osm_manager.artifact().is_some(), + "cgroup_memory": { + "current_bytes": mem.current_bytes, + "max_bytes": mem.max_bytes, + }, + "note": "Phase B observational endpoint. Reports the OsmArtifactManager's \ + lifecycle/artifact snapshot and cgroup memory; nothing publishes \ + real content into it yet — Phase C wires actual hydration through \ + publish_lifecycle/publish_artifact. The existing OnceLock-backed \ + slab/books/chains serving path (see /api/osm/health) is untouched.", + })) +} + #[cfg(test)] mod garmin_scene_tests { use super::{ diff --git a/crates/cockpit-server/src/osm_artifact_manager.rs b/crates/cockpit-server/src/osm_artifact_manager.rs new file mode 100644 index 000000000..6f532a809 --- /dev/null +++ b/crates/cockpit-server/src/osm_artifact_manager.rs @@ -0,0 +1,315 @@ +//! Phase B of the OSM/Lance lifecycle plan +//! (`claude-notes/plans/2026-08-15-osm-lance-lifecycle.md`): the serving +//! mechanism that replaces the env-var/`OnceLock` activation model with an +//! atomically replaceable `Arc`. +//! +//! ## What this module is +//! +//! [`OsmArtifactManager`] holds two independently swappable snapshots: +//! +//! - [`osm_lifecycle::Lifecycle`] — the coordination state Phase A already +//! defined (what's active, what's importing, what failed). Real today: +//! nothing else in this crate constructs one yet, so a manager starts +//! `Lifecycle::absent()` and stays there until Phase C wires real imports +//! through it. +//! - [`ActiveArtifact`] — the actual dataset content (the `RowSlab` / books / +//! chains handles). **Deliberately an empty marker in Phase B.** Phase C +//! replaces the four independent `OnceLock`s in `osm_features.rs` +//! (`SLAB_MMAP`, `BOOKS`, `CHAINS`, `SLAB_DIGEST`) with real content here, +//! rotated as ONE unit through `publish_artifact` — matching the +//! `ServingSet` invariant that active/previous rotate together, not four +//! independent statics that can each be at a different version. +//! +//! Both use `arc_swap` so a reader holds a stable `Arc` for the lifetime of +//! its request: `current()`/`artifact()` clone the `Arc` (cheap, atomic, no +//! lock), and a concurrent `publish_*` call never invalidates that clone — +//! the reader finishes against the version it observed, and the next reader +//! sees the new one. This is the literal falsifier from the plan ("in-flight +//! readers finish against the old Arc; new readers receive the new +//! version"), proven below without any I/O. +//! +//! ## What this module does NOT do (yet) +//! +//! It does not replace the `osm_features.rs` `OnceLock`s — that swap is +//! deferred to Phase C, once there is real hydration logic to populate +//! `ActiveArtifact` with. Wiring the manager into `AppState` and exposing it +//! at `/api/osm/status` (this session's work) is purely additive: the +//! existing OnceLock-backed serving path is untouched and carries zero risk +//! from this change. + +use std::sync::Arc; + +use arc_swap::{ArcSwap, ArcSwapOption}; + +use crate::osm_lifecycle::Lifecycle; + +/// The dataset content Phase C will populate (the `RowSlab` / books / chains +/// bundle, rotated as one unit). Empty in Phase B — nothing constructs a +/// non-`None` value yet; see the module doc for why. +#[derive(Debug)] +pub struct ActiveArtifact { + _private: (), +} + +/// The atomically-replaceable serving state for the OSM/Lance lifecycle. +/// +/// One instance lives in `AppState`, shared behind an `Arc` like every other +/// piece of shared state there. Cloning a *snapshot* (`current()`, +/// `artifact()`) is cheap and lock-free; publishing a new snapshot +/// (`publish_lifecycle`, `publish_artifact`) is a single atomic store that +/// never blocks or invalidates readers already holding an older snapshot. +pub struct OsmArtifactManager { + lifecycle: ArcSwap, + artifact: ArcSwapOption, +} + +impl OsmArtifactManager { + /// Nothing local, nothing in flight, no dataset content. The only state + /// Phase B ever constructs — Phase C's hydration path is what first + /// calls `publish_lifecycle`/`publish_artifact` with something else. + #[must_use] + pub fn absent() -> Self { + Self { + lifecycle: ArcSwap::from_pointee(Lifecycle::absent()), + artifact: ArcSwapOption::empty(), + } + } + + /// The current lifecycle snapshot. Cheap `Arc` clone, never blocks. + #[must_use] + pub fn current(&self) -> Arc { + self.lifecycle.load_full() + } + + /// The current dataset content, if any has been published. + #[must_use] + pub fn artifact(&self) -> Option> { + self.artifact.load_full() + } + + /// Atomically replace the lifecycle snapshot. A caller holding an + /// `Arc` from a prior `current()` keeps observing the old + /// value — this call never mutates through an existing `Arc`. + pub fn publish_lifecycle(&self, next: Lifecycle) { + self.lifecycle.store(Arc::new(next)); + } + + /// Atomically replace the dataset content. Same non-disturbance + /// guarantee as `publish_lifecycle`, and `None` is a valid publication + /// (Phase C uses it to retract content whose lifecycle moved to + /// `absent`/`CandidateFailed` with no prior active version). + pub fn publish_artifact(&self, next: Option>) { + self.artifact.store(next); + } +} + +impl Default for OsmArtifactManager { + fn default() -> Self { + Self::absent() + } +} + +// ── cgroup memory telemetry ───────────────────────────────────────────── + +/// A snapshot of cgroup v2 memory accounting, if the platform/environment +/// exposes it. `None` fields are honest gaps (unreadable file, non-Linux, +/// unlimited `max`), never a fabricated zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CgroupMemory { + /// `memory.current` — resident cgroup memory, in bytes. + pub current_bytes: Option, + /// `memory.max` — the cgroup's memory ceiling, in bytes. `None` means + /// either the file was unreadable OR the cgroup is genuinely unlimited + /// (`memory.max` contains the literal string `max`) — the two are + /// distinguished by `current_bytes` being present: an unlimited cgroup + /// still reports a real `current`, an unreadable one reports neither. + pub max_bytes: Option, +} + +/// Parse `memory.current`'s content: a bare decimal integer. +#[must_use] +pub fn parse_cgroup_current(raw: &str) -> Option { + raw.trim().parse().ok() +} + +/// Parse `memory.max`'s content: a bare decimal integer, or the literal +/// string `max` meaning "no limit" (represented here as `None`, same as an +/// unreadable file — see [`CgroupMemory::max_bytes`] for how to tell them +/// apart). +#[must_use] +pub fn parse_cgroup_max(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed == "max" { + return None; + } + trimmed.parse().ok() +} + +#[cfg(target_os = "linux")] +#[must_use] +pub fn read_cgroup_memory() -> CgroupMemory { + let current = std::fs::read_to_string("/sys/fs/cgroup/memory.current") + .ok() + .and_then(|s| parse_cgroup_current(&s)); + let max = std::fs::read_to_string("/sys/fs/cgroup/memory.max") + .ok() + .and_then(|s| parse_cgroup_max(&s)); + CgroupMemory { + current_bytes: current, + max_bytes: max, + } +} + +/// Non-Linux platforms have no cgroup v2 accounting to read. An honest +/// empty snapshot, not a panic and not a fabricated value — the +/// cross-platform rule (`.claude/rules/cross-platform.md`) forbids the +/// unconditional unix-only path this would otherwise be. +#[cfg(not(target_os = "linux"))] +#[must_use] +pub fn read_cgroup_memory() -> CgroupMemory { + CgroupMemory::default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::osm_lifecycle::{ImportId, Lifecycle}; + + // ── OsmArtifactManager ─────────────────────────────────────────────── + + /// PROBE (Phase B). The manager's only self-constructed state: nothing + /// local, nothing in flight, no dataset content. + #[test] + fn a_fresh_manager_is_absent_with_no_artifact() { + let mgr = OsmArtifactManager::absent(); + assert_eq!(*mgr.current(), Lifecycle::absent()); + assert!(mgr.artifact().is_none()); + } + + /// PROBE (Phase B). The literal falsifier from the plan: "in-flight + /// readers finish against the old Arc; new readers receive the new + /// version." A reader that already cloned a snapshot must keep seeing + /// what it observed, even after a publish. + #[test] + fn publishing_a_new_lifecycle_does_not_disturb_an_old_readers_snapshot() { + let mgr = OsmArtifactManager::absent(); + let old_reader = mgr.current(); + assert_eq!(*old_reader, Lifecycle::absent()); + + let served = Lifecycle::serving(ImportId(1), None); + mgr.publish_lifecycle(served.clone()); + + // The snapshot taken BEFORE the publish is unchanged. + assert_eq!(*old_reader, Lifecycle::absent()); + // A NEW read sees the published value. + assert_eq!(*mgr.current(), served); + } + + /// PROBE (Phase B). Same non-disturbance guarantee, on the artifact + /// side — this is the half that actually matters once Phase C starts + /// publishing real dataset content: an in-flight tile request must not + /// have its `RowSlab` swapped out from under it mid-read. + #[test] + fn publishing_a_new_artifact_does_not_disturb_an_old_readers_snapshot() { + let mgr = OsmArtifactManager::absent(); + assert!(mgr.artifact().is_none()); + + let first = Arc::new(ActiveArtifact { _private: () }); + mgr.publish_artifact(Some(first.clone())); + let old_reader = mgr.artifact().expect("just published"); + assert!(Arc::ptr_eq(&old_reader, &first)); + + let second = Arc::new(ActiveArtifact { _private: () }); + mgr.publish_artifact(Some(second.clone())); + + // The reader's clone still points at the FIRST artifact. + assert!(Arc::ptr_eq(&old_reader, &first)); + // A new read sees the SECOND. + let new_reader = mgr.artifact().expect("second publish"); + assert!(Arc::ptr_eq(&new_reader, &second)); + } + + /// PROBE (Phase B). `publish_artifact(None)` is a valid retraction, not + /// a no-op — a manager can go from serving content to serving none. + #[test] + fn publishing_none_retracts_the_artifact() { + let mgr = OsmArtifactManager::absent(); + mgr.publish_artifact(Some(Arc::new(ActiveArtifact { _private: () }))); + assert!(mgr.artifact().is_some()); + + mgr.publish_artifact(None); + assert!( + mgr.artifact().is_none(), + "publishing None must actually clear the slot, not be ignored" + ); + } + + /// PROBE (Phase B). `Default` and `absent()` must agree — a caller + /// reaching for `OsmArtifactManager::default()` (e.g. in a `derive`d + /// `AppState`) gets the same honest starting state as the explicit + /// constructor, not a silently different one. + #[test] + fn default_agrees_with_absent() { + let via_default = OsmArtifactManager::default(); + let via_absent = OsmArtifactManager::absent(); + assert_eq!(*via_default.current(), *via_absent.current()); + assert!(via_default.artifact().is_none()); + assert!(via_absent.artifact().is_none()); + } + + // ── cgroup memory parsing ──────────────────────────────────────────── + + /// PROBE (Phase B). The common case: a bare integer, possibly with the + /// trailing newline every `/sys/fs/cgroup/*` file actually has. + #[test] + fn parse_cgroup_current_reads_a_plain_integer() { + assert_eq!(parse_cgroup_current("123456789\n"), Some(123_456_789)); + assert_eq!(parse_cgroup_current("0\n"), Some(0)); + } + + /// PROBE (Phase B). Malformed content must decline, not panic or guess. + #[test] + fn parse_cgroup_current_declines_malformed_content() { + assert_eq!(parse_cgroup_current(""), None); + assert_eq!(parse_cgroup_current("not a number"), None); + assert_eq!(parse_cgroup_current("-5"), None, "memory is never negative"); + } + + /// PROBE (Phase B). `max` is a real, distinct value cgroup v2 writes + /// for "no limit" — it is not malformed input and must not be confused + /// with an unreadable file (both currently render as `None`, but for + /// different reasons; see `max_bytes`'s doc comment for how a caller + /// tells them apart). + #[test] + fn parse_cgroup_max_recognizes_the_unlimited_sentinel() { + assert_eq!(parse_cgroup_max("max\n"), None); + assert_eq!(parse_cgroup_max("max"), None); + } + + /// PROBE (Phase B). A bounded cgroup reports a real ceiling. + #[test] + fn parse_cgroup_max_reads_a_bounded_ceiling() { + assert_eq!(parse_cgroup_max("25769803776\n"), Some(25_769_803_776)); + } + + /// PROBE (Phase B). Malformed content declines here too — a corrupt + /// `memory.max` must not be silently read as "unlimited" just because + /// both cases return `None`. + #[test] + fn parse_cgroup_max_declines_malformed_content() { + assert_eq!(parse_cgroup_max(""), None); + assert_eq!(parse_cgroup_max("maxxx"), None); + assert_eq!(parse_cgroup_max("not a number"), None); + } + + /// PROBE (Phase B). `read_cgroup_memory` never panics, on any platform + /// — the real system falsifier this session can actually run. On + /// Linux CI/containers `/sys/fs/cgroup/memory.current` is normally + /// readable, so this also incidentally exercises the real read path; + /// the assertion itself only requires the call to return, honestly, + /// whatever the platform has. + #[test] + fn read_cgroup_memory_never_panics() { + let _ = read_cgroup_memory(); + } +}