diff --git a/dev-packages/application-manager/src/rebuild.ts b/dev-packages/application-manager/src/rebuild.ts index 269cc481d3ab6..a82f99e9bf7f2 100644 --- a/dev-packages/application-manager/src/rebuild.ts +++ b/dev-packages/application-manager/src/rebuild.ts @@ -17,9 +17,17 @@ import cp = require('child_process'); import fs = require('fs-extra'); import path = require('path'); +import os = require('os'); export type RebuildTarget = 'electron' | 'browser'; +const EXIT_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; + +interface ExitToken { + getLastSignal(): NodeJS.Signals | undefined + onSignal(callback: (signal: NodeJS.Signals) => void): void +} + export const DEFAULT_MODULES = [ '@theia/node-pty', 'nsfw', @@ -54,13 +62,21 @@ export function rebuild(target: RebuildTarget, options: RebuildOptions = {}): vo } = options; const cache = path.resolve(cacheRoot, '.browser_modules'); const cacheExists = folderExists(cache); - if (target === 'electron' && !cacheExists) { - rebuildElectronModules(cache, modules); - } else if (target === 'browser' && cacheExists) { - revertBrowserModules(cache, modules); - } else { - console.log(`native node modules are already rebuilt for ${target}`); - } + guardExit(async token => { + if (target === 'electron' && !cacheExists) { + process.exitCode = await rebuildElectronModules(cache, modules, token); + } else if (target === 'browser' && cacheExists) { + process.exitCode = await revertBrowserModules(cache, modules); + } else { + console.log(`native node modules are already rebuilt for ${target}`); + } + }).catch(errorOrSignal => { + if (typeof errorOrSignal === 'string' && errorOrSignal in os.constants.signals) { + process.kill(process.pid, errorOrSignal); + } else { + throw errorOrSignal; + } + }); } function folderExists(folder: string): boolean { @@ -75,7 +91,7 @@ function folderExists(folder: string): boolean { } /** - * Schema for `/modules.json` + * Schema for `/modules.json`. */ interface ModulesJson { [moduleName: string]: ModuleBackup @@ -84,14 +100,14 @@ interface ModuleBackup { originalLocation: string } -async function rebuildElectronModules(browserModuleCache: string, modules: string[]): Promise { +async function rebuildElectronModules(browserModuleCache: string, modules: string[], token: ExitToken): Promise { const modulesJsonPath = path.join(browserModuleCache, 'modules.json'); const modulesJson: ModulesJson = await fs.access(modulesJsonPath).then( - exists => fs.readJSON(modulesJsonPath), - missing => ({}) + () => fs.readJson(modulesJsonPath), + () => ({}) ); let success = true; - // backup already-built browser modules + // Backup already built browser modules. await Promise.all(modules.map(async module => { let modulePath; try { @@ -100,13 +116,13 @@ async function rebuildElectronModules(browserModuleCache: string, modules: strin }); } catch (_) { console.debug(`Module not found: ${module}`); - return; // Skip + return; // Skip current module. } const src = path.dirname(modulePath); const dest = path.join(browserModuleCache, module); try { await fs.remove(dest); - await fs.copy(src, dest); + await fs.copy(src, dest, { overwrite: true }); modulesJson[module] = { originalLocation: src, }; @@ -118,65 +134,194 @@ async function rebuildElectronModules(browserModuleCache: string, modules: strin })); if (Object.keys(modulesJson).length === 0) { console.debug('No module to rebuild.'); - process.exit(0); + return 0; } - // update manifest tracking the backups original locations - await fs.writeJSON(modulesJsonPath, modulesJson, { spaces: 2 }); - // if we failed to process a module then exit now + // Update manifest tracking the backups' original locations. + await fs.writeJson(modulesJsonPath, modulesJson, { spaces: 2 }); + // If we failed to process a module then exit now. if (!success) { - process.exit(1); + return 1; } - // rebuild for electron - const todo = modules - .map(m => { - // electron-rebuild ignores the module namespace... - const slash = m.indexOf('/'); - return m.startsWith('@') && slash !== -1 - ? m.substring(slash + 1) - : m; - }) - .join(','); - await new Promise((resolve, reject) => { - const electronRebuild = cp.spawn(`npx --no-install electron-rebuild -f -w="${todo}" -o="${todo}"`, { - stdio: 'inherit', - shell: true, - }); - electronRebuild.on('error', reject); - electronRebuild.on('close', (code, signal) => { - if (code || signal) { - reject(`electron-rebuild exited with "${code || signal}"`); - } else { - resolve(); - } - }); + const todo = modules.map(m => { + // electron-rebuild ignores the module namespace... + const slash = m.indexOf('/'); + return m.startsWith('@') && slash !== -1 + ? m.substring(slash + 1) + : m; }); + try { + if (process.env.THEIA_REBUILD_NO_WORKAROUND) { + return await runElectronRebuild(todo, token); + } else { + return await electronRebuildExtraModulesWorkaround(process.cwd(), todo, () => runElectronRebuild(todo, token), token); + } + } catch (error) { + return revertBrowserModules(browserModuleCache, modules); + } } -async function revertBrowserModules(browserModuleCache: string, modules: string[]): Promise { +async function revertBrowserModules(browserModuleCache: string, modules: string[]): Promise { + let exitCode = 0; const modulesJsonPath = path.join(browserModuleCache, 'modules.json'); - const modulesJson: ModulesJson = await fs.readJSON(modulesJsonPath); + const modulesJson: ModulesJson = await fs.readJson(modulesJsonPath); await Promise.all(Object.entries(modulesJson).map(async ([moduleName, entry]) => { if (!modules.includes(moduleName)) { - return; // skip modules that weren't requested + return; // Skip modules that weren't requested. } const src = path.join(browserModuleCache, moduleName); + if (!await fs.pathExists(src)) { + delete modulesJson[moduleName]; + console.error(`Missing backup for ${moduleName}!`); + exitCode = 1; + return; + } const dest = entry.originalLocation; try { await fs.remove(dest); - await fs.copy(src, dest); + await fs.copy(src, dest, { overwrite: false }); await fs.remove(src); delete modulesJson[moduleName]; console.debug(`Reverted "${moduleName}"`); } catch (error) { console.error(`Error while reverting "${moduleName}": ${error}`); - process.exitCode = 1; + exitCode = 1; } })); if (Object.keys(modulesJson).length === 0) { - // we restored everything so we can delete the cache + // We restored everything, so we can delete the cache. await fs.remove(browserModuleCache); } else { - // some things were not restored so we update the manifest - await fs.writeJSON(modulesJsonPath, modulesJson, { spaces: 2 }); + // Some things were not restored, so we update the manifest. + await fs.writeJson(modulesJsonPath, modulesJson, { spaces: 2 }); + } + return exitCode; +} + +async function runElectronRebuild(modules: string[], token: ExitToken): Promise { + const todo = modules.join(','); + return new Promise((resolve, reject) => { + const electronRebuild = cp.spawn(`npx --no-install electron-rebuild -f -w="${todo}" -o="${todo}"`, { + stdio: 'inherit', + shell: true, + }); + token.onSignal(signal => electronRebuild.kill(signal)); + electronRebuild.on('error', reject); + electronRebuild.on('close', (code, signal) => { + if (signal) { + reject(new Error(`electron-rebuild exited with "${signal}"`)); + } else { + resolve(code); + } + }); + }); +} + +/** + * `electron-rebuild` is supposed to accept a list of modules to build, even when not part of the dependencies. + * But there is a bug that causes `electron-rebuild` to not correctly process this list of modules. + * + * This workaround will temporarily modify the current package.json file. + * + * PR with fix: https://github.com/electron/electron-rebuild/pull/888 + * + * TODO: Remove this workaround. + */ +async function electronRebuildExtraModulesWorkaround(cwd: string, extraModules: string[], run: (token: ExitToken) => Promise, token: ExitToken): Promise { + const packageJsonPath = path.resolve(cwd, 'package.json'); + if (await fs.pathExists(packageJsonPath)) { + // package.json exists: We back it up before modifying it then revert it. + const packageJsonCopyPath = `${packageJsonPath}.copy`; + const packageJson = await fs.readJson(packageJsonPath); + await fs.copy(packageJsonPath, packageJsonCopyPath); + await throwIfSignal(token, async () => { + await fs.unlink(packageJsonCopyPath); + }); + if (typeof packageJson.dependencies !== 'object') { + packageJson.dependencies = {}; + } + for (const extraModule of extraModules) { + if (!packageJson.dependencies[extraModule]) { + packageJson.dependencies[extraModule] = '*'; + } + } + try { + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); + await throwIfSignal(token); + return await run(token); + } finally { + await fs.move(packageJsonCopyPath, packageJsonPath, { overwrite: true }); + } + } else { + // package.json does not exist: We create one then remove it. + const packageJson = { + name: 'theia-rebuild-workaround', + version: '0.0.0', + dependencies: {} as Record, + }; + for (const extraModule of extraModules) { + packageJson.dependencies[extraModule] = '*'; + } + try { + await fs.writeJson(packageJsonPath, packageJson); + await throwIfSignal(token); + return await run(token); + } finally { + await fs.unlink(packageJsonPath); + } + } +} + +/** + * Temporarily install hooks to **try** to prevent the process from exiting while `run` is running. + * + * Note that it is still possible to kill the process and prevent cleanup logic (e.g. SIGKILL, computer forced shutdown, etc). + */ +async function guardExit(run: (token: ExitToken) => Promise): Promise { + const token = new ExitTokenImpl(); + const signalListener = (signal: NodeJS.Signals) => token._emitSignal(signal); + for (const signal of EXIT_SIGNALS) { + process.on(signal, signalListener); + } + try { + return await run(token); + } finally { + for (const signal of EXIT_SIGNALS) { + process.off(signal, signalListener); + } + } +} + +class ExitTokenImpl implements ExitToken { + + protected _listeners = new Set<(signal: NodeJS.Signals) => void>(); + protected _lastSignal?: NodeJS.Signals; + + onSignal(callback: (signal: NodeJS.Signals) => void): void { + this._listeners.add(callback); + } + + getLastSignal(): NodeJS.Signals | undefined { + return this._lastSignal; + } + + _emitSignal(signal: NodeJS.Signals): void { + this._lastSignal = signal; + for (const listener of this._listeners) { + listener(signal); + } + } +} + +/** + * Throw `signal` if one was received, runs `cleanup` before doing so. + */ +async function throwIfSignal(token: ExitToken, cleanup?: () => Promise): Promise { + if (token.getLastSignal()) { + try { + await cleanup?.(); + } finally { + // eslint-disable-next-line no-throw-literal + throw token.getLastSignal()!; + } } }