Skip to content

Commit f361ca3

Browse files
Apply PR #13052: experiment: use ffi to get around bun raw input/ctrl+c issues
2 parents 0d96536 + 56dfbbb commit f361ca3

File tree

4 files changed

+147
-0
lines changed

4 files changed

+147
-0
lines changed

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
33
import { TextAttributes } from "@opentui/core"
44
import { RouteProvider, useRoute } from "@tui/context/route"
55
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
6+
import { win32DisableProcessedInput, win32IgnoreCtrlC, win32InstallCtrlCGuard } from "./win32"
67
import { Installation } from "@/installation"
78
import { Flag } from "@/flag/flag"
89
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -110,8 +111,18 @@ export function tui(input: {
110111
}) {
111112
// promise to prevent immediate exit
112113
return new Promise<void>(async (resolve) => {
114+
const unguard = win32InstallCtrlCGuard()
115+
win32DisableProcessedInput()
116+
win32IgnoreCtrlC()
117+
113118
const mode = await getTerminalBackgroundColor()
119+
120+
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
121+
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
122+
win32DisableProcessedInput()
123+
114124
const onExit = async () => {
125+
unguard?.()
115126
await input.onExit?.()
116127
resolve()
117128
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Log } from "@/util/log"
99
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
1010
import type { Event } from "@opencode-ai/sdk/v2"
1111
import type { EventSource } from "./context/sdk"
12+
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
1213

1314
declare global {
1415
const OPENCODE_WORKER_PATH: string
@@ -77,6 +78,14 @@ export const TuiThreadCommand = cmd({
7778
describe: "agent to use",
7879
}),
7980
handler: async (args) => {
81+
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
82+
// (Important when running under `bun run` wrappers on Windows.)
83+
win32InstallCtrlCGuard()
84+
85+
// Must be the very first thing — disables CTRL_C_EVENT before any Worker
86+
// spawn or async work so the OS cannot kill the process group.
87+
win32DisableProcessedInput()
88+
8089
if (args.fork && !args.continue && !args.session) {
8190
UI.error("--fork requires --continue or --session")
8291
process.exit(1)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { dlopen, ptr } from "bun:ffi"
2+
3+
const STD_INPUT_HANDLE = -10
4+
const ENABLE_PROCESSED_INPUT = 0x0001
5+
6+
const kernel = () =>
7+
dlopen("kernel32.dll", {
8+
GetStdHandle: { args: ["i32"], returns: "ptr" },
9+
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
10+
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
11+
SetConsoleCtrlHandler: { args: ["ptr", "i32"], returns: "i32" },
12+
})
13+
14+
let k32: ReturnType<typeof kernel> | undefined
15+
16+
function load() {
17+
if (process.platform !== "win32") return false
18+
try {
19+
k32 ??= kernel()
20+
return true
21+
} catch {
22+
return false
23+
}
24+
}
25+
26+
/**
27+
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
28+
*/
29+
export function win32DisableProcessedInput() {
30+
if (process.platform !== "win32") return
31+
if (!process.stdin.isTTY) return
32+
if (!load()) return
33+
34+
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
35+
const buf = new Uint32Array(1)
36+
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
37+
38+
const mode = buf[0]!
39+
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
40+
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
41+
}
42+
43+
/**
44+
* Tell Windows to ignore CTRL_C_EVENT for this process.
45+
*
46+
* SetConsoleCtrlHandler(NULL, TRUE) makes the process ignore Ctrl+C
47+
* signals at the OS level. Belt-and-suspenders alongside disabling
48+
* ENABLE_PROCESSED_INPUT.
49+
*/
50+
export function win32IgnoreCtrlC() {
51+
if (process.platform !== "win32") return
52+
if (!process.stdin.isTTY) return
53+
if (!load()) return
54+
55+
k32!.symbols.SetConsoleCtrlHandler(null, 1)
56+
}
57+
58+
let unhook: (() => void) | undefined
59+
60+
/**
61+
* Keep ENABLE_PROCESSED_INPUT disabled.
62+
*
63+
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
64+
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
65+
* (sometimes on a later tick), and the flag is console-global, not per-process.
66+
*
67+
* We combine:
68+
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
69+
* - A low-frequency poll as a backstop for native/external mode changes.
70+
*/
71+
export function win32InstallCtrlCGuard() {
72+
if (process.platform !== "win32") return
73+
if (!process.stdin.isTTY) return
74+
if (!load()) return
75+
if (unhook) return unhook
76+
77+
const stdin = process.stdin as any
78+
const original = stdin.setRawMode
79+
80+
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
81+
const buf = new Uint32Array(1)
82+
83+
const enforce = () => {
84+
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
85+
const mode = buf[0]!
86+
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
87+
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
88+
}
89+
90+
// Some runtimes can re-apply console modes on the next tick; enforce twice.
91+
const later = () => {
92+
enforce()
93+
setImmediate(enforce)
94+
}
95+
96+
if (typeof original === "function") {
97+
stdin.setRawMode = (mode: boolean) => {
98+
const result = original.call(stdin, mode)
99+
later()
100+
return result
101+
}
102+
}
103+
104+
// Ensure it's cleared immediately too (covers any earlier mode changes).
105+
later()
106+
107+
const interval = setInterval(enforce, 100)
108+
109+
unhook = () => {
110+
clearInterval(interval)
111+
if (typeof original === "function") {
112+
stdin.setRawMode = original
113+
}
114+
unhook = undefined
115+
}
116+
117+
return unhook
118+
}

packages/opencode/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { AttachCommand } from "./cli/cmd/tui/attach"
2323
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
2424
import { AcpCommand } from "./cli/cmd/acp"
2525
import { EOL } from "os"
26+
import { win32DisableProcessedInput, win32IgnoreCtrlC } from "./cli/cmd/tui/win32"
2627
import { WebCommand } from "./cli/cmd/web"
2728
import { PrCommand } from "./cli/cmd/pr"
2829
import { SessionCommand } from "./cli/cmd/session"
@@ -43,6 +44,14 @@ process.on("uncaughtException", (e) => {
4344
})
4445
})
4546

47+
// Disable Windows CTRL_C_EVENT as early as possible. When running under
48+
// `bun run` (e.g. `bun dev`), the parent bun process shares this console
49+
// and would be killed by the OS before any JS signal handler fires.
50+
win32DisableProcessedInput()
51+
// Belt-and-suspenders: even if something re-enables ENABLE_PROCESSED_INPUT
52+
// later (opentui raw mode, libuv, etc.), ignore the generated event.
53+
win32IgnoreCtrlC()
54+
4655
const cli = yargs(hideBin(process.argv))
4756
.parserConfiguration({ "populate--": true })
4857
.scriptName("opencode")

0 commit comments

Comments
 (0)