Skip to main content

ic_testkit/pic/
diagnostics.rs

1use std::{
2    collections::HashMap,
3    fmt,
4    panic::{AssertUnwindSafe, catch_unwind},
5    time::{Duration, Instant},
6};
7
8use candid::Principal;
9use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc, RejectResponse};
10
11use super::transport;
12
13/// Default maximum number of canister-log records retained in diagnostics.
14pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
15
16/// Default maximum aggregate raw canister-log bytes retained in diagnostics.
17pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
18
19/// Explicit bounds applied when canister logs are converted to diagnostic text.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct CanisterLogRenderLimits {
22    record_limit: usize,
23    byte_limit: usize,
24}
25
26impl CanisterLogRenderLimits {
27    /// Set exact record and aggregate raw-content byte limits.
28    ///
29    /// Zero is valid for either limit and retains no content in that dimension.
30    #[must_use]
31    pub const fn new(record_limit: usize, byte_limit: usize) -> Self {
32        Self {
33            record_limit,
34            byte_limit,
35        }
36    }
37
38    /// Maximum number of retained records.
39    #[must_use]
40    pub const fn record_limit(self) -> usize {
41        self.record_limit
42    }
43
44    /// Maximum aggregate number of retained raw content bytes.
45    #[must_use]
46    pub const fn byte_limit(self) -> usize {
47        self.byte_limit
48    }
49}
50
51impl Default for CanisterLogRenderLimits {
52    fn default() -> Self {
53        Self::new(
54            DEFAULT_CANISTER_LOG_RECORD_LIMIT,
55            DEFAULT_CANISTER_LOG_BYTE_LIMIT,
56        )
57    }
58}
59
60/// Exact controller-aware inputs for one best-effort diagnostic collection.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct CanisterDiagnosticsRequest {
63    canister_id: Principal,
64    status_sender: Principal,
65    log_sender: Principal,
66    log_limits: CanisterLogRenderLimits,
67}
68
69impl CanisterDiagnosticsRequest {
70    /// Create a request with independent, exact status and log senders.
71    ///
72    /// Anonymous access remains available only by explicitly supplying
73    /// [`Principal::anonymous`] for the corresponding operation.
74    #[must_use]
75    pub fn new(canister_id: Principal, status_sender: Principal, log_sender: Principal) -> Self {
76        Self {
77            canister_id,
78            status_sender,
79            log_sender,
80            log_limits: CanisterLogRenderLimits::default(),
81        }
82    }
83
84    /// Override the bounds used to retain and render fetched log content.
85    #[must_use]
86    pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
87        self.log_limits = limits;
88        self
89    }
90
91    /// Target canister.
92    #[must_use]
93    pub const fn canister_id(self) -> Principal {
94        self.canister_id
95    }
96
97    /// Exact sender supplied to `canister_status`.
98    #[must_use]
99    pub const fn status_sender(self) -> Principal {
100        self.status_sender
101    }
102
103    /// Exact sender supplied to `fetch_canister_logs`.
104    #[must_use]
105    pub const fn log_sender(self) -> Principal {
106        self.log_sender
107    }
108
109    /// Log rendering bounds.
110    #[must_use]
111    pub const fn log_limits(self) -> CanisterLogRenderLimits {
112        self.log_limits
113    }
114}
115
116/// One caller-labeled exact diagnostic request in a collect-all batch.
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct LabeledCanisterDiagnosticsRequest {
119    label: String,
120    request: CanisterDiagnosticsRequest,
121}
122
123/// Structural error that prevents a diagnostics batch from starting.
124#[non_exhaustive]
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub enum CanisterDiagnosticsBatchContractError {
127    /// An entry label was empty.
128    EmptyLabel {
129        /// Zero-based position of the invalid entry.
130        index: usize,
131    },
132    /// Two entries used the same label.
133    DuplicateLabel {
134        /// Duplicated caller label.
135        label: String,
136        /// Position where the label first appeared.
137        first_index: usize,
138        /// Position where the label was repeated.
139        duplicate_index: usize,
140    },
141}
142
143impl LabeledCanisterDiagnosticsRequest {
144    /// Attach a stable caller-facing label to an exact diagnostic request.
145    #[must_use]
146    pub fn new(label: impl Into<String>, request: CanisterDiagnosticsRequest) -> Self {
147        Self {
148            label: label.into(),
149            request,
150        }
151    }
152
153    /// Caller-supplied label retained in the batch report.
154    #[must_use]
155    pub fn label(&self) -> &str {
156        &self.label
157    }
158
159    /// Exact controller-aware request.
160    #[must_use]
161    pub const fn request(&self) -> CanisterDiagnosticsRequest {
162        self.request
163    }
164
165    /// Consume the labeled request into its caller label and exact request.
166    #[must_use]
167    pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
168        (self.label, self.request)
169    }
170}
171
172/// A failed best-effort PocketIC diagnostic call.
173#[non_exhaustive]
174#[derive(Debug)]
175pub enum CanisterDiagnosticFailure {
176    /// PocketIC returned a structured management-canister rejection.
177    Rejected(RejectResponse),
178    /// The PocketIC instance was no longer reachable.
179    InstanceUnavailable {
180        /// Captured transport or panic message.
181        message: String,
182    },
183    /// PocketIC panicked for a reason other than a dead instance.
184    Panicked {
185        /// Captured panic message.
186        message: String,
187    },
188}
189
190impl fmt::Display for CanisterDiagnosticFailure {
191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        match self {
193            Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
194            Self::InstanceUnavailable { message } => {
195                write!(formatter, "PocketIC instance unavailable: {message}")
196            }
197            Self::Panicked { message } => write!(formatter, "panicked: {message}"),
198        }
199    }
200}
201
202impl std::error::Error for CanisterDiagnosticFailure {}
203
204/// One canister-log record retained as bounded lossy UTF-8 text.
205#[derive(Clone, Debug, Eq, PartialEq)]
206pub struct CanisterDiagnosticLogRecord {
207    index: u64,
208    timestamp_nanos: u64,
209    content: String,
210    original_content_bytes: usize,
211    omitted_content_bytes: usize,
212}
213
214impl CanisterDiagnosticLogRecord {
215    /// Upstream record index.
216    #[must_use]
217    pub const fn index(&self) -> u64 {
218        self.index
219    }
220
221    /// Upstream record timestamp in nanoseconds.
222    #[must_use]
223    pub const fn timestamp_nanos(&self) -> u64 {
224        self.timestamp_nanos
225    }
226
227    /// Retained content converted with lossy UTF-8 decoding.
228    #[must_use]
229    pub fn content(&self) -> &str {
230        &self.content
231    }
232
233    /// Original raw content length before bounding and conversion.
234    #[must_use]
235    pub const fn original_content_bytes(&self) -> usize {
236        self.original_content_bytes
237    }
238
239    /// Raw content bytes omitted from this retained record.
240    #[must_use]
241    pub const fn omitted_content_bytes(&self) -> usize {
242        self.omitted_content_bytes
243    }
244
245    /// Whether this record's content was truncated.
246    #[must_use]
247    pub const fn was_truncated(&self) -> bool {
248        self.omitted_content_bytes != 0
249    }
250}
251
252/// Successfully fetched canister logs after bounded text conversion.
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct CanisterDiagnosticLogs {
255    records: Vec<CanisterDiagnosticLogRecord>,
256    total_records: usize,
257    total_content_bytes: usize,
258    omitted_content_bytes: usize,
259}
260
261impl CanisterDiagnosticLogs {
262    /// Retained records in upstream order.
263    #[must_use]
264    pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
265        &self.records
266    }
267
268    /// Total number of records returned by PocketIC before bounding.
269    #[must_use]
270    pub const fn total_records(&self) -> usize {
271        self.total_records
272    }
273
274    /// Total raw content bytes returned by PocketIC before bounding.
275    #[must_use]
276    pub const fn total_content_bytes(&self) -> usize {
277        self.total_content_bytes
278    }
279
280    /// Number of whole records omitted by the configured bounds.
281    #[must_use]
282    pub const fn omitted_records(&self) -> usize {
283        self.total_records - self.records.len()
284    }
285
286    /// Aggregate raw content bytes omitted across retained and omitted records.
287    #[must_use]
288    pub const fn omitted_content_bytes(&self) -> usize {
289        self.omitted_content_bytes
290    }
291
292    /// Whether either whole records or record content were truncated.
293    #[must_use]
294    pub const fn was_truncated(&self) -> bool {
295        self.omitted_records() != 0 || self.omitted_content_bytes != 0
296    }
297}
298
299impl fmt::Display for CanisterDiagnosticLogs {
300    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
301        if self.records.is_empty() {
302            if self.total_records == 0 {
303                formatter.write_str("<empty>")?;
304            } else {
305                formatter.write_str("<no retained records>")?;
306            }
307        } else {
308            for (position, record) in self.records.iter().enumerate() {
309                if position != 0 {
310                    formatter.write_str(", ")?;
311                }
312                write!(
313                    formatter,
314                    "[{}@{}]={:?}",
315                    record.index, record.timestamp_nanos, record.content
316                )?;
317                if record.was_truncated() {
318                    write!(
319                        formatter,
320                        " (truncated {} bytes)",
321                        record.omitted_content_bytes
322                    )?;
323                }
324            }
325        }
326        if self.was_truncated() {
327            write!(
328                formatter,
329                "; truncated omitted_records={} omitted_content_bytes={}",
330                self.omitted_records(),
331                self.omitted_content_bytes
332            )?;
333        }
334        Ok(())
335    }
336}
337
338/// Independent status and log outcomes for one diagnostic collection.
339#[derive(Debug)]
340pub struct CanisterDiagnosticsReport {
341    request: CanisterDiagnosticsRequest,
342    status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
343    logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
344}
345
346impl CanisterDiagnosticsReport {
347    /// Exact request used for this report.
348    #[must_use]
349    pub const fn request(&self) -> CanisterDiagnosticsRequest {
350        self.request
351    }
352
353    /// Status result, independent of log retrieval.
354    pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
355        self.status.as_ref()
356    }
357
358    /// Log result, independent of status retrieval.
359    pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
360        self.logs.as_ref()
361    }
362
363    /// Whether both status and log collection succeeded.
364    #[must_use]
365    pub const fn is_success(&self) -> bool {
366        self.status.is_ok() && self.logs.is_ok()
367    }
368
369    /// Consume the report into its exact request and independent outcomes.
370    pub fn into_parts(
371        self,
372    ) -> (
373        CanisterDiagnosticsRequest,
374        Result<CanisterStatusResult, CanisterDiagnosticFailure>,
375        Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
376    ) {
377        (self.request, self.status, self.logs)
378    }
379
380    /// Render a compact, bounded diagnostic line suitable for failure output.
381    #[must_use]
382    pub fn render_compact(&self) -> String {
383        self.to_string()
384    }
385}
386
387/// One ordered labeled entry in a collect-all diagnostics batch.
388#[derive(Debug)]
389pub struct CanisterDiagnosticsBatchEntry {
390    label: String,
391    report: CanisterDiagnosticsReport,
392    entry_elapsed: Duration,
393}
394
395impl CanisterDiagnosticsBatchEntry {
396    /// Caller-supplied label.
397    #[must_use]
398    pub fn label(&self) -> &str {
399        &self.label
400    }
401
402    /// Structured status and log outcomes for this entry.
403    #[must_use]
404    pub const fn report(&self) -> &CanisterDiagnosticsReport {
405        &self.report
406    }
407
408    /// Whether both status and log collection succeeded for this entry.
409    #[must_use]
410    pub const fn is_success(&self) -> bool {
411        self.report.is_success()
412    }
413
414    /// Complete wall-clock time for this target's diagnostic collection.
415    #[must_use]
416    pub const fn entry_elapsed(&self) -> Duration {
417        self.entry_elapsed
418    }
419
420    /// Consume the entry into its label, structured report, and wall time.
421    #[must_use]
422    pub fn into_parts(self) -> (String, CanisterDiagnosticsReport, Duration) {
423        (self.label, self.report, self.entry_elapsed)
424    }
425}
426
427/// Ordered entries from a sequential collect-all diagnostics batch.
428#[derive(Debug, Default)]
429pub struct CanisterDiagnosticsBatchReport {
430    entries: Vec<CanisterDiagnosticsBatchEntry>,
431    total: Duration,
432}
433
434impl CanisterDiagnosticsBatchReport {
435    /// Entries in the supplied request order.
436    #[must_use]
437    pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
438        &self.entries
439    }
440
441    /// Entries with at least one failed status or log operation.
442    pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
443        self.entries.iter().filter(|entry| !entry.is_success())
444    }
445
446    /// Whether every entry collected both status and logs successfully.
447    #[must_use]
448    pub fn is_success(&self) -> bool {
449        self.entries
450            .iter()
451            .all(CanisterDiagnosticsBatchEntry::is_success)
452    }
453
454    /// Complete wall-clock time for the sequential collect-all batch.
455    #[must_use]
456    pub const fn total(&self) -> Duration {
457        self.total
458    }
459
460    /// Consume the report into its ordered entries.
461    #[must_use]
462    pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
463        self.entries
464    }
465
466    /// Render compact bounded diagnostics with retained caller labels.
467    #[must_use]
468    pub fn render_compact(&self) -> String {
469        self.to_string()
470    }
471}
472
473impl fmt::Display for CanisterDiagnosticsBatchReport {
474    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
475        write!(
476            formatter,
477            "diagnostics={} total={:?}",
478            self.entries.len(),
479            self.total
480        )?;
481        for entry in &self.entries {
482            write!(
483                formatter,
484                "; label={:?} elapsed={:?} {}",
485                entry.label, entry.entry_elapsed, entry.report
486            )?;
487        }
488        Ok(())
489    }
490}
491
492impl fmt::Display for CanisterDiagnosticsReport {
493    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
494        write!(
495            formatter,
496            "canister={} status_sender={} status=",
497            self.request.canister_id, self.request.status_sender
498        )?;
499        match &self.status {
500            Ok(status) => write!(
501                formatter,
502                "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
503                status.status,
504                status.version,
505                status.settings.controllers.len(),
506                status.module_hash.as_ref().map_or(0, Vec::len),
507                status.memory_size,
508                status.cycles,
509            ),
510            Err(failure) => write!(formatter, "<{failure}>"),
511        }?;
512        write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
513        match &self.logs {
514            Err(failure) => write!(formatter, "<{failure}>")?,
515            Ok(logs) => write!(formatter, "{logs}")?,
516        }
517        Ok(())
518    }
519}
520
521/// Reusable structured PocketIC failure diagnostics.
522pub trait PocketIcDiagnosticsExt {
523    /// Collect status and bounded logs using the request's exact senders.
524    ///
525    /// Both calls are attempted independently. PocketIC transport panics are
526    /// captured so diagnostics can remain subordinate to the original failure.
527    fn collect_canister_diagnostics(
528        &self,
529        request: CanisterDiagnosticsRequest,
530    ) -> CanisterDiagnosticsReport;
531
532    /// Collect every labeled request sequentially in its supplied order.
533    ///
534    /// Every target is attempted even when an earlier entry fails or panics.
535    /// Each entry preserves its exact request and independent status/log
536    /// outcomes; no anonymous retry or fallback is performed.
537    fn collect_canister_diagnostics_batch(
538        &self,
539        requests: &[LabeledCanisterDiagnosticsRequest],
540    ) -> Result<CanisterDiagnosticsBatchReport, CanisterDiagnosticsBatchContractError> {
541        validate_diagnostics_batch_labels(requests)?;
542        let started = Instant::now();
543        let entries = requests
544            .iter()
545            .map(|labeled| {
546                let entry_started = Instant::now();
547                let request = labeled.request;
548                let report = catch_unwind(AssertUnwindSafe(|| {
549                    self.collect_canister_diagnostics(request)
550                }))
551                .unwrap_or_else(|payload| {
552                    let message = transport::panic_payload_to_string(payload.as_ref());
553                    CanisterDiagnosticsReport {
554                        request,
555                        status: Err(diagnostic_panic_failure(message.clone())),
556                        logs: Err(diagnostic_panic_failure(message)),
557                    }
558                });
559                CanisterDiagnosticsBatchEntry {
560                    label: labeled.label.clone(),
561                    report,
562                    entry_elapsed: entry_started.elapsed(),
563                }
564            })
565            .collect();
566        Ok(CanisterDiagnosticsBatchReport {
567            entries,
568            total: started.elapsed(),
569        })
570    }
571}
572
573fn validate_diagnostics_batch_labels(
574    requests: &[LabeledCanisterDiagnosticsRequest],
575) -> Result<(), CanisterDiagnosticsBatchContractError> {
576    let mut seen = HashMap::new();
577    for (index, request) in requests.iter().enumerate() {
578        let label = request.label.as_str();
579        if label.is_empty() {
580            return Err(CanisterDiagnosticsBatchContractError::EmptyLabel { index });
581        }
582        if let Some(first_index) = seen.insert(label, index) {
583            return Err(CanisterDiagnosticsBatchContractError::DuplicateLabel {
584                label: label.to_owned(),
585                first_index,
586                duplicate_index: index,
587            });
588        }
589    }
590    Ok(())
591}
592
593impl fmt::Display for CanisterDiagnosticsBatchContractError {
594    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
595        match self {
596            Self::EmptyLabel { index } => {
597                write!(
598                    formatter,
599                    "diagnostics batch label at index {index} is empty"
600                )
601            }
602            Self::DuplicateLabel {
603                label,
604                first_index,
605                duplicate_index,
606            } => write!(
607                formatter,
608                "diagnostics batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
609            ),
610        }
611    }
612}
613
614impl std::error::Error for CanisterDiagnosticsBatchContractError {}
615
616impl PocketIcDiagnosticsExt for PocketIc {
617    fn collect_canister_diagnostics(
618        &self,
619        request: CanisterDiagnosticsRequest,
620    ) -> CanisterDiagnosticsReport {
621        let status = capture_diagnostic_call(|| {
622            self.canister_status(request.canister_id, Some(request.status_sender))
623        });
624        let logs = capture_diagnostic_call(|| {
625            self.fetch_canister_logs(request.canister_id, request.log_sender)
626        })
627        .map(|records| render_log_records(records, request.log_limits));
628
629        CanisterDiagnosticsReport {
630            request,
631            status,
632            logs,
633        }
634    }
635}
636
637fn capture_diagnostic_call<T>(
638    call: impl FnOnce() -> Result<T, RejectResponse>,
639) -> Result<T, CanisterDiagnosticFailure> {
640    match catch_unwind(AssertUnwindSafe(call)) {
641        Ok(Ok(value)) => Ok(value),
642        Ok(Err(response)) => Err(CanisterDiagnosticFailure::Rejected(response)),
643        Err(payload) => {
644            let message = transport::panic_payload_to_string(payload.as_ref());
645            Err(diagnostic_panic_failure(message))
646        }
647    }
648}
649
650fn diagnostic_panic_failure(message: String) -> CanisterDiagnosticFailure {
651    if transport::is_dead_instance_transport_error(&message) {
652        CanisterDiagnosticFailure::InstanceUnavailable { message }
653    } else {
654        CanisterDiagnosticFailure::Panicked { message }
655    }
656}
657
658fn render_log_records(
659    records: Vec<CanisterLogRecord>,
660    limits: CanisterLogRenderLimits,
661) -> CanisterDiagnosticLogs {
662    let total_records = records.len();
663    let mut total_content_bytes = 0usize;
664    let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
665    let mut retained_bytes = 0usize;
666    let mut omitted_content_bytes = 0usize;
667
668    for record in records {
669        total_content_bytes = total_content_bytes.saturating_add(record.content.len());
670        if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
671            omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
672            continue;
673        }
674
675        let available = limits.byte_limit.saturating_sub(retained_bytes);
676        let retained = record.content.len().min(available);
677        let omitted = record.content.len().saturating_sub(retained);
678        let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
679        retained_bytes = retained_bytes.saturating_add(retained);
680        omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
681        rendered.push(CanisterDiagnosticLogRecord {
682            index: record.idx,
683            timestamp_nanos: record.timestamp_nanos,
684            content,
685            original_content_bytes: record.content.len(),
686            omitted_content_bytes: omitted,
687        });
688    }
689
690    CanisterDiagnosticLogs {
691        records: rendered,
692        total_records,
693        total_content_bytes,
694        omitted_content_bytes,
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use std::cell::Cell;
701
702    use candid::Principal;
703    use pocket_ic::CanisterLogRecord;
704
705    use super::{
706        CanisterDiagnosticFailure, CanisterDiagnosticsBatchContractError,
707        CanisterDiagnosticsReport, CanisterDiagnosticsRequest, CanisterLogRenderLimits,
708        LabeledCanisterDiagnosticsRequest, PocketIcDiagnosticsExt, capture_diagnostic_call,
709        render_log_records,
710    };
711
712    #[test]
713    fn diagnostic_transport_classification_survives_context_and_io_wrapping() {
714        const REFUSED: &str = "HTTP failure: reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/read/get_time\", source: ConnectError(\"tcp connect error\", Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }) }";
715        const RESET: &str = "HTTP failure: reqwest::Error { kind: Request, url: \"http://127.0.0.1:49238/instances/0/read/get_time\", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Io, Os { code: 54, kind: ConnectionReset, message: \"Connection reset by peer\" })) }";
716        for message in [REFUSED, RESET, "application worker channel closed"] {
717            let error = capture_diagnostic_call::<()>(|| panic!("{message}"))
718                .expect_err("diagnostic operation should capture the panic");
719            let expected = message != "application worker channel closed";
720            assert_eq!(
721                matches!(
722                    &error,
723                    CanisterDiagnosticFailure::InstanceUnavailable { .. }
724                ),
725                expected,
726            );
727            assert_eq!(
728                super::super::is_dead_pocket_ic_transport_error(&error),
729                expected,
730            );
731            let wrapped = std::io::Error::other(error);
732            assert_eq!(
733                super::super::is_dead_pocket_ic_transport_error(&wrapped),
734                expected,
735            );
736        }
737    }
738
739    struct PanickingThenReporting {
740        calls: Cell<usize>,
741    }
742
743    impl PocketIcDiagnosticsExt for PanickingThenReporting {
744        fn collect_canister_diagnostics(
745            &self,
746            request: CanisterDiagnosticsRequest,
747        ) -> CanisterDiagnosticsReport {
748            let call = self.calls.get();
749            self.calls.set(call + 1);
750            assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
751            CanisterDiagnosticsReport {
752                request,
753                status: Err(CanisterDiagnosticFailure::Panicked {
754                    message: "synthetic status failure".to_owned(),
755                }),
756                logs: Err(CanisterDiagnosticFailure::Panicked {
757                    message: "synthetic log failure".to_owned(),
758                }),
759            }
760        }
761    }
762
763    #[test]
764    fn labeled_batch_retains_order_and_continues_after_entry_panic() {
765        let collector = PanickingThenReporting {
766            calls: Cell::new(0),
767        };
768        let first = CanisterDiagnosticsRequest::new(
769            Principal::from_slice(&[1]),
770            Principal::from_slice(&[2]),
771            Principal::from_slice(&[3]),
772        );
773        let second = CanisterDiagnosticsRequest::new(
774            Principal::from_slice(&[4]),
775            Principal::from_slice(&[5]),
776            Principal::from_slice(&[6]),
777        );
778        let report = collector
779            .collect_canister_diagnostics_batch(&[
780                LabeledCanisterDiagnosticsRequest::new("root", first),
781                LabeledCanisterDiagnosticsRequest::new("worker", second),
782            ])
783            .expect("valid labeled diagnostics batch");
784
785        assert_eq!(collector.calls.get(), 2);
786        assert_eq!(report.entries().len(), 2);
787        assert_eq!(report.entries()[0].label(), "root");
788        assert_eq!(report.entries()[0].report().request(), first);
789        assert_eq!(report.entries()[1].label(), "worker");
790        assert_eq!(report.entries()[1].report().request(), second);
791        assert!(
792            report
793                .entries()
794                .iter()
795                .all(|entry| entry.entry_elapsed() <= report.total())
796        );
797        assert_eq!(report.failures().count(), 2);
798        assert!(!report.is_success());
799        let compact = report.render_compact();
800        assert!(compact.contains("label=\"root\""));
801        assert!(compact.contains("label=\"worker\""));
802        assert!(compact.contains("synthetic first-entry diagnostic panic"));
803    }
804
805    #[test]
806    fn diagnostic_batch_rejects_invalid_labels_before_collection() {
807        let collector = PanickingThenReporting {
808            calls: Cell::new(0),
809        };
810        let request = CanisterDiagnosticsRequest::new(
811            Principal::from_slice(&[1]),
812            Principal::from_slice(&[2]),
813            Principal::from_slice(&[3]),
814        );
815        let empty = collector
816            .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest::new(
817                "", request,
818            )])
819            .expect_err("empty label must reject diagnostics batch");
820        assert_eq!(
821            empty,
822            CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
823        );
824        assert_eq!(collector.calls.get(), 0);
825
826        let duplicate = collector
827            .collect_canister_diagnostics_batch(&[
828                LabeledCanisterDiagnosticsRequest::new("same", request),
829                LabeledCanisterDiagnosticsRequest::new("same", request),
830            ])
831            .expect_err("duplicate labels must reject diagnostics batch");
832        assert_eq!(
833            duplicate,
834            CanisterDiagnosticsBatchContractError::DuplicateLabel {
835                label: "same".to_owned(),
836                first_index: 0,
837                duplicate_index: 1,
838            }
839        );
840        assert_eq!(collector.calls.get(), 0);
841    }
842
843    #[test]
844    fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
845        let logs = render_log_records(
846            vec![
847                CanisterLogRecord {
848                    idx: 7,
849                    timestamp_nanos: 11,
850                    content: vec![b'f', 0x80, b'o'],
851                },
852                CanisterLogRecord {
853                    idx: 8,
854                    timestamp_nanos: 12,
855                    content: b"bar".to_vec(),
856                },
857            ],
858            CanisterLogRenderLimits::new(1, 2),
859        );
860
861        assert_eq!(logs.total_records(), 2);
862        assert_eq!(logs.total_content_bytes(), 6);
863        assert_eq!(logs.omitted_records(), 1);
864        assert_eq!(logs.omitted_content_bytes(), 4);
865        assert!(logs.was_truncated());
866        assert_eq!(logs.records().len(), 1);
867        assert_eq!(logs.records()[0].content(), "f�");
868        assert_eq!(logs.records()[0].original_content_bytes(), 3);
869        assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
870        assert!(logs.records()[0].was_truncated());
871        let rendered = logs.to_string();
872        assert!(rendered.contains("f�"));
873        assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
874    }
875
876    #[test]
877    fn zero_log_bounds_retain_only_aggregate_truncation() {
878        for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
879            let logs = render_log_records(
880                vec![CanisterLogRecord {
881                    idx: 1,
882                    timestamp_nanos: 2,
883                    content: b"hello".to_vec(),
884                }],
885                CanisterLogRenderLimits::new(record_limit, byte_limit),
886            );
887
888            assert_eq!(logs.records(), []);
889            assert_eq!(logs.total_records(), 1);
890            assert_eq!(logs.total_content_bytes(), 5);
891            assert_eq!(logs.omitted_records(), 1);
892            assert_eq!(logs.omitted_content_bytes(), 5);
893            assert!(logs.was_truncated());
894            assert_eq!(
895                logs.to_string(),
896                "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
897            );
898        }
899    }
900
901    #[test]
902    fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
903        let limits = CanisterLogRenderLimits::new(3, 3);
904        let empty = render_log_records(vec![], limits);
905        assert_eq!(empty.total_records(), 0);
906        assert_eq!(empty.total_content_bytes(), 0);
907        assert_eq!(empty.omitted_records(), 0);
908        assert_eq!(empty.omitted_content_bytes(), 0);
909        assert!(!empty.was_truncated());
910        assert_eq!(empty.to_string(), "<empty>");
911
912        let logs = render_log_records(
913            vec![
914                CanisterLogRecord {
915                    idx: 1,
916                    timestamp_nanos: 2,
917                    content: vec![],
918                },
919                CanisterLogRecord {
920                    idx: 3,
921                    timestamp_nanos: 4,
922                    content: vec![0x80, b'a'],
923                },
924                CanisterLogRecord {
925                    idx: 5,
926                    timestamp_nanos: 6,
927                    content: vec![],
928                },
929            ],
930            limits,
931        );
932        assert_eq!(logs.total_records(), 3);
933        assert_eq!(logs.records().len(), 3);
934        assert_eq!(logs.total_content_bytes(), 2);
935        assert_eq!(logs.records()[0].content(), "");
936        assert_eq!(logs.records()[1].content(), "�a");
937        assert_eq!(logs.records()[2].content(), "");
938        assert_eq!(logs.omitted_records(), 0);
939        assert_eq!(logs.omitted_content_bytes(), 0);
940        assert!(!logs.was_truncated());
941    }
942}