Selaa lähdekoodia

fix(win32): use ffi to get around bun raw input/ctrl+c issues (#13052)

Luke Parker 2 viikkoa sitten
vanhempi
sitoutus
8f9742d988

+ 12 - 1
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -3,6 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
 import { TextAttributes } from "@opentui/core"
 import { TextAttributes } from "@opentui/core"
 import { RouteProvider, useRoute } from "@tui/context/route"
 import { RouteProvider, useRoute } from "@tui/context/route"
 import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
 import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
+import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
 import { Installation } from "@/installation"
 import { Installation } from "@/installation"
 import { Flag } from "@/flag/flag"
 import { Flag } from "@/flag/flag"
 import { DialogProvider, useDialog } from "@tui/ui/dialog"
 import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -110,8 +111,17 @@ export function tui(input: {
 }) {
 }) {
   // promise to prevent immediate exit
   // promise to prevent immediate exit
   return new Promise<void>(async (resolve) => {
   return new Promise<void>(async (resolve) => {
+    const unguard = win32InstallCtrlCGuard()
+    win32DisableProcessedInput()
+
     const mode = await getTerminalBackgroundColor()
     const mode = await getTerminalBackgroundColor()
+
+    // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
+    // the original console mode which re-enables ENABLE_PROCESSED_INPUT.
+    win32DisableProcessedInput()
+
     const onExit = async () => {
     const onExit = async () => {
+      unguard?.()
       await input.onExit?.()
       await input.onExit?.()
       resolve()
       resolve()
     }
     }
@@ -730,7 +740,8 @@ function ErrorComponent(props: {
   const handleExit = async () => {
   const handleExit = async () => {
     renderer.setTerminalTitle("")
     renderer.setTerminalTitle("")
     renderer.destroy()
     renderer.destroy()
-    props.onExit()
+    win32FlushInputBuffer()
+    await props.onExit()
   }
   }
 
 
   useKeyboard((evt) => {
   useKeyboard((evt) => {

+ 30 - 22
packages/opencode/src/cli/cmd/tui/attach.ts

@@ -1,5 +1,6 @@
 import { cmd } from "../cmd"
 import { cmd } from "../cmd"
 import { tui } from "./app"
 import { tui } from "./app"
+import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 
 
 export const AttachCommand = cmd({
 export const AttachCommand = cmd({
   command: "attach <url>",
   command: "attach <url>",
@@ -26,27 +27,34 @@ export const AttachCommand = cmd({
         describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
         describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
       }),
       }),
   handler: async (args) => {
   handler: async (args) => {
-    const directory = (() => {
-      if (!args.dir) return undefined
-      try {
-        process.chdir(args.dir)
-        return process.cwd()
-      } catch {
-        // If the directory doesn't exist locally (remote attach), pass it through.
-        return args.dir
-      }
-    })()
-    const headers = (() => {
-      const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
-      if (!password) return undefined
-      const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
-      return { Authorization: auth }
-    })()
-    await tui({
-      url: args.url,
-      args: { sessionID: args.session },
-      directory,
-      headers,
-    })
+    const unguard = win32InstallCtrlCGuard()
+    try {
+      win32DisableProcessedInput()
+
+      const directory = (() => {
+        if (!args.dir) return undefined
+        try {
+          process.chdir(args.dir)
+          return process.cwd()
+        } catch {
+          // If the directory doesn't exist locally (remote attach), pass it through.
+          return args.dir
+        }
+      })()
+      const headers = (() => {
+        const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
+        if (!password) return undefined
+        const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
+        return { Authorization: auth }
+      })()
+      await tui({
+        url: args.url,
+        args: { sessionID: args.session },
+        directory,
+        headers,
+      })
+    } finally {
+      unguard?.()
+    }
   },
   },
 })
 })

+ 2 - 0
packages/opencode/src/cli/cmd/tui/context/exit.tsx

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

+ 98 - 85
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -9,6 +9,7 @@ import { Log } from "@/util/log"
 import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
 import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
 import type { Event } from "@opencode-ai/sdk/v2"
 import type { Event } from "@opencode-ai/sdk/v2"
 import type { EventSource } from "./context/sdk"
 import type { EventSource } from "./context/sdk"
+import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 
 
 declare global {
 declare global {
   const OPENCODE_WORKER_PATH: string
   const OPENCODE_WORKER_PATH: string
@@ -77,99 +78,111 @@ export const TuiThreadCommand = cmd({
         describe: "agent to use",
         describe: "agent to use",
       }),
       }),
   handler: async (args) => {
   handler: async (args) => {
-    if (args.fork && !args.continue && !args.session) {
-      UI.error("--fork requires --continue or --session")
-      process.exit(1)
-    }
-
-    // Resolve relative paths against PWD to preserve behavior when using --cwd flag
-    const baseCwd = process.env.PWD ?? process.cwd()
-    const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
-    const localWorker = new URL("./worker.ts", import.meta.url)
-    const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
-    const workerPath = await iife(async () => {
-      if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
-      if (await Bun.file(distWorker).exists()) return distWorker
-      return localWorker
-    })
+    // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
+    // (Important when running under `bun run` wrappers on Windows.)
+    const unguard = win32InstallCtrlCGuard()
     try {
     try {
-      process.chdir(cwd)
-    } catch (e) {
-      UI.error("Failed to change directory to " + cwd)
-      return
-    }
+      // Must be the very first thing — disables CTRL_C_EVENT before any Worker
+      // spawn or async work so the OS cannot kill the process group.
+      win32DisableProcessedInput()
 
 
-    const worker = new Worker(workerPath, {
-      env: Object.fromEntries(
-        Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
-      ),
-    })
-    worker.onerror = (e) => {
-      Log.Default.error(e)
-    }
-    const client = Rpc.client<typeof rpc>(worker)
-    process.on("uncaughtException", (e) => {
-      Log.Default.error(e)
-    })
-    process.on("unhandledRejection", (e) => {
-      Log.Default.error(e)
-    })
-    process.on("SIGUSR2", async () => {
-      await client.call("reload", undefined)
-    })
+      if (args.fork && !args.continue && !args.session) {
+        UI.error("--fork requires --continue or --session")
+        process.exitCode = 1
+        return
+      }
 
 
-    const prompt = await iife(async () => {
-      const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
-      if (!args.prompt) return piped
-      return piped ? piped + "\n" + args.prompt : args.prompt
-    })
+      // Resolve relative paths against PWD to preserve behavior when using --cwd flag
+      const baseCwd = process.env.PWD ?? process.cwd()
+      const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
+      const localWorker = new URL("./worker.ts", import.meta.url)
+      const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
+      const workerPath = await iife(async () => {
+        if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
+        if (await Bun.file(distWorker).exists()) return distWorker
+        return localWorker
+      })
+      try {
+        process.chdir(cwd)
+      } catch (e) {
+        UI.error("Failed to change directory to " + cwd)
+        return
+      }
 
 
-    // Check if server should be started (port or hostname explicitly set in CLI or config)
-    const networkOpts = await resolveNetworkOptions(args)
-    const shouldStartServer =
-      process.argv.includes("--port") ||
-      process.argv.includes("--hostname") ||
-      process.argv.includes("--mdns") ||
-      networkOpts.mdns ||
-      networkOpts.port !== 0 ||
-      networkOpts.hostname !== "127.0.0.1"
+      const worker = new Worker(workerPath, {
+        env: Object.fromEntries(
+          Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
+        ),
+      })
+      worker.onerror = (e) => {
+        Log.Default.error(e)
+      }
+      const client = Rpc.client<typeof rpc>(worker)
+      process.on("uncaughtException", (e) => {
+        Log.Default.error(e)
+      })
+      process.on("unhandledRejection", (e) => {
+        Log.Default.error(e)
+      })
+      process.on("SIGUSR2", async () => {
+        await client.call("reload", undefined)
+      })
 
 
-    let url: string
-    let customFetch: typeof fetch | undefined
-    let events: EventSource | undefined
+      const prompt = await iife(async () => {
+        const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
+        if (!args.prompt) return piped
+        return piped ? piped + "\n" + args.prompt : args.prompt
+      })
 
 
-    if (shouldStartServer) {
-      // Start HTTP server for external access
-      const server = await client.call("server", networkOpts)
-      url = server.url
-    } else {
-      // Use direct RPC communication (no HTTP)
-      url = "http://opencode.internal"
-      customFetch = createWorkerFetch(client)
-      events = createEventSource(client)
-    }
+      // Check if server should be started (port or hostname explicitly set in CLI or config)
+      const networkOpts = await resolveNetworkOptions(args)
+      const shouldStartServer =
+        process.argv.includes("--port") ||
+        process.argv.includes("--hostname") ||
+        process.argv.includes("--mdns") ||
+        networkOpts.mdns ||
+        networkOpts.port !== 0 ||
+        networkOpts.hostname !== "127.0.0.1"
 
 
-    const tuiPromise = tui({
-      url,
-      fetch: customFetch,
-      events,
-      args: {
-        continue: args.continue,
-        sessionID: args.session,
-        agent: args.agent,
-        model: args.model,
-        prompt,
-        fork: args.fork,
-      },
-      onExit: async () => {
-        await client.call("shutdown", undefined)
-      },
-    })
+      let url: string
+      let customFetch: typeof fetch | undefined
+      let events: EventSource | undefined
+
+      if (shouldStartServer) {
+        // Start HTTP server for external access
+        const server = await client.call("server", networkOpts)
+        url = server.url
+      } else {
+        // Use direct RPC communication (no HTTP)
+        url = "http://opencode.internal"
+        customFetch = createWorkerFetch(client)
+        events = createEventSource(client)
+      }
 
 
-    setTimeout(() => {
-      client.call("checkUpgrade", { directory: cwd }).catch(() => {})
-    }, 1000)
+      const tuiPromise = tui({
+        url,
+        fetch: customFetch,
+        events,
+        args: {
+          continue: args.continue,
+          sessionID: args.session,
+          agent: args.agent,
+          model: args.model,
+          prompt,
+          fork: args.fork,
+        },
+        onExit: async () => {
+          await client.call("shutdown", undefined)
+        },
+      })
 
 
-    await tuiPromise
+      setTimeout(() => {
+        client.call("checkUpgrade", { directory: cwd }).catch(() => {})
+      }, 1000)
+
+      await tuiPromise
+    } finally {
+      unguard?.()
+    }
   },
   },
 })
 })

+ 129 - 0
packages/opencode/src/cli/cmd/tui/win32.ts

@@ -0,0 +1,129 @@
+import { dlopen, ptr } from "bun:ffi"
+
+const STD_INPUT_HANDLE = -10
+const ENABLE_PROCESSED_INPUT = 0x0001
+
+const kernel = () =>
+  dlopen("kernel32.dll", {
+    GetStdHandle: { args: ["i32"], returns: "ptr" },
+    GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
+    SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
+    FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
+  })
+
+let k32: ReturnType<typeof kernel> | undefined
+
+function load() {
+  if (process.platform !== "win32") return false
+  try {
+    k32 ??= kernel()
+    return true
+  } catch {
+    return false
+  }
+}
+
+/**
+ * Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
+ */
+export function win32DisableProcessedInput() {
+  if (process.platform !== "win32") return
+  if (!process.stdin.isTTY) return
+  if (!load()) return
+
+  const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
+  const buf = new Uint32Array(1)
+  if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
+
+  const mode = buf[0]!
+  if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
+  k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
+}
+
+/**
+ * Discard any queued console input (mouse events, key presses, etc.).
+ */
+export function win32FlushInputBuffer() {
+  if (process.platform !== "win32") return
+  if (!process.stdin.isTTY) return
+  if (!load()) return
+
+  const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
+  k32!.symbols.FlushConsoleInputBuffer(handle)
+}
+
+let unhook: (() => void) | undefined
+
+/**
+ * Keep ENABLE_PROCESSED_INPUT disabled.
+ *
+ * On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
+ * ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
+ * (sometimes on a later tick), and the flag is console-global, not per-process.
+ *
+ * We combine:
+ * - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
+ * - A low-frequency poll as a backstop for native/external mode changes.
+ */
+export function win32InstallCtrlCGuard() {
+  if (process.platform !== "win32") return
+  if (!process.stdin.isTTY) return
+  if (!load()) return
+  if (unhook) return unhook
+
+  const stdin = process.stdin as any
+  const original = stdin.setRawMode
+
+  const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
+  const buf = new Uint32Array(1)
+
+  if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
+  const initial = buf[0]!
+
+  const enforce = () => {
+    if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
+    const mode = buf[0]!
+    if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
+    k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
+  }
+
+  // Some runtimes can re-apply console modes on the next tick; enforce twice.
+  const later = () => {
+    enforce()
+    setImmediate(enforce)
+  }
+
+  let wrapped: ((mode: boolean) => unknown) | undefined
+
+  if (typeof original === "function") {
+    wrapped = (mode: boolean) => {
+      const result = original.call(stdin, mode)
+      later()
+      return result
+    }
+
+    stdin.setRawMode = wrapped
+  }
+
+  // Ensure it's cleared immediately too (covers any earlier mode changes).
+  later()
+
+  const interval = setInterval(enforce, 100)
+  interval.unref()
+
+  let done = false
+  unhook = () => {
+    if (done) return
+    done = true
+
+    clearInterval(interval)
+    if (wrapped && stdin.setRawMode === wrapped) {
+      stdin.setRawMode = original
+    }
+
+    k32!.symbols.SetConsoleMode(handle, initial)
+    unhook = undefined
+  }
+
+  return unhook
+}