#17083 · @altendky · opened Mar 11, 2026 at 7:16 PM UTC · last updated Mar 21, 2026 at 8:48 PM UTC

fix: flush stdin on POSIX exit to prevent stale bytes leaking to shell

tuifix
66
+37135 files

Score breakdown

Impact

9.0

Clarity

10.0

Urgency

6.0

Ease Of Review

8.0

Guidelines

7.0

Readiness

5.0

Size

8.0

Trust

8.0

Traction

0.0

Summary

This PR addresses a bug where stale stdin bytes leak to the parent shell on POSIX systems after opencode exits, causing issues like extra spaces or blocking ctrl+d. It implements tcflush via FFI for POSIX and unifies platform-specific console functions.

Open in GitHub

Description

Issue for this PR

Closes #17081

Type of change

  • [x] Bug fix
  • [ ] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

When opencode exits on POSIX systems (Linux/macOS), stale bytes left in the terminal's stdin buffer leak to the parent shell, typically manifesting as an extra space character on the prompt or blocking ctrl+d. This happens because the existing FlushConsoleInputBuffer call only works on Windows.

This PR:

  • Adds a POSIX tcflush(STDIN_FILENO, TCIFLUSH) call via Bun's libc FFI to discard pending tty input bytes on exit
  • Renames win32.tsconsole.ts and consolidates platform-specific FFI (Windows kernel32 + POSIX libc) under a unified load() function
  • Drops the win32 prefix from flushInputBuffer since it is now cross-platform; Windows-only functions (win32DisableProcessedInput, win32InstallCtrlCGuard) retain their prefix

How did you verify your code works?

Tested on Linux by running opencode, pressing space several times before exiting, and confirming no stale bytes appear on the shell prompt after exit. Also verified Windows codepath is unchanged by reviewing the conditional logic.

Screenshots / recordings

N/A — terminal behavior fix, no UI change.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

#17081 Stale stdin bytes leak to shell after exit on POSIX (space on prompt, blocks ctrl+d)

View issue

Comments

No comments.

Changed Files

packages/opencode/src/cli/cmd/tui/app.tsx

+22
@@ -4,7 +4,7 @@ import { Selection } from "@tui/util/selection"
import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { win32DisableProcessedInput, flushInputBuffer, win32InstallCtrlCGuard } from "./console"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -777,7 +777,7 @@ function ErrorComponent(props: {
const handleExit = async () => {
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
flushInputBuffer()
await props.onExit()
}

packages/opencode/src/cli/cmd/tui/attach.ts

+11
@@ -1,7 +1,7 @@
import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./console"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"

packages/opencode/src/cli/cmd/tui/console.ts

+317
@@ -1,5 +1,7 @@
import { dlopen, ptr } from "bun:ffi"
// -- Windows (kernel32) -------------------------------------------------------
const STD_INPUT_HANDLE = -10
const ENABLE_PROCESSED_INPUT = 0x0001
@@ -13,16 +15,32 @@ const kernel = () =>
let k32: ReturnType<typeof kernel> | undefined
// -- POSIX (libc) -------------------------------------------------------------
const libc = () =>
dlopen(process.platform === "darwin" ? "libSystem.B.dylib" : "libc.so.6", {
tcflush: { args: ["i32", "i32"], returns: "i32" },
})
let lc: ReturnType<typeof libc> | undefined
// -----------------------------------------------------------------------------
function load() {
if (process.platform !== "win32") return false
try {
k32 ??= kernel()
if (process.platform === "win32") k32 ??= kernel()
else lc ??= libc()
return true
} catch {
return false
}
}
// TCIFLUSH: discard received-but-unread input
const TCIFLUSH = process.platform === "darwin" ? 1 : 0
// -- Exports ------------------------------------------------------------------
/**
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
*/
@@ -41,15 +59,21

packages/opencode/src/cli/cmd/tui/context/exit.tsx

+22
@@ -1,7 +1,7 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { win32FlushInputBuffer } from "../win32"
import { flushInputBuffer } from "../console"
type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
@@ -36,7 +36,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
flushInputBuffer()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {

packages/opencode/src/cli/cmd/tui/thread.ts

+11
@@ -11,7 +11,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./console"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"