mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 14:24:29 +00:00
fix(core): restore Ctrl+C in Windows terminals (#47163)
This commit is contained in:
parent
0ec1587948
commit
7ba5f3e5b2
3 changed files with 126 additions and 0 deletions
|
|
@ -1,8 +1,24 @@
|
|||
import { dlopen } from "bun:ffi"
|
||||
import { spawn } from "bun-pty"
|
||||
import type { Opts, Proc } from "./pty.js"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty.js"
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const library = dlopen("kernel32.dll", {
|
||||
SetConsoleCtrlHandler: { args: ["ptr", "i32"], returns: "i32" },
|
||||
GetLastError: { args: [], returns: "u32" },
|
||||
})
|
||||
try {
|
||||
// Detached servers start with Ctrl+C ignored, and ConPTY shells inherit it.
|
||||
// Clear that attribute once before spawning shells; keep registered handlers.
|
||||
if (library.symbols.SetConsoleCtrlHandler(null, 0) === 0)
|
||||
throw new Error(`Failed to enable PTY Ctrl+C handling: Windows error ${library.symbols.GetLastError()}`)
|
||||
} finally {
|
||||
library.close()
|
||||
}
|
||||
}
|
||||
|
||||
function spawnPty(file: string, args: string[], opts: Opts): Proc {
|
||||
const pty = spawn(file, args, opts)
|
||||
return {
|
||||
|
|
|
|||
69
packages/core/test/fixture/pty-windows.ts
Normal file
69
packages/core/test/fixture/pty-windows.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { spawn } from "../../src/pty/pty.bun"
|
||||
|
||||
const raw = process.argv[2] === "raw"
|
||||
const pty = spawn(
|
||||
Bun.which("pwsh") ?? "powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
...(raw ? [] : ["-NoExit"]),
|
||||
"-Command",
|
||||
raw
|
||||
? "[Console]::TreatControlCAsInput = $true; Write-Output 'PTY_RAW_READY'; $key = [Console]::ReadKey($true); Write-Output ('PTY_KEY:' + [int]$key.KeyChar)"
|
||||
: "Remove-Module PSReadLine -ErrorAction SilentlyContinue; function prompt { 'PTY_PROMPT> ' }",
|
||||
],
|
||||
{ name: "xterm-256color", cols: 160, rows: 24, env: { ...process.env, TERM: "xterm-256color" } },
|
||||
)
|
||||
const output = { text: "", cursor: 0 }
|
||||
const listeners = new Set<() => void>()
|
||||
const exited = Promise.withResolvers<number>()
|
||||
pty.onExit((event) => exited.resolve(event.exitCode))
|
||||
pty.onData((text) => {
|
||||
output.text += text
|
||||
listeners.forEach((check) => check())
|
||||
})
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
await waitFor("PTY_RAW_READY")
|
||||
pty.write("\x03")
|
||||
await waitFor("PTY_KEY:3")
|
||||
}
|
||||
if (!raw) {
|
||||
await waitFor("PTY_PROMPT>")
|
||||
// Split markers so echoed command text cannot satisfy the output checks.
|
||||
pty.write("Write-Output ('PTY_' + 'BUSY'); Start-Sleep -Seconds 60; Write-Output ('PTY_' + 'COMPLETED')\r")
|
||||
await waitFor("PTY_BUSY")
|
||||
pty.write("\x03")
|
||||
await waitFor("PTY_PROMPT>")
|
||||
assert.ok(!output.text.includes("PTY_COMPLETED"))
|
||||
pty.write("Write-Output ('PTY_' + 'REUSED')\r")
|
||||
await waitFor("PTY_REUSED")
|
||||
await waitFor("PTY_PROMPT>")
|
||||
pty.write("exit 0\r")
|
||||
}
|
||||
assert.equal(await exited.promise, 0)
|
||||
} finally {
|
||||
pty.kill()
|
||||
}
|
||||
process.exit(0)
|
||||
|
||||
function waitFor(text: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
listeners.delete(check)
|
||||
reject(new Error(`Timed out waiting for ${JSON.stringify(text)}: ${output.text}`))
|
||||
}, 5_000)
|
||||
const check = () => {
|
||||
const index = output.text.indexOf(text, output.cursor)
|
||||
if (index === -1) return
|
||||
output.cursor = index + text.length
|
||||
listeners.delete(check)
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
listeners.add(check)
|
||||
check()
|
||||
})
|
||||
}
|
||||
41
packages/core/test/pty/pty-windows.test.ts
Normal file
41
packages/core/test/pty/pty-windows.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { expect } from "bun:test"
|
||||
import { spawn } from "node:child_process"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const windowsTest = process.platform === "win32" ? it.live : it.live.skip
|
||||
|
||||
Array.of("interrupt", "raw").forEach((scenario) => {
|
||||
windowsTest(
|
||||
scenario === "interrupt"
|
||||
? "detached PTY hosts interrupt commands without closing the shell"
|
||||
: "detached PTY hosts preserve Ctrl+C input for raw-mode programs",
|
||||
Effect.gen(function* () {
|
||||
// Isolate the inheritable Windows console state from the test runner.
|
||||
const worker = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const child = spawn(process.execPath, [path.join(import.meta.dir, "../fixture/pty-windows.ts"), scenario], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
timeout: 30_000,
|
||||
})
|
||||
const output: string[] = []
|
||||
child.stderr.on("data", (chunk: Buffer) => output.push(chunk.toString()))
|
||||
const exited = new Promise<number | null>((resolve, reject) => {
|
||||
child.once("error", reject)
|
||||
child.once("close", resolve)
|
||||
})
|
||||
return { child, output, exited }
|
||||
}),
|
||||
(worker) =>
|
||||
Effect.sync(() => {
|
||||
if (worker.child.exitCode === null && worker.child.signalCode === null) worker.child.kill()
|
||||
}),
|
||||
)
|
||||
const code = yield* Effect.promise(() => worker.exited)
|
||||
expect({ code, output: worker.output.join("") }).toEqual({ code: 0, output: "" })
|
||||
}),
|
||||
35_000,
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue