diff --git a/src/bun.js/bindings/c-bindings.cpp b/src/bun.js/bindings/c-bindings.cpp index 71c3e46c79..8d12608f3e 100644 --- a/src/bun.js/bindings/c-bindings.cpp +++ b/src/bun.js/bindings/c-bindings.cpp @@ -394,6 +394,22 @@ extern "C" void bun_restore_stdio() #if !OS(WINDOWS) + // Restore cursor visibility on TTY before exiting. + // Many CLI applications (like Ink) hide the cursor during operation and rely on + // cleanup handlers to restore it. If the process exits before the cursor-show + // escape sequence is flushed, the cursor remains invisible in the terminal. + // Writing the cursor-show sequence here ensures the cursor is always visible + // after Bun exits, regardless of how the process terminates. + // See: https://github.com/oven-sh/bun/issues/26642 + if (bun_stdio_tty[STDOUT_FILENO]) { + // Show cursor: CSI ? 25 h + const char show_cursor[] = "\x1b[?25h"; + ssize_t ret; + do { + ret = write(STDOUT_FILENO, show_cursor, sizeof(show_cursor) - 1); + } while (ret == -1 && errno == EINTR); + } + // restore stdio for (int32_t fd = 0; fd < 3; fd++) { if (!bun_stdio_tty[fd]) diff --git a/test/regression/issue/26642.test.ts b/test/regression/issue/26642.test.ts new file mode 100644 index 0000000000..3e1f592f18 --- /dev/null +++ b/test/regression/issue/26642.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isMacOS, isWindows } from "harness"; + +// Test that cursor visibility is restored on process exit when stdout is a TTY. +// This is needed because CLI applications like Ink hide the cursor during operation +// and rely on cleanup handlers to restore it. If the process exits before the +// cursor-show escape sequence is flushed, the cursor remains invisible. +// See: https://github.com/oven-sh/bun/issues/26642 + +// Skip on Windows and non-macOS - the script command behavior varies and CI +// environments often don't provide proper PTY support. The actual fix is +// most critical for macOS terminals where users reported the issue. +test.skipIf(isWindows || !isMacOS)("cursor visibility is restored on exit when stdout is TTY", async () => { + // Check if script command is available (needed to create a PTY) + const hasScript = Bun.which("script"); + if (!hasScript) { + console.log("Skipping test: requires 'script' command for PTY simulation"); + return; + } + + // Script that just exits immediately - cursor restore should happen automatically + const testScript = `process.exit(0);`; + + // Use script command to provide a PTY environment (macOS syntax) + const scriptCmd = ["script", "-q", "/dev/null", bunExe(), "-e", testScript]; + + await using proc = Bun.spawn({ + cmd: scriptCmd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The cursor-show escape sequence is \x1b[?25h + // It should be present in stdout when running in a TTY + const cursorShow = "\x1b[?25h"; + expect(stdout).toContain(cursorShow); + expect(exitCode).toBe(0); +});