From 6345810764bea42b6db2fcb41c17380b57b10d7e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 18:44:24 -0400 Subject: [PATCH 1/4] fix(util): no filesystem side effects at global module load --- packages/core/test/global.test.ts | 2 +- .../core/test/instructions/builtins.test.ts | 7 ++- packages/util/src/global.ts | 46 ++++++++++------- packages/util/test/global.test.ts | 51 +++++++++++++++++++ 4 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 packages/util/test/global.test.ts diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts index f4213785fa57..efbde78d4ef4 100644 --- a/packages/core/test/global.test.ts +++ b/packages/core/test/global.test.ts @@ -10,7 +10,7 @@ describe("global paths", () => { expect(Global.make().tmp).toBe(Global.Path.tmp) }) - test("tmp path is created on module load", async () => { + test("tmp path is created on first access", async () => { expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true) }) }) diff --git a/packages/core/test/instructions/builtins.test.ts b/packages/core/test/instructions/builtins.test.ts index 434c5509618d..c2ff8a9702c9 100644 --- a/packages/core/test/instructions/builtins.test.ts +++ b/packages/core/test/instructions/builtins.test.ts @@ -1,4 +1,6 @@ import { describe, expect } from "bun:test" +import os from "os" +import path from "path" import { Effect, Layer } from "effect" import * as TestClock from "effect/testing/TestClock" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -16,6 +18,7 @@ const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core")) const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo")) const timestamp = Date.parse("2026-06-03T12:00:00.000Z") const sessionID = SessionSchema.ID.make("ses_builtin_test") +const temporary = path.join(os.tmpdir(), "opencode-instruction-builtins-test") const localDate = (time: number) => new Date(time).toDateString() const locationLayer = Layer.succeed( Location.Service, @@ -29,7 +32,7 @@ const locationLayer = Layer.succeed( const it = testEffect( AppNodeBuilder.build(InstructionBuiltIns.node, [ [Location.node, locationLayer], - [Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })], + [Global.node, Global.layerWith({ config: path.join(temporary, "config"), tmp: temporary })], ]), ) @@ -49,7 +52,7 @@ describe("InstructionBuiltIns", () => { ` Workspace root folder: ${projectDirectory}`, " Is directory a git repo: yes", ` Platform: ${process.platform}`, - " Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.", + ` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`, "", "", `Today's date: ${localDate(timestamp)}`, diff --git a/packages/util/src/global.ts b/packages/util/src/global.ts index a350db440076..fa582a0c7b5a 100644 --- a/packages/util/src/global.ts +++ b/packages/util/src/global.ts @@ -1,5 +1,5 @@ import path from "path" -import fs from "fs/promises" +import fs from "fs" import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import os from "os" import { Context, Effect, Layer } from "effect" @@ -12,8 +12,7 @@ const cache = path.join(xdgCache!, app) const config = path.join(xdgConfig!, app) const state = path.join(xdgState!, app) const tmp = path.join(os.tmpdir(), app) - -await fs.mkdir(tmp, { recursive: true }) +const resolvedTmp: { value?: string } = {} const paths = { get home() { @@ -26,22 +25,18 @@ const paths = { cache, config, state, - tmp: await fs.realpath(tmp), + get tmp() { + if (resolvedTmp.value) return resolvedTmp.value + fs.mkdirSync(tmp, { recursive: true }) + resolvedTmp.value = fs.realpathSync(tmp) + return resolvedTmp.value + }, } export const Path = paths Flock.setGlobal({ state }) -await Promise.all([ - fs.mkdir(Path.data, { recursive: true }), - fs.mkdir(Path.config, { recursive: true }), - fs.mkdir(Path.state, { recursive: true }), - fs.mkdir(Path.log, { recursive: true }), - fs.mkdir(Path.bin, { recursive: true }), - fs.mkdir(Path.repos, { recursive: true }), -]) - export class Service extends Context.Service()("@opencode/Global") {} export interface Interface { @@ -71,17 +66,30 @@ export function make(input: Partial = {}): Interface { } } +const acquire = (input: Partial) => + Effect.gen(function* () { + const service = Service.of(make(input)) + yield* Effect.promise(() => + Promise.all([ + fs.promises.mkdir(service.data, { recursive: true }), + fs.promises.mkdir(service.config, { recursive: true }), + fs.promises.mkdir(service.state, { recursive: true }), + fs.promises.mkdir(service.log, { recursive: true }), + fs.promises.mkdir(service.bin, { recursive: true }), + fs.promises.mkdir(service.repos, { recursive: true }), + fs.promises.mkdir(service.tmp, { recursive: true }), + ]), + ) + return service + }) + const layer = Layer.effect( Service, - Effect.sync(() => Service.of(make({ config: process.env.OPENCODE_CONFIG_DIR ?? Path.config }))), + Effect.suspend(() => acquire({ config: process.env.OPENCODE_CONFIG_DIR ?? Path.config })), ) export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) -export const layerWith = (input: Partial) => - Layer.effect( - Service, - Effect.sync(() => Service.of(make(input))), - ) +export const layerWith = (input: Partial) => Layer.effect(Service, acquire(input)) export * as Global from "./global.js" diff --git a/packages/util/test/global.test.ts b/packages/util/test/global.test.ts new file mode 100644 index 000000000000..cac65389f4d6 --- /dev/null +++ b/packages/util/test/global.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs" +import os from "os" +import path from "path" +import { pathToFileURL } from "url" +import { Effect, Layer } from "effect" +import { Global } from "../src/global.js" + +describe("global", () => { + test("importing the module does not create directories", async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-global-import-")) + const directories = ["data", "cache", "config", "state", "tmp"].map((directory) => path.join(root, directory)) + const module = pathToFileURL(path.join(import.meta.dir, "../src/global.ts")).href + const result = Bun.spawnSync({ + cmd: [process.execPath, "-e", `await import(${JSON.stringify(module)})`], + env: { + ...process.env, + XDG_DATA_HOME: directories[0], + XDG_CACHE_HOME: directories[1], + XDG_CONFIG_HOME: directories[2], + XDG_STATE_HOME: directories[3], + TMPDIR: directories[4], + }, + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + directories.forEach((directory) => expect(fs.existsSync(path.join(directory, "opencode"))).toBe(false)) + }) + + test("building the layer creates service directories", async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-global-layer-")) + const directories = { + data: path.join(root, "data"), + config: path.join(root, "config"), + state: path.join(root, "state"), + log: path.join(root, "log"), + bin: path.join(root, "bin"), + repos: path.join(root, "repos"), + tmp: path.join(root, "tmp"), + } + + await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith(directories)))) + + await Promise.all( + Object.values(directories).map(async (directory) => { + expect((await fs.promises.stat(directory)).isDirectory()).toBe(true) + }), + ) + }) +}) From 9d34a927d6ad463141009aee31870960754b28b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 18:49:23 -0400 Subject: [PATCH 2/4] refactor(util): simplify global directory acquisition --- packages/core/test/global.test.ts | 12 +++++------- packages/core/test/instructions/builtins.test.ts | 5 ++--- packages/util/src/global.ts | 16 ++++++---------- packages/util/test/global.test.ts | 14 ++++++-------- 4 files changed, 19 insertions(+), 28 deletions(-) diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts index efbde78d4ef4..943c673206bf 100644 --- a/packages/core/test/global.test.ts +++ b/packages/core/test/global.test.ts @@ -5,12 +5,10 @@ import path from "path" import { Global } from "@opencode-ai/util/global" describe("global paths", () => { - test("tmp path is the canonical system temp directory", async () => { - expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode"))) - expect(Global.make().tmp).toBe(Global.Path.tmp) - }) - - test("tmp path is created on first access", async () => { - expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true) + test("tmp path is canonical and created on first access", async () => { + const tmp = Global.Path.tmp + expect(tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode"))) + expect(Global.make().tmp).toBe(tmp) + expect((await fs.stat(tmp)).isDirectory()).toBe(true) }) }) diff --git a/packages/core/test/instructions/builtins.test.ts b/packages/core/test/instructions/builtins.test.ts index c2ff8a9702c9..31c3b30764ea 100644 --- a/packages/core/test/instructions/builtins.test.ts +++ b/packages/core/test/instructions/builtins.test.ts @@ -1,6 +1,5 @@ import { describe, expect } from "bun:test" import os from "os" -import path from "path" import { Effect, Layer } from "effect" import * as TestClock from "effect/testing/TestClock" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -18,7 +17,7 @@ const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core")) const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo")) const timestamp = Date.parse("2026-06-03T12:00:00.000Z") const sessionID = SessionSchema.ID.make("ses_builtin_test") -const temporary = path.join(os.tmpdir(), "opencode-instruction-builtins-test") +const temporary = os.tmpdir() const localDate = (time: number) => new Date(time).toDateString() const locationLayer = Layer.succeed( Location.Service, @@ -32,7 +31,7 @@ const locationLayer = Layer.succeed( const it = testEffect( AppNodeBuilder.build(InstructionBuiltIns.node, [ [Location.node, locationLayer], - [Global.node, Global.layerWith({ config: path.join(temporary, "config"), tmp: temporary })], + [Global.node, Global.layerWith({ config: temporary, tmp: temporary })], ]), ) diff --git a/packages/util/src/global.ts b/packages/util/src/global.ts index fa582a0c7b5a..5ced9f962fa5 100644 --- a/packages/util/src/global.ts +++ b/packages/util/src/global.ts @@ -58,7 +58,7 @@ export function make(input: Partial = {}): Interface { cache: Path.cache, config: Path.config, state: Path.state, - tmp: Path.tmp, + tmp: input.tmp ?? Path.tmp, bin: Path.bin, log: Path.log, repos: Path.repos, @@ -70,15 +70,11 @@ const acquire = (input: Partial) => Effect.gen(function* () { const service = Service.of(make(input)) yield* Effect.promise(() => - Promise.all([ - fs.promises.mkdir(service.data, { recursive: true }), - fs.promises.mkdir(service.config, { recursive: true }), - fs.promises.mkdir(service.state, { recursive: true }), - fs.promises.mkdir(service.log, { recursive: true }), - fs.promises.mkdir(service.bin, { recursive: true }), - fs.promises.mkdir(service.repos, { recursive: true }), - fs.promises.mkdir(service.tmp, { recursive: true }), - ]), + Promise.all( + [service.data, service.config, service.state, service.log, service.bin, service.repos, service.tmp].map( + (directory) => fs.promises.mkdir(directory, { recursive: true }), + ), + ), ) return service }) diff --git a/packages/util/test/global.test.ts b/packages/util/test/global.test.ts index cac65389f4d6..f052ddfa8522 100644 --- a/packages/util/test/global.test.ts +++ b/packages/util/test/global.test.ts @@ -7,8 +7,8 @@ import { Effect, Layer } from "effect" import { Global } from "../src/global.js" describe("global", () => { - test("importing the module does not create directories", async () => { - const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-global-import-")) + test("importing the module does not create directories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-import-")) const directories = ["data", "cache", "config", "state", "tmp"].map((directory) => path.join(root, directory)) const module = pathToFileURL(path.join(import.meta.dir, "../src/global.ts")).href const result = Bun.spawnSync({ @@ -26,10 +26,11 @@ describe("global", () => { expect(result.exitCode, result.stderr.toString()).toBe(0) directories.forEach((directory) => expect(fs.existsSync(path.join(directory, "opencode"))).toBe(false)) + fs.rmSync(root, { recursive: true, force: true }) }) test("building the layer creates service directories", async () => { - const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-global-layer-")) + const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-layer-")) const directories = { data: path.join(root, "data"), config: path.join(root, "config"), @@ -42,10 +43,7 @@ describe("global", () => { await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith(directories)))) - await Promise.all( - Object.values(directories).map(async (directory) => { - expect((await fs.promises.stat(directory)).isDirectory()).toBe(true) - }), - ) + Object.values(directories).forEach((directory) => expect(fs.statSync(directory).isDirectory()).toBe(true)) + fs.rmSync(root, { recursive: true, force: true }) }) }) From a93c9135d463b3102e3b5c4e6bd12dcee3332754 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 19:42:37 -0400 Subject: [PATCH 3/4] fix(util): create log directory before opening logger --- packages/core/test/instruction-discovery.test.ts | 12 +++++++----- packages/util/src/observability/logging.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index aa5c759b63e2..b4e8b08491f2 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import fs from "fs/promises" +import os from "os" import path from "path" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" @@ -15,6 +16,7 @@ import { testEffect } from "./lib/effect" import { readInitial, readUpdate, state } from "./lib/instructions" const it = testEffect(Layer.empty) +const testConfig = path.join(os.tmpdir(), "opencode-instruction-discovery-test") const instructionLayer = (input: { config: string @@ -147,7 +149,7 @@ describe("InstructionDiscovery", () => { Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ - config: "/global", + config: testConfig, filesystemLayer: failingFS, locationServiceLayer: Layer.succeed( Location.Service, @@ -183,7 +185,7 @@ describe("InstructionDiscovery", () => { Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ - config: "/global", + config: testConfig, filesystemLayer: racingFS, locationServiceLayer: Layer.succeed( Location.Service, @@ -222,7 +224,7 @@ describe("InstructionDiscovery", () => { Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ - config: "/global", + config: testConfig, filesystemLayer: observingFS, locationServiceLayer: Layer.succeed( Location.Service, @@ -250,7 +252,7 @@ describe("InstructionDiscovery", () => { Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ - config: "/global", + config: testConfig, project: false, filesystemLayer: Layer.effect( FSUtil.Service, @@ -277,7 +279,7 @@ describe("InstructionDiscovery", () => { Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ - config: "/global", + config: testConfig, filesystemLayer: Layer.effect( FSUtil.Service, FSUtil.Service.pipe( diff --git a/packages/util/src/observability/logging.ts b/packages/util/src/observability/logging.ts index b8e162bad3cd..458ca2245d8f 100644 --- a/packages/util/src/observability/logging.ts +++ b/packages/util/src/observability/logging.ts @@ -1,4 +1,4 @@ -import { Formatter, Logger, type LogLevel } from "effect" +import { Effect, FileSystem, Formatter, Logger, type LogLevel } from "effect" import path from "path" import { Global } from "../global.js" import { runID } from "./shared.js" @@ -53,7 +53,11 @@ export function file(local = true, channel = "local") { export function fileLogger(target = file(), id: string = runID) { // Do not set batchWindow to 0; it causes high idle CPU usage. - return Logger.toFile(formatter(id), target, { flag: "a" }) + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(path.dirname(target), { recursive: true }) + return yield* Logger.toFile(formatter(id), target, { flag: "a" }) + }) } const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n")) From 88eae987a2922192f87d7a6f37d47ced8be62d7a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 19:58:12 -0400 Subject: [PATCH 4/4] refactor(util): canonicalize tmp during layer acquisition --- packages/core/test/global.test.ts | 14 -------- packages/util/src/global.ts | 12 +++---- packages/util/test/global.test.ts | 54 ++++++++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 27 deletions(-) delete mode 100644 packages/core/test/global.test.ts diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts deleted file mode 100644 index 943c673206bf..000000000000 --- a/packages/core/test/global.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, test } from "bun:test" -import fs from "fs/promises" -import os from "os" -import path from "path" -import { Global } from "@opencode-ai/util/global" - -describe("global paths", () => { - test("tmp path is canonical and created on first access", async () => { - const tmp = Global.Path.tmp - expect(tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode"))) - expect(Global.make().tmp).toBe(tmp) - expect((await fs.stat(tmp)).isDirectory()).toBe(true) - }) -}) diff --git a/packages/util/src/global.ts b/packages/util/src/global.ts index 5ced9f962fa5..3e05ca975975 100644 --- a/packages/util/src/global.ts +++ b/packages/util/src/global.ts @@ -12,7 +12,6 @@ const cache = path.join(xdgCache!, app) const config = path.join(xdgConfig!, app) const state = path.join(xdgState!, app) const tmp = path.join(os.tmpdir(), app) -const resolvedTmp: { value?: string } = {} const paths = { get home() { @@ -25,12 +24,7 @@ const paths = { cache, config, state, - get tmp() { - if (resolvedTmp.value) return resolvedTmp.value - fs.mkdirSync(tmp, { recursive: true }) - resolvedTmp.value = fs.realpathSync(tmp) - return resolvedTmp.value - }, + tmp, } export const Path = paths @@ -52,6 +46,7 @@ export interface Interface { } export function make(input: Partial = {}): Interface { + // The acquired service canonicalizes default tmp; use it instead of Path.tmp for path comparisons. return { home: Path.home, data: Path.data, @@ -76,7 +71,8 @@ const acquire = (input: Partial) => ), ), ) - return service + const canonicalTmp = yield* Effect.promise(() => fs.promises.realpath(service.tmp)) + return Service.of({ ...service, tmp: input.tmp ?? canonicalTmp }) }) const layer = Layer.effect( diff --git a/packages/util/test/global.test.ts b/packages/util/test/global.test.ts index f052ddfa8522..9b1ed8e63eaa 100644 --- a/packages/util/test/global.test.ts +++ b/packages/util/test/global.test.ts @@ -3,7 +3,7 @@ import fs from "fs" import os from "os" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer } from "effect" +import { Context, Effect, Layer } from "effect" import { Global } from "../src/global.js" describe("global", () => { @@ -12,7 +12,7 @@ describe("global", () => { const directories = ["data", "cache", "config", "state", "tmp"].map((directory) => path.join(root, directory)) const module = pathToFileURL(path.join(import.meta.dir, "../src/global.ts")).href const result = Bun.spawnSync({ - cmd: [process.execPath, "-e", `await import(${JSON.stringify(module)})`], + cmd: [process.execPath, "-e", `const { Global } = await import(${JSON.stringify(module)}); void Global.Path.tmp`], env: { ...process.env, XDG_DATA_HOME: directories[0], @@ -29,7 +29,7 @@ describe("global", () => { fs.rmSync(root, { recursive: true, force: true }) }) - test("building the layer creates service directories", async () => { + test("building layerWith creates service directories and preserves an explicit tmp", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-layer-")) const directories = { data: path.join(root, "data"), @@ -38,12 +38,56 @@ describe("global", () => { log: path.join(root, "log"), bin: path.join(root, "bin"), repos: path.join(root, "repos"), - tmp: path.join(root, "tmp"), + tmp: path.join(root, "nested", "..", "tmp"), } - await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith(directories)))) + const context = await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith(directories)))) Object.values(directories).forEach((directory) => expect(fs.statSync(directory).isDirectory()).toBe(true)) + expect(Context.get(context, Global.Service).tmp).toBe(directories.tmp) + fs.rmSync(root, { recursive: true, force: true }) + }) + + test("building a layer with default tmp creates and canonicalizes it", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-layer-")) + const directories = ["data", "cache", "config", "state", "tmp"].map((directory) => path.join(root, directory)) + const module = pathToFileURL(path.join(import.meta.dir, "../src/global.ts")).href + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "-e", + ` + import { Context, Effect, Layer } from "effect" + const { Global } = await import(${JSON.stringify(module)}) + const context = await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith({})))) + process.stdout.write(Context.get(context, Global.Service).tmp) + `, + ], + cwd: path.join(import.meta.dir, ".."), + env: { + ...process.env, + XDG_DATA_HOME: directories[0], + XDG_CACHE_HOME: directories[1], + XDG_CONFIG_HOME: directories[2], + XDG_STATE_HOME: directories[3], + TMPDIR: directories[4], + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(result.stdout.toString()).toBe(fs.realpathSync(path.join(directories[4], "opencode"))) + const created = [ + path.join(directories[0], "opencode"), + path.join(directories[1], "opencode", "bin"), + path.join(directories[2], "opencode"), + path.join(directories[3], "opencode"), + path.join(directories[0], "opencode", "log"), + path.join(directories[0], "opencode", "repos"), + path.join(directories[4], "opencode"), + ] + created.forEach((directory) => expect(fs.statSync(directory).isDirectory()).toBe(true)) fs.rmSync(root, { recursive: true, force: true }) }) })