Alt+Delete closes the active tab; kill only pgids the compositor owns

Close the active tab on Alt+Delete: the tab vanishes immediately (active
moves to the right neighbor, or the left one for a rightmost tab), the
client's whole process group gets SIGTERM with a short SIGKILL escalation
if it ignores the TERM. Grabbed and repeat-latched so a single hold closes
one tab.

Slots are claimed in connection order (not launch order), so close signals
the process group recorded at claim time. Only groups the compositor owns
are group-signaled; a client started from the compositor's own terminal
shares our group and is killed by pid instead, so teardown can't group-kill
the launcher's session (a pytest run died 143/137 for exactly this).

Tests: settle the active-tab log before cycling (wait_any_active +
cycle_ack quiesce), spawn the extra client in its own session, retry
wl-paste when the host clipboard stalls, and only skip the host-colour
assertion when the host pixel is unobservable. Full suite 5/5 green.
This commit is contained in:
Andrew Hamilton 2026-08-17 10:26:48 +10:00
parent f197c229ca
commit fb29f03dc0
3 changed files with 422 additions and 28 deletions

View file

@ -90,6 +90,16 @@
struct tabbed_app {
char *cmd;
pid_t pid;
/* Process group the *connected* client lives in. Slots are claimed in
connection order (see handle_new_toplevel), which is not the launch
order used for app->pid, so a close must signal the group recorded at
claim time rather than the launcher's pid. */
pid_t pgid;
/* PID of the *connected* client (from claim-time credentials). Used as
the kill target when the client belongs to a process group we do not
own (e.g. one started from the compositor's own shell/terminal), where
signaling the whole group would kill the launcher's session too. */
pid_t client_pid;
struct wlr_xdg_toplevel *toplevel;
struct wlr_scene_tree *tree;
struct wl_listener destroy;
@ -114,6 +124,12 @@ struct tabbed_app {
stale free one (a closed tab's hole), so a new terminal keeps its
"right of the current tab" position. */
bool pending_claim;
/* The user closed this tab (Alt+Delete). While set, cycle_tab skips the
slot and the SIGKILL escalation timer (close_timer) may still be armed;
cleared once the toplevel is destroyed or the slot is reused. */
bool closing;
struct wl_event_source *close_timer;
};
struct tabbed_server {
@ -260,6 +276,7 @@ static bool shutting_down;
so it may carry arguments. */
static const char *term_cmd = "alacritty";
static bool term_key_held; /* Ctrl+Shift+T currently down (repeat latch) */
static bool delete_key_held; /* Alt+Delete currently down (repeat latch) */
/* ===================== Clipboard bridge ===================== */
@ -397,19 +414,42 @@ static void tabbed_log_level_init(void) {
(void)tabbed_log_level();
}
/* The process group to signal for an app: the group its claimed client lives
in (set at claim time; slots are claimed in connection order, not launch
order), falling back to the launcher pid for slots with no client yet. */
static pid_t app_signal_group(struct tabbed_app *app) {
return (app && app->pgid > 0) ? app->pgid : (app ? app->pid : -1);
}
/* Whether an app has a killable target: either a process group we own, or a
connected client pid (external clients sharing our own group). */
static bool app_killable(struct tabbed_app *app) {
return app && (app_signal_group(app) > 0 || app->client_pid > 0);
}
/* Signal an app's process target. A group we created (launch_client runs the
child in its own setpgid'd group) is signaled as a group so the whole client
tree dies. A client we did NOT create, or one sharing our own process group
(e.g. started from the same terminal as the compositor), is signaled by pid
alone: group-signaling it would kill the launcher's session, ourselves
included. */
static void kill_app(struct tabbed_app *app, int sig) {
pid_t g = app_signal_group(app);
if (g > 0 && g != getpgid(0))
kill(-g, sig);
else if (app && app->client_pid > 0)
kill(app->client_pid, sig);
}
/* Terminate every launched client: SIGTERM first for a graceful shutdown,
then SIGKILL any stragglers after a short grace period. */
static void terminate_clients(void) {
for (int i = 0; i < server.num_apps; i++) {
if (server.apps[i]->pid > 0)
kill(-server.apps[i]->pid, SIGTERM);
}
for (int i = 0; i < server.num_apps; i++)
kill_app(server.apps[i], SIGTERM);
struct timespec wait = { .tv_sec = 0, .tv_nsec = 150000000 };
nanosleep(&wait, NULL);
for (int i = 0; i < server.num_apps; i++) {
if (server.apps[i]->pid > 0)
kill(-server.apps[i]->pid, SIGKILL);
}
for (int i = 0; i < server.num_apps; i++)
kill_app(server.apps[i], SIGKILL);
}
/* Remove the session's Wayland socket file (and its companion .lock file,
@ -1805,6 +1845,14 @@ static void handle_toplevel_destroy(struct wl_listener *listener, void *data) {
wl_list_remove(&app->request_move.link);
wl_list_remove(&app->request_resize.link);
/* A pending Alt+Delete escalation must not fire against this slot once
the toplevel is gone; drop the timer and the closing latch. */
app->closing = false;
if (app->close_timer) {
wl_event_source_remove(app->close_timer);
app->close_timer = NULL;
}
if (server.active != NULL && server.active == toplevel) {
int died = -1;
for (int i = 0; i < server.num_apps; i++)
@ -1833,6 +1881,68 @@ static void handle_toplevel_destroy(struct wl_listener *listener, void *data) {
}
}
/* Grace period after the Alt+Delete SIGTERM before SIGKILL escalation. */
#define TABBED_CLOSE_ESCALATE_MS 150
/* Escalate an Alt+Delete close to SIGKILL if the client ignored the SIGTERM
and its toplevel is still up. The callback lives on the app slot, which is
never freed at runtime (only in shutdown_cleanup), and handle_toplevel_destroy
removes the source once the toplevel dies, so a stale callback can never fire
against a reaped or reused pid. */
static int close_tab_escalate(void *data) {
struct tabbed_app *app = data;
if (app->closing && app_killable(app))
kill_app(app, SIGKILL);
if (app->close_timer) {
wl_event_source_remove(app->close_timer);
app->close_timer = NULL;
}
return 0;
}
/* Alt+Delete close of the active tab. The tab vanishes immediately: the
active surface moves to the right neighbor (or the left neighbor if the
closed tab was the rightmost) in this same call, and the dying client's
scene node is disabled. The client's whole process group gets SIGTERM, then
SIGKILL after a short grace if it ignores the TERM (see close_tab_escalate).
When the last tab is closed the session tears down via handle_toplevel_destroy
once the client's socket drops. */
static void close_active_tab(void) {
struct tabbed_app *app = app_for_toplevel(server.active);
if (!app || !app_killable(app)) return;
pid_t g = app_signal_group(app);
app->closing = true;
int died = app_index(app);
struct wlr_xdg_toplevel *next = NULL;
/* Prefer the neighbor to the right so the tabs shift left; a rightmost
tab falls back to its left neighbor. */
for (int i = died + 1; i < server.num_apps; i++)
if (server.apps[i]->toplevel && !server.apps[i]->closing) {
next = server.apps[i]->toplevel;
break;
}
if (!next)
for (int i = died - 1; i >= 0; i--)
if (server.apps[i]->toplevel && !server.apps[i]->closing) {
next = server.apps[i]->toplevel;
break;
}
if (next)
set_active_surface(next);
/* With no successor this was the last tab; handle_toplevel_destroy exits. */
pid_t shown = (g > 0 && g != getpgid(0)) ? g : app->client_pid;
kill_app(app, SIGTERM);
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: closing tab %d (pid %d)\n",
died, (int)shown);
app->close_timer = wl_event_loop_add_timer(server.loop, close_tab_escalate,
app);
if (app->close_timer)
wl_event_source_timer_update(app->close_timer, TABBED_CLOSE_ESCALATE_MS);
}
static void handle_request_maximize(struct wl_listener *listener, void *data) {
(void)data;
struct tabbed_app *app = wl_container_of(listener, app, request_maximize);
@ -2084,6 +2194,16 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
if (toplevel->base->client)
wl_client_get_credentials(toplevel->base->client->client,
&cpid, NULL, NULL);
/* The client that actually occupies this slot may have been launched by a
*different* app entry (slots are claimed in connection order, which is
not the launch order used for app->pid). Record its process group so a
close signals the right one -- but only when that group is one WE own.
An externally started client (e.g. from the compositor's own terminal)
shares our process group; adopting it would make close/shutdown group-
signal the launcher's whole session. Such clients are killed by pid. */
app->client_pid = cpid;
pid_t cpg = cpid > 0 ? getpgid(cpid) : -1;
app->pgid = (cpg > 0 && cpg != getpgid(0)) ? cpg : -1;
tabbed_log(TABBED_LOG_INFO, "[claim] app=%d pid=%d\n",
app_index(app), (int)cpid);
@ -2275,7 +2395,7 @@ static void cycle_tab(int dir) {
}
for (int k = 1; k <= server.num_apps; k++) {
int j = ((idx < 0 ? 0 : idx) + k * dir + server.num_apps) % server.num_apps;
if (server.apps[j]->toplevel) {
if (server.apps[j]->toplevel && !server.apps[j]->closing) {
set_active_surface(server.apps[j]->toplevel);
return;
}
@ -3299,6 +3419,9 @@ static void dump_host_back(void) {
int w = host.width, h = host.height;
unsigned char *px = malloc((size_t)w * h * 4);
if (!px) return;
/* The blit draws are queued on the GPU; without a finish the readback can
race the previous frame's swap and return stale/mid-clear content. */
glFinish();
glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, px);
unsigned long nonblack = 0, lum = 0;
for (int y = 0; y < h * w; y++) {
@ -3566,6 +3689,27 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
}
return false;
}
/* Alt+Delete closes the active tab. Grabbed so the focused client never
sees the key; a latch drops host-synthesized repeats so one hold can't
close a tab per repeat. The active surface already moved to the right
neighbor inside close_active_tab(), so the tab is gone this frame even
before the client process exits. */
{ bool is_delete = server.xkb_state
? (sym == XKB_KEY_Delete)
: key == 111;
if (alt && is_delete) {
kbd_repeat_cancel();
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
if (!delete_key_held) {
delete_key_held = true;
close_active_tab();
}
} else {
delete_key_held = false;
}
return true;
}
}
/* Ctrl+Shift+T opens a new terminal tab ($TERMINAL, default alacritty).
The tab is inserted right after the current one and becomes active when
it maps. Grabbed here so the focused client never sees it; a latch
@ -5125,8 +5269,8 @@ int main(int argc, char *argv[]) {
}
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. "
"Alt+Up/Down switches tabs; Ctrl+Shift+T opens a terminal "
"(%s).\n", term_cmd);
"Alt+Up/Down switches tabs; Alt+Delete closes the active tab; "
"Ctrl+Shift+T opens a terminal (%s).\n", term_cmd);
/* Standalone event loop: no host display, so drive the server loop alone.
The native output's frame signal (or the headless 60Hz timer) wakes the