Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 67 additions & 16 deletions flatn/bun-install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -131,40 +141,81 @@ 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 }
});

let stdout = '';
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);
const quietSec = Math.round((Date.now() - lastOutputAt) / 1000);
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');
Expand Down
41 changes: 41 additions & 0 deletions flatn/tests/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,6 +15,7 @@ import {
} from "flatn/index.js"

import { PackageSpec } from "flatn/package-map.js"
import { bunInstall } from "flatn/bun-install.js"


/*
Expand Down Expand Up @@ -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);
Expand Down
Loading