- date
- entry
- 045
- topic
- tooling
- rev
- —
headless Chrome without --user-data-dir: kill it, and the profile directory stays
Without --user-data-dir, headless Chrome creates a temporary profile and deletes it on exit. That assumes it exits on its own. Every run I killed with timeout left its profile in ~/Library/Application Support, and the run stopped with SIGTERM returned exit code 0 with no screenshot written.
My cover-image script takes screenshots with headless Chrome and doesn't pass --user-data-dir (the user data directory, where the profile lives). Without it, Chrome creates a temporary profile and deletes it when done, so that script cleans up only its own temp files, not Chrome's.
But on October 15, with 2 GB of disk left, I found four orphaned scoped_dir* directories in ~/Library/Application Support/Google/Chrome-headless/, dated August 20, October 8 and October 13 (two), the largest 128 MiB (all sizes in this note are from du -k, in units of 1,024). This note is about when the temporary profile gets left behind, plus two things I measured along the way: Chrome's exit code when stopped with SIGTERM, and which flag actually ends a stuck screenshot. In order: where the temporary profile lives, when it stays, exit code 0 with no image, the cause of the hang and --timeout, and the wrapper script I use now.
Where the temporary profile lives
The test setup is Google Chrome 155.0.8059.41 on macOS, with the command written the way my cover-image script does it:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless --disable-gpu --hide-scrollbars \
--window-size=1200,630 --screenshot=out.png \
--virtual-time-budget=3000 file:///…/page.html
I had assumed the temporary profile was in $TMPDIR. Listing $TMPDIR every 0.2 seconds during a run showed only one new com.google.Chrome.XXXXXX, gone after exit. With Chrome stuck, ps on its child processes shows --user-data-dir=/Users/…/Library/Application Support/Google/Chrome-headless/scoped_dirXXXXXX, and lsof shows it holding cache files under that directory's Default/. So the temporary profile is in the user's Application Support, not in the temp directory.
When it stays
Three normal exits: entries under Chrome-headless went 7 → 7 each time. Then I used a page that hangs (a while(true){} in JavaScript), stopped Chrome after 3 seconds in different ways, and counted again 3 seconds later:
kill -TERM → exit 0 profile dirs +1
kill -KILL → exit 137 profile dirs +1
kill -INT → exit 133 profile dirs +1 (133 = 128 + 5, i.e. SIGTRAP; I didn't chase why)
timeout 3 … → exit 124 profile dirs +1
Each of the four left one behind. The ones stopped after 3 seconds are 5.5 to 6.7 MiB each; three later runs stopped after 20 to 25 seconds are about 47 MiB each, about 40 MiB of which is optimization_guide_model_store. I haven't worked out where those 40 MiB come from. I tried --disable-component-update, and --disable-features=OptimizationGuideModelDownloading,OptimizationHintsFetching,OptimizationTargetPrediction, each with an explicit --user-data-dir and a 20-second run; that directory was still about 40 MiB.
$TMPDIR keeps something too, but much less. Every stopped run left a com.google.Chrome.XXXXXX directory that du counts as 0 KiB, holding only SingletonCookie and SingletonSocket; the first SIGTERM run also left a 788 KiB scoped_dir containing CRX_INSTALL. This machine's $TMPDIR has 72 .com.google.Chrome.* files and 32 com.google.Chrome.XXXXXX directories of the same shape, but regular Chrome creates files there too, and I can't tell which came from headless runs.
How the four old directories from the start were left, I don't know; all I can say is that their location and names match the ones these tests left.
Exit code 0, no screenshot
The easiest line in that table to misread is the first. In this infinite-loop test, after kill -TERM, wait got exit code 0, and the screenshot file didn't exist. I ran it once more to check: 0 again, no file again. A script that judges success only by exit code would count this as a success.
The 124 seen with timeout is timeout's own exit code, meaning it stopped the command; it isn't Chrome's. So whether a screenshot succeeded has to be checked, at minimum, on the output file directly: does it exist, and is it non-empty; keep the exit code as diagnostic information. My cover script already had an existsSync check for existence; now I know it can't be removed, and a non-empty check is worth adding.
The hang: one unanswered request
A hang isn't necessarily an infinite loop; another kind is a resource that never answers. Locally I opened a socket that accepts connections and sends nothing, pointed an <img> at it, and wrapped Chrome in a 25-second timeout:
no flag → stopped by timeout at 25 s, no screenshot, profile dirs +1
--virtual-time-budget=3000 → stopped by timeout at 25 s, no screenshot, profile dirs +1
--timeout=3000 → screenshot in about 4 s, exit 0, profile dirs +0
--virtual-time-budget looks like "wait at most 3 seconds", and it isn't. The comment on it in Chromium's components/headless/command_handler/headless_command_switches.cc (on the GitHub mirror's main branch, the file was last changed on November 17, 2023) says that, for determinism, virtual time does not advance while there are pending network fetches. If the request never returns, the budget is never spent. The same file's comment on --timeout: it issues a stop after the specified number of milliseconds, which cancels all navigation and causes DOMContentLoaded to fire. What the test showed: it captured whatever was on screen, exited normally, and deleted the temporary profile; whether DOMContentLoaded fired, I didn't monitor.
--timeout didn't rescue this infinite loop. With the while(true){} page and --timeout=3000, it still took the outer timeout to stop it, and another profile directory appeared. So the wrapper below has both layers: --timeout for resources that never arrive, and an outer timeout for when Chrome can't stop itself.
Wrapper script
Since every outer stop in these tests left the profile behind, pass your own temporary directory and have the script delete it:
#!/bin/bash
# usage: shot.sh <url> <out.png> [width,height]
set -u
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
url=$1; out=$2; size=${3:-1200,630}
prof=$(mktemp -d "${TMPDIR:-/tmp}/shot-profile.XXXXXX") || exit 1
trap 'rm -rf "$prof"' EXIT
rm -f "$out" || exit 1
timeout -k 5 30 "$CHROME" --headless --disable-gpu --hide-scrollbars \
--user-data-dir="$prof" --timeout=5000 \
--window-size="$size" --screenshot="$out" "$url" >/dev/null 2>&1
rc=$?
[ -s "$out" ] || { echo "no screenshot (exit ${rc})" >&2; exit 1; }
timeout isn't built into macOS; on this machine it's Homebrew's coreutils 9.5. At the deadline it sends SIGTERM, and -k 5 sends SIGKILL if the command is still running 5 seconds later. rc is timeout's exit code, not Chrome's. Two cases tested: a normal page produced a 1200×630 PNG and exit 0; the infinite-loop page printed "no screenshot (exit 124)" after 30 seconds and exited 1. After both, nothing new under Chrome-headless, and no shot-profile.* in $TMPDIR. A stop by timeout still adds a 0 KiB com.google.Chrome.* (the Singleton one) to $TMPDIR, outside what the trap cleans; I'm ignoring it for now.
The script has three more limits. trap … EXIT doesn't run if the script itself is killed with SIGKILL, and then the temporary directory stays; -s only checks the file is non-empty, not that it's a complete PNG, so for a stricter check read its size again with sips -g pixelWidth; and a non-empty output counts as success even when rc isn't 0, which is deliberate (I want whatever --timeout captured), so check rc too if you don't. Also, the braces in ${rc} aren't style. The first version of the script printed the message in Chinese and had $rc followed directly by a full-width parenthesis, and macOS's /bin/bash 3.2 answered rc�: unbound variable. Why is in 046.
The 11 profile directories these tests left (about 192 MiB in total) and the leftovers in $TMPDIR aren't deleted yet; the four old directories from the start are still there too.
If you remember one thing
In these tests, headless Chrome deleted its temporary profile only when it exited on its own; every stop added a directory under ~/Library/Application Support/Google/Chrome-headless/, and the SIGTERM run exited 0. Give screenshot scripts their own --user-data-dir and remove it with trap, add --timeout for requests that never arrive, and check the output file, not just the exit code.
Revision history
- First published