From 518d342f80fa43a6a6eff73df10ca8f69f808e9f Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Wed, 22 Jul 2026 14:11:28 -0700 Subject: [PATCH] fix: pass --ozone-platform=headless as argv for headless Linux launches Linux release builds have failed since v2.4.31, and headless remote daemon setup has been broken for users on the same versions. Both regressed with the Electron 37 -> 41 upgrade (#352). Electron removed ELECTRON_OZONE_PLATFORM_HINT in 38 and ignores it from 39 on, so the variable every headless launch path relied on became a no-op. Ozone also resolves its platform before the app's main script runs, so the app.commandLine.appendSwitch('ozone-platform', 'headless') fallback in index.ts could never have selected headless either. With neither mechanism live, Chromium's auto default falls back to X11 and exits with "Missing X server or $DISPLAY". Pass --ozone-platform=headless --disable-gpu as argv from every headless launch site instead: the CI verification script, the generated systemd unit, and both the npm and PyPI wrappers. --disable-gpu stays in index.ts because it is read late enough to still apply. The env var is left in the wrapper environments so older Pane builds keep working. Contract parity now covers the argument builder in both wrappers, so the npm and Python launch paths cannot drift. --- docs/SELF_HOSTED_REMOTE_DAEMON.md | 18 ++++++++---- main/src/daemon/setupRemoteHost.ts | 14 +++++---- main/src/index.ts | 9 ++++-- packages/runpane-py/src/runpane/installers.py | 15 +++++++++- packages/runpane/src/installers.ts | 14 ++++++++- .../test-packaged-headless-remote-setup.sh | 2 +- scripts/test-runpane-contract.js | 29 +++++++++++++++++++ 7 files changed, 84 insertions(+), 17 deletions(-) diff --git a/docs/SELF_HOSTED_REMOTE_DAEMON.md b/docs/SELF_HOSTED_REMOTE_DAEMON.md index 5e874305..612b23f3 100644 --- a/docs/SELF_HOSTED_REMOTE_DAEMON.md +++ b/docs/SELF_HOSTED_REMOTE_DAEMON.md @@ -62,13 +62,17 @@ pane --remote-setup --label "VM" ``` On displayless Linux hosts, prefer `runpane install daemon`; the npm and PyPI -wrappers set Electron's required headless environment automatically. For a -direct packaged launch, set it explicitly: +wrappers pass Electron's required headless flags automatically. For a direct +packaged launch, pass them explicitly: ```bash -ELECTRON_OZONE_PLATFORM_HINT=headless pane --remote-setup --label "VM" +pane --ozone-platform=headless --disable-gpu --remote-setup --label "VM" ``` +These must be command-line arguments: Electron picks its Ozone platform before +the app starts, so the environment variable `ELECTRON_OZONE_PLATFORM_HINT` no +longer works (removed in Electron 38, ignored from 39 on). + The wrappers are lightweight installers and configurators. They currently download the packaged Pane runtime because the daemon is not distributed as a separate binary. AppImage installs therefore require FUSE; on Debian-based @@ -247,9 +251,11 @@ the security tradeoff. ### Remote setup exits because X11 or `$DISPLAY` is missing -Use `runpane install daemon`, or prefix a direct packaged launch with -`ELECTRON_OZONE_PLATFORM_HINT=headless`. This failure occurs before Tailscale -setup and does not mean the daemon requires a graphical session. +Use `runpane install daemon`, or add `--ozone-platform=headless --disable-gpu` +to a direct packaged launch. This failure occurs before Tailscale setup and does +not mean the daemon requires a graphical session. If you previously relied on +`ELECTRON_OZONE_PLATFORM_HINT=headless`, switch to the flags: Electron removed +that variable in 38 and ignores it from 39 on. ### The headless daemon starts but remote connect fails diff --git a/main/src/daemon/setupRemoteHost.ts b/main/src/daemon/setupRemoteHost.ts index cea62693..6010dac6 100644 --- a/main/src/daemon/setupRemoteHost.ts +++ b/main/src/daemon/setupRemoteHost.ts @@ -573,21 +573,25 @@ function buildHeadlessDaemonCommand(paneDir: string): string { if (process.platform === 'win32') { return `cd /d ${quoteForWindows(sourceRoot)} && set "PANE_DIR=${paneDir}" && pnpm daemon:headless -- --pane-dir ${quoteForWindows(paneDir)}`; } - return `cd ${quoteForPosix(sourceRoot)} && ${buildPosixHeadlessEnvironment(paneDir)} pnpm daemon:headless -- --pane-dir ${quoteForPosix(paneDir)}`; + return `cd ${quoteForPosix(sourceRoot)} && ${buildPosixHeadlessEnvironment(paneDir)} pnpm daemon:headless --${buildLinuxHeadlessFlags()} --pane-dir ${quoteForPosix(paneDir)}`; } if (process.platform === 'win32') { return `${quoteForWindows(process.execPath)} --daemon-headless --pane-dir ${quoteForWindows(paneDir)}`; } - return `${buildPosixHeadlessEnvironment(paneDir)} ${quoteForPosix(process.execPath)} --daemon-headless --pane-dir ${quoteForPosix(paneDir)}`; + return `${buildPosixHeadlessEnvironment(paneDir)} ${quoteForPosix(process.execPath)}${buildLinuxHeadlessFlags()} --daemon-headless --pane-dir ${quoteForPosix(paneDir)}`; +} + +// Ozone selects its platform before the app's main script runs, so this must be +// passed as argv — app.commandLine.appendSwitch() is too late, and +// ELECTRON_OZONE_PLATFORM_HINT was removed in Electron 38 (no-op since 39). +function buildLinuxHeadlessFlags(): string { + return process.platform === 'linux' ? ' --ozone-platform=headless --disable-gpu' : ''; } function buildPosixHeadlessEnvironment(paneDir: string): string { const entries = [`PANE_DIR=${quoteForPosix(paneDir)}`]; - if (process.platform === 'linux') { - entries.push('ELECTRON_OZONE_PLATFORM_HINT=headless'); - } return entries.join(' '); } diff --git a/main/src/index.ts b/main/src/index.ts index 28130624..ed8cfb9d 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -37,9 +37,12 @@ if (process.platform === 'linux') { app.commandLine.appendSwitch('gtk-version', '3'); if (launchHeadlessDaemon || launchRemoteSetup) { - // Best-effort fallback. Packaged no-display Linux launches need - // ELECTRON_OZONE_PLATFORM_HINT=headless in the parent environment. - app.commandLine.appendSwitch('ozone-platform', 'headless'); + // Ozone has already picked its platform by the time this script runs, so + // appending --ozone-platform here cannot select headless: no-display Linux + // launches must pass `--ozone-platform=headless` as argv. (Before Electron + // 38, ELECTRON_OZONE_PLATFORM_HINT=headless did that for us; the variable + // was removed in 38 and is a no-op from 39 on.) --disable-gpu is still read + // late enough to take effect here. app.commandLine.appendSwitch('disable-gpu'); } } diff --git a/packages/runpane-py/src/runpane/installers.py b/packages/runpane-py/src/runpane/installers.py index 8a152a9c..4b0880b8 100644 --- a/packages/runpane-py/src/runpane/installers.py +++ b/packages/runpane-py/src/runpane/installers.py @@ -75,12 +75,25 @@ def install_pane_artifact( def spawn_pane(executable_path: str, args: List[str]) -> int: try: - return subprocess.call([executable_path, *args], env=build_pane_daemon_environment()) + return subprocess.call( + [executable_path, *build_pane_daemon_args(args)], + env=build_pane_daemon_environment(), + ) except OSError as error: print(f"Failed to launch Pane: {error}") return 1 +def build_pane_daemon_args(args: List[str], system_name: Optional[str] = None) -> List[str]: + """Ozone resolves its platform before the app's main script runs, so headless + selection must arrive as argv. ELECTRON_OZONE_PLATFORM_HINT (below) was removed + in Electron 38 and is a no-op from 39 on; it is kept only so older Pane builds + keep working.""" + if (system_name or platform.system()).lower() != "linux": + return list(args) + return ["--ozone-platform=headless", "--disable-gpu", *args] + + def build_pane_daemon_environment( system_name: Optional[str] = None, base_environment: Optional[dict] = None, diff --git a/packages/runpane/src/installers.ts b/packages/runpane/src/installers.ts index 19a2576a..05e5f6fd 100644 --- a/packages/runpane/src/installers.ts +++ b/packages/runpane/src/installers.ts @@ -59,7 +59,7 @@ export async function installPaneArtifact( export function spawnPane(executablePath: string, args: string[]): Promise { return new Promise((resolve) => { - const child = childProcess.spawn(executablePath, args, { + const child = childProcess.spawn(executablePath, buildPaneDaemonArgs(args), { stdio: 'inherit', shell: false, env: buildPaneDaemonEnvironment() @@ -72,6 +72,18 @@ export function spawnPane(executablePath: string, args: string[]): Promise