From d4ae13f2a0e7748dd8f3a94ec21ee05050ec2cf7 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 13 Mar 2026 17:58:00 +0530 Subject: [PATCH] fix(opencode): serialize config bun installs (#17342) --- packages/opencode/src/config/config.ts | 2 ++ packages/opencode/test/config/config.test.ts | 36 +++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f3d0d0b7a..27ba4e186 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -37,6 +37,7 @@ import { Account } from "@/account" import { ConfigPaths } from "./paths" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" +import { Lock } from "@/util/lock" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) @@ -289,6 +290,7 @@ export namespace Config { // Install any additional dependencies defined in the package.json // This allows local plugins and custom tools to use external packages + using _ = await Lock.write("bun-install") await BunProc.run( [ "install", diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 90727cf8a..baf209d86 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, mock, afterEach } from "bun:test" +import { test, expect, describe, mock, afterEach, spyOn } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" @@ -10,6 +10,7 @@ import { pathToFileURL } from "url" import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util/filesystem" +import { BunProc } from "../../src/bun" // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! @@ -763,6 +764,39 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { } }) +test("serializes concurrent config dependency installs", async () => { + await using tmp = await tmpdir() + const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")] + await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true }))) + + const seen: string[] = [] + let active = 0 + let max = 0 + const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => { + active++ + max = Math.max(max, active) + seen.push(opts?.cwd ?? "") + await new Promise((resolve) => setTimeout(resolve, 25)) + active-- + return { + code: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + } + }) + + try { + await Promise.all(dirs.map((dir) => Config.installDependencies(dir))) + } finally { + run.mockRestore() + } + + expect(max).toBe(1) + expect(seen.toSorted()).toEqual(dirs.toSorted()) + expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true) + expect(await Filesystem.exists(path.join(dirs[1], "package.json"))).toBe(true) +}) + test("resolves scoped npm plugins in config", async () => { await using tmp = await tmpdir({ init: async (dir) => {