diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index bb0d8707d..3e73aafdd 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -20,10 +20,12 @@ */ import { rebuild } from '@electron/rebuild' -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' +const require = createRequire(import.meta.url) const projectDir = process.cwd() const electronVersion = JSON.parse( readFileSync(resolve(projectDir, 'node_modules/electron/package.json'), 'utf8') @@ -41,6 +43,23 @@ if (ignoreModules.length > 0) { // ABI regardless of the package manager's store layout. const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features'] const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) +const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1' + +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) + if (probe.ok) { + console.log('[rebuild] Native modules already load in Electron; skipping rebuild.') + process.exit(0) + } + console.log('[rebuild] Native modules do not load in Electron; rebuilding.') + if (probe.stderr.trim()) { + console.log(probe.stderr.trim()) + } +} else { + console.log('[rebuild] ORCA_FORCE_NATIVE_REBUILD=1 set; forcing native rebuild.') +} // Why: cpu-features ships without `buildcheck.gypi`; its own `install` script // generates it by running `node buildcheck.js > buildcheck.gypi` before @@ -49,10 +68,9 @@ const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) // store prune, or a prior failed run) node-gyp aborts with // "buildcheck.gypi not found". Regenerate it here before rebuilding. if (!ignoreModules.includes('cpu-features')) { - const cpuFeatureDirs = globSync( - 'node_modules/.pnpm/cpu-features@*/node_modules/cpu-features', - { cwd: projectDir }, - ) + const cpuFeatureDirs = globSync('node_modules/.pnpm/cpu-features@*/node_modules/cpu-features', { + cwd: projectDir + }) for (const relDir of cpuFeatureDirs) { const dir = resolve(projectDir, relDir) const gypiPath = resolve(dir, 'buildcheck.gypi') @@ -62,15 +80,12 @@ if (!ignoreModules.includes('cpu-features')) { try { const out = execFileSync(process.execPath, ['buildcheck.js'], { cwd: dir, - encoding: 'utf8', + encoding: 'utf8' }) writeFileSync(gypiPath, out) console.log(`[rebuild] Generated ${relDir}/buildcheck.gypi`) } catch (/** @type {any} */ err) { - console.error( - `[rebuild] Failed to generate ${relDir}/buildcheck.gypi:`, - err?.message ?? err, - ) + console.error(`[rebuild] Failed to generate ${relDir}/buildcheck.gypi:`, err?.message ?? err) process.exit(1) } } @@ -87,9 +102,121 @@ try { // (e.g., system Node instead of Electron's embedded Node). This is // common after pnpm install, which compiles native modules for system // Node before postinstall runs this script. - force: true, + force: true }) } catch (/** @type {any} */ err) { console.error('[rebuild] Native module rebuild failed:', err?.message ?? err) + if (isWindowsNativeLockError(err)) { + console.error( + '[rebuild] A Windows process appears to be using a native .node file. ' + + 'Close running Orca/Electron/dev processes for this worktree, then rerun `pnpm install` ' + + 'or `pnpm run rebuild:electron`.' + ) + if (isPostinstall() && process.env.ORCA_STRICT_NATIVE_REBUILD !== '1') { + console.error( + '[rebuild] Continuing postinstall because the failure is a Windows file lock. ' + + 'The next dev/start command will re-check native modules.' + ) + process.exit(0) + } + } process.exit(1) } + +function probeElectronNativeModules(moduleNames) { + let electronExecutable + try { + electronExecutable = require('electron') + } catch (error) { + return { ok: false, status: null, stderr: formatError(error) } + } + + const probeSource = ` +const { createRequire } = require('node:module') +const { release } = require('node:os') +const { resolve } = require('node:path') +const projectRequire = createRequire(resolve(process.cwd(), 'package.json')) +const moduleNames = ${JSON.stringify(moduleNames)} +const failures = [] + +for (const moduleName of moduleNames) { + try { + loadNativeModule(moduleName) + } catch (error) { + failures.push(moduleName + ': ' + formatError(error)) + } +} + +if (failures.length > 0) { + console.error(failures.join('\\n')) + process.exit(1) +} + +function loadNativeModule(moduleName) { + if (moduleName === 'better-sqlite3') { + const Database = projectRequire(moduleName) + const db = new Database(':memory:') + db.close() + return + } + if (moduleName === 'node-pty') { + projectRequire('node-pty') + const { loadNativeModule } = projectRequire('node-pty/lib/utils') + loadNativeModule(getNodePtyNativeModuleName()) + return + } + projectRequire(moduleName) +} + +function getNodePtyNativeModuleName() { + if (process.platform !== 'win32') { + return 'pty' + } + const match = /(\\d+)\\.(\\d+)\\.(\\d+)/g.exec(release()) + const buildNumber = match && match.length === 4 ? Number.parseInt(match[3], 10) : 0 + return buildNumber >= 18309 ? 'conpty' : 'pty' +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error) +} +` + + const result = spawnSync(electronExecutable, ['-e', probeSource], { + cwd: projectDir, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1' + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + + return { + ok: result.status === 0, + status: result.status, + stderr: [result.stderr, result.stdout, result.error ? formatError(result.error) : ''] + .filter(Boolean) + .join('\n') + } +} + +function isWindowsNativeLockError(error) { + if (process.platform !== 'win32') { + return false + } + const text = [error?.message, error?.stack, error?.stdout, error?.stderr] + .filter(Boolean) + .join('\n') + return /(?:EPERM|operation not permitted)[\s\S]*(?:unlink|\.node|conpty\.node|pty\.node)/i.test( + text + ) +} + +function isPostinstall() { + return process.env.npm_lifecycle_event === 'postinstall' +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error) +}