Why Ctrl-C Leaves Your Terminal Broken Mid-Prompt
You run a tool, it asks for a password, you hit Ctrl-C, and now nothing you type shows up. The terminal is not frozen. It is still in the mode the program put it in, and the program died before it could put things back.
The terminal's settings belong to the terminal device, not to the process. That one fact explains the whole mess.
The settings outlive the process
A terminal (a tty) carries a set of flags in the kernel, called termios. Any process with the tty open can change them, and they stay changed after that process exits. Nobody resets them for you.
The flags that matter for a prompt:
ECHO: the kernel prints back what you type. Password prompts switch this off.ICANON: line editing. The program only sees input once you press Enter. Single-key prompts ("Continue? [y/N]") switch this off.ISIG: Ctrl-C becomes SIGINT, Ctrl-Z becomes SIGTSTP.ICRNLandOPOST: translate Enter to a newline on input, and newline to carriage return plus newline on output.
So a program that hides your typing flips ECHO off, reads a line, and flips it back. If it dies between those two steps, the flag stays off.
Why a deferred restore does not save you
The obvious fix is "restore in a defer". It does not work for a signal. The default action for SIGINT is to terminate the process immediately, and no user-space cleanup runs. Not defer in Go, not Drop in Rust, not a finally block.
Quick detour, because this surprises people: the shell's own prompt usually looks fine afterwards. Bash's readline and zsh's line editor set the modes they want every time they draw a prompt, which hides the problem. It bites when there is no such shell in the way: after an ssh -t command, inside docker exec -it, from a script, or a program that ran under a pager or sudo. In those cases you inherit whatever was left behind.
Raw mode changes what Ctrl-C even is
There is a second wrinkle. Full raw mode (what a TUI or a key-by-key prompt uses) clears ISIG as well. Ctrl-C then produces no signal at all; the program just reads byte 0x03.
That is a design choice: the program takes responsibility for quitting. If it forgets, Ctrl-C does nothing, and people reach for Ctrl-\ or kill from another window. That path leaves the terminal broken too, as SIGQUIT or SIGKILL cannot be caught usefully.
Compare that with golang.org/x/term's ReadPassword. It only clears ECHO and keeps ISIG on, so Ctrl-C sends a real SIGINT and the default action kills the process mid-read.
Fixing it in Go
Save the state before you change it, catch the signals, and restore explicitly in the handler. Do not rely on the deferred path alone.
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"golang.org/x/term"
)
func main() {
fd := int(os.Stdin.Fd())
old, err := term.GetState(fd)
if err != nil {
fmt.Fprintln(os.Stderr, "stdin is not a terminal:", err)
os.Exit(1)
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
<-sig
_ = term.Restore(fd, old)
fmt.Fprintln(os.Stderr)
os.Exit(130) // 128 + SIGINT, what shells expect
}()
fmt.Fprint(os.Stderr, "Password: ")
pw, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
if err != nil {
fmt.Fprintln(os.Stderr, "read failed:", err)
os.Exit(1)
}
fmt.Println("got", len(pw), "bytes")
}
A few things worth noticing:
- The state is captured before
ReadPasswordtouches anything, so the handler can never restore a half-changed state. - Restoring when nothing was changed yet is harmless, so there is no race to worry about.
os.Exitskips defers, which is fine here because the handler did the restore itself.- The prompt goes to stderr, so
pw=$(tool)in a script does not swallow it.
Recovering a broken terminal right now
You may be typing blind, but the shell still hears you. If ICRNL was cleared, Enter sends a carriage return that the shell may not accept, so use Ctrl-J instead, which is a plain newline.
- Press Ctrl-J, type
stty sane, press Ctrl-J again. - If the display is still garbled, type
resetand Ctrl-J. It does more (it re-initialises the terminal), and takes a moment.
In a shell script that needs the same protection, save the state with old=$(stty -g) and use trap 'stty "$old"' EXIT INT TERM. The EXIT trap covers normal returns; the signal traps cover the rest.
What is left
You cannot catch SIGKILL, so an OOM kill or a kill -9 while echo is off will always leave the flag behind. That is acceptable: you recover with stty sane. The point is that the ordinary Ctrl-C should never need it.