Skip to content
Open
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
49 changes: 47 additions & 2 deletions apps/vscode/src/engine/local/engine-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -92,6 +98,8 @@ export class EngineLocal extends EngineBackend {
* Emits progress events during download.
*/
async install(versionSpec: string, _config: ConnectionGroupConfig, token?: vscode.CancellationToken): Promise<void> {
await this.rejectIfDarwinX64Unsupported();

this.emitStatus({ phase: 'working', message: 'Checking for updates...' });

const githubToken = await this.getGitHubToken();
Expand Down Expand Up @@ -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<void> {
// 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 ---
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<void> {
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<string | undefined> {
try {
Expand Down
88 changes: 88 additions & 0 deletions apps/vscode/src/engine/shared/darwinX64Fallback.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
}
}
26 changes: 22 additions & 4 deletions apps/vscode/src/engine/shared/engine-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -212,6 +216,12 @@ export class EngineInstaller {
token?: vscode.CancellationToken,
githubToken?: string
): Promise<string> {
// 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}`);

Expand Down Expand Up @@ -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. */
Expand All @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions apps/vscode/src/test/darwinX64Fallback.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading