diff --git a/flatn/bun-install.js b/flatn/bun-install.js index 0bcf8131b6..5d4dd881f8 100644 --- a/flatn/bun-install.js +++ b/flatn/bun-install.js @@ -4,6 +4,16 @@ import path from 'path'; import { gitSpecFromVersion } from './flatn-cjs.js'; const lvInfoFileName = '.lv-npm-helper-info.json'; +const bunInstallMaxAttempts = 2; +const defaultBunInstallStallTimeoutMs = 120_000; +const bunInstallTerminationGraceMs = 5_000; + +class BunInstallStallError extends Error { + constructor (stallTimeoutMs) { + super(`bun install produced no output for ${Math.round(stallTimeoutMs / 1000)}s`); + this.name = 'BunInstallStallError'; + } +} export function detectBun () { if (process.env.BUN_PATH) { @@ -131,9 +141,27 @@ export async function bunInstall (bunPath, livelyDirs, destDir, projectRoot, ver async function runBunInstall (bunPath, bunWorkDir, verbose) { console.log(' Running bun install...'); + const configuredStallTimeoutMs = Number(process.env.LIVELY_BUN_INSTALL_STALL_TIMEOUT_MS); + const stallTimeoutMs = Number.isFinite(configuredStallTimeoutMs) && configuredStallTimeoutMs > 0 + ? configuredStallTimeoutMs + : defaultBunInstallStallTimeoutMs; + + for (let attempt = 1; attempt <= bunInstallMaxAttempts; attempt++) { + try { + await runBunInstallAttempt(bunPath, bunWorkDir, verbose, stallTimeoutMs); + return; + } catch (err) { + const canRetry = err instanceof BunInstallStallError && attempt < bunInstallMaxAttempts; + if (!canRetry) throw err; + console.warn(` [!] ${err.message}; retrying bun install (attempt ${attempt + 1}/${bunInstallMaxAttempts})...`); + } + } +} + +async function runBunInstallAttempt (bunPath, bunWorkDir, verbose, stallTimeoutMs) { const child = spawn(bunPath, ['install', '--no-progress'], { cwd: bunWorkDir, - stdio: verbose ? 'inherit' : ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } }); @@ -141,17 +169,33 @@ async function runBunInstall (bunPath, bunWorkDir, verbose) { let stderr = ''; let lastOutputAt = Date.now(); const startedAt = Date.now(); + let stalled = false; + let stallTimer; + let forceKillTimer; + + const armStallTimer = () => { + clearTimeout(stallTimer); + stallTimer = setTimeout(() => { + stalled = true; + child.kill('SIGTERM'); + forceKillTimer = setTimeout(() => child.kill('SIGKILL'), bunInstallTerminationGraceMs); + }, stallTimeoutMs); + }; - if (!verbose) { - child.stdout?.on('data', chunk => { - stdout += chunk.toString(); - lastOutputAt = Date.now(); - }); - child.stderr?.on('data', chunk => { - stderr += chunk.toString(); - lastOutputAt = Date.now(); - }); - } + child.stdout?.on('data', chunk => { + if (verbose) process.stdout.write(chunk); + else stdout += chunk.toString(); + lastOutputAt = Date.now(); + armStallTimer(); + }); + child.stderr?.on('data', chunk => { + if (verbose) process.stderr.write(chunk); + else stderr += chunk.toString(); + lastOutputAt = Date.now(); + armStallTimer(); + }); + + armStallTimer(); const heartbeat = !verbose && setInterval(() => { const elapsedSec = Math.round((Date.now() - startedAt) / 1000); @@ -159,12 +203,19 @@ async function runBunInstall (bunPath, bunWorkDir, verbose) { console.log(` bun install still running... ${elapsedSec}s elapsed, ${quietSec}s since last output`); }, 10000); - const result = await new Promise((resolve, reject) => { - child.on('error', reject); - child.on('close', (code, signal) => resolve({ code, signal })); - }); + let result; + try { + result = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code, signal) => resolve({ code, signal })); + }); + } finally { + if (heartbeat) clearInterval(heartbeat); + clearTimeout(stallTimer); + clearTimeout(forceKillTimer); + } - if (heartbeat) clearInterval(heartbeat); + if (stalled) throw new BunInstallStallError(stallTimeoutMs); if (result.code !== 0) { const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n'); diff --git a/flatn/tests/test.js b/flatn/tests/test.js index 33bcfe4049..e6688fff81 100644 --- a/flatn/tests/test.js +++ b/flatn/tests/test.js @@ -3,6 +3,7 @@ import { expect } from "mocha-es6"; import { tmpdir } from "./util.js"; import { join as j } from "path"; import { execSync, exec } from "child_process"; +import fs from "fs"; const { resource, createFiles } = lively.resources; import { @@ -14,6 +15,7 @@ import { } from "flatn/index.js" import { PackageSpec } from "flatn/package-map.js" +import { bunInstall } from "flatn/bun-install.js" /* @@ -115,6 +117,45 @@ describe("flat packages", function() { describe("installation", () => { + it("retries a stalled bun install once", async () => { + let projectRoot = baseDir.join("bun-retry-project/").path(), + packageDir = j(projectRoot, "local-package"), + dependenciesDir = j(projectRoot, "dependencies"), + fakeBun = j(projectRoot, "fake-bun"), + previousStallTimeout = process.env.LIVELY_BUN_INSTALL_STALL_TIMEOUT_MS; + + fs.mkdirSync(j(projectRoot, "lively.installer"), { recursive: true }); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(j(projectRoot, "lively.installer", "packages-config.json"), "[]"); + fs.writeFileSync(j(packageDir, "package.json"), JSON.stringify({ + name: "local-package", + version: "1.0.0", + dependencies: {} + })); + fs.writeFileSync(fakeBun, `#!/bin/sh +attempt_file="$PWD/.bun-install-attempted" +if [ ! -f "$attempt_file" ]; then + touch "$attempt_file" + exec sleep 10 +fi +exit 0 +`); + fs.chmodSync(fakeBun, 0o755); + + process.env.LIVELY_BUN_INSTALL_STALL_TIMEOUT_MS = "500"; + try { + let result = await bunInstall(fakeBun, [packageDir], dependenciesDir, projectRoot); + expect(result.newPackages).equals([]); + expect(fs.existsSync(j(projectRoot, "tmp", "bun-install-workdir", ".bun-install-attempted"))).equals(true); + } finally { + if (previousStallTimeout === undefined) { + delete process.env.LIVELY_BUN_INSTALL_STALL_TIMEOUT_MS; + } else { + process.env.LIVELY_BUN_INSTALL_STALL_TIMEOUT_MS = previousStallTimeout; + } + } + }); + it("installs a package via npm", async () => { let basePath = baseDir.join("package-install-dir").path(), { packageMap, newPackages } = await installPackage("strip-ansi@^3", basePath);