fix(core): restore Ctrl+C in Windows terminals (#47163)

This commit is contained in:
Luke Parker 2026-09-04 09:16:14 +10:00 committed by GitHub
parent 0ec1587948
commit 7ba5f3e5b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 126 additions and 0 deletions

View file

@ -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 {

View 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()
})
}

View 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,
)
})