From 562bd28cffaeb646a9e8af95c3c1a87ae01e1ed2 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Tue, 19 May 2026 22:25:43 +0200 Subject: [PATCH 01/11] fix(ui): guard terminal writes while suspended We forgot to guard `beep()` and `flush()`. Ref: 36ec2b0ae1fa7afcdfe6f166323955b8bd121b0f Ref: dd76f9087fe93db5ced608ee3c744f299b933cbe Signed-off-by: Michael Vetter Fixes: https://github.com/profanity-im/profanity/issues/2162 (cherry picked from commit 5e4b5d313a9e055e039e2feb4efae58b6aaead78) --- src/event/server_events.c | 4 ++-- src/ui/chatwin.c | 4 ++-- src/ui/core.c | 17 +++++++++++++++++ src/ui/inputwin.c | 4 +++- src/ui/privwin.c | 4 ++-- src/ui/ui.h | 2 ++ tests/unittests/ui/stub_ui.c | 10 ++++++++++ 7 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/event/server_events.c b/src/event/server_events.c index 09b8af0a..71d7af35 100644 --- a/src/event/server_events.c +++ b/src/event/server_events.c @@ -341,7 +341,7 @@ sv_ev_room_message(ProfMessage* message) status_bar_active(num, WIN_MUC, mucwin->roomjid); if ((g_strcmp0(mynick, message->from_jid->resourcepart) != 0) && (prefs_get_boolean(PREF_BEEP))) { - beep(); + ui_beep(); } // not currently on groupchat window @@ -349,7 +349,7 @@ sv_ev_room_message(ProfMessage* message) status_bar_new(num, WIN_MUC, mucwin->roomjid); if ((g_strcmp0(mynick, message->from_jid->resourcepart) != 0) && (prefs_get_boolean(PREF_FLASH))) { - flash(); + ui_flash(); } cons_show_incoming_room_message(message->from_jid->resourcepart, mucwin->roomjid, num, mention, triggers, mucwin->unread, window); diff --git a/src/ui/chatwin.c b/src/ui/chatwin.c index f231c23e..414d3517 100644 --- a/src/ui/chatwin.c +++ b/src/ui/chatwin.c @@ -327,7 +327,7 @@ chatwin_incoming_msg(ProfChatWin* chatwin, ProfMessage* message, gboolean win_cr cons_show_incoming_message(display_name, num, chatwin->unread, window); if (prefs_get_boolean(PREF_FLASH)) { - flash(); + ui_flash(); } chatwin->unread++; @@ -364,7 +364,7 @@ chatwin_incoming_msg(ProfChatWin* chatwin, ProfMessage* message, gboolean win_cr wins_add_quotes_ac(window, message->plain, FALSE); if (prefs_get_boolean(PREF_BEEP)) { - beep(); + ui_beep(); } } diff --git a/src/ui/core.c b/src/ui/core.c index f755cfa9..f67df803 100644 --- a/src/ui/core.c +++ b/src/ui/core.c @@ -180,6 +180,7 @@ void ui_suspend(void) { inp_suspend(); + doupdate(); endwin(); } @@ -190,6 +191,22 @@ ui_resume(void) inp_resume(); } +void +ui_beep(void) +{ + if (!isendwin()) { + beep(); + } +} + +void +ui_flash(void) +{ + if (!isendwin()) { + flash(); + } +} + void ui_resize(void) { diff --git a/src/ui/inputwin.c b/src/ui/inputwin.c index c9c4aef8..9ad741c6 100644 --- a/src/ui/inputwin.c +++ b/src/ui/inputwin.c @@ -459,7 +459,9 @@ _inp_write(char* line, int offset) _inp_win_handle_scroll(); _inp_win_update_virtual(); - doupdate(); + if (!isendwin()) { + doupdate(); + } } static int diff --git a/src/ui/privwin.c b/src/ui/privwin.c index 19384d6e..33bd53a6 100644 --- a/src/ui/privwin.c +++ b/src/ui/privwin.c @@ -57,7 +57,7 @@ privwin_incoming_msg(ProfPrivateWin* privatewin, ProfMessage* message) privatewin->unread++; if (prefs_get_boolean(PREF_FLASH)) { - flash(); + ui_flash(); } } @@ -65,7 +65,7 @@ privwin_incoming_msg(ProfPrivateWin* privatewin, ProfMessage* message) wins_add_quotes_ac(window, message->plain, TRUE); if (prefs_get_boolean(PREF_BEEP)) { - beep(); + ui_beep(); } if (notify) { diff --git a/src/ui/ui.h b/src/ui/ui.h index 7bed0782..4622ca1c 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -84,6 +84,8 @@ void ui_handle_recipient_error(const char* const recipient, const char* const er void ui_handle_error(const char* const err_msg); void ui_clear_win_title(void); void ui_goodbye_title(void); +void ui_beep(void); +void ui_flash(void); void ui_handle_room_configuration_form_error(const char* const roomjid, const char* const message); void ui_handle_room_config_submit_result(const char* const roomjid); void ui_handle_room_config_submit_result_error(const char* const roomjid, const char* const message); diff --git a/tests/unittests/ui/stub_ui.c b/tests/unittests/ui/stub_ui.c index 2d5da2ef..ad338403 100644 --- a/tests/unittests/ui/stub_ui.c +++ b/tests/unittests/ui/stub_ui.c @@ -1475,3 +1475,13 @@ ui_is_suspended(void) { return FALSE; } + +void +ui_beep(void) +{ +} + +void +ui_flash(void) +{ +} -- 2.49.1 From b282b124975bd9c08db96879f2e614e82d8a85cb Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Wed, 27 May 2026 22:16:51 +0300 Subject: [PATCH 02/11] fix(ui): guard password/TLS prompt redraw while suspended inp_get_line()/inp_get_password() issued an unconditional doupdate() before their input loop. If such a prompt is raised while an external editor is active (e.g. a reconnect password or TLS-cert prompt arriving via session_process_events), that doupdate() writes over the editor. Guard it with is_suspended || isendwin(), matching the inp_readline() guard, so it fails safe even if a stray write later clears isendwin(). This closes the one suspended-write path the upstream fix left open. --- src/ui/inputwin.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ui/inputwin.c b/src/ui/inputwin.c index 9ad741c6..b3629861 100644 --- a/src/ui/inputwin.c +++ b/src/ui/inputwin.c @@ -311,7 +311,10 @@ inp_get_line(void) werase(inp_win); wmove(inp_win, 0, 0); _inp_win_update_virtual(); - doupdate(); + // Don't write to the terminal while suspended (external editor active). + if (!is_suspended && !isendwin()) { + doupdate(); + } char* line = NULL; while (!line) { line = inp_readline(); @@ -338,7 +341,10 @@ inp_get_password(void) werase(inp_win); wmove(inp_win, 0, 0); _inp_win_update_virtual(); - doupdate(); + // Don't write to the terminal while suspended (external editor active). + if (!is_suspended && !isendwin()) { + doupdate(); + } char* password = NULL; get_password = TRUE; while (!password) { -- 2.49.1 From 394df6d391046869da0e2c620c1a7edea2df1d52 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Wed, 27 May 2026 22:00:18 +0300 Subject: [PATCH 03/11] fix(ui): add authoritative suspend flag gating redraws over the editor Builds on the upstream "guard terminal writes while suspended" fix. That fix keeps isendwin() a valid "suspended" signal by guarding stray writes (_inp_write, beep, flash) so the existing isendwin() checks in ui_update/ui_resize/ui_redraw hold. That is correct but fail-open: any future unguarded doupdate()/refresh() reachable during an editor session clears isendwin() and the ~10Hz ui_update() repaints over the editor again (the original bug class). Track suspension explicitly with a ui_suspended flag set in ui_suspend()/ui_resume() and gate the three redraw entry points on ui_suspended || isendwin() via _ui_redraw_suspended(). The flag is set exactly at the suspend/resume boundary, so the dominant repaint path fails safe regardless of isendwin() flapping. isendwin() is kept in the condition for the shutdown/closed-screen case. --- src/ui/core.c | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/ui/core.c b/src/ui/core.c index f67df803..93d240cd 100644 --- a/src/ui/core.c +++ b/src/ui/core.c @@ -54,6 +54,7 @@ static int inp_size; static gboolean perform_resize = FALSE; +static gboolean ui_suspended = FALSE; static GTimer* ui_idle_time; static WINDOW* main_scr; @@ -63,6 +64,16 @@ static Display* display; static void _ui_draw_term_title(void); +// Skip drawing while an external editor is active (ui_suspended) or after the +// screen has been closed (endwin). The flag is authoritative and does not rely +// on isendwin() staying TRUE, which a stray doupdate() during the session could +// otherwise clear. +static gboolean +_ui_redraw_suspended(void) +{ + return ui_suspended || isendwin(); +} + static void _ui_close(void) { @@ -120,8 +131,7 @@ ui_sigwinch_handler(int sig) void ui_update(void) { - // UI is suspended - if (isendwin()) { + if (_ui_redraw_suspended()) { return; } @@ -179,6 +189,7 @@ ui_reset_idle_time(void) void ui_suspend(void) { + ui_suspended = TRUE; inp_suspend(); doupdate(); endwin(); @@ -187,6 +198,7 @@ ui_suspend(void) void ui_resume(void) { + ui_suspended = FALSE; refresh(); inp_resume(); } @@ -210,8 +222,7 @@ ui_flash(void) void ui_resize(void) { - // UI is suspended - if (isendwin()) { + if (_ui_redraw_suspended()) { return; } @@ -233,8 +244,7 @@ ui_resize(void) void ui_redraw(void) { - // UI is suspended - if (isendwin()) { + if (_ui_redraw_suspended()) { return; } -- 2.49.1 From 2da2449777d68520eed8f888de2c256e04662722 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Fri, 29 May 2026 08:48:25 +0300 Subject: [PATCH 04/11] fix(ui): release global lock around suspend-branch sleep in inp_readline The normal path of inp_readline unlocks the global `lock` around select(), giving worker threads (http_upload / http_download / ai_client in src/tools and src/ai) a window to acquire it and update the UI. The suspend branch (entered while an external editor is active) used to g_usleep(100000) and return NULL while holding the lock, so the main thread monopolised it for the whole editor session and every in-flight transfer froze; on a long composition (the customer's main /editor use case) this could be long enough for server-side timeouts to fire. Mirror the normal-path unlock / sleep / lock pattern so the same window exists during the editor session. Workers in the suspended state print through wnoutrefresh paths only (no direct doupdate / refresh / wrefresh in src outside core.c / inputwin.c, all of which are already guarded against suspended writes), so opening the window introduces no new race. --- src/ui/inputwin.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ui/inputwin.c b/src/ui/inputwin.c index b3629861..6e98af02 100644 --- a/src/ui/inputwin.c +++ b/src/ui/inputwin.c @@ -182,7 +182,13 @@ inp_readline(void) { // UI is suspended if (is_suspended || isendwin()) { + // Mirror the normal-path unlock/sleep/lock so worker threads + // (http_upload / http_download / ai_client) can acquire `lock` + // during the editor session — otherwise the main thread holds + // it continuously and every in-flight transfer freezes. + pthread_mutex_unlock(&lock); g_usleep(100000); // 100ms + pthread_mutex_lock(&lock); return NULL; } -- 2.49.1 From b1ffc750a229a03d6f7e9e413a4a3a7d70e38279 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Fri, 29 May 2026 08:48:51 +0300 Subject: [PATCH 05/11] fix(ui): iterate main context inside synchronous input loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inp_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. --- src/ui/inputwin.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ui/inputwin.c b/src/ui/inputwin.c index 6e98af02..fd8a8835 100644 --- a/src/ui/inputwin.c +++ b/src/ui/inputwin.c @@ -325,6 +325,10 @@ inp_get_line(void) while (!line) { line = inp_readline(); 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)) + ; } status_bar_clear_prompt(); return line; @@ -356,6 +360,10 @@ inp_get_password(void) while (!password) { password = inp_readline(); 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)) + ; } get_password = FALSE; status_bar_clear_prompt(); -- 2.49.1 From a51afa53f690623333a48b91f390d925896734c2 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Fri, 29 May 2026 08:49:57 +0300 Subject: [PATCH 06/11] fix(editor): guard against re-entrant launch_editor The async editor rewrite (g_child_watch + ui_suspend / ui_resume) lost the editor_task.active check the old pthread implementation had at the top of get_message_from_editor_async. With the guard gone, a second launch_editor call while the first session is in flight would call ui_suspend a second time, fork another editor, and register a second child watch; whichever child exits first runs ui_resume and clears suspension while the other still owns the terminal. Triggering this needs a code path other than the main-loop alt+c / /editor (those can't fire while the main loop is parked in the suspended iteration), so the realistic source is a plugin firing launch_editor from a g_main_context_iteration dispatch. Restore an explicit editor_active static in src/tools/editor.c: check it at the start of launch_editor (bail with a console error), set it just before ui_suspend, and clear it on the fork-error path and at the start of _editor_exit_cb. Keeps the same simple semantics the pre-merge code had, without exposing a new ui.h API. --- src/tools/editor.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/tools/editor.c b/src/tools/editor.c index a8648307..dc3bdeb8 100644 --- a/src/tools/editor.c +++ b/src/tools/editor.c @@ -32,9 +32,20 @@ typedef struct EditorContext void* user_data; } EditorContext; +// Set TRUE for the lifetime of an editor session (from launch_editor up to +// _editor_exit_cb / fork-error cleanup). The async editor model lost the old +// editor_task.active check from the pthread implementation; without this +// 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. +static gboolean editor_active = FALSE; + static void _editor_exit_cb(GPid pid, gint status, gpointer data) { + editor_active = FALSE; + EditorContext* ctx = data; gchar* contents = NULL; GError* error = NULL; @@ -70,6 +81,11 @@ _editor_exit_cb(GPid pid, gint status, gpointer data) gboolean launch_editor(gchar* initial_content, void (*callback)(gchar* content, void* data), void* user_data) { + if (editor_active) { + cons_show_error("An editor session is already active."); + return TRUE; + } + auto_gchar gchar* filename = NULL; auto_gerror GError* glib_error = NULL; const char* jid = connection_get_barejid(); @@ -118,6 +134,7 @@ launch_editor(gchar* initial_content, void (*callback)(gchar* content, void* dat ctx->callback = callback; ctx->user_data = user_data; + editor_active = TRUE; ui_suspend(); // Force GLib to install its SIGCHLD handler before forking. Creating a @@ -129,6 +146,7 @@ launch_editor(gchar* initial_content, void (*callback)(gchar* content, void* dat pid_t pid = fork(); if (pid == -1) { log_error("[Editor] Failed to fork: %s", strerror(errno)); + editor_active = FALSE; ui_resume(); ui_resize(); cons_show_error("Failed to start editor: %s", strerror(errno)); -- 2.49.1 From bb0af8b7c806733b3e39696d96f5b7c778fd6d97 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Fri, 29 May 2026 08:54:30 +0300 Subject: [PATCH 07/11] refactor(ui): centralize physical paint through prof_doupdate() Upstream's "guard terminal writes while suspended" fix and commit 2 of this branch both work by sprinkling `if (!isendwin())` (or `!is_suspended && !isendwin()`) guards around every doupdate() reachable during a suspended session. That keeps isendwin() a valid signal but turns the no-stray-write invariant into a whitelist: every new doupdate / refresh / wrefresh added later in src/ has to remember the guard, and the original bug class came from exactly such an oversight in _inp_write. Introduce a single primitive, prof_doupdate(), that flushes only when not suspended (via the existing _ui_redraw_suspended() helper). Replace every direct doupdate() in src/ui/inputwin.c (inp_get_line, inp_get_password, _inp_write) and the trailing doupdate in ui_update with the wrapper, collapsing their inline guards. ui_suspend() now flushes via prof_doupdate() before flipping ui_suspended, so upstream's pre-endwin flush is preserved. The flag-based gate in ui_update / ui_resize / ui_redraw is kept as belt-and-suspenders: even if a future raw doupdate slips in elsewhere, ui_update has not been refreshing the virtual screen, so the blit finds stale content rather than a fresh status-bar clock or chat line. --- src/ui/core.c | 15 ++++++++++++--- src/ui/inputwin.c | 14 +++----------- src/ui/ui.h | 4 ++++ tests/unittests/ui/stub_ui.c | 4 ++++ 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/ui/core.c b/src/ui/core.c index 93d240cd..b9f0fdeb 100644 --- a/src/ui/core.c +++ b/src/ui/core.c @@ -148,7 +148,7 @@ ui_update(void) title_bar_update_virtual(); status_bar_draw(); inp_put_back(); - doupdate(); + prof_doupdate(); if (perform_resize) { perform_resize = FALSE; @@ -189,9 +189,10 @@ ui_reset_idle_time(void) void ui_suspend(void) { - ui_suspended = TRUE; inp_suspend(); - doupdate(); + // Flush before flipping the flag (prof_doupdate skips while suspended). + prof_doupdate(); + ui_suspended = TRUE; endwin(); } @@ -219,6 +220,14 @@ ui_flash(void) } } +void +prof_doupdate(void) +{ + if (!_ui_redraw_suspended()) { + doupdate(); + } +} + void ui_resize(void) { diff --git a/src/ui/inputwin.c b/src/ui/inputwin.c index fd8a8835..cea4290c 100644 --- a/src/ui/inputwin.c +++ b/src/ui/inputwin.c @@ -317,10 +317,7 @@ inp_get_line(void) werase(inp_win); wmove(inp_win, 0, 0); _inp_win_update_virtual(); - // Don't write to the terminal while suspended (external editor active). - if (!is_suspended && !isendwin()) { - doupdate(); - } + prof_doupdate(); char* line = NULL; while (!line) { line = inp_readline(); @@ -351,10 +348,7 @@ inp_get_password(void) werase(inp_win); wmove(inp_win, 0, 0); _inp_win_update_virtual(); - // Don't write to the terminal while suspended (external editor active). - if (!is_suspended && !isendwin()) { - doupdate(); - } + prof_doupdate(); char* password = NULL; get_password = TRUE; while (!password) { @@ -479,9 +473,7 @@ _inp_write(char* line, int offset) _inp_win_handle_scroll(); _inp_win_update_virtual(); - if (!isendwin()) { - doupdate(); - } + prof_doupdate(); } static int diff --git a/src/ui/ui.h b/src/ui/ui.h index 4622ca1c..5134f6d4 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -43,6 +43,10 @@ void ui_load_colours(void); void ui_update(void); void ui_redraw(void); void ui_resize(void); +// Centralised physical-paint primitive: flushes the virtual screen unless the +// UI is suspended (external editor active) or the screen has been closed. Use +// this instead of raw doupdate() in any path reachable while suspended. +void prof_doupdate(void); void ui_focus_win(ProfWin* window); void ui_sigwinch_handler(int sig); void ui_handle_otr_error(const char* const barejid, const char* const message); diff --git a/tests/unittests/ui/stub_ui.c b/tests/unittests/ui/stub_ui.c index ad338403..7ac85dbe 100644 --- a/tests/unittests/ui/stub_ui.c +++ b/tests/unittests/ui/stub_ui.c @@ -85,6 +85,10 @@ void ui_resize(void) { } +void +prof_doupdate(void) +{ +} void ui_focus_win(ProfWin* win) -- 2.49.1 From e4422ac7a1ac9fb56c5f0665bf2f0210aae537a7 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Sun, 31 May 2026 14:08:03 +0300 Subject: [PATCH 08/11] fix(editor): Ctrl-Z aborts editor and returns to profanity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the SIGTSTP=SIG_IGN inheritance approach (was commit 8) with a detect-and-abort design that matches user expectation: Ctrl-Z inside the editor brings you back to profanity instead of leaving the session suspended. - src/tools/editor.c: SIGTSTP reset back to SIG_DFL in the child so vim's :stop / Ctrl-Z actually stop the editor (revert previous SIG_IGN inheritance). - src/tools/editor.c: new editor_check_stopped() — non-reaping waitpid poll for WIFSTOPPED. Lets prof_run detect Ctrl-Z / :stop / gdb-attach on the editor child. - src/tools/editor.c: editor_emergency_kill() now sends SIGCONT before SIGTERM so the abort works on a STOPPED child too. - src/tools/editor.c: _editor_exit_cb handles WIFSIGNALED cleanly with "Editor session cancelled." instead of printing garbage via WEXITSTATUS on a signaled child. - src/profanity.c: SIGTSTP / SIGCONT signal handlers set flags consumed by the main loop. Editor STOPPED -> editor_emergency_kill (returns to the chat with the pre-alt+c readline buffer intact). Plain Ctrl-Z with no editor -> mutt-style drop the process group to the shell. SIGCONT -> refresh curses with clearok + ui_resize. While editor is active, swallow suspend_requested (race: vim's kill(0,SIGTSTP) beats waitpid seeing STOPPED — next poll catches it). - src/tools/editor.h: declare editor_check_stopped and editor_is_active. Verbose comment blocks across the branch tightened to one to two lines. --- src/profanity.c | 62 +++++++++++++++++++++++++++++++++++++++++++++- src/tools/editor.c | 52 ++++++++++++++++++++++++++++++-------- src/tools/editor.h | 11 ++++++++ src/ui/core.c | 6 ++--- src/ui/inputwin.c | 14 +++++------ src/ui/ui.h | 5 ++-- 6 files changed, 124 insertions(+), 26 deletions(-) diff --git a/src/profanity.c b/src/profanity.c index f841f158..2d278ba5 100644 --- a/src/profanity.c +++ b/src/profanity.c @@ -20,6 +20,7 @@ #include #include +#include #include "profanity.h" #include "common.h" @@ -70,6 +71,12 @@ static void _connect_default(const char* const account); pthread_mutex_t lock; static gboolean force_quit = FALSE; +// 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. +static volatile sig_atomic_t suspend_requested = FALSE; +static volatile sig_atomic_t cont_requested = FALSE; + void prof_run(gchar* log_level, gchar* account_name, gchar* config_file, gchar* log_file, gchar* theme_name, gchar** commands) { @@ -93,6 +100,36 @@ prof_run(gchar* log_level, gchar* account_name, gchar* config_file, gchar* log_f int waittime = 0; while (cont && !force_quit) { log_stderr_handler(); + + // Resume from fg after SIGSTOP. Vim handles its own refresh. + if (cont_requested) { + cont_requested = FALSE; + if (!editor_is_active()) { + clearok(stdscr, TRUE); + ui_resize(); + } + } + + // Editor STOPPED → abort. Plain Ctrl-Z → drop to shell. While editor + // is active, swallow suspend_requested (race: vim's kill(0,SIGTSTP) + // beats waitpid seeing STOPPED) — next poll catches it. + if (editor_check_stopped()) { + log_info("[Editor] stopped; aborting back to profanity"); + suspend_requested = FALSE; + editor_emergency_kill(); + } else if (suspend_requested) { + suspend_requested = FALSE; + if (editor_is_active()) { + log_info("[Editor] SIGTSTP from vim group; deferring to next poll"); + } else { + log_info("[Suspend] dropping to shell"); + if (!isendwin()) { + endwin(); + } + kill(0, SIGSTOP); + } + } + session_check_autoaway(); line = commands ? *commands : inp_readline(); @@ -182,6 +219,27 @@ sigterm_handler(int sig) force_quit = TRUE; } +// SIGUSR1 escape for an alive-but-stuck editor (WUNTRACED can't catch it). +// Dispatched in the main loop. Trigger: `pkill -USR1 profanity`. +static gboolean +_emergency_resume_handler(gpointer data) +{ + editor_emergency_kill(); + return G_SOURCE_CONTINUE; +} + +static void +sigtstp_handler(int sig) +{ + suspend_requested = TRUE; +} + +static void +sigcont_handler(int sig) +{ + cont_requested = TRUE; +} + static void _init(char* log_level, char* config_file, char* log_file, char* theme_name) { @@ -189,10 +247,12 @@ _init(char* log_level, char* config_file, char* log_file, char* theme_name) // ignore SIGPIPE signal(SIGPIPE, SIG_IGN); signal(SIGINT, SIG_IGN); - signal(SIGTSTP, SIG_IGN); + signal(SIGTSTP, sigtstp_handler); + signal(SIGCONT, sigcont_handler); signal(SIGWINCH, ui_sigwinch_handler); signal(SIGTERM, sigterm_handler); signal(SIGHUP, sigterm_handler); + g_unix_signal_add(SIGUSR1, _emergency_resume_handler, NULL); if (pthread_mutex_init(&lock, NULL) != 0) { log_error("Mutex init failed"); exit(1); diff --git a/src/tools/editor.c b/src/tools/editor.c index dc3bdeb8..ea79dfdb 100644 --- a/src/tools/editor.c +++ b/src/tools/editor.c @@ -32,19 +32,16 @@ typedef struct EditorContext void* user_data; } EditorContext; -// Set TRUE for the lifetime of an editor session (from launch_editor up to -// _editor_exit_cb / fork-error cleanup). The async editor model lost the old -// editor_task.active check from the pthread implementation; without this -// 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. +// Re-entrancy guard (lost in the async-editor rewrite). Prevents a plugin +// from spawning a second editor that would fight the first for the terminal. static gboolean editor_active = FALSE; +static pid_t editor_pid = 0; static void _editor_exit_cb(GPid pid, gint status, gpointer data) { editor_active = FALSE; + editor_pid = 0; EditorContext* ctx = data; gchar* contents = NULL; @@ -63,8 +60,10 @@ _editor_exit_cb(GPid pid, gint status, gpointer data) cons_show_error("Could not read edited content: %s", error->message); g_error_free(error); } - } else { - cons_show_error("Editor exited with error status %d", WEXITSTATUS(status)); + } else if (WIFEXITED(status)) { + cons_show_error("Editor exited with status %d", WEXITSTATUS(status)); + } else if (WIFSIGNALED(status)) { + cons_show("Editor session cancelled."); } if (remove(ctx->filename) != 0) { @@ -157,7 +156,8 @@ launch_editor(gchar* initial_content, void (*callback)(gchar* content, void* dat } else if (pid == 0) { // Child process: Inherits TTY from parent - // Reset signal handlers that profanity sets so the editor doesn't inherit them + // SIGTSTP=SIG_DFL lets vim's :stop / Ctrl-Z work; profanity catches + // the STOPPED state via editor_check_stopped() and drops to the shell. signal(SIGINT, SIG_DFL); signal(SIGTSTP, SIG_DFL); signal(SIGPIPE, SIG_DFL); @@ -169,7 +169,39 @@ launch_editor(gchar* initial_content, void (*callback)(gchar* content, void* dat } // Parent process: Watch the child asynchronously + editor_pid = pid; g_child_watch_add((GPid)pid, _editor_exit_cb, ctx); g_strfreev(editor_argv); return FALSE; } + +void +editor_emergency_kill(void) +{ + if (!editor_active || editor_pid <= 0) { + return; + } + log_warning("[Editor] aborting editor pid %d", editor_pid); + kill(editor_pid, SIGCONT); // wake if STOPPED; no-op otherwise + kill(editor_pid, SIGTERM); + // _editor_exit_cb will restore the UI when the child reaps. +} + +gboolean +editor_is_active(void) +{ + return editor_active; +} + +gboolean +editor_check_stopped(void) +{ + // Poll for STOPPED without reaping (g_child_watch handles exit via its + // own WNOHANG). Lets prof_run drop the group to the shell on Ctrl-Z. + if (!editor_active || editor_pid <= 0) { + return FALSE; + } + int status; + pid_t r = waitpid(editor_pid, &status, WNOHANG | WUNTRACED); + return (r == editor_pid && WIFSTOPPED(status)); +} diff --git a/src/tools/editor.h b/src/tools/editor.h index a6ef0cef..51e912ed 100644 --- a/src/tools/editor.h +++ b/src/tools/editor.h @@ -16,4 +16,15 @@ gboolean launch_editor(gchar* initial_content, void (*callback)(gchar* content, void* data), void* user_data); +// SIGUSR1 escape: SIGTERM the editor child. Use when WUNTRACED can't catch +// it (alive but stuck). Wired in profanity.c. +void editor_emergency_kill(void); + +// TRUE if editor child is STOPPED (Ctrl-Z / :stop / gdb-attach). Does not +// reap; g_child_watch keeps handling exit. +gboolean editor_check_stopped(void); + +// Editor-session flag accessor. Used by prof_run on SIGCONT resume. +gboolean editor_is_active(void); + #endif diff --git a/src/ui/core.c b/src/ui/core.c index b9f0fdeb..2d492752 100644 --- a/src/ui/core.c +++ b/src/ui/core.c @@ -64,10 +64,8 @@ static Display* display; static void _ui_draw_term_title(void); -// Skip drawing while an external editor is active (ui_suspended) or after the -// screen has been closed (endwin). The flag is authoritative and does not rely -// on isendwin() staying TRUE, which a stray doupdate() during the session could -// otherwise clear. +// Fail-safe gate: authoritative flag (doesn't depend on isendwin staying +// TRUE), plus the regular endwin check for shutdown. static gboolean _ui_redraw_suspended(void) { diff --git a/src/ui/inputwin.c b/src/ui/inputwin.c index cea4290c..34222853 100644 --- a/src/ui/inputwin.c +++ b/src/ui/inputwin.c @@ -182,10 +182,8 @@ inp_readline(void) { // UI is suspended if (is_suspended || isendwin()) { - // Mirror the normal-path unlock/sleep/lock so worker threads - // (http_upload / http_download / ai_client) can acquire `lock` - // during the editor session — otherwise the main thread holds - // it continuously and every in-flight transfer freezes. + // Mirror the normal-path unlock so workers aren't starved + // for the whole editor session. pthread_mutex_unlock(&lock); g_usleep(100000); // 100ms pthread_mutex_lock(&lock); @@ -322,8 +320,8 @@ inp_get_line(void) while (!line) { line = inp_readline(); ui_update(); - // Let GLib sources (notably the editor child watch) dispatch so - // ui_resume can run if the prompt was raised mid-editor-session. + // Let the editor child watch dispatch — otherwise a prompt raised + // mid-editor-session deadlocks here. while (g_main_context_iteration(NULL, FALSE)) ; } @@ -354,8 +352,8 @@ inp_get_password(void) while (!password) { password = inp_readline(); ui_update(); - // Let GLib sources (notably the editor child watch) dispatch so - // ui_resume can run if the prompt was raised mid-editor-session. + // Let the editor child watch dispatch — otherwise a prompt raised + // mid-editor-session deadlocks here. while (g_main_context_iteration(NULL, FALSE)) ; } diff --git a/src/ui/ui.h b/src/ui/ui.h index 5134f6d4..bdcdb406 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -43,9 +43,8 @@ void ui_load_colours(void); void ui_update(void); void ui_redraw(void); void ui_resize(void); -// Centralised physical-paint primitive: flushes the virtual screen unless the -// UI is suspended (external editor active) or the screen has been closed. Use -// this instead of raw doupdate() in any path reachable while suspended. +// doupdate() wrapper that skips when the UI is suspended. Use this instead +// of raw doupdate() to avoid the per-site whitelist class of bug. void prof_doupdate(void); void ui_focus_win(ProfWin* window); void ui_sigwinch_handler(int sig); -- 2.49.1 From 3f91cbc8b04cf3a0f38c75c5915b5653899ebc27 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Sun, 31 May 2026 14:25:42 +0300 Subject: [PATCH 09/11] fix(ui): clearok(stdscr) in ui_resume to clear vim screen residue ncurses didn't know the editor clobbered the physical screen, so the first refresh after vim exit was a no-op diff. Stale frame stayed visible until the next keystroke triggered readline redisplay. --- src/tools/editor.c | 2 +- src/ui/core.c | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tools/editor.c b/src/tools/editor.c index ea79dfdb..7a8e4948 100644 --- a/src/tools/editor.c +++ b/src/tools/editor.c @@ -182,7 +182,7 @@ editor_emergency_kill(void) return; } log_warning("[Editor] aborting editor pid %d", editor_pid); - kill(editor_pid, SIGCONT); // wake if STOPPED; no-op otherwise + kill(editor_pid, SIGCONT); // wake if STOPPED; no-op otherwise kill(editor_pid, SIGTERM); // _editor_exit_cb will restore the UI when the child reaps. } diff --git a/src/ui/core.c b/src/ui/core.c index 2d492752..e779aaa7 100644 --- a/src/ui/core.c +++ b/src/ui/core.c @@ -198,6 +198,7 @@ void ui_resume(void) { ui_suspended = FALSE; + clearok(stdscr, TRUE); // ncurses can't see vim's screen clobber refresh(); inp_resume(); } -- 2.49.1 From b90dec5be37d3c9daa3790d5c5748d6323ace55a Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Sun, 31 May 2026 15:12:40 +0300 Subject: [PATCH 10/11] remove SIGUSR1 escape; pkill is the standard recovery The SIGUSR1 -> editor_emergency_kill wiring was a contrived escape hatch. A console user with a stuck editor reaches for kill / pkill / SIGKILL on the editor, not undocumented signals on the chat client. editor_emergency_kill() stays for the internal Ctrl-Z abort path. --- src/command/cmd_defs.c | 3 ++- src/profanity.c | 11 ----------- src/tools/editor.h | 4 +--- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/command/cmd_defs.c b/src/command/cmd_defs.c index 60a85eec..e3e26ddf 100644 --- a/src/command/cmd_defs.c +++ b/src/command/cmd_defs.c @@ -2638,7 +2638,8 @@ static const struct cmd_t command_defs[] = { CMD_DESC( "Spawn external editor to edit message. " "After editing the inputline may appear empty. Press enter to send the text anyways. " - "Use /executable to set your favourite editor." ) + "Use /executable to set your favourite editor. " + "Ctrl-Z in the editor aborts the session and returns to Profanity." ) }, { CMD_PREAMBLE("/correct-editor", diff --git a/src/profanity.c b/src/profanity.c index 2d278ba5..2d5aca43 100644 --- a/src/profanity.c +++ b/src/profanity.c @@ -20,7 +20,6 @@ #include #include -#include #include "profanity.h" #include "common.h" @@ -219,15 +218,6 @@ sigterm_handler(int sig) force_quit = TRUE; } -// SIGUSR1 escape for an alive-but-stuck editor (WUNTRACED can't catch it). -// Dispatched in the main loop. Trigger: `pkill -USR1 profanity`. -static gboolean -_emergency_resume_handler(gpointer data) -{ - editor_emergency_kill(); - return G_SOURCE_CONTINUE; -} - static void sigtstp_handler(int sig) { @@ -252,7 +242,6 @@ _init(char* log_level, char* config_file, char* log_file, char* theme_name) signal(SIGWINCH, ui_sigwinch_handler); signal(SIGTERM, sigterm_handler); signal(SIGHUP, sigterm_handler); - g_unix_signal_add(SIGUSR1, _emergency_resume_handler, NULL); if (pthread_mutex_init(&lock, NULL) != 0) { log_error("Mutex init failed"); exit(1); diff --git a/src/tools/editor.h b/src/tools/editor.h index 51e912ed..9be51a6f 100644 --- a/src/tools/editor.h +++ b/src/tools/editor.h @@ -16,9 +16,7 @@ gboolean launch_editor(gchar* initial_content, void (*callback)(gchar* content, void* data), void* user_data); -// SIGUSR1 escape: SIGTERM the editor child. Use when WUNTRACED can't catch -// it (alive but stuck). Wired in profanity.c. -void editor_emergency_kill(void); +void editor_emergency_kill(void); // SIGCONT+SIGTERM the editor child // TRUE if editor child is STOPPED (Ctrl-Z / :stop / gdb-attach). Does not // reap; g_child_watch keeps handling exit. -- 2.49.1 From 62d9c57925664242b9f770e3c5911247dfd8ddc7 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Sun, 31 May 2026 15:24:12 +0300 Subject: [PATCH 11/11] refactor(profanity): extract _handle_suspend_signals; tighten comments Main loop now calls a single helper for cont/suspend/Ctrl-Z state instead of inlining the signal-handling block. Comment blocks in profanity.c / editor.c / inputwin.c shortened to trailing form. --- src/profanity.c | 63 ++++++++++++++++++++++------------------------ src/tools/editor.c | 4 +-- src/ui/inputwin.c | 8 ++---- 3 files changed, 33 insertions(+), 42 deletions(-) diff --git a/src/profanity.c b/src/profanity.c index 2d5aca43..08655295 100644 --- a/src/profanity.c +++ b/src/profanity.c @@ -70,12 +70,38 @@ static void _connect_default(const char* const account); pthread_mutex_t lock; static gboolean force_quit = FALSE; -// 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. static volatile sig_atomic_t suspend_requested = FALSE; static volatile sig_atomic_t cont_requested = FALSE; +static void +_handle_suspend_signals(void) +{ + if (cont_requested) { + cont_requested = FALSE; + if (!editor_is_active()) { + clearok(stdscr, TRUE); + ui_resize(); + } + } + + if (editor_check_stopped()) { + log_info("[Editor] stopped; aborting back to profanity"); + suspend_requested = FALSE; + editor_emergency_kill(); + } else if (suspend_requested) { + suspend_requested = FALSE; + if (editor_is_active()) { + log_info("[Editor] SIGTSTP from vim group; deferring to next poll"); + } else { + log_info("[Suspend] dropping to shell"); + if (!isendwin()) { + endwin(); + } + kill(0, SIGSTOP); + } + } +} + void prof_run(gchar* log_level, gchar* account_name, gchar* config_file, gchar* log_file, gchar* theme_name, gchar** commands) { @@ -99,36 +125,7 @@ prof_run(gchar* log_level, gchar* account_name, gchar* config_file, gchar* log_f int waittime = 0; while (cont && !force_quit) { log_stderr_handler(); - - // Resume from fg after SIGSTOP. Vim handles its own refresh. - if (cont_requested) { - cont_requested = FALSE; - if (!editor_is_active()) { - clearok(stdscr, TRUE); - ui_resize(); - } - } - - // Editor STOPPED → abort. Plain Ctrl-Z → drop to shell. While editor - // is active, swallow suspend_requested (race: vim's kill(0,SIGTSTP) - // beats waitpid seeing STOPPED) — next poll catches it. - if (editor_check_stopped()) { - log_info("[Editor] stopped; aborting back to profanity"); - suspend_requested = FALSE; - editor_emergency_kill(); - } else if (suspend_requested) { - suspend_requested = FALSE; - if (editor_is_active()) { - log_info("[Editor] SIGTSTP from vim group; deferring to next poll"); - } else { - log_info("[Suspend] dropping to shell"); - if (!isendwin()) { - endwin(); - } - kill(0, SIGSTOP); - } - } - + _handle_suspend_signals(); session_check_autoaway(); line = commands ? *commands : inp_readline(); diff --git a/src/tools/editor.c b/src/tools/editor.c index 7a8e4948..2974ed74 100644 --- a/src/tools/editor.c +++ b/src/tools/editor.c @@ -32,9 +32,7 @@ typedef struct EditorContext void* user_data; } EditorContext; -// Re-entrancy guard (lost in the async-editor rewrite). Prevents a plugin -// from spawning a second editor that would fight the first for the terminal. -static gboolean editor_active = FALSE; +static gboolean editor_active = FALSE; // re-entrancy guard static pid_t editor_pid = 0; static void diff --git a/src/ui/inputwin.c b/src/ui/inputwin.c index 34222853..1e371f5f 100644 --- a/src/ui/inputwin.c +++ b/src/ui/inputwin.c @@ -320,9 +320,7 @@ inp_get_line(void) while (!line) { line = inp_readline(); ui_update(); - // Let the editor child watch dispatch — otherwise a prompt raised - // mid-editor-session deadlocks here. - while (g_main_context_iteration(NULL, FALSE)) + while (g_main_context_iteration(NULL, FALSE)) // non-blocking; bounded by ready sources ; } status_bar_clear_prompt(); @@ -352,9 +350,7 @@ inp_get_password(void) while (!password) { password = inp_readline(); ui_update(); - // Let the editor child watch dispatch — otherwise a prompt raised - // mid-editor-session deadlocks here. - while (g_main_context_iteration(NULL, FALSE)) + while (g_main_context_iteration(NULL, FALSE)) // non-blocking; bounded by ready sources ; } get_password = FALSE; -- 2.49.1