fix(linux): report a missing display instead of dying in uv_close

This commit is contained in:
Neil
2026-09-01 03:25:53 -07:00
parent 4f90ecf001
commit 8d108d3fe8
10 changed files with 334 additions and 41 deletions
+12 -1
View File
@@ -1,4 +1,4 @@
const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
@@ -268,6 +268,7 @@ module.exports = {
}
writeMacBuildCompatibility(resourcesDir, { version, commit, architecture })
}
stampPackagedCliVersion(resourcesDir, context.packager.appInfo.version)
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
@@ -561,6 +562,16 @@ module.exports = {
}
}
// Stamp the effective channel version where node-mode CLI code can read it.
function stampPackagedCliVersion(resourcesDir, version) {
const packageJsonPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json')
if (!existsSync(packageJsonPath)) {
throw new Error(`Missing unpacked CLI package boundary: ${packageJsonPath}`)
}
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
writeFileSync(packageJsonPath, `${JSON.stringify({ ...packageJson, version }, null, 2)}\n`)
}
function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) {
if (electronPlatformName === 'win32') {
return
@@ -619,6 +619,11 @@ describe('electron-builder config', () => {
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
'utf8'
)
await writeFile(
join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'),
`${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`,
'utf8'
)
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
@@ -637,10 +642,14 @@ describe('electron-builder config', () => {
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux',
arch: 1
arch: 1,
packager: { appInfo: { version: '9.9.9' } }
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
await expect(
readFile(join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), 'utf8')
).resolves.toContain('"version": "9.9.9"')
} finally {
await rm(root, { recursive: true, force: true })
}
+9 -10
View File
@@ -5,15 +5,14 @@ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'nod
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const OUT_COMMONJS_PACKAGE_JSON = `${JSON.stringify(
{
name: 'orca-compiled-output',
type: 'commonjs',
private: true
},
null,
2
)}\n`
// Electron packaging restamps the channel-specific version after compilation.
function buildOutPackageJson(version) {
return `${JSON.stringify(
{ name: 'orca-compiled-output', type: 'commonjs', private: true, version },
null,
2
)}\n`
}
/**
* Verifies the published CLI entrypoint and the module-type boundary for the
@@ -49,7 +48,7 @@ export function verifyPackageCliBin({
const outPackageJsonPath = path.join(projectDir, 'out', 'package.json')
if (fixPackageJson) {
mkdirSync(path.dirname(outPackageJsonPath), { recursive: true })
writeFileSync(outPackageJsonPath, OUT_COMMONJS_PACKAGE_JSON, 'utf8')
writeFileSync(outPackageJsonPath, buildOutPackageJson(packageJson.version), 'utf8')
}
let outPackageJson
try {
+7 -5
View File
@@ -392,11 +392,13 @@ Rolling back is the case that needs care — see [Roll back](#roll-back).
### Record the version you deploy
Orca has no headless version command: there is no `--version` flag or `version`
subcommand, and `orca serve` prints only its endpoint. Choose a release tag
explicitly instead of following the `latest` URL, and record it next to the
binary so upgrades are auditable. The steps below keep that record in
`/opt/orca/VERSION`.
The bundled CLI launcher prints the Orca build with `orca-ide --version`. For an
extracted deployment, that launcher is
`squashfs-root/resources/bin/orca-ide`; deb/rpm installs and CLI registration put
it on `PATH`. Do not use `orca-linux.AppImage --version` for this audit because
Electron owns the direct binary's version flags and may report its own runtime
version. For an AppImage service, choose a release tag explicitly and record it
next to the binary. The steps below keep that record in `/opt/orca/VERSION`.
### Upgrade steps
+36
View File
@@ -0,0 +1,36 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { readOrcaCliVersion } from './cli-version'
const temporaryDirectories: string[] = []
afterEach(() =>
Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true })))
)
describe('CLI version', () => {
it('reads the package boundary beside the compiled CLI', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-cli-version-'))
const runtimeDir = join(root, 'cli')
temporaryDirectories.push(root)
await mkdir(runtimeDir)
await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.4.178-rc.2' }))
expect(readOrcaCliVersion(runtimeDir)).toBe('1.4.178-rc.2')
})
it('rejects missing, malformed, and non-string versions', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-cli-version-invalid-'))
const runtimeDir = join(root, 'cli')
temporaryDirectories.push(root)
await mkdir(runtimeDir)
expect(readOrcaCliVersion(runtimeDir)).toBeNull()
await writeFile(join(root, 'package.json'), '{')
expect(readOrcaCliVersion(runtimeDir)).toBeNull()
await writeFile(join(root, 'package.json'), JSON.stringify({ version: 178 }))
expect(readOrcaCliVersion(runtimeDir)).toBeNull()
})
})
+14
View File
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
// Node-mode CLI code cannot read the package metadata inside app.asar.
export function readOrcaCliVersion(runtimeDir = __dirname): string | null {
try {
const parsed = JSON.parse(readFileSync(join(runtimeDir, '..', 'package.json'), 'utf8')) as {
version?: unknown
}
return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null
} catch {
return null
}
}
+12
View File
@@ -8,6 +8,7 @@ import {
specPaths,
validateCommandAndFlags
} from './args'
import { readOrcaCliVersion } from './cli-version'
import { dispatch } from './dispatch'
import {
assertEnvironmentSelectorResolvable,
@@ -59,6 +60,17 @@ export async function main(
argv = process.argv.slice(2),
cwd = resolveInvocationCwd()
): Promise<void> {
// Why: version audits use the bundled launcher; Electron intercepts direct binary version flags.
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
const version = readOrcaCliVersion()
if (!version) {
process.stderr.write('Could not determine the Orca version for this build.\n')
process.exitCode = 1
return
}
process.stdout.write(`${version}\n`)
return
}
if (argv[0] === 'agent-teams-tmux') {
await runAgentTeamsTmuxShim(argv.slice(1))
return
+10 -1
View File
@@ -169,7 +169,11 @@ import {
} from './startup/main-process-error-guards'
import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom'
import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv'
import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display'
import {
ensureVirtualDisplayForHeadlessServe,
hasUsableLinuxDisplay,
MISSING_LINUX_DISPLAY_MESSAGE
} from './startup/ensure-virtual-display'
import {
clearGpuFallbackMarker,
readActiveGpuFallbackMarker,
@@ -548,6 +552,11 @@ if (argvRequestsServeMode(process.argv)) {
process.argv = normalizeServeModeArgv(process.argv)
}
const isServeMode = process.argv.includes('--serve')
// Fail before Chromium's missing-display teardown can segfault (#13719).
if (app.isPackaged && !isServeMode && !hasUsableLinuxDisplay()) {
process.stderr.write(`${MISSING_LINUX_DISPLAY_MESSAGE}\n`)
app.exit(1)
}
function updateGpuAccelerationAboutPanel(): void {
app.setAboutPanelOptions(
+149 -16
View File
@@ -1,24 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { spawnMock, spawnSyncMock, existsSyncMock, readFileSyncMock, rmSyncMock, appMock } =
vi.hoisted(() => ({
spawnMock: vi.fn(),
spawnSyncMock: vi.fn(),
existsSyncMock: vi.fn(),
readFileSyncMock: vi.fn(),
rmSyncMock: vi.fn(),
appMock: {
disableHardwareAcceleration: vi.fn(),
commandLine: { appendSwitch: vi.fn() },
once: vi.fn()
}
}))
const {
spawnMock,
spawnSyncMock,
existsSyncMock,
readFileSyncMock,
rmSyncMock,
statSyncMock,
appMock
} = vi.hoisted(() => ({
spawnMock: vi.fn(),
spawnSyncMock: vi.fn(),
existsSyncMock: vi.fn(),
readFileSyncMock: vi.fn(),
rmSyncMock: vi.fn(),
statSyncMock: vi.fn(),
appMock: {
disableHardwareAcceleration: vi.fn(),
commandLine: { appendSwitch: vi.fn(), getSwitchValue: vi.fn() },
once: vi.fn()
}
}))
vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: spawnSyncMock }))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
rmSync: rmSyncMock
rmSync: rmSyncMock,
statSync: statSyncMock
}))
vi.mock('electron', () => ({ app: appMock }))
@@ -29,6 +38,13 @@ function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
function mockLiveXDisplay(pid = 4321): void {
statSyncMock.mockReturnValue({ isSocket: () => true })
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(`${pid}\n`)
vi.spyOn(process, 'kill').mockImplementation(() => true)
}
describe('ensureVirtualDisplayForHeadlessServe', () => {
beforeEach(() => {
spawnMock.mockReset()
@@ -36,8 +52,10 @@ describe('ensureVirtualDisplayForHeadlessServe', () => {
existsSyncMock.mockReset()
readFileSyncMock.mockReset()
rmSyncMock.mockReset()
statSyncMock.mockReset()
appMock.disableHardwareAcceleration.mockReset()
appMock.commandLine.appendSwitch.mockReset()
appMock.commandLine.getSwitchValue.mockReset().mockReturnValue('')
appMock.once.mockReset()
delete process.env.DISPLAY
})
@@ -76,6 +94,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => {
it('reuses an externally provided DISPLAY without starting Xvfb', async () => {
setPlatform('linux')
process.env.DISPLAY = ':0'
mockLiveXDisplay()
const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display')
expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true)
@@ -89,14 +108,20 @@ describe('ensureVirtualDisplayForHeadlessServe', () => {
it('reports unsupported (no spawn) when Xvfb is not installed', async () => {
setPlatform('linux')
spawnSyncMock.mockReturnValue({ status: 1 }) // `which Xvfb` fails
const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display')
const { ensureVirtualDisplayForHeadlessServe, MISSING_LINUX_DISPLAY_MESSAGE } =
await import('./ensure-virtual-display')
expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false)
expect(spawnMock).not.toHaveBeenCalled()
expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('endpoint is unavailable')
expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('XDG_RUNTIME_DIR')
expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('`xvfb` on Debian/Ubuntu')
expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('`xorg-x11-server-Xvfb`')
})
it('starts Xvfb and switches to software rendering when none exists', async () => {
it('starts Xvfb when the configured local display is stale', async () => {
setPlatform('linux')
process.env.DISPLAY = ':77'
spawnSyncMock.mockReturnValue({ status: 0 }) // `which Xvfb` succeeds
// First existsSync (stale-socket check) false; later (socket-ready poll) true.
existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true)
@@ -163,4 +188,112 @@ describe('ensureVirtualDisplayForHeadlessServe', () => {
expect(process.env.DISPLAY).toBe(':99')
killSpy.mockRestore()
})
describe('hasUsableLinuxDisplay', () => {
it('accepts live local X11 and Wayland sockets', async () => {
setPlatform('linux')
mockLiveXDisplay()
const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display')
expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true)
expect(
hasUsableLinuxDisplay({
WAYLAND_DISPLAY: 'wayland-0',
XDG_RUNTIME_DIR: '/run/user/1000'
})
).toBe(true)
expect(statSyncMock).toHaveBeenCalledWith('/tmp/.X11-unix/X0')
expect(statSyncMock).toHaveBeenCalledWith('/run/user/1000/wayland-0')
})
it('rejects an orphaned local X11 socket without a live server lock', async () => {
setPlatform('linux')
statSyncMock.mockReturnValue({ isSocket: () => true })
existsSyncMock.mockReturnValue(false)
const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display')
expect(hasUsableLinuxDisplay({ DISPLAY: ':77' })).toBe(false)
expect(existsSyncMock).toHaveBeenCalledWith('/tmp/.X77-lock')
})
it('accepts a live local X11 server owned by another user', async () => {
setPlatform('linux')
statSyncMock.mockReturnValue({ isSocket: () => true })
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue('4321\n')
vi.spyOn(process, 'kill').mockImplementation(() => {
throw Object.assign(new Error('not permitted'), { code: 'EPERM' })
})
const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display')
expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true)
})
it('rejects absent, blank, and stale local displays', async () => {
setPlatform('linux')
statSyncMock.mockImplementation(() => {
throw new Error('ENOENT')
})
const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display')
expect(hasUsableLinuxDisplay({})).toBe(false)
expect(hasUsableLinuxDisplay({ DISPLAY: ' ', WAYLAND_DISPLAY: '' })).toBe(false)
expect(hasUsableLinuxDisplay({ DISPLAY: ':77' })).toBe(false)
expect(
hasUsableLinuxDisplay({ WAYLAND_DISPLAY: 'wayland-0', XDG_RUNTIME_DIR: '/run/user/1000' })
).toBe(false)
expect(hasUsableLinuxDisplay({ WAYLAND_DISPLAY: 'wayland-0' })).toBe(false)
})
it.each([
['localhost:10.0', true],
['build-host.example:1', true],
['[2001:db8::1]:2.0', true],
['tcp/build-host.example:3', true],
['garbage', false],
['build host:1', false],
['build-host.example:', false],
['build-host.example:abc', false]
])('validates remote X display syntax for %s', async (display, expected) => {
setPlatform('linux')
const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display')
expect(hasUsableLinuxDisplay({ DISPLAY: display })).toBe(expected)
expect(statSyncMock).not.toHaveBeenCalled()
})
it('honors forced X11 and Wayland platform selection', async () => {
setPlatform('linux')
statSyncMock.mockImplementation((path: string) => ({
isSocket: () => path === '/run/user/1000/wayland-0'
}))
const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display')
const env = {
DISPLAY: ':77',
WAYLAND_DISPLAY: 'wayland-0',
XDG_RUNTIME_DIR: '/run/user/1000'
}
appMock.commandLine.getSwitchValue.mockReturnValue('x11')
expect(hasUsableLinuxDisplay(env)).toBe(false)
appMock.commandLine.getSwitchValue.mockReturnValue('wayland')
expect(hasUsableLinuxDisplay(env)).toBe(true)
statSyncMock.mockImplementation((path: string) => ({
isSocket: () => path === '/tmp/.X11-unix/X0'
}))
expect(hasUsableLinuxDisplay({ ...env, DISPLAY: ':0' })).toBe(false)
appMock.commandLine.getSwitchValue.mockReturnValue('')
expect(hasUsableLinuxDisplay({ ...env, ELECTRON_OZONE_PLATFORM_HINT: 'x11' })).toBe(false)
})
it('never gates a non-Linux platform', async () => {
setPlatform('darwin')
const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display')
expect(hasUsableLinuxDisplay({})).toBe(true)
expect(statSyncMock).not.toHaveBeenCalled()
})
})
})
+75 -7
View File
@@ -1,5 +1,6 @@
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'
import { isAbsolute, join } from 'node:path'
import { app } from 'electron'
// Why: headless `orca serve` backs browser panes with offscreen BrowserWindows.
@@ -12,6 +13,8 @@ const XVFB_STARTUP_TIMEOUT_MS = 5_000
const XVFB_POLL_INTERVAL_MS = 50
const VIRTUAL_DISPLAY_NUMBER = 99
const VIRTUAL_DISPLAY = `:${VIRTUAL_DISPLAY_NUMBER}`
const XVFB_INSTALL_GUIDANCE =
'Install `xvfb` on Debian/Ubuntu or `xorg-x11-server-Xvfb` on RPM-based systems.'
let xvfbProcess: ChildProcess | null = null
@@ -54,8 +57,8 @@ function isDisplayServerAlive(displayNumber: number): boolean {
// signal 0 probes existence without affecting the process.
process.kill(pid, 0)
return true
} catch {
return false
} catch (error) {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EPERM'
}
}
@@ -95,6 +98,72 @@ function waitForDisplaySocket(displayNumber: number, deadline: number): boolean
return existsSync(socket)
}
// Validate display syntax and local sockets before Chromium reaches Ozone initialization.
export function hasUsableLinuxDisplay(env: NodeJS.ProcessEnv = process.env): boolean {
if (process.platform !== 'linux') {
return true
}
const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform').trim().toLowerCase()
const ozonePlatformHint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase()
const selectedPlatform =
ozonePlatform === 'x11' || ozonePlatform === 'wayland'
? ozonePlatform
: ozonePlatformHint === 'x11' || ozonePlatformHint === 'wayland'
? ozonePlatformHint
: null
if (selectedPlatform === 'x11') {
return hasUsableXDisplay(env.DISPLAY)
}
if (selectedPlatform === 'wayland') {
return hasUsableWaylandDisplay(env)
}
return hasUsableXDisplay(env.DISPLAY) || hasUsableWaylandDisplay(env)
}
export const MISSING_LINUX_DISPLAY_MESSAGE = [
'Orca needs a usable display server, but the selected X11 or Wayland endpoint is unavailable.',
'Check DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR, and any --ozone-platform override.',
`Use \`orca-ide serve\` to run headless. On a bare server, ${XVFB_INSTALL_GUIDANCE}`
].join('\n')
function isUnixSocket(path: string): boolean {
try {
return statSync(path).isSocket()
} catch {
return false
}
}
function hasUsableXDisplay(value: string | undefined): boolean {
const display = value?.trim()
if (!display) {
return false
}
const localDisplay = /^(?:unix\/?)?:(\d+)(?:\.\d+)?$/i.exec(display)
// Remote endpoints cannot be proven with local socket checks.
if (!localDisplay) {
return /^\S+:\d+(?:\.\d+)?$/.test(display)
}
const displayNumber = Number(localDisplay[1])
return isUnixSocket(xvfbSocketPath(displayNumber)) && isDisplayServerAlive(displayNumber)
}
function hasUsableWaylandDisplay(env: NodeJS.ProcessEnv): boolean {
const display = env.WAYLAND_DISPLAY?.trim()
if (!display) {
return false
}
if (isAbsolute(display)) {
return isUnixSocket(display)
}
const runtimeDir = env.XDG_RUNTIME_DIR?.trim()
return Boolean(runtimeDir && isAbsolute(runtimeDir) && isUnixSocket(join(runtimeDir, display)))
}
/**
* Ensure a usable X display for headless Linux serve. Returns true when a
* display is available (pre-existing or freshly started), false when browser
@@ -107,16 +176,15 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo
configureHeadlessServeChromiumFlags()
// Why: respect an externally provided display (a real X server, or the image
// already running its own Xvfb). Don't start a competing one.
if (process.env.DISPLAY && process.env.DISPLAY.trim().length > 0) {
// Offscreen serve windows require X11; Wayland alone still needs Xvfb.
if (hasUsableXDisplay(process.env.DISPLAY)) {
return true
}
if (!hasXvfbBinary()) {
console.warn(
'[serve] Xvfb not found; browser panes are unavailable on this headless Linux host. ' +
'Install Xvfb (e.g. `apt-get install xvfb`) or set DISPLAY to enable them.'
`${XVFB_INSTALL_GUIDANCE} Set DISPLAY to enable them with an existing X server.`
)
return false
}