Bake the browser's runtime libraries into the base image
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m13s
Build Container / build-container (pull_request) Successful in 13m11s
Build App / build-linux (pull_request) Successful in 6m53s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped

`npx playwright install chromium` downloaded ~150 MB of browser that then
died with "error while loading shared libraries: libglib-2.0.so.0" —
verified, not inferred, against the current image. The image shipped none
of Chromium's shared libraries, which is why `apt install
google-chrome-stable` looked like the cure: apt was quietly installing the
same set as Chrome's own dependencies.

Installing them at runtime instead converges on the worst possible state.
The libraries land in the container's writable layer, so they are re-paid
after every Reset and *lost* on base-image migration, which replays apt
from a manifest. The browsers ride in ~/.cache/ms-playwright, inside the
home volume, and survive both — leaving a 400 MB browser present with its
libraries gone. So the libraries are baked and the browsers are not: each
half now lives where it already persists.

The layer runs `npx --yes playwright@latest install-deps chromium` rather
than a hand-written apt list. Ubuntu 24.04's 64-bit-time_t transition
renamed a swathe of these packages (libasound2t64, libatk1.0-0t64,
libglib2.0-0t64, …) and a new Chromium dependency would drift straight back
into the launch failure this exists to prevent; letting Playwright name its
own dependencies is self-maintaining. It sits immediately after Node — npx
is its only prerequisite — and well above the shim COPYs, so editing a shim
does not re-run it.

The `--dry-run` that follows is a build-time assertion, not decoration: on a
platform Playwright has no list for, `install-deps` prints a warning and
returns having installed **nothing, with exit status 0**. Without the
assertion that ships a broken image behind a clean build log.

Measured, on a build of this file with the layer applied over an otherwise
identical image: +99 packages, +334 MiB unpacked and +119 MiB compressed
(2950 → 3284 MiB, 759 → 878 MiB). Two thirds of that is not reachable by
trimming — libgbm1, which Chromium needs, pulls mesa-libgallium, which
pulls libllvm20. A chromium-only apt list measures 247 MiB against
install-deps' 341 MiB; the ~94 MiB difference is xvfb and the CJK/emoji
fonts, kept because the base ships no fonts at all and every page this
feature exists to display would otherwise render as tofu.

Verified on real builds, both architectures: a `--platform linux/arm64`
build of this file installs the same 99 packages and passes the same
assertion. On the new amd64 image, `playwright install chromium` with no
`--with-deps` and no `install-deps` launches headless Chromium 151.0.7922.34
and loads a page; on the old image the identical script fails on
libglib-2.0.so.0.

`install.rs` no longer runs `install-deps` unconditionally — that would be a
minutes-long apt run for nothing on a current image. It asks
`install-deps --dry-run` first and skips the install when everything is
present, saying which of the two happened on the progress stream. The check
is Playwright's rather than a probe of our own for library names, so check
and fix cannot disagree about what the dependency set is. Note that
`--dry-run` exits 0 both when everything is installed and when Playwright
has no list for the platform, so the verdict is read from its output.

Containers on older images stay the normal case until people migrate, and
they still work: on such an image the simulation cannot even resolve the
package names (the index is cleaned in every base image), which reports as
"couldn't tell" and installs — the right answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
This commit is contained in:
2026-08-10 10:57:21 -07:00
co-authored by Claude Opus 5
parent a5bcc462a7
commit 4fdfed7955
6 changed files with 389 additions and 52 deletions
+33 -6
View File
@@ -118,11 +118,18 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
touches nothing of the user's, needs no sudo (npm's prefix is `/usr`, which is root-owned), touches nothing of the user's, needs no sudo (npm's prefix is `/usr`, which is root-owned),
and is on the module resolution path for scripts in the project. Browsers go to and is on the module resolution path for scripts in the project. Browsers go to
`~/.cache/ms-playwright` as `claude`, i.e. the home volume. `~/.cache/ms-playwright` as `claude`, i.e. the home volume.
- **The base image ships none of Chromium's shared libraries.** `playwright install chromium` - **Current base images ship Chromium's shared libraries; older ones do not** — and a project
therefore downloads a browser that cannot launch, which is why installing Chrome via apt keeps the base image it was first built from until it is migrated, so "older" is the normal
looks like a fix. The install action runs `install-deps` as root first and then *actually case. Without them `playwright install chromium` downloads a browser that cannot launch, which
launches* the browser to verify. `@playwright/mcp` wants the `chrome` **channel** is why installing Chrome via apt looks like a fix. `install.rs` asks
specifically, so both browsers are offered. `install-deps --dry-run` first and skips the apt step when the answer is "all present",
*saying so* in the progress stream. Do not decide this by probing for library names: the
dry-run simulates the same `apt-get install` the fix would run, so check and fix cannot
disagree about what the dependency set is. Note that `--dry-run` exits **0** both when
everything is installed and when Playwright has no list for the platform — match on its
output, not its exit code. Either way the action ends by *actually launching* the browser to
verify. `@playwright/mcp` wants the `chrome` **channel** specifically, so both browsers are
offered.
- **`docker/`** — Docker API layer using bollard: - **`docker/`** — Docker API layer using bollard:
- `client.rs` — Singleton Docker connection via `OnceLock` - `client.rs` — Singleton Docker connection via `OnceLock`
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect) - `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
@@ -153,7 +160,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
### Container (`container/`) ### Container (`container/`)
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed - **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
libraries a browser links against (see below)
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
dependency. Measured: +99 packages, +334 MiB unpacked / +119 MiB compressed, on both arches. Do
not replace it with a hand-written apt list without pinning the Playwright version you derived
it from — a `chromium`-only list saves ~94 MiB (Playwright's `tools` group: xvfb and the CJK
fonts) and nothing more, because `libgbm1``mesa-libgallium``libllvm20` is ~213 MiB that
no trimming removes.
- The `install-deps --dry-run` call after it is a **build-time assertion, not decoration**: on a
platform Playwright's table does not cover, `install-deps` prints a warning and returns having
installed nothing **with exit status 0**. Without the assertion that ships a broken image
behind a clean build log.
- Baking the libraries but not the browsers is the whole point of the split. Browsers live in
`~/.cache/ms-playwright` (home volume) and already survive recreation *and* migration; a
runtime `apt-get install` of the libraries lands in the writable layer, is re-paid after every
Reset, and is **lost on base-image migration**, which replays apt from a manifest. The runtime
approach converges on the worst state: a 400 MB browser present with its libraries gone.
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity` - **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations - **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
+6
View File
@@ -1127,6 +1127,12 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, a **browser shim** (`triple-c-open`, installed as `xdg-open`, `sensible-browser`, `www-browser`, `x-www-browser` and `$BROWSER`) that relays URLs to your host browser — see [Opening URLs in Your Browser](#opening-urls-in-your-browser-url-relay) — and an **audio shim** (`rec`, `arecord`) for future voice mode support. The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, a **browser shim** (`triple-c-open`, installed as `xdg-open`, `sensible-browser`, `www-browser`, `x-www-browser` and `$BROWSER`) that relays URLs to your host browser — see [Opening URLs in Your Browser](#opening-urls-in-your-browser-url-relay) — and an **audio shim** (`rec`, `arecord`) for future voice mode support.
It also ships the **system libraries a browser needs to run** (`libnss3`, `libgbm1`, `libatk*`, `libasound2t64`, `libcups2t64`, `libpango`, `libdrm2`, fonts, and the rest of the set Playwright asks for). So `npx playwright install chromium` gives you a browser that actually starts. Before these were baked in, that download succeeded and the browser then died with *"Host system is missing dependencies: libnss3.so"*, which is why `sudo apt install google-chrome-stable` looked like the cure — apt was quietly installing the same libraries as Chrome's own dependencies.
The **browsers themselves are not pre-installed** — they are hundreds of megabytes and tied to the Playwright version you use. Install one with the Browser tab's setup buttons, or `npx playwright install chromium` in a terminal. They land in `~/.cache/ms-playwright`, which is on the home volume, so a browser survives container recreation and base-image migration and is only lost on a project **Reset**.
If your project's container was created from an older base image, it won't have the libraries — the Browser tab's install action detects that and installs them for you first, and says so while it does. That install lives in the container's writable layer, so it is undone by a **Reset** and by a base-image migration; migrating the project onto the current base image is what picks the libraries up for good.
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets). You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
--- ---
+20
View File
@@ -386,4 +386,24 @@ Users can override this in Settings via the global `docker_socket_path` option.
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode) **Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
**Browser runtime libraries**: the shared libraries Chromium links against (`libnss3`, `libgbm1`,
`libatk*`, `libasound2t64`, `libcups2t64`, `libpango`, `libdrm2`, … plus fonts) are baked in, via
`npx playwright install-deps chromium` at build time. Without them `playwright install chromium`
downloads a browser that then dies at launch with *"Host system is missing dependencies:
libnss3.so"* — which is why installing `google-chrome-stable` used to look like the fix (apt was
pulling the libraries in as *its* dependencies). Measured cost of the layer: +99 packages,
**+334 MiB unpacked / +119 MiB compressed** (2950 → 3284 MiB unpacked, 759 → 878 MiB compressed).
Two thirds of that is not avoidable by trimming — `libgbm1`, which Chromium needs, depends on
`mesa-libgallium`, which depends on `libllvm20`. The list is taken from Playwright rather than
hand-written so it cannot rot against Ubuntu 24.04's `t64` renames or a future Chromium dependency,
and the `install-deps --dry-run` that follows it is a build-time assertion: on a platform
Playwright has no list for, `install-deps` installs nothing and still exits 0.
**Browser binaries are deliberately not baked.** They are large, they are version-coupled to
whatever Playwright the user installs, and they already persist: `~/.cache/ms-playwright` is inside
the home volume, so a downloaded browser survives container recreation *and* base-image migration.
The libraries are the opposite — a runtime `apt-get install` lands in the container's writable
layer, is re-paid after every Reset, and is lost on migration (which replays apt from a manifest
against the new base). Baking one and not the other puts each half where it already persists.
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host) **Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
+209 -19
View File
@@ -11,10 +11,14 @@
//! image leaves npm's prefix at `/usr`, so the unprivileged form fails with //! image leaves npm's prefix at `/usr`, so the unprivileged form fails with
//! `EACCES` first. //! `EACCES` first.
//! * `playwright install chromium` downloads a browser that then cannot start, //! * `playwright install chromium` downloads a browser that then cannot start,
//! because the base image ships **none** of Chromium's shared libraries //! because the image shipped **none** of Chromium's shared libraries
//! (`libnss3`, `libgbm1`, `libatk*`, `libasound2`, `libcups2`, …). The //! (`libnss3`, `libgbm1`, `libatk*`, `libasound2`, `libcups2`, …). The
//! download succeeds, the launch fails, and the error reads like a Playwright //! download succeeds, the launch fails, and the error reads like a Playwright
//! bug. //! bug. Current base images bake those libraries in (see `container/
//! Dockerfile`), so this step is now usually a no-op — but a project stays on
//! the base image it was first built from until someone migrates it, so the
//! old case is the *normal* case and has to keep working. Hence: check, then
//! install only if needed, and say which happened.
//! * Getting from there to a working pane took a long tail of further commands. //! * Getting from there to a working pane took a long tail of further commands.
//! //!
//! ## Where the packages go, and why it is `/workspace` //! ## Where the packages go, and why it is `/workspace`
@@ -86,6 +90,10 @@ pub const INSTALL_DIR: &str = "/workspace";
const NPM_TIMEOUT: Duration = Duration::from_secs(10 * 60); const NPM_TIMEOUT: Duration = Duration::from_secs(10 * 60);
/// `apt-get update` plus a dozen library packages, or Google's apt repository. /// `apt-get update` plus a dozen library packages, or Google's apt repository.
const DEPS_TIMEOUT: Duration = Duration::from_secs(20 * 60); const DEPS_TIMEOUT: Duration = Duration::from_secs(20 * 60);
/// `apt-get install -s` over ~100 already-installed packages. Local work; a
/// container that cannot answer this in two minutes gets the libraries
/// installed rather than a hang.
const DEPS_CHECK_TIMEOUT: Duration = Duration::from_secs(2 * 60);
/// The browser download itself, on a bad connection. /// The browser download itself, on a bad connection.
const BROWSER_TIMEOUT: Duration = Duration::from_secs(45 * 60); const BROWSER_TIMEOUT: Duration = Duration::from_secs(45 * 60);
/// Starting a headless browser, and one page load. /// Starting a headless browser, and one page load.
@@ -148,8 +156,9 @@ impl BrowserTarget {
pub fn download_note(self) -> &'static str { pub fn download_note(self) -> &'static str {
match self { match self {
Self::Chromium => { Self::Chromium => {
"Playwright's Chromium build plus the system libraries it needs — several \ "Playwright's Chromium build — a few hundred MB, a few minutes on a normal \
hundred MB in total, a few minutes on a normal connection" connection. The system libraries it needs are already in current base images; \
on an older container they are installed first"
} }
Self::Chrome => { Self::Chrome => {
"Google Chrome from Google's apt repository, with its dependencies — roughly \ "Google Chrome from Google's apt repository, with its dependencies — roughly \
@@ -302,20 +311,54 @@ pub async fn install_browser(
// cause of the "Chromium downloads and then dies" reports, so it gets its // cause of the "Chromium downloads and then dies" reports, so it gets its
// own progress line rather than being folded into the download. // own progress line rather than being folded into the download.
// //
// This is what `playwright install --with-deps` does internally. Running // Current base images bake these in, so on an up-to-date container there is
// `install-deps` directly *as root* is the same apt work without depending // nothing to do here. That is *not* a reason to drop the step: a project
// on Playwright's own privilege escalation: read from the shipped source, it // keeps the base image it was first built from until it is migrated, so
// shells out to `sudo -- sh -c "apt-get update && apt-get install …"` when // containers without the libraries are the common case for a long while
// it is not root, which would work here (`claude` has passwordless sudo) but // yet. So: ask first, skip loudly, install only when the answer is no.
// puts an extra failure mode between the user and the answer. //
// The question is put to Playwright rather than answered by probing for
// library names ourselves. `install-deps --dry-run` simulates the very
// `apt-get install` that `install-deps` would run and exits non-zero if
// anything is missing, which means the check and the fix can never disagree
// about what "the libraries" means — including after a Playwright release
// adds one.
//
// Installing runs `install-deps` directly *as root*: that is what `playwright
// install --with-deps` does internally, minus Playwright's own privilege
// escalation (read from the shipped source, it shells out to
// `sudo -- sh -c "apt-get update && apt-get install …"` when it is not root,
// which would work here — `claude` has passwordless sudo — but puts an extra
// failure mode between the user and the answer).
// //
// For the Chrome channel apt installs `google-chrome-stable`, whose own // For the Chrome channel apt installs `google-chrome-stable`, whose own
// dependencies cover the same libraries — but running `install-deps` first // dependencies cover the same libraries — but going through the same check
// costs little and makes the two paths behave identically. // first makes the two paths behave identically.
emit_progress( emit_progress(
app, app,
project_id, project_id,
"Step 1/3 — installing browser system libraries with apt (needs root; a minute or two)", "Step 1/3 — checking whether this container already has the browser system libraries",
);
let state = check_libraries(container_id, &cli, target).await;
match &state {
LibraryState::Present => {
emit_progress(
app,
project_id,
"Step 1/3 — already there: this image ships the browser system libraries. \
Skipping the apt install.",
);
push_section(&mut log, "Browser system libraries: already installed, apt skipped.");
}
LibraryState::Missing(_) | LibraryState::Unknown(_) => {
emit_progress(
app,
project_id,
&format!(
"Step 1/3 — {} Installing browser system libraries with apt (needs root; a \
minute or two)…",
state.detail()
),
); );
let deps = run_step( let deps = run_step(
app, app,
@@ -332,18 +375,20 @@ pub async fn install_browser(
DEPS_TIMEOUT, DEPS_TIMEOUT,
) )
.await?; .await?;
log.push_str(&deps.log); push_section(&mut log, &deps.log);
if deps.exit_code != 0 { if deps.exit_code != 0 {
// Not fatal on its own — the libraries may already be present — but it // Not fatal on its own — some of the libraries may already be
// must never pass silently, because the failure it causes surfaces much // present — but it must never pass silently, because the failure
// later and looks like something else. // it causes surfaces much later and looks like something else.
warning = Some(format!( warning = Some(format!(
"Installing the browser's system libraries failed (exit {}). The browser may install \ "Installing the browser's system libraries failed (exit {}). The browser may \
and then refuse to start. apt said:\n{}", install and then refuse to start. apt said:\n{}",
deps.exit_code, deps.exit_code,
deps.log_or("nothing") deps.log_or("nothing")
)); ));
} }
}
}
// Step 2 — the download the user was warned about. Chromium is fetched as // Step 2 — the download the user was warned about. Chromium is fetched as
// `claude` so the bundle lands in the home volume's `~/.cache/ms-playwright` // `claude` so the bundle lands in the home volume's `~/.cache/ms-playwright`
@@ -436,6 +481,102 @@ pub async fn install_browser(
}) })
} }
/// Phrases `install-deps --dry-run` prints. Matching on Playwright's own words
/// is load-bearing: the exit code alone cannot tell "everything is installed"
/// (0) apart from "this platform isn't in my table, so I did nothing" (also 0).
const DEPS_OK_MARKER: &str = "All system dependencies are installed";
const DEPS_MISSING_MARKER: &str = "Missing system dependencies";
const DEPS_UNKNOWN_PLATFORM_MARKER: &str = "Cannot install dependencies for";
/// Whether this container already has the libraries a browser links against.
#[derive(Debug, Clone, PartialEq, Eq)]
enum LibraryState {
/// Playwright confirms every package it would install is present. Current
/// base images bake them, so this is the answer on an up-to-date container.
Present,
/// Playwright named packages that are absent.
Missing(String),
/// The check could not answer. Always installs — an unnecessary apt run
/// costs a minute, a skipped one costs a browser that will not start.
Unknown(String),
}
impl LibraryState {
/// What the user sees on the progress line, and why.
fn detail(&self) -> &str {
match self {
Self::Present => "",
Self::Missing(d) | Self::Unknown(d) => d,
}
}
}
/// Ask Playwright whether the libraries are already installed.
///
/// `--dry-run` simulates the same `apt-get install` that `install-deps` would
/// perform and exits non-zero if any package is missing, so the check can never
/// disagree with the fix about what the dependency set is — including after a
/// Playwright release changes it.
///
/// Note that the simulation works on an image whose `/var/lib/apt/lists` has
/// been cleaned (every base image's has): apt knows installed packages from
/// dpkg's status file. A *missing* package on such an image is simply not in
/// any index, so apt fails, Playwright reports the failure, and this returns
/// [`LibraryState::Unknown`] — which installs, which is the right answer.
async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState {
let run = exec_oneshot_as(
container_id,
"root",
vec![
"node".to_string(),
cli.to_string(),
"install-deps".to_string(),
"--dry-run".to_string(),
target.cli_name().to_string(),
],
vec![],
);
match tokio::time::timeout(DEPS_CHECK_TIMEOUT, run).await {
Ok(Ok((output, code))) => classify_library_check(&output, code),
Ok(Err(e)) => LibraryState::Unknown(format!(
"Couldn't ask Playwright whether they're already there ({}), so installing them to be \
sure.",
e
)),
Err(_) => LibraryState::Unknown(
"The check for them didn't finish in time, so installing them to be sure.".to_string(),
),
}
}
/// Read the verdict out of `install-deps --dry-run`'s output.
fn classify_library_check(output: &str, exit_code: i64) -> LibraryState {
// Checked before the success marker, not after: this branch also exits 0.
if output.contains(DEPS_UNKNOWN_PLATFORM_MARKER) {
return LibraryState::Unknown(
"Playwright doesn't have a dependency list for this container's platform, so it \
can't say — installing them to be sure."
.to_string(),
);
}
if exit_code == 0 && output.contains(DEPS_OK_MARKER) {
return LibraryState::Present;
}
if let Some(idx) = output.find(DEPS_MISSING_MARKER) {
let summary = output[idx..]
.lines()
.next()
.unwrap_or(DEPS_MISSING_MARKER)
.trim();
return LibraryState::Missing(format!("Playwright reports {}", summary.to_lowercase()));
}
LibraryState::Unknown(format!(
"Couldn't tell whether they're already there (the check exited {}), so installing them to \
be sure.",
exit_code
))
}
/// The result of actually starting a browser. /// The result of actually starting a browser.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct LaunchVerdict { struct LaunchVerdict {
@@ -759,6 +900,55 @@ mod tests {
assert!(err.contains("chrome"), "{}", err); assert!(err.contains("chrome"), "{}", err);
} }
#[test]
fn baked_in_libraries_are_detected_and_the_apt_step_is_skipped() {
// What a container built from a current base image says. The whole
// point of baking them: this must not re-run apt.
assert_eq!(
classify_library_check("All system dependencies are installed.\n", 0),
LibraryState::Present
);
}
#[test]
fn an_older_image_without_them_is_detected_and_named() {
let v = classify_library_check(
"Missing system dependencies (12):\n libnss3\n libgbm1\n",
1,
);
match v {
LibraryState::Missing(d) => assert!(d.contains("(12)"), "{}", d),
other => panic!("expected Missing, got {:?}", other),
}
}
#[test]
fn an_unrecognised_platform_installs_rather_than_reporting_success() {
// Playwright prints this and exits **0** having installed nothing, so a
// check that trusted the exit code would skip the apt step on exactly
// the container that needs it.
let v = classify_library_check(
"Cannot install dependencies for ubuntu24.04-riscv64 with Playwright 1.62.1!\n",
0,
);
assert!(matches!(v, LibraryState::Unknown(_)), "{:?}", v);
}
#[test]
fn a_check_that_could_not_run_installs_rather_than_guessing() {
// e.g. apt cannot resolve a package because the image's package index
// was cleaned and the package is genuinely absent.
let v = classify_library_check("E: Unable to locate package libgbm1\n", 100);
match v {
LibraryState::Unknown(d) => assert!(d.contains("100"), "{}", d),
other => panic!("expected Unknown, got {:?}", other),
}
// Present contributes nothing to the progress line; the other two must
// explain themselves, because the reason is shown to the user.
assert_eq!(LibraryState::Present.detail(), "");
assert!(!LibraryState::Unknown("why".into()).detail().is_empty());
}
#[test] #[test]
fn a_successful_launch_and_page_load_is_reported_as_working() { fn a_successful_launch_and_page_load_is_reported_as_working() {
let v = parse_launch_output(concat!( let v = parse_launch_output(concat!(
@@ -310,8 +310,11 @@ function missingParts(d: PlaywrightDetection | null): string[] {
* The old pane printed npm commands here and left the rest to the user. The * The old pane printed npm commands here and left the rest to the user. The
* result, verified with a real one: an `@playwright/mcp` install that could * result, verified with a real one: an `@playwright/mcp` install that could
* never satisfy this pane, a global install that hit EACCES, a Chromium that * never satisfy this pane, a global install that hit EACCES, a Chromium that
* downloaded and then would not start because the image ships none of its * downloaded and then would not start because the image shipped none of its
* shared libraries, and a long tail of commands after that. * shared libraries, and a long tail of commands after that. Current base images
* bake those libraries in, so that last one is fixed at the source — but a
* project keeps its original base image until it is migrated, so the install
* action still handles a container that lacks them.
*/ */
function Setup({ function Setup({
detection, detection,
@@ -386,11 +389,13 @@ function Setup({
title="2. A browser to drive" title="2. A browser to drive"
detail={ detail={
<> <>
Both install the system libraries first the base image ships none of them, Both check the system libraries a browser links against first. Current base
which is why a browser can download successfully and then refuse to start images ship them, so that step is normally skipped; a container built from an
and both end by actually launching the browser to prove it works. Browsers older image gets them installed with apt, which is the difference between a
land in <Code>~/.cache/ms-playwright</Code>, which is on the home volume, so browser that downloads successfully and one that also starts. Both end by
they survive container recreation and are only lost on a project Reset. actually launching the browser to prove it works. Browsers land in{" "}
<Code>~/.cache/ms-playwright</Code>, which is on the home volume, so they
survive container recreation and are only lost on a project Reset.
</> </>
} }
done={browsers.length > 0 || chrome !== null} done={browsers.length > 0 || chrome !== null}
+89
View File
@@ -78,6 +78,95 @@ RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& npm install -g pnpm && npm install -g pnpm
# ── Browser runtime libraries (Chromium / Google Chrome) ────────────────────
# Chromium links against a set of shared libraries Ubuntu's base image does not
# ship — libnss3, libgbm1, libatk*, libasound2t64, libcups2t64, libpango,
# libdrm2 and friends. Without them `playwright install chromium` downloads a
# browser that then dies at launch with "Host system is missing dependencies:
# libnss3.so", which reads like a Playwright bug and is not one. Installing
# google-chrome-stable used to look like the fix only because apt pulled these
# in as *its* dependencies.
#
# ## Why baked, and why only the libraries
#
# A runtime `apt-get install` lands in the container's writable layer: it is
# re-paid after every project Reset, and it is *lost* on base-image migration,
# which replays apt from a manifest against the new base. The browsers
# themselves live in ~/.cache/ms-playwright, inside the home volume, and survive
# both — so the runtime approach converges on the worst state, a 400 MB browser
# present with its libraries gone. Baking the libraries and leaving the browsers
# out puts each half where it already persists.
#
# Browser binaries are deliberately NOT baked: they are large, they are
# version-coupled to whatever Playwright the user installs, and the home volume
# already keeps them.
#
# ## Why `install-deps` rather than a hand-written apt list
#
# Playwright names its own dependencies, so the list cannot silently rot. That
# matters more than usual on Ubuntu 24.04, whose 64-bit-time_t transition
# renamed a swathe of these packages (libasound2 → libasound2t64, libatk1.0-0 →
# libatk1.0-0t64, libglib2.0-0 → libglib2.0-0t64, …); a hardcoded list drifts
# into "E: Unable to locate package" build failures, and a list that predates a
# new Chromium dependency drifts into exactly the launch failure this layer
# exists to prevent.
#
# Verified on a real `--platform linux/arm64` build of this file, not assumed:
# it resolves and installs there too (99 packages on both arches), and the
# --dry-run assertion below passes. Worth checking rather than assuming:
# Playwright looks its dependency list up under `<distro><version>-<arch>`, so
# arm64 is a separate lookup that could have missed.
#
# ## What it costs
#
# Measured with this layer applied on top of an otherwise identical image
# (linux/amd64, playwright 1.62.1): **+99 packages, +334 MiB unpacked, +119 MiB
# compressed** — the image goes 2950 → 3284 MiB unpacked, 759 → 878 MiB
# compressed. (`docker history` calls the layer 361 MB, i.e. 344 MiB; the
# difference is tar metadata `du` doesn't count.)
#
# Where it goes, by dpkg Installed-Size:
# ~213 MiB libllvm20 + mesa-libgallium + libicu74. Not optional and not
# avoidable by trimming the list: libgbm1, which Chromium genuinely
# needs, Depends on mesa-libgallium, which Depends on libllvm20.
# ~94 MiB Playwright's `tools` group — xvfb and the CJK/emoji fonts. Kept:
# the base image ships no fonts at all, so without them every page
# this feature exists to display renders as tofu, and xvfb is what
# lets a *headed* browser run in here.
# the rest Chromium's own library closure.
#
# An explicit apt list of just `chromium`'s dependencies measures 247 MiB
# installed against install-deps' 341 MiB, so hand-maintaining one would save
# ~94 MiB. Not worth owning the drift; if you disagree, derive the list from
# `install-deps --dry-run chromium` and pin the Playwright version you took it
# from in a comment here.
#
# The retry loop is for the same transient mirror-sync failures the other apt
# layers guard against; install-deps runs its own un-retried `apt-get update`
# internally. `npx --yes` is what makes it non-interactive, and the version it
# resolved is printed so a build log says which Playwright named this set.
#
# Placed immediately after Node (npx is its only prerequisite) and well above
# the shim COPYs, so editing a shim at the bottom of this file does not re-run a
# multi-hundred-megabyte apt install.
#
# `--dry-run` afterwards is the build-time assertion, and it is not decoration:
# on a platform Playwright's table does not cover, `install-deps` prints a
# warning and returns having installed **nothing, with exit status 0**. Without
# this check that failure mode would ship an image whose build log looked clean.
# `--dry-run` exits non-zero if any required package is still missing.
RUN npx --yes playwright@latest --version \
&& ok=0 \
&& for i in 1 2 3 4 5; do \
if npx --yes playwright@latest install-deps chromium; then ok=1; break; fi; \
echo "install-deps failed (attempt $i), retrying in 10s..."; \
rm -rf /var/lib/apt/lists/*; \
sleep 10; \
done \
&& [ "$ok" = 1 ] \
&& npx --yes playwright@latest install-deps --dry-run chromium \
&& rm -rf /var/lib/apt/lists/* /root/.npm
# ── Python 3 + pip + uv + ruff ────────────────────────────────────────────── # ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
RUN for i in 1 2 3 4 5; do \ RUN for i in 1 2 3 4 5; do \
apt-get -o Acquire::Retries=3 update && break; \ apt-get -o Acquire::Retries=3 update && break; \