Skip to main content

xlog_cuda/device_runtime/
logging.rs

1//! [`LoggingResource`] — telemetry decorator for any
2//! [`DeviceMemoryResource`].
3//!
4//! Wraps an inner resource and records every `allocate`,
5//! `deallocate`, and `reap_pending` call (success and failure) into
6//! a [`LoggingSink`]. The decorator is fully transparent to allocator
7//! semantics: it forwards every method, never alters the inner
8//! result, and never panics or returns an error caused by the sink
9//! itself. Logging failure is recorded into a per-resource diagnostic
10//! counter (visible via [`LoggingResource::dropped_records`]) and
11//! otherwise silenced — losing a log line must not corrupt
12//! allocation correctness.
13//!
14//! Sink design
15//! -----------
16//! [`LoggingSink`] is a tiny trait with a single `emit` method that
17//! returns `Result<(), SinkError>`. The default in-memory sink
18//! ([`InMemorySink`]) buffers records in a `Mutex<Vec<LogRecord>>`
19//! and is the test workhorse — it lets unit tests assert the exact
20//! sequence of records without filesystem dependencies. A future
21//! `CsvFileSink` or `RingBufferSink` slots in without touching the
22//! decorator.
23//!
24//! Record contents
25//! ---------------
26//! Every emitted [`LogRecord`] carries:
27//!   * `action` — Allocate / Deallocate / ReapPending
28//!   * `device_ordinal`
29//!   * `stream_id` — present for Allocate / Deallocate (the block's
30//!     alloc_stream); absent for ReapPending which spans streams.
31//!   * `ptr` / `bytes` / `tag` / `generation` — present when the
32//!     operation has a corresponding [`DeviceBlock`] reference.
33//!   * `thread_id` — `std::thread::current().id()` rendered as u64
34//!     for portability.
35//!   * `order_counter` — monotonic per-process u64. Strictly
36//!     increasing across all sinks, all threads, all resources.
37//!   * `timestamp_nanos` — `SystemTime::now()` since UNIX epoch in
38//!     nanoseconds. Wall-clock; not monotonic. Use `order_counter`
39//!     for ordering, `timestamp_nanos` for human-readable spans.
40//!   * `result` — Ok or short error tag string.
41//!
42//! Decorator does NOT capture the inner resource's pre-call state
43//! (e.g., bytes_outstanding before allocate) — that would require
44//! locking the inner resource an extra time and is not part of the
45//! v0.6 telemetry contract. Callers that need pre/post diffs read
46//! `bytes_outstanding` themselves.
47
48use std::fmt;
49use std::sync::atomic::{AtomicU64, Ordering};
50use std::sync::Mutex;
51use std::time::{SystemTime, UNIX_EPOCH};
52
53use super::resource::{
54    Access, AllocTag, BlockId, DeviceBlock, DeviceMemoryResource, Generation,
55    ResourceBudgetSnapshot, ResourceError, ResourceResult, StreamId,
56};
57
58/// Action recorded in a [`LogRecord`]. Distinct variants for the
59/// three inner methods so consumers can filter without parsing the
60/// result message.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum LogAction {
63    Allocate,
64    Deallocate,
65    ReapPending,
66}
67
68impl fmt::Display for LogAction {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            LogAction::Allocate => f.write_str("allocate"),
72            LogAction::Deallocate => f.write_str("deallocate"),
73            LogAction::ReapPending => f.write_str("reap_pending"),
74        }
75    }
76}
77
78/// Result status as recorded in a log entry. Successful operations
79/// are recorded as `Ok`; failed ones carry the error variant tag
80/// plus an optional short message. We keep the message bounded so a
81/// runaway driver-error string cannot blow up the sink buffer.
82#[derive(Clone, Debug, Eq, PartialEq)]
83pub enum LogResult {
84    Ok,
85    Err { kind: &'static str, message: String },
86}
87
88impl LogResult {
89    /// Convert a [`ResourceResult`] outcome into a `LogResult`,
90    /// truncating overlong messages to keep records bounded.
91    pub fn from_result<T>(r: &ResourceResult<T>) -> Self {
92        match r {
93            Ok(_) => LogResult::Ok,
94            Err(e) => LogResult::Err {
95                kind: classify_error(e),
96                message: truncate(format!("{}", e), 256),
97            },
98        }
99    }
100}
101
102fn classify_error(e: &ResourceError) -> &'static str {
103    match e {
104        ResourceError::AllocationRetained(_) => "AllocationRetained",
105        ResourceError::OutOfBudget { .. } => "OutOfBudget",
106        ResourceError::Driver(_) => "Driver",
107        ResourceError::StreamMisuse(_) => "StreamMisuse",
108        ResourceError::UseAfterFree { .. } => "UseAfterFree",
109        ResourceError::OutOfBounds { .. } => "OutOfBounds",
110    }
111}
112
113fn truncate(mut s: String, cap: usize) -> String {
114    if s.len() > cap {
115        // String::truncate panics if `cap` is not on a UTF-8 char
116        // boundary. Error messages can include non-ASCII payloads
117        // (driver strings, user tags, etc.), so walk back to the
118        // previous boundary before truncating. This preserves the
119        // decorator's "never panics" guarantee — the logging path
120        // must not crash the allocator.
121        let mut end = cap;
122        while end > 0 && !s.is_char_boundary(end) {
123            end -= 1;
124        }
125        s.truncate(end);
126        s.push('…');
127    }
128    s
129}
130
131/// A single allocation-log entry. Values are owned/`Copy` so the
132/// record is `Clone + Send + Sync` and trivially serializable.
133#[derive(Clone, Debug)]
134pub struct LogRecord {
135    pub action: LogAction,
136    pub device_ordinal: u32,
137    pub stream_id: Option<StreamId>,
138    pub ptr: Option<u64>,
139    pub bytes: Option<usize>,
140    pub tag: Option<AllocTag>,
141    pub generation: Option<Generation>,
142    pub thread_id: u64,
143    pub order_counter: u64,
144    pub timestamp_nanos: u128,
145    pub result: LogResult,
146}
147
148/// Process-wide monotonic counter used for `LogRecord::order_counter`.
149/// Cross-resource, cross-thread strict order — useful for
150/// reconstructing the exact emission sequence in tests with multiple
151/// resources composed.
152static ORDER_COUNTER: AtomicU64 = AtomicU64::new(1);
153
154fn next_order_counter() -> u64 {
155    ORDER_COUNTER.fetch_add(1, Ordering::Relaxed)
156}
157
158fn now_nanos() -> u128 {
159    SystemTime::now()
160        .duration_since(UNIX_EPOCH)
161        .map(|d| d.as_nanos())
162        .unwrap_or(0)
163}
164
165fn current_thread_id_u64() -> u64 {
166    // `ThreadId` does not expose its inner u64 on stable. Hash the
167    // Debug representation instead — stable for the lifetime of the
168    // thread, distinct across threads, and fits in u64.
169    let s = format!("{:?}", std::thread::current().id());
170    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
171    for b in s.as_bytes() {
172        h ^= *b as u64;
173        h = h.wrapping_mul(0x100_0000_01b3);
174    }
175    h
176}
177
178/// Sink-side error. Returned by [`LoggingSink::emit`] when the sink
179/// cannot accept a record (full ring buffer, IO error, etc.). The
180/// decorator catches this and increments
181/// [`LoggingResource::dropped_records`]; it does **not** propagate
182/// the error to the allocator caller.
183#[derive(Debug)]
184pub enum SinkError {
185    /// The sink is closed or full and refused the record.
186    Refused(String),
187    /// IO-backed sinks may surface a wrapped IO error here.
188    Io(String),
189}
190
191impl fmt::Display for SinkError {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        match self {
194            SinkError::Refused(m) => write!(f, "log sink refused record: {}", m),
195            SinkError::Io(m) => write!(f, "log sink io error: {}", m),
196        }
197    }
198}
199
200impl std::error::Error for SinkError {}
201
202/// Trait for log destinations. Implementations are required to be
203/// thread-safe (`Send + Sync`); the decorator may emit from any
204/// thread that calls into the resource.
205pub trait LoggingSink: Send + Sync {
206    /// Accept a record. Implementations must not panic on full /
207    /// closed / IO-error conditions — return [`SinkError`] instead.
208    fn emit(&self, record: LogRecord) -> Result<(), SinkError>;
209}
210
211/// In-memory sink for tests and lightweight use. Records are
212/// appended to a `Vec` under a `Mutex`. `snapshot` returns a clone
213/// so consumers can inspect without holding the mutex.
214pub struct InMemorySink {
215    records: Mutex<Vec<LogRecord>>,
216}
217
218impl InMemorySink {
219    pub fn new() -> Self {
220        Self {
221            records: Mutex::new(Vec::new()),
222        }
223    }
224
225    /// Clone the current record buffer.
226    pub fn snapshot(&self) -> Vec<LogRecord> {
227        self.records.lock().expect("InMemorySink poisoned").clone()
228    }
229
230    /// Drop all buffered records. Useful for tests that want a
231    /// clean slate between phases.
232    pub fn clear(&self) {
233        self.records.lock().expect("InMemorySink poisoned").clear();
234    }
235
236    /// Number of records currently buffered.
237    pub fn len(&self) -> usize {
238        self.records.lock().expect("InMemorySink poisoned").len()
239    }
240
241    pub fn is_empty(&self) -> bool {
242        self.len() == 0
243    }
244}
245
246impl Default for InMemorySink {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252impl LoggingSink for InMemorySink {
253    fn emit(&self, record: LogRecord) -> Result<(), SinkError> {
254        self.records
255            .lock()
256            .expect("InMemorySink poisoned")
257            .push(record);
258        Ok(())
259    }
260}
261
262/// Discard sink: accepts every record and drops it. Use this when
263/// the test or production stack needs the runtime composition to
264/// match `LoggingResource(...)` shape but does **not** need to
265/// retain log records.
266///
267/// `InMemorySink` keeps every record alive for as long as the sink
268/// (and therefore the wrapping `LoggingResource`) lives. In long-
269/// running stress loops that compose
270/// `LoggingResource(InMemorySink)` once and reuse it across many
271/// iterations, that buffer grows unbounded — measurable memory and
272/// CPU overhead even if no test reads the records.
273/// `NullSink` solves that by discarding the record without
274/// allocating; the decorator still constructs the `LogRecord`
275/// (one stack-allocated value per call), but no per-record retention
276/// occurs.
277pub struct NullSink;
278
279impl NullSink {
280    pub fn new() -> Self {
281        Self
282    }
283}
284
285impl Default for NullSink {
286    fn default() -> Self {
287        Self
288    }
289}
290
291impl LoggingSink for NullSink {
292    fn emit(&self, _record: LogRecord) -> Result<(), SinkError> {
293        Ok(())
294    }
295}
296
297/// Telemetry decorator for [`DeviceMemoryResource`].
298pub struct LoggingResource {
299    inner: std::sync::Arc<dyn DeviceMemoryResource + Send + Sync>,
300    sink: std::sync::Arc<dyn LoggingSink>,
301    /// Count of records the sink refused or errored on. Surfaced as
302    /// a diagnostic for callers that want to detect telemetry loss
303    /// without halting the data-plane.
304    dropped_records: AtomicU64,
305}
306
307impl LoggingResource {
308    /// Wrap `inner` with `sink`. Records are emitted on every public
309    /// call; sink failures are counted in `dropped_records`.
310    pub fn new(
311        inner: Box<dyn DeviceMemoryResource + Send + Sync>,
312        sink: std::sync::Arc<dyn LoggingSink>,
313    ) -> Self {
314        Self {
315            inner: std::sync::Arc::from(inner),
316            sink,
317            dropped_records: AtomicU64::new(0),
318        }
319    }
320
321    /// Total records the sink has refused. A nonzero value means
322    /// telemetry was lost; allocator semantics are unaffected.
323    pub fn dropped_records(&self) -> u64 {
324        self.dropped_records.load(Ordering::Relaxed)
325    }
326
327    fn emit(&self, record: LogRecord) {
328        if self.sink.emit(record).is_err() {
329            self.dropped_records.fetch_add(1, Ordering::Relaxed);
330        }
331    }
332
333    fn record_allocation(
334        &self,
335        bytes: usize,
336        stream: StreamId,
337        tag: AllocTag,
338        result: &ResourceResult<crate::memory::ResourceBlockRetirement>,
339    ) {
340        let (ptr, generation, recorded_bytes) = match &result {
341            Ok(owner) => {
342                let block = owner.block();
343                (Some(block.ptr), Some(block.generation), Some(block.bytes))
344            }
345            Err(_) => (None, None, Some(bytes)),
346        };
347        self.emit(LogRecord {
348            action: LogAction::Allocate,
349            device_ordinal: self.inner.device_ordinal(),
350            stream_id: Some(stream),
351            ptr,
352            bytes: recorded_bytes,
353            tag: Some(tag),
354            generation,
355            thread_id: current_thread_id_u64(),
356            order_counter: next_order_counter(),
357            timestamp_nanos: now_nanos(),
358            result: LogResult::from_result(result),
359        });
360    }
361}
362
363impl DeviceMemoryResource for LoggingResource {
364    fn materialize(
365        &self,
366        request: super::resource::AllocationRequest,
367    ) -> ResourceResult<DeviceBlock> {
368        let (bytes, stream, tag) = (request.bytes, request.stream, request.tag);
369        let reclamation = request.reclamation();
370        let result = self.inner.materialize(request).map(|block| {
371            crate::memory::ResourceBlockRetirement::new(
372                block,
373                std::sync::Arc::clone(&self.inner),
374                reclamation,
375            )
376        });
377        self.record_allocation(bytes, stream, tag, &result);
378        result.map(crate::memory::ResourceBlockRetirement::into_block)
379    }
380
381    fn allocation_accounting(&self) -> std::sync::Arc<super::resource::AllocationAccounting> {
382        self.inner.allocation_accounting()
383    }
384
385    fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
386        // Capture identifying fields before the move into inner —
387        // on success the block is consumed and we still need them
388        // for the log record.
389        let ptr = block.ptr;
390        let bytes = block.bytes;
391        let tag = block.tag;
392        let gen = block.generation;
393        let stream = block.alloc_stream;
394        let dev = block.device_ordinal;
395
396        let result = self.inner.deallocate(block);
397        self.emit(LogRecord {
398            action: LogAction::Deallocate,
399            device_ordinal: dev,
400            stream_id: Some(stream),
401            ptr: Some(ptr),
402            bytes: Some(bytes),
403            tag: Some(tag),
404            generation: Some(gen),
405            thread_id: current_thread_id_u64(),
406            order_counter: next_order_counter(),
407            timestamp_nanos: now_nanos(),
408            result: LogResult::from_result(&result),
409        });
410        result
411    }
412
413    fn device_ordinal(&self) -> u32 {
414        self.inner.device_ordinal()
415    }
416
417    fn bytes_outstanding(&self) -> usize {
418        self.inner.bytes_outstanding()
419    }
420
421    fn budget_snapshot(&self) -> Option<ResourceBudgetSnapshot> {
422        self.inner.budget_snapshot()
423    }
424
425    fn reap_pending(&self) -> ResourceResult<()> {
426        let result = self.inner.reap_pending();
427        self.emit(LogRecord {
428            action: LogAction::ReapPending,
429            device_ordinal: self.inner.device_ordinal(),
430            stream_id: None,
431            ptr: None,
432            bytes: None,
433            tag: None,
434            generation: None,
435            thread_id: current_thread_id_u64(),
436            order_counter: next_order_counter(),
437            timestamp_nanos: now_nanos(),
438            result: LogResult::from_result(&result),
439        });
440        result
441    }
442
443    fn record_block_use(&self, block: &DeviceBlock, use_stream: StreamId) -> ResourceResult<()> {
444        // Pass-through to the inner stream-ordered backend.
445        // No log record emitted: the launch builder calls this
446        // potentially many times per launch; recording each
447        // would balloon the log without telling the consumer
448        // anything they couldn't infer from Allocate/Deallocate
449        // pairs. If a future need arises, add a LogAction::Use
450        // variant rather than tagging this onto the existing
451        // shape.
452        self.inner.record_block_use(block, use_stream)
453    }
454
455    fn supports_block_use_tracking(&self) -> bool {
456        self.inner.supports_block_use_tracking()
457    }
458
459    fn access_dependencies(
460        &self,
461        block: BlockId,
462        bytes: usize,
463    ) -> ResourceResult<Option<std::sync::Arc<super::resource::DeviceAccessDependencies>>> {
464        self.inner.access_dependencies(block, bytes)
465    }
466
467    fn prepare_block_use(
468        &self,
469        block: BlockId,
470        use_stream: StreamId,
471        access: Access,
472    ) -> ResourceResult<()> {
473        // Pass-through: same rationale as record_block_use.
474        // Pre-launch waits are an inner-backend concern.
475        self.inner.prepare_block_use(block, use_stream, access)
476    }
477
478    fn finish_block_use(
479        &self,
480        block: BlockId,
481        use_stream: StreamId,
482        access: Access,
483    ) -> ResourceResult<()> {
484        // Pass-through: see prepare_block_use rationale.
485        self.inner.finish_block_use(block, use_stream, access)
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::super::direct::DirectCudaResource;
492    use super::super::resource::BlockState;
493    use super::*;
494    use std::sync::Arc;
495
496    #[test]
497    fn host_logging_panic_retires_the_unpublished_allocation() {
498        struct PanicSink;
499        impl LoggingSink for PanicSink {
500            fn emit(&self, _: LogRecord) -> Result<(), SinkError> {
501                panic!("telemetry publication interrupted");
502            }
503        }
504        let _serial = crate::cuda_graph::capture_lifecycle_test_guard();
505        let inner = super::super::budget::tests::DeferredHostResource {
506            release_on_detach: true,
507            ..Default::default()
508        };
509        let ledger = Arc::clone(&inner.accounting);
510        let owners = Arc::clone(&inner.owners);
511        let logging = LoggingResource::new(Box::new(inner), Arc::new(PanicSink));
512        let budget = super::super::budget::GlobalDeviceBudget::new(Box::new(logging), 64);
513        assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
514            let _ = budget.allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED);
515        }))
516        .is_err());
517        crate::cuda_graph::reap_capture_retirements();
518        assert_eq!(ledger.snapshot(), (0, 64));
519        assert_eq!(budget.reserved_bytes(), 0);
520        assert!(owners.lock().unwrap().is_empty());
521    }
522
523    use crate::CudaDevice;
524
525    fn try_device() -> Option<Arc<CudaDevice>> {
526        CudaDevice::new(0).ok().map(Arc::new)
527    }
528
529    #[test]
530    fn pass_through_alloc_dealloc_emits_two_ok_records() {
531        let Some(device) = try_device() else {
532            return;
533        };
534        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
535        let sink = Arc::new(InMemorySink::new());
536        let r = LoggingResource::new(inner, sink.clone());
537
538        let block = r
539            .allocate(1024, StreamId::DEFAULT, AllocTag("logging-test"))
540            .expect("alloc");
541        assert_eq!(block.bytes, 1024);
542        assert_eq!(block.state, BlockState::Live);
543        r.deallocate(block).expect("dealloc");
544
545        let recs = sink.snapshot();
546        assert_eq!(recs.len(), 2, "expected 2 records, got {:?}", recs);
547        assert_eq!(recs[0].action, LogAction::Allocate);
548        assert_eq!(recs[0].result, LogResult::Ok);
549        assert_eq!(recs[0].bytes, Some(1024));
550        assert_eq!(recs[0].stream_id, Some(StreamId::DEFAULT));
551        assert!(recs[0].ptr.is_some());
552
553        assert_eq!(recs[1].action, LogAction::Deallocate);
554        assert_eq!(recs[1].result, LogResult::Ok);
555        assert_eq!(recs[1].ptr, recs[0].ptr);
556        assert_eq!(recs[1].generation, recs[0].generation);
557    }
558
559    #[test]
560    fn order_counter_strictly_increases_across_records() {
561        let Some(device) = try_device() else {
562            return;
563        };
564        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
565        let sink = Arc::new(InMemorySink::new());
566        let r = LoggingResource::new(inner, sink.clone());
567
568        for _ in 0..4 {
569            let b = r
570                .allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED)
571                .expect("alloc");
572            r.deallocate(b).expect("dealloc");
573        }
574        r.reap_pending().expect("reap");
575
576        let recs = sink.snapshot();
577        assert_eq!(recs.len(), 9); // 4*alloc + 4*dealloc + 1 reap
578        let mut last = 0u64;
579        for rec in &recs {
580            assert!(
581                rec.order_counter > last,
582                "order_counter must strictly increase: prev={}, now={}",
583                last,
584                rec.order_counter
585            );
586            last = rec.order_counter;
587        }
588    }
589
590    #[test]
591    fn failed_alloc_records_error_result() {
592        let Some(device) = try_device() else {
593            return;
594        };
595        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
596        let sink = Arc::new(InMemorySink::new());
597        let r = LoggingResource::new(inner, sink.clone());
598
599        // Zero-byte alloc fails per resource contract.
600        let _ = r.allocate(0, StreamId::DEFAULT, AllocTag::UNTAGGED);
601        let recs = sink.snapshot();
602        assert_eq!(recs.len(), 1);
603        assert_eq!(recs[0].action, LogAction::Allocate);
604        assert!(matches!(recs[0].result, LogResult::Err { kind, .. } if kind == "Driver"));
605        // Failed allocs still record the requested byte count.
606        assert_eq!(recs[0].bytes, Some(0));
607        assert!(recs[0].ptr.is_none());
608        assert!(recs[0].generation.is_none());
609    }
610
611    #[test]
612    fn failed_dealloc_records_error_result() {
613        let Some(device) = try_device() else {
614            return;
615        };
616        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
617        let sink = Arc::new(InMemorySink::new());
618        let r = LoggingResource::new(inner, sink.clone());
619
620        let bogus = DeviceBlock {
621            ptr: 0xdead_beef,
622            device_ordinal: 0,
623            alloc_stream: StreamId::DEFAULT,
624            bytes: 16,
625            align: 1,
626            tag: AllocTag::UNTAGGED,
627            generation: Generation::next(),
628            state: BlockState::Live,
629        };
630        let res = r.deallocate(bogus);
631        assert!(res.is_err());
632        let recs = sink.snapshot();
633        assert_eq!(recs.len(), 1);
634        assert_eq!(recs[0].action, LogAction::Deallocate);
635        assert!(matches!(recs[0].result, LogResult::Err { kind, .. } if kind == "UseAfterFree"));
636        assert_eq!(recs[0].ptr, Some(0xdead_beef));
637    }
638
639    #[test]
640    fn reap_pending_emits_record() {
641        let Some(device) = try_device() else {
642            return;
643        };
644        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
645        let sink = Arc::new(InMemorySink::new());
646        let r = LoggingResource::new(inner, sink.clone());
647
648        r.reap_pending().expect("reap");
649        let recs = sink.snapshot();
650        assert_eq!(recs.len(), 1);
651        assert_eq!(recs[0].action, LogAction::ReapPending);
652        assert_eq!(recs[0].result, LogResult::Ok);
653        assert!(recs[0].stream_id.is_none());
654        assert!(recs[0].ptr.is_none());
655    }
656
657    #[test]
658    fn sink_failure_increments_dropped_records_but_does_not_break_alloc() {
659        // Custom sink that always refuses. Verifies LoggingResource
660        // never propagates sink errors and counts losses.
661        struct RefuseAllSink;
662        impl LoggingSink for RefuseAllSink {
663            fn emit(&self, _r: LogRecord) -> Result<(), SinkError> {
664                Err(SinkError::Refused("test sink refuses all".into()))
665            }
666        }
667
668        let Some(device) = try_device() else {
669            return;
670        };
671        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
672        let sink = Arc::new(RefuseAllSink);
673        let r = LoggingResource::new(inner, sink);
674
675        // Allocator semantics are still correct.
676        let block = r
677            .allocate(128, StreamId::DEFAULT, AllocTag("refuse-test"))
678            .expect("alloc must succeed even when sink refuses");
679        r.deallocate(block).expect("dealloc must succeed too");
680        // Two refused records (alloc + dealloc).
681        assert_eq!(r.dropped_records(), 2);
682    }
683
684    #[test]
685    fn forwards_bytes_outstanding_and_device_ordinal() {
686        let Some(device) = try_device() else {
687            return;
688        };
689        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 3));
690        let sink = Arc::new(InMemorySink::new());
691        let r = LoggingResource::new(inner, sink);
692
693        assert_eq!(r.device_ordinal(), 3);
694        assert_eq!(r.bytes_outstanding(), 0);
695    }
696
697    #[test]
698    fn truncate_handles_non_ascii_at_cap_without_panicking() {
699        // `String::truncate(cap)` would panic if `cap` lands inside
700        // a multibyte UTF-8 sequence; the boundary-safe `truncate`
701        // helper must walk back to the previous boundary and
702        // succeed instead. Regression for the logging-path
703        // panic the decorator's "never panics on telemetry"
704        // contract requires.
705        // "héllo": h, é (2 bytes), l, l, o = 6 bytes total. Cap=2
706        // would slice into é if naive.
707        let s = String::from("héllo");
708        let out = truncate(s, 2);
709        assert!(out.starts_with('h'));
710        assert!(out.ends_with('…'));
711
712        // Cap larger than string is a no-op.
713        let out = truncate(String::from("ok"), 100);
714        assert_eq!(out, "ok");
715
716        // Cap = 0 produces just the ellipsis.
717        let out = truncate(String::from("héllo"), 0);
718        assert_eq!(out, "…");
719
720        // Cap on a valid char boundary does not regress the simple
721        // path.
722        let out = truncate(String::from("abcdefgh"), 3);
723        assert_eq!(out, "abc…");
724    }
725
726    #[test]
727    fn null_sink_accepts_records_without_retention() {
728        let sink = NullSink::new();
729        let rec = LogRecord {
730            action: LogAction::Allocate,
731            device_ordinal: 0,
732            stream_id: Some(StreamId::DEFAULT),
733            ptr: Some(0xdead_beef),
734            bytes: Some(64),
735            tag: Some(AllocTag::UNTAGGED),
736            generation: Some(Generation::next()),
737            thread_id: 0,
738            order_counter: 1,
739            timestamp_nanos: 0,
740            result: LogResult::Ok,
741        };
742        sink.emit(rec).expect("NullSink never errors");
743        // No retention surface to inspect — the contract is that
744        // emit succeeded and nothing else happened.
745    }
746}