Skip to main content

incular_runtime/
app_log.rs

1//! Application log records bound for the DevTools console.
2//!
3//! [`log`] is callable from any thread. With the `devtools` feature it appends
4//! to a bounded process-wide queue (drop-oldest, counted) that the desktop host
5//! drains on the UI thread; without the feature every call is a no-op.
6
7use std::fmt;
8
9/// Maximum queued records before the oldest is dropped.
10pub const LOG_QUEUE_CAPACITY: usize = 1024;
11/// Maximum message length in bytes; longer messages are truncated.
12pub const LOG_MESSAGE_LIMIT: usize = 4096;
13
14/// Severity of an application log record.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum LogLevel {
17    Debug,
18    Info,
19    Warn,
20    Error,
21}
22
23impl LogLevel {
24    /// Lowercase wire name understood by the DevTools console.
25    #[must_use]
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::Debug => "debug",
29            Self::Info => "info",
30            Self::Warn => "warn",
31            Self::Error => "error",
32        }
33    }
34}
35
36impl fmt::Display for LogLevel {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(self.as_str())
39    }
40}
41
42/// One queued application log record.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct LogRecord {
45    pub level: LogLevel,
46    pub target: String,
47    pub message: String,
48}
49
50/// Records drained from the queue plus how many were dropped since the last drain.
51#[derive(Clone, Debug, Default, PartialEq, Eq)]
52pub struct LogBatch {
53    pub records: Vec<LogRecord>,
54    pub dropped: u64,
55}
56
57/// Bounded drop-oldest queue. Public so hosts and tests can own private instances.
58#[derive(Debug)]
59pub struct LogQueue {
60    capacity: usize,
61    records: std::collections::VecDeque<LogRecord>,
62    dropped: u64,
63}
64
65impl LogQueue {
66    #[must_use]
67    pub fn new(capacity: usize) -> Self {
68        Self {
69            capacity: capacity.max(1),
70            records: std::collections::VecDeque::new(),
71            dropped: 0,
72        }
73    }
74
75    /// Appends a record, truncating its message and dropping the oldest entry
76    /// when full.
77    pub fn push(&mut self, level: LogLevel, target: &str, message: &str) {
78        if self.records.len() >= self.capacity {
79            self.records.pop_front();
80            self.dropped = self.dropped.saturating_add(1);
81        }
82        self.records.push_back(LogRecord {
83            level,
84            target: truncate(target, 256).to_owned(),
85            message: truncate(message, LOG_MESSAGE_LIMIT).to_owned(),
86        });
87    }
88
89    /// Takes at most `max` oldest records; the dropped counter is reported once.
90    pub fn drain(&mut self, max: usize) -> LogBatch {
91        let take = max.min(self.records.len());
92        LogBatch {
93            records: self.records.drain(..take).collect(),
94            dropped: std::mem::take(&mut self.dropped),
95        }
96    }
97
98    #[must_use]
99    pub fn len(&self) -> usize {
100        self.records.len()
101    }
102
103    #[must_use]
104    pub fn is_empty(&self) -> bool {
105        self.records.is_empty()
106    }
107}
108
109/// Truncates to at most `limit` bytes on a character boundary.
110fn truncate(text: &str, limit: usize) -> &str {
111    if text.len() <= limit {
112        return text;
113    }
114    let mut end = limit;
115    while !text.is_char_boundary(end) {
116        end -= 1;
117    }
118    &text[..end]
119}
120
121#[cfg(feature = "devtools")]
122mod global {
123    use super::{LOG_QUEUE_CAPACITY, LogBatch, LogLevel, LogQueue};
124    use std::sync::{
125        Arc, Mutex, OnceLock,
126        atomic::{AtomicBool, Ordering},
127    };
128
129    type Wake = Arc<dyn Fn() + Send + Sync>;
130
131    struct Global {
132        queue: Mutex<LogQueue>,
133        wake: Mutex<Option<Wake>>,
134        wake_pending: AtomicBool,
135    }
136
137    fn global() -> &'static Global {
138        static GLOBAL: OnceLock<Global> = OnceLock::new();
139        GLOBAL.get_or_init(|| Global {
140            queue: Mutex::new(LogQueue::new(LOG_QUEUE_CAPACITY)),
141            wake: Mutex::new(None),
142            wake_pending: AtomicBool::new(false),
143        })
144    }
145
146    pub fn log(level: LogLevel, target: &str, message: &str) {
147        let global = global();
148        if let Ok(mut queue) = global.queue.lock() {
149            queue.push(level, target, message);
150        }
151        // Coalesce: one wake per drain, so a burst posts a single event.
152        if !global.wake_pending.swap(true, Ordering::AcqRel) {
153            let wake = global.wake.lock().ok().and_then(|wake| wake.clone());
154            if let Some(wake) = wake {
155                wake();
156            }
157        }
158    }
159
160    pub fn set_wake(wake: Wake) {
161        let global = global();
162        if let Ok(mut slot) = global.wake.lock() {
163            *slot = Some(wake);
164        }
165        // Wake once for records logged before the host registered.
166        global.wake_pending.store(false, Ordering::Release);
167        if !global.queue.lock().map(|q| q.is_empty()).unwrap_or(true) {
168            global.wake_pending.store(true, Ordering::Release);
169            if let Some(wake) = global.wake.lock().ok().and_then(|w| w.clone()) {
170                wake();
171            }
172        }
173    }
174
175    pub fn drain(max: usize) -> LogBatch {
176        let global = global();
177        // Clear before taking so a record racing the drain re-arms the wake.
178        global.wake_pending.store(false, Ordering::Release);
179        let (batch, remaining) = global
180            .queue
181            .lock()
182            .map(|mut queue| (queue.drain(max), !queue.is_empty()))
183            .unwrap_or_default();
184        if remaining
185            && !global.wake_pending.swap(true, Ordering::AcqRel)
186            && let Some(wake) = global.wake.lock().ok().and_then(|w| w.clone())
187        {
188            wake();
189        }
190        batch
191    }
192
193    pub fn pending() -> bool {
194        global()
195            .queue
196            .lock()
197            .map(|q| !q.is_empty())
198            .unwrap_or(false)
199    }
200}
201
202/// Queues an application log record for the DevTools console. Never blocks on
203/// I/O, allocates a bounded amount, and is safe from any thread. No-op without
204/// the `devtools` feature.
205pub fn log(level: LogLevel, target: &str, message: impl AsRef<str>) {
206    #[cfg(feature = "devtools")]
207    global::log(level, target, message.as_ref());
208    #[cfg(not(feature = "devtools"))]
209    let _ = (level, target, message);
210}
211
212/// Registers the host callback that wakes the event loop when records are
213/// queued. Backend API for desktop hosts.
214#[cfg(feature = "devtools")]
215pub fn set_log_wake(wake: std::sync::Arc<dyn Fn() + Send + Sync>) {
216    global::set_wake(wake);
217}
218
219/// Drains up to `max` queued records from the process-wide queue. Backend API
220/// for desktop hosts.
221#[cfg(feature = "devtools")]
222#[must_use]
223pub fn drain_logs(max: usize) -> LogBatch {
224    global::drain(max)
225}
226
227/// True while records remain queued. Backend API for desktop hosts.
228#[cfg(feature = "devtools")]
229#[must_use]
230pub fn logs_pending() -> bool {
231    global::pending()
232}