Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/build-plugins/plain-node-entry-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const PLAIN_NODE_ENTRY_NAMES = [
'parcel-watcher-process-entry',
'computer-sidecar',
'wsl-transcript-fs-process-entry',
'orcad/orcad-local-serve-selection-entry',
...CLI_MAIN_ENTRY_NAMES
] as const

Expand Down
1 change: 1 addition & 0 deletions config/scripts/node-server-test-paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } =
'src/main/orcad/orcad-node-launcher.integration.test.ts',
'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts',
'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts',
'src/main/orcad/orcad-serve-parity.integration.test.ts',
'config/scripts/zip-extractor-command.test.mjs'
]
: []),
Expand Down
5 changes: 5 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CLI_MAIN_ENTRY_NAMES,
createPlainNodeEntryGuardPlugin
} from './config/build-plugins/plain-node-entry-guard'
import { ORCAD_LOCAL_SERVE_SELECTION_ENTRY } from './src/shared/orcad-local-serve-selection'
import packageJson from './package.json' with { type: 'json' }

const BUNDLED_MAIN_DEPENDENCIES = new Set([
Expand Down Expand Up @@ -265,6 +266,10 @@ export const electronViteConfig: UserConfig = {
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: `orca serve` runs it under ELECTRON_RUN_AS_NODE so the CLI never bundles orcad prep.
[ORCAD_LOCAL_SERVE_SELECTION_ENTRY]: resolve(
'src/main/orcad/orcad-local-serve-selection-entry.ts'
),
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
// without paying for another Electron process.
'main-thread-hang-watchdog-entry': resolve(
Expand Down
153 changes: 45 additions & 108 deletions src/cli/runtime/launch.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import { runProcessSync } from '../../shared/child-process/run-process'
import {
SERVE_UPDATE_HANDOFF_PATH_ENV,
getServeUpdateHandoffPath
} from '../../shared/serve-update-handoff'
import {
getEphemeralVmRecipeResultConnection,
parseEphemeralVmRecipeResult
} from '../../shared/ephemeral-vm-recipes'
import { getDefaultUserDataPath } from './metadata'
import { getMacAppBundlePath } from './mac-app-update-bundle'
import {
Expand All @@ -19,8 +14,10 @@ import {
superviseForegroundServe
} from './serve-update-supervisor'
import { RuntimeClientError } from './types'
import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection'
import { resolveLocalServeRuntime, serveWithOrcad } from './serve-orcad-launch'
import { waitForRecipeJson } from './serve-recipe-json'

const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000

export function launchOrcaApp(): void {
Expand Down Expand Up @@ -77,18 +74,49 @@ function spawnDetached(command: string, args: string[], options: SpawnOptions):
child.unref()
}

export function serveOrcaApp(
args: {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
recipeJson?: boolean
projectRoot?: string | null
} = {}
): Promise<number> {
export type ServeOrcaAppArgs = {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
recipeJson?: boolean
projectRoot?: string | null
}

export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
// Why synchronous unless opted in: the default path must spawn Electron exactly as before.
if (process.env[SERVE_RUNTIME_ENV] !== 'orcad') {
return serveWithElectron(executable, args)
}
if (args.recipeJson && !args.projectRoot) {
throw new RuntimeClientError('invalid_argument', 'Recipe JSON output requires --project-root.')
}
return serveWithSelectedRuntime(executable, args)
}

async function serveWithSelectedRuntime(
executable: string,
args: ServeOrcaAppArgs
): Promise<number> {
const selection = await resolveLocalServeRuntime({
executable,
appRoot: resolveAppRoot(),
userDataPath: getDefaultUserDataPath(),
usesMacUpdateHandoff: args.recipeJson !== true && getMacAppBundlePath(executable) !== null
})
if (selection.kind === 'orcad') {
process.stderr.write(`[serve] running on orcad ${selection.version}\n`)
return serveWithOrcad(selection, args, getDefaultUserDataPath(), spawnProcess)
}
if (selection.reason) {
process.stderr.write(`[serve] using Electron serve: ${selection.reason}\n`)
}
return serveWithElectron(executable, args)
}

function serveWithElectron(executable: string, args: ServeOrcaAppArgs): Promise<number> {
const childArgs = [...getExecutableAppArgs(executable)]
childArgs.push('--serve')
if (args.json) {
Expand Down Expand Up @@ -164,97 +192,6 @@ export function serveOrcaApp(
})
}

function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<number> {
return new Promise((resolve, reject) => {
let output = ''
let settled = false
const timeout = setTimeout(() => {
finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.'))
child.kill('SIGTERM')
}, 60000)
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
child.stdout?.off('data', onData)
child.off('error', onError)
child.off('close', onClose)
if (error) {
reject(error)
return
}
child.stdout?.destroy?.()
child.unref()
resolve(0)
}
const writeIgnoredRecipeStdout = (): void => {
// Why: non-readiness child stdout is untrusted and cannot be safely
// redacted, including schema-valid results with arbitrary user data.
process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`)
}
const processRecipeOutputLine = (line: string): void => {
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (!normalizedLine.trim()) {
return
}
const parsed = parseEphemeralVmRecipeResult(normalizedLine)
if (!parsed.ok) {
writeIgnoredRecipeStdout()
return
}
if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') {
writeIgnoredRecipeStdout()
return
}
process.stdout.write(`${normalizedLine.trim()}\n`)
finish()
}
const stdoutDecoder = new StringDecoder('utf8')
const onData = (chunk: Buffer | string): void => {
output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)
while (!settled) {
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
return
}
const line = output.slice(0, newlineIndex)
output = output.slice(newlineIndex + 1)
processRecipeOutputLine(line)
}
}
const onError = (error: Error): void => {
finish(error)
}
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) {
return
}
output += stdoutDecoder.end()
if (output.trim()) {
processRecipeOutputLine(output)
}
if (settled) {
return
}
finish(
new RuntimeClientError(
'runtime_serve_failed',
typeof code === 'number'
? `Orca serve exited before printing valid recipe JSON with code ${code}.`
: `Orca serve exited before printing valid recipe JSON via ${signal}.`
)
)
}
child.stdout?.on('data', onData)
child.once('error', onError)
// Why: `exit` can precede the final piped stdout data. `close` waits until
// stdio closes so a last recipe chunk is not mistaken for missing output.
child.once('close', onClose)
})
}

export function getExecutableAppArgs(executable: string): string[] {
const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
if (shouldDisableExtractedAppImageSandbox(executable)) {
Expand Down
94 changes: 94 additions & 0 deletions src/cli/runtime/serve-orcad-launch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest'
import { formatServeRuntimeSelection } from '../../shared/orcad-local-serve-selection'
import type { runProcess } from '../../shared/child-process/run-process'
import { orcadServeArgs, resolveLocalServeRuntime } from './serve-orcad-launch'

type RunProcess = typeof runProcess

function answering(stdout: string, code = 0): RunProcess {
return vi.fn<RunProcess>(async () => ({
code,
signal: null,
stdout,
stderr: '',
timedOut: false
}))
}

const options = {
executable: '/Applications/Orca.app/Contents/MacOS/Orca',
appRoot: '/Applications/Orca.app/Contents/Resources/app.asar',
userDataPath: '/Users/u/Library/Application Support/orca',
usesMacUpdateHandoff: false
}

describe('orca serve asking the app which host to run', () => {
it("runs the app's own selection entry as plain Node and reads its answer", async () => {
const selection = {
kind: 'orcad' as const,
runtime: '/rt/node',
entry: '/slot/orcad.js',
version: '1'
}
const run = answering(`noise\n${formatServeRuntimeSelection(selection)}\n`)
expect(await resolveLocalServeRuntime({ ...options, usesMacUpdateHandoff: true }, run)).toEqual(
selection
)
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
program: options.executable,
args: [
`${options.appRoot}/out/main/orcad/orcad-local-serve-selection-entry.js`,
'--user-data',
options.userDataPath,
'--app-root',
options.appRoot,
'--mac-update-handoff'
],
env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' })
})
)
})

it('serves on Electron, and says why, when the app gives no answer', async () => {
expect(await resolveLocalServeRuntime(options, answering('', 1))).toEqual({
kind: 'electron',
reason: expect.stringContaining('did not answer')
})
const failing = vi.fn<RunProcess>(async () => {
throw new Error('spawn ENOENT')
})
expect(await resolveLocalServeRuntime(options, failing)).toEqual({
kind: 'electron',
reason: expect.stringContaining('spawn ENOENT')
})
})

it('forwards every desktop serve flag, binding wide as Electron serve does', () => {
expect(
orcadServeArgs({
json: true,
port: '6768',
pairingAddress: '10.0.0.5',
noPairing: true,
mobilePairing: true,
recipeJson: true,
projectRoot: '/work/app'
})
).toEqual([
'--bind',
'0.0.0.0',
'--json',
'--port',
'6768',
'--pairing-address',
'10.0.0.5',
'--no-pairing',
'--mobile-pairing',
'--recipe-json',
'--project-root',
'/work/app'
])
expect(orcadServeArgs({})).toEqual(['--bind', '0.0.0.0'])
})
})
Loading
Loading