From 4ae7c77f8abda8d51ddf52ee6e07890fa19b6629 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 15 Apr 2026 11:50:24 -0400 Subject: migrate: move flock and hash utilities to shared package (#22640) --- packages/shared/test/filesystem/filesystem.test.ts | 338 ++++++++++++++++ packages/shared/test/fixture/flock-worker.ts | 72 ++++ packages/shared/test/lib/effect.ts | 53 +++ packages/shared/test/npm.test.ts | 18 + packages/shared/test/util/flock.test.ts | 426 +++++++++++++++++++++ 5 files changed, 907 insertions(+) create mode 100644 packages/shared/test/filesystem/filesystem.test.ts create mode 100644 packages/shared/test/fixture/flock-worker.ts create mode 100644 packages/shared/test/lib/effect.ts create mode 100644 packages/shared/test/npm.test.ts create mode 100644 packages/shared/test/util/flock.test.ts (limited to 'packages/shared/test') diff --git a/packages/shared/test/filesystem/filesystem.test.ts b/packages/shared/test/filesystem/filesystem.test.ts new file mode 100644 index 000000000..ce990d379 --- /dev/null +++ b/packages/shared/test/filesystem/filesystem.test.ts @@ -0,0 +1,338 @@ +import { describe, test, expect } from "bun:test" +import { Effect, Layer, FileSystem } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { testEffect } from "../lib/effect" +import path from "path" + +const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) +const { effect: it } = testEffect(live) + +describe("AppFileSystem", () => { + describe("isDir", () => { + it( + "returns true for directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + expect(yield* fs.isDir(tmp)).toBe(true) + }), + ) + + it( + "returns false for files", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "test.txt") + yield* filesys.writeFileString(file, "hello") + expect(yield* fs.isDir(file)).toBe(false) + }), + ) + + it( + "returns false for non-existent paths", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false) + }), + ) + }) + + describe("isFile", () => { + it( + "returns true for files", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "test.txt") + yield* filesys.writeFileString(file, "hello") + expect(yield* fs.isFile(file)).toBe(true) + }), + ) + + it( + "returns false for directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + expect(yield* fs.isFile(tmp)).toBe(false) + }), + ) + }) + + describe("readJson / writeJson", () => { + it( + "round-trips JSON data", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "data.json") + const data = { name: "test", count: 42, nested: { ok: true } } + + yield* fs.writeJson(file, data) + const result = yield* fs.readJson(file) + + expect(result).toEqual(data) + }), + ) + }) + + describe("ensureDir", () => { + it( + "creates nested directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const nested = path.join(tmp, "a", "b", "c") + + yield* fs.ensureDir(nested) + + const info = yield* filesys.stat(nested) + expect(info.type).toBe("Directory") + }), + ) + + it( + "is idempotent", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const dir = path.join(tmp, "existing") + yield* filesys.makeDirectory(dir) + + yield* fs.ensureDir(dir) + + const info = yield* filesys.stat(dir) + expect(info.type).toBe("Directory") + }), + ) + }) + + describe("writeWithDirs", () => { + it( + "creates parent directories if missing", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "deep", "nested", "file.txt") + + yield* fs.writeWithDirs(file, "hello") + + expect(yield* filesys.readFileString(file)).toBe("hello") + }), + ) + + it( + "writes directly when parent exists", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "direct.txt") + + yield* fs.writeWithDirs(file, "world") + + expect(yield* filesys.readFileString(file)).toBe("world") + }), + ) + + it( + "writes Uint8Array content", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "binary.bin") + const content = new Uint8Array([0x00, 0x01, 0x02, 0x03]) + + yield* fs.writeWithDirs(file, content) + + const result = yield* filesys.readFile(file) + expect(new Uint8Array(result)).toEqual(content) + }), + ) + }) + + describe("findUp", () => { + it( + "finds target in start directory", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found") + + const result = yield* fs.findUp("target.txt", tmp) + expect(result).toEqual([path.join(tmp, "target.txt")]) + }), + ) + + it( + "finds target in parent directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "marker"), "root") + const child = path.join(tmp, "a", "b") + yield* filesys.makeDirectory(child, { recursive: true }) + + const result = yield* fs.findUp("marker", child, tmp) + expect(result).toEqual([path.join(tmp, "marker")]) + }), + ) + + it( + "returns empty array when not found", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const result = yield* fs.findUp("nonexistent", tmp, tmp) + expect(result).toEqual([]) + }), + ) + }) + + describe("up", () => { + it( + "finds multiple targets walking up", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a") + yield* filesys.writeFileString(path.join(tmp, "b.txt"), "b") + const child = path.join(tmp, "sub") + yield* filesys.makeDirectory(child) + yield* filesys.writeFileString(path.join(child, "a.txt"), "a-child") + + const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp }) + + expect(result).toContain(path.join(child, "a.txt")) + expect(result).toContain(path.join(tmp, "a.txt")) + expect(result).toContain(path.join(tmp, "b.txt")) + }), + ) + }) + + describe("glob", () => { + it( + "finds files matching pattern", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a") + yield* filesys.writeFileString(path.join(tmp, "b.ts"), "b") + yield* filesys.writeFileString(path.join(tmp, "c.json"), "c") + + const result = yield* fs.glob("*.ts", { cwd: tmp }) + expect(result.sort()).toEqual(["a.ts", "b.ts"]) + }), + ) + + it( + "supports absolute paths", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello") + + const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true }) + expect(result).toEqual([path.join(tmp, "file.txt")]) + }), + ) + }) + + describe("globMatch", () => { + it( + "matches patterns", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + expect(fs.globMatch("*.ts", "foo.ts")).toBe(true) + expect(fs.globMatch("*.ts", "foo.json")).toBe(false) + expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true) + }), + ) + }) + + describe("globUp", () => { + it( + "finds files walking up directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "root.md"), "root") + const child = path.join(tmp, "a", "b") + yield* filesys.makeDirectory(child, { recursive: true }) + yield* filesys.writeFileString(path.join(child, "leaf.md"), "leaf") + + const result = yield* fs.globUp("*.md", child, tmp) + expect(result).toContain(path.join(child, "leaf.md")) + expect(result).toContain(path.join(tmp, "root.md")) + }), + ) + }) + + describe("built-in passthrough", () => { + it( + "exists works", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "exists.txt") + yield* filesys.writeFileString(file, "yes") + + expect(yield* filesys.exists(file)).toBe(true) + expect(yield* filesys.exists(file + ".nope")).toBe(false) + }), + ) + + it( + "remove works", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "delete-me.txt") + yield* filesys.writeFileString(file, "bye") + + yield* filesys.remove(file) + + expect(yield* filesys.exists(file)).toBe(false) + }), + ) + }) + + describe("pure helpers", () => { + test("mimeType returns correct types", () => { + expect(AppFileSystem.mimeType("file.json")).toBe("application/json") + expect(AppFileSystem.mimeType("image.png")).toBe("image/png") + expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream") + }) + + test("contains checks path containment", () => { + expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true) + expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false) + }) + + test("overlaps detects overlapping paths", () => { + expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true) + expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true) + expect(AppFileSystem.overlaps("/a", "/b")).toBe(false) + }) + }) +}) diff --git a/packages/shared/test/fixture/flock-worker.ts b/packages/shared/test/fixture/flock-worker.ts new file mode 100644 index 000000000..9954d290c --- /dev/null +++ b/packages/shared/test/fixture/flock-worker.ts @@ -0,0 +1,72 @@ +import fs from "fs/promises" +import { Flock } from "@opencode-ai/shared/util/flock" + +type Msg = { + key: string + dir: string + staleMs?: number + timeoutMs?: number + baseDelayMs?: number + maxDelayMs?: number + holdMs?: number + ready?: string + active?: string + done?: string +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function input() { + const raw = process.argv[2] + if (!raw) { + throw new Error("Missing flock worker input") + } + + return JSON.parse(raw) as Msg +} + +async function job(input: Msg) { + if (input.ready) { + await fs.writeFile(input.ready, String(process.pid)) + } + + if (input.active) { + await fs.writeFile(input.active, String(process.pid), { flag: "wx" }) + } + + try { + if (input.holdMs && input.holdMs > 0) { + await sleep(input.holdMs) + } + + if (input.done) { + await fs.appendFile(input.done, "1\n") + } + } finally { + if (input.active) { + await fs.rm(input.active, { force: true }) + } + } +} + +async function main() { + const msg = input() + + await Flock.withLock(msg.key, () => job(msg), { + dir: msg.dir, + staleMs: msg.staleMs, + timeoutMs: msg.timeoutMs, + baseDelayMs: msg.baseDelayMs, + maxDelayMs: msg.maxDelayMs, + }) +} + +await main().catch((err) => { + const text = err instanceof Error ? (err.stack ?? err.message) : String(err) + process.stderr.write(text) + process.exit(1) +}) diff --git a/packages/shared/test/lib/effect.ts b/packages/shared/test/lib/effect.ts new file mode 100644 index 000000000..131ec5cc6 --- /dev/null +++ b/packages/shared/test/lib/effect.ts @@ -0,0 +1,53 @@ +import { test, type TestOptions } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" + +type Body = Effect.Effect | (() => Effect.Effect) + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + Effect.gen(function* () { + const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + if (Exit.isFailure(exit)) { + for (const err of Cause.prettyErrors(exit.cause)) { + yield* Effect.logError(err) + } + } + return yield* exit + }).pipe(Effect.runPromise) + +const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, testLayer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, testLayer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, testLayer), opts) + + const live = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, liveLayer), opts) + + live.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, liveLayer), opts) + + live.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, liveLayer), opts) + + return { effect, live } +} + +// Test environment with TestClock and TestConsole +const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +// Live environment - uses real clock, but keeps TestConsole for output capture +const liveEnv = TestConsole.layer + +export const it = make(testEnv, liveEnv) + +export const testEffect = (layer: Layer.Layer) => + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) diff --git a/packages/shared/test/npm.test.ts b/packages/shared/test/npm.test.ts new file mode 100644 index 000000000..4443d2985 --- /dev/null +++ b/packages/shared/test/npm.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import { Npm } from "@opencode-ai/shared/npm" + +const win = process.platform === "win32" + +describe("Npm.sanitize", () => { + test("keeps normal scoped package specs unchanged", () => { + expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") + expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0") + expect(Npm.sanitize("prettier")).toBe("prettier") + }) + + test("handles git https specs", () => { + const spec = "acme@git+https://github.com/opencode/acme.git" + const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec + expect(Npm.sanitize(spec)).toBe(expected) + }) +}) diff --git a/packages/shared/test/util/flock.test.ts b/packages/shared/test/util/flock.test.ts new file mode 100644 index 000000000..f1053dfd2 --- /dev/null +++ b/packages/shared/test/util/flock.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import { spawn } from "child_process" +import path from "path" +import os from "os" +import { Flock } from "@opencode-ai/shared/util/flock" +import { Hash } from "@opencode-ai/shared/util/hash" + +type Msg = { + key: string + dir: string + staleMs?: number + timeoutMs?: number + baseDelayMs?: number + maxDelayMs?: number + holdMs?: number + ready?: string + active?: string + done?: string +} + +const root = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts") + +async function tmpdir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-")) + return { + path: dir, + async [Symbol.asyncDispose]() { + await fs.rm(dir, { recursive: true, force: true }) + }, + } +} + +function lock(dir: string, key: string) { + return path.join(dir, Hash.fast(key) + ".lock") +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +async function exists(file: string) { + return fs + .stat(file) + .then(() => true) + .catch(() => false) +} + +async function wait(file: string, timeout = 3_000) { + const stop = Date.now() + timeout + while (Date.now() < stop) { + if (await exists(file)) return + await sleep(20) + } + + throw new Error(`Timed out waiting for file: ${file}`) +} + +function run(msg: Msg) { + return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { + const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { + cwd: root, + }) + + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + + proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) + proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) + + proc.on("close", (code) => { + resolve({ + code: code ?? 1, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }) + }) + }) +} + +function spawnWorker(msg: Msg) { + return spawn(process.execPath, [worker, JSON.stringify(msg)], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + }) +} + +function stopWorker(proc: ReturnType) { + if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() + + if (process.platform !== "win32" || !proc.pid) { + proc.kill() + return Promise.resolve() + } + + return new Promise((resolve) => { + const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) + killProc.on("close", () => { + proc.kill() + resolve() + }) + }) +} + +async function readJson(p: string): Promise { + return JSON.parse(await fs.readFile(p, "utf8")) +} + +describe("util.flock", () => { + test("enforces mutual exclusion under process contention", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const done = path.join(tmp.path, "done.log") + const active = path.join(tmp.path, "active") + const key = "flock:stress" + const n = 16 + + const out = await Promise.all( + Array.from({ length: n }, () => + run({ + key, + dir, + done, + active, + holdMs: 30, + staleMs: 1_000, + timeoutMs: 15_000, + }), + ), + ) + + expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) + expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) + + const lines = (await fs.readFile(done, "utf8")) + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + expect(lines.length).toBe(n) + }, 20_000) + + test("times out while waiting when lock is still healthy", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:timeout" + const ready = path.join(tmp.path, "ready") + const proc = spawnWorker({ + key, + dir, + ready, + holdMs: 20_000, + staleMs: 10_000, + timeoutMs: 30_000, + }) + + try { + await wait(ready, 5_000) + const seen: string[] = [] + const err = await Flock.withLock(key, async () => {}, { + dir, + staleMs: 10_000, + timeoutMs: 1_000, + onWait: (tick) => { + seen.push(tick.key) + }, + }).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + expect(err.message).toContain("Timed out waiting for lock") + expect(seen.length).toBeGreaterThan(0) + expect(seen.every((x) => x === key)).toBe(true) + } finally { + await stopWorker(proc).catch(() => undefined) + await new Promise((resolve) => proc.on("close", resolve)) + } + }, 15_000) + + test("recovers after a crashed lock owner", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:crash" + const ready = path.join(tmp.path, "ready") + const proc = spawnWorker({ + key, + dir, + ready, + holdMs: 20_000, + staleMs: 500, + timeoutMs: 30_000, + }) + + await wait(ready, 5_000) + await stopWorker(proc) + await new Promise((resolve) => proc.on("close", resolve)) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 500, + timeoutMs: 8_000, + }, + ) + + expect(hit).toBe(true) + }, 20_000) + + test("breaks stale lock dirs when heartbeat is missing", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:missing-heartbeat" + const lockDir = lock(dir, key) + + await fs.mkdir(lockDir, { recursive: true }) + const old = new Date(Date.now() - 2_000) + await fs.utimes(lockDir, old, old) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 200, + timeoutMs: 3_000, + }, + ) + + expect(hit).toBe(true) + }) + + test("recovers when a stale breaker claim was left behind", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:stale-breaker" + const lockDir = lock(dir, key) + const breaker = lockDir + ".breaker" + + await fs.mkdir(lockDir, { recursive: true }) + await fs.mkdir(breaker) + + const old = new Date(Date.now() - 2_000) + await fs.utimes(lockDir, old, old) + await fs.utimes(breaker, old, old) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 200, + timeoutMs: 3_000, + }, + ) + + expect(hit).toBe(true) + expect(await exists(breaker)).toBe(false) + }) + + test("fails clearly if lock dir is removed while held", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:compromised" + const lockDir = lock(dir, key) + + const err = await Flock.withLock( + key, + async () => { + await fs.rm(lockDir, { + recursive: true, + force: true, + }) + }, + { + dir, + staleMs: 1_000, + timeoutMs: 3_000, + }, + ).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + expect(err.message).toContain("compromised") + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 200, + timeoutMs: 3_000, + }, + ) + expect(hit).toBe(true) + }) + + test("writes owner metadata while lock is held", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:meta" + const file = path.join(lock(dir, key), "meta.json") + + await Flock.withLock( + key, + async () => { + const json = await readJson<{ + token?: unknown + pid?: unknown + hostname?: unknown + createdAt?: unknown + }>(file) + + expect(typeof json.token).toBe("string") + expect(typeof json.pid).toBe("number") + expect(typeof json.hostname).toBe("string") + expect(typeof json.createdAt).toBe("string") + }, + { + dir, + staleMs: 1_000, + timeoutMs: 3_000, + }, + ) + }) + + test("supports acquire with await using", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:acquire" + const lockDir = lock(dir, key) + + { + await using _ = await Flock.acquire(key, { + dir, + staleMs: 1_000, + timeoutMs: 3_000, + }) + expect(await exists(lockDir)).toBe(true) + } + + expect(await exists(lockDir)).toBe(false) + }) + + test("refuses token mismatch release and recovers from stale", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:token" + const lockDir = lock(dir, key) + const meta = path.join(lockDir, "meta.json") + + const err = await Flock.withLock( + key, + async () => { + const json = await readJson<{ token?: string }>(meta) + json.token = "tampered" + await fs.writeFile(meta, JSON.stringify(json, null, 2)) + }, + { + dir, + staleMs: 500, + timeoutMs: 3_000, + }, + ).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + expect(err.message).toContain("token mismatch") + expect(await exists(lockDir)).toBe(true) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 500, + timeoutMs: 6_000, + }, + ) + expect(hit).toBe(true) + }) + + test("fails clearly on unwritable lock roots", async () => { + if (process.platform === "win32") return + + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:perm" + + await fs.mkdir(dir, { recursive: true }) + await fs.chmod(dir, 0o500) + + try { + const err = await Flock.withLock(key, async () => {}, { + dir, + staleMs: 100, + timeoutMs: 500, + }).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + const text = err.message + expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true) + } finally { + await fs.chmod(dir, 0o700) + } + }) +}) -- cgit v1.2.3 From a147ad68e6aed8a6a3eeaf2ce1e56f73fab7fa31 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 20:55:14 -0400 Subject: feat(shared): add Effect-idiomatic file lock (EffectFlock) (#22681) --- packages/shared/src/util/effect-flock.ts | 278 +++++++++++++++ .../shared/test/fixture/effect-flock-worker.ts | 64 ++++ packages/shared/test/util/effect-flock.test.ts | 388 +++++++++++++++++++++ 3 files changed, 730 insertions(+) create mode 100644 packages/shared/src/util/effect-flock.ts create mode 100644 packages/shared/test/fixture/effect-flock-worker.ts create mode 100644 packages/shared/test/util/effect-flock.test.ts (limited to 'packages/shared/test') diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts new file mode 100644 index 000000000..d728c0ef1 --- /dev/null +++ b/packages/shared/src/util/effect-flock.ts @@ -0,0 +1,278 @@ +import path from "path" +import os from "os" +import { randomUUID } from "crypto" +import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect" +import type { FileSystem, Scope } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { AppFileSystem } from "../filesystem" +import { Global } from "../global" +import { Hash } from "./hash" + +export namespace EffectFlock { + // --------------------------------------------------------------------------- + // Errors + // --------------------------------------------------------------------------- + + export class LockTimeoutError extends Schema.TaggedErrorClass()("LockTimeoutError", { + key: Schema.String, + }) {} + + export class LockCompromisedError extends Schema.TaggedErrorClass()("LockCompromisedError", { + detail: Schema.String, + }) {} + + class ReleaseError extends Schema.TaggedErrorClass()("ReleaseError", { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }) { + override get message() { + return this.detail + } + } + + /** Internal: signals "lock is held, retry later". Never leaks to callers. */ + class NotAcquired extends Schema.TaggedErrorClass()("NotAcquired", {}) {} + + export type LockError = LockTimeoutError | LockCompromisedError + + // --------------------------------------------------------------------------- + // Timing (baked in — no caller ever overrides these) + // --------------------------------------------------------------------------- + + const STALE_MS = 60_000 + const TIMEOUT_MS = 5 * 60_000 + const BASE_DELAY_MS = 100 + const MAX_DELAY_MS = 2_000 + const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3)) + + const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( + Schedule.either(Schedule.spaced(MAX_DELAY_MS)), + Schedule.jittered, + Schedule.while((meta) => meta.elapsed < TIMEOUT_MS), + ) + + // --------------------------------------------------------------------------- + // Lock metadata schema + // --------------------------------------------------------------------------- + + const LockMetaJson = Schema.fromJsonString( + Schema.Struct({ + token: Schema.String, + pid: Schema.Number, + hostname: Schema.String, + createdAt: Schema.String, + }), + ) + + const decodeMeta = Schema.decodeUnknownSync(LockMetaJson) + const encodeMeta = Schema.encodeSync(LockMetaJson) + + // --------------------------------------------------------------------------- + // Service + // --------------------------------------------------------------------------- + + export interface Interface { + readonly acquire: (key: string, dir?: string) => Effect.Effect + readonly withLock: { + (key: string, dir?: string): (body: Effect.Effect) => Effect.Effect + (body: Effect.Effect, key: string, dir?: string): Effect.Effect + } + } + + export class Service extends Context.Service()("EffectFlock") {} + + // --------------------------------------------------------------------------- + // Layer + // --------------------------------------------------------------------------- + + function wall() { + return performance.timeOrigin + performance.now() + } + + const mtimeMs = (info: FileSystem.File.Info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime() + + const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown" + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const global = yield* Global.Service + const fs = yield* AppFileSystem.Service + const lockRoot = path.join(global.state, "locks") + const hostname = os.hostname() + const ensuredDirs = new Set() + + // -- helpers (close over fs) -- + + const safeStat = (file: string) => + fs.stat(file).pipe( + Effect.catchIf(isPathGone, () => Effect.void), + Effect.orDie, + ) + + const forceRemove = (target: string) => fs.remove(target, { recursive: true }).pipe(Effect.ignore) + + /** Atomic mkdir — returns true if created, false if already exists, dies on other errors. */ + const atomicMkdir = (dir: string) => + fs.makeDirectory(dir, { mode: 0o700 }).pipe( + Effect.as(true), + Effect.catchIf( + (e) => e.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + Effect.orDie, + ) + + /** Write with exclusive create — compromised error if file already exists. */ + const exclusiveWrite = (filePath: string, content: string, lockDir: string, detail: string) => + fs.writeFileString(filePath, content, { flag: "wx" }).pipe( + Effect.catch(() => + Effect.gen(function* () { + yield* forceRemove(lockDir) + return yield* new LockCompromisedError({ detail }) + }), + ), + ) + + const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) { + const bs = yield* safeStat(breakerPath) + if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath) + return false + }) + + const ensureDir = Effect.fnUntraced(function* (dir: string) { + if (ensuredDirs.has(dir)) return + yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) + ensuredDirs.add(dir) + }) + + const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) { + const now = wall() + + const hb = yield* safeStat(heartbeatPath) + if (hb) return now - mtimeMs(hb) > STALE_MS + + const meta = yield* safeStat(metaPath) + if (meta) return now - mtimeMs(meta) > STALE_MS + + const dir = yield* safeStat(lockDir) + if (!dir) return false + + return now - mtimeMs(dir) > STALE_MS + }) + + // -- single lock attempt -- + + type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } + + const tryAcquireLockDir = Effect.fn("EffectFlock.tryAcquire")(function* (lockDir: string) { + const token = randomUUID() + const metaPath = path.join(lockDir, "meta.json") + const heartbeatPath = path.join(lockDir, "heartbeat") + + // Atomic mkdir — the POSIX lock primitive + const created = yield* atomicMkdir(lockDir) + + if (!created) { + if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() + + // Stale — race for breaker ownership + const breakerPath = lockDir + ".breaker" + + const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe( + Effect.as(true), + Effect.catchIf( + (e) => e.reason._tag === "AlreadyExists", + () => cleanStaleBreaker(breakerPath), + ), + Effect.catchIf(isPathGone, () => Effect.succeed(false)), + Effect.orDie, + ) + + if (!claimed) return yield* new NotAcquired() + + // We own the breaker — double-check staleness, nuke, recreate + const recreated = yield* Effect.gen(function* () { + if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false + yield* forceRemove(lockDir) + return yield* atomicMkdir(lockDir) + }).pipe(Effect.ensuring(forceRemove(breakerPath))) + + if (!recreated) return yield* new NotAcquired() + } + + // We own the lock dir — write heartbeat + meta with exclusive create + yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed") + + const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() }) + yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed") + + return { token, metaPath, heartbeatPath, lockDir } satisfies Handle + }) + + // -- retry wrapper (preserves Handle type) -- + + const acquireHandle = (lockfile: string, key: string): Effect.Effect => + tryAcquireLockDir(lockfile).pipe( + Effect.retry({ + while: (err) => err._tag === "NotAcquired", + schedule: retrySchedule, + }), + Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))), + ) + + // -- release -- + + const release = (handle: Handle) => + Effect.gen(function* () { + const raw = yield* fs.readFileString(handle.metaPath).pipe( + Effect.catch((err) => { + if (isPathGone(err)) return Effect.die(new ReleaseError({ detail: "metadata missing" })) + return Effect.die(err) + }), + ) + + const parsed = yield* Effect.try({ + try: () => decodeMeta(raw), + catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }), + }).pipe(Effect.orDie) + + if (parsed.token !== handle.token) return yield* Effect.die(new ReleaseError({ detail: "token mismatch" })) + + yield* forceRemove(handle.lockDir) + }) + + // -- build service -- + + const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) { + const lockDir = dir ?? lockRoot + yield* ensureDir(lockDir) + + const lockfile = path.join(lockDir, Hash.fast(key) + ".lock") + + // acquireRelease: acquire is uninterruptible, release is guaranteed + const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle)) + + // Heartbeat fiber — scoped, so it's interrupted before release runs + yield* fs + .utimes(handle.heartbeatPath, new Date(), new Date()) + .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped) + }) + + const withLock: Interface["withLock"] = Function.dual( + (args) => Effect.isEffect(args[0]), + (body: Effect.Effect, key: string, dir?: string): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + yield* acquire(key, dir) + return yield* body + }), + ), + ) + + return Service.of({ acquire, withLock }) + }), + ) + + export const live = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) +} diff --git a/packages/shared/test/fixture/effect-flock-worker.ts b/packages/shared/test/fixture/effect-flock-worker.ts new file mode 100644 index 000000000..7fd2e144a --- /dev/null +++ b/packages/shared/test/fixture/effect-flock-worker.ts @@ -0,0 +1,64 @@ +import fs from "fs/promises" +import path from "path" +import os from "os" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { Global } from "@opencode-ai/shared/global" + +type Msg = { + key: string + dir: string + holdMs?: number + ready?: string + active?: string + done?: string +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const msg: Msg = JSON.parse(process.argv[2]!) + +const testGlobal = Layer.succeed( + Global.Service, + Global.Service.of({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), + }), +) + +const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) + +async function job() { + if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) + if (msg.active) await fs.writeFile(msg.active, String(process.pid), { flag: "wx" }) + + try { + if (msg.holdMs && msg.holdMs > 0) await sleep(msg.holdMs) + if (msg.done) await fs.appendFile(msg.done, "1\n") + } finally { + if (msg.active) await fs.rm(msg.active, { force: true }) + } +} + +await Effect.runPromise( + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + yield* flock.withLock( + Effect.promise(() => job()), + msg.key, + msg.dir, + ) + }).pipe(Effect.provide(testLayer)), +).catch((err) => { + const text = err instanceof Error ? (err.stack ?? err.message) : String(err) + process.stderr.write(text) + process.exit(1) +}) diff --git a/packages/shared/test/util/effect-flock.test.ts b/packages/shared/test/util/effect-flock.test.ts new file mode 100644 index 000000000..6e094c2e1 --- /dev/null +++ b/packages/shared/test/util/effect-flock.test.ts @@ -0,0 +1,388 @@ +import { describe, expect } from "bun:test" +import { spawn } from "child_process" +import fs from "fs/promises" +import path from "path" +import os from "os" +import { Cause, Effect, Exit, Layer } from "effect" +import { testEffect } from "../lib/effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { Global } from "@opencode-ai/shared/global" +import { Hash } from "@opencode-ai/shared/util/hash" + +function lock(dir: string, key: string) { + return path.join(dir, Hash.fast(key) + ".lock") +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function exists(file: string) { + return fs + .stat(file) + .then(() => true) + .catch(() => false) +} + +async function readJson(p: string): Promise { + return JSON.parse(await fs.readFile(p, "utf8")) +} + +// --------------------------------------------------------------------------- +// Worker subprocess helpers +// --------------------------------------------------------------------------- + +type Msg = { + key: string + dir: string + holdMs?: number + ready?: string + active?: string + done?: string +} + +const root = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts") + +function run(msg: Msg) { + return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { + const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) + proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) + proc.on("close", (code) => { + resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) }) + }) + }) +} + +function spawnWorker(msg: Msg) { + return spawn(process.execPath, [worker, JSON.stringify(msg)], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + }) +} + +function stopWorker(proc: ReturnType) { + if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() + if (process.platform !== "win32" || !proc.pid) { + proc.kill() + return Promise.resolve() + } + return new Promise((resolve) => { + const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) + killProc.on("close", () => { + proc.kill() + resolve() + }) + }) +} + +async function waitForFile(file: string, timeout = 3_000) { + const stop = Date.now() + timeout + while (Date.now() < stop) { + if (await exists(file)) return + await sleep(20) + } + throw new Error(`Timed out waiting for file: ${file}`) +} + +// --------------------------------------------------------------------------- +// Test layer +// --------------------------------------------------------------------------- + +const testGlobal = Layer.succeed( + Global.Service, + Global.Service.of({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), + }), +) + +const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("util.effect-flock", () => { + const it = testEffect(testLayer) + + it.live( + "acquire and release via scoped Effect", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const lockDir = lock(dir, "eflock:acquire") + + yield* Effect.scoped(flock.acquire("eflock:acquire", dir)) + + expect(yield* Effect.promise(() => exists(lockDir))).toBe(false) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "withLock data-first", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + + let hit = false + yield* flock.withLock( + Effect.sync(() => { + hit = true + }), + "eflock:df", + dir, + ) + expect(hit).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "withLock pipeable", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + + let hit = false + yield* Effect.sync(() => { + hit = true + }).pipe(flock.withLock("eflock:pipe", dir)) + expect(hit).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "writes owner metadata", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:meta" + const file = path.join(lock(dir, key), "meta.json") + + yield* Effect.scoped( + Effect.gen(function* () { + yield* flock.acquire(key, dir) + const json = yield* Effect.promise(() => + readJson<{ token?: unknown; pid?: unknown; hostname?: unknown; createdAt?: unknown }>(file), + ) + expect(typeof json.token).toBe("string") + expect(typeof json.pid).toBe("number") + expect(typeof json.hostname).toBe("string") + expect(typeof json.createdAt).toBe("string") + }), + ) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "breaks stale lock dirs", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:stale" + const lockDir = lock(dir, key) + + yield* Effect.promise(async () => { + await fs.mkdir(lockDir, { recursive: true }) + const old = new Date(Date.now() - 120_000) + await fs.utimes(lockDir, old, old) + }) + + let hit = false + yield* flock.withLock( + Effect.sync(() => { + hit = true + }), + key, + dir, + ) + expect(hit).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "recovers from stale breaker", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:stale-breaker" + const lockDir = lock(dir, key) + const breaker = lockDir + ".breaker" + + yield* Effect.promise(async () => { + await fs.mkdir(lockDir, { recursive: true }) + await fs.mkdir(breaker) + const old = new Date(Date.now() - 120_000) + await fs.utimes(lockDir, old, old) + await fs.utimes(breaker, old, old) + }) + + let hit = false + yield* flock.withLock( + Effect.sync(() => { + hit = true + }), + key, + dir, + ) + expect(hit).toBe(true) + expect(yield* Effect.promise(() => exists(breaker))).toBe(false) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "detects compromise when lock dir removed", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:compromised" + const lockDir = lock(dir, key) + + const result = yield* flock + .withLock( + Effect.promise(() => fs.rm(lockDir, { recursive: true, force: true })), + key, + dir, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing") + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "detects token mismatch", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:token" + const lockDir = lock(dir, key) + const meta = path.join(lockDir, "meta.json") + + const result = yield* flock + .withLock( + Effect.promise(async () => { + const json = await readJson<{ token?: string }>(meta) + json.token = "tampered" + await fs.writeFile(meta, JSON.stringify(json, null, 2)) + }), + key, + dir, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch") + expect(yield* Effect.promise(() => exists(lockDir))).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "fails on unwritable lock roots", + Effect.gen(function* () { + if (process.platform === "win32") return + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + + yield* Effect.promise(async () => { + await fs.mkdir(dir, { recursive: true }) + await fs.chmod(dir, 0o500) + }) + + const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit) + expect(String(result)).toContain("PermissionDenied") + yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true }))) + }), + ) + + it.live( + "enforces mutual exclusion under process contention", + () => + Effect.promise(async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-stress-")) + const dir = path.join(tmp, "locks") + const done = path.join(tmp, "done.log") + const active = path.join(tmp, "active") + const n = 16 + + try { + const out = await Promise.all( + Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })), + ) + + expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) + expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) + + const lines = (await fs.readFile(done, "utf8")) + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + expect(lines.length).toBe(n) + } finally { + await fs.rm(tmp, { recursive: true, force: true }) + } + }), + 60_000, + ) + + it.live( + "recovers after a crashed lock owner", + () => + Effect.promise(async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-")) + const dir = path.join(tmp, "locks") + const ready = path.join(tmp, "ready") + + const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 }) + + try { + await waitForFile(ready, 5_000) + await stopWorker(proc) + await new Promise((resolve) => proc.on("close", resolve)) + + // Backdate lock files so they're past STALE_MS (60s) + const lockDir = lock(dir, "eflock:crash") + const old = new Date(Date.now() - 120_000) + await fs.utimes(lockDir, old, old).catch(() => {}) + await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {}) + await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {}) + + const done = path.join(tmp, "done.log") + const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 }) + expect(result.code).toBe(0) + expect(result.stderr.toString()).toBe("") + } finally { + await stopWorker(proc).catch(() => {}) + await fs.rm(tmp, { recursive: true, force: true }) + } + }), + 30_000, + ) +}) -- cgit v1.2.3 From cce05c16658a39d091f658bdb53dcce1e88c66d0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:01:53 -0400 Subject: fix: clean up 49 unused variables, catch params, and stale imports (#22695) --- infra/enterprise.ts | 2 +- packages/app/src/addons/serialize.test.ts | 4 ++-- packages/app/src/app.tsx | 7 +----- packages/app/src/pages/session.tsx | 3 --- packages/app/src/pages/session/file-tabs.tsx | 6 ----- packages/console/app/script/generate-sitemap.ts | 1 - packages/console/app/src/routes/index.tsx | 2 -- packages/console/app/src/routes/user-menu.tsx | 2 +- packages/function/src/api.ts | 2 +- packages/opencode/src/bus/bus.ts | 1 - .../cli/cmd/tui/component/prompt/autocomplete.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 ++-- .../src/cli/cmd/tui/routes/session/permission.tsx | 2 +- .../cli/cmd/tui/routes/session/subagent-footer.tsx | 2 +- packages/opencode/src/cli/network.ts | 2 -- packages/opencode/src/command/index.ts | 3 --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/control-plane/workspace.ts | 4 ++-- packages/opencode/src/mcp/oauth-callback.ts | 2 +- packages/opencode/src/server/fence.ts | 2 +- .../opencode/src/server/instance/middleware.ts | 2 -- packages/opencode/src/server/instance/provider.ts | 3 --- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/test/file/index.test.ts | 2 +- .../opencode/test/fixture/lsp/fake-lsp-server.js | 2 -- packages/opencode/test/provider/transform.test.ts | 2 -- packages/opencode/test/server/session-list.test.ts | 2 +- packages/opencode/test/session/llm.test.ts | 1 - .../test/session/messages-pagination.test.ts | 8 +++---- packages/sdk/js/src/v2/data.ts | 2 +- packages/shared/test/filesystem/filesystem.test.ts | 4 ++-- .../shared/test/fixture/effect-flock-worker.ts | 1 - packages/slack/src/index.ts | 2 +- script/publish.ts | 28 ---------------------- script/stats.ts | 2 +- sdks/vscode/src/extension.ts | 6 ++--- 37 files changed, 32 insertions(+), 94 deletions(-) (limited to 'packages/shared/test') diff --git a/infra/enterprise.ts b/infra/enterprise.ts index 38f0c3c8f..dc336a684 100644 --- a/infra/enterprise.ts +++ b/infra/enterprise.ts @@ -3,7 +3,7 @@ import { shortDomain } from "./stage" const storage = new sst.cloudflare.Bucket("EnterpriseStorage") -const teams = new sst.cloudflare.x.SolidStart("Teams", { +new sst.cloudflare.x.SolidStart("Teams", { domain: shortDomain, path: "packages/enterprise", buildCommand: "bun run build:cloudflare", diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index 7f6780557..6828e60f8 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -180,8 +180,8 @@ describe("SerializeAddon", () => { await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) - const origFg = origLine!.getCell(0)!.getFgColor() - const origBg = origLine!.getCell(0)!.getBgColor() + const _origFg = origLine!.getCell(0)!.getFgColor() + const _origBg = origLine!.getCell(0)!.getBgColor() expect(origLine!.getCell(0)!.isBold()).toBe(1) const serialized = addon.serialize({ range: { start: 0, end: 0 } }) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 35fd36cca..9983548ba 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -10,7 +10,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" -import { type Duration, Effect } from "effect" +import { Effect } from "effect" import { type Component, createMemo, @@ -156,11 +156,6 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -const effectMinDuration = - (duration: Duration.Input) => - (e: Effect.Effect) => - Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0])) - function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e328e3f0c..32df997f7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -433,7 +433,6 @@ export default function Page() { const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasSessionReview = createMemo(() => sessionCount() > 0) const canReview = createMemo(() => !!sync.project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -443,8 +442,6 @@ export default function Page() { review: reviewTab, hasReview: canReview, }) - const contextOpen = tabState.contextOpen - const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index a64dff64e..37bffcd2f 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -378,12 +378,6 @@ export function FileTabContent(props: { tab: string }) { requestAnimationFrame(() => comments.clearFocus()) }) - const cancelCommenting = () => { - const p = path() - if (p) file.setSelectedLines(p, null) - setNote("commenting", null) - } - let prev = { loaded: false, ready: false, diff --git a/packages/console/app/script/generate-sitemap.ts b/packages/console/app/script/generate-sitemap.ts index 89bca6bac..9fd3ba0f0 100755 --- a/packages/console/app/script/generate-sitemap.ts +++ b/packages/console/app/script/generate-sitemap.ts @@ -8,7 +8,6 @@ import { LOCALES, route } from "../src/lib/language.js" const __dirname = dirname(fileURLToPath(import.meta.url)) const BASE_URL = config.baseUrl const PUBLIC_DIR = join(__dirname, "../public") -const ROUTES_DIR = join(__dirname, "../src/routes") const DOCS_DIR = join(__dirname, "../../../web/src/content/docs") interface SitemapEntry { diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index e47134d2b..b5b12a84b 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -31,8 +31,6 @@ export default function Home() { const i18n = useI18n() const language = useLanguage() const githubData = createAsync(() => github()) - const release = createMemo(() => githubData()?.release) - const handleCopyClick = (event: Event) => { const button = event.currentTarget as HTMLButtonElement const text = button.textContent diff --git a/packages/console/app/src/routes/user-menu.tsx b/packages/console/app/src/routes/user-menu.tsx index fa1c1f60b..7b305d8ea 100644 --- a/packages/console/app/src/routes/user-menu.tsx +++ b/packages/console/app/src/routes/user-menu.tsx @@ -6,7 +6,7 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import "./user-menu.css" -const logout = action(async () => { +const _logout = action(async () => { "use server" const auth = await useAuthSession() const event = getRequestEvent() diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 4d8b295ec..68b2d450b 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -181,7 +181,7 @@ export default new Hono<{ Bindings: Env }>() let info const messages: Record = {} data.forEach((d) => { - const [root, type, ...splits] = d.key.split("/") + const [root, type] = d.key.split("/") if (root !== "session") return if (type === "info") { info = d.content diff --git a/packages/opencode/src/bus/bus.ts b/packages/opencode/src/bus/bus.ts index c5e31e6c2..fe9169171 100644 --- a/packages/opencode/src/bus/bus.ts +++ b/packages/opencode/src/bus/bus.ts @@ -4,7 +4,6 @@ import { EffectBridge } from "@/effect/bridge" import { Log } from "../util/log" import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" -import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 2118fe98e..7ca73310b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -111,7 +111,7 @@ export function Autocomplete(props: { const position = createMemo(() => { if (!store.visible) return { x: 0, y: 0, width: 0 } - const dims = dimensions() + dimensions() positionTick() const anchor = props.anchor() const parent = anchor.parent diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f9fd5a9b9..9f0dfa603 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -157,10 +157,10 @@ export function Session() { const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) - const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) + const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") - const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) + const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) const wide = createMemo(() => dimensions().width > 120) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index e0b5002b6..ad824fe48 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -599,7 +599,7 @@ function Prompt>(props: { }) const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) - const renderer = useRenderer() + useRenderer() const content = () => ( (null) - const dimensions = useTerminalDimensions() + useTerminalDimensions() return ( diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 6321c056d..ea281aafb 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -43,8 +43,6 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") - const corsExplicitlySet = process.argv.includes("--cors") - const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 28fb37f27..539ae0dac 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -8,13 +8,10 @@ import z from "zod" import { Config } from "../config" import { MCP } from "../mcp" import { Skill } from "../skill" -import { Log } from "../util/log" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" export namespace Command { - const log = Log.create({ service: "command" }) - type State = { commands: Record } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 63e41f445..58d9343ad 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1095,7 +1095,7 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string } function writable(info: Info) { - const { plugin_origins, ...next } = info + const { plugin_origins: _plugin_origins, ...next } = info return next } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 67583107f..4fef4f932 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -328,7 +328,7 @@ export namespace Workspace { try { const adaptor = await getAdaptor(info.projectID, row.type) await adaptor.remove(info) - } catch (err) { + } catch { log.error("adaptor not available when removing workspace", { type: row.type }) } Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) @@ -404,7 +404,7 @@ export namespace Workspace { return synced(state) }, }) - } catch (error) { + } catch { if (signal?.aborted) throw signal.reason ?? new Error("Request aborted") throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`) } diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index b5b6a7a6e..6babccd77 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -218,7 +218,7 @@ export namespace McpOAuthCallback { log.info("oauth callback server stopped") } - for (const [name, pending] of pendingAuths) { + for (const [_name, pending] of pendingAuths) { clearTimeout(pending.timeout) pending.reject(new Error("OAuth callback server stopped")) } diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index bb41bd7a4..b6dbde008 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -40,7 +40,7 @@ export function parse(headers: Headers) { try { data = JSON.parse(raw) - } catch (err) { + } catch { return } diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 0e29daa9e..5fd1fc25e 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -16,8 +16,6 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } -const OPENCODE_WORKSPACE = process.env.OPENCODE_WORKSPACE - const RULES: Array = [ { path: "/session/status", action: "forward" }, { method: "GET", path: "/session", action: "local" }, diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index 8018dfbea..bbde4c955 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -10,11 +10,8 @@ import { AppRuntime } from "../../effect/app-runtime" import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" -import { Log } from "../../util/log" import { Effect } from "effect" -const log = Log.create({ service: "server" }) - export const ProviderRoutes = lazy(() => new Hono() .get( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4e10fdf2d..b69967689 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1825,7 +1825,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the onSuccess: (output: unknown) => void }): AITool { // Remove $schema property if present (not needed for tool input) - const { $schema, ...toolSchema } = input.schema + const { $schema: _, ...toolSchema } = input.schema return tool({ id: "StructuredOutput" as any, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ef55758a5..b9870d194 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -176,7 +176,7 @@ export namespace ToolRegistry { } } - const cfg = yield* config.get() + yield* config.get() const questionEnabled = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index d8203ac12..877e2ae0a 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -276,7 +276,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty array buffer on error for images", async () => { await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "broken.png") + const _filepath = path.join(tmp.path, "broken.png") // Don't create the file await Instance.provide({ diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index 39e578801..be62f96f3 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -1,8 +1,6 @@ // Simple JSON-RPC 2.0 LSP-like fake server over stdio // Implements a minimal LSP handshake and triggers a request upon notification -const net = require("net") - let nextId = 1 function encode(message) { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4952a126b..0e0810d0e 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2,8 +2,6 @@ import { describe, expect, test } from "bun:test" import { ProviderTransform } from "../../src/provider/transform" import { ModelID, ProviderID } from "../../src/provider/schema" -const OUTPUT_TOKEN_MAX = 32000 - describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 8c86dc2f0..75adb7f9f 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -67,7 +67,7 @@ describe("session.list", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await svc.create({ title: "new-session" }) + await svc.create({ title: "new-session" }) const futureStart = Date.now() + 86400000 const sessions = [...svc.list({ start: futureStart })] diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index e908545d4..f25ecc356 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1181,7 +1181,6 @@ describe("session.llm.stream", () => { const providerID = "google" const modelID = "gemini-2.5-flash" const fixture = await loadFixture(providerID, modelID) - const provider = fixture.provider const model = fixture.model const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent` diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 668918ec8..f728bd364 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -724,7 +724,7 @@ describe("MessageV2.filterCompacted", () => { const u1 = await addUser(session.id, "hello") await addCompactionPart(session.id, u1) - const u2 = await addUser(session.id, "world") + await addUser(session.id, "world") const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) expect(result).toHaveLength(2) @@ -748,7 +748,7 @@ describe("MessageV2.filterCompacted", () => { isRetryable: true, }).toObject() as MessageV2.Assistant["error"] await addAssistant(session.id, u1, { summary: true, finish: "end_turn", error }) - const u2 = await addUser(session.id, "retry") + await addUser(session.id, "retry") const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) // Error assistant doesn't add to completed, so compaction boundary never triggers @@ -770,7 +770,7 @@ describe("MessageV2.filterCompacted", () => { // summary=true but no finish await addAssistant(session.id, u1, { summary: true }) - const u2 = await addUser(session.id, "next") + await addUser(session.id, "next") const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) expect(result).toHaveLength(3) @@ -892,7 +892,7 @@ describe("MessageV2 consistency", () => { directory: root, fn: async () => { const session = await svc.create({}) - const ids = await fill(session.id, 4) + await fill(session.id, 4) const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) const all = Array.from(MessageV2.stream(session.id)).reverse() diff --git a/packages/sdk/js/src/v2/data.ts b/packages/sdk/js/src/v2/data.ts index baae6f278..776b168ad 100644 --- a/packages/sdk/js/src/v2/data.ts +++ b/packages/sdk/js/src/v2/data.ts @@ -5,7 +5,7 @@ export const message = { info: UserMessage parts: Part[] } { - const { parts, ...rest } = input + const { parts: _parts, ...rest } = input const info: UserMessage = { ...rest, diff --git a/packages/shared/test/filesystem/filesystem.test.ts b/packages/shared/test/filesystem/filesystem.test.ts index ce990d379..b49026bcb 100644 --- a/packages/shared/test/filesystem/filesystem.test.ts +++ b/packages/shared/test/filesystem/filesystem.test.ts @@ -290,7 +290,7 @@ describe("AppFileSystem", () => { it( "exists works", Effect.gen(function* () { - const fs = yield* AppFileSystem.Service + yield* AppFileSystem.Service const filesys = yield* FileSystem.FileSystem const tmp = yield* filesys.makeTempDirectoryScoped() const file = path.join(tmp, "exists.txt") @@ -304,7 +304,7 @@ describe("AppFileSystem", () => { it( "remove works", Effect.gen(function* () { - const fs = yield* AppFileSystem.Service + yield* AppFileSystem.Service const filesys = yield* FileSystem.FileSystem const tmp = yield* filesys.makeTempDirectoryScoped() const file = path.join(tmp, "delete-me.txt") diff --git a/packages/shared/test/fixture/effect-flock-worker.ts b/packages/shared/test/fixture/effect-flock-worker.ts index 7fd2e144a..c9116c2d5 100644 --- a/packages/shared/test/fixture/effect-flock-worker.ts +++ b/packages/shared/test/fixture/effect-flock-worker.ts @@ -1,5 +1,4 @@ import fs from "fs/promises" -import path from "path" import os from "os" import { Effect, Layer } from "effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 123710aa4..85d685129 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -27,7 +27,7 @@ const sessions = new Map tags -- Highlights with the same source attribute get grouped together ---> - - - -` - console.log("=== publishing ===\n") const pkgjsons = await Array.fromAsync( diff --git a/script/stats.ts b/script/stats.ts index 318b590af..9201e26e4 100755 --- a/script/stats.ts +++ b/script/stats.ts @@ -193,7 +193,7 @@ console.log("Fetching GitHub releases for anomalyco/opencode...\n") const releases = await fetchReleases() console.log(`\nFetched ${releases.length} releases total\n`) -const { total: githubTotal, stats } = calculate(releases) +const { total: githubTotal } = calculate(releases) console.log("Fetching npm all-time downloads for opencode-ai...\n") const npmDownloads = await fetchNpmDownloads("opencode-ai") diff --git a/sdks/vscode/src/extension.ts b/sdks/vscode/src/extension.ts index 772da9cc2..693e7267e 100644 --- a/sdks/vscode/src/extension.ts +++ b/sdks/vscode/src/extension.ts @@ -6,11 +6,11 @@ import * as vscode from "vscode" const TERMINAL_NAME = "opencode" export function activate(context: vscode.ExtensionContext) { - let openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => { + const openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => { await openTerminal() }) - let openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => { + const openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => { // An opencode terminal already exists => focus it const existingTerminal = vscode.window.terminals.find((t) => t.name === TERMINAL_NAME) if (existingTerminal) { @@ -40,7 +40,7 @@ export function activate(context: vscode.ExtensionContext) { } }) - context.subscriptions.push(openTerminalDisposable, addFilepathDisposable) + context.subscriptions.push(openNewTerminalDisposable, openTerminalDisposable, addFilepathDisposable) async function openTerminal() { // Create a new terminal in split screen -- cgit v1.2.3 From 8aa0f9fe9515ba0234ab6a0a58c868068913bb05 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:50:47 -0400 Subject: feat: enable type-aware no-base-to-string rule, fix 56 violations (#22750) --- .oxlintrc.json | 4 ++++ .../workspace/[id]/billing/black-section.tsx | 4 ++-- .../[id]/billing/monthly-limit-section.tsx | 4 ++-- .../workspace/[id]/billing/reload-section.tsx | 22 +++++++++++----------- .../src/routes/workspace/[id]/go/lite-section.tsx | 4 ++-- .../src/routes/workspace/[id]/keys/key-section.tsx | 8 ++++---- .../workspace/[id]/members/member-section.tsx | 22 +++++++++++----------- .../src/routes/workspace/[id]/model-section.tsx | 8 ++++---- .../src/routes/workspace/[id]/provider-section.tsx | 17 ++++++++++------- .../workspace/[id]/settings/settings-section.tsx | 4 ++-- packages/opencode/script/schema.ts | 2 +- packages/opencode/src/effect/logger.ts | 2 ++ .../opencode/src/plugin/github-copilot/copilot.ts | 2 +- packages/opencode/src/provider/transform.ts | 4 ++-- packages/opencode/src/pty/service.ts | 2 +- packages/opencode/src/snapshot/snapshot.ts | 2 +- packages/opencode/src/tool/apply_patch.ts | 10 +++++++++- packages/opencode/src/util/error.ts | 1 + packages/opencode/src/v2/session-entry.ts | 1 + packages/opencode/test/config/config.test.ts | 4 ++-- packages/shared/src/util/retry.ts | 1 + packages/shared/test/util/effect-flock.test.ts | 1 + .../.storybook/mocks/app/context/language.ts | 1 + packages/ui/src/components/file.tsx | 3 +++ packages/ui/src/components/session-turn.tsx | 1 + packages/web/src/components/share/part.tsx | 8 +++++++- 26 files changed, 87 insertions(+), 55 deletions(-) (limited to 'packages/shared/test') diff --git a/.oxlintrc.json b/.oxlintrc.json index e16c8408d..a0b620649 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,9 +1,13 @@ { "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json", + "options": { + "typeAware": true + }, "categories": { "suspicious": "warn" }, "rules": { + "typescript/no-base-to-string": "warn", // Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield "require-yield": "off", // SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx index b8f089864..5b4389466 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx @@ -116,9 +116,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) = const setUseBalance = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const useBalance = form.get("useBalance")?.toString() === "true" + const useBalance = (form.get("useBalance") as string | null) === "true" return json( await withActor(async () => { diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx index ef54b8409..7da1de238 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx @@ -10,11 +10,11 @@ import { formError, localizeError } from "~/lib/form-error" const setMonthlyLimit = action(async (form: FormData) => { "use server" - const limit = form.get("limit")?.toString() + const limit = form.get("limit") as string | null if (!limit) return { error: formError.limitRequired } const numericLimit = parseInt(limit) if (numericLimit < 0) return { error: formError.monthlyLimitInvalid } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx index a25963ab0..c9a72c087 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx @@ -12,7 +12,7 @@ import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localiz const reload = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: queryBillingInfo.key, @@ -21,11 +21,11 @@ const reload = action(async (form: FormData) => { const setReload = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const reloadValue = form.get("reload")?.toString() === "true" - const amountStr = form.get("reloadAmount")?.toString() - const triggerStr = form.get("reloadTrigger")?.toString() + const reloadValue = (form.get("reload") as string | null) === "true" + const amountStr = form.get("reloadAmount") as string | null + const triggerStr = form.get("reloadTrigger") as string | null const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null @@ -91,8 +91,8 @@ export function ReloadSection() { const info = billingInfo()! setStore("show", true) setStore("reload", true) - setStore("reloadAmount", info.reloadAmount.toString()) - setStore("reloadTrigger", info.reloadTrigger.toString()) + setStore("reloadAmount", String(info.reloadAmount)) + setStore("reloadTrigger", String(info.reloadTrigger)) } function hide() { @@ -152,11 +152,11 @@ export function ReloadSection() { data-component="input" name="reloadAmount" type="number" - min={billingInfo()?.reloadAmountMin.toString()} + min={String(billingInfo()?.reloadAmountMin ?? "")} step="1" value={store.reloadAmount} onInput={(e) => setStore("reloadAmount", e.currentTarget.value)} - placeholder={billingInfo()?.reloadAmount.toString()} + placeholder={String(billingInfo()?.reloadAmount ?? "")} disabled={!store.reload} /> @@ -166,11 +166,11 @@ export function ReloadSection() { data-component="input" name="reloadTrigger" type="number" - min={billingInfo()?.reloadTriggerMin.toString()} + min={String(billingInfo()?.reloadTriggerMin ?? "")} step="1" value={store.reloadTrigger} onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)} - placeholder={billingInfo()?.reloadTrigger.toString()} + placeholder={String(billingInfo()?.reloadTrigger ?? "")} disabled={!store.reload} /> diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 95ff7af2b..d0f812182 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -120,9 +120,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) = const setLiteUseBalance = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const useBalance = form.get("useBalance")?.toString() === "true" + const useBalance = (form.get("useBalance") as string | null) === "true" return json( await withActor(async () => { diff --git a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx index 837ab743a..cb273a422 100644 --- a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx @@ -12,18 +12,18 @@ import { formError, localizeError } from "~/lib/form-error" const removeKey = action(async (form: FormData) => { "use server" - const id = form.get("id")?.toString() + const id = form.get("id") as string | null if (!id) return { error: formError.idRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key }) }, "key.remove") const createKey = action(async (form: FormData) => { "use server" - const name = form.get("name")?.toString().trim() + const name = (form.get("name") as string | null)?.trim() if (!name) return { error: formError.nameRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx index 5a440632f..00edb400c 100644 --- a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx @@ -24,13 +24,13 @@ const listMembers = query(async (workspaceID: string) => { const inviteMember = action(async (form: FormData) => { "use server" - const email = form.get("email")?.toString().trim() + const email = (form.get("email") as string | null)?.trim() if (!email) return { error: formError.emailRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const role = form.get("role")?.toString() as (typeof UserRole)[number] + const role = form.get("role") as (typeof UserRole)[number] | null if (!role) return { error: formError.roleRequired } - const limit = form.get("limit")?.toString() + const limit = form.get("limit") as string | null const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid } return json( @@ -47,9 +47,9 @@ const inviteMember = action(async (form: FormData) => { const removeMember = action(async (form: FormData) => { "use server" - const id = form.get("id")?.toString() + const id = form.get("id") as string | null if (!id) return { error: formError.idRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( @@ -66,13 +66,13 @@ const removeMember = action(async (form: FormData) => { const updateMember = action(async (form: FormData) => { "use server" - const id = form.get("id")?.toString() + const id = form.get("id") as string | null if (!id) return { error: formError.idRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const role = form.get("role")?.toString() as (typeof UserRole)[number] + const role = form.get("role") as (typeof UserRole)[number] | null if (!role) return { error: formError.roleRequired } - const limit = form.get("limit")?.toString() + const limit = form.get("limit") as string | null const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid } @@ -118,7 +118,7 @@ function MemberRow(props: { } setStore("editing", true) setStore("selectedRole", props.member.role) - setStore("limit", props.member.monthlyLimit?.toString() ?? "") + setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "") } function hide() { diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx index bf19f81cd..b9cdf3bc3 100644 --- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -67,11 +67,11 @@ const getModelsInfo = query(async (workspaceID: string) => { const updateModel = action(async (form: FormData) => { "use server" - const model = form.get("model")?.toString() + const model = form.get("model") as string | null if (!model) return { error: formError.modelRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const enabled = form.get("enabled")?.toString() === "true" + const enabled = (form.get("enabled") as string | null) === "true" return json( withActor(async () => { if (enabled) { @@ -163,7 +163,7 @@ export function ModelSection() {
- +