tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Sutichaykuna

feat(shuma): shuma-session + shuma-exec — sesiones de trabajo y ejecución

shuma-session: el shell trabaja dentro de una WorkSession — directorio
actual (que es el identificador de aislamiento, hash estable del cwd),
historial de comandos ejecutados (CommandRun con salida y estado) y
grupos de comandos guardados y reutilizables (CommandGroup).

shuma-exec: ejecutor con salida en streaming — lanza bash -c en un
directorio y entrega stdout/stderr línea a línea por un canal, sin
esperar al final. Es la capa que sandokan (poll-based, orquesta Cards)
deliberadamente no cubre.

15 tests. Agnósticos de UI, #![forbid(unsafe_code)].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

0f2f1033eb3498e3d2635cd89fb4ba6899165d95 · ruwaq sergio · (5 killa ñawpaq) · tayta 8250ecbc0 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 52

crates/modules/shuma/shuma-exec/src/lib.rs

crates/modules/shuma/shuma-session/src/lib.rs

7 qillqakuna · +601 −0

diff --git a/Cargo.lock b/Cargo.lock
index c288713..5a15638 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11380,6 +11380,10 @@ dependencies = [
  "toml 0.8.23",
 ]
 
+[[package]]
+name = "shuma-exec"
+version = "0.1.0"
+
 [[package]]
 name = "shuma-gateway"
 version = "0.1.0"
@@ -11420,6 +11424,13 @@ dependencies = [
  "ulid",
 ]
 
+[[package]]
+name = "shuma-session"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "shuma-shell"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 540c2af..61ff42c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -182,6 +182,8 @@ members = [
     "crates/modules/shuma/shuma-intent",
     "crates/modules/shuma/shuma-line",
     "crates/modules/shuma/shuma-sysmon",
+    "crates/modules/shuma/shuma-session",
+    "crates/modules/shuma/shuma-exec",
     "crates/modules/shuma/shuma-shell-render",
 
     # ============================================================
diff --git a/crates/modules/shuma/shuma-exec/Cargo.toml b/crates/modules/shuma/shuma-exec/Cargo.toml
new file mode 100644
index 0000000..f042f40
--- /dev/null
+++ b/crates/modules/shuma/shuma-exec/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "shuma-exec"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — ejecutor de comandos del shell con salida en streaming: lanza un proceso y entrega stdout/stderr línea a línea por un canal. Agnóstico de UI."
+
+[dependencies]
diff --git a/crates/modules/shuma/shuma-exec/src/lib.rs b/crates/modules/shuma/shuma-exec/src/lib.rs
new file mode 100644
index 0000000..504d8b8
--- /dev/null
+++ b/crates/modules/shuma/shuma-exec/src/lib.rs
@@ -0,0 +1,256 @@
+//! `shuma-exec` — ejecución de comandos del shell con salida en streaming.
+//!
+//! Lanza una línea de comandos en un shell (`bash -c …`) dentro de un
+//! directorio, y entrega su salida **a medida que ocurre**: cada línea
+//! de stdout o stderr llega como un [`RunEvent`] por un canal, sin
+//! esperar a que el proceso termine.
+//!
+//! Esto es lo que `sandokan` no hace: el orquestador es poll-based y
+//! orquesta *Cards* de brahman (entidades aisladas y supervisadas). El
+//! shell, en cambio, corre líneas de shell ad-hoc y necesita ver la
+//! salida fluir. Dos capas distintas, a propósito.
+//!
+//! El crate es agnóstico de frontend: el proceso y sus lectores corren
+//! en hilos; el consumidor (shell GPUI o TUI) drena el canal cuando
+//! quiere — sin `async`, sin acoplarse a ningún runtime.
+
+#![forbid(unsafe_code)]
+
+use std::io::{BufRead, BufReader};
+use std::process::{Command, Stdio};
+use std::sync::mpsc::{self, Receiver, TryRecvError};
+
+/// Qué ejecutar: una línea de comandos, en un directorio, con un shell.
+#[derive(Debug, Clone)]
+pub struct CommandSpec {
+    /// La línea completa — se pasa como `shell -c "<line>"`.
+    pub line: String,
+    /// Directorio de trabajo del proceso.
+    pub cwd: String,
+    /// Programa de shell — `"bash"`, `"sh"`, `"fish"`…
+    pub shell: String,
+}
+
+impl CommandSpec {
+    /// Spec con `bash` como shell.
+    pub fn bash(line: impl Into<String>, cwd: impl Into<String>) -> Self {
+        Self { line: line.into(), cwd: cwd.into(), shell: "bash".into() }
+    }
+}
+
+/// Un evento de la ejecución de un comando.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RunEvent {
+    /// Una línea de salida estándar.
+    Stdout(String),
+    /// Una línea de salida de error.
+    Stderr(String),
+    /// El proceso terminó con este código de salida.
+    Exited(i32),
+    /// El proceso no pudo siquiera lanzarse.
+    Failed(String),
+}
+
+impl RunEvent {
+    /// `true` si el evento cierra la ejecución (`Exited` o `Failed`).
+    pub fn is_terminal(&self) -> bool {
+        matches!(self, RunEvent::Exited(_) | RunEvent::Failed(_))
+    }
+}
+
+/// Asa de un comando en ejecución. El consumidor la conserva y drena sus
+/// eventos cuando le conviene.
+pub struct RunHandle {
+    rx: Receiver<RunEvent>,
+    finished: bool,
+}
+
+impl RunHandle {
+    /// Drena todos los eventos disponibles ahora mismo, sin bloquear.
+    /// Marca el asa como terminada al ver un evento terminal.
+    pub fn try_events(&mut self) -> Vec<RunEvent> {
+        let mut out = Vec::new();
+        loop {
+            match self.rx.try_recv() {
+                Ok(ev) => {
+                    if ev.is_terminal() {
+                        self.finished = true;
+                    }
+                    out.push(ev);
+                }
+                Err(TryRecvError::Empty) => break,
+                Err(TryRecvError::Disconnected) => {
+                    self.finished = true;
+                    break;
+                }
+            }
+        }
+        out
+    }
+
+    /// Bloquea hasta que el proceso termine y devuelve todos sus eventos
+    /// en orden. Pensado para tests y para usos sincrónicos.
+    pub fn wait_all(&mut self) -> Vec<RunEvent> {
+        let mut out = Vec::new();
+        while let Ok(ev) = self.rx.recv() {
+            let terminal = ev.is_terminal();
+            out.push(ev);
+            if terminal {
+                self.finished = true;
+            }
+        }
+        self.finished = true;
+        out
+    }
+
+    /// `true` si ya se observó el evento terminal.
+    pub fn is_finished(&self) -> bool {
+        self.finished
+    }
+}
+
+/// Lanza `spec` y devuelve un [`RunHandle`] desde el que drenar la
+/// salida. La función vuelve de inmediato: el proceso corre en hilos.
+pub fn run(spec: &CommandSpec) -> RunHandle {
+    let (tx, rx) = mpsc::channel();
+    let spec = spec.clone();
+
+    std::thread::spawn(move || {
+        let spawned = Command::new(&spec.shell)
+            .arg("-c")
+            .arg(&spec.line)
+            .current_dir(&spec.cwd)
+            .stdin(Stdio::null())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped())
+            .spawn();
+
+        let mut child = match spawned {
+            Ok(c) => c,
+            Err(e) => {
+                let _ = tx.send(RunEvent::Failed(e.to_string()));
+                return;
+            }
+        };
+
+        // Un hilo lector por flujo: stdout y stderr fluyen en paralelo.
+        let stdout = child.stdout.take();
+        let stderr = child.stderr.take();
+        let out_reader = stdout.map(|s| {
+            let tx = tx.clone();
+            std::thread::spawn(move || {
+                for line in BufReader::new(s).lines().map_while(Result::ok) {
+                    if tx.send(RunEvent::Stdout(line)).is_err() {
+                        break;
+                    }
+                }
+            })
+        });
+        let err_reader = stderr.map(|s| {
+            let tx = tx.clone();
+            std::thread::spawn(move || {
+                for line in BufReader::new(s).lines().map_while(Result::ok) {
+                    if tx.send(RunEvent::Stderr(line)).is_err() {
+                        break;
+                    }
+                }
+            })
+        });
+
+        if let Some(h) = out_reader {
+            let _ = h.join();
+        }
+        if let Some(h) = err_reader {
+            let _ = h.join();
+        }
+        let code = child
+            .wait()
+            .ok()
+            .and_then(|s| s.code())
+            .unwrap_or(-1);
+        let _ = tx.send(RunEvent::Exited(code));
+    });
+
+    RunHandle { rx, finished: false }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// `sh` está en cualquier entorno POSIX — más portable que bash
+    /// para los tests.
+    fn sh(line: &str) -> CommandSpec {
+        CommandSpec { line: line.into(), cwd: ".".into(), shell: "sh".into() }
+    }
+
+    #[test]
+    fn captures_stdout_and_exit_code() {
+        let mut h = run(&sh("echo hola-mundo"));
+        let events = h.wait_all();
+        assert!(events.contains(&RunEvent::Stdout("hola-mundo".into())));
+        assert!(events.contains(&RunEvent::Exited(0)));
+        assert!(h.is_finished());
+    }
+
+    #[test]
+    fn captures_stderr() {
+        let mut h = run(&sh("echo problema 1>&2"));
+        let events = h.wait_all();
+        assert!(events.contains(&RunEvent::Stderr("problema".into())));
+    }
+
+    #[test]
+    fn nonzero_exit_is_reported() {
+        let mut h = run(&sh("exit 3"));
+        let events = h.wait_all();
+        assert!(events.contains(&RunEvent::Exited(3)));
+    }
+
+    #[test]
+    fn multiple_output_lines_arrive_in_order() {
+        let mut h = run(&sh("echo uno; echo dos; echo tres"));
+        let lines: Vec<String> = h
+            .wait_all()
+            .into_iter()
+            .filter_map(|e| match e {
+                RunEvent::Stdout(l) => Some(l),
+                _ => None,
+            })
+            .collect();
+        assert_eq!(lines, vec!["uno", "dos", "tres"]);
+    }
+
+    #[test]
+    fn pipes_run_through_the_shell() {
+        let mut h = run(&sh("printf 'b\\na\\nc\\n' | sort"));
+        let lines: Vec<String> = h
+            .wait_all()
+            .into_iter()
+            .filter_map(|e| match e {
+                RunEvent::Stdout(l) => Some(l),
+                _ => None,
+            })
+            .collect();
+        assert_eq!(lines, vec!["a", "b", "c"]);
+    }
+
+    #[test]
+    fn missing_shell_fails_gracefully() {
+        let spec = CommandSpec {
+            line: "echo x".into(),
+            cwd: ".".into(),
+            shell: "/no/existe/shell-xyz".into(),
+        };
+        let mut h = run(&spec);
+        let events = h.wait_all();
+        assert!(matches!(events.first(), Some(RunEvent::Failed(_))));
+    }
+
+    #[test]
+    fn terminal_event_detection() {
+        assert!(RunEvent::Exited(0).is_terminal());
+        assert!(RunEvent::Failed("x".into()).is_terminal());
+        assert!(!RunEvent::Stdout("x".into()).is_terminal());
+    }
+}
diff --git a/crates/modules/shuma/shuma-session/Cargo.toml b/crates/modules/shuma/shuma-session/Cargo.toml
new file mode 100644
index 0000000..8cf6328
--- /dev/null
+++ b/crates/modules/shuma/shuma-session/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "shuma-session"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — el modelo de la sesión de trabajo: directorio actual (identificador de aislamiento), historial de comandos ejecutados y grupos reutilizables."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/crates/modules/shuma/shuma-session/src/lib.rs b/crates/modules/shuma/shuma-session/src/lib.rs
new file mode 100644
index 0000000..5cfe623
--- /dev/null
+++ b/crates/modules/shuma/shuma-session/src/lib.rs
@@ -0,0 +1,308 @@
+//! `shuma-session` — la sesión de trabajo del shell.
+//!
+//! El shell no es una sucesión suelta de comandos: trabaja *dentro de
+//! una sesión*. Una [`WorkSession`] contiene:
+//!
+//! - el **directorio actual** — que es además el identificador de
+//!   aislamiento (cada directorio es un contexto separado);
+//! - el **historial** de comandos ejecutados, cada uno con su salida y
+//!   su estado ([`CommandRun`]);
+//! - los **grupos** de comandos guardados y reutilizables
+//!   ([`CommandGroup`]).
+//!
+//! Modelo puro y agnóstico: la ejecución real la hace `shuma-exec`, el
+//! tiempo lo inyecta el caller. Determinista y testeable.
+
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+
+/// Identificador de un comando dentro de su sesión.
+pub type RunId = u64;
+
+/// Estado de un comando ejecutado.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum RunStatus {
+    /// Ejecutándose; su salida sigue llegando.
+    Running,
+    /// Terminó con código 0.
+    Ok,
+    /// Terminó con código distinto de 0, o no pudo lanzarse.
+    Failed,
+}
+
+/// Un comando ejecutado: la línea, el directorio, el estado y la salida.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CommandRun {
+    pub id: RunId,
+    /// La línea de comandos tal como se escribió.
+    pub line: String,
+    /// El directorio en que se ejecutó.
+    pub cwd: String,
+    pub status: RunStatus,
+    /// Código de salida, una vez terminado.
+    pub exit_code: Option<i32>,
+    /// Salida combinada (stdout + stderr), una línea por elemento.
+    pub output: Vec<String>,
+    /// Segundo Unix en que arrancó.
+    pub started_at: u64,
+    /// Segundo Unix en que terminó.
+    pub finished_at: Option<u64>,
+}
+
+impl CommandRun {
+    /// `true` si el comando sigue corriendo.
+    pub fn is_running(&self) -> bool {
+        self.status == RunStatus::Running
+    }
+
+    /// Cantidad de líneas de salida.
+    pub fn line_count(&self) -> usize {
+        self.output.len()
+    }
+}
+
+/// Un grupo de comandos guardado para reutilizar — una receta.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CommandGroup {
+    pub name: String,
+    /// Las líneas de comando, en orden de ejecución.
+    pub lines: Vec<String>,
+}
+
+/// La sesión de trabajo: directorio actual + historial + grupos.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WorkSession {
+    pub name: String,
+    cwd: String,
+    history: Vec<CommandRun>,
+    groups: Vec<CommandGroup>,
+    next_id: RunId,
+}
+
+/// FNV-1a de 64 bits — base del identificador de aislamiento.
+fn fnv1a(bytes: &[u8]) -> u64 {
+    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+    for &b in bytes {
+        h ^= b as u64;
+        h = h.wrapping_mul(0x100_0000_01b3);
+    }
+    h
+}
+
+impl WorkSession {
+    /// Abre una sesión con un nombre y un directorio inicial.
+    pub fn new(name: impl Into<String>, cwd: impl Into<String>) -> Self {
+        Self {
+            name: name.into(),
+            cwd: cwd.into(),
+            history: Vec::new(),
+            groups: Vec::new(),
+            next_id: 1,
+        }
+    }
+
+    /// Directorio actual de la sesión.
+    pub fn cwd(&self) -> &str {
+        &self.cwd
+    }
+
+    /// Cambia el directorio actual — y con él, el contexto de aislamiento.
+    pub fn set_cwd(&mut self, cwd: impl Into<String>) {
+        self.cwd = cwd.into();
+    }
+
+    /// Identificador de aislamiento del directorio actual: un hash corto
+    /// y estable del `cwd`. Cada directorio es un contexto distinto, así
+    /// que el id cambia al hacer `cd`.
+    pub fn isolation_id(&self) -> String {
+        format!("{:012x}", fnv1a(self.cwd.as_bytes()) & 0xffff_ffff_ffff)
+    }
+
+    // --- Historial de comandos ---
+
+    /// Registra el inicio de un comando (estado `Running`) en el `cwd`
+    /// actual. Devuelve su id.
+    pub fn begin_run(&mut self, line: impl Into<String>, now: u64) -> RunId {
+        let id = self.next_id;
+        self.next_id += 1;
+        self.history.push(CommandRun {
+            id,
+            line: line.into(),
+            cwd: self.cwd.clone(),
+            status: RunStatus::Running,
+            exit_code: None,
+            output: Vec::new(),
+            started_at: now,
+            finished_at: None,
+        });
+        id
+    }
+
+    pub fn run(&self, id: RunId) -> Option<&CommandRun> {
+        self.history.iter().find(|r| r.id == id)
+    }
+
+    fn run_mut(&mut self, id: RunId) -> Option<&mut CommandRun> {
+        self.history.iter_mut().find(|r| r.id == id)
+    }
+
+    /// Añade una línea de salida a un comando en curso.
+    pub fn append_output(&mut self, id: RunId, line: impl Into<String>) {
+        if let Some(r) = self.run_mut(id) {
+            r.output.push(line.into());
+        }
+    }
+
+    /// Marca un comando como terminado con su código de salida.
+    pub fn finish_run(&mut self, id: RunId, exit_code: i32, now: u64) {
+        if let Some(r) = self.run_mut(id) {
+            r.exit_code = Some(exit_code);
+            r.status = if exit_code == 0 { RunStatus::Ok } else { RunStatus::Failed };
+            r.finished_at = Some(now);
+        }
+    }
+
+    /// Historial completo, del más antiguo al más reciente.
+    pub fn history(&self) -> &[CommandRun] {
+        &self.history
+    }
+
+    /// Comandos que siguen corriendo.
+    pub fn running(&self) -> Vec<RunId> {
+        self.history
+            .iter()
+            .filter(|r| r.is_running())
+            .map(|r| r.id)
+            .collect()
+    }
+
+    /// Vacía el historial (no toca los grupos ni el `cwd`).
+    pub fn clear_history(&mut self) {
+        self.history.clear();
+    }
+
+    // --- Grupos reutilizables ---
+
+    /// Guarda un grupo de comandos. Si ya existe uno con ese nombre, lo
+    /// reemplaza.
+    pub fn save_group(&mut self, name: impl Into<String>, lines: Vec<String>) {
+        let name = name.into();
+        self.groups.retain(|g| g.name != name);
+        self.groups.push(CommandGroup { name, lines });
+    }
+
+    /// Guarda como grupo las líneas de los últimos `n` comandos del
+    /// historial — la forma natural de "convertir lo que acabo de hacer
+    /// en una receta".
+    pub fn save_recent_as_group(&mut self, name: impl Into<String>, n: usize) {
+        let lines: Vec<String> = self
+            .history
+            .iter()
+            .rev()
+            .take(n)
+            .rev()
+            .map(|r| r.line.clone())
+            .collect();
+        self.save_group(name, lines);
+    }
+
+    pub fn groups(&self) -> &[CommandGroup] {
+        &self.groups
+    }
+
+    pub fn group(&self, name: &str) -> Option<&CommandGroup> {
+        self.groups.iter().find(|g| g.name == name)
+    }
+
+    /// Quita un grupo. `true` si existía.
+    pub fn remove_group(&mut self, name: &str) -> bool {
+        let before = self.groups.len();
+        self.groups.retain(|g| g.name != name);
+        self.groups.len() != before
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn isolation_id_follows_the_directory() {
+        let mut s = WorkSession::new("trabajo", "/home/sergio/brahman");
+        let id_a = s.isolation_id();
+        s.set_cwd("/tmp");
+        let id_b = s.isolation_id();
+        assert_ne!(id_a, id_b, "cd cambia el contexto de aislamiento");
+        // Estable: el mismo directorio da el mismo id.
+        s.set_cwd("/home/sergio/brahman");
+        assert_eq!(s.isolation_id(), id_a);
+    }
+
+    #[test]
+    fn a_run_records_its_directory() {
+        let mut s = WorkSession::new("t", "/home");
+        let id = s.begin_run("ls -la", 1000);
+        assert_eq!(s.run(id).unwrap().cwd, "/home");
+        assert_eq!(s.run(id).unwrap().status, RunStatus::Running);
+    }
+
+    #[test]
+    fn output_accumulates_and_run_finishes() {
+        let mut s = WorkSession::new("t", "/home");
+        let id = s.begin_run("echo hola", 1000);
+        s.append_output(id, "hola");
+        s.finish_run(id, 0, 1001);
+        let r = s.run(id).unwrap();
+        assert_eq!(r.output, vec!["hola"]);
+        assert_eq!(r.status, RunStatus::Ok);
+        assert_eq!(r.exit_code, Some(0));
+        assert_eq!(r.finished_at, Some(1001));
+    }
+
+    #[test]
+    fn nonzero_exit_marks_failed() {
+        let mut s = WorkSession::new("t", "/home");
+        let id = s.begin_run("false", 0);
+        s.finish_run(id, 1, 1);
+        assert_eq!(s.run(id).unwrap().status, RunStatus::Failed);
+    }
+
+    #[test]
+    fn running_lists_unfinished_commands() {
+        let mut s = WorkSession::new("t", "/home");
+        let a = s.begin_run("sleep 1", 0);
+        let b = s.begin_run("sleep 2", 0);
+        s.finish_run(a, 0, 1);
+        assert_eq!(s.running(), vec![b]);
+    }
+
+    #[test]
+    fn save_and_recall_a_group() {
+        let mut s = WorkSession::new("t", "/home");
+        s.save_group("deploy", vec!["cargo build".into(), "scp target host:/srv".into()]);
+        assert_eq!(s.group("deploy").unwrap().lines.len(), 2);
+        // Guardar con el mismo nombre reemplaza.
+        s.save_group("deploy", vec!["echo nuevo".into()]);
+        assert_eq!(s.group("deploy").unwrap().lines, vec!["echo nuevo"]);
+    }
+
+    #[test]
+    fn save_recent_history_as_a_group() {
+        let mut s = WorkSession::new("t", "/home");
+        for line in ["git add .", "git commit", "git push"] {
+            s.begin_run(line, 0);
+        }
+        s.save_recent_as_group("publicar", 2);
+        // Los 2 últimos, en orden cronológico.
+        assert_eq!(s.group("publicar").unwrap().lines, vec!["git commit", "git push"]);
+    }
+
+    #[test]
+    fn remove_group() {
+        let mut s = WorkSession::new("t", "/home");
+        s.save_group("x", vec!["echo x".into()]);
+        assert!(s.remove_group("x"));
+        assert!(!s.remove_group("x"));
+    }
+}
diff --git a/nohup.out b/nohup.out
index a64455c..51542db 100644
--- a/nohup.out
+++ b/nohup.out
@@ -952,3 +952,6 @@ Crash Annotation GraphicsCriticalError: |[C0][GFX1-]: Managed to allocate after
 [Child 27081, MediaDecoderStateMachine #42] WARNING: 72497533eee0 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279
 [Child 27081, MediaDecoderStateMachine #42] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630
 [Child 27081, MediaDecoderStateMachine #42] WARNING: Decoder=724971e98800 Decode error: NS_ERROR_DOM_MEDIA_MEDIASINK_ERR (0x806e000b) - OnMediaSinkAudioError: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachineBase.cpp:168
+[Child 27081, MediaDecoderStateMachine #49] WARNING: 72496a597ca0 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279
+[Child 27081, MediaDecoderStateMachine #49] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630
+[Child 27081, MediaDecoderStateMachine #49] WARNING: Decoder=724971e98800 Decode error: NS_ERROR_DOM_MEDIA_MEDIASINK_ERR (0x806e000b) - OnMediaSinkAudioError: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachineBase.cpp:168