Launch CLI tabs in parallel; slot-claim by process group

- All command-line tabs fork at startup; the last one is active and shown
  black until its first frame maps, matching the existing README contract
  that the last command's window appears immediately.
- Claim incoming toplevels by process group, not exact pid, so wrapped
  commands (sh -c "foot -e htop") keep their own slot even though the
  sh wrapper forks the real client.
- Closed tabs are fully removed: remove_app() compacts the array, frees the
  slot, clears parent refs, and prefers the left neighbor as the successor
  (right on explicit close, mirroring the old behavior on destroy).
- Black (pending) tabs are live slots: cycle/reorder walk them, activation
  logs '[diag] active slot=N (black, awaiting map)', and Alt+Delete closes
  one outright.  A SIGCHLD-latched sweep drops black tabs whose whole
  process group exits before ever connecting (e.g. /bin/false).
- The map-reveal rule keys on the scene node still being disabled, since a
  slot's toplevel is set at claim time (before its map event).
- Update the standalone/headless and nested tests to wait for a mapped
  frame (the startup tab is active before it maps, so the old 'no tab was
  ever activated' wait no longer fires), and add two tests covering
  Alt+Delete on a black active tab and the never-connecting-command sweep.
This commit is contained in:
Andrew Hamilton 2026-08-18 22:41:27 +10:00
parent 5d840a09dd
commit 7f756ba8ee
3 changed files with 485 additions and 219 deletions

View file

@ -94,20 +94,30 @@ Notes:
### Startup order ### Startup order
The command-line commands are launched one at a time — each starts only after The command-line commands are all launched at the same time, in the order
the previous one's client has connected to the socket — so the tabs appear in you wrote them. Each command runs in its own process group, and when its
exactly the order you wrote them on the command line, no matter which program client connects it claims its own slot by that group — so every tab keeps
takes longest to open its window. 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** Until a client connects, its tab is black and listed in the overlay under
command's tab is ready it becomes the active tab and the window reveals it; its **command line** instead of a window title. From the very first frame
nothing steals focus before that, so you don't flicker through every the **last** command's tab is the active one, so you wait only on that
intermediate tab. This "last command wins" rule also means the tab you see on single tab: when it maps, the window reveals it and nothing steals focus
startup is always the one from the last argument. 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 If a command never connects — e.g. `sleep 999` — the rest of the command
never starts and the window stays black; the compositor itself keeps running line still starts (they all launched together) and the window shows the
normally (`Alt+Shift+T` still works) until you close it. 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 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 the active surface on its own. `Alt+Shift+T` still focuses its fresh

View file

@ -100,10 +100,11 @@ struct tabbed_app {
char *cmd; char *cmd;
pid_t pid; pid_t pid;
/* Process group the *connected* client lives in. Command-line commands /* 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 are forked all at once at startup (see launch_all_cli), so a CLI tab's
is claimed by its own client in command-line order; sub-windows and slot is claimed by whichever command's client connects -- matched by
external clients claim later. A close must signal the group recorded process group at claim time, not by order; sub-windows and external
at claim time rather than the launcher's pid. */ clients claim later. A close must signal the group recorded at claim
time rather than the launcher's pid. */
pid_t pgid; pid_t pgid;
/* PID of the *connected* client (from claim-time credentials). Used as /* 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 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; struct wlr_texture *icon_wlr;
bool icon_resolved; /* true once a load attempt has been made */ bool icon_resolved; /* true once a load attempt has been made */
/* Slot created by add_tab() (Alt+Shift+T) awaiting its toplevel. Lets /* Slot awaiting its toplevel. True from creation until a client claims
handle_new_toplevel claim exactly this slot instead of an earlier the slot in handle_new_toplevel (matched by launch process group), so
stale free one (a closed tab's hole), so a new terminal keeps its a CLI tab or a fresh Alt+Shift+T terminal is live-but-black while its
"right of the current tab" position. */ 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; bool pending_claim;
/* Where a tab's slot came from. ORIGIN_CLI slots hold the command-line /* 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 commands and are forked all at once at startup (see launch_all_cli),
tab order mirrors the command line. ORIGIN_ADD_TAB is an Alt+Shift+T so the tab order mirrors the command line. ORIGIN_ADD_TAB is an
terminal. ORIGIN_SUB is a second toplevel from an already-connected Alt+Shift+T terminal. ORIGIN_SUB is a second toplevel from an
client and is glued right of its parent tab. ORIGIN_EXTERNAL is a already-connected client and is glued right of its parent tab.
client we did not launch (e.g. started from our own shell). */ 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; 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 /* For ORIGIN_SUB: the tab owning the connected client this window
belongs to (the "parent" tab its slot is glued right of). */ belongs to (the "parent" tab its slot is glued right of). */
struct tabbed_app *parent_app; struct tabbed_app *parent_app;
@ -188,12 +189,12 @@ struct tabbed_server {
struct wl_listener output_destroy; struct wl_listener output_destroy;
struct wl_event_source *ctl_source; struct wl_event_source *ctl_source;
struct wlr_xdg_toplevel *active; /* The active tab, by slot. Set to the last command-line command's slot
/* The last command-line command's slot. While the window is still black at startup, so that tab is active -- black, with its command line as
it stays inactive; the moment its toplevel maps it is "crowned" active the overlay title -- from the very first frame and the user only waits
(unless the user already focused something) -- the only map that ever for it. A slot that is active while still pending (no toplevel yet)
auto-focuses on its own. */ shows the black backdrop and clear seat focus until it maps. */
struct tabbed_app *crown_app; struct tabbed_app *active_app;
struct tabbed_app **apps; struct tabbed_app **apps;
int num_apps; int num_apps;
int apps_cap; int apps_cap;
@ -400,15 +401,33 @@ static volatile sig_atomic_t dump_scene_req, dump_host_req;
static int dump_seq; static int dump_seq;
static bool client_dumped = false; 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 set_active_surface(struct wlr_xdg_toplevel *toplevel);
static void render_and_present(void); static void render_and_present(void);
static struct tabbed_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel); static struct tabbed_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel);
static int app_index(struct tabbed_app *app); 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 add_tab(const char *cmd);
static int cmd_in_path(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 insert_app_after(struct tabbed_app *app, struct tabbed_app *anchor);
static void launch_client(struct tabbed_app *app); 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 /* Logging levels: quiet by default so a plain launch writes nothing to the
launching terminal except fatal errors. TABBED_LOG=1 shows status lines, launching terminal except fatal errors. TABBED_LOG=1 shows status lines,
@ -554,6 +573,14 @@ static int apps_ensure_cap(int n) {
return 0; 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 /* Full shutdown of the nested session: take down the clients first, then
free our own allocations and exit. Guarded so the various close paths free our own allocations and exit. Guarded so the various close paths
(host window close, last tab closed) only run once. */ (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 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 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 highlighted. Up/Down already cycle tabs while Alt is held, so the
highlight follows server.active automatically. Titles are read from the highlight follows server.active_app automatically. Titles are read from
wlr_xdg_toplevel at draw time (terminals retitle themselves), so no the wlr_xdg_toplevel at draw time (terminals retitle themselves); a
per-client listeners are needed. */ still-black tab shows its command line instead. No per-client listeners
are needed. */
static FT_Library overlay_ft_lib; static FT_Library overlay_ft_lib;
static FT_Face overlay_ft_face; 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'; 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) { static void draw_tab_overlay(void) {
if (!host.overlay_visible || server.num_apps < 1) return; 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; int max_row_w = 0, n_rows = 0;
for (int i = 0; i < server.num_apps; i++) { for (int i = 0; i < server.num_apps; i++) {
struct tabbed_app *app = server.apps[i]; struct tabbed_app *app = server.apps[i];
if (!app->toplevel) continue; if (!app_overlay_row(app)) continue;
if (!app->icon_resolved) if (!app->icon_resolved)
overlay_icon_load(app); overlay_icon_load(app);
const char *title = app->toplevel->title; const char *title = app_row_title(app);
char fallback[16]; char fallback[16];
if (!title || !title[0]) { if (!title) {
snprintf(fallback, sizeof(fallback), "tab %d", i); snprintf(fallback, sizeof(fallback), "tab %d", i);
title = fallback; title = fallback;
} }
@ -1445,15 +1489,15 @@ static void draw_tab_overlay(void) {
int row = 0; int row = 0;
for (int i = 0; i < server.num_apps; i++) { for (int i = 0; i < server.num_apps; i++) {
struct tabbed_app *app = server.apps[i]; struct tabbed_app *app = server.apps[i];
if (!app->toplevel) continue; if (!app_overlay_row(app)) continue;
const char *title = app->toplevel->title; const char *title = app_row_title(app);
char fallback[16]; char fallback[16];
if (!title || !title[0]) { if (!title) {
snprintf(fallback, sizeof(fallback), "tab %d", i); snprintf(fallback, sizeof(fallback), "tab %d", i);
title = fallback; title = fallback;
} }
int ry = box_y + boxpad + row * row_h; int ry = box_y + boxpad + row * row_h;
bool active = app->toplevel == server.active; bool active = app == server.active_app;
char row_text[512]; char row_text[512];
int budget = max_row_w - icon_sz - icon_gap; int budget = max_row_w - icon_sz - icon_gap;
if (budget < 0) budget = 0; if (budget < 0) budget = 0;
@ -1477,7 +1521,7 @@ static void draw_tab_overlay(void) {
ix, iy, icon_sz, icon_sz, ix, iy, icon_sz, icon_sz,
host.width, host.height, tint); host.width, host.height, tint);
} else { } 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, 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, draw_rect(ix + 3, iy + 3, icon_sz - 6, icon_sz - 6,
0.07f, 0.07f, 0.09f, 1.0f); 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; int max_row_w = 0, n_rows = 0;
for (int i = 0; i < server.num_apps; i++) { for (int i = 0; i < server.num_apps; i++) {
struct tabbed_app *app = server.apps[i]; struct tabbed_app *app = server.apps[i];
if (!app->toplevel) continue; if (!app_overlay_row(app)) continue;
if (!app->icon_resolved) if (!app->icon_resolved)
overlay_icon_load(app); overlay_icon_load(app);
const char *title = app->toplevel->title; const char *title = app_row_title(app);
char fallback[16]; char fallback[16];
if (!title || !title[0]) { if (!title) {
snprintf(fallback, sizeof(fallback), "tab %d", i); snprintf(fallback, sizeof(fallback), "tab %d", i);
title = fallback; title = fallback;
} }
@ -1598,15 +1642,15 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass) {
int row = 0; int row = 0;
for (int i = 0; i < server.num_apps; i++) { for (int i = 0; i < server.num_apps; i++) {
struct tabbed_app *app = server.apps[i]; struct tabbed_app *app = server.apps[i];
if (!app->toplevel) continue; if (!app_overlay_row(app)) continue;
const char *title = app->toplevel->title; const char *title = app_row_title(app);
char fallback[16]; char fallback[16];
if (!title || !title[0]) { if (!title) {
snprintf(fallback, sizeof(fallback), "tab %d", i); snprintf(fallback, sizeof(fallback), "tab %d", i);
title = fallback; title = fallback;
} }
int ry = box_y + boxpad + row * row_h; int ry = box_y + boxpad + row * row_h;
bool active = app->toplevel == server.active; bool active = app == server.active_app;
char row_text[512]; char row_text[512];
int budget = max_row_w - icon_sz - icon_gap; int budget = max_row_w - icon_sz - icon_gap;
if (budget < 0) budget = 0; 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) { static void handle_toplevel_destroy(struct wl_listener *listener, void *data) {
(void)data; (void)data;
struct tabbed_app *app = wl_container_of(listener, app, destroy); struct tabbed_app *app = wl_container_of(listener, app, destroy);
struct wlr_xdg_toplevel *toplevel = app->toplevel;
app->toplevel = NULL; app->toplevel = NULL;
app->tree = NULL; app->tree = NULL;
@ -1909,42 +2010,35 @@ static void handle_toplevel_destroy(struct wl_listener *listener, void *data) {
app->close_timer = NULL; app->close_timer = NULL;
} }
if (server.active != NULL && server.active == toplevel) { if (server.active_app == app) {
int died = -1; server.active_app = NULL;
for (int i = 0; i < server.num_apps; i++) /* Prefer the neighbor to the left (index died-1); if this is the
if (server.apps[i] == app) { died = i; break; } first tab, fall through to the right-side neighbor so the tab
server.active = NULL; that was second becomes the new leftmost. */
if (died >= 0) { struct tabbed_app *succ = pick_successor(app, -1);
/* Prefer the neighbor to the left (index died-1); if this is the if (succ) {
first tab, fall through to the rightmost-side neighbor so the set_active_app(succ);
tab that was second becomes the new leftmost. */ } else {
for (int i = died - 1; i >= 0; i--) /* last tab closed: close the host window (nested only) and exit */
if (server.apps[i]->toplevel) { if (host.toplevel) xdg_toplevel_destroy(host.toplevel);
set_active_surface(server.apps[i]->toplevel); if (host.surface) wl_surface_destroy(host.surface);
return; shutdown_cleanup();
} exit(0);
for (int i = died + 1; i < server.num_apps; i++)
if (server.apps[i]->toplevel) {
set_active_surface(server.apps[i]->toplevel);
return;
}
} }
/* 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. */ /* Grace period after the Alt+Delete SIGTERM before SIGKILL escalation. */
#define TABBED_CLOSE_ESCALATE_MS 150 #define TABBED_CLOSE_ESCALATE_MS 150
/* Escalate an Alt+Delete close to SIGKILL if the client ignored the SIGTERM /* 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 and its toplevel is still up. Live slots are removed at toplevel destroy
never freed at runtime (only in shutdown_cleanup), and handle_toplevel_destroy (which also cancels this timer), so for a mapped tab the callback never
removes the source once the toplevel dies, so a stale callback can never fire fires against a reaped or reused pid. For a black tab there is no
against a reaped or reused pid. */ toplevel whose destroy would reap the slot, so its removal happens here,
right after the SIGKILL. */
static int close_tab_escalate(void *data) { static int close_tab_escalate(void *data) {
struct tabbed_app *app = data; struct tabbed_app *app = data;
if (app->closing && app_killable(app)) if (app->closing && app_killable(app))
@ -1953,43 +2047,47 @@ static int close_tab_escalate(void *data) {
wl_event_source_remove(app->close_timer); wl_event_source_remove(app->close_timer);
app->close_timer = NULL; app->close_timer = NULL;
} }
if (app->closing && !app->toplevel)
remove_app(app_index(app));
return 0; return 0;
} }
/* Alt+Delete close of the active tab. The tab vanishes immediately: the /* 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 active crown 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 closed tab was the rightmost) in this same call. The client's whole
scene node is disabled. The client's whole process group gets SIGTERM, then process group gets SIGTERM, then SIGKILL after a short grace if it ignores
SIGKILL after a short grace if it ignores the TERM (see close_tab_escalate). the TERM (see close_tab_escalate). A closing black tab (still pending)
When the last tab is closed the session tears down via handle_toplevel_destroy drops out of the overlay and cycle right away and is removed by the
once the client's socket drops. */ escalation; a closing last tab tears the session down here, since nothing
else will. */
static void close_active_tab(void) { 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; if (!app || !app_killable(app)) return;
pid_t g = app_signal_group(app); pid_t g = app_signal_group(app);
pid_t shown = (g > 0 && g != getpgid(0)) ? g : app->client_pid;
app->closing = true; app->closing = true;
int died = app_index(app); 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); kill_app(app, SIGTERM);
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: closing tab %d (pid %d)\n", tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: closing tab %d (pid %d)\n",
died, (int)shown); died, (int)shown);
@ -2109,7 +2207,7 @@ static void handle_toplevel_commit(struct wl_listener *listener, void *data) {
is_dma, is_dma,
is_dma ? (dma.n_planes ? "" : " planes=0") : "", is_dma ? (dma.n_planes ? "" : " planes=0") : "",
node_on, node_on,
server.active == toplevel); server.active_app == app);
if (is_dma) { if (is_dma) {
tabbed_log(TABBED_LOG_DEBUG, "[diag] dmabuf planes=%d " tabbed_log(TABBED_LOG_DEBUG, "[diag] dmabuf planes=%d "
"format=0x%x mod=0x%llx\n", "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 /* 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 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 one currently shown, and the startup-selected last command-line tab
command-line command (only while nothing is focused yet). Every other when it finally maps (it has been the active, black tab all along,
map stays hidden -- the overlay and cycling reach it, but it does not revealed the moment its first frame arrives). Every other map stays
yank the active surface. */ 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; bool activate = false;
if (app->origin == ORIGIN_ADD_TAB) if (app->origin == ORIGIN_ADD_TAB)
activate = true; activate = true;
else if (app->origin == ORIGIN_SUB && app->parent_app && else if (app->origin == ORIGIN_SUB && app->parent_app &&
app->parent_app->toplevel == server.active) app->parent_app->toplevel == active_toplevel())
activate = true; activate = true;
else if (app == server.crown_app && !server.active) else if (app == server.active_app && app->tree &&
!app->tree->node.enabled)
activate = true; 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) { if (activate) {
wlr_xdg_toplevel_set_activated(toplevel, true); wlr_xdg_toplevel_set_activated(toplevel, true);
set_active_surface(toplevel); set_active_surface(toplevel);
@ -2266,18 +2373,28 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
&cpid, NULL, NULL); &cpid, NULL, NULL);
struct tabbed_app *app = NULL; struct tabbed_app *app = NULL;
/* Prefer a slot prepared by add_tab() (Alt+Shift+T): its position was /* A slot awaiting its toplevel -- an unclaimed command-line command or
chosen to sit right next to the tab the user launched it from, so an a fresh Alt+Shift+T terminal -- belongs to exactly ONE client: the one
earlier stale free slot (a closed tab's hole) must not steal it. */ WE launched for it. launch_client() runs each command in its own
for (int i = 0; i < server.num_apps && !app; i++) process group (setpgid(0,0)), so the connected client's group id
if (!server.apps[i]->toplevel && server.apps[i]->pending_claim) equals the launcher pid app->pid, including through a shell wrapper
app = server.apps[i]; (`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 /* 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 of that tab: give it its own slot glued right after its parent (the
protocol parent, else the owning tab), never touching a reserved protocol parent, else the owning tab), never touching a reserved
pending or free CLI slot. Command-line commands are forked one at a pending slot. Command-line commands are all forked at startup (see
time (see maybe_launch_next), so their clients never match here and launch_all_cli); their clients match the pending loop above, and only
claim their own slots in command-line order below. */ then. */
if (!app && cpid > 0) { if (!app && cpid > 0) {
struct tabbed_app *owner = sub_window_owner(cpid); struct tabbed_app *owner = sub_window_owner(cpid);
if (owner) { if (owner) {
@ -2294,16 +2411,19 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
app = sub; app = sub;
} }
} }
/* Claim the first free slot: the serial CLI command that just connected /* No pending slot matched (an external client, or one from a group we
lands on its own command-line slot (later commands have not even been never created). Closed tabs are removed outright, so there are no
forked yet, so nothing can claim ahead of it), or an external client free holes to fall back into; this loop only catches a slot that is
started from our own shell takes the earliest free hole. */ transiently without a toplevel and not reserved, so it usually falls
through to the ORIGIN_EXTERNAL append below. */
if (!app) if (!app)
for (int i = 0; i < server.num_apps; i++) for (int i = 0; i < server.num_apps; i++) {
if (!server.apps[i]->toplevel) { struct tabbed_app *a = server.apps[i];
app = server.apps[i]; if (!a->toplevel && !a->pending_claim && !a->closing) {
app = a;
break; break;
} }
}
if (!app) { if (!app) {
/* No slot for this toplevel (an external client with every slot /* No slot for this toplevel (an external client with every slot
taken). Give it its own tab so it appears on top and stays 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 struct tabbed_app *pa = toplevel->parent
? app_for_toplevel(toplevel->parent) : NULL; ? app_for_toplevel(toplevel->parent) : NULL;
if (!pa) if (!pa)
pa = app_for_toplevel(server.active); pa = app_for_toplevel(active_toplevel());
insert_app_after(extra, pa); insert_app_after(extra, pa);
app = extra; 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 /* [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 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 the process-group match against a pending command (CLI or add_tab), by
its own slot, by the sub-window/parent rule, or by the free-slot the sub-window/parent rule, or by the ORIGIN_EXTERNAL append. */
fallback. */ /* The client that actually occupies this slot may be the command we
/* The client that actually occupies this slot may be the CLI command that forked, an add_tab() tab, or an external client. Record its process
forked it, an add_tab() tab, or a fallback-claimed extra. Record its group so a close signals the right one -- but only when that group is
process group so a close signals the right one -- but only when that one WE own. An externally started client (e.g. from the compositor's
group is one WE own. An externally started client (e.g. from the own terminal) shares our process group; adopting it would make
compositor's own terminal) shares our process group; adopting it would close/shutdown group-signal the launcher's whole session. Such
make close/shutdown group-signal the launcher's whole session. Such
clients are killed by pid. */ clients are killed by pid. */
app->client_pid = cpid; app->client_pid = cpid;
pid_t cpg = cpid > 0 ? getpgid(cpid) : -1; 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); wl_signal_add(&toplevel->events.request_move, &app->request_move);
app->request_resize.notify = handle_request_resize; app->request_resize.notify = handle_request_resize;
wl_signal_add(&toplevel->events.request_resize, &app->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 ===================== */ /* ===================== Tab management ===================== */
@ -2494,54 +2609,77 @@ static void handle_new_popup(struct wl_listener *listener, void *data) {
create_popup(popup, parent_tree); create_popup(popup, parent_tree);
} }
static void set_active_surface(struct wlr_xdg_toplevel *toplevel) { /* Make `app` the active slot. A mapped slot shows its surface and takes
if (server.active == toplevel) return; 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); struct tabbed_app *old = server.active_app;
if (old && old->tree) if (old && old != app && old->toplevel) {
wlr_scene_node_set_enabled(&old->tree->node, false); if (old->tree)
if (server.active) wlr_scene_node_set_enabled(&old->tree->node, false);
wlr_xdg_toplevel_set_activated(server.active, false); wlr_xdg_toplevel_set_activated(old->toplevel, false);
}
server.active_app = app;
server.active = toplevel; if (app->toplevel) {
wlr_xdg_toplevel_set_activated(toplevel, true); wlr_xdg_toplevel_set_activated(app->toplevel, true);
struct tabbed_app *app = app_for_toplevel(server.active);
if (app && app->tree) {
position_toplevel(app); 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) if (tabbed_log_level() >= TABBED_LOG_DEBUG)
tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d surface=%dx%d " tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d surface=%dx%d "
"node on\n", app_index(app), "node on\n", app_index(app),
toplevel->base->surface->current.width, app->toplevel->base->surface->current.width,
toplevel->base->surface->current.height); app->toplevel->base->surface->current.height);
} else if (tabbed_log_level() >= TABBED_LOG_DEBUG) { } else if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d has NO scene node!\n", tabbed_log(TABBED_LOG_DEBUG, "[diag] active slot=%d (black, awaiting map)\n",
app ? app_index(app) : -1); app_index(app));
} }
/* In standalone the seat keyboard is the emitting native device (set by /* In standalone the seat keyboard is the emitting native device (set by
the standalone handlers); fall back to the virtual keyboard otherwise 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); struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat);
if (!kbd) kbd = &server.keyboard; if (!kbd) kbd = &server.keyboard;
wlr_seat_keyboard_notify_enter(server.seat, toplevel->base->surface, if (app->toplevel)
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers); 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; 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) { static void cycle_tab(int dir) {
if (server.num_apps == 0) return; if (server.num_apps == 0) return;
int idx = -1; int idx = -1;
for (int i = 0; i < server.num_apps; i++) 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; idx = i;
break; break;
} }
for (int k = 1; k <= server.num_apps; k++) { for (int k = 1; k <= server.num_apps; k++) {
int j = ((idx < 0 ? 0 : idx) + k * dir + server.num_apps) % server.num_apps; int j = ((idx < 0 ? 0 : idx) + k * dir + server.num_apps) % server.num_apps;
if (server.apps[j]->toplevel && !server.apps[j]->closing) { if (app_live(server.apps[j]) && !server.apps[j]->closing) {
set_active_surface(server.apps[j]->toplevel); set_active_app(server.apps[j]);
return; 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 /* 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 (dir<0) within the overlay order, wrapping at the ends. The active
surface is unchanged; only its slot in server.apps[] moves, which the surface is unchanged; only its slot in server.apps[] moves, which the
overlay and cycling both iterate. Only slots that currently show a overlay and cycling both iterate. Only live slots (mapped or pending
toplevel (and aren't mid-close, mirroring cycle_tab) count as positions; black, mirroring cycle_tab) that aren't mid-close count as positions;
with fewer than two of them there is nowhere to move. */ with fewer than two of them there is nowhere to move. */
static void reorder_active_tab(int dir) { static void reorder_active_tab(int dir) {
int n = 0; int n = 0;
for (int i = 0; i < server.num_apps; i++) 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++; n++;
if (n < 2) return; if (n < 2) return;
int from = -1; int from = -1;
for (int i = 0; i < server.num_apps; i++) 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; from = i;
break; break;
} }
@ -2570,25 +2708,25 @@ static void reorder_active_tab(int dir) {
int neighbor = -1; int neighbor = -1;
if (dir > 0) { if (dir > 0) {
for (int i = from + 1; i < server.num_apps; i++) 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; neighbor = i;
break; break;
} }
if (neighbor < 0) if (neighbor < 0)
for (int i = 0; i < server.num_apps; i++) 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; neighbor = i;
break; break;
} }
} else { } else {
for (int i = from - 1; i >= 0; i--) 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; neighbor = i;
break; break;
} }
if (neighbor < 0) if (neighbor < 0)
for (int i = server.num_apps - 1; i >= 0; i--) 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; neighbor = i;
break; 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); bridge_host_set_selection(server.seat->selection_source);
} }
tabbed_log(TABBED_LOG_DEBUG, "[kbd] enter surface=%p serial=%u active=%p\n", tabbed_log(TABBED_LOG_DEBUG, "[kbd] enter surface=%p serial=%u active=%p\n",
(void *)surface, serial, (void *)server.active); (void *)surface, serial, (void *)active_toplevel());
if (server.active) struct wlr_xdg_toplevel *tl = active_toplevel();
wlr_seat_keyboard_notify_enter(server.seat, if (tl)
server.active->base->surface, wlr_seat_keyboard_notify_enter(server.seat, tl->base->surface,
server.keyboard.keycodes, server.keyboard.num_keycodes, server.keyboard.keycodes, server.keyboard.num_keycodes,
&server.keyboard.modifiers); &server.keyboard.modifiers);
} }
@ -4973,39 +5111,82 @@ static void launch_client(struct tabbed_app *app) {
} }
if (pid > 0) { if (pid > 0) {
app->pid = pid; app->pid = pid;
app->launched = true;
tabbed_log(TABBED_LOG_INFO, "[launch] app=%d pid=%d cmd=%s\n", tabbed_log(TABBED_LOG_INFO, "[launch] app=%d pid=%d cmd=%s\n",
app_index(app), pid, app->cmd); app_index(app), pid, app->cmd);
} }
} }
/* Fork the next command-line command, one at a time, gated on the previous /* Fork every command-line command at startup, in command-line order. Each
one's connection. Because only one such client is running at a time, the slot keeps its position because handle_new_toplevel claims it by process
next connector is unambiguously the next command, so the tab order follows group (launch_client runs each command in its own setpgid'd group, whose
the command-line order with no pid matching. A command that never id equals app->pid), so the tab order mirrors the command line no matter
connects stalls the chain (and, for the last command, the startup crown) which client connects first. A command that never connects leaves its
indefinitely, leaving the window black -- accepted behaviour. */ tab black; a command whose process group exits without ever connecting is
static void maybe_launch_next(void) { swept away later (see sweep_dead_startup_groups). */
struct tabbed_app *next = NULL; static void launch_all_cli(void) {
for (int i = 0; i < server.num_apps; i++) { for (int i = 0; i < server.num_apps; i++) {
struct tabbed_app *a = server.apps[i]; struct tabbed_app *a = server.apps[i];
if (a->origin != ORIGIN_CLI || a->launched) continue; if (a->origin != ORIGIN_CLI) continue;
if (!next || a->cli_seq < next->cli_seq) next = a; launch_client(a);
} }
if (!next) return; }
if (next->cli_seq > 0) {
bool prev_connected = false; /* Drop black tabs whose command's whole process group has exited (e.g.
for (int i = 0; i < server.num_apps; i++) { `tabbed-wlroots /bin/false` -- sh runs false, the group empties, and the
struct tabbed_app *a = server.apps[i]; tab would otherwise stay black forever). Runs from the main loop after a
if (a->origin == ORIGIN_CLI && SIGCHLD, never from the handler: it frees slots. Group liveness, not the
a->cli_seq == next->cli_seq - 1 && a->toplevel) { launcher pid's, decides: `sh -c "cmd &"` returns while cmd is still
prev_connected = true; starting, but cmd holds the group open, so kill(-groupid,0) succeeds and
break; 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 /* 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->pending_claim = true;
app->origin = ORIGIN_ADD_TAB; 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); int idx = app_index(app);
launch_client(app); launch_client(app);
return idx; return idx;
@ -5042,6 +5223,7 @@ static void crash_handler(int sig, siginfo_t *info, void *ctx) {
static void sigchld_handler(int sig) { static void sigchld_handler(int sig) {
(void)sig; (void)sig;
sigchld_seen = 1;
int status; int status;
pid_t pid; pid_t pid;
if (tabbed_log_level() >= TABBED_LOG_DEBUG) { 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->cmd = strdup(default_tab ? term_cmd : appv[i]);
app->pid = -1; app->pid = -1;
app->origin = ORIGIN_CLI; 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.apps[i] = app;
} }
server.num_apps = total_apps; 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++) { for (int i = 0; i < server.num_apps; i++) {
if (server.apps[i]->cmd[0] == '-') { 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). */ /* The last command-line command is active from the very first frame:
maybe_launch_next(); 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"; const char *prefix = tab_prefix == PREFIX_SUPER ? "Super" : "Alt";
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. " 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); ret = wl_event_loop_dispatch(server.loop, -1);
} while (ret == -1 && errno == EINTR); } while (ret == -1 && errno == EINTR);
wl_display_flush_clients(server.wl_display); wl_display_flush_clients(server.wl_display);
sweep_dead_startup();
if (frame_pending || needs_redraw) { if (frame_pending || needs_redraw) {
frame_pending = false; frame_pending = false;
needs_redraw = false; needs_redraw = false;
@ -5634,6 +5821,7 @@ int main(int argc, char *argv[]) {
wl_event_loop_dispatch(server.loop, 0); wl_event_loop_dispatch(server.loop, 0);
} }
wl_display_flush_clients(server.wl_display); wl_display_flush_clients(server.wl_display);
sweep_dead_startup();
wl_display_dispatch_pending(host.display); wl_display_dispatch_pending(host.display);
/* The headless output fires a frame signal at ~60Hz; render then /* The headless output fires a frame signal at ~60Hz; render then

View file

@ -249,13 +249,15 @@ class Session:
pytest.fail(f"active app never changed away from {away_from}") pytest.fail(f"active app never changed away from {away_from}")
def wait_any_active(self, timeout=10.0): 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 last command-line command is active from the very first frame --
(the "crown"); every earlier tab maps hidden with no `active app=` black, titled by its command line -- and is the only tab that ever
line, so the first active line is the settled tab -- there is no auto-activates on boot; every earlier tab maps hidden with no
churn to wait out. For the fixtures' N-tab sessions the crown is ``active app=`` line. The first active line is therefore the settled
always index N-1. 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+)") rx = re.compile(r"active app=(\d+)")
since = time.monotonic() since = time.monotonic()
@ -264,7 +266,23 @@ class Session:
if m: if m:
return int(m[-1]) return int(m[-1])
time.sleep(0.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): def wait_app_active(self, index, timeout=10.0):
"""Wait until the single app `index` reports itself active (for """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.""" whose centre darkens while Alt is held."""
s = standalone_session s = standalone_session
s.wait_any_active() s.wait_any_active()
s.wait_active_frame()
# Before Alt: the frame should be the client content (no dark overlay). # Before Alt: the frame should be the client content (no dark overlay).
w, h, px = s.ppm_rgb(s.dump_host()) 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.""" through the same flip."""
s = standalone_session s = standalone_session
s.wait_any_active() s.wait_any_active()
s.wait_active_frame()
assert s.proc.poll() is None assert s.proc.poll() is None
def patch_at(path, cx, cy, r=22): def patch_at(path, cx, cy, r=22):
@ -636,6 +656,7 @@ def test_standalone_headless_cursor_relative_motion(standalone_session):
the cursor.""" the cursor."""
s = standalone_session s = standalone_session
s.wait_any_active() s.wait_any_active()
s.wait_active_frame()
assert s.proc.poll() is None assert s.proc.poll() is None
def patch_at(path, cx, cy, r=22): def patch_at(path, cx, cy, r=22):
@ -704,6 +725,7 @@ def test_standalone_headless_screen_mode_cursor_stays_aligned():
"build/wltest '#ff0000'") "build/wltest '#ff0000'")
try: try:
s.wait_app_active(0) s.wait_app_active(0)
s.wait_active_frame()
assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}"
def patch_at(path, cx, cy, r=22): 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 box pixel appears in the centered window while Alt is held and reverts
after release.""" after release."""
session.wait_any_active() session.wait_any_active()
session.wait_active_frame()
def has_dark(w, h, px): def has_dark(w, h, px):
for y in range(h // 4, 3 * h // 4): for y in range(h // 4, 3 * h // 4):
@ -1737,3 +1760,48 @@ def test_missing_arg_fails():
assert r.returncode != 0 assert r.returncode != 0
assert b"definitely-no-such-cmd-xyz" in (r.stdout + r.stderr) assert b"definitely-no-such-cmd-xyz" in (r.stdout + r.stderr)
assert b"not found in PATH" 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()