A nvim instance that survives terminal close, can't crash your system, and any terminal can attach to. Built on systemd + abduco.
Two problems this solves.
Editor state is tied to the terminal. Close the alacritty/foot/kitty window and you lose your buffers, splits, undo history, scroll position. Every reopen pays the LSP-startup cost again.
Heavy LSPs can OOM the whole system. A Kotlin Multiplatform project with kotlin-language-server can spawn 10+ gradle daemons during classpath resolution, each grabbing 1–4 GiB. Combined with browsers, electron apps, and assorted Wayland gunk, you cross 23 GiB physical and the kernel OOM-killer picks something — often the wrong thing. We hit this twice in a row, both times the kernel killed Claude Code instead of the actual gradle daemons (because gradle had a lower oom_score_adj).
The fix has two parts:
- Decouple nvim from the terminal. abduco hosts nvim; any terminal can
abduco attachto see it. - Cap nvim's memory at the cgroup level. systemd enforces a hard 2 GiB limit. If nvim crosses it, systemd stops the whole service cleanly with a logged failure — no silent kernel SIGKILL, no half-dead state, no system-wide thrashing.
┌──────────────────────────────────────────────────────┐
│ systemd user service (lifecycle + memory cap) │
│ ┌────────────────────────────────────────────────┐ │
│ │ abduco session "editor" │ │
│ │ (persistent PTY for the TUI) │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ nvim --listen $XDG_RUNTIME_DIR/nvim.sock │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
▲ ▲
│ abduco attach │ neovim-remote (nvr)
│ │
nvim-attach nvim-show <path>
(user, in any terminal) (Claude / scripts)
Three commands cover everything:
| Command | Purpose |
|---|---|
nvim-attach |
User reattaches a terminal to the running nvim. Detach with Ctrl-\ (abduco's default) — nvim survives. |
nvim-show <abs-path> |
Open file(s) in the running nvim. For Claude / scripts. Fails loudly if service isn't up; never auto-spawns a ghost. |
systemctl --user {start,stop,restart,status} nvim.service |
Lifecycle. Logs via journalctl --user -u nvim.service. |
Dependencies: nvim, abduco, systemd (user units enabled), neovim-remote (for nvr).
# Get nvr if missing
uv tool install neovim-remote # or: pipx install neovim-remote
# Clone and install
git clone https://github.com/<you>/persistent-nvim ~/Codes/persistent-nvim
cd ~/Codes/persistent-nvim
./install.shinstall.sh symlinks nvim.service into ~/.config/systemd/user/, the three scripts into ~/.local/bin/, then enables and starts the service. Symlinks (not copies), so git pull propagates changes immediately.
After install: open any terminal, run nvim-attach, you're in.
The unit file enforces four overlapping guarantees. All four matter; removing any one weakens the rest.
MemoryAccounting=yes
MemoryMax=2G # cgroup ceiling, kernel-enforced
MemorySwapMax=0 # no swap allowed
OOMPolicy=stop # cgroup-OOM → systemd stops service cleanly
OOMScoreAdjust=-500 # system-wide OOM picks something else first| Setting | What it prevents |
|---|---|
MemoryMax=2G |
nvim ballooning to 5–10 GiB during a runaway plugin / lua script. |
MemorySwapMax=0 |
Swap thrashing. Without this, nvim can spill to swap and drag load average to 20+ before the cap fires — the system feels frozen for minutes. |
OOMPolicy=stop |
Kernel SIGKILL leaving partial state. With stop, systemd treats cgroup-OOM as a unit failure: clean stop, status=failed, full journal entry, ExecStopPost runs. |
OOMScoreAdjust=-500 |
nvim getting picked when SOMETHING ELSE (chrome explosion, etc.) causes system-wide OOM. We're not contributing to the OOM and shouldn't be sacrificed for it. |
Restart=on-failure brings the service back automatically two seconds after any failure (including memory-cap stop). nvim-show works again immediately.
When the service stops for any reason, ExecStopPost= runs nvim-postmortem, which dumps:
SERVICE_RESULT(success / oom-kill / timeout / signal)systemctl statussnapshot- Last 200 lines of journal
- cgroup
memory.peak,memory.events,memory.swap.peak(if cgroup still readable) - System-wide
free -h
Reports land in ~/.local/state/nvim-postmortems/ with a last.txt symlink to the most recent.
tmux instead of abduco. tried first, doesn't work: on systemd-aware distros, tmux uses dbus to ask systemd-logind to put each new session in its own scope. The spawned nvim ends up in a sibling cgroup (tmux-spawn-<uuid>.scope), not in nvim.service. MemoryMax= set on the service has zero effect on processes that escaped it. abduco doesn't do scope migration — children inherit the parent cgroup, period. (If you must use tmux, you need ExecStartPost= hooks to manually echo $pid > cgroup.procs for every spawned nvim child, which is fragile.)
Headless nvim + nvim --remote-ui clients. Cleaner separation in theory: backend nvim runs headless, frontend nvim attaches over the socket as a UI client. In practice, LazyVim's plugin set has too many startup-time UI assumptions (snacks.dashboard, render-markdown, theme priority loading). Some break silently in headless mode. abduco gives nvim a real (pseudo-)TTY so all plugins behave normally.
Niri-only persistence. Niri keeps the alacritty window alive across compositor sessions, so why not just let alacritty hold the nvim? Because (a) closing alacritty kills nvim regardless, (b) Niri can't auto-restart alacritty if alacritty itself crashes, (c) no memory cap.
A daemon written from scratch. Pointless. systemd already provides everything needed: lifecycle, restart, cgroup limits, logging, state queries. abduco already provides PTY persistence. Both are battle-tested in places this code never will be.
MIT.