fix(ui): stop status bar / chat bleed-through over the external editor #131
Closed
jabber.developer2
wants to merge 11 commits from
fix/editor-ui-suspend-redraw into master
pull from: fix/editor-ui-suspend-redraw
merge into: devs:master
devs:master
devs:feat/ai-api-type
devs:fix/editor-terminal-size
devs:ci/docker-hub-publishing
devs:feat/disco-ac
devs:feat/privacy-enhancements
devs:fix/clientid-regression
devs:fix/ai-chat-completions-followup
devs:feat/ai-custom
devs:fix/unencrypted-send
devs:rollback/pre-upstream-merge
devs:fix/autoping-warning-null-domain
devs:fix/pad-dead-space-reclaim
devs:feat/autoping-warning
devs:chore/untrack-gitversion
devs:fix/multiline-pad-clip
devs:fix/issue-112-followups
devs:fix/issue-128-migrate-v3-dedup
devs:fix/delay-timestamp-validation
devs:ref/light-cleanup
devs:fix/history-scroll-pad-redraw-storm
devs:fix/plugin-post-display-incoming-only
devs:merge/upstream-full
devs:merge-improve
devs:chore/remove-chatlog-stage-1
devs:fix/ai-json-encoding
devs:feat/no-db-backlog-114
devs:fix/scroll-non-chat-windows
devs:fix/paged-non-chat-windows
devs:fix/ai-leaks
devs:feat/ai
devs:fix/ai-followups
devs:test/ai-coverage-unit-only
devs:test/ai-coverage
devs:refactor/scroll-mechanism
devs:fix/verify-per-contact-context
devs:feat/no-db-mode
devs:feat/ai-json
devs:feat/pikaur-parity-arch
devs:fix-pikaur-build
devs:feat/upstream-sync
devs:feat/functest-speedup
devs:fix/cwe-134-format-string-audit
devs:ci/separate-build-step
devs:test/autoping-functional-tests
devs:test/db-functional-tests
devs:fix/arch-build
devs:fix/xep-0030-disco-items-error-handling
devs:fix/xep-0030-empty-disco-items
devs:playground/fix/src_refactoring
devs:tests/disco
devs:fix/test-CI-stability
devs:feat/parallel-tests-clean
devs:feat/parallel-functional-tests
devs:fix/functional_tests_v2
devs:fix/functional_tests
devs:fix/connect_max_args
devs:feat/extended_debug_info
devs:playground/fix/scroll-stuck
devs:build/multicore
devs:build/reenable-fedora
devs:build/autoupdate
No Reviewers
Dismiss Review
Are you sure you want to dismiss this review?
Labels
Clear labels
Compat/Breaking
Breaking change that won't be backward compatible
Kind/Bug
Something is not working
Kind/Documentation
Documentation changes
Kind/Enhancement
Improve existing functionality
Kind/Feature
New functionality
Kind/Security
This is security issue
Kind/Testing
Issue or pull request related to testing
Priority
Critical
1
The priority is critical
Priority
High
2
The priority is high
Priority
Low
4
The priority is low
Priority
Medium
3
The priority is medium
Reviewed
Confirmed
1
Issue has been confirmed
Reviewed
Duplicate
2
This issue or pull request already exists
Reviewed
Invalid
3
Invalid issue
Reviewed
Won't Fix
3
This issue won't be fixed
Status
Abandoned
3
Somebody has started to work on this but abandoned work
Status
Blocked
1
Something is blocking this issue or pull request
Status
Need More Info
2
Feedback is required to reproduce issue or to continue work
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: devs/cproof#131
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
No description provided.
Delete Branch "fix/editor-ui-suspend-redraw"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
When composing in an external editor (
/editororAlt+C, e.g. nano), Profanity's UI bleeds through onto the editor screen. Most visible artifact: after a short pause, the status-bar clock's updated minute digit (a single character) appears on top of the editor; a partial status bar and the previous input line can show up too.Root cause
The upstream-merge editor rewrite (blocking pthread +
background_mode→ asyncg_child_watch) dropped thebackground_modeguard that paused the main loop while the editor runs.ui_update/ui_resize/ui_redrawnow self-guard onisendwin(), which is not a reliable "editor active" signal — any realdoupdate()during the session (e.g. the readline redisplay right after theAlt+Ckeypress) clears it. Once cleared, the ~10 Hz main-loopui_update()keeps repainting the status bar, current chat window and input pad, flushing them over the editor withdoupdate(). ncurses emits only changed cells, so in a quiet session the sole frame-to-frame change is the clock digit — hence the "single character" bleed-through tied to minute ticks.Fix
Builds on the upstream fix
5e4b5d313("guard terminal writes while suspended", upstream PR 2167) which is cherry-picked here, and layers seven additional commits across three concerns: closing the suspended-write path upstream left open, fixing pre-existing hang classes around the async editor model (including the Ctrl-Z / hung-editor case), and centralising the no-stray-write invariant into a primitive that can't be silently skipped. Eight commits:fix(ui): guard terminal writes while suspended— upstream (Michael Vetter), cherry-picked. Routesbeep()/flash()throughui_beep()/ui_flash()guarded by!isendwin(), flushes pending output withdoupdate()inui_suspend()beforeendwin(), and guards the_inp_write()redisplaydoupdate()with!isendwin(). Keepsisendwin()a valid "suspended" signal so the existing redraw-path checks hold.fix(ui): guard password/TLS prompt redraw while suspended— ours. Guards thedoupdate()ininp_get_line()/inp_get_password()withis_suspended || isendwin()(matchinginp_readline()'s guard), closing the one suspended-write path upstream left open.fix(ui): add authoritative suspend flag gating redraws over the editor— ours. Adds aui_suspendedflag set inui_suspend()/ui_resume()and gatesui_update/ui_resize/ui_redrawonui_suspended || isendwin()via_ui_redraw_suspended(). Fail-safe against any future rawdoupdate()clearingisendwin().fix(ui): release global lock around suspend-branch sleep in inp_readline— ours. Mirrors the normal-path unlock/sleep/lock so worker threads (http_upload,http_download,ai_client) can acquirelockduring the editor session — otherwise the main thread held it continuously and every in-flight transfer froze for the whole suspend window.fix(ui): iterate main context inside synchronous input loops— ours. Addswhile (g_main_context_iteration(NULL, FALSE)) ;toinp_get_line()/inp_get_password()loops so the editor child watch can dispatch, breaking the deadlock that occurred when a TLS-cert (sv_ev_certfail) or PGP passphrase (ui_ask_pgp_passphrase) prompt was raised from a network handler mid-session.fix(editor): guard against re-entrant launch_editor— ours. Restores theeditor_task.activecheck the pre-merge pthread implementation had; bails with a console error if a plugin (viag_main_context_iterationdispatch) tries to launch a second editor while one is in flight.refactor(ui): centralize physical paint through prof_doupdate()— ours. Introduces a singleprof_doupdate()primitive insrc/ui/core.cthat internally checks the suspend flag; replaces every directdoupdate()insrc/ui/inputwin.cand the trailing one inui_update()with the wrapper, collapsing the inline guards. Turns "remember to guard eachdoupdatesite" into "useprof_doupdate()instead ofdoupdate()" — a single discipline check (e.g. a grep CI rule) rather than per-site review.fix(editor): avoid Ctrl-Z hang on the editor child, add SIGUSR1 escape— ours. Drops theSIGTSTPreset in the editor child:SIGTSTPstays inherited asSIG_IGNfrom profanity's_init, so Ctrl-Z and vim's:stopbecome no-ops inside/editor. This avoids the hang whereg_child_watch(waiting withWNOHANGwithoutWUNTRACED) never wakes for a STOPPED child.SIGINTandSIGPIPEare still reset toSIG_DFLso Neovim's libuv still detects an interactive terminal. As a universal escape for any other hung-editor case (looping$EDITOR, corrupted tty), installs aSIGUSR1handler viag_unix_signal_addthat sendsSIGTERMto the editor child througheditor_emergency_kill(); the existing child watch then runs_editor_exit_cb→ui_resume. Recovery from another terminal:pkill -USR1 profanity.Why three layers
Upstream's commit 1 keeps
isendwin()as the single "suspended" signal and guards each direct terminal write. That is correct but fail-open: any future unguardeddoupdate()/refresh()reachable during a session clearsisendwin(), and the periodicui_update()repaints over the editor — exactly how the original bug slipped in.Commit 7's
prof_doupdate()wrapper generalises the upstream approach by centralising the primitive. New code that paints throughprof_doupdate()is automatically guarded; the no-stray-write invariant collapses from a per-site whitelist to a single discipline check ("use the wrapper, not rawdoupdate").Commit 3's
ui_suspendedflag is the fail-safe orthogonal to both. It gatesui_update/ui_resize/ui_redrawat the consumer side, so even if some path flipsisendwin()mid-session, the periodic full-screen repaint that produces the visible bleed cannot run. The flag does not cover direct writes (which is why the upstream guards and the wrapper are both still needed), but it makes the dominant damage path declaratively safe.Together: upstream guards plug the known direct-write sites; the wrapper makes future ones automatic; the flag keeps the worst-case repaint impossible regardless of either.
Verify
Alt+C(or/editor), type a longish line, stay idle ≥ 1 minute. Before: the minute digit / status bar bled onto the editor. After: nothing repaints over the editor.Behavior verification
Traced through the relevant scenarios against the actual code on the branch.
Alt+C, idle ≥ 1 minuteui_updatereturns at_ui_redraw_suspended()beforestatus_bar_draw/win_update_virtual/doupdate.doupdate()clearsisendwin()mid-sessionui_updatefrom repainting_ui_redraw_suspended()ORs the flag withisendwin(); the flag held alone keeps the gate closed (fail-safe).Alt+C_inp_writeskipsdoupdate()whileisendwin().ui_beep/ui_flashduring session!isendwin()guard;isendwin()staysTRUEbecause alldoupdatesites are guarded.prof_doupdatechecksis_suspended || isendwin()) skip the entrydoupdate(). Commit 5'sg_main_context_iterationinside the loop lets the editor child watch dispatch, soui_resumeruns as soon as the editor closes and the prompt resumes accepting input — no externalkill -9required.ui_suspendorderinginp_suspenddeprep, flush pending output, set the flag, thenendwin()src/ui/core.c:inp_suspend(),prof_doupdate()(flag stillFALSEso it flushes), setui_suspended,endwin(). Flushing before flipping the flag preserves upstream's pre-endwinflush.ui_resumeorderingrefresh(), theninp_resume()refresh(),isendwin()is stillTRUE, so the gate still holds. Afterrefresh()both are clear and normal operation resumes._editor_exit_cb→ui_resume()→ui_resize()→ callbacksrc/tools/editor.c,ui_resizeruns afterui_resume, so its_ui_redraw_suspended()check isFALSEand the resize proceeds.Build: clean under
-Werror. The flag and_ui_redraw_suspendedhelper are file-static insrc/ui/core.c; commit 7 adds aprof_doupdate(void)declaration insrc/ui/ui.hand a corresponding stub intests/unittests/ui/stub_ui.c(next to theui_beep/ui_flashstubs from upstream's commit 1).Pre-existing hang classes closed by this PR
Four hang classes that pre-dated this branch (and are not addressed upstream either) are closed by commits 4 / 5 / 6 / 8:
pthread_mutex_t lock(src/profanity.h) is acquired once by the main thread inprof_run's_initand held for the whole program lifetime, released only around theselect()insideinp_readline(src/ui/inputwin.c). The suspend branch used to skip that unlock — every worker thread (src/tools/http_upload.c,src/tools/http_download.c,src/ai/ai_client.c) blocked for the whole session, long enough to time out in-flight transfers on the server side. Commit 4 mirrors the normal-path unlock/sleep/lock so the window exists during the editor session too.inp_get_line()/inp_get_password()loop with nog_main_context_iteration; if a network handler raised a TLS-cert prompt (sv_ev_certfail→ui_get_line()insrc/event/server_events.c) or PGP passphrase prompt (ui_ask_pgp_passphrase()insrc/pgp/gpg.c) mid-session, the main thread parked in that loop, the editor child watch never dispatched, andkill -9was the only recovery. Commit 5 runsg_main_context_iterationinside the loop so the watch can fire andui_resumeruns as soon as the editor closes.launch_editor. The async editor lost theeditor_task.activecheck the old pthread implementation had; a plugin firinglaunch_editorfrom ag_main_context_iterationdispatch could spawn a second editor that fought the first for the terminal and triggered prematureui_resume. Commit 6 restores an expliciteditor_activestatic, bailing with a console error on re-entry.g_child_watchwaits withWNOHANGwithoutWUNTRACED, so a STOPPED editor (Ctrl-Z, vim's:stop) never woke the watch and the app froze until killed externally. Same for any hung$EDITOR(looping script, corrupted tty). Other TUI chat clients carry this open for years (e.g. weechat/weechat#985); shells solve it with full job control (setpgid+tcsetpgrp+WUNTRACED), heavy for our use case. Commit 8 closes Ctrl-Z by inheritingSIGTSTPasSIG_IGN(the editor cannot be stopped; vim's:stopbecomes a no-op — acceptable trade for not freezing the whole app), and addspkill -USR1 profanityas a universal escape hatch for the remaining hung-editor cases viaeditor_emergency_kill(). ("Broken$EDITOR" was never a trigger —execvpfailure makes the child_exit(EXIT_FAILURE).)Out of scope — architectural limits inherent to the external-editor model
Independent of which approach is in use, the "spawn external editor with terminal handover" model has a few intrinsic ceilings:
libnotify, desktop tray).lockinsrc/profanity.hcouples worker threads to the main loop through one writer; commit 4 opens a window during suspend, but the coarse-grained locking remains.These would require a different model (embedded editor, tmux-style pane) to close — not "fixes".
f5fdc85324to1bc5c51bdb1bc5c51bdbto56e030501756e0305017to394df6d391inp_get_line() / inp_get_password() loop `while (!x) { inp_readline(); ui_update(); }` with no g_main_context_iteration call. If such a prompt is raised from a network handler during an editor session — realistic triggers are TLS-cert verification on a reconnect (sv_ev_certfail -> ui_get_line() in src/event/server_events.c) and PGP passphrase on an incoming encrypted message (ui_ask_pgp_passphrase() in src/pgp/gpg.c) — the main thread is parked in this loop. inp_readline returns NULL (suspended), ui_update returns (ui_suspended), and the editor child watch never dispatches because the main loop never reaches its own g_main_context_iteration. is_suspended stays TRUE forever, the loop never receives input, and the app deadlocks until killed externally. Run the GLib iteration inside the loop body so the child watch can dispatch, _editor_exit_cb fires, ui_resume clears suspension, and the next inp_readline reads from the terminal — letting the user answer the deferred prompt once the editor is closed.generally LGTM. Please address minor issues
@@ -184,1 +185,4 @@// Universal "I'm stuck in the editor" escape hatch. Dispatched in the main// loop by GLib (g_unix_signal_add), so it is safe to call into editor.c// directly. Trigger from another terminal: `pkill -USR1 profanity`.excessive comment length + inefficient/inappropriate tone. please cut here and elsewhere
@@ -35,0 +38,4 @@// guard a second launch_editor (e.g. via a plugin dispatched through// g_main_context_iteration) would call ui_suspend again and fork a second// editor that fights the first one for the terminal, then prematurely// ui_resume when either exits.excessive comment length
@@ -318,1 +324,4 @@ui_update();// Let GLib sources (notably the editor child watch) dispatch so// ui_resume can run if the prompt was raised mid-editor-session.while (g_main_context_iteration(NULL, FALSE))is it safe from infinite looping?
Yes. The inner g_main_context_iteration(NULL, FALSE) is non-blocking — returns FALSE as soon as no source is ready — and the only sources we have (g_child_watch for the editor, tray timer) dispatch once per state change, not re-arm in place. The outer while (!line) is the standard blocking-prompt pattern: it exits when readline accumulates a full line (user Enter). That same loop existed before; the only change is adding the GLib iteration so a prompt raised mid-editor-session can dispatch _editor_exit_cb instead of deadlocking until the editor exits. No new infinite-loop class.
86d924eaactoe4422ac7a1@@ -72,1 +73,4 @@// Signal-driven flags consumed by the main loop. SIGTSTP and a STOPPED editor// child → drop the process group to the shell (mutt-style). SIGCONT →// refresh curses on return.comments are still too verbose. Let's keep implementation details within commit messages.
corrected
@@ -95,1 +102,4 @@log_stderr_handler();// Resume from fg after SIGSTOP. Vim handles its own refresh.if (cont_requested) {Please extract this functionality to a different method if possible. Let's keep main loop minimal.
corrected
b05961e5a4to3f91cbc8b09213272527tob90dec5be3Pull request closed