From f601a60da84725f0d152ecd358f0a0e26f6e37e6 Mon Sep 17 00:00:00 2001 From: bhanuaravind9549 Date: Mon, 17 Aug 2026 13:37:20 -0400 Subject: [PATCH] fix(vscode): graceful Docker fallback on Intel Mac engine spawn Reject unsupported darwin-x64 local engines early and prompt to switch connectionMode to docker when spawn fails with a CPU arch mismatch. Fixes #1000 --- apps/vscode/src/engine/local/engine-local.ts | 49 ++++++++++- .../src/engine/shared/darwinX64Fallback.ts | 88 +++++++++++++++++++ .../src/engine/shared/engine-installer.ts | 26 +++++- .../vscode/src/test/darwinX64Fallback.test.ts | 37 ++++++++ 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 apps/vscode/src/engine/shared/darwinX64Fallback.ts create mode 100644 apps/vscode/src/test/darwinX64Fallback.test.ts diff --git a/apps/vscode/src/engine/local/engine-local.ts b/apps/vscode/src/engine/local/engine-local.ts index f6e249bd2..23ee7955b 100644 --- a/apps/vscode/src/engine/local/engine-local.ts +++ b/apps/vscode/src/engine/local/engine-local.ts @@ -28,6 +28,12 @@ import { EngineBackend, type StatusEmitter, type EngineInfo, type EngineBackendS import type { ConnectionMode } from '../../config'; import { getUserConfigDir } from '../config/config-migration'; import { EngineInstaller } from '../shared/engine-installer'; +import { + DARWIN_X64_UNSUPPORTED_MESSAGE, + isArchMismatchSpawnError, + isUnsupportedDarwinX64, + promptDarwinX64DockerFallback, +} from '../shared/darwinX64Fallback'; import type { ConnectionGroupConfig } from '../../config'; import { getLogger } from '../../shared/util/output'; import { icons } from '../../shared/util/icons'; @@ -92,6 +98,8 @@ export class EngineLocal extends EngineBackend { * Emits progress events during download. */ async install(versionSpec: string, _config: ConnectionGroupConfig, token?: vscode.CancellationToken): Promise { + await this.rejectIfDarwinX64Unsupported(); + this.emitStatus({ phase: 'working', message: 'Checking for updates...' }); const githubToken = await this.getGitHubToken(); @@ -125,6 +133,8 @@ export class EngineLocal extends EngineBackend { * Emits 'ready' with the URI when the engine is accepting connections. */ async start(config: ConnectionGroupConfig, token?: vscode.CancellationToken): Promise { + // rejectIfDarwinX64Unsupported runs inside install() (always called first) so + // Intel Mac users fail before "Starting server…" without a double modal. const versionSpec = config.local.engineVersion || 'latest'; // --- Phase 1: Download/Install --- @@ -156,7 +166,20 @@ export class EngineLocal extends EngineBackend { '--port=0', // Dynamic port assignment ]; - await this.spawnProcess(executablePath, args); + try { + await this.spawnProcess(executablePath, args); + } catch (error: unknown) { + if (isArchMismatchSpawnError(error)) { + this.emitStatus({ + phase: 'error', + message: DARWIN_X64_UNSUPPORTED_MESSAGE, + error: DARWIN_X64_UNSUPPORTED_MESSAGE, + }); + await promptDarwinX64DockerFallback(); + throw new Error(DARWIN_X64_UNSUPPORTED_MESSAGE); + } + throw error; + } this.logger.output(`${icons.success} Local server started on port ${this.actualPort}`); const installed = this.installer.getInstalledVersion(); @@ -260,6 +283,10 @@ export class EngineLocal extends EngineBackend { try { switch (command) { case 'install': { + if (isUnsupportedDarwinX64()) { + await promptDarwinX64DockerFallback(); + return { success: false, error: DARWIN_X64_UNSUPPORTED_MESSAGE }; + } const parentDir = getUserConfigDir(); const installer = new EngineInstaller(parentDir, 'version.local.json'); const version = (params?.version as string) || 'latest'; @@ -326,7 +353,12 @@ export class EngineLocal extends EngineBackend { processErrored = true; this.logger.output(`${icons.error} Engine exited during startup (code=${code}, signal=${signal})`); if (this.child === child) this.cleanupProcess(); - reject(new Error(`Process exited during startup: code=${code}, signal=${signal}`)); + // Exit code 126 often means the OS refused to execute the binary (wrong arch). + const exitErr = new Error(`Process exited during startup: code=${code}, signal=${signal}`); + if (code === 126) { + (exitErr as NodeJS.ErrnoException).code = 'ENOEXEC'; + } + reject(exitErr); return; } @@ -430,6 +462,19 @@ export class EngineLocal extends EngineBackend { // HELPERS // ========================================================================= + /** Blocks local engine on Intel Macs and offers a Docker fallback. */ + private async rejectIfDarwinX64Unsupported(): Promise { + if (!isUnsupportedDarwinX64()) return; + this.emitStatus({ + phase: 'error', + message: DARWIN_X64_UNSUPPORTED_MESSAGE, + error: DARWIN_X64_UNSUPPORTED_MESSAGE, + }); + this.logger.output(`${icons.error} ${DARWIN_X64_UNSUPPORTED_MESSAGE}`); + await promptDarwinX64DockerFallback(); + throw new Error(DARWIN_X64_UNSUPPORTED_MESSAGE); + } + /** Gets existing GitHub session token (no prompt). */ private async getGitHubToken(): Promise { try { diff --git a/apps/vscode/src/engine/shared/darwinX64Fallback.ts b/apps/vscode/src/engine/shared/darwinX64Fallback.ts new file mode 100644 index 000000000..07d909b4d --- /dev/null +++ b/apps/vscode/src/engine/shared/darwinX64Fallback.ts @@ -0,0 +1,88 @@ +// ============================================================================= +// MIT License +// Copyright (c) 2026 Aparavi Software AG +// ============================================================================= + +/** + * darwinX64Fallback.ts — Intel Mac (darwin-x64) local-engine guardrails. + * + * No native darwin-x64 engine binary is published. Intel Mac users must run + * the engine via Docker. These helpers detect the unsupported platform and + * spawn-time arch mismatches (ENOEXEC / EBADARCH / "bad CPU type"), and offer + * a one-click switch to Docker connection mode. + * + * Pure detection helpers stay free of vscode imports so unit tests can run + * under node:test without the extension host. + */ + +/** User-facing explanation when local engine cannot run on Intel macOS. */ +export const DARWIN_X64_UNSUPPORTED_MESSAGE = + 'Local engine is not supported on Intel Macs (no darwin-x64 build is published). ' + + 'Switch rocketride.development.connectionMode to "docker" to run the engine in Docker Desktop.'; + +const SWITCH_TO_DOCKER = 'Switch to Docker'; +const OPEN_SETTINGS = 'Open Settings'; + +/** + * True when the host is macOS on x64 (Intel). Accepts overrides for tests. + */ +export function isUnsupportedDarwinX64( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): boolean { + return platform === 'darwin' && arch === 'x64'; +} + +/** + * True when a spawn/exec failure indicates a CPU architecture mismatch + * (typical when an arm64 Mach-O binary is launched on Intel macOS). + */ +export function isArchMismatchSpawnError(error: unknown): boolean { + if (error == null) return false; + + const code = typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code ?? '') + : ''; + if (code === 'ENOEXEC' || code === 'EBADARCH') return true; + + const message = error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : String(error); + + const lower = message.toLowerCase(); + return lower.includes('bad cpu type') + || lower.includes('ebadarch') + || lower.includes('enoexec'); +} + +/** + * Shows an actionable modal so the user can switch to Docker or open settings. + * Does not throw — callers should throw/reject after awaiting this. + * + * vscode / ConfigManager are loaded lazily so pure helpers remain unit-testable. + */ +export async function promptDarwinX64DockerFallback( + message: string = DARWIN_X64_UNSUPPORTED_MESSAGE, +): Promise { + const vscode = await import('vscode'); + const { ConfigManager } = await import('../../config'); + + const choice = await vscode.window.showErrorMessage( + message, + { + modal: true, + detail: 'Apple Silicon (darwin-arm64) binaries cannot run on Intel CPUs. Docker Desktop runs the published linux/amd64 engine image natively on Intel Macs.', + }, + SWITCH_TO_DOCKER, + OPEN_SETTINGS, + ); + + const config = ConfigManager.getInstance(); + if (choice === SWITCH_TO_DOCKER) { + await config.updateConnectionMode('development', 'docker'); + } else if (choice === OPEN_SETTINGS) { + await config.openSettings(); + } +} diff --git a/apps/vscode/src/engine/shared/engine-installer.ts b/apps/vscode/src/engine/shared/engine-installer.ts index 2f300e6a1..99c055e61 100644 --- a/apps/vscode/src/engine/shared/engine-installer.ts +++ b/apps/vscode/src/engine/shared/engine-installer.ts @@ -37,6 +37,10 @@ import * as lockfile from 'proper-lockfile'; import { execFile, execFileSync } from 'child_process'; import { getLogger } from '../../shared/util/output'; import { icons } from '../../shared/util/icons'; +import { + DARWIN_X64_UNSUPPORTED_MESSAGE, + isUnsupportedDarwinX64, +} from './darwinX64Fallback'; // ============================================================================= // TYPES @@ -212,6 +216,12 @@ export class EngineInstaller { token?: vscode.CancellationToken, githubToken?: string ): Promise { + // No darwin-x64 engine asset is published — fail before lock/download. + if (isUnsupportedDarwinX64()) { + this.logger.output(`${icons.error} ${DARWIN_X64_UNSUPPORTED_MESSAGE}`); + throw new Error(DARWIN_X64_UNSUPPORTED_MESSAGE); + } + const displaySpec = versionSpec.replace(/^server-/, ''); this.logger.output(`${icons.info} Engine version requested: ${displaySpec}`); @@ -568,12 +578,17 @@ export class EngineInstaller { if (platform === 'win32') return { name: 'win64', ext: 'zip' }; if (platform === 'darwin') { - const darwinArch = arch === 'arm64' ? 'arm64' : 'x64'; - return { name: `darwin-${darwinArch}`, ext: 'tar.gz' }; + if (arch === 'x64') { + throw new Error(DARWIN_X64_UNSUPPORTED_MESSAGE); + } + if (arch !== 'arm64') { + throw new Error(`Unsupported platform: ${platform} ${arch}. Supported: Windows (x64), macOS (ARM64), Linux (x64). Use Docker on Intel Macs.`); + } + return { name: 'darwin-arm64', ext: 'tar.gz' }; } if (platform === 'linux') return { name: 'linux-x64', ext: 'tar.gz' }; - throw new Error(`Unsupported platform: ${platform} ${arch}. Supported: Windows (x64), macOS (x64/ARM64), Linux (x64).`); + throw new Error(`Unsupported platform: ${platform} ${arch}. Supported: Windows (x64), macOS (ARM64), Linux (x64).`); } /** Finds the matching asset for this platform in a release. */ @@ -586,7 +601,10 @@ export class EngineInstaller { if (!asset) { const available = release.assets.map(a => a.name).join(', '); - throw new Error(`No release asset found for this platform (expected: *${suffix}). Available: ${available}`); + throw new Error( + `No release asset found for this platform (expected: *${suffix}). ` + + `Available: ${available}. On Intel Macs, use Docker (rocketride.development.connectionMode = "docker").` + ); } return asset; diff --git a/apps/vscode/src/test/darwinX64Fallback.test.ts b/apps/vscode/src/test/darwinX64Fallback.test.ts new file mode 100644 index 000000000..b540a14c2 --- /dev/null +++ b/apps/vscode/src/test/darwinX64Fallback.test.ts @@ -0,0 +1,37 @@ +// ============================================================================= +// MIT License +// Copyright (c) 2026 Aparavi Software AG +// ============================================================================= + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DARWIN_X64_UNSUPPORTED_MESSAGE, + isArchMismatchSpawnError, + isUnsupportedDarwinX64, +} from '../engine/shared/darwinX64Fallback'; + +test('isUnsupportedDarwinX64 is true only for darwin + x64', () => { + assert.equal(isUnsupportedDarwinX64('darwin', 'x64'), true); + assert.equal(isUnsupportedDarwinX64('darwin', 'arm64'), false); + assert.equal(isUnsupportedDarwinX64('linux', 'x64'), false); + assert.equal(isUnsupportedDarwinX64('win32', 'x64'), false); +}); + +test('isArchMismatchSpawnError detects ENOEXEC and EBADARCH codes', () => { + assert.equal(isArchMismatchSpawnError(Object.assign(new Error('spawn failed'), { code: 'ENOEXEC' })), true); + assert.equal(isArchMismatchSpawnError(Object.assign(new Error('spawn failed'), { code: 'EBADARCH' })), true); + assert.equal(isArchMismatchSpawnError(Object.assign(new Error('spawn failed'), { code: 'ENOENT' })), false); +}); + +test('isArchMismatchSpawnError detects bad CPU type messages', () => { + assert.equal(isArchMismatchSpawnError(new Error('bad CPU type in executable')), true); + assert.equal(isArchMismatchSpawnError('posix_spawn: Bad CPU type in executable'), true); + assert.equal(isArchMismatchSpawnError(new Error('connection refused')), false); + assert.equal(isArchMismatchSpawnError(null), false); +}); + +test('DARWIN_X64_UNSUPPORTED_MESSAGE points users at Docker connection mode', () => { + assert.match(DARWIN_X64_UNSUPPORTED_MESSAGE, /docker/i); + assert.match(DARWIN_X64_UNSUPPORTED_MESSAGE, /darwin-x64/i); +});