diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index c45027521..6530ba755 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -49,7 +49,7 @@ function readRuntimeArg() { } function ensureNodeRuntime() { - const initial = runCurrentProcessCheck() + const initial = runNodeCheck() if (initial.ok) { return } @@ -61,7 +61,7 @@ function ensureNodeRuntime() { printCheckError(initial) runPnpm(['rebuild', ...failedModules]) - const final = runCurrentProcessCheck() + const final = runNodeCheck() if (!final.ok) { console.error( `[native-runtime] Native modules still do not load for ${formatRuntimeLabel('node')}.` @@ -93,12 +93,16 @@ function ensureElectronRuntime() { } } -function runCurrentProcessCheck() { - const failures = collectNativeModuleFailures() - if (failures.length === 0) { - return { ok: true, failures } - } - return { ok: false, failures } +function runNodeCheck() { + // Why: a failed native addon load can poison the current process, so the + // post-rebuild verification must happen in a fresh Node process. + const result = spawnSync(process.execPath, [scriptPath, CHILD_CHECK_FLAG], { + cwd: projectDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + + return parseChildCheckResult(result) } function runElectronCheck() { @@ -119,15 +123,33 @@ function runElectronCheck() { stdio: ['ignore', 'pipe', 'pipe'] }) + return parseChildCheckResult(result) +} + +function parseChildCheckResult(result) { + const failures = parseCheckFailures(result.stderr) + return { ok: result.status === 0, status: result.status, stdout: result.stdout, stderr: result.stderr, - error: result.error + error: result.error, + failures } } +function parseCheckFailures(stderr) { + const failures = [] + for (const line of (stderr ?? '').split(/\r?\n/)) { + const match = /^([^:]+):\s*(.*)$/.exec(line) + if (match && NATIVE_MODULES.includes(match[1])) { + failures.push({ moduleName: match[1], message: match[2] }) + } + } + return failures +} + function collectNativeModuleFailures() { const failures = [] for (const moduleName of NATIVE_MODULES) { diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs new file mode 100644 index 000000000..65ba4f0cd --- /dev/null +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -0,0 +1,135 @@ +import { spawnSync } from 'node:child_process' +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) + +describe('ensure-native-runtime', () => { + it('rechecks Node native modules in fresh child processes after rebuilding', () => { + 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) + writeFakeNativeModules(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) + const log = readFileSync(logPath, 'utf8') + expect(log).toContain('pnpm rebuild better-sqlite3\n') + expect(log.split('\n').filter((line) => line.startsWith('better-sqlite3 '))).toEqual([ + 'better-sqlite3 child marker=false', + 'better-sqlite3 child marker=true' + ]) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) +}) + +function mkTempProject() { + const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-')) + mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + return projectDir +} + +function envWithPrependedPath(binDir, extraEnv) { + const pathKey = + process.platform === 'win32' + ? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path') + : 'PATH' + return { + ...process.env, + ...extraEnv, + [pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}` + } +} + +function writeFakeNativeModules(projectDir) { + const sqliteDir = join(projectDir, 'node_modules', 'better-sqlite3') + const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') + mkdirSync(sqliteDir, { recursive: true }) + mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) + + writeFileSync( + join(sqliteDir, 'index.js'), + ` +const { appendFileSync, existsSync } = require('node:fs') + +module.exports = class Database { + constructor() { + const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER) + appendFileSync( + process.env.ORCA_NATIVE_TEST_LOG, + \`better-sqlite3 \${process.argv.includes('--check-only') ? 'child' : 'parent'} marker=\${markerExists}\\n\` + ) + if (!markerExists) { + throw new Error('ABI mismatch sentinel') + } + } + + close() {} +} +` + ) + + writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') + writeFileSync( + join(nodePtyDir, 'lib', 'utils.js'), + ` +const { appendFileSync } = require('node:fs') + +exports.loadNativeModule = function loadNativeModule(nativeName) { + appendFileSync( + process.env.ORCA_NATIVE_TEST_LOG, + \`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName}\\n\` + ) +} +` + ) +} + +function writeFakePnpm(binDir) { + mkdirSync(binDir, { recursive: true }) + const shimPath = join(binDir, 'pnpm-shim.cjs') + writeFileSync( + shimPath, + ` +const { appendFileSync, writeFileSync } = require('node:fs') + +appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`) +writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt') +` + ) + + const posixPnpmPath = join(binDir, 'pnpm') + writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`) + chmodSync(posixPnpmPath, 0o755) + writeFileSync( + join(binDir, 'pnpm.cmd'), + `@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n` + ) +}