mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
fix(linux): report a missing display instead of dying in uv_close
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user