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
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

View file

@ -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

View file

@ -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()