Skip to content

WSL2: screen/tmux Sessions Die When the Last Terminal Closes (Post-July-2026 Lifecycle Change)

Problem

Detached screen (or tmux) sessions inside WSL2 die whenever the last terminal window closes. Reopening a terminal shows a freshly booted distro and screen -ls finds nothing. The behavior appeared suddenly on a machine where background sessions had survived terminal closes for months.

Diagnosis

The key insight: don't assume screen is the problem — check whether the whole distro is being torn down. The distro's systemd journal keeps a boot ledger:

journalctl --list-boots --no-pager | tail -15

On MajorRig this showed a hard behavioral flip:

  • Before 2026-07-15: distro uptimes of 2–4.5 days, each ending exactly at a Windows reboot. The VM never idle-terminated; background sessions rode on that.
  • After 2026-07-15: distro lifetimes of 17 seconds to 4 minutes — seven boots in two days. The VM now tears down seconds-to-minutes after the last WSL session ends.

The flip coincided with the 2026-07-15 02:31 patch-Tuesday reboot (KB5101650, KB5120102, KB5100998). Everything else was ruled out with evidence:

Suspect Verdict Evidence
WSL package update No Appx 2.7.10 unchanged since Jun 30, already live during a working session Jul 14 (Windows rebooted Jul 12)
wsl.conf / systemd enablement No Unchanged since March
logind killing user processes No KillUserProcesses=no (default)
In-distro packages (screen, systemd, glibc) No All installed and live before the working Jul 14 session
Scheduled-task guards No Sessions survived before the guards existed, while they ran, and after they were disabled

Useful commands for the elimination sweep:

# Windows boot history (which reboot activated what)
powershell.exe -Command "Get-WinEvent -FilterHashtable @{LogName='System'; Id=6005} -MaxEvents 6 | Select TimeCreated"

# Recent Windows patches
powershell.exe -Command "Get-HotFix | Sort InstalledOn -Descending | Select -First 5"

# logind kill setting
busctl get-property org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager KillUserProcesses

# When did in-distro packages actually change
rpm -q screen systemd --qf '%{NAME} %{VERSION}-%{RELEASE} installed %{INSTALLTIME:date}\n'
sudo dnf history list | head

Root Cause

One of the July 2026 Windows cumulative updates changed host-side WSL VM lifecycle enforcement: background processes inside the distro no longer count as a reason to keep it alive. This matches long-standing reports for systemd-enabled distros (microsoft/WSL#9245); it now applies here too. It is not fixable from inside the distro — no Fedora package, config, or systemd setting can stop the host from deallocating the VM.

WSL has enforced idle teardown since ~2.5.7 (mid-2025) via two separate timers: an instance timer that terminates the distro after its last client session ends, and a VM timer that deallocates the utility VM after the last distro stops. Screens die with the instance — which is why the well-known vmIdleTimeout setting alone does not help.

Fix: disable both idle timeouts in .wslconfig

The proper fix is two settings in %UserProfile%\.wslconfig, confirmed by a WSL maintainer in microsoft/WSL#13291:

[general]
instanceIdleTimeout=-1

[wsl2]
vmIdleTimeout=-1

Gotchas that cost people weeks in that issue thread:

  • instanceIdleTimeout is undocumented — it appears nowhere in the official .wslconfig docs as of 2026-07. It keeps the distro instance (where your screens live) alive.
  • It goes under [general], not [wsl2]. Put it in the wrong section and it silently does nothing.
  • vmIdleTimeout=-1 alone does not work — the instance still dies, taking screen/tmux with it. Both lines are required.

Apply with wsl --shutdown and relaunch. Verified on MajorRig 2026-07-16: after the config, detached screens survived closing every terminal and SSH session, with no keep-alive process running.

Limits: like any in-WSL solution, this does not survive Windows reboots or sleep — nothing auto-starts the distro after a reboot. Anything that must outlive those belongs on an always-on host.

Fallback: hidden keep-alive scheduled task

If a future WSL update breaks the undocumented setting, the version-proof workaround is to hold a wsl.exe client open permanently (WSL never idles an instance with an attached client). This was MajorRig's original same-day fix and is kept registered-but-disabled as a fallback. Windowless, so it never flashes a console:

$action = New-ScheduledTaskAction -Execute 'conhost.exe' `
  -Argument '--headless wsl.exe -d FedoraLinux-43 --exec sleep infinity'
$trigger = New-ScheduledTaskTrigger -AtLogOn -User 'MajorRig\majorlinux'
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) `
  -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
  -RestartCount 10 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName 'MajorRig-WSL-KeepAlive' -Action $action -Trigger $trigger -Settings $settings
Start-ScheduledTask -TaskName 'MajorRig-WSL-KeepAlive'

Notes:

  • conhost.exe --headless is the no-window trick. A bare console app in an interactive scheduled task flashes (or keeps) a console window — that popup is what got MajorRig's 15-minute guard tasks disabled on 2026-07-09.
  • The task must run as the logged-on user with LogonType Interactive: wsl.exe refuses to run as SYSTEM (WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED).
  • No ExecutionTimeLimit, auto-restart on failure, IgnoreNew for duplicate instances.
  • Limits: does not survive Windows reboots or sleep. Anything that must outlive those belongs on an always-on host, not in WSL.

Verify: close every terminal, wait two minutes, reopen, screen -ls — the detached session should still be there.

Bonus gotcha: portproxy rules that look right but bind nothing

While re-verifying MajorRig's port-2222 break-glass channel (netsh portproxy → WSL-native sshd), the rule text was correct but nothing was listening on the port:

netsh interface portproxy show v4tov4   # rule present and correct
netstat -an | findstr :2222             # ...but no LISTENING line

Cause: if the IP Helper service (iphlpsvc) evaluates the rule before the listen address exists — e.g. a Tailscale IP shortly after boot — the bind fails silently and is not retried on a schedule. A guard script that only compares rule text will report OK forever while the channel is dead.

Fix: make the guard also check for a live listener and force a rebind by deleting and re-adding the rule:

$listening = (netstat -an | Select-String "TCP\s+$([regex]::Escape($listenAddr)):$listenPort\s.*LISTENING") -ne $null
if (-not $currentlyPointsHere -or -not $listening) {
    netsh interface portproxy delete v4tov4 listenaddress=$listenAddr listenport=$listenPort | Out-Null
    netsh interface portproxy add v4tov4 listenaddress=$listenAddr listenport=$listenPort connectaddress=$wslIp connectport=$connectPort | Out-Null
}

End-to-end test from another host — a TCP connect is not enough, demand the banner:

ssh-keyscan -p 2222 -T 5 <wsl-host-tailscale-ip>   # want: SSH-2.0-OpenSSH_...

Also worth doing while in there: netsh interface portproxy show v4tov4 and delete any rules pointing at WSL IPs from previous boots — they're dead weight that fails silently.

Bonus gotcha 2: stale $WSL_INTEROP sockets in surviving sessions

Once sessions do survive, a follow-on failure appears: Windows interop calls (cmd.exe, powershell.exe, wsl.exe) from inside the surviving shell fail with:

<3>WSL (####) ERROR: UtilAcceptVsock:273: accept4 failed 110

Cause: $WSL_INTEROP points at a per-session socket (/run/WSL/<pid>_interop) that died with the login session that spawned the shell. Any process living in screen/tmux past its original terminal inherits the dead pointer. Fix from inside the surviving shell:

export WSL_INTEROP=/run/WSL/1_interop

The init-owned sockets (1_interop, 2_interop) persist for the life of the instance. To find live ones generically:

for s in /run/WSL/*_interop; do
  WSL_INTEROP=$s timeout 5 cmd.exe /c "echo ok" >/dev/null 2>&1 && echo "LIVE: $s"
done

Lessons

  1. When background sessions die, check the distro boot ledger first. journalctl --list-boots distinguishes "my process was killed" from "the whole VM was torn down" in one command.
  2. Correlate with Windows patch/reboot history before blaming in-distro changes. Package installs only take effect at the next distro restart, which on WSL usually means the next Windows reboot — the activation date is the reboot, not the install.
  3. A netsh portproxy rule is not a listener. Verify with netstat, and test remotely by demanding the SSH banner, not just a TCP connect.
  4. When the documented knob doesn't work, search the project's issue tracker before building a workaround. The real fix (instanceIdleTimeout under [general]) exists only in a maintainer comment on microsoft/WSL#13291 — the docs still don't mention it.
  5. Interop failures in screen/tmux survivors are usually a stale $WSL_INTEROP, not a broken WSL — re-point it at /run/WSL/1_interop.