msedge --headless --print-to-pdf exits 0 and writes no file
The command returns immediately, the exit code is 0, there is no error on stdout or stderr, and the PDF does not exist. A browser window was already open, and your invocation handed its arguments to that process and quit.
TL;DR · THE FIX
Chromium-family browsers keep a singleton lock in the user data directory. If an instance is already running with that profile, a second invocation forwards its arguments to the running process and exits 0 immediately, so a headless render silently does nothing and the exit code says it succeeded. Always pass an isolated --user-data-dir pointing at a fresh temporary directory, and always assert the output file exists and is non-empty afterwards, because the exit code will never tell you.
The symptom
Rendering an HTML file to a PDF from a script, on Windows, with Edge:
& "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" `
--headless --disable-gpu `
--print-to-pdf="C:\out\card.pdf" `
"file:///C:/work/card.html"
$LASTEXITCODE # 0
Test-Path C:\out\card.pdf # False
Exit code 0, nothing on stdout or stderr, and no PDF. It returns fast, too, noticeably faster than a real render, and a fast success is not something you investigate.
The identical command works some of the time. Run it in the morning and you get a PDF; run it after lunch and you get nothing, with the same script, the same HTML, and the same machine.
What is happening
Chromium, and therefore Edge and Chrome, keeps a singleton lock inside its user data directory. It exists for the ordinary desktop behaviour you rely on every day: double-click a second shortcut and you get a new tab in the browser already open rather than a second browser. The second process starts, finds the lock, sends its command line to the process that holds it, and exits immediately with status 0, because from its point of view it delivered the request.
The process that receives the request is the browser you already have open, which is not headless. It has no idea what to do with --print-to-pdf, because those flags are read at startup and it started hours ago without them, so it does nothing visible, possibly opens your file:/// URL in a tab, and carries on.
That accounts for every symptom: the speed, because no rendering happened; the exit code, because the handoff succeeded; the silence, because nothing failed; and the intermittency, because it depends on whether you happen to have a browser window open, which is not a variable anybody thinks to control for.
The fix
Give the headless invocation a user data directory of its own, so there is no lock to find and no running process to defer to:
$tmp = Join-Path $env:TEMP ("edge-" + [guid]::NewGuid())
& "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" `
--headless --disable-gpu `
--user-data-dir="$tmp" `
--no-first-run --no-default-browser-check `
--print-to-pdf="C:\out\card.pdf" `
"file:///C:/work/card.html"
Remove-Item -Recurse -Force $tmp
A fresh directory per run beats a single reused scratch profile, because two renders running concurrently would contend for the same lock and you would be back where you started with one of them silently doing nothing. --no-first-run and --no-default-browser-check matter for a related reason: a brand new profile otherwise triggers first-run behaviour that can block or slow the render.
The same applies to screenshots:
& $edge --headless --disable-gpu --user-data-dir="$tmp" `
--screenshot="C:\out\card.png" --window-size=1000,1500 `
"file:///C:/work/card.html"
And to Lighthouse, Puppeteer, Playwright and anything else that drives a Chromium binary you did not launch yourself. If it can attach to an existing instance, it eventually will.
Assert on the artifact
The exit code was never going to help, so stop consulting it as though it might:
if (-not (Test-Path $out) -or (Get-Item $out).Length -lt 1024) {
throw "render produced no usable file at $out"
}
The length check earns its place. A zero-byte or near-zero-byte file is a real outcome here, produced when the browser starts, fails to load the page, and writes an empty document, and Test-Path alone returns true for that.
The equivalent in Python:
import pathlib, subprocess, tempfile, uuid
def render_pdf(html: pathlib.Path, out: pathlib.Path, edge: str) -> None:
profile = pathlib.Path(tempfile.gettempdir()) / f"edge-{uuid.uuid4()}"
subprocess.run([
edge, "--headless", "--disable-gpu",
f"--user-data-dir={profile}",
"--no-first-run", "--no-default-browser-check",
f"--print-to-pdf={out}",
html.resolve().as_uri(),
], check=True, timeout=120)
if not out.exists() or out.stat().st_size < 1024:
raise RuntimeError(f"render produced no usable file at {out}")
check=True catches a browser that failed to start. Only the file check catches a browser that handed your arguments to another process and exited cleanly, which is the failure this post is about.
Two smaller things while you are here. Use the full path to msedge.exe rather than relying on it being on PATH, because it usually is not. And Lighthouse reads the browser location from the CHROME_PATH environment variable; its --chrome-path flag is ignored in some versions, which is its own afternoon.
The lesson
An exit code tells you the request was accepted and nothing about whether the work happened. Here it was even more indirect than usual: the process that returned 0 was reporting the success of forwarding a message, and the process that received the message never agreed to do anything.
For anything that produces a file, assert on the file: its existence, its size, and where it is cheap, something about its content. That check costs two lines and it is the only one of your checks that looks at the thing you wanted. And for any Chromium binary you invoke from a script, pass --user-data-dir, because without one you have written a script whose behaviour depends on whether a human happened to leave a browser window open.
Discussion
Powered by GitHub. Sign in to leave a comment.