Skip to content
Open
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
12 changes: 11 additions & 1 deletion src/install/PackageManager/runTasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1860,7 +1860,17 @@ pub fn generate_network_task_for_tarball<'a>(
.expect("unreachable"),
};

network_task.for_tarball(extract_tarball, scope, authorization)?;
if let Err(err) = network_task.for_tarball(extract_tarball, scope, authorization) {
// `has_created_network_task` recorded `task_id` as created, but no task
// exists to ever complete or fail it. Without this mark a later enqueue
// for the same id queues its callback behind the missing task, and the
// isolated installer waits for that callback forever.
mark_network_task_failed(this, task_id);
// SAFETY: `write_init` fully initialized the slot. `for_tarball` fails
// before it writes `unsafe_http_client`, so `put` has nothing else to drop.
unsafe { this.preallocated_network_tasks.put(net_ptr) };
return Err(err);
}

if extract_tarball::uses_streaming_extraction() {
// Pre-create the extract Task and streaming state here on the
Expand Down
119 changes: 119 additions & 0 deletions test/cli/install/isolated-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,125 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f
expect(await installedIndexJs.text()).toBe('console.log("patched");\n');
});

// A tarball that cannot even be requested (here: a URL that is not http) hung
// `bun install` forever with the isolated linker. Creating the download task
// fails after the task id is recorded as created, so every later request for
// the same tarball parked its store entry behind a task that does not exist.
describe("tarball whose download task cannot be created", () => {
test("second store entry of the package: fails instead of hanging", async () => {
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });

// `peer-deps@1.0.0` has a peer on `no-deps`. Two workspaces with different
// `no-deps` versions give it two store entries sharing one tarball.
await write(packageJson, JSON.stringify({ name: "invalid-tarball-url", workspaces: ["packages/*"] }));
for (const version of ["1.0.0", "1.0.1"]) {
await write(
join(packageDir, "packages", `pkg-${version}`, "package.json"),
JSON.stringify({
name: `pkg-${version}`,
version: "1.0.0",
dependencies: { "peer-deps": "1.0.0", "no-deps": version },
}),
);
}

async function install(cacheDir: string) {
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
// CI exports BUN_INSTALL_CACHE_DIR; pin it so the second install really
// has to download `peer-deps` again.
env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: cacheDir },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

const first = await install(join(packageDir, ".bun-cache-1"));
expect(first.stderr).not.toContain("error:");
expect(first.exitCode).toBe(0);
expect(
(await readdirSorted(join(packageDir, "node_modules", ".bun"))).filter(entry => entry.startsWith("peer-deps@")),
).toHaveLength(2);

const lockfile = join(packageDir, "bun.lock");
const lockfileBefore = await file(lockfile).text();
const lockfileAfter = lockfileBefore.replace(
/("peer-deps@1\.0\.0", )"[^"]*"/,
'$1"ftp://localhost/peer-deps/-/peer-deps-1.0.0.tgz"',
);
expect(lockfileAfter).not.toBe(lockfileBefore);
await write(lockfile, lockfileAfter);
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });

// Empty cache: the install phase requests the tarball for both entries.
const second = await install(join(packageDir, ".bun-cache-2"));
expect(second.stderr).toContain("failed to enqueue package for download: peer-deps@1.0.0");
expect(second.stdout).not.toContain("packages installed");
expect(second.exitCode).toBe(1);
});

test("package resolved from the manifest cache: fails instead of hanging", async () => {
// The first install fetches the manifest, caches it, and fails when it
// requests the tarball while resolving. The second install resolves from
// the cached manifest. That request fails again, and this time the
// install phase asks for the same tarball once more.
using server = Bun.serve({
port: 0,
fetch(req) {
if (new URL(req.url).pathname !== "/bad-tarball-url") return new Response("Not found", { status: 404 });
return Response.json(
{
name: "bad-tarball-url",
"dist-tags": { latest: "1.0.0" },
versions: {
"1.0.0": {
name: "bad-tarball-url",
version: "1.0.0",
dist: { tarball: "ftp://localhost/bad-tarball-url/-/bad-tarball-url-1.0.0.tgz" },
},
},
},
{ headers: { "Cache-Control": "public, max-age=300" } },
);
},
});

using packageDir = tempDir("manifest-cache-bad-tarball-", {
"package.json": JSON.stringify({
name: "manifest-cache-bad-tarball",
dependencies: { "bad-tarball-url": "1.0.0" },
}),
"bunfig.toml": `[install]\nregistry = "http://localhost:${server.port}/"\nlinker = "isolated"\n`,
});

async function install() {
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(packageDir),
// One cache for both installs, separate from the other tests' caches.
env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(packageDir), ".bun-cache") },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

const expectedError = 'Expected tarball URL to start with https:// or http://, got "ftp://localhost/';
const first = await install();
expect(first.stderr).toContain(expectedError);
expect(first.exitCode).toBe(1);

const second = await install();
expect(second.stderr).toContain(expectedError);
expect(second.stdout).not.toContain("packages installed");
expect(second.exitCode).toBe(1);
});
});

for (const backend of ["clonefile", "hardlink", "copyfile"]) {
test(`isolated install with backend: ${backend}`, async () => {
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
Expand Down
Loading