Skip to content

tui: skip audio init when no playback device on the node runtime #61673

Open

Description

@wanazhar

Follow-up from #59128 (flagged in its review thread).

The #41763 display corruption comes from libasound probe output painted straight into the alternate screen via fd 2. #59128 handles two halves: fd-2 containment for the renderer scope on the bun runtime (#stderr-capture), and a no-re-probe sentinel in packages/tui/src/audio.ts after a failed engine start — the second is runtime-agnostic.

Residual on the node runtime (npm install path): the capture is a bun-only branch, so the first failed start() still paints one ALSA batch onto the terminal. Without #59128 it prints per attention event; with it, once per session.

Measured context from the #59128 thread (reproduced against @opentui/core 0.8.1, aarch64 linuxkit container, no /dev/snd, libasound.so.2 present): a single start() attempt emits a 35-line / ~2.9 KB batch — cannot find card '0' -> Unknown PCM default -> dmix -> dmix:0 -> dmix:0,0 -> Cannot get card index for 0 — before the JS Audio start failed: -5 error fires.

Direction: probe for a playback device (or /dev/snd PCM nodes) before Audio.create on the node path — the approach in #51842 and #51854, both closed unmerged — or gate engine creation on the same condition. Porting the fd capture to the node runtime is the alternative but the bun:ffi technique does not port straightforwardly; the probe is the smaller change.

Happy to take the node branch inside #59128 instead if maintainers prefer.

Activity

  1. opencodejr commented on Nov 4, 2026

    @opencodejr
    Contributor

    Thanks for the detailed write-up. I could reproduce the underlying problem.

    Setup: x86_64 Linux container with libasound.so.2 installed and no /dev/snd, @opencode/[email protected], attention.sound: true, and three prompts to a simulated model. Each "Session done" sound makes libasound write a new 35-line batch to the TUI's stderr: cannot find card '0' → Unknown PCM default → the dmix variants. That is 35, then 70, then 105 lines over three turns. It still happens the same way on the latest dev (8568952), where packages/tui/src/audio.ts neither checks for a playback device nor stops calling start() again after a failed start.

    Caveats:

    • I used the shipped Bun binary, not a Node build. Since fix(tui): keep native and child stderr off the rendered screen #59128 isn't merged, I couldn't isolate the Node-only "one batch per session" part on its own. Today the batch repeats on every attention event on either runtime.
    • The harness sends the TUI's stderr to a log file, so the attached recording shows the session but not the ALSA text on screen. The script below counts the lines instead.

    Related: #41763 (original report), #59128 (Bun-side capture and the no-re-probe change), and #51842 / #51854 (the device check you suggested, closed without merging).

    recording.mp4
    Reproduction script (drive.ts)
    // Repro for #61673 / #41763: on Linux with libasound.so.2 but no /dev/snd,
    // every attention sound ("Session done") makes libasound write a batch of
    // "ALSA lib ..." diagnostics to the TUI process's fd 2. In a real terminal fd 2
    // is the tty, so the batch paints over the rendered TUI. Drive redirects the
    // TUI's stderr to a log file, so this script counts the lines there.
    import { Effect, Stream } from "effect"
    import { readdirSync, readFileSync, statSync } from "node:fs"
    import { join } from "node:path"
    import { tmpdir } from "node:os"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const start = Date.now()
    function stderrLogs(dir: string, depth = 0): string[] {
      if (depth > 5) return []
      let out: string[] = []
      let entries: string[] = []
      try { entries = readdirSync(dir) } catch { return [] }
      for (const e of entries) {
        const p = join(dir, e)
        let s
        try { s = statSync(p) } catch { continue }
        if (s.isDirectory()) out = out.concat(stderrLogs(p, depth + 1))
        else if (e.endsWith(".stderr.log") && s.mtimeMs >= start) out.push(p)
      }
      return out
    }
    const report = (label: string) =>
      Effect.sync(() => {
        for (const f of stderrLogs(tmpdir())) {
          const text = readFileSync(f, "utf8")
          const alsa = text.split("\n").filter((l) => l.startsWith("ALSA lib")).length
          console.log(`[${label}] ${f}: ${alsa} "ALSA lib" lines`)
          if (alsa && label === "end") console.log(text.split("\n").filter((l) => l.startsWith("ALSA lib")).slice(0, 8).join("\n"))
        }
      })
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        tuiConfig: { attention: { sound: true } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* ui.screenshot("before")
          yield* llm.serve((_request, index) => Stream.make(Llm.text(`Reply number ${index + 1}.`)))
          for (const n of [1, 2, 3]) {
            yield* ui.submit(`hello ${n}`)
            yield* Effect.sleep("6 seconds")
            yield* ui.screenshot(`after-turn-${n}`)
            yield* report(`after turn ${n}`)
          }
          yield* report("end")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions