diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 899c393a53a..a1388ae6280 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promise import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { headlessServeOzonePlatformSwitches } from '../../shared/headless-serve-ozone-platform' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' import { getServeUpdateHandoffPath, @@ -72,6 +73,8 @@ const INVALID_SSH_RECIPE_JSON = SSH_RECIPE_JSON.replace('/workspace/repo', 'rela const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' const PROFILE = resolve('/profiles/serve') const PIN_PROFILE = `--user-data-dir=${PROFILE}` +// Linux CI runners get the X11 pin; macOS and Windows hosts get nothing. +const HOST_SERVE_OZONE_SWITCHES = headlessServeOzonePlatformSwitches(process.platform) function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { @@ -439,7 +442,7 @@ describe('serveOrcaApp', () => { expect(spawnMock).toHaveBeenCalledWith( '/Applications/Orca.app/Contents/MacOS/Orca', - [PIN_PROFILE, '--serve', '--serve-json'], + [PIN_PROFILE, '--serve', '--serve-json', ...HOST_SERVE_OZONE_SWITCHES], expect.objectContaining({ cwd: resolve(__dirname, '../../..') }) @@ -479,7 +482,8 @@ describe('serveOrcaApp', () => { '6768', '--serve-pairing-address', '100.64.1.20', - '--serve-mobile-pairing' + '--serve-mobile-pairing', + ...HOST_SERVE_OZONE_SWITCHES ], expect.objectContaining({ cwd: resolve(__dirname, '../../..') @@ -513,7 +517,8 @@ describe('serveOrcaApp', () => { '--serve', '--serve-json', '--serve-port', - '6768' + '6768', + ...HOST_SERVE_OZONE_SWITCHES ], expect.objectContaining({ cwd: resolve(__dirname, '../../..') @@ -574,7 +579,7 @@ describe('serveOrcaApp', () => { ) expect(spawnMock).toHaveBeenCalledWith( executable, - [...expectedPrefix, PIN_PROFILE, '--serve', '--serve-json'], + [...expectedPrefix, PIN_PROFILE, '--serve', '--serve-json', '--ozone-platform=x11'], // Foreground serve must share POSIX job-control signals with its CLI supervisor. expect.objectContaining({ detached: false }) ) @@ -616,7 +621,8 @@ describe('serveOrcaApp', () => { 'wss://sandbox.example.com', '--serve-recipe-json', '--serve-project-root', - '/workspace/repo' + '/workspace/repo', + ...HOST_SERVE_OZONE_SWITCHES ], expect.objectContaining({ cwd: resolve(__dirname, '../../..'), diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index 01a1c67fcb3..3a0f642fcb9 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -12,6 +12,7 @@ import { resolveLaunchUserDataPath } from './launch-user-data-path' import { foregroundServeIsolatedProfileSwitches } from '../../shared/electron-isolated-profile-switches' +import { headlessServeOzonePlatformSwitches } from '../../shared/headless-serve-ozone-platform' import { ELECTRON_NO_SANDBOX_REQUEST_ENV } from '../../shared/electron-no-sandbox-request' import { needsElectronNoSandbox } from './electron-sandbox-switch' import { getMacAppBundlePath } from './mac-app-update-bundle' @@ -225,6 +226,7 @@ function spawnElectronServe( // Why: the isolated profile has no login keychain, and the probe that discovers this blocks the // foreground process on the Keychain authorization prompt before it advertises readiness (#25453). childArgs.push(...foregroundServeIsolatedProfileSwitches()) + childArgs.push(...headlessServeOzonePlatformSwitches(process.platform)) const handoffPath = args.recipeJson !== true && getMacAppBundlePath(executable) ? getServeUpdateHandoffPath(userDataPath) diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index e12a438d321..4782063b27b 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -20,6 +20,7 @@ vi.mock('@orca/process-host', async (importOriginal) => ({ })) import { BROWSER_SIDECAR_OWNER_ENV } from '../../shared/browser-sidecar-lifetime' +import { headlessServeOzonePlatformSwitches } from '../../shared/headless-serve-ozone-platform' import { ElectronServeBrowserProcess } from './electron-serve-browser-process' import { startOrcadBrowserProvider } from './orcad-browser-startup' import { resolveOrcadBrowserProvider } from './orcad-browser-provider' @@ -205,7 +206,14 @@ describe('ElectronServeBrowserProcess start-up', () => { expect(spec.program).toBe(INSTALLED_EXECUTABLE) const args = [...(spec.args ?? [])] expect(args).toEqual( - expect.arrayContaining(['--serve', '--serve-port', '--serve-json', '--serve-no-pairing']) + expect.arrayContaining([ + '--serve', + '--serve-port', + '--serve-json', + '--serve-no-pairing', + // The sidecar is a headless serve too, so on Linux it must not inherit Wayland. + ...headlessServeOzonePlatformSwitches(process.platform) + ]) ) const port = Number(args[args.indexOf('--serve-port') + 1]) expect(Number.isInteger(port) && port > 0 && port < 65_536).toBe(true) diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index e6d4564f9dd..89427210ecb 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -14,6 +14,7 @@ import { spawnProcess } from '@orca/process-host' import { isProcessAlive } from '../../shared/serve-bundle-coordination' import { BROWSER_SIDECAR_OWNER_ENV } from '../../shared/browser-sidecar-lifetime' import { electronIsolatedProfileSwitches } from '../../shared/electron-isolated-profile-switches' +import { headlessServeOzonePlatformSwitches } from '../../shared/headless-serve-ozone-platform' import { sendOrcadSidecarRequest } from './orcad-sidecar-runtime-client' import { ElectronSidecarTabRegistry, @@ -114,6 +115,7 @@ export class ElectronServeBrowserProcess { '--serve-json', '--serve-no-pairing', ...electronIsolatedProfileSwitches(process.env), + ...headlessServeOzonePlatformSwitches(process.platform), `--user-data-dir=${userDataPath}` ], env: { ...electronServeEnvironment(userDataPath), [BROWSER_SIDECAR_OWNER_ENV]: '1' }, diff --git a/src/main/startup/cli-launch-redirect.test.ts b/src/main/startup/cli-launch-redirect.test.ts index 78f5a0bdd51..faf4ddd56b4 100644 --- a/src/main/startup/cli-launch-redirect.test.ts +++ b/src/main/startup/cli-launch-redirect.test.ts @@ -6,6 +6,8 @@ import { type CliLaunchRedirectOptions } from './cli-launch-redirect' +vi.mock('electron', () => ({ app: { commandLine: { getSwitchValue: () => '' } } })) + const COMMAND_NAMES = ['project', 'serve', 'status', 'skills', 'worktree'] const linux = { @@ -472,3 +474,38 @@ describe('CLI launch redirect: spawning', () => { expect(result).toEqual({ redirected: true, status: 1 }) }) }) + +describe('CLI launch redirect: in-process serve on Wayland (#25400)', () => { + const redirect = (argv: string[], readOzonePlatform: () => string): ReturnType => { + const execve = vi.fn() + maybeRedirectCliLaunch({ + argv, + env: { XDG_SESSION_TYPE: 'wayland' }, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + execve, + readOzonePlatform + }) + return execve + } + + it('re-execs a direct --serve onto X11 when Electron resolved Wayland', () => { + const execve = redirect([linux.execPath, '--serve', '--serve-port', '6768'], () => 'wayland') + expect(execve).toHaveBeenCalledExactlyOnceWith( + linux.execPath, + [linux.execPath, '--ozone-platform=x11', '--serve', '--serve-port', '6768'], + { XDG_SESSION_TYPE: 'wayland' } + ) + }) + + it.each([ + { name: 'a serve already on X11', argv: [linux.execPath, '--serve'], platform: 'x11' }, + { name: 'a desktop launch', argv: [linux.execPath], platform: 'wayland' } + ])('leaves $name alone', ({ argv, platform }) => { + expect(redirect(argv, () => platform)).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/startup/cli-launch-redirect.ts b/src/main/startup/cli-launch-redirect.ts index b80b5b7826f..1cf02360a1c 100644 --- a/src/main/startup/cli-launch-redirect.ts +++ b/src/main/startup/cli-launch-redirect.ts @@ -4,7 +4,8 @@ import { runProcessSync } from '@orca/process-host' import { CLI_BOOLEAN_FLAGS, findCliCommandIndex } from '../../shared/cli-argument-boundary' import { ELECTRON_NO_SANDBOX_REQUEST_ENV } from '../../shared/electron-no-sandbox-request' import { CLI_COMMAND_NAMES } from './cli-command-names' -import { VALUE_TAKING_FLAGS } from './serve-mode-argv' +import { reexecHeadlessServeOnX11IfNeeded } from './headless-serve-x11-reexec' +import { argvRequestsServeMode, VALUE_TAKING_FLAGS } from './serve-mode-argv' export type CliLaunchRedirectResult = { redirected: false } | { redirected: true; status: number } @@ -20,6 +21,8 @@ export type CliLaunchRedirectOptions = { run?: typeof runProcessSync /** Replaces this process; null when the runtime cannot, which keeps serve in-process. */ execve?: ((file: string, args: string[], env: NodeJS.ProcessEnv) => void) | null + /** The Ozone platform Electron resolved before main ran; defaults to Electron's switch. */ + readOzonePlatform?: () => string } const CLI_EARLY_EXIT_FLAGS = new Set(['--help', '-h', 'help', '--version', '-v']) @@ -59,6 +62,16 @@ export function maybeRedirectCliLaunch( if (serve && execve && exists(cliEntryPath)) { execDirectServe(execve, execPath, cliEntryPath, serve, env) } + // In-process serve (direct `--serve`, or a CLI exec that failed) never got the CLI's X11 pin. + reexecHeadlessServeOnX11IfNeeded({ + isServeMode: argvRequestsServeMode(argv), + argv, + platform, + execPath, + env, + execve: execve ?? null, + readResolvedOzonePlatform: options.readOzonePlatform + }) return { redirected: false } } if (env[REDIRECT_ATTEMPT_ENV] === '1') { diff --git a/src/main/startup/headless-serve-x11-reexec.test.ts b/src/main/startup/headless-serve-x11-reexec.test.ts new file mode 100644 index 00000000000..da14d836d9d --- /dev/null +++ b/src/main/startup/headless-serve-x11-reexec.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { headlessServeOzonePlatformSwitches } from '../../shared/headless-serve-ozone-platform' +import { reexecHeadlessServeOnX11IfNeeded } from './headless-serve-x11-reexec' + +vi.mock('electron', () => ({ app: { commandLine: { getSwitchValue: () => 'wayland' } } })) + +const EXEC_PATH = '/opt/Orca/orca-ide' + +/** + * Electron's pre-main `--ozone-platform-hint=auto` resolution, verified against Electron 43 on + * Linux: `XDG_SESSION_TYPE=wayland` alone selects Wayland, and app.commandLine changes made + * afterwards do not move it, so only argv decides. + */ +function resolveOzonePlatform(argv: readonly string[], env: NodeJS.ProcessEnv): string { + const explicit = argv.find((arg) => arg.startsWith('--ozone-platform=')) + if (explicit) { + return explicit.slice('--ozone-platform='.length) + } + const hint = env.ELECTRON_OZONE_PLATFORM_HINT ?? 'auto' + const wayland = + hint === 'wayland' || + (hint === 'auto' && (env.XDG_SESSION_TYPE === 'wayland' || Boolean(env.WAYLAND_DISPLAY))) + return wayland ? 'wayland' : 'x11' +} + +/** Boots a fake Electron and returns the platform Chromium ends up on, following any exec. */ +function bootServe(argv: string[], env: NodeJS.ProcessEnv): { platform: string; execs: number } { + let execs = 0 + let current = argv + for (;;) { + let next: string[] | null = null + reexecHeadlessServeOnX11IfNeeded({ + isServeMode: current.includes('--serve'), + readResolvedOzonePlatform: () => resolveOzonePlatform(current, env), + argv: current, + platform: 'linux', + execPath: EXEC_PATH, + env, + execve: (_file, args) => { + next = args + } + }) + if (!next || execs > 2) { + return { platform: resolveOzonePlatform(current, env), execs } + } + execs += 1 + current = next + } +} + +const SERVE_ARGS = ['--serve', '--serve-port', '6768', '--serve-no-pairing'] + +const matrix = (['wayland', 'x11', undefined] as const).flatMap((sessionType) => + ([':0', undefined] as const).flatMap((display) => + (['wayland-0', undefined] as const).flatMap((waylandDisplay) => + (['direct --serve', 'CLI-spawned'] as const).map((launch) => ({ + sessionType, + display, + waylandDisplay, + launch + })) + ) + ) +) + +describe('reexecHeadlessServeOnX11IfNeeded', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each(matrix)( + '$launch serve runs on X11 with XDG_SESSION_TYPE=$sessionType DISPLAY=$display WAYLAND_DISPLAY=$waylandDisplay', + ({ sessionType, display, waylandDisplay, launch }) => { + const env: NodeJS.ProcessEnv = { + ...(sessionType ? { XDG_SESSION_TYPE: sessionType } : {}), + ...(display ? { DISPLAY: display } : {}), + ...(waylandDisplay ? { WAYLAND_DISPLAY: waylandDisplay } : {}) + } + const argv = + launch === 'CLI-spawned' + ? [EXEC_PATH, ...SERVE_ARGS, ...headlessServeOzonePlatformSwitches('linux')] + : [EXEC_PATH, ...SERVE_ARGS] + const booted = bootServe(argv, env) + + expect(booted.platform).toBe('x11') + // The CLI path pins on the spawn, so it never pays for an exec. + expect(booted.execs).toBe( + launch === 'direct --serve' && resolveOzonePlatform(argv, env) === 'wayland' ? 1 : 0 + ) + } + ) + + it('pins an inherited ELECTRON_OZONE_PLATFORM_HINT=wayland and an argv hint', () => { + const booted = bootServe([EXEC_PATH, '--ozone-platform-hint=auto', ...SERVE_ARGS], { + ELECTRON_OZONE_PLATFORM_HINT: 'wayland' + }) + expect(booted).toEqual({ platform: 'x11', execs: 1 }) + }) + + it('keeps the pid, env and argv order, with the switch ahead of a `--` terminator', () => { + const execve = vi.fn() + const env = { XDG_SESSION_TYPE: 'wayland', HOME: '/home/orca' } + reexecHeadlessServeOnX11IfNeeded({ + isServeMode: true, + readResolvedOzonePlatform: () => 'wayland', + argv: [EXEC_PATH, '--serve', '--', '--ozone-platform=wayland'], + platform: 'linux', + execPath: EXEC_PATH, + env, + execve + }) + expect(execve).toHaveBeenCalledWith( + EXEC_PATH, + [EXEC_PATH, '--ozone-platform=x11', '--serve', '--', '--ozone-platform=wayland'], + env + ) + }) + + it("reads Electron's resolved switch by default", () => { + const execve = vi.fn() + reexecHeadlessServeOnX11IfNeeded({ + isServeMode: true, + argv: [EXEC_PATH, '--serve'], + platform: 'linux', + execPath: EXEC_PATH, + execve + }) + expect(execve).toHaveBeenCalledWith( + EXEC_PATH, + [EXEC_PATH, '--ozone-platform=x11', '--serve'], + expect.any(Object) + ) + }) + + it('leaves an explicit --ozone-platform alone', () => { + const booted = bootServe([EXEC_PATH, '--ozone-platform=wayland', ...SERVE_ARGS], { + XDG_SESSION_TYPE: 'wayland' + }) + expect(booted).toEqual({ platform: 'wayland', execs: 0 }) + }) + + it.each([ + { name: 'desktop launches', isServeMode: true, platform: 'darwin' as const }, + { name: 'non-serve Linux launches', isServeMode: false, platform: 'linux' as const } + ])('does nothing for $name', ({ isServeMode, platform }) => { + const execve = vi.fn() + reexecHeadlessServeOnX11IfNeeded({ + isServeMode, + readResolvedOzonePlatform: () => 'wayland', + argv: [EXEC_PATH], + platform, + execve + }) + expect(execve).not.toHaveBeenCalled() + }) + + it('keeps serving in-process when the runtime cannot exec', () => { + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const args = { + isServeMode: true, + readResolvedOzonePlatform: () => 'wayland', + argv: [EXEC_PATH, '--serve'], + platform: 'linux' as const + } + expect(() => reexecHeadlessServeOnX11IfNeeded({ ...args, execve: null })).not.toThrow() + reexecHeadlessServeOnX11IfNeeded({ + ...args, + execve: () => { + throw new Error('EACCES') + } + }) + expect(stderr).toHaveBeenCalledWith('[serve] Could not restart on X11: EACCES\n') + }) +}) diff --git a/src/main/startup/headless-serve-x11-reexec.ts b/src/main/startup/headless-serve-x11-reexec.ts new file mode 100644 index 00000000000..82770bcd3ce --- /dev/null +++ b/src/main/startup/headless-serve-x11-reexec.ts @@ -0,0 +1,55 @@ +import { app } from 'electron' +import { + HEADLESS_SERVE_OZONE_PLATFORM_SWITCH, + hasExplicitOzonePlatform +} from '../../shared/headless-serve-ozone-platform' + +export type HeadlessServeX11ReexecOptions = { + isServeMode: boolean + /** The platform Electron already resolved into `--ozone-platform`; read only for Linux serve. */ + readResolvedOzonePlatform?: () => string + argv?: string[] + platform?: NodeJS.Platform + execPath?: string + env?: NodeJS.ProcessEnv + /** Replaces this process; null when the runtime cannot. */ + execve: ((file: string, args: string[], env: NodeJS.ProcessEnv) => void) | null +} + +/** + * A direct `--serve` launch (no CLI to add the switch) that Chromium already put on Wayland is + * exec'd again with X11 pinned. Exec keeps the pid, so systemd and the AppImage runtime still own + * it. Returns only when no exec was needed or it failed; serve then continues as before. + */ +export function reexecHeadlessServeOnX11IfNeeded(options: HeadlessServeX11ReexecOptions): void { + const argv = options.argv ?? process.argv + if ( + !options.isServeMode || + (options.platform ?? process.platform) !== 'linux' || + // Also the loop fence: the exec'd argv carries the switch. + hasExplicitOzonePlatform(argv.slice(1)) || + !options.execve || + readOzonePlatform(options).trim().toLowerCase() !== 'wayland' + ) { + return + } + const execPath = options.execPath ?? process.execPath + try { + // Why first: a switch after a `--` terminator would be read as a positional. + options.execve( + execPath, + [execPath, HEADLESS_SERVE_OZONE_PLATFORM_SWITCH, ...argv.slice(1)], + options.env ?? process.env + ) + } catch (error) { + process.stderr.write( + `[serve] Could not restart on X11: ${error instanceof Error ? error.message : String(error)}\n` + ) + } +} + +function readOzonePlatform(options: HeadlessServeX11ReexecOptions): string { + return options.readResolvedOzonePlatform + ? options.readResolvedOzonePlatform() + : app.commandLine.getSwitchValue('ozone-platform') +} diff --git a/src/shared/headless-serve-ozone-platform.test.ts b/src/shared/headless-serve-ozone-platform.test.ts new file mode 100644 index 00000000000..d18e80a8ac5 --- /dev/null +++ b/src/shared/headless-serve-ozone-platform.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { + hasExplicitOzonePlatform, + headlessServeOzonePlatformSwitches +} from './headless-serve-ozone-platform' + +describe('headless serve Ozone platform switches', () => { + it('pins X11 only for Linux serve launches', () => { + expect(headlessServeOzonePlatformSwitches('linux')).toEqual(['--ozone-platform=x11']) + expect(headlessServeOzonePlatformSwitches('darwin')).toEqual([]) + expect(headlessServeOzonePlatformSwitches('win32')).toEqual([]) + }) + + it.each([ + { argv: ['--ozone-platform=wayland'], explicit: true }, + { argv: ['--ozone-platform', 'x11'], explicit: true }, + { argv: ['--ozone-platform-hint=auto'], explicit: false }, + { argv: ['--', '--ozone-platform=x11'], explicit: false }, + { argv: ['--serve'], explicit: false } + ])('reads $argv as explicit=$explicit', ({ argv, explicit }) => { + expect(hasExplicitOzonePlatform(argv)).toBe(explicit) + }) +}) diff --git a/src/shared/headless-serve-ozone-platform.ts b/src/shared/headless-serve-ozone-platform.ts new file mode 100644 index 00000000000..017078b479e --- /dev/null +++ b/src/shared/headless-serve-ozone-platform.ts @@ -0,0 +1,26 @@ +/** + * Headless serve renders browser panes in hidden windows on an X display (often its own Xvfb), so + * it must run Chromium on X11. Chromium picks its platform before Orca's main code runs, from + * XDG_SESSION_TYPE / WAYLAND_DISPLAY / ELECTRON_OZONE_PLATFORM_HINT, and `app.commandLine` changes + * cannot move it afterwards. An inherited `XDG_SESSION_TYPE=wayland` with no Wayland compositor + * makes it exit in platform init (#25400), so the switch must be on the launch argv. + */ +export const HEADLESS_SERVE_OZONE_PLATFORM_SWITCH = '--ozone-platform=x11' + +/** True when argv already names a platform; a `--ozone-platform-hint` is only a preference. */ +export function hasExplicitOzonePlatform(argv: readonly string[]): boolean { + for (const arg of argv) { + if (arg === '--') { + return false + } + if (arg === '--ozone-platform' || arg.startsWith('--ozone-platform=')) { + return true + } + } + return false +} + +/** Switches for an Electron serve launch on `platform`. */ +export function headlessServeOzonePlatformSwitches(platform: NodeJS.Platform): string[] { + return platform === 'linux' ? [HEADLESS_SERVE_OZONE_PLATFORM_SWITCH] : [] +}