Skip to content

Release 2.7.4 — stopped jobs no longer wreck the terminal - #61

Merged
LESdylan merged 6 commits into
mainfrom
develop
Aug 23, 2026
Merged

Release 2.7.4 — stopped jobs no longer wreck the terminal#61
LESdylan merged 6 commits into
mainfrom
develop

Conversation

@LESdylan

Copy link
Copy Markdown
Member

Ships v2.7.4. Two reports from real sessions; one of them could leave your
terminal unusable, so this is worth taking on its own if you are on 2.7.x.

#58 — leaving a shell with a stopped job wrecked the terminal

top & then Ctrl-D, and the terminal you came back to had no echo and spliced your
next command into garbage — sometimes. Four defects, stacked, each hiding the next:

  1. Ctrl-D never asked. The stopped-jobs guard existed and worked, but only the
    exit builtin called it. Both end-of-input paths set "time to leave" themselves.
  2. The guard read a stale answer. A job the kernel stops the moment it touches
    the terminal is only recorded when the shell next reaps — on a later prompt. So
    top & then an immediate exit saw a running job and let you out, while the same
    pair with any command in between saw a stopped one and warned. That is the entire
    "sometimes it works": a race, not a flake.
  3. The warning never came back. Forgotten only by a builtin, so an external
    command in between left it standing: warn once, run ps, press Ctrl-D, and the
    shell walked out over a job it had already been told about.
  4. And the damage itself. On the exit you did mean, the shell handed the
    terminal back and left while the jobs it had just hung up were still dying
    and a full-screen program does not die quietly. Dozens of tops writing to the
    tty after the shell let go of it. It now hangs them up, waits for them, and
    only then restores the terminal — including the settings it started with, because
    a job stopped halfway through raw mode never can.

#56 — a new release stayed invisible until you typed update

The banner's "X available — run update --now" line renders fine and was simply
unreachable: it asked a flag owned by the prompt's one-shot notice, and the prompt
always spoke first. The banner now keeps its own record and announces each release
once. Plus HELLISH_BANNER=0|1, since there was no knob anybody guessed.

Verification

develop is 33/33 green on 848c305, including the full platform matrix, the
hard corpus, allocator parity and the pty gates. Locally: pty suite 27 ok / 0
failed
, golden suite 3790/3790 vs bash, norm clean.

Closes #58

LESdylan and others added 6 commits August 23, 2026 02:15
Reported in #56 from a real session on 2.7.2 with 2.7.3 out. Three defects.

1. `BANNER=1 hellish` did nothing.
   There was no such knob. The two that existed, HELLISH_NO_BANNER and
   HELLISH_ALWAYS_BANNER, are two names for the two ends of one tri-state
   and neither is the one anybody guesses. There is now a single
   HELLISH_BANNER=0|1. Both old names still work -- they are documented and
   they are in people's rc files, so dropping them would be a worse bug than
   the one being fixed. Precedence is "off wins": NO_BANNER, then
   HELLISH_BANNER, then the legacy force, because a stray force in a login
   file must never override a deliberate silence on the command line.

   show_welcome() also read HELLISH_NO_BANNER itself, so the "should it
   show" rule lived in two places and only one of them would have learned
   about the new name. It now asks banner_should_show() and nothing else.

2. The banner could never announce a pending release.
   banner_should_show() re-showed the panel for "an update the user has not
   been told about" by testing `s.notified` -- a flag owned by a DIFFERENT
   channel, the prompt's one-shot between-commands notice. Whichever fired
   first silenced the other, and the prompt always won: the session that
   discovers a release draws its banner before the background check has
   written anything, and by the next session `notified` was set and the
   banner was gated off for good.

   So status_line()'s "X available - run update --now" row, which renders
   perfectly well, was unreachable in the normal flow. Users only ever
   learned about a release by typing `update`, which is exactly what the
   report shows.

   The banner now records what IT announced, in its own `announced` field,
   and re-shows whenever a pending version is not the one it last named.
   Two channels, two records, neither able to mute the other. It announces
   each release once and then leaves the standing reminder to the prompt
   badge -- which already persisted correctly across sessions and is now
   pinned so it stays that way.

3. The check was started after the banner that reports its result.
   main() called show_welcome() and only then maybe_spawn_update_check(),
   guaranteeing the discovering session read a cold cache. Swapped.

Unchanged, and now guarded: the check is a detached double-fork and the
prompt never waits on the network (#20). The new test points the shell at a
black-holed endpoint with a cold cache and asserts first output still
arrives in well under a second.

tests/banner_update_test.py drives real ptys against a seeded state file --
no network, so it is deterministic on any runner -- and covers the knob's
four precedence cases, the banner announcing a pending release unprompted,
announcing it once per version rather than nagging, announcing again when a
newer one appears, the badge persisting across sessions and clearing once
the shell is current, and the two startup-timing cases. 5 checks fail
before this change.

Verified: pty suite 26 ok / 0 failed, make update-test green,
update_badge_test and update_ui_test green, norm clean.

Closes #56
…minal

Reported in #58: `top &` then Ctrl-D, and the terminal you came back to had
no echo and spliced your next command into garbage. Intermittently -- the
same keystrokes sometimes warned and stayed, sometimes walked out.

Four defects, stacked, each hiding the next.

1. Ctrl-D never asked the guard.
   exit_stopped_guard() existed and worked, but only the `exit` BUILTIN
   called it. handle_eof_or_error() and handle_eof() set should_exit
   themselves. Every report came in through Ctrl-D. Both now go through
   rl_eof_exit_ok(), one place that answers "may this EOF end the shell".

2. The guard read a stale job table.
   A job stopped by SIGTTIN/SIGTTOU is only recorded when the shell next
   reaps, at the top of a later REPL turn. `cat &` followed immediately by
   exit saw JOB_RUNNING and let the user out; the same pair with any
   command in between saw JOB_STOPPED and warned. That is the whole of the
   "sometimes it works" -- a race, not a flake. It reaps before deciding.

3. The warning never re-armed.
   exit_warned was cleared only when a BUILTIN ran, so an external command
   left it set: warn once, run `ps`, press Ctrl-D, and the shell walked out
   over a job it had already been told about. It is now aged one turn at a
   time in open_cycle, which is bash's actual rule -- the warning stands
   only while the previous turn was itself an attempt to leave.

   A single Ctrl-D also reaches BOTH EOF paths in one turn, so the first
   warned and the second read exit_warned as "already told them" and left.
   rl.eof_refused makes one keypress get one answer.

4. And the damage itself, which survived all three.
   On the deliberate exit the shell restored the terminal and left while
   the hung-up jobs were still dying -- and a full-screen program does not
   die quietly: it repaints, restores its own terminal idea and prints a
   farewell. With 25 stopped `top`s that is 25 processes writing to the tty
   AFTER the restore, on a terminal that now belongs to the parent. Hence
   the screenful of blank lines and the mangled command.

   jobs_hangup_on_exit() now sends SIGCONT+SIGHUP (continue first -- a
   stopped process never runs its handler) and DRAINS them, bounded to two
   seconds, before off() hands the terminal back. tty_snapshot_save/restore
   keeps the settings the shell started with, because a background job
   stopped mid-raw-mode can never restore them itself.

The choice, since shells differ. bash refuses first and then leaves the
jobs behind; it can afford that because it is normally the session leader
and the kernel hangs them up when the terminal goes. A nested hellish is
not, so a job left stopped there holds a raw terminal forever. hellish
keeps bash's warning semantics -- refuse the first attempt, obey the
second -- and then hangs up cleanly on the way out. It is also
deliberately stricter than bash in one case: bash's `jobs` marks what it
lists as notified and then leaves without a word, silently abandoning your
stopped jobs; hellish warns anyway.

tests/exit_stopped_jobs_test.py drives real ptys, uses bash as the oracle
for the damaging cases, and covers: the race, both exit paths, the warning
re-arming after an external command AND a builtin, many stopped jobs at
once, raw-mode jobs, that the terminal comes back usable, and that nothing
of ours survives. 8 checks fail before this change.

Verified: pty suite 27 ok / 0 failed, golden suite 3790/3790 vs bash,
norm clean.

Closes #58
fix(jobs): leaving a shell with stopped jobs no longer wrecks the terminal
Two reports from real sessions. One of them could leave a terminal
unusable, which is what makes this worth shipping on its own.

  #58  Leaving a shell that still held a stopped job wrecked the terminal.
       Four defects stacked: Ctrl-D never asked the stopped-jobs guard; the
       guard read a job table it had not reaped, so the answer depended on
       typing speed rather than on the jobs; the warning was forgotten only
       by a builtin, so any external command in between let the next Ctrl-D
       walk out; and on the exit the user did mean, the shell handed the
       terminal back while the jobs it had just hung up were still dying
       and still writing to it.

  #56  A new release stayed invisible until you typed `update`. The banner
       line that announces one was unreachable -- it asked a flag owned by
       the prompt's one-shot notice, and the prompt always spoke first.
       Also adds HELLISH_BANNER=0|1, since there was no knob anyone guessed.

Patch, not minor: no new builtin, no new syntax. HELLISH_BANNER is a new
name for a control that already existed at both ends, and both old names
still work.

version.h, npm/package.json, the release notes and wiki/context.md agree;
`make update-config-test` green; the binary reports 2.7.4.
@LESdylan
LESdylan merged commit 13a918b into main Aug 23, 2026
74 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Ctrl-D with a stopped background job abandons it and wrecks the terminal

1 participant