diff --git a/README.md b/README.md index 5c775aa..ca0f6c8 100644 --- a/README.md +++ b/README.md @@ -94,20 +94,30 @@ Notes: ### Startup order -The command-line commands are launched one at a time — each starts only after -the previous one's client has connected to the socket — so the tabs appear in -exactly the order you wrote them on the command line, no matter which program -takes longest to open its window. +The command-line commands are all launched at the same time, in the order +you wrote them. Each command runs in its own process group, and when its +client connects it claims its own slot by that group — so every tab keeps +its command-line position even with wrapped commands like `sh -c "foot -e +htop"`, no matter which program takes longest to open its window. -The window stays black while they load (tabs map hidden). When the **last** -command's tab is ready it becomes the active tab and the window reveals it; -nothing steals focus before that, so you don't flicker through every -intermediate tab. This "last command wins" rule also means the tab you see on -startup is always the one from the last argument. +Until a client connects, its tab is black and listed in the overlay under +its **command line** instead of a window title. From the very first frame +the **last** command's tab is the active one, so you wait only on that +single tab: when it maps, the window reveals it and nothing steals focus +before that, so you don't flicker through every intermediate tab. This +"last command wins" rule also means the tab you see on startup is always +the one from the last argument. -If a command never connects — e.g. `sleep 999` — the rest of the command line -never starts and the window stays black; the compositor itself keeps running -normally (`Alt+Shift+T` still works) until you close it. +If a command never connects — e.g. `sleep 999` — the rest of the command +line still starts (they all launched together) and the window shows the +last tab as soon as it is ready. A command whose whole process group exits +without ever connecting (e.g. `tabbed-wlroots /bin/false`) drops its tab +entirely instead of leaving a permanent black hole. + +Closing a tab takes it out of the list for good: its slot is removed and +the remaining tabs reflow into place — there are no holes for later clients +to fall into, so externally started windows are always appended, not +absorbed into a dead tab's position. Later tab openings follow the same no-steal rule: a brand-new tab never yanks the active surface on its own. `Alt+Shift+T` still focuses its fresh diff --git a/tabbed-wlroots.c b/tabbed-wlroots.c index 2c44e33..a7868ce 100644 --- a/tabbed-wlroots.c +++ b/tabbed-wlroots.c @@ -100,10 +100,11 @@ struct tabbed_app { char *cmd; pid_t pid; /* Process group the *connected* client lives in. Command-line commands - are forked one at a time (see maybe_launch_next), so a CLI tab's slot - is claimed by its own client in command-line order; sub-windows and - external clients claim later. A close must signal the group recorded - at claim time rather than the launcher's pid. */ + are forked all at once at startup (see launch_all_cli), so a CLI tab's + slot is claimed by whichever command's client connects -- matched by + process group at claim time, not by order; sub-windows and external + clients claim later. 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 @@ -129,24 +130,24 @@ struct tabbed_app { struct wlr_texture *icon_wlr; bool icon_resolved; /* true once a load attempt has been made */ - /* Slot created by add_tab() (Alt+Shift+T) awaiting its toplevel. Lets - handle_new_toplevel claim exactly this slot instead of an earlier - stale free one (a closed tab's hole), so a new terminal keeps its - "right of the current tab" position. */ + /* Slot awaiting its toplevel. True from creation until a client claims + the slot in handle_new_toplevel (matched by launch process group), so + a CLI tab or a fresh Alt+Shift+T terminal is live-but-black while its + command starts: it shows in the overlay with its command line as the + title, and cycling can land on it (backdrop + title). Cleared on + claim, and again when a black tab is closed. A slot without a + toplevel and without pending_claim would only exist transiently, so + closed tabs leave no holes: remove_app() drops them outright. */ bool pending_claim; /* Where a tab's slot came from. ORIGIN_CLI slots hold the command-line - commands and are forked one at a time (see maybe_launch_next), so the - tab order mirrors the command line. ORIGIN_ADD_TAB is an Alt+Shift+T - terminal. ORIGIN_SUB is a second toplevel from an already-connected - client and is glued right of its parent tab. ORIGIN_EXTERNAL is a - client we did not launch (e.g. started from our own shell). */ + commands and are forked all at once at startup (see launch_all_cli), + so the tab order mirrors the command line. ORIGIN_ADD_TAB is an + Alt+Shift+T terminal. ORIGIN_SUB is a second toplevel from an + already-connected client and is glued right of its parent tab. + ORIGIN_EXTERNAL is a client we did not launch (e.g. started from our + own shell). */ enum { ORIGIN_CLI, ORIGIN_ADD_TAB, ORIGIN_SUB, ORIGIN_EXTERNAL } origin; - /* Stable launch order for ORIGIN_CLI slots. Sub-tab insertion shifts - array indices, so the launcher keys on this, not on the array index. */ - int cli_seq; - /* True once this ORIGIN_CLI command has been forked. */ - bool launched; /* For ORIGIN_SUB: the tab owning the connected client this window belongs to (the "parent" tab its slot is glued right of). */ struct tabbed_app *parent_app; @@ -188,12 +189,12 @@ struct tabbed_server { struct wl_listener output_destroy; struct wl_event_source *ctl_source; - struct wlr_xdg_toplevel *active; - /* The last command-line command's slot. While the window is still black - it stays inactive; the moment its toplevel maps it is "crowned" active - (unless the user already focused something) -- the only map that ever - auto-focuses on its own. */ - struct tabbed_app *crown_app; + /* The active tab, by slot. Set to the last command-line command's slot + at startup, so that tab is active -- black, with its command line as + the overlay title -- from the very first frame and the user only waits + for it. A slot that is active while still pending (no toplevel yet) + shows the black backdrop and clear seat focus until it maps. */ + struct tabbed_app *active_app; struct tabbed_app **apps; int num_apps; int apps_cap; @@ -400,15 +401,33 @@ static volatile sig_atomic_t dump_scene_req, dump_host_req; static int dump_seq; static bool client_dumped = false; +/* Set by sigchld_handler whenever a child exits; the main loop clears it and + runs sweep_dead_startup_groups() to drop black tabs whose command exited + without ever connecting. A plain flag (set + cleared in the loop) keeps + the reaping work out of the signal handler. */ +static volatile sig_atomic_t sigchld_seen = 0; + +/* A tab is "live" when it can own the show: it has a mapped toplevel, or + it is pending (black) -- an unclaimed CLI tab at startup or a fresh + Alt+Shift+T terminal. Slots are otherwise removed outright, so a live + check is all that's needed to know whether a slot should be shown, + cycled to, or handed the active crown. */ +static bool app_live(struct tabbed_app *app); +/* The currently focused toplevel (NULL while the active slot is black). */ +static struct wlr_xdg_toplevel *active_toplevel(void); +static void set_active_app(struct tabbed_app *app); static void set_active_surface(struct wlr_xdg_toplevel *toplevel); static void render_and_present(void); static struct tabbed_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel); static int app_index(struct tabbed_app *app); +static struct tabbed_app *pick_successor(struct tabbed_app *app, + int prefer_right); +static void remove_app(int idx); static int add_tab(const char *cmd); static int cmd_in_path(const char *cmd); static void insert_app_after(struct tabbed_app *app, struct tabbed_app *anchor); static void launch_client(struct tabbed_app *app); -static void maybe_launch_next(void); +static void launch_all_cli(void); /* Logging levels: quiet by default so a plain launch writes nothing to the launching terminal except fatal errors. TABBED_LOG=1 shows status lines, @@ -554,6 +573,14 @@ static int apps_ensure_cap(int n) { return 0; } +static bool app_live(struct tabbed_app *app) { + return app && (app->toplevel || app->pending_claim); +} + +static struct wlr_xdg_toplevel *active_toplevel(void) { + return server.active_app ? server.active_app->toplevel : NULL; +} + /* Full shutdown of the nested session: take down the clients first, then free our own allocations and exit. Guarded so the various close paths (host window close, last tab closed) only run once. */ @@ -776,9 +803,10 @@ static void draw_texture_rect(GLuint tex, int tex_w, int tex_h, While the host Alt modifier is held, a passive list of tab titles is drawn into the top-left of the host window with the active tab highlighted. Up/Down already cycle tabs while Alt is held, so the - highlight follows server.active automatically. Titles are read from the - wlr_xdg_toplevel at draw time (terminals retitle themselves), so no - per-client listeners are needed. */ + highlight follows server.active_app automatically. Titles are read from + the wlr_xdg_toplevel at draw time (terminals retitle themselves); a + still-black tab shows its command line instead. No per-client listeners + are needed. */ static FT_Library overlay_ft_lib; static FT_Face overlay_ft_face; @@ -1402,6 +1430,22 @@ static void fit_text(const char *src, int max_px, char *out, int out_sz) out[n] = '\0'; } +/* Row title for an overlay slot: the mapped toplevel's title, or the + command line for a still-black (pending) tab. */ +static const char *app_row_title(struct tabbed_app *app) { + if (app->toplevel && app->toplevel->title && app->toplevel->title[0]) + return app->toplevel->title; + if (app && app->cmd && app->cmd[0]) + return app->cmd; + return NULL; +} + +/* A row in the overlay (both the host-GL and the standalone wlr paths): + skip slots that are dead or mid-close; black pending tabs are rows too. */ +static bool app_overlay_row(struct tabbed_app *app) { + return app_live(app) && !app->closing; +} + static void draw_tab_overlay(void) { if (!host.overlay_visible || server.num_apps < 1) return; @@ -1412,12 +1456,12 @@ static void draw_tab_overlay(void) { int max_row_w = 0, n_rows = 0; for (int i = 0; i < server.num_apps; i++) { struct tabbed_app *app = server.apps[i]; - if (!app->toplevel) continue; + if (!app_overlay_row(app)) continue; if (!app->icon_resolved) overlay_icon_load(app); - const char *title = app->toplevel->title; + const char *title = app_row_title(app); char fallback[16]; - if (!title || !title[0]) { + if (!title) { snprintf(fallback, sizeof(fallback), "tab %d", i); title = fallback; } @@ -1445,15 +1489,15 @@ static void draw_tab_overlay(void) { int row = 0; for (int i = 0; i < server.num_apps; i++) { struct tabbed_app *app = server.apps[i]; - if (!app->toplevel) continue; - const char *title = app->toplevel->title; + if (!app_overlay_row(app)) continue; + const char *title = app_row_title(app); char fallback[16]; - if (!title || !title[0]) { + if (!title) { snprintf(fallback, sizeof(fallback), "tab %d", i); title = fallback; } int ry = box_y + boxpad + row * row_h; - bool active = app->toplevel == server.active; + bool active = app == server.active_app; char row_text[512]; int budget = max_row_w - icon_sz - icon_gap; if (budget < 0) budget = 0; @@ -1477,7 +1521,7 @@ static void draw_tab_overlay(void) { ix, iy, icon_sz, icon_sz, host.width, host.height, tint); } else { - /* Generic placeholder: a bordered square for apps with no icon. */ + /* Generic placeholder for no icon or a still-black tab. */ draw_rect(ix, iy, icon_sz, icon_sz, 0.45f, 0.45f, 0.48f, 0.85f); draw_rect(ix + 3, iy + 3, icon_sz - 6, icon_sz - 6, 0.07f, 0.07f, 0.09f, 1.0f); @@ -1565,12 +1609,12 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass) { int max_row_w = 0, n_rows = 0; for (int i = 0; i < server.num_apps; i++) { struct tabbed_app *app = server.apps[i]; - if (!app->toplevel) continue; + if (!app_overlay_row(app)) continue; if (!app->icon_resolved) overlay_icon_load(app); - const char *title = app->toplevel->title; + const char *title = app_row_title(app); char fallback[16]; - if (!title || !title[0]) { + if (!title) { snprintf(fallback, sizeof(fallback), "tab %d", i); title = fallback; } @@ -1598,15 +1642,15 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass) { int row = 0; for (int i = 0; i < server.num_apps; i++) { struct tabbed_app *app = server.apps[i]; - if (!app->toplevel) continue; - const char *title = app->toplevel->title; + if (!app_overlay_row(app)) continue; + const char *title = app_row_title(app); char fallback[16]; - if (!title || !title[0]) { + if (!title) { snprintf(fallback, sizeof(fallback), "tab %d", i); title = fallback; } int ry = box_y + boxpad + row * row_h; - bool active = app->toplevel == server.active; + bool active = app == server.active_app; char row_text[512]; int budget = max_row_w - icon_sz - icon_gap; if (budget < 0) budget = 0; @@ -1870,10 +1914,67 @@ server.output = output; } } +/* Nearest live slot, scanning right first (prefer_right) or left first. + The scan stops at the array ends -- it never wraps -- so the reflow after + a slot is removed matches the old first/last-tab behavior. NULL when + `app` is the only live slot. */ +static struct tabbed_app *pick_successor(struct tabbed_app *app, + int prefer_right) { + if (!app) return NULL; + int died = app_index(app); + if (prefer_right) { + for (int i = died + 1; i < server.num_apps; i++) + if (app_live(server.apps[i]) && !server.apps[i]->closing) + return server.apps[i]; + for (int i = died - 1; i >= 0; i--) + if (app_live(server.apps[i]) && !server.apps[i]->closing) + return server.apps[i]; + } else { + for (int i = died - 1; i >= 0; i--) + if (app_live(server.apps[i]) && !server.apps[i]->closing) + return server.apps[i]; + for (int i = died + 1; i < server.num_apps; i++) + if (app_live(server.apps[i]) && !server.apps[i]->closing) + return server.apps[i]; + } + return NULL; +} + +/* Drop slot `idx` outright: free the tab's allocations, sever any other + slot's parent_app reference to it, and compact server.apps[] so a closed + tab leaves no hole that a later client could stumble into. The caller + must already have handed the active crown elsewhere; remove_app only + defends active_app against a slot that was somehow still on it. */ +static void remove_app(int idx) { + if (idx < 0 || idx >= server.num_apps) return; + struct tabbed_app *dead = server.apps[idx]; + if (server.active_app == dead) + server.active_app = NULL; + for (int i = 0; i < server.num_apps; i++) + if (server.apps[i]->parent_app == dead) + server.apps[i]->parent_app = NULL; + if (dead->close_timer) { + wl_event_source_remove(dead->close_timer); + dead->close_timer = NULL; + } + if (host.egl_ok && dead->icon_tex) { + glDeleteTextures(1, &dead->icon_tex); + dead->icon_tex = 0; + } + if (dead->icon_wlr) { + wlr_texture_destroy(dead->icon_wlr); + dead->icon_wlr = NULL; + } + free(dead->cmd); + free(dead); + memmove(&server.apps[idx], &server.apps[idx + 1], + (size_t)(server.num_apps - idx - 1) * sizeof(*server.apps)); + server.num_apps--; +} + static void handle_toplevel_destroy(struct wl_listener *listener, void *data) { (void)data; struct tabbed_app *app = wl_container_of(listener, app, destroy); - struct wlr_xdg_toplevel *toplevel = app->toplevel; app->toplevel = NULL; app->tree = NULL; @@ -1909,42 +2010,35 @@ static void handle_toplevel_destroy(struct wl_listener *listener, void *data) { app->close_timer = NULL; } - if (server.active != NULL && server.active == toplevel) { - int died = -1; - for (int i = 0; i < server.num_apps; i++) - if (server.apps[i] == app) { died = i; break; } - server.active = NULL; - if (died >= 0) { - /* Prefer the neighbor to the left (index died-1); if this is the - first tab, fall through to the rightmost-side neighbor so the - tab that was second becomes the new leftmost. */ - for (int i = died - 1; i >= 0; i--) - if (server.apps[i]->toplevel) { - set_active_surface(server.apps[i]->toplevel); - return; - } - for (int i = died + 1; i < server.num_apps; i++) - if (server.apps[i]->toplevel) { - set_active_surface(server.apps[i]->toplevel); - return; - } + if (server.active_app == app) { + server.active_app = NULL; + /* Prefer the neighbor to the left (index died-1); if this is the + first tab, fall through to the right-side neighbor so the tab + that was second becomes the new leftmost. */ + struct tabbed_app *succ = pick_successor(app, -1); + if (succ) { + set_active_app(succ); + } else { + /* last tab closed: close the host window (nested only) and exit */ + if (host.toplevel) xdg_toplevel_destroy(host.toplevel); + if (host.surface) wl_surface_destroy(host.surface); + shutdown_cleanup(); + exit(0); } - /* last tab closed: close the host window (nested only) and exit */ - if (host.toplevel) xdg_toplevel_destroy(host.toplevel); - if (host.surface) wl_surface_destroy(host.surface); - shutdown_cleanup(); - exit(0); } + /* The slot itself is removed outright: closed tabs leave no hole. */ + remove_app(app_index(app)); } /* 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. */ + and its toplevel is still up. Live slots are removed at toplevel destroy + (which also cancels this timer), so for a mapped tab the callback never + fires against a reaped or reused pid. For a black tab there is no + toplevel whose destroy would reap the slot, so its removal happens here, + right after the SIGKILL. */ static int close_tab_escalate(void *data) { struct tabbed_app *app = data; if (app->closing && app_killable(app)) @@ -1953,43 +2047,47 @@ static int close_tab_escalate(void *data) { wl_event_source_remove(app->close_timer); app->close_timer = NULL; } + if (app->closing && !app->toplevel) + remove_app(app_index(app)); 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. */ + active crown moves to the right neighbor (or the left neighbor if the + closed tab was the rightmost) in this same call. The client's whole + process group gets SIGTERM, then SIGKILL after a short grace if it ignores + the TERM (see close_tab_escalate). A closing black tab (still pending) + drops out of the overlay and cycle right away and is removed by the + escalation; a closing last tab tears the session down here, since nothing + else will. */ static void close_active_tab(void) { - struct tabbed_app *app = app_for_toplevel(server.active); + struct tabbed_app *app = server.active_app; if (!app || !app_killable(app)) return; pid_t g = app_signal_group(app); + pid_t shown = (g > 0 && g != getpgid(0)) ? g : app->client_pid; 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; + /* A black tab never fires a toplevel destroy, so drop it from the + overlay/cycle immediately; only the escalation timer and the + success-removal paths touch it afterwards. */ + if (!app->toplevel) + app->pending_claim = false; + + struct tabbed_app *succ = pick_successor(app, +1); + if (succ) { + set_active_app(succ); + } else { + /* Last live tab: tear the session down now. */ + tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: closing tab %d (pid %d)\n", + died, (int)shown); + if (host.toplevel) xdg_toplevel_destroy(host.toplevel); + if (host.surface) wl_surface_destroy(host.surface); + shutdown_cleanup(); + exit(0); + } + kill_app(app, SIGTERM); tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: closing tab %d (pid %d)\n", died, (int)shown); @@ -2109,7 +2207,7 @@ static void handle_toplevel_commit(struct wl_listener *listener, void *data) { is_dma, is_dma ? (dma.n_planes ? "" : " planes=0") : "", node_on, - server.active == toplevel); + server.active_app == app); if (is_dma) { tabbed_log(TABBED_LOG_DEBUG, "[diag] dmabuf planes=%d " "format=0x%x mod=0x%llx\n", @@ -2237,19 +2335,28 @@ static void handle_toplevel_map(struct wl_listener *listener, void *data) { /* A new tab never steals focus on its own. The exceptions: a Alt+Shift+T terminal (add_tab), a sub-window whose parent tab is the - one currently shown, and the one-shot startup crown of the last - command-line command (only while nothing is focused yet). Every other - map stays hidden -- the overlay and cycling reach it, but it does not - yank the active surface. */ + one currently shown, and the startup-selected last command-line tab + when it finally maps (it has been the active, black tab all along, + revealed the moment its first frame arrives). Every other map stays + hidden -- the overlay and cycling reach it, but it does not yank the + active surface. A slot's toplevel is set at claim time (before its + map), so "is it already shown?" is whether the scene node is enabled, + not whether active_toplevel() is set. */ bool activate = false; if (app->origin == ORIGIN_ADD_TAB) activate = true; else if (app->origin == ORIGIN_SUB && app->parent_app && - app->parent_app->toplevel == server.active) + app->parent_app->toplevel == active_toplevel()) activate = true; - else if (app == server.crown_app && !server.active) + else if (app == server.active_app && app->tree && + !app->tree->node.enabled) activate = true; + /* A slot claimed while it was still black may have been drawn with a + placeholder icon; once a real client maps it, re-resolve the icon so + the overlay picks up its app_id. */ + app->icon_resolved = false; + if (activate) { wlr_xdg_toplevel_set_activated(toplevel, true); set_active_surface(toplevel); @@ -2266,18 +2373,28 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) { &cpid, NULL, NULL); struct tabbed_app *app = NULL; - /* Prefer a slot prepared by add_tab() (Alt+Shift+T): its position was - chosen to sit right next to the tab the user launched it from, so an - earlier stale free slot (a closed tab's hole) must not steal it. */ - for (int i = 0; i < server.num_apps && !app; i++) - if (!server.apps[i]->toplevel && server.apps[i]->pending_claim) - app = server.apps[i]; + /* A slot awaiting its toplevel -- an unclaimed command-line command or + a fresh Alt+Shift+T terminal -- belongs to exactly ONE client: the one + WE launched for it. launch_client() runs each command in its own + process group (setpgid(0,0)), so the connected client's group id + equals the launcher pid app->pid, including through a shell wrapper + (`sh -c "foot -e htop"`). Matching by group -- not exact pid -- is + what keeps a wrapped command on its own command-line slot. A client + from a group we never created (an external one) never matches, so it + can't steal a still-loading tab's position. */ + pid_t pgid = cpid > 0 ? getpgid(cpid) : -1; + for (int i = 0; i < server.num_apps && !app; i++) { + struct tabbed_app *a = server.apps[i]; + if (a->toplevel || !a->pending_claim) continue; + if (a->pid > 0 && a->pid == pgid) + app = a; + } /* A second toplevel from a client that already owns a tab is a sub-window of that tab: give it its own slot glued right after its parent (the protocol parent, else the owning tab), never touching a reserved - pending or free CLI slot. Command-line commands are forked one at a - time (see maybe_launch_next), so their clients never match here and - claim their own slots in command-line order below. */ + pending slot. Command-line commands are all forked at startup (see + launch_all_cli); their clients match the pending loop above, and only + then. */ if (!app && cpid > 0) { struct tabbed_app *owner = sub_window_owner(cpid); if (owner) { @@ -2294,16 +2411,19 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) { app = sub; } } - /* Claim the first free slot: the serial CLI command that just connected - lands on its own command-line slot (later commands have not even been - forked yet, so nothing can claim ahead of it), or an external client - started from our own shell takes the earliest free hole. */ + /* No pending slot matched (an external client, or one from a group we + never created). Closed tabs are removed outright, so there are no + free holes to fall back into; this loop only catches a slot that is + transiently without a toplevel and not reserved, so it usually falls + through to the ORIGIN_EXTERNAL append below. */ if (!app) - for (int i = 0; i < server.num_apps; i++) - if (!server.apps[i]->toplevel) { - app = server.apps[i]; + for (int i = 0; i < server.num_apps; i++) { + struct tabbed_app *a = server.apps[i]; + if (!a->toplevel && !a->pending_claim && !a->closing) { + app = a; break; } + } if (!app) { /* No slot for this toplevel (an external client with every slot taken). Give it its own tab so it appears on top and stays @@ -2318,7 +2438,7 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) { struct tabbed_app *pa = toplevel->parent ? app_for_toplevel(toplevel->parent) : NULL; if (!pa) - pa = app_for_toplevel(server.active); + pa = app_for_toplevel(active_toplevel()); insert_app_after(extra, pa); app = extra; } @@ -2332,15 +2452,14 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) { /* [claim] ties the app slot to the *connected client's* PID, so tests can kill the tab that is actually active. The slot was chosen above by - add_tab()'s pending claim, by a serial-launched CLI command landing on - its own slot, by the sub-window/parent rule, or by the free-slot - fallback. */ - /* The client that actually occupies this slot may be the CLI command that - forked it, an add_tab() tab, or a fallback-claimed extra. 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 + the process-group match against a pending command (CLI or add_tab), by + the sub-window/parent rule, or by the ORIGIN_EXTERNAL append. */ + /* The client that actually occupies this slot may be the command we + forked, an add_tab() tab, or an external client. 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; @@ -2367,10 +2486,6 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) { wl_signal_add(&toplevel->events.request_move, &app->request_move); app->request_resize.notify = handle_request_resize; wl_signal_add(&toplevel->events.request_resize, &app->request_resize); - - /* A command-line tab has connected: unblock the launcher so the next - command-line command can fork. */ - maybe_launch_next(); } /* ===================== Tab management ===================== */ @@ -2494,54 +2609,77 @@ static void handle_new_popup(struct wl_listener *listener, void *data) { create_popup(popup, parent_tree); } -static void set_active_surface(struct wlr_xdg_toplevel *toplevel) { - if (server.active == toplevel) return; +/* Make `app` the active slot. A mapped slot shows its surface and takes + keyboard focus; a pending (black) slot shows the backdrop and drops the + seat focus until it maps. The old active slot is hidden and deactivated. + Called with the same app as already active only to promote it black -> + mapped (the one case with something still to do), so callers that want a + true no-op must guard first. */ +static void set_active_app(struct tabbed_app *app) { + if (!app) return; - struct tabbed_app *old = app_for_toplevel(server.active); - if (old && old->tree) - wlr_scene_node_set_enabled(&old->tree->node, false); - if (server.active) - wlr_xdg_toplevel_set_activated(server.active, false); + struct tabbed_app *old = server.active_app; + if (old && old != app && old->toplevel) { + if (old->tree) + wlr_scene_node_set_enabled(&old->tree->node, false); + wlr_xdg_toplevel_set_activated(old->toplevel, false); + } + server.active_app = app; - server.active = toplevel; - wlr_xdg_toplevel_set_activated(toplevel, true); - - struct tabbed_app *app = app_for_toplevel(server.active); - if (app && app->tree) { + if (app->toplevel) { + wlr_xdg_toplevel_set_activated(app->toplevel, true); position_toplevel(app); - wlr_scene_node_set_enabled(&app->tree->node, true); + if (app->tree) + wlr_scene_node_set_enabled(&app->tree->node, true); if (tabbed_log_level() >= TABBED_LOG_DEBUG) tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d surface=%dx%d " "node on\n", app_index(app), - toplevel->base->surface->current.width, - toplevel->base->surface->current.height); + app->toplevel->base->surface->current.width, + app->toplevel->base->surface->current.height); } else if (tabbed_log_level() >= TABBED_LOG_DEBUG) { - tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d has NO scene node!\n", - app ? app_index(app) : -1); + tabbed_log(TABBED_LOG_DEBUG, "[diag] active slot=%d (black, awaiting map)\n", + app_index(app)); } /* In standalone the seat keyboard is the emitting native device (set by the standalone handlers); fall back to the virtual keyboard otherwise - so the nested/ctl path keeps using it. */ + so the nested/ctl path keeps using it. A black active tab has no + surface to enter, so clear the focus instead of leaving a stale one. */ struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat); if (!kbd) kbd = &server.keyboard; - wlr_seat_keyboard_notify_enter(server.seat, toplevel->base->surface, - kbd->keycodes, kbd->num_keycodes, &kbd->modifiers); + if (app->toplevel) + wlr_seat_keyboard_notify_enter(server.seat, + app->toplevel->base->surface, + kbd->keycodes, kbd->num_keycodes, &kbd->modifiers); + else + wlr_seat_keyboard_notify_clear_focus(server.seat); needs_redraw = true; } +static void set_active_surface(struct wlr_xdg_toplevel *toplevel) { + struct tabbed_app *app = app_for_toplevel(toplevel); + if (!app) return; + /* Promoting the already-active black tab to mapped is the one same-app + call that must still run; everything else that's a true no-op returns + here, so redundant activations emit no [diag] churn. */ + if (app == server.active_app && app->toplevel && app->tree && + app->tree->node.enabled) + return; + set_active_app(app); +} + static void cycle_tab(int dir) { if (server.num_apps == 0) return; int idx = -1; for (int i = 0; i < server.num_apps; i++) - if (server.apps[i]->toplevel == server.active) { + if (server.apps[i] == server.active_app) { idx = i; break; } 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 && !server.apps[j]->closing) { - set_active_surface(server.apps[j]->toplevel); + if (app_live(server.apps[j]) && !server.apps[j]->closing) { + set_active_app(server.apps[j]); return; } } @@ -2550,18 +2688,18 @@ static void cycle_tab(int dir) { /* Move the active tab one position up (dir>0: later in the list) or down (dir<0) within the overlay order, wrapping at the ends. The active surface is unchanged; only its slot in server.apps[] moves, which the - overlay and cycling both iterate. Only slots that currently show a - toplevel (and aren't mid-close, mirroring cycle_tab) count as positions; + overlay and cycling both iterate. Only live slots (mapped or pending + black, mirroring cycle_tab) that aren't mid-close count as positions; with fewer than two of them there is nowhere to move. */ static void reorder_active_tab(int dir) { int n = 0; for (int i = 0; i < server.num_apps; i++) - if (server.apps[i]->toplevel && !server.apps[i]->closing) + if (app_live(server.apps[i]) && !server.apps[i]->closing) n++; if (n < 2) return; int from = -1; for (int i = 0; i < server.num_apps; i++) - if (server.apps[i]->toplevel == server.active) { + if (server.apps[i] == server.active_app) { from = i; break; } @@ -2570,25 +2708,25 @@ static void reorder_active_tab(int dir) { int neighbor = -1; if (dir > 0) { for (int i = from + 1; i < server.num_apps; i++) - if (server.apps[i]->toplevel && !server.apps[i]->closing) { + if (app_live(server.apps[i]) && !server.apps[i]->closing) { neighbor = i; break; } if (neighbor < 0) for (int i = 0; i < server.num_apps; i++) - if (server.apps[i]->toplevel && !server.apps[i]->closing) { + if (app_live(server.apps[i]) && !server.apps[i]->closing) { neighbor = i; break; } } else { for (int i = from - 1; i >= 0; i--) - if (server.apps[i]->toplevel && !server.apps[i]->closing) { + if (app_live(server.apps[i]) && !server.apps[i]->closing) { neighbor = i; break; } if (neighbor < 0) for (int i = server.num_apps - 1; i >= 0; i--) - if (server.apps[i]->toplevel && !server.apps[i]->closing) { + if (app_live(server.apps[i]) && !server.apps[i]->closing) { neighbor = i; break; } @@ -3565,10 +3703,10 @@ static void host_kbd_enter(void *data, struct wl_keyboard *kbd, uint32_t serial, bridge_host_set_selection(server.seat->selection_source); } tabbed_log(TABBED_LOG_DEBUG, "[kbd] enter surface=%p serial=%u active=%p\n", - (void *)surface, serial, (void *)server.active); - if (server.active) - wlr_seat_keyboard_notify_enter(server.seat, - server.active->base->surface, + (void *)surface, serial, (void *)active_toplevel()); + struct wlr_xdg_toplevel *tl = active_toplevel(); + if (tl) + wlr_seat_keyboard_notify_enter(server.seat, tl->base->surface, server.keyboard.keycodes, server.keyboard.num_keycodes, &server.keyboard.modifiers); } @@ -4973,39 +5111,82 @@ static void launch_client(struct tabbed_app *app) { } if (pid > 0) { app->pid = pid; - app->launched = true; tabbed_log(TABBED_LOG_INFO, "[launch] app=%d pid=%d cmd=%s\n", app_index(app), pid, app->cmd); } } -/* Fork the next command-line command, one at a time, gated on the previous - one's connection. Because only one such client is running at a time, the - next connector is unambiguously the next command, so the tab order follows - the command-line order with no pid matching. A command that never - connects stalls the chain (and, for the last command, the startup crown) - indefinitely, leaving the window black -- accepted behaviour. */ -static void maybe_launch_next(void) { - struct tabbed_app *next = NULL; +/* Fork every command-line command at startup, in command-line order. Each + slot keeps its position because handle_new_toplevel claims it by process + group (launch_client runs each command in its own setpgid'd group, whose + id equals app->pid), so the tab order mirrors the command line no matter + which client connects first. A command that never connects leaves its + tab black; a command whose process group exits without ever connecting is + swept away later (see sweep_dead_startup_groups). */ +static void launch_all_cli(void) { for (int i = 0; i < server.num_apps; i++) { struct tabbed_app *a = server.apps[i]; - if (a->origin != ORIGIN_CLI || a->launched) continue; - if (!next || a->cli_seq < next->cli_seq) next = a; + if (a->origin != ORIGIN_CLI) continue; + launch_client(a); } - if (!next) return; - if (next->cli_seq > 0) { - bool prev_connected = false; - for (int i = 0; i < server.num_apps; i++) { - struct tabbed_app *a = server.apps[i]; - if (a->origin == ORIGIN_CLI && - a->cli_seq == next->cli_seq - 1 && a->toplevel) { - prev_connected = true; - break; +} + +/* Drop black tabs whose command's whole process group has exited (e.g. + `tabbed-wlroots /bin/false` -- sh runs false, the group empties, and the + tab would otherwise stay black forever). Runs from the main loop after a + SIGCHLD, never from the handler: it frees slots. Group liveness, not the + launcher pid's, decides: `sh -c "cmd &"` returns while cmd is still + starting, but cmd holds the group open, so kill(-groupid,0) succeeds and + the tab survives. */ +static void sweep_dead_startup_groups(void) { + for (int i = 0; i < server.num_apps; i++) { + struct tabbed_app *a = server.apps[i]; + if (a->toplevel || !a->pending_claim || a->closing || a->pid <= 0) + continue; + errno = 0; + if (kill(-a->pid, 0) == 0 || errno == EPERM) + continue; /* the group still has a member */ + if (errno != ESRCH) + continue; + if (server.active_app == a) { + server.active_app = NULL; + struct tabbed_app *succ = pick_successor(a, -1); + if (!succ) succ = pick_successor(a, +1); + if (succ) + set_active_app(succ); + else { + /* the last tab died before ever connecting: tear down */ + if (host.toplevel) xdg_toplevel_destroy(host.toplevel); + if (host.surface) wl_surface_destroy(host.surface); + shutdown_cleanup(); + exit(0); } } - if (!prev_connected) return; + tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: dropping tab %d (pid %d) " + "-- command exited without connecting\n", i, (int)a->pid); + remove_app(i); + i--; } - launch_client(next); + /* If the sweep's succession handed active to a slot that this same pass + then removed, put the crown back on some live tab (or nothing). */ + if (!server.active_app) { + struct tabbed_app *succ = NULL; + for (int i = 0; i < server.num_apps; i++) + if (app_live(server.apps[i]) && !server.apps[i]->closing) { + succ = server.apps[i]; + break; + } + if (succ) + set_active_app(succ); + } +} + +/* Drain the SIGCHLD latch and reap dead startup groups; called from both + event loops after each dispatch. */ +static void sweep_dead_startup(void) { + if (!sigchld_seen) return; + sigchld_seen = 0; + sweep_dead_startup_groups(); } /* Add a new tab launching `cmd` and start it, exactly as if `cmd` had been @@ -5022,7 +5203,7 @@ static int add_tab(const char *cmd) { app->pending_claim = true; app->origin = ORIGIN_ADD_TAB; - insert_app_after(app, app_for_toplevel(server.active)); + insert_app_after(app, app_for_toplevel(active_toplevel())); int idx = app_index(app); launch_client(app); return idx; @@ -5042,6 +5223,7 @@ static void crash_handler(int sig, siginfo_t *info, void *ctx) { static void sigchld_handler(int sig) { (void)sig; + sigchld_seen = 1; int status; pid_t pid; if (tabbed_log_level() >= TABBED_LOG_DEBUG) { @@ -5547,13 +5729,11 @@ int main(int argc, char *argv[]) { app->cmd = strdup(default_tab ? term_cmd : appv[i]); app->pid = -1; app->origin = ORIGIN_CLI; - app->cli_seq = i; + /* Live-but-black until the command claims the slot with a toplevel. */ + app->pending_claim = true; server.apps[i] = app; } server.num_apps = total_apps; - /* The last command-line command is the one the startup crown singles - out once its toplevel maps. */ - server.crown_app = server.apps[total_apps - 1]; for (int i = 0; i < server.num_apps; i++) { if (server.apps[i]->cmd[0] == '-') { @@ -5573,9 +5753,15 @@ int main(int argc, char *argv[]) { } } } - /* Launch the command-line commands one at a time, gated on each one - connecting (see maybe_launch_next). */ - maybe_launch_next(); + + /* The last command-line command is active from the very first frame: + black, titled by its command line, until the client maps. The user + waits only on that one tab. */ + set_active_app(server.apps[total_apps - 1]); + + /* Fork every command-line command at once; the process-group match in + handle_new_toplevel gives each its own slot on arrival. */ + launch_all_cli(); const char *prefix = tab_prefix == PREFIX_SUPER ? "Super" : "Alt"; tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. " @@ -5594,6 +5780,7 @@ int main(int argc, char *argv[]) { ret = wl_event_loop_dispatch(server.loop, -1); } while (ret == -1 && errno == EINTR); wl_display_flush_clients(server.wl_display); + sweep_dead_startup(); if (frame_pending || needs_redraw) { frame_pending = false; needs_redraw = false; @@ -5634,6 +5821,7 @@ int main(int argc, char *argv[]) { wl_event_loop_dispatch(server.loop, 0); } wl_display_flush_clients(server.wl_display); + sweep_dead_startup(); wl_display_dispatch_pending(host.display); /* The headless output fires a frame signal at ~60Hz; render then diff --git a/tests/test_smoke.py b/tests/test_smoke.py index c83db7d..1cdaccf 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -249,13 +249,15 @@ class Session: pytest.fail(f"active app never changed away from {away_from}") def wait_any_active(self, timeout=10.0): - """Wait until the startup crown has activated a tab, return its index. + """Wait until the startup-selected tab has activated (its first map), + return its index. - Only the last command-line command is ever auto-activated on boot - (the "crown"); every earlier tab maps hidden with no `active app=` - line, so the first active line is the settled tab -- there is no - churn to wait out. For the fixtures' N-tab sessions the crown is - always index N-1. + The last command-line command is active from the very first frame -- + black, titled by its command line -- and is the only tab that ever + auto-activates on boot; every earlier tab maps hidden with no + ``active app=`` line. The first active line is therefore the settled + tab, with no churn to wait out. For the fixtures' N-tab sessions + that tab is index N-1. """ rx = re.compile(r"active app=(\d+)") since = time.monotonic() @@ -264,7 +266,23 @@ class Session: if m: return int(m[-1]) time.sleep(0.1) - pytest.fail("no tab was ever activated (startup crown never fired)") + pytest.fail("no tab was ever activated (startup tab never mapped)") + + def wait_active_frame(self, timeout=10.0): + """Wait until the active tab has painted a real frame: the composed + (or host-presented) frame's centre is no longer the black backdrop. + Guards single-read pixel asserts that need black to mean "overlay + shown" and probe areas to mean "pure client colour".""" + since = time.monotonic() + while time.monotonic() - since < timeout: + path = self.dump_host() + if path: + w, h, px = self.ppm_rgb(path) + i = 3 * (h // 2 * w + w // 2) + if (px[i], px[i + 1], px[i + 2]) != (0, 0, 0): + return + time.sleep(0.1) + pytest.fail("active tab never painted a frame (centre stayed black)") def wait_app_active(self, index, timeout=10.0): """Wait until the single app `index` reports itself active (for @@ -508,6 +526,7 @@ def test_standalone_headless_alt_overlay_scene(standalone_session): whose centre darkens while Alt is held.""" s = standalone_session s.wait_any_active() + s.wait_active_frame() # Before Alt: the frame should be the client content (no dark overlay). w, h, px = s.ppm_rgb(s.dump_host()) @@ -577,6 +596,7 @@ def test_standalone_headless_cursor_visible(standalone_session): through the same flip.""" s = standalone_session s.wait_any_active() + s.wait_active_frame() assert s.proc.poll() is None def patch_at(path, cx, cy, r=22): @@ -636,6 +656,7 @@ def test_standalone_headless_cursor_relative_motion(standalone_session): the cursor.""" s = standalone_session s.wait_any_active() + s.wait_active_frame() assert s.proc.poll() is None def patch_at(path, cx, cy, r=22): @@ -704,6 +725,7 @@ def test_standalone_headless_screen_mode_cursor_stays_aligned(): "build/wltest '#ff0000'") try: s.wait_app_active(0) + s.wait_active_frame() assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" def patch_at(path, cx, cy, r=22): @@ -1155,6 +1177,7 @@ def test_alt_holds_tab_overlay(session): box pixel appears in the centered window while Alt is held and reverts after release.""" session.wait_any_active() + session.wait_active_frame() def has_dark(w, h, px): for y in range(h // 4, 3 * h // 4): @@ -1737,3 +1760,48 @@ def test_missing_arg_fails(): assert r.returncode != 0 assert b"definitely-no-such-cmd-xyz" in (r.stdout + r.stderr) assert b"not found in PATH" in (r.stdout + r.stderr) + + +def test_close_black_tab_active_last(): + """Alt+Delete on a black tab (its command has not connected yet) closes + it outright: the surviving tab becomes active, the black slot is removed + (closed tabs leave no hole), and closing the survivor exits cleanly.""" + if not os.environ.get("WAYLAND_DISPLAY"): + pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") + # CLI #0 (RED) maps hidden; the active tab is the still-black `sleep`. + s = _nested_session(f"{WL} '{RED}'", "sleep 999") + try: + s.wait_for(r"\[diag\] map app=0") + s.chord(ALT, DELETE) + s.wait_for(r"tabbed-wlroots: closing tab 1") + # The black tab is gone; the RED survivor becomes active in its place. + s.wait_for(r"active app=0") + assert s.proc.poll() is None, \ + "compositor exited after closing the black tab" + + # Closing the last live tab tears the session down cleanly. + m = re.search(r"\[claim\] app=0 pid=(\d+)", s.read_log()) + assert m, "RED tab never claimed a slot" + os.kill(int(m.group(1)), signal.SIGTERM) + s.proc.wait(timeout=5) + assert s.proc.returncode == 0, \ + f"compositor exited with {s.proc.returncode} after last tab closed" + finally: + s.close() + + +def test_command_never_connecting_drops_tab(): + """A command whose whole process group exits without ever connecting + (`/bin/false`) is swept away instead of leaving a permanent black tab; + the surviving command's tab stays active.""" + if not os.environ.get("WAYLAND_DISPLAY"): + pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") + s = _nested_session("/bin/false", f"{WL} '{BLUE}'") + try: + s.wait_for(r"dropping tab 0") + # The surviving tab claims the vacated index and becomes active. + s.wait_for(r"active app=0") + assert s.proc.poll() is None, \ + "compositor exited after dropping the never-connecting tab" + finally: + s.close()