Repository navigation
Conversation
Member
|
To any future submitters. Please don't feel you need to write a 4000 word description for a 10-line change. |
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When Terminator's window is closed, the shell sometimes fails to flush its history file (the path bash's
$HISTFILEpoints at, defaulting to~/.bash_history). With bash 5.2 patch 14 or later (released December 2022) the loss is frequent — typically observed on a sizeable fraction of window closes, more under load — rather than the occasional flake it used to be. The cause is two-layered:Terminal.close()sendsSIGHUPto the shell and immediately tears down the VTE widget, closing the PTY master. This is a fire-and-forget pattern with no synchronization between the application-level signal and the kernel-level PTY hangup that follows microseconds later.6647917a, 2022-12-13) added a code path insig.cthat, when a second terminating signal arrives while the shell'stermsig_handleris mid-flight, short-circuits tokill_shell(sig)— terminating bash immediately, beforemaybe_save_shell_history()finishes writing to disk. Bash 5.2 patch 14 didn't cause the race — Terminator's close pattern has always delivered two SIGHUPs in rapid succession — it removed the implicit protection that had been making the race benign. Whether the resulting kill arrives before or after the history write completes depends on system timing, so the user-visible loss is intermittent but now common (empirically ~34% in the harness, higher under load).This PR fixes the Terminator side by inserting a short, bounded wait so the shell finishes exiting (and the history write completes) before the PTY is destroyed. The fix is correct and complete on its own: by the time the kernel would deliver the second SIGHUP via tty hangup, bash is already gone and there is no recipient to interrupt. Terminator users on every affected bash version get correct behavior the moment this lands.
The bug
Closing the Terminator window — via the
[X]button, the keybinding, or exiting the last terminal — sometimes leaves commands typed in the session out of bash's history file. The behavior is non-deterministic: identical sessions can save or drop history depending on scheduling.Root cause — Terminator side
In
terminatorlib/terminal.py,Terminal.close()used to do this:The window-destroy path (
terminatorlib/window.py:294-310) callsterminal.close()for each terminal and thenself.destroy()on the window. The above body has zero delay betweenSIGHUPand PTY teardown.Concretely the race looks like this:
os.kill(self.pid, signal.SIGHUP)— bash enters its termination handler, which runs theEXITtrap and writes$HISTFILEviamaybe_save_shell_history().self.terminalbox.remove(self.vte)anddel(self.vte)finalize the VTE widget, closing the PTY master.SIGHUPto bash's foreground process group — a second hangup arriving while bash is still flushing.Whether bash finishes writing
$HISTFILEbefore step 2/3/4 cuts it short depends partly on kernel scheduling — but as the next section explains, the bash side of the equation also changed in late 2022 in a way that makes the race much harder to win. That is why this same Terminator code was effectively race-free for years on bash ≤ 5.2 patch 13 yet loses on bash ≥ 5.2 patch 14.Root cause — bash side (the reason losing the race went from harmless to costly)
Bash 5.2 patch 14 (Chet Ramey, commit
6647917a, 2022-12-13, "process additional terminating signals when running the EXIT trap after a terminating signal") added a new short-circuit totermsig_sighandlerinsig.c:handling_termsigis set bytermsig_handlerwhile it is running the shell's cleanup, includingmaybe_save_shell_history(). If a second terminating signal arrives during that window, the new branch callskill_shell(), which restores the default disposition for the signal andkill(getpid(), sig)s the shell — bypassing whatever cleanup was in progress.Why this lands on Terminator specifically
Terminator's close path delivers two SIGHUPs to the same bash, microseconds apart:
os.kill(self.pid, signal.SIGHUP)from the close path. Bash enterstermsig_sighandler→termsig_handler→ setshandling_termsig = SIGHUP→ startsmaybe_save_shell_history()which opens$HISTFILEand begins writing.termsig_sighandlerseeshandling_termsig != 0and callskill_shell(SIGHUP). Bash terminates immediately. The file write started in step 1 is interrupted partway through —$HISTFILEis truncated, empty, or missing the most recent entries depending on where bash was in thefopen/fputs/fclosesequence.The pre-patch-14 code had no such short-circuit. A second SIGHUP went through the standard
terminate_immediatelycheck, which explicitly exempts SIGHUP, SIGTERM, SIGINT, etc. from immediate termination. The duplicate was effectively swallowed;maybe_save_shell_history()ran to completion; history saved correctly.The bash author flagged the choice deliberately in the source comment, naming the exact one-line revision that would restore the older behavior: "to implement the former, replace the kill_shell(sig) with return."
Why this fix on the Terminator side works regardless of bash
With this PR applied, the timeline becomes:
maybe_save_shell_history(), runs its EXIT trap, and terminates cleanly via thekill_shellcall at the end oftermsig_handler(the intentional one, not the regression branch).The race the bash patch-14 branch turns into data loss simply cannot happen, because no second SIGHUP is delivered to a live bash. The fix is durable across bash versions: it works on pre-patch-14 bash (where it would also be correct, just slightly less necessary) and on post-patch-14 bash (where it is required to avoid data loss).
Regression history
This is a two-step regression. A latent Terminator-side race was introduced in 2017 — the second SIGHUP has always been getting delivered to bash a few microseconds after the first. What changed in late 2022 was not the race itself but bash's tolerance for losing it: a bash-side change removed an implicit protection that had been silently absorbing the duplicate SIGHUP. The race outcome flipped from "swallowed, history saved" to "frequently fatal, history sometimes truncated mid-write." Bash didn't cause the bug — it stopped masking it.
2010-01-25 —
8d8681e6(Chris Jones, "... we now specifically send a SIGHUP to the child of a terminal when that terminal is explicitly close()d"). At this pointTerminal.close()was just:The PTY/VTE widget was not destroyed here — that happened later, naturally interleaved with GTK's destroy chain, which gave bash plenty of time to flush history between the
SIGHUPand the PTY going away. No race; history was reliably saved.2017-02-11 —
c4d372c5(Stephen Boddy, "Fixes vte object not being released properly, and holding open hidden /tmp files.") added inline VTE teardown immediately after theSIGHUP:def close(self): """Close ourselves""" dbg('close: called') - self.cnxids.remove_signal(self.vte, 'child-exited') + self.cnxids.remove_widget(self.vte) self.emit('close-term') try: dbg('close: killing %d' % self.pid) … + if self.vte: + self.terminalbox.remove(self.vte) + del(self.vte)That commit fixed a real bug — VTE widgets were leaking and holding open files in
/tmp— by forcing the widget reference to drop synchronously insideclose()instead of relying on GC during the GTK destroy chain. But pulling the teardown intoclose()means it now runs ~microseconds after theSIGHUP, with no synchronization between the two. That is the race this PR fixes: the shell is given a hangup signal and then has its PTY ripped out from under it before itsSIGHUPhandler has a chance to runmaybe_save_shell_history()to completion.2022-12-13 — bash 5.2 patch 14, commit
6647917a(Chet Ramey). Promoted bash'shandling_termsigflag from atermsig_handlerlocal to a file-scope static, and added the newif (handling_termsig) kill_shell(sig)branch intermsig_sighandler. The latent Terminator-side race had existed since 2017; this bash change is what made the race start losing reliably.So the bug surface is:
Terminal.close()behavior[X]?SIGHUP, then GTK destroys the widget naturally laterSIGHUP, then immediatelyterminalbox.remove(vte)+del(vte)SIGHUP, wait for shell to exit (≤ 500 ms,os.waitid+WNOWAIT), thenterminalbox.remove(vte)+delThe fix restores the pre-2017 invariant ("shell gets time to flush before its PTY dies") without reintroducing the leak
c4d372c5solved: the wait happens betweenSIGHUPand the widget teardown, so the widget is still released synchronously insideclose()— just a few tens of milliseconds later, after the shell has finished its work. It also closes the race in a way that does not depend on bash's behavior: it works regardless of which bash version the user is running.Fix
Briefly wait for the shell to actually exit before destroying the VTE widget. The wait is bounded (500 ms cap, 20 ms poll interval) so a hung shell can't freeze the UI.
Why
os.waitidwithWNOWAITVTE installs its own GLib child watch on the shell PID, which internally relies on
SIGCHLD+waitpidto reap and report the exit status via thechild-exitedsignal. Ifclose()called plainos.waitpid(), we would reap the child first; VTE's watch would then getECHILDand its bookkeeping would diverge.os.waitid(P_PID, pid, WEXITED | WNOWAIT | WNOHANG):WEXITED— match a process that has terminated.WNOHANG— return immediately when the child is still running, so we can poll without blocking the GTK main loop indefinitely.WNOWAIT— do not reap the child. It stays as a zombie so VTE's child watch can collect it normally.This lets us detect "the shell has finished" without taking the status away from VTE.
Behavior after the fix
$HISTFILEis written reliably.exit_action=closetriggered by the shell exiting on its own):os.waitidraisesOSError(ECHILD) and we return immediately.Reproducing the bug
The race is intermittent in normal operation, which makes ad-hoc reproduction frustrating: a single window close usually saves history fine. Two things were needed to drive reproduction:
Terminal.close()that drives the close path into the pre-fix behavior (and optionally guarantees loss withSIGKILLfor a 100%-deterministic check).wmctrl(the same code path as clicking[X]).The debug shim (development-only, not shipped)
The shim is gated on an env var so it stays dormant unless explicitly enabled. It is not in the shipping patch — it was inserted into a local copy of
terminatorlib/terminal.pyduring development to drive the reproduction script. To re-run the reproduction below, paste the shim back intoTerminal.close()(replacing the SIGHUP block) for the duration of testing:Why the shim was needed:
hupmode reproduces the bug exactly: same signal, same lack of wait. But because the race is real, loss is intermittent — typically a handful out of 50 closes on a modern machine.killmode sendsSIGKILLinstead. Bash can't install a handler for it, so the EXIT trap andmaybe_save_shell_history()are never reached. Loss is 100% deterministic, which is the right mode for binary "fix works / doesn't" CI checks.offmode (env var absent) takes the shipping path and is used to confirm the fix actually fixes the symptom.The reproduction harness
The harness used to drive the numbers in the Results table below is not committed as a tracked file in this PR; it is documented inline so anyone reading this can paste it into a local script and reproduce the same measurements. Save the script below as
/tmp/repro.sh,chmod +x, and invoke it from the repo root.Each iteration:
HISTFILE,unset PROMPT_COMMAND(so distros that flush per-command viahistory -acan't mask the test), and a uniquemarker-N-pid-randpre-seeded into in-memory history withhistory -s../terminator -u …(the in-tree binary, so the shim is actually loaded;-ubypasses D-Bus single-instance routing which would silently drop the env var).wmctrl -luntil the new window appears, then callswmctrl -c <title>— this sendsWM_DELETE_WINDOW, the same event clicking[X]produces and the only path that triggersTerminal.close()(kill -HUP $PPIDand friends route through the unrelatedexit_actionhandler instead).HISTFILEversus how many were attempted. The exit code is non-zero if any were dropped.Usage (from the repo root, after pasting the script into
/tmp/repro.shandchmod +x):/tmp/repro.sh 50 # default: DEBUG_MODE=hup DEBUG_MODE=kill /tmp/repro.sh 50 DEBUG_MODE=off /tmp/repro.sh 50Avoidance traps the harness handles automatically:
pkill+-u).PROMPT_COMMAND='history -a'(overridden in the rcfile).Terminal.close()onWM_DELETE) from the unrelated exit-action path (bashexits voluntarily)../terminatorrather than the system binary, so the debug shim is actually loaded.Results (50 iterations each)
With the debug shim temporarily reinstated for the
hupandkillrows:hupkilloffRaw output:
The
huprow demonstrates that the bug is real and reproducible in the pre-fix code path. Thekillrow is a sanity check on the harness itself (ifkilldidn't lose anything, the test would be broken). Theoffrow is the proof that the fix closes the race: across 50 window closes through the shippingTerminal.close(), every single marker landed inHISTFILE.