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