Skip to main content

ic_memory/
diagnostics.rs

1use crate::{
2    constants::WASM_PAGE_SIZE_BYTES,
3    declaration::AllocationDeclaration,
4    ledger::{AllocationLedger, AllocationRecord, GenerationRecord},
5    physical::CommitStoreDiagnostic,
6    slot::{AllocationSlotDescriptor, MemoryManagerAuthorityRecord, MemoryManagerRangeAuthority},
7};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11///
12/// DiagnosticExport
13///
14/// Read-only machine-readable allocation ledger export.
15#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(deny_unknown_fields)]
17pub struct DiagnosticExport {
18    /// Current committed generation.
19    pub current_generation: u64,
20    /// Ledger anchor descriptor.
21    pub ledger_anchor: AllocationSlotDescriptor,
22    /// Allocation records.
23    pub records: Vec<DiagnosticRecord>,
24    /// Generation records.
25    pub generations: Vec<DiagnosticGeneration>,
26    /// Optional protected commit recovery diagnostic.
27    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
28    pub commit_recovery: Option<CommitStoreDiagnostic>,
29}
30
31///
32/// MemoryRuntimeDoctorReport
33///
34/// Preflight and runtime diagnostic report for one concrete
35/// [`crate::MemoryRuntime`].
36///
37/// This report is intended for operator-facing diagnostics. Recoverable
38/// runtime problems, such as corrupt stable-cell bytes or commit recovery
39/// failure, are represented as fields instead of aborting report construction.
40///
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43#[serde(deny_unknown_fields)]
44pub struct MemoryRuntimeDoctorReport {
45    /// Whether this runtime has completed bootstrap validation.
46    pub bootstrapped: bool,
47    /// Ledger anchor descriptor used by this runtime.
48    pub ledger_anchor: AllocationSlotDescriptor,
49    /// Stable-cell ledger storage status.
50    pub stable_cell: DiagnosticStableCell,
51    /// Protected commit recovery status when a ledger record was readable.
52    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
53    pub commit_recovery: Option<CommitStoreDiagnostic>,
54    /// Recovered allocation ledger export when protected recovery succeeded.
55    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
56    pub ledger: Option<DiagnosticExport>,
57    /// Static declarations registered by linked crates.
58    pub registered_declarations: Vec<DiagnosticDeclaration>,
59    /// Static range authority registered by linked crates and the effective
60    /// authority table supplied to this runtime.
61    pub range_authority: DiagnosticRangeAuthority,
62    /// Current generic runtime declaration validation preflight result.
63    ///
64    /// Caller-supplied policies passed to [`crate::MemoryRuntime::bootstrap`] or
65    /// the default TLS bootstrap wrapper are not represented in this check.
66    pub validation: DiagnosticCheck,
67}
68
69///
70/// DiagnosticDeclaration
71///
72/// Read-only diagnostic view of one static allocation declaration.
73///
74
75#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
76#[serde(deny_unknown_fields)]
77pub struct DiagnosticDeclaration {
78    /// Crate or integration authority that registered the declaration.
79    pub authority: String,
80    /// Allocation declaration registered by that authority.
81    pub declaration: AllocationDeclaration,
82}
83
84impl DiagnosticDeclaration {
85    /// Build a diagnostic declaration record.
86    #[must_use]
87    pub fn new(authority: impl Into<String>, declaration: AllocationDeclaration) -> Self {
88        Self {
89            authority: authority.into(),
90            declaration,
91        }
92    }
93}
94
95///
96/// DiagnosticCode
97///
98/// Stable machine-readable category for an operator diagnostic failure.
99///
100
101#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub enum DiagnosticCode {
103    /// Deferred static initialization could not run.
104    #[serde(rename = "eager_init")]
105    EagerInit,
106    /// Static allocation declarations could not be read.
107    #[serde(rename = "declaration_registry")]
108    DeclarationRegistry,
109    /// Static range declarations could not be read.
110    #[serde(rename = "range_registry")]
111    RangeRegistry,
112    /// Effective range authority could not be constructed or applied.
113    #[serde(rename = "range_authority")]
114    RangeAuthority,
115    /// Registered declarations could not form a valid snapshot.
116    #[serde(rename = "declaration_snapshot")]
117    DeclarationSnapshot,
118    /// Stable-cell storage could not be decoded.
119    #[serde(rename = "stable_cell")]
120    StableCell,
121    /// Persisted bytes use a recognized but unsupported durable format.
122    #[serde(rename = "unsupported_format")]
123    UnsupportedFormat,
124    /// Protected ledger recovery failed.
125    #[serde(rename = "ledger_recovery")]
126    LedgerRecovery,
127    /// An empty current-format genesis ledger could not be constructed.
128    #[serde(rename = "genesis_ledger")]
129    GenesisLedger,
130    /// Current declarations failed allocation validation.
131    #[serde(rename = "allocation_validation")]
132    AllocationValidation,
133}
134
135///
136/// DiagnosticFailure
137///
138/// Machine-readable diagnostic code paired with an operator-facing message.
139///
140
141#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
142#[serde(deny_unknown_fields)]
143pub struct DiagnosticFailure {
144    /// Stable diagnostic category.
145    pub code: DiagnosticCode,
146    /// Human-readable failure detail.
147    pub message: String,
148}
149
150impl DiagnosticFailure {
151    /// Build a coded diagnostic failure.
152    #[must_use]
153    pub fn new(code: DiagnosticCode, message: impl Into<String>) -> Self {
154        Self {
155            code,
156            message: message.into(),
157        }
158    }
159}
160
161///
162/// DiagnosticRangeAuthority
163///
164/// Read-only diagnostic view of registered and effective range authority.
165///
166
167#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
168#[serde(deny_unknown_fields)]
169pub struct DiagnosticRangeAuthority {
170    /// Range records registered directly by linked crates.
171    pub registered_records: Vec<MemoryManagerAuthorityRecord>,
172    /// Effective range authority table or its validation error.
173    pub effective_authority: Result<MemoryManagerRangeAuthority, DiagnosticFailure>,
174}
175
176impl DiagnosticRangeAuthority {
177    /// Build a range-authority diagnostic.
178    #[must_use]
179    pub const fn new(
180        registered_records: Vec<MemoryManagerAuthorityRecord>,
181        effective_authority: Result<MemoryManagerRangeAuthority, DiagnosticFailure>,
182    ) -> Self {
183        Self {
184            registered_records,
185            effective_authority,
186        }
187    }
188}
189
190///
191/// DiagnosticStableCell
192///
193/// Read-only diagnostic view of the stable-cell ledger storage envelope.
194///
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
197#[serde(deny_unknown_fields)]
198pub struct DiagnosticStableCell {
199    /// Stable-cell status.
200    pub status: DiagnosticStableCellStatus,
201    /// Backing memory size for the ledger cell.
202    pub memory_size: DiagnosticMemorySize,
203}
204
205impl DiagnosticStableCell {
206    /// Build a stable-cell diagnostic.
207    #[must_use]
208    pub const fn new(
209        status: DiagnosticStableCellStatus,
210        memory_size: DiagnosticMemorySize,
211    ) -> Self {
212        Self {
213            status,
214            memory_size,
215        }
216    }
217}
218
219///
220/// DiagnosticStableCellStatus
221///
222/// Stable-cell ledger storage status.
223///
224
225#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
226#[serde(deny_unknown_fields)]
227pub enum DiagnosticStableCellStatus {
228    /// The ledger memory is empty and can be initialized.
229    Empty,
230    /// The stable-cell envelope and ledger record decoded successfully.
231    Readable,
232    /// The ledger memory is present but could not be decoded as the expected
233    /// stable-cell ledger record.
234    Corrupt {
235        /// Stable-cell envelope or ledger-record decode failure.
236        failure: DiagnosticFailure,
237    },
238}
239
240///
241/// DiagnosticCheck
242///
243/// Read-only diagnostic status for a preflight check.
244///
245
246#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
247#[serde(deny_unknown_fields)]
248pub enum DiagnosticCheck {
249    /// The check could not run because prerequisite state was unavailable.
250    NotRun {
251        /// Stable diagnostic category.
252        code: DiagnosticCode,
253        /// Reason the check could not run.
254        message: String,
255    },
256    /// The check completed successfully.
257    Passed,
258    /// The check ran and found a problem.
259    Failed {
260        /// Stable diagnostic category.
261        code: DiagnosticCode,
262        /// Validation failure.
263        message: String,
264    },
265}
266
267impl DiagnosticCheck {
268    /// Build a passed diagnostic check.
269    #[must_use]
270    pub const fn passed() -> Self {
271        Self::Passed
272    }
273
274    /// Build a failed diagnostic check.
275    #[must_use]
276    pub fn failed(code: DiagnosticCode, message: impl Into<String>) -> Self {
277        Self::Failed {
278            code,
279            message: message.into(),
280        }
281    }
282
283    /// Build a skipped diagnostic check.
284    #[must_use]
285    pub fn not_run(code: DiagnosticCode, message: impl Into<String>) -> Self {
286        Self::NotRun {
287            code,
288            message: message.into(),
289        }
290    }
291}
292
293impl DiagnosticExport {
294    /// Build a read-only diagnostic export from an allocation ledger.
295    #[must_use]
296    pub fn from_ledger(ledger: &AllocationLedger, ledger_anchor: AllocationSlotDescriptor) -> Self {
297        Self::from_ledger_with_commit_recovery(ledger, ledger_anchor, None)
298    }
299
300    /// Build a read-only diagnostic export with protected commit recovery state.
301    #[must_use]
302    pub fn from_ledger_with_commit_recovery(
303        ledger: &AllocationLedger,
304        ledger_anchor: AllocationSlotDescriptor,
305        commit_recovery: Option<CommitStoreDiagnostic>,
306    ) -> Self {
307        Self::from_ledger_with_commit_recovery_and_memory_sizes(
308            ledger,
309            ledger_anchor,
310            commit_recovery,
311            std::iter::empty(),
312        )
313    }
314
315    /// Build a read-only diagnostic export with live memory sizes.
316    #[must_use]
317    pub fn from_ledger_with_memory_sizes(
318        ledger: &AllocationLedger,
319        ledger_anchor: AllocationSlotDescriptor,
320        memory_sizes: impl IntoIterator<Item = (AllocationSlotDescriptor, DiagnosticMemorySize)>,
321    ) -> Self {
322        Self::from_ledger_with_commit_recovery_and_memory_sizes(
323            ledger,
324            ledger_anchor,
325            None,
326            memory_sizes,
327        )
328    }
329
330    /// Build a read-only diagnostic export with protected recovery state and live memory sizes.
331    #[must_use]
332    pub fn from_ledger_with_commit_recovery_and_memory_sizes(
333        ledger: &AllocationLedger,
334        ledger_anchor: AllocationSlotDescriptor,
335        commit_recovery: Option<CommitStoreDiagnostic>,
336        memory_sizes: impl IntoIterator<Item = (AllocationSlotDescriptor, DiagnosticMemorySize)>,
337    ) -> Self {
338        let memory_sizes: BTreeMap<_, _> = memory_sizes.into_iter().collect();
339        Self {
340            current_generation: ledger.current_generation,
341            ledger_anchor,
342            records: ledger
343                .allocation_history()
344                .records()
345                .iter()
346                .cloned()
347                .map(|allocation| {
348                    let memory_size = memory_sizes.get(allocation.slot()).copied();
349                    DiagnosticRecord {
350                        allocation,
351                        memory_size,
352                    }
353                })
354                .collect(),
355            generations: ledger
356                .allocation_history()
357                .generations()
358                .iter()
359                .cloned()
360                .map(|generation| DiagnosticGeneration { generation })
361                .collect(),
362            commit_recovery,
363        }
364    }
365}
366
367///
368/// DiagnosticRecord
369///
370/// Read-only diagnostic allocation record.
371#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
372#[serde(deny_unknown_fields)]
373pub struct DiagnosticRecord {
374    /// Allocation record.
375    pub allocation: AllocationRecord,
376    /// Live backing memory size, when the exporter measured one.
377    ///
378    /// This is allocation size reported by the backing memory, not logical user
379    /// payload size inside the stable structure.
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub memory_size: Option<DiagnosticMemorySize>,
382}
383
384///
385/// DiagnosticMemorySize
386///
387/// Live size reported by a backing stable memory.
388///
389
390#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
391#[serde(deny_unknown_fields)]
392pub struct DiagnosticMemorySize {
393    /// WebAssembly pages reported by the memory.
394    pub wasm_pages: u64,
395    /// Bytes represented by the page count.
396    pub bytes: u64,
397}
398
399impl DiagnosticMemorySize {
400    /// Build a size from a WebAssembly page count.
401    #[must_use]
402    pub const fn from_wasm_pages(wasm_pages: u64) -> Self {
403        Self {
404            wasm_pages,
405            bytes: wasm_pages.saturating_mul(WASM_PAGE_SIZE_BYTES),
406        }
407    }
408}
409
410///
411/// DiagnosticGeneration
412///
413/// Read-only diagnostic generation record.
414#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
415#[serde(deny_unknown_fields)]
416pub struct DiagnosticGeneration {
417    /// Generation record.
418    pub generation: GenerationRecord,
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::{
425        declaration::AllocationDeclaration,
426        ledger::{AllocationHistory, AllocationRecord},
427        physical::{CommitRecoveryError, CommitSlotDiagnostic, CommitStoreDiagnostic},
428        schema::SchemaMetadata,
429    };
430
431    #[test]
432    fn diagnostic_export_copies_ledger_records() {
433        let declaration = AllocationDeclaration::new(
434            "app.users.v1",
435            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
436            None,
437            SchemaMetadata::default(),
438        )
439        .expect("declaration");
440        let ledger = AllocationLedger {
441            current_generation: 3,
442            allocation_history: AllocationHistory::from_parts(
443                vec![AllocationRecord::active(3, declaration).expect("valid schema metadata")],
444                vec![GenerationRecord {
445                    generation: 3,
446                    parent_generation: 2,
447                    runtime_fingerprint: Some("wasm:abc123".to_string()),
448                    declaration_count: 1,
449                    committed_at: None,
450                }],
451            ),
452        };
453
454        let export = DiagnosticExport::from_ledger(
455            &ledger,
456            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
457        );
458
459        assert_eq!(export.current_generation, 3);
460        assert_eq!(export.records.len(), 1);
461        assert_eq!(export.records[0].memory_size, None);
462        assert_eq!(export.generations.len(), 1);
463        assert_eq!(
464            export.ledger_anchor,
465            AllocationSlotDescriptor::memory_manager(0).expect("usable slot")
466        );
467        assert_eq!(export.commit_recovery, None);
468    }
469
470    #[test]
471    fn diagnostic_export_rejects_unknown_top_level_fields() {
472        use crate::test_cbor::Value;
473
474        let export = DiagnosticExport {
475            current_generation: 0,
476            ledger_anchor: AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
477            records: Vec::new(),
478            generations: Vec::new(),
479            commit_recovery: None,
480        };
481        let Value::Map(mut map) = crate::test_cbor::to_value(export).expect("diagnostic value")
482        else {
483            panic!("diagnostic export encodes as a map");
484        };
485        crate::test_cbor::map_insert(
486            &mut map,
487            Value::Text("future_field".to_string()),
488            Value::Bool(true),
489        );
490        let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("diagnostic bytes");
491
492        let err = crate::test_cbor::from_slice::<DiagnosticExport>(&bytes)
493            .expect_err("unknown diagnostic field must fail closed");
494
495        assert!(err.to_string().contains("future_field"));
496    }
497
498    #[test]
499    fn diagnostic_outcome_states_round_trip() {
500        let stable_cell = DiagnosticStableCell::new(
501            DiagnosticStableCellStatus::Corrupt {
502                failure: DiagnosticFailure::new(
503                    DiagnosticCode::StableCell,
504                    "bad stable-cell record",
505                ),
506            },
507            DiagnosticMemorySize::from_wasm_pages(1),
508        );
509        let range_authority = DiagnosticRangeAuthority::new(
510            Vec::new(),
511            Err(DiagnosticFailure::new(
512                DiagnosticCode::RangeAuthority,
513                "overlapping authority ranges",
514            )),
515        );
516        let check = DiagnosticCheck::failed(
517            DiagnosticCode::AllocationValidation,
518            "duplicate declaration",
519        );
520
521        for value in [DiagnosticCheck::passed(), check] {
522            let bytes = crate::test_cbor::to_vec(&value).expect("check bytes");
523            let decoded: DiagnosticCheck =
524                crate::test_cbor::from_slice(&bytes).expect("check round trip");
525            assert_eq!(decoded, value);
526        }
527
528        let bytes = crate::test_cbor::to_vec(&stable_cell).expect("stable-cell diagnostic bytes");
529        let decoded: DiagnosticStableCell =
530            crate::test_cbor::from_slice(&bytes).expect("stable-cell round trip");
531        assert_eq!(decoded, stable_cell);
532
533        let bytes = crate::test_cbor::to_vec(&range_authority).expect("range diagnostic bytes");
534        let decoded: DiagnosticRangeAuthority =
535            crate::test_cbor::from_slice(&bytes).expect("range round trip");
536        assert_eq!(decoded, range_authority);
537    }
538
539    #[test]
540    fn diagnostic_codes_have_stable_wire_names() {
541        let cases = [
542            (DiagnosticCode::EagerInit, "eager_init"),
543            (DiagnosticCode::DeclarationRegistry, "declaration_registry"),
544            (DiagnosticCode::RangeRegistry, "range_registry"),
545            (DiagnosticCode::RangeAuthority, "range_authority"),
546            (DiagnosticCode::DeclarationSnapshot, "declaration_snapshot"),
547            (DiagnosticCode::StableCell, "stable_cell"),
548            (DiagnosticCode::UnsupportedFormat, "unsupported_format"),
549            (DiagnosticCode::LedgerRecovery, "ledger_recovery"),
550            (DiagnosticCode::GenesisLedger, "genesis_ledger"),
551            (
552                DiagnosticCode::AllocationValidation,
553                "allocation_validation",
554            ),
555        ];
556
557        for (code, expected) in cases {
558            assert_eq!(
559                crate::test_cbor::to_value(code).expect("diagnostic code value"),
560                crate::test_cbor::Value::Text(expected.to_string())
561            );
562        }
563    }
564
565    #[test]
566    fn diagnostic_export_can_include_commit_recovery_state() {
567        let ledger = AllocationLedger {
568            current_generation: 3,
569            allocation_history: AllocationHistory::default(),
570        };
571        let commit_recovery = CommitStoreDiagnostic {
572            slot0: CommitSlotDiagnostic::Valid { generation: 3 },
573            slot1: CommitSlotDiagnostic::Empty,
574            recovery: Ok(3),
575        };
576
577        let export = DiagnosticExport::from_ledger_with_commit_recovery(
578            &ledger,
579            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
580            Some(commit_recovery),
581        );
582
583        assert_eq!(export.commit_recovery, Some(commit_recovery));
584    }
585
586    #[test]
587    fn diagnostic_export_can_include_memory_sizes() {
588        let declaration = AllocationDeclaration::new(
589            "app.users.v1",
590            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
591            None,
592            SchemaMetadata::default(),
593        )
594        .expect("declaration");
595        let ledger = AllocationLedger {
596            current_generation: 3,
597            allocation_history: AllocationHistory::from_parts(
598                vec![AllocationRecord::active(3, declaration).expect("valid schema metadata")],
599                Vec::new(),
600            ),
601        };
602
603        let export = DiagnosticExport::from_ledger_with_memory_sizes(
604            &ledger,
605            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
606            [(
607                AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
608                DiagnosticMemorySize::from_wasm_pages(2),
609            )],
610        );
611
612        assert_eq!(
613            export.records[0].memory_size,
614            Some(DiagnosticMemorySize {
615                wasm_pages: 2,
616                bytes: 131_072,
617            })
618        );
619    }
620
621    #[test]
622    fn diagnostic_export_can_report_recovery_failure() {
623        let ledger = AllocationLedger {
624            current_generation: 0,
625            allocation_history: AllocationHistory::default(),
626        };
627        let commit_recovery = CommitStoreDiagnostic {
628            slot0: CommitSlotDiagnostic::Empty,
629            slot1: CommitSlotDiagnostic::Empty,
630            recovery: Err(CommitRecoveryError::NoValidGeneration),
631        };
632
633        let export = DiagnosticExport::from_ledger_with_commit_recovery(
634            &ledger,
635            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
636            Some(commit_recovery),
637        );
638
639        assert_eq!(
640            export.commit_recovery.expect("commit recovery").recovery,
641            Err(CommitRecoveryError::NoValidGeneration)
642        );
643    }
644}