Docker Desktop -Shutdown exits 0, prints "backend already running", and shuts nothing down

4 min read DockerWindowsWSLAutomation

The documented quit command returns success, leaves Docker running, and on one invocation spawned an extra 237 MB process. The flag is not parsed by recent builds, so it falls through to the default show-the-dashboard path.

TL;DR · THE FIX

"Docker Desktop.exe" -Shutdown is unparsed on recent builds, so it falls through to the default path and brings up the dashboard instead of quitting. Exit code 0 either way. Stop the containers, Stop-Process the four Docker processes, then wsl --shutdown to collapse vmmemWSL, which is the actual memory hog. Assert on the world, never on the return code.

The symptom

I wanted a script that frees memory by shutting Docker down when I am not using it. The documented way on Windows is the -Shutdown flag:

& "C:\Program Files\Docker\Docker\Docker Desktop.exe" -Shutdown
echo $LASTEXITCODE
# 0

Exit 0 and nothing on stderr. Docker still running, whale still in the tray, containers still up, memory still gone. Running it again produced the same clean exit and this line, which is the tell:

backend already running, signaling show-dashboard

On one invocation it also spawned an additional 237 MB process. A command whose entire job is to reduce resource usage had increased it, and reported success.

What I tried first

I assumed a permissions problem, because that is the usual reason a Windows quit command silently declines. An elevated shell gave the same result and the same message. I assumed a timing problem and waited, on the theory that shutdown is asynchronous and I was measuring too early; five minutes later everything was still running.

Then I read the message properly. signaling show-dashboard means it had decided to open the UI.

What was happening

That build does not parse the flag, and it neither rejects nor warns about it. Unparsed arguments fall through to the executable’s default behaviour, and the default behaviour of Docker Desktop.exe is to bring up the dashboard, starting the backend if it is not already running. So I asked it to shut down, it did not recognise the request, it defaulted to showing me the dashboard, it noticed the backend was already running so it only had to signal the window, and it exited 0 because from its point of view it had done the thing it decided to do.

That is also why the extra 237 MB appeared on one run. The default path is willing to start things, and asking a process to quit in a way it does not understand can read to it as an instruction to launch.

Every observable signal said success: exit code 0, no stderr, and a log line that reads as informational unless you notice it describes a different verb than the one you asked for. This is the same shape as a “success” line from a CLI that did not persist the change: the return code tells you the request was accepted and nothing about the state.

The fix

Stop asking politely and assert on the world afterwards. In order:

# 1. containers first, so nothing is mid-write
docker stop $(docker ps -q) 2>$null

# 2. the desktop app and its backend processes
foreach ($n in 'Docker Desktop','com.docker.backend','com.docker.build','docker-agent') {
  Get-Process -Name $n -ErrorAction SilentlyContinue | Stop-Process -Force
}

# 3. collapse the WSL VM, which is where the memory actually is
wsl --shutdown

Step 3 matters most and is the one people leave out. Killing the Docker processes barely moves the needle, because the memory is in vmmemWSL, the WSL2 virtual machine, sitting at 2.9 GB at idle on this machine, and wsl --shutdown is what releases it.

Then verify:

wsl -l -v
# both distros: Stopped
Get-Process *docker*, *wsl*, vmmem* -ErrorAction SilentlyContinue |
  Select-Object Name, @{n='MB';e={[int]($_.WorkingSet64/1MB)}}
# only wslservice, ~31 MB

That is what a real shutdown looks like from outside: both distros Stopped, one 31 MB service process left, and free memory back up to 12.4 GB of 31.1 GB here. None of those numbers are visible to a script that checks $LASTEXITCODE.

The lesson

An exit code of 0 from a CLI that ignored your flag is a false success, and a nasty one, because unparsed arguments produce defaults rather than errors. The tool did something else, correctly.

Assert on the world rather than the return code. Is the process gone, is the memory back, did the file change? Those questions have answers that do not depend on the tool agreeing with you about what it was asked to do.

And read the informational output when the result is wrong. backend already running, signaling show-dashboard was there on the first run and named the actual behaviour precisely. I skipped it because it looked like noise, which is what a log line looks like when it is describing something you did not ask for.

Related fixes

Discussion

Powered by GitHub. Sign in to leave a comment.