Skip to content
Open
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
90 changes: 90 additions & 0 deletions packages/cli/test/updater-orphan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"

const describeUnix = process.platform === "win32" ? describe.skip : describe
const children: Bun.Subprocess[] = []

afterEach(() => children.splice(0).forEach((child) => child.kill()))

describeUnix("package-manager updater executable lifetime", () => {
test(
"replacing a globally installed native package orphans its running executable",
async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-updater-orphan-"))
const prefix = path.join(root, "prefix")
const env = {
...process.env,
BUN_INSTALL_GLOBAL_DIR: path.join(prefix, "install", "global"),
BUN_INSTALL_BIN: path.join(prefix, "bin"),
}

try {
const archives = await Promise.all(["1.0.0", "1.0.1"].map((version) => fixture(root, version)))
await run(["bun", "install", "--global", archives[0]], undefined, env)

const executable = path.join(prefix, "bin", "opencode-updater-orphan-fixture")
const child = Bun.spawn([executable], { stdout: "ignore", stderr: "ignore" })
children.push(child)
await Bun.sleep(100)

const before = await mappedExecutable(child.pid)
await run(["bun", "remove", "--global", "opencode-updater-orphan-fixture"], undefined, env)
await run(["bun", "install", "--global", archives[1]], undefined, env)
const after = await mappedExecutable(child.pid)

console.log(JSON.stringify({ pid: child.pid, before, after }))
expect(before).toContain("/node_modules/opencode-updater-orphan-fixture/bin/opencode-updater-orphan-fixture")
expect(after).toBe(`${before} (deleted)`)
expect(child.exitCode).toBeNull()
} finally {
await fs.rm(root, { recursive: true, force: true })
}
},
30_000,
)
})

async function fixture(root: string, version: string) {
const directory = path.join(root, version)
const source = path.join(directory, "fixture.c")
const executable = path.join(directory, "bin", "opencode-updater-orphan-fixture")
await fs.mkdir(path.dirname(executable), { recursive: true })
await fs.writeFile(
path.join(directory, "package.json"),
JSON.stringify({
name: "opencode-updater-orphan-fixture",
version,
bin: { "opencode-updater-orphan-fixture": "bin/opencode-updater-orphan-fixture" },
}),
)
await fs.writeFile(
source,
"#include <unistd.h>\nint main(void) { for (;;) { access(\".\", F_OK); usleep(10000); } }\n",
)
await run(["cc", source, "-o", executable])
await fs.chmod(executable, 0o755)
await run(["bun", "pm", "pack", "--destination", root], directory)
return path.join(root, `opencode-updater-orphan-fixture-${version}.tgz`)
}

async function mappedExecutable(pid: number) {
if (process.platform === "linux") return fs.readlink(`/proc/${pid}/exe`)
const result = await run(["lsof", "-a", "-p", String(pid), "-d", "txt", "-Fn"])
return result
.split("\n")
.find((line) => line.startsWith("n"))
?.slice(1)
}

async function run(command: string[], cwd?: string, env?: Record<string, string | undefined>) {
const result = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" })
const [code, stdout, stderr] = await Promise.all([
result.exited,
new Response(result.stdout).text(),
new Response(result.stderr).text(),
])
if (code === 0) return stdout.trim()
throw new Error(`${command.join(" ")} failed (${code}): ${stderr.trim()}`)
}
Loading