Moving to NixOS

I started using Linux as my main desktop OS in about the year 2000. I bought the Debian 2.1 “slink” CD-ROM set. At the time I also dabbled with other OSes too: Windows, BeOS, and also Minix. Over the last 20 years, through University and my career so far as a software engineer, Debian has been incredibly reliable, and has been my go-to choice whenever setting up a new machine. About five years ago I came across NixOS. I spent a fair amount of time learning NixOS for a project at work and getting an initial understanding of its concepts and design. But then I changed job and put it away for a while.

As a software engineer, I increasingly despair at the level of accidental complexity that we, as a community and industry, have built for ourselves. It’s utterly absurd, not just how anything works, but the way we write software these days. In a no doubt futile quest to get a handle on and manage this complexity, I’ve decided to switch my main laptops and desktops to NixOS. I have no real intention for this piece to be evangelism. There are bits of the design of NixOS that I really like, and I will continue to use it. For me, being able to declare and construct environments which contain only the things I want, and nothing I don’t want, is valuable.

Here I’m just going to note a few bits and pieces that I’ve solved for myself that might come in handy for anyone walking the same path.

Emacs, GoPls and Systemd

I write quite a lot of Go; I still use emacs; I like using gopls; I dislike the amount of memory gopls uses (and sometimes CPU too) and I want to constrain this. So here’s the plan:

  1. Run gopls from a user systemd unit, and limit its memory. This boils down to running gopls -listen=... to create the server.
  2. Configure emacs to connect to the systemd-managed gopls, via unix domain socket. Which boils down to running gopls -remote=... to create the client and connect to the server.

I’m using home-manager, so I can create my own systemd units by adding to ~/.config/nixpkgs/home.nix:

systemd.user.services = {
  gopls = {
    Unit = {
      Description = "Run gopls as a daemon";
    };
    Install = {
      WantedBy = [ "default.target" ];
    };
    Service = {
      ExecStart = "${homeManager.home.homeDirectory}/go/bin/gopls -listen=unix;%t/gopls";
      ExecStopPost = "/run/current-system/sw/bin/rm -f %t/gopls";
      Restart = "always";
      RestartSec = 3;
      MemoryHigh = "1.5G";
      MemoryMax = "2G";
    };
  };
};
systemd.user.startServices = "sd-switch";

I’ve chosen to install gopls manually with the normal go install golang.org/x/tools/gopls@latest which means the binary has ended up at ~/go/bin/gopls. I could choose to use gopls as provided by Nix, but then I’d have to do more work if I wanted to upgrade it ahead of the nixpkgs repository. It’s a tradeoff: I’ve decided that I’m not worried about having it pinned to any particular version, and I just want the latest and I’ll be in charge of upgrading it every few days if I want to. But you might decide you don’t want to live on the bleeding edge so much, you want the Nix-provided version and so all you’d need to do is add pkgs.gopls to home.packages and then set ExecStart in the above to "${pkgs.gopls}/bin/gopls ..." or something like that.

So here we’re declaring a systemd unit, and putting some resource limits on it. After running home-manager switch we should be able to see gopls has been launched:

# systemctl status --user gopls
● gopls.service - Run gopls as a daemon
Loaded: loaded (/nix/store/cg631mc569q6132avg4kn54751ivgl5v-home-manager-files/.config/systemd/user/gopls.service; enabled; vendor preset: enabled)
Active: active (running) since Sat 2021-10-30 12:37:34 BST; 1h 36min ago
Main PID: 1557 (gopls)
Tasks: 18 (limit: 37762)
Memory: 25.5M (high: 1.5G max: 2.0G)
CPU: 1.896s
CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/gopls.service
└─1557 /home/matthew/go/bin/gopls -listen=unix;/run/user/1000/gopls

Oct 30 12:37:34 rocket gopls[1557]: serve.go:114: Gopls daemon: listening on unix network, address /run/user/1000/gopls...
Oct 30 12:37:34 rocket systemd[1547]: Started Run gopls as a daemon.

In the systemd unit declaration, note the %t in the ExecStart and ExecStopPost lines. That gets expanded to the XDG_RUNTIME_DIR which is the right place to put a unix domain socket. So we’re creating (and removing) a socket at $XDG_RUNTIME_DIR/gopls. We now need to tell emacs how to connect to that.

In emacs, I’m using lsp-mode, go-mode and a bunch of other hooks and features (check out treemacs for some nice extensions which get almost-IDE-like features). We need to configure the lsp-go-gopls-server-args variable.

Snippet from ~/.emacs:

(use-package lsp-mode
  :ensure t
  :custom (lsp-go-gopls-server-args (list (format "-remote=unix;%s/gopls" (getenv "XDG_RUNTIME_DIR"))))
  :commands (lsp lsp-deferred)
  :config (progn
            ;; use flycheck, not flymake
            (setq lsp-prefer-flymake nil)))

(I don’t use home-manager to manage my ~/.emacs, but I do have that file checked in to a repo for safe-keeping and easy reproducibility.)

The variable lsp-go-gopls-server-args wants a list of strings, which is why I’m using the list function to put the string result of format into a list. I’ve gone to a little effort to grab that XDG_RUNTIME_DIR value from the environment and avoid hard-coding any paths there.

And that’s it, for now. It seems to work OK.