Hacking up a window manager is finally fun again

I’ve long been a fan of Wayland. For anyone who doesn’t know, Wayland is the major “sequel” system to the X Window System, which has been a core part of the experience of using a Unix operating system for decades. Wayland is designed more directly for modern computing, supporting things like (without extensions, and without difficulty on the user’s part) complex multi-monitor setups with mixed display resolutions, VSync, and more.

Being this kind of sequel makes Wayland somewhat like systemd, which, similarly, replaces a venerable Unix system with something which, for users, is faster and better suited to modern machines.

Unlike systemd, though, Wayland isn’t a meaningful compromise in simplicity or comprehensibility. Looking at basically any Unix-oriented mailing list, it isn’t hard to find someone complaining about X being big and slow in just about any year in the last few decades.

As I see it, the major downside of Wayland comes due to its monolithic architecture: unlike in the X world, where the X server handles most of the complex pixel-pushing and a window manager merely needs to interact with it, a Wayland compositor needs to implement everything that the window manager would have done, in addition to everything the X server would have done as well. This has inhibited the number of Wayland window managers that have been written, because writing an entire compositor just to explore a unique take on window management is simply too big of a lift.

A couple different attempts have been made to bridge that gap. Most notable is the wlroots library, which was split from the Sway compositor and is, as it describes itself, “about 60,000 lines of code you were going to write anyway.” The folks at the “minimalist Wayland interest group” wayland.fyi offer an alternative, more minimal set of libraries and compositors, choosing to eschew many of the features that other compositors have to struggle to maintain. (Incidentally, having tried a few of these, they seem not to work very well on my machine. Sometimes minimalism imposes costs of its own.)

Just a couple months ago, another kind of attempt has been made, by the River compositor.

The River Wayland compositor

River is a Wayland compositor which is distinguished by being “non-monolithic”. Written by Isaac Freund, River performs most of the work of a Wayland compositor (being built itself on wlroots), but exposes its own set of private Wayland protocols with which another program can interact in order to perform window management. This enables, to some degree, the best of both worlds: A Wayland compositor, with all the modern niceties that Wayland provides, but with a similar sort of modularity that X made possible.

This is actually a relatively late-breaking change made to River. Before version 0.4, River performed its own window management, with configuration and control happening via IPC from the external riverctl program shipped with the compositor. I myself used this prior form of River for years, initially having picked it out because it shared some characteristics with my long-time preferred X window manager, bspwm. I didn’t look at this new version of River until the river package on my machine was renamed to river-classic, and I got curious enough to start looking at the “new hotness”.

At the time of writing, there are several window managers for River that have already been written, and I was hoping that I could adopt this newly non-monolithic paradigm with one of them. None of them were quite satisfying, though.

For one thing, none of these WMs have been around long enough or picked up enough usage to have been packaged in the official Arch repos, and so to use any of them, I’d have to build it myself—and most of the window managers that have been written are in languages I don’t already use. For the most part, outside of work, I spend my time hacking in C, and my degree of patience for installing an entire new language toolchain for each window manager I want to explore is not very high at all.

So, given that, I was hoping to find a simple tiling window manager written in C that I could quickly build and explore. At the time of writing, here were two tiling WMs implemented in C listed on the River wiki: zrwm and anvl. Neither of these worked great for me, though:

So, I decided to take the opportunity to just try writing my own window manager, and hopefully solve some of my historic window-manager pet peeves along the way. What came out of this effort is JrWM.

JrWM

JrWM (short for “Junior Window Manager”, or “Jpco’s river Window Manager”) is in spirit similar to the other C tiling window managers for River, but written to satisfy my own practical and aesthetic goals.

As described in its README, JrWM is designed to be small, low-dependency, easy to build, read and modify, and to have a good degree of correctness. It is built with a fairly simple Makefile portable to (at least) both GNU and BSD make, and comprises a single header file jrwm.h with three C files: jrwm.c, bindings.c, and layout.c.

This is an immediate departure from the other C window managers, including the tinyrwm window manager provided as a “demo” as part of the River project, which all pack all of their window management code into a single file. I find that the organization of JrWM allows each file to be better focused, and therefore more legible: jrwm.c handles listening for Wayland events and dispatching to other files, bindings.c handles managing and dispatching key bindings, and layout.c handles the placement and focus of the entities in the window manager.

JrWM is not especially novel with its behavior nor rich with its feature set. Each window is organized into a “space”, and an output can switch between spaces to view different sets of windows. Each space has its own layout, though there are just two layouts at the moment: a dwm-style tiling layout, and a monocle layout. Spaces have a secondary role in the WM as a sort of “clearing house” type: each of the major components of the WM (the windows, outputs, and seats) all point to a space, and it is only via spaces that these components can refer to each other. This simplifies the management of all these objects as they are dynamically added to and removed from a running session.

The more carefully thought-out code layout and the simplification provided by using spaces together make it easier to achieve JrWM’s last listed design goal, which is thorough correctness.

The first form of correctness JrWM does pretty well is its behavior with the River window management protocol. Within this protocol, code can be running during one of three phases: the manage sequence, the render sequence, and everything in between. The relevant River blog post explains what these two sequences are in detail, but the upshot is that certain behaviors can only be performed during one of the two sequences; attempting these behaviors at other times is a protocol error. River seems to be forgiving with out-of-sequence calls, but trying to get this right (and making it easy to do so, by making it clear during which sequence, if either, functions run) is still a goal of JrWM, and I believe this is achieved pretty well.

In fact, JrWM is over-conservative: according to the documentation for the river-window-management-v1 interface,

[r]endering state may be modified by the window manager during a manage sequence or a render sequence.

But at the same time, the documentation for the protocol also states, for each request which modifies rendering state,

This request [...] may only be made as part of a render sequence, see the river_window_manager_v1 description.

I believe this more restrictive statement is incorrect, but in the face of doubt, JrWM is more conservative and only changes rendering state during render sequences.

The other form of correctness JrWM targets is that of the environment of its spawned subprocesses. Like a shell, a window manager typically spawns child processes in order to start up graphical applications, and these child processes need to be reaped after they exit. A window manager could manually wait for and reap child processes like a shell does, with wait(3) or waitpid(3), but given window managers tend not to be nearly as fastidious with exit statuses as a shell is, the easiest thing to do is to simply call signal(SIGCHLD, SIG_IGN) (or the equivalent with sigaction(3)); if a process ignores SIGCHLD, then child processes will be automatically reaped after they exit.

However, ignoring SIGCHLD creates its own obligation to then un-ignore SIGCHLD when forking off a child process, so that that process has a normal operating environment to run in. Many programs, like shells, will automatically un-ignore SIGCHLD themselves on startup, but it is (at the very least) bad hygiene to require every child process to do this.

In addition, a window manager must setsid(3) each child process it creates to ensure that each new window is part of a distinct session. This is simply part of the process hierarchy protocol of POSIX-compatible Unix operating systems.

Putting these together, the most minimal but still correct way to spawn a child process (assuming the window manager configured itself with signal(SIGCHLD, SIG_IGN) at startup) is:

if (fork() == 0) {
	signal(SIGCHLD, SIG_DFL);
	setsid();
	exec(...);
}

And, looking at its source as of the time of writing, dwm does exactly this (though using the preferable sigaction(3) call, rather than signal(3)):

if (fork() == 0) {
	if (dpy)
		close(ConnectionNumber(dpy));
	setsid();

	sigemptyset(&sa.sa_mask);
	sa.sa_flags = 0;
	sa.sa_handler = SIG_DFL;
	sigaction(SIGCHLD, &sa, NULL);

	execvp(((char **)arg->v)[0], (char **)arg->v);
	die("dwm: execvp '%s' failed:", ((char **)arg->v)[0]);
}

Caveats

Ironically, JrWM’s greatest weakness is probably its actual UX. Because my desktop computing setup could be reasonably described as barbaric (a single monitor on a laptop with no lock screen; window decorations only to show focus; no wallpaper, window margins, or animations; a computing environment that’s 99% terminal and browser; and a plain status bar that only displays battery state, a clock, and notifications), I don’t have a good intuition for things like how multi-output setups are expected to work, so while there is technically a workable behavior implemented, it may not be something anybody considers familiar or correct. I would love if someone could tidy these behaviors for me. I’ll probably get to it myself at some point, assuming nobody else does.

I am, at the moment, dissatisfied with JrWM’s multi-seat handling. This is in part due to the “space” simplification: the WM can’t actually model multiple windows having focus within a single space. Assuming this is fixed, though, there is still the more severe problem that the window manager has no way to know with which seat a newly-created window should be associated. I believe this requires River integrating with the xdg-activation-v1 protocol to get right, but I don’t know what workarounds are available. Given this isn’t just a missing feature, but an incorrect behavior, it’s a real pet peeve. (One of my goals was to avoid global state as much as possible, in part to be flexible around multiple Outputs and Seats.)

Lastly, I would like to get floating windows into the shell at some point fairly soon, at the very least for dialog-type windows.

One last thing to note is that I’m really not actually very knowledgeable about the nitty gritty of Wayland. While I’m a reasonably experienced C hacker (and a professional code critic), the whole ecosystem that exists around Wayland and its many protocol extensions remains fairly obscure to me, which means that I&rsuqo;m probably wrong about details around both correctness and features and I don’t even know it!

Conclusion

I’m really pretty excited about River as a project and the small (but quickly growing) community of window managers that are springing up around it. Writing up JrWM has been fairly quick and enjoyable, with little unnecessary cruft getting in the way of the meaty stuff. While it’s not the most thrilling or novel piece of software, I hope JrWM is at least slightly as useful for others as it is for me.