Skip to main content

ax_runtime/
console.rs

1//! Sleepable task-context access to the runtime-owned physical console.
2//!
3//! This layer owns console selection and handoff. It deliberately exposes raw
4//! receive items and complete log records; line discipline and terminal ABI
5//! policy remain in the consuming OS.
6
7use alloc::{boxed::Box, sync::Arc};
8use core::fmt::{self, Write};
9
10use ax_lazyinit::OnceLock;
11use ax_sync::Mutex;
12use axpoll::PollSet;
13
14pub use crate::serial::RxItem;
15use crate::{RuntimeError, RuntimeResult, raw_console::RawConsoleInput, serial, sync::SpinLock};
16
17static ACTIVATION: OnceLock<ConsoleActivation> = OnceLock::new();
18static TTY_NUMBERS: OnceLock<Box<[Option<usize>]>> = OnceLock::new();
19// Task writers take these in order. Log publishers take only the hardware
20// lock, so early CPUs and interrupt context never touch task-owned state.
21static RAW_OUTPUT_LOCK: Mutex<()> = Mutex::new(());
22static RAW_HARDWARE_LOCK: SpinLock<()> = SpinLock::new(());
23static RAW_OUTPUT_SOURCE: OnceLock<Arc<PollSet>> = OnceLock::new();
24
25/// Result of selecting the firmware console before secondary CPUs start.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub(crate) enum ConsoleActivation {
28    /// One serial runtime owns the physical console.
29    Active {
30        runtime_index: usize,
31        tty_number: usize,
32    },
33    /// No compatible runtime UART was discovered; the HAL console remains the
34    /// sole owner.
35    RawHal(ConsoleUnavailable),
36    /// No runtime may use the former early console after this point.
37    FailedClosed(ConsoleUnavailable),
38}
39
40/// Why no task-context serial console was activated.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub(crate) enum ConsoleUnavailable {
43    NoSerialDevice,
44    NoHardwareSelected,
45    SelectedDeviceNotFound,
46    NoTtyS0Fallback,
47    HandoffFailed,
48    RuntimeAdoptFailed,
49    LogRoutingBusy,
50}
51
52/// Selects, starts, and commits the sole runtime console before SMP startup.
53pub(crate) fn activate_before_smp() -> ConsoleActivation {
54    if let Some(activation) = ACTIVATION.get().copied() {
55        return activation;
56    }
57
58    let runtimes = serial::runtimes();
59    let tty_numbers = initialize_tty_numbers(runtimes);
60    let selection = select_runtime(runtimes, tty_numbers, ax_hal::console::device_id());
61    let (runtime_index, tty_number) = match selection {
62        Ok(selection) => selection,
63        Err(unavailable) => return use_raw_hal(unavailable),
64    };
65
66    let runtime = &runtimes[runtime_index];
67    if runtime.begin_console_handoff().is_err() {
68        return fail_closed(runtime, ConsoleUnavailable::HandoffFailed);
69    }
70    if runtime.adopt_prepared_console().is_err() {
71        return fail_closed(runtime, ConsoleUnavailable::RuntimeAdoptFailed);
72    }
73    if let Err(error) = runtime.commit_console_handoff() {
74        let reason = if error == RuntimeError::SerialConsoleBusy {
75            ConsoleUnavailable::LogRoutingBusy
76        } else {
77            ConsoleUnavailable::HandoffFailed
78        };
79        return fail_closed(runtime, reason);
80    }
81
82    let activation = ConsoleActivation::Active {
83        runtime_index,
84        tty_number,
85    };
86    ACTIVATION.call_once(|| activation);
87    activation
88}
89
90fn use_raw_hal(reason: ConsoleUnavailable) -> ConsoleActivation {
91    let activation = raw_hal_activation(reason);
92    ACTIVATION.call_once(|| activation);
93    activation
94}
95
96const fn raw_hal_activation(reason: ConsoleUnavailable) -> ConsoleActivation {
97    ConsoleActivation::RawHal(reason)
98}
99
100fn fail_closed(
101    runtime: &serial::SerialRuntimeHandle,
102    reason: ConsoleUnavailable,
103) -> ConsoleActivation {
104    runtime.fail_console_closed();
105    ax_hal::console::fail_runtime_handoff_closed();
106    let activation = ConsoleActivation::FailedClosed(reason);
107    ACTIVATION.call_once(|| activation);
108    activation
109}
110
111fn select_runtime(
112    runtimes: &[serial::SerialRuntimeHandle],
113    tty_numbers: &[Option<usize>],
114    selected: ax_hal::console::ConsoleDeviceIdResult,
115) -> Result<(usize, usize), ConsoleUnavailable> {
116    let candidates = runtimes
117        .iter()
118        .zip(tty_numbers.iter().copied())
119        .map(|(runtime, tty_number)| (runtime.info().device_id, tty_number))
120        .collect::<alloc::vec::Vec<_>>();
121    select_candidate(&candidates, selected)
122}
123
124fn select_candidate(
125    candidates: &[(ax_hal::console::ConsoleDeviceId, Option<usize>)],
126    selected: ax_hal::console::ConsoleDeviceIdResult,
127) -> Result<(usize, usize), ConsoleUnavailable> {
128    if candidates.is_empty() {
129        return Err(ConsoleUnavailable::NoSerialDevice);
130    }
131    match selected {
132        Ok(device_id) => candidates
133            .iter()
134            .position(|(candidate, _)| *candidate == device_id)
135            .and_then(|index| Some((index, candidates[index].1?)))
136            .ok_or(ConsoleUnavailable::SelectedDeviceNotFound),
137        Err(ax_hal::console::ConsoleDeviceIdError::NotSpecified) => candidates
138            .iter()
139            .position(|(_, number)| *number == Some(0))
140            .map(|index| (index, 0))
141            .ok_or(ConsoleUnavailable::NoTtyS0Fallback),
142        Err(ax_hal::console::ConsoleDeviceIdError::NoHardwareDevice) => {
143            Err(ConsoleUnavailable::NoHardwareSelected)
144        }
145        Err(ax_hal::console::ConsoleDeviceIdError::DeviceNotFound) => {
146            Err(ConsoleUnavailable::SelectedDeviceNotFound)
147        }
148    }
149}
150
151/// Returns the Linux-compatible `ttyS` number assigned to a serial runtime.
152pub fn tty_number(runtime: &serial::SerialRuntimeHandle) -> Option<usize> {
153    let index = serial::runtimes()
154        .iter()
155        .position(|candidate| candidate.info().device_id == runtime.info().device_id)?;
156    TTY_NUMBERS.get()?.get(index).copied().flatten()
157}
158
159fn initialize_tty_numbers(runtimes: &[serial::SerialRuntimeHandle]) -> &'static [Option<usize>] {
160    TTY_NUMBERS.call_once(|| {
161        assign_tty_numbers(
162            &runtimes
163                .iter()
164                .map(|runtime| runtime.info().alias_index)
165                .collect::<alloc::vec::Vec<_>>(),
166        )
167        .into_boxed_slice()
168    })
169}
170
171fn activation() -> Option<ConsoleActivation> {
172    ACTIVATION.get().copied()
173}
174
175/// Returns whether this runtime owns the active physical console.
176pub fn is_active(runtime: &serial::SerialRuntimeHandle) -> bool {
177    serial::active_console()
178        .is_some_and(|active| active.info().device_id == runtime.info().device_id)
179}
180
181fn inactive_console_error(activation: Option<ConsoleActivation>) -> RuntimeError {
182    match activation {
183        Some(ConsoleActivation::FailedClosed(_)) | Some(ConsoleActivation::Active { .. }) => {
184            RuntimeError::ConsoleFailedClosed
185        }
186        Some(ConsoleActivation::RawHal(_)) | None => RuntimeError::SerialNotStarted,
187    }
188}
189
190/// Takes the unique raw input capability for the active console.
191pub fn take_input() -> RuntimeResult<TaskConsoleInput> {
192    if let Some(runtime) = serial::active_console() {
193        return runtime
194            .take_rx_subscription()
195            .map(|inner| TaskConsoleInput {
196                inner: TaskConsoleInputInner::Runtime(inner),
197            })
198            .ok_or(RuntimeError::SerialConsoleBusy);
199    }
200    match activation() {
201        Some(ConsoleActivation::RawHal(_)) => Ok(TaskConsoleInput {
202            inner: TaskConsoleInputInner::RawHal(crate::raw_console::take_input()?),
203        }),
204        activation => Err(inactive_console_error(activation)),
205    }
206}
207
208/// Returns a cloneable output capability for the active console.
209pub fn output() -> RuntimeResult<TaskConsoleOutput> {
210    if let Some(runtime) = serial::active_console() {
211        return Ok(TaskConsoleOutput {
212            inner: TaskConsoleOutputInner::Runtime(runtime.task_output()),
213        });
214    }
215    match activation() {
216        Some(ConsoleActivation::RawHal(_)) => Ok(TaskConsoleOutput {
217            inner: TaskConsoleOutputInner::RawHal,
218        }),
219        activation => Err(inactive_console_error(activation)),
220    }
221}
222
223/// Takes the unique complete-log-record subscription for the active console.
224pub fn subscribe_logs() -> RuntimeResult<ConsoleLogSubscription> {
225    let runtime = serial::active_console().ok_or_else(|| match activation() {
226        Some(ConsoleActivation::RawHal(_)) => RuntimeError::OperationNotSupported,
227        activation => inactive_console_error(activation),
228    })?;
229    runtime
230        .take_log_subscription()
231        .map(|inner| ConsoleLogSubscription { inner })
232        .ok_or(RuntimeError::SerialConsoleBusy)
233}
234
235/// Serializes one ordinary log record with raw-HAL task output when no runtime
236/// UART exists. Failed-closed ownership consumes the record without touching
237/// the former early console.
238pub(crate) fn try_publish_without_runtime(
239    args: fmt::Arguments<'_>,
240) -> Option<ax_log::PublishStatus> {
241    match activation()? {
242        ConsoleActivation::RawHal(_) => {
243            // Logging can run on a secondary CPU before its scheduler has
244            // installed a current task, or from interrupt context. A
245            // sleepable mutex is therefore never a valid record arbiter.
246            Some(publish_raw_record(args, &mut RawHalWriter))
247        }
248        ConsoleActivation::Active { .. } | ConsoleActivation::FailedClosed(_) => {
249            Some(ax_log::PublishStatus::Dropped)
250        }
251    }
252}
253
254fn publish_raw_record(args: fmt::Arguments<'_>, writer: &mut impl Write) -> ax_log::PublishStatus {
255    let Some(_hardware) = RAW_HARDWARE_LOCK.try_lock_irqsave() else {
256        return ax_log::PublishStatus::Dropped;
257    };
258    if writer.write_fmt(args).is_ok() {
259        ax_log::PublishStatus::Published
260    } else {
261        ax_log::PublishStatus::Dropped
262    }
263}
264
265/// Unique raw RX capability. It performs no CR/LF or terminal transformation.
266pub struct TaskConsoleInput {
267    inner: TaskConsoleInputInner,
268}
269
270enum TaskConsoleInputInner {
271    Runtime(serial::SerialRxSubscription),
272    RawHal(RawConsoleInput),
273}
274
275impl TaskConsoleInput {
276    pub fn try_read(&self, out: &mut [RxItem]) -> usize {
277        match &self.inner {
278            TaskConsoleInputInner::Runtime(inner) => inner.drain(out),
279            TaskConsoleInputInner::RawHal(inner) => inner.try_read(out),
280        }
281    }
282
283    pub fn wait_readable(&self) -> RuntimeResult {
284        match &self.inner {
285            TaskConsoleInputInner::Runtime(inner) => inner.wait_readable(),
286            TaskConsoleInputInner::RawHal(inner) => {
287                inner.wait_readable();
288                Ok(())
289            }
290        }
291    }
292
293    pub fn read(&self, out: &mut [RxItem]) -> RuntimeResult<usize> {
294        if out.is_empty() {
295            return Ok(0);
296        }
297        loop {
298            let read = self.try_read(out);
299            if read != 0 {
300                return Ok(read);
301            }
302            self.wait_readable()?;
303        }
304    }
305
306    pub fn discard_pending(&self) -> RuntimeResult {
307        match &self.inner {
308            TaskConsoleInputInner::Runtime(inner) => inner.discard_pending(),
309            TaskConsoleInputInner::RawHal(inner) => {
310                inner.discard_pending();
311                Ok(())
312            }
313        }
314    }
315
316    pub fn poll_source(&self) -> Arc<PollSet> {
317        match &self.inner {
318            TaskConsoleInputInner::Runtime(inner) => inner.poll_source(),
319            TaskConsoleInputInner::RawHal(inner) => inner.poll_source(),
320        }
321    }
322
323    /// Sleeps until either RX or a complete subscribed log record is ready.
324    pub fn wait_event(&self, logs: &ConsoleLogSubscription) -> RuntimeResult {
325        match &self.inner {
326            TaskConsoleInputInner::Runtime(inner) => inner.wait_console_event(&logs.inner),
327            TaskConsoleInputInner::RawHal(inner) => {
328                inner.wait_readable();
329                Ok(())
330            }
331        }
332    }
333}
334
335fn raw_output_source() -> Arc<PollSet> {
336    RAW_OUTPUT_SOURCE
337        .call_once(|| Arc::new(PollSet::new()))
338        .clone()
339}
340
341/// Cloneable output capability. All clones share one sleepable output lock.
342#[derive(Clone)]
343pub struct TaskConsoleOutput {
344    inner: TaskConsoleOutputInner,
345}
346
347#[derive(Clone)]
348enum TaskConsoleOutputInner {
349    Runtime(serial::SerialTaskOutput),
350    RawHal,
351}
352
353impl TaskConsoleOutput {
354    pub fn try_write(&self, bytes: &[u8]) -> RuntimeResult<usize> {
355        match &self.inner {
356            TaskConsoleOutputInner::Runtime(inner) => inner.try_write(bytes),
357            TaskConsoleOutputInner::RawHal => {
358                let Some(_output) = RAW_OUTPUT_LOCK.try_lock() else {
359                    return Err(RuntimeError::WouldBlock);
360                };
361                let Some(_hardware) = RAW_HARDWARE_LOCK.try_lock_irqsave() else {
362                    return Err(RuntimeError::WouldBlock);
363                };
364                ax_hal::console::write_bytes(bytes);
365                Ok(bytes.len())
366            }
367        }
368    }
369
370    pub fn write_all(&self, bytes: &[u8]) -> RuntimeResult<usize> {
371        match &self.inner {
372            TaskConsoleOutputInner::Runtime(inner) => inner.write_all(bytes),
373            TaskConsoleOutputInner::RawHal => {
374                let _output = RAW_OUTPUT_LOCK.lock();
375                let _hardware = RAW_HARDWARE_LOCK.lock_irqsave();
376                ax_hal::console::write_bytes(bytes);
377                Ok(bytes.len())
378            }
379        }
380    }
381
382    pub fn write_text_all(&self, bytes: &[u8]) -> RuntimeResult<usize> {
383        match &self.inner {
384            TaskConsoleOutputInner::Runtime(inner) => inner.write_text_all(bytes),
385            TaskConsoleOutputInner::RawHal => {
386                let _output = RAW_OUTPUT_LOCK.lock();
387                let _hardware = RAW_HARDWARE_LOCK.lock_irqsave();
388                ax_hal::console::write_text_bytes(bytes);
389                Ok(bytes.len())
390            }
391        }
392    }
393
394    pub fn write_fmt(&self, args: fmt::Arguments<'_>) -> fmt::Result {
395        match &self.inner {
396            TaskConsoleOutputInner::Runtime(inner) => inner.write_fmt(args),
397            TaskConsoleOutputInner::RawHal => {
398                let _output = RAW_OUTPUT_LOCK.lock();
399                let _hardware = RAW_HARDWARE_LOCK.lock_irqsave();
400                RawHalWriter.write_fmt(args)
401            }
402        }
403    }
404
405    pub fn drain(&self) -> RuntimeResult {
406        match &self.inner {
407            TaskConsoleOutputInner::Runtime(inner) => inner.wait_idle(),
408            TaskConsoleOutputInner::RawHal => {
409                let _output = RAW_OUTPUT_LOCK.lock();
410                let _hardware = RAW_HARDWARE_LOCK.lock_irqsave();
411                Ok(())
412            }
413        }
414    }
415
416    pub fn discard_pending(&self) -> RuntimeResult {
417        match &self.inner {
418            TaskConsoleOutputInner::Runtime(inner) => inner.discard_pending(),
419            TaskConsoleOutputInner::RawHal => {
420                let _output = RAW_OUTPUT_LOCK.lock();
421                let _hardware = RAW_HARDWARE_LOCK.lock_irqsave();
422                Ok(())
423            }
424        }
425    }
426
427    /// Serializes an optional drain/configuration transaction with all writers.
428    pub fn reconfigure(
429        &self,
430        config: Option<serial::Config>,
431        drain: bool,
432        publish: impl FnOnce(),
433    ) -> RuntimeResult {
434        match &self.inner {
435            TaskConsoleOutputInner::Runtime(inner) => inner.reconfigure(config, drain, publish),
436            TaskConsoleOutputInner::RawHal => {
437                let _output = RAW_OUTPUT_LOCK.lock();
438                let _hardware = RAW_HARDWARE_LOCK.lock_irqsave();
439                if config.is_some() {
440                    return Err(RuntimeError::OperationNotSupported);
441                }
442                let _ = drain;
443                publish();
444                Ok(())
445            }
446        }
447    }
448
449    pub fn poll_source(&self) -> Arc<PollSet> {
450        match &self.inner {
451            TaskConsoleOutputInner::Runtime(inner) => inner.poll_source(),
452            TaskConsoleOutputInner::RawHal => raw_output_source(),
453        }
454    }
455}
456
457struct RawHalWriter;
458
459impl Write for RawHalWriter {
460    fn write_str(&mut self, text: &str) -> fmt::Result {
461        ax_hal::console::write_text_bytes(text.as_bytes());
462        Ok(())
463    }
464}
465
466/// One complete record produced by the shared kernel logger.
467pub struct ConsoleLogRecord {
468    inner: serial::LogRecord,
469}
470
471impl ConsoleLogRecord {
472    pub fn bytes(&self) -> &[u8] {
473        self.inner.bytes()
474    }
475
476    pub fn cpu_id(&self) -> usize {
477        self.inner.cpu_id()
478    }
479
480    pub fn timestamp_nanos(&self) -> u64 {
481        self.inner.timestamp_nanos()
482    }
483
484    pub fn task_id(&self) -> Option<u64> {
485        self.inner.task_id()
486    }
487
488    pub fn is_truncated(&self) -> bool {
489        self.inner.is_truncated()
490    }
491
492    pub fn is_log(&self) -> bool {
493        self.inner.kind() == serial::LogRecordKind::Log
494    }
495}
496
497/// Records discarded because the subscriber did not keep up.
498#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
499pub struct ConsoleLogDropReport {
500    pub records: usize,
501    pub source_bytes: usize,
502}
503
504/// Unique optional complete-record log subscription.
505pub struct ConsoleLogSubscription {
506    inner: serial::SerialLogSubscription,
507}
508
509impl ConsoleLogSubscription {
510    pub fn try_read(&self) -> Option<ConsoleLogRecord> {
511        self.inner
512            .try_read()
513            .map(|inner| ConsoleLogRecord { inner })
514    }
515
516    pub fn dropped(&self) -> ConsoleLogDropReport {
517        let (records, source_bytes) = self.inner.dropped();
518        ConsoleLogDropReport {
519            records,
520            source_bytes,
521        }
522    }
523
524    pub fn wait_readable(&self) -> RuntimeResult {
525        self.inner.wait_readable()
526    }
527}
528
529fn assign_tty_numbers(alias_indices: &[Option<usize>]) -> alloc::vec::Vec<Option<usize>> {
530    let mut assigned = alloc::vec![None; alias_indices.len()];
531    let mut used = alloc::vec::Vec::new();
532
533    for (device_index, alias) in alias_indices.iter().copied().enumerate() {
534        let Some(number) = alias else {
535            continue;
536        };
537        if used.contains(&number) {
538            continue;
539        }
540        assigned[device_index] = Some(number);
541        used.push(number);
542    }
543
544    let mut next = 0usize;
545    for number in &mut assigned {
546        if number.is_some() {
547            continue;
548        }
549        while used.contains(&next) {
550            next += 1;
551        }
552        *number = Some(next);
553        used.push(next);
554    }
555    assigned
556}
557
558#[cfg(test)]
559mod tests {
560    use ax_hal::console::ConsoleDeviceIdError;
561
562    use super::{
563        ACTIVATION, ConsoleActivation, ConsoleUnavailable, RAW_OUTPUT_LOCK, assign_tty_numbers,
564        inactive_console_error, output, publish_raw_record, raw_hal_activation, select_candidate,
565        take_input,
566    };
567    use crate::RuntimeError;
568
569    #[test]
570    fn tty_numbering_preserves_aliases_and_fills_gaps() {
571        assert_eq!(
572            assign_tty_numbers(&[Some(0), None, Some(2), None]),
573            [Some(0), Some(1), Some(2), Some(3)]
574        );
575        assert_eq!(
576            assign_tty_numbers(&[Some(1), Some(1), None]),
577            [Some(1), Some(0), Some(2)]
578        );
579    }
580
581    #[test]
582    fn firmware_device_id_wins_over_ttys0() {
583        let tty_s0 = rdrive::DeviceId::from(10);
584        let tty_s1 = rdrive::DeviceId::from(11);
585        assert_eq!(
586            select_candidate(&[(tty_s0, Some(0)), (tty_s1, Some(1))], Ok(tty_s1)),
587            Ok((1, 1))
588        );
589    }
590
591    #[test]
592    fn only_not_specified_falls_back_to_ttys0() {
593        let tty_s0 = rdrive::DeviceId::from(10);
594        let candidates = [(tty_s0, Some(0))];
595        assert_eq!(
596            select_candidate(&candidates, Err(ConsoleDeviceIdError::NotSpecified)),
597            Ok((0, 0))
598        );
599        assert_eq!(
600            select_candidate(&candidates, Err(ConsoleDeviceIdError::NoHardwareDevice)),
601            Err(ConsoleUnavailable::NoHardwareSelected)
602        );
603        assert_eq!(
604            select_candidate(&candidates, Err(ConsoleDeviceIdError::DeviceNotFound)),
605            Err(ConsoleUnavailable::SelectedDeviceNotFound)
606        );
607    }
608
609    #[test]
610    fn missing_hardware_and_ttys0_are_unavailable_for_runtime_selection() {
611        let tty_s1 = rdrive::DeviceId::from(11);
612        assert_eq!(
613            select_candidate(
614                &[(tty_s1, Some(1))],
615                Err(ConsoleDeviceIdError::NotSpecified)
616            ),
617            Err(ConsoleUnavailable::NoTtyS0Fallback)
618        );
619        assert_eq!(
620            select_candidate(&[], Err(ConsoleDeviceIdError::NotSpecified)),
621            Err(ConsoleUnavailable::NoSerialDevice)
622        );
623    }
624
625    #[test]
626    fn unavailable_runtime_selection_keeps_the_raw_hal_owner() {
627        for reason in [
628            ConsoleUnavailable::NoSerialDevice,
629            ConsoleUnavailable::NoHardwareSelected,
630            ConsoleUnavailable::SelectedDeviceNotFound,
631            ConsoleUnavailable::NoTtyS0Fallback,
632        ] {
633            assert_eq!(
634                raw_hal_activation(reason),
635                ConsoleActivation::RawHal(reason)
636            );
637        }
638    }
639
640    #[test]
641    fn failed_closed_console_never_falls_back_to_the_raw_hal() {
642        assert_eq!(
643            inactive_console_error(Some(ConsoleActivation::FailedClosed(
644                ConsoleUnavailable::HandoffFailed,
645            ))),
646            RuntimeError::ConsoleFailedClosed
647        );
648        assert_eq!(
649            inactive_console_error(Some(ConsoleActivation::RawHal(
650                ConsoleUnavailable::NoSerialDevice,
651            ))),
652            RuntimeError::SerialNotStarted
653        );
654    }
655
656    #[test]
657    fn raw_hal_without_irq_does_not_fake_sleepable_input() {
658        ACTIVATION.call_once(|| ConsoleActivation::RawHal(ConsoleUnavailable::NoSerialDevice));
659        assert!(matches!(
660            take_input(),
661            Err(RuntimeError::OperationNotSupported)
662        ));
663        assert!(output().is_ok());
664    }
665
666    #[test]
667    fn raw_hal_logging_does_not_require_the_task_output_mutex() {
668        ACTIVATION.call_once(|| ConsoleActivation::RawHal(ConsoleUnavailable::NoSerialDevice));
669        let _task_output = RAW_OUTPUT_LOCK.lock();
670        let mut rendered = alloc::string::String::new();
671
672        assert_eq!(
673            publish_raw_record(format_args!("early secondary record"), &mut rendered),
674            ax_log::PublishStatus::Published
675        );
676        assert_eq!(rendered, "early secondary record");
677    }
678}