diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 33870b3faa5..1796b309532 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -51,7 +51,18 @@ function readRuntimeArg() { function ensureNodeRuntime() { const initial = runNodeCheck() - if (initial.ok) { + const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason() + if (initial.ok && !patchedNodePtyRebuildReason) { + return + } + + if (patchedNodePtyRebuildReason) { + console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`) + if (!initial.ok) { + printCheckError(initial) + } + runPnpm(['rebuild', 'node-pty']) + verifyNodeRuntimeAfterRebuild() return } @@ -61,7 +72,10 @@ function ensureNodeRuntime() { ) printCheckError(initial) runPnpm(['rebuild', ...failedModules]) + verifyNodeRuntimeAfterRebuild() +} +function verifyNodeRuntimeAfterRebuild() { const final = runNodeCheck() if (!final.ok) { console.error( @@ -74,14 +88,22 @@ function ensureNodeRuntime() { function ensureElectronRuntime() { const initial = runElectronCheck() - if (initial.ok) { + const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason() + if (initial.ok && !patchedNodePtyRebuildReason) { return } - console.warn( - `[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.` - ) - printCheckError(initial) + if (patchedNodePtyRebuildReason) { + console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`) + if (!initial.ok) { + printCheckError(initial) + } + } else { + console.warn( + `[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.` + ) + printCheckError(initial) + } runNodeScript(['config/scripts/rebuild-native-deps.mjs']) const final = runElectronCheck() @@ -234,7 +256,12 @@ function loadNodePtyNativeModule() { const nativeName = getNodePtyNativeModuleName() // Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a // terminal is created, so require('node-pty') alone can miss ABI mismatches. - loadNativeModule(nativeName) + const native = loadNativeModule(nativeName) + if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native.dir)) { + throw new Error( + `node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active` + ) + } } function getNodePtyNativeModuleName() { @@ -245,6 +272,43 @@ function getNodePtyNativeModuleName() { return getWindowsBuildNumber() >= 18309 ? 'conpty' : 'pty' } +function getPatchedNodePtyRebuildReason() { + if (!requiresPatchedNodePtySourceBuild()) { + return null + } + + // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix + // patch only lands in the source-built build/Release artifacts. + const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty') + const missingArtifact = [ + resolve(nodePtyDir, 'build', 'Release', 'pty.node'), + resolve(nodePtyDir, 'build', 'Release', 'spawn-helper') + ].find((artifactPath) => !existsSync(artifactPath)) + + if (!missingArtifact) { + return null + } + + return 'Patched node-pty build artifacts are missing; rebuilding native deps.' +} + +function requiresPatchedNodePtySourceBuild() { + if (process.platform === 'win32') { + return false + } + + const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch') + if (!existsSync(nodePtyPatchPath)) { + return false + } + + return existsSync(resolve(projectDir, 'node_modules', 'node-pty')) +} + +function isNodePtyReleaseBuildDir(nativeDir) { + return typeof nativeDir === 'string' && nativeDir.replace(/\\/g, '/').includes('build/Release/') +} + function getWindowsBuildNumber() { const match = /(\d+)\.(\d+)\.(\d+)/g.exec(release()) return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0 diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 99e1a3760f9..d5d8396aeea 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -48,6 +48,75 @@ describe('ensure-native-runtime', () => { rmSync(projectDir, { recursive: true, force: true }) } }) + + it.skipIf(process.platform === 'win32')( + 'rebuilds patched node-pty artifacts even when the Node load check passes', + () => { + const projectDir = mkTempProject() + + try { + const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') + const logPath = join(projectDir, 'native-runtime.log') + const markerPath = join(projectDir, 'rebuilt.marker') + const binDir = join(projectDir, 'bin') + copyFileSync(sourceScriptPath, scriptPath) + writeLoadableNativeModules(projectDir) + writeNodePtyPatchFile(projectDir) + writeFakePnpm(binDir) + + const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { + cwd: projectDir, + encoding: 'utf8', + env: envWithPrependedPath(binDir, { + ORCA_NATIVE_TEST_LOG: logPath, + ORCA_NATIVE_TEST_MARKER: markerPath + }) + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stderr).toContain( + 'Patched node-pty build artifacts are missing; rebuilding native deps.' + ) + expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'rebuilds when patched artifacts exist but node-pty resolves to prebuilds', + () => { + const projectDir = mkTempProject() + + try { + const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') + const logPath = join(projectDir, 'native-runtime.log') + const markerPath = join(projectDir, 'rebuilt.marker') + const binDir = join(projectDir, 'bin') + copyFileSync(sourceScriptPath, scriptPath) + writeLoadableNativeModules(projectDir) + writeNodePtyPatchFile(projectDir) + writePatchedNodePtyBuildArtifacts(projectDir) + writeFakePnpm(binDir) + + const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { + cwd: projectDir, + encoding: 'utf8', + env: envWithPrependedPath(binDir, { + ORCA_NATIVE_TEST_LOG: logPath, + ORCA_NATIVE_TEST_MARKER: markerPath + }) + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active") + expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) }) function mkTempProject() { @@ -92,6 +161,38 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { ) } +function writeLoadableNativeModules(projectDir) { + const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') + mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) + + writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') + writeFileSync( + join(nodePtyDir, 'lib', 'utils.js'), + ` +const { appendFileSync, existsSync } = require('node:fs') + +exports.loadNativeModule = function loadNativeModule(nativeName) { + const rebuilt = existsSync(process.env.ORCA_NATIVE_TEST_MARKER) + const dir = rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/' + appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`) + return { dir, module: {} } +} +` + ) +} + +function writeNodePtyPatchFile(projectDir) { + mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) + writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') +} + +function writePatchedNodePtyBuildArtifacts(projectDir) { + const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(buildDir, { recursive: true }) + writeFileSync(join(buildDir, 'pty.node'), '') + writeFileSync(join(buildDir, 'spawn-helper'), '') +} + function writeFakePnpm(binDir) { mkdirSync(binDir, { recursive: true }) const shimPath = join(binDir, 'pnpm-shim.cjs') diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 715d65f7549..a56c45bc05f 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -59,7 +59,11 @@ const forceRebuild = ensureElectronPackageInstalled() -if (!forceRebuild) { +const patchedNodePtyRebuildReason = forceRebuild ? null : getPatchedNodePtyRebuildReason() + +if (patchedNodePtyRebuildReason) { + console.log(`[rebuild] ${patchedNodePtyRebuildReason}`) +} else if (!forceRebuild) { // Why: Windows cannot unlink a loaded .node DLL, so avoid @electron/rebuild // when the current install already works with Electron's ABI. const probe = probeElectronNativeModules(onlyModules) @@ -337,6 +341,45 @@ function getElectronExecutablePath() { : resolve(electronPackageDir, 'dist', platformPath) } +function getPatchedNodePtyRebuildReason() { + if (!requiresPatchedNodePtySourceBuild()) { + return null + } + + // Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can + // load successfully in Electron while missing the patched fd/error handling. + const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty') + const missingArtifact = [ + resolve(nodePtyDir, 'build', 'Release', 'pty.node'), + resolve(nodePtyDir, 'build', 'Release', 'spawn-helper') + ].find((artifactPath) => !existsSync(artifactPath)) + + if (!missingArtifact) { + return null + } + + return 'Patched node-pty build artifacts are missing; rebuilding from source.' +} + +function requiresPatchedNodePtySourceBuild() { + if (!onlyModules.includes('node-pty')) { + return false + } + if (rebuildPlatform === 'win32') { + return false + } + if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) { + return false + } + + const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch') + if (!existsSync(nodePtyPatchPath)) { + return false + } + + return existsSync(resolve(projectDir, 'node_modules', 'node-pty')) +} + function probeElectronNativeModules(moduleNames) { if (!electronPackageIsUsable()) { return { ok: false, status: null, stderr: 'Electron package binary is unavailable.' } @@ -349,6 +392,7 @@ const { release } = require('node:os') const { resolve } = require('node:path') const projectRequire = createRequire(resolve(process.cwd(), 'package.json')) const moduleNames = ${JSON.stringify(moduleNames)} +const requirePatchedNodePtySourceBuild = ${JSON.stringify(requiresPatchedNodePtySourceBuild())} const failures = [] for (const moduleName of moduleNames) { @@ -368,12 +412,23 @@ function loadNativeModule(moduleName) { if (moduleName === 'node-pty') { projectRequire('node-pty') const { loadNativeModule } = projectRequire('node-pty/lib/utils') - loadNativeModule(getNodePtyNativeModuleName()) + const native = loadNativeModule(getNodePtyNativeModuleName()) + if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) { + throw new Error( + 'node-pty resolved to ' + + native.dir + + '; expected build/Release so Orca\\'s node-pty patch is active' + ) + } return } projectRequire(moduleName) } +function isNodePtyReleaseBuildDir(nativeDir) { + return typeof nativeDir === 'string' && nativeDir.replace(/\\\\/g, '/').includes('build/Release/') +} + function getNodePtyNativeModuleName() { if (process.platform !== 'win32') { return 'pty' diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index 55ff6c41fbf..23e7a80640f 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process' import { chmodSync, copyFileSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -122,6 +123,97 @@ describe('rebuild-native-deps Electron install fallback', () => { }) }) +describe('rebuild-native-deps patched node-pty rebuild', () => { + it.skipIf(process.platform === 'win32')( + 'rebuilds when Electron can load node-pty but patched build artifacts are missing', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir) + writeNodePtyPatchFile(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain( + 'Patched node-pty build artifacts are missing; rebuilding from source.' + ) + + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['node-pty']) + expect(rebuildCall.ignoreModules).toEqual(['cpu-features']) + expect(rebuildCall.force).toBe(true) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'keeps the Electron load-probe fast path once patched node-pty artifacts exist', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) + writeNodePtyPatchFile(projectDir) + writePatchedNodePtyBuildArtifacts(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain( + 'Native modules already load in Electron; skipping rebuild.' + ) + expect(existsSync(rebuildLogPath)).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'rebuilds when patched artifacts exist but Electron falls back to node-pty prebuilds', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { nativeDir: '../prebuilds/darwin-arm64/' }) + writeNodePtyPatchFile(projectDir) + writePatchedNodePtyBuildArtifacts(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Native modules do not load in Electron; rebuilding.') + expect(result.stdout).toContain("expected build/Release so Orca's node-pty patch is active") + + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['node-pty']) + expect(rebuildCall.force).toBe(true) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) +}) + function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-')) mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) @@ -225,9 +317,89 @@ module.exports = async function extract(_zipPath, options) { chmodSync(join(extractDir, 'index.js'), 0o755) } -function writeFakeElectronRebuild(projectDir) { +function writeFakeElectronRebuild(projectDir, { logPathEnv = null } = {}) { const rebuildDir = join(projectDir, 'node_modules', '@electron', 'rebuild') mkdirSync(rebuildDir, { recursive: true }) writeFileSync(join(rebuildDir, 'package.json'), JSON.stringify({ type: 'module' })) - writeFileSync(join(rebuildDir, 'index.js'), 'export async function rebuild() {}\n') + writeFileSync( + join(rebuildDir, 'index.js'), + logPathEnv + ? ` +import { appendFileSync } from 'node:fs' + +export async function rebuild(options) { + const logPath = process.env[${JSON.stringify(logPathEnv)}] + if (!logPath) { + return + } + appendFileSync( + logPath, + JSON.stringify({ + arch: options.arch, + electronVersion: options.electronVersion, + force: options.force, + ignoreModules: options.ignoreModules, + onlyModules: options.onlyModules, + platform: options.platform + }) + '\\n' + ) +} +` + : 'export async function rebuild() {}\n' + ) +} + +function writeFakeUsableElectronPackage(projectDir) { + writeFakeElectronPackage(projectDir) + const electronDir = join(projectDir, 'node_modules', 'electron') + const electronPath = join(electronDir, 'dist', 'electron') + mkdirSync(join(electronDir, 'dist'), { recursive: true }) + writeFileSync(join(electronDir, 'path.txt'), 'electron') + writeFileSync(join(electronDir, 'dist', 'version'), 'v41.5.0') + writeFileSync( + electronPath, + `#!/usr/bin/env node +const { spawnSync } = require('node:child_process') + +const result = spawnSync(process.execPath, process.argv.slice(2), { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit' +}) + +if (result.error) { + console.error(result.error.message) + process.exit(1) +} + +process.exit(result.status ?? 0) +` + ) + chmodSync(electronPath, 0o755) +} + +function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) { + const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') + mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) + writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') + writeFileSync( + join(nodePtyDir, 'lib', 'utils.js'), + ` +exports.loadNativeModule = function loadNativeModule(nativeName) { + return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } } +} +` + ) +} + +function writeNodePtyPatchFile(projectDir) { + mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) + writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') +} + +function writePatchedNodePtyBuildArtifacts(projectDir) { + const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(buildDir, { recursive: true }) + writeFileSync(join(buildDir, 'pty.node'), '') + writeFileSync(join(buildDir, 'spawn-helper'), '') }