Skip to content

Commit 1fcafcb

Browse files
committed
fix(tui): cover instance inputs in the SIGUSR2 reload gate
Review follow-ups: - Read failures can no longer wedge reload: the cached read runs through catchCause and invalidate() always executes. An unreadable config is treated as changed (the previous unconditional-dispose behavior), so a malformed file neither skips invalidation nor pins the cache. - The gate now also covers per-instance on-disk inputs (project config, agent/command/mode/plugin files, OPENCODE_CONFIG) via a content fingerprint captured at instance boot, so editing those and signaling still reloads the instance. Theme files stay excluded. - Config comparison is key-order insensitive (canonicalEquals), so pure key reordering no longer counts as a change.
1 parent f96a88f commit 1fcafcb

7 files changed

Lines changed: 354 additions & 21 deletions

File tree

‎packages/opencode/src/cli/tui/worker.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ServerAuth } from "@/server/auth"
77
import { writeHeapSnapshot } from "node:v8"
88
import { Heap } from "@/cli/heap"
99
import { AppRuntime } from "@/effect/app-runtime"
10-
import { reloadIfGlobalConfigChanged } from "@/server/global-lifecycle"
10+
import { reloadIfConfigChanged } from "@/server/global-lifecycle"
1111

1212
Heap.start()
1313

@@ -59,7 +59,7 @@ export const rpc = {
5959
await upgrade().catch(() => {})
6060
},
6161
async reload() {
62-
await AppRuntime.runPromise(reloadIfGlobalConfigChanged())
62+
await AppRuntime.runPromise(reloadIfConfigChanged())
6363
},
6464
async shutdown() {
6565
await InstanceRuntime.disposeAllInstances()
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createHash } from "crypto"
2+
import { Flag } from "@opencode-ai/core/flag/flag"
3+
import { Glob } from "@opencode-ai/core/util/glob"
4+
import { Effect } from "effect"
5+
import fs from "fs/promises"
6+
import path from "path"
7+
import { ConfigParse } from "./parse"
8+
import { ConfigPaths } from "./paths"
9+
10+
// Order-insensitive structural equality for decoded config values: key order
11+
// in a JSON object carries no meaning, so reordering keys must not count as a
12+
// change.
13+
export const canonicalEquals = (a: unknown, b: unknown): boolean =>
14+
JSON.stringify(canonicalize(a)) === JSON.stringify(canonicalize(b))
15+
16+
const canonicalize = (value: unknown): unknown => {
17+
if (Array.isArray(value)) return value.map(canonicalize)
18+
if (value && typeof value === "object") {
19+
return Object.fromEntries(
20+
Object.entries(value)
21+
.sort(([x], [y]) => x.localeCompare(y))
22+
.map(([k, v]) => [k, canonicalize(v)]),
23+
)
24+
}
25+
return value
26+
}
27+
28+
// JSON/JSONC files are canonicalized so that pure key reorders hash equal;
29+
// anything unparseable is hashed raw (a malformed file is still a state worth
30+
// detecting, and fixing it changes the hash).
31+
const normalize = (file: string, text: string): string => {
32+
if (!file.endsWith(".json") && !file.endsWith(".jsonc")) return text
33+
try {
34+
return JSON.stringify(canonicalize(ConfigParse.jsonc(text, file)))
35+
} catch {
36+
return text
37+
}
38+
}
39+
40+
// Content fingerprint of everything the instance config loader reads from disk
41+
// for one project: the project opencode.json/jsonc chain, every config
42+
// directory's agent/mode/command/plugin files, the filtered directories'
43+
// opencode.json/jsonc, and the OPENCODE_CONFIG override file. Mirrors the
44+
// reads in Config's instance loader. Theme files are deliberately excluded:
45+
// they affect rendering only and must not force an instance rebuild.
46+
export const hashInstanceInputs = Effect.fn("ConfigFingerprint.hashInstanceInputs")(function* (
47+
directory: string,
48+
worktree?: string,
49+
) {
50+
const files: string[] = []
51+
for (const file of yield* ConfigPaths.files("opencode", directory, worktree).pipe(Effect.orDie)) {
52+
files.push(file)
53+
}
54+
for (const dir of yield* ConfigPaths.directories(directory, worktree).pipe(Effect.orDie)) {
55+
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
56+
files.push(path.join(dir, "opencode.json"), path.join(dir, "opencode.jsonc"))
57+
}
58+
for (const pattern of [
59+
"{agent,agents}/**/*.md",
60+
"{mode,modes}/*.md",
61+
"{command,commands}/**/*.md",
62+
"{plugin,plugins}/*.{ts,js}",
63+
]) {
64+
files.push(...(yield* Effect.promise(() => Glob.scan(pattern, { cwd: dir, absolute: true, dot: true }))))
65+
}
66+
}
67+
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
68+
69+
const hash = createHash("sha256")
70+
for (const file of [...new Set(files)].sort()) {
71+
const text = yield* Effect.promise(() => fs.readFile(file, "utf8").catch(() => undefined))
72+
if (text === undefined) continue
73+
hash.update(file)
74+
hash.update("\0")
75+
hash.update(normalize(file, text))
76+
hash.update("\0")
77+
}
78+
return hash.digest("hex")
79+
})
80+
81+
export * as ConfigFingerprint from "./fingerprint"

‎packages/opencode/src/project/instance-store.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
66
import { InstanceRef } from "@/effect/instance-ref"
77
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
88
import { FSUtil } from "@opencode-ai/core/fs-util"
9+
import { ConfigFingerprint } from "@/config/fingerprint"
910
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
1011
import { type InstanceContext } from "./instance-context"
1112
import { InstanceBootstrap } from "./bootstrap-service"
@@ -24,6 +25,10 @@ export interface Interface {
2425
readonly disposeDirectory: (directory: string) => Effect.Effect<void>
2526
readonly disposeAll: () => Effect.Effect<void>
2627
readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
28+
// True when any loaded instance's on-disk config inputs (project config,
29+
// agent/command/mode/plugin files, OPENCODE_CONFIG) changed since that
30+
// instance booted. A missing or unreadable baseline counts as changed.
31+
readonly configChanged: () => Effect.Effect<boolean>
2732
}
2833

2934
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
@@ -32,13 +37,15 @@ export const use = serviceUse(Service)
3237

3338
interface Entry {
3439
readonly deferred: Deferred.Deferred<InstanceContext>
40+
configHash?: string
3541
}
3642

37-
const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service> = Layer.effect(
43+
const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service | FSUtil.Service> = Layer.effect(
3844
Service,
3945
Effect.gen(function* () {
4046
const project = yield* Project.Service
4147
const bootstrap = yield* InstanceBootstrap.Service
48+
const fsutil = yield* FSUtil.Service
4249
const scope = yield* Scope.Scope
4350
const cache = new Map<string, Entry>()
4451

@@ -73,6 +80,11 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
7380
Effect.gen(function* () {
7481
const exit = yield* Effect.exit(boot({ ...input, directory }))
7582
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
83+
else
84+
entry.configHash = yield* ConfigFingerprint.hashInstanceInputs(directory, exit.value.worktree).pipe(
85+
Effect.provideService(FSUtil.Service, fsutil),
86+
Effect.orElseSucceed(() => undefined),
87+
)
7688
yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid)
7789
})
7890

@@ -186,6 +198,20 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
186198
return yield* cachedDisposeAll
187199
})
188200

201+
const configChanged = Effect.fn("InstanceStore.configChanged")(function* () {
202+
for (const [directory, entry] of cache) {
203+
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
204+
if (Exit.isFailure(exit)) continue
205+
if (!entry.configHash) return true
206+
const fresh = yield* ConfigFingerprint.hashInstanceInputs(directory, exit.value.worktree).pipe(
207+
Effect.provideService(FSUtil.Service, fsutil),
208+
Effect.orElseSucceed(() => undefined),
209+
)
210+
if (fresh === undefined || fresh !== entry.configHash) return true
211+
}
212+
return false
213+
})
214+
189215
const provide = <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
190216
load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))
191217

@@ -198,6 +224,7 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
198224
disposeDirectory,
199225
disposeAll,
200226
provide,
227+
configChanged,
201228
})
202229
}),
203230
)
@@ -207,7 +234,7 @@ export const bootstrapNode = LayerNode.unbound(InstanceBootstrap.Service, Node.t
207234
export const node = makeGlobalNode({
208235
service: Service,
209236
layer: layer,
210-
deps: [Project.node, bootstrapNode],
237+
deps: [Project.node, bootstrapNode, FSUtil.node],
211238
})
212239

213240
export * as InstanceStore from "./instance-store"

‎packages/opencode/src/server/global-lifecycle.ts‎

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { GlobalBus } from "@/bus/global"
22
import { Config } from "@/config/config"
3+
import { ConfigFingerprint } from "@/config/fingerprint"
34
import { InstanceStore } from "@/project/instance-store"
4-
import { Effect } from "effect"
5+
import { Effect, Option } from "effect"
56
import { Event } from "./event"
67

78
export const emitGlobalDisposed = Effect.sync(() =>
@@ -28,17 +29,37 @@ export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.dispos
2829

2930
// External reload signals (SIGUSR2 from theme switchers such as Omarchy or
3031
// Noctalia hooks) usually mean "repaint": themes are discovered from
31-
// themes/*.json and never touch the global config. Disposing instances
32-
// aborts their in-flight sessions, so only pay that cost when the global
33-
// config itself actually changed. Returns whether instances were disposed.
34-
export const reloadIfGlobalConfigChanged = Effect.fn("Server.reloadIfGlobalConfigChanged")(function* () {
32+
// themes/*.json and never touch the config. Disposing instances aborts their
33+
// in-flight sessions, so only pay that cost when config inputs actually
34+
// changed. Covers the global config and every loaded instance's on-disk
35+
// inputs (project config, agent/command/mode/plugin files, OPENCODE_CONFIG).
36+
// A read failure or a missing fingerprint baseline counts as changed, keeping
37+
// the historical unconditional-dispose behavior for any case the gate cannot
38+
// prove is clean. Returns whether instances were disposed.
39+
export const reloadIfConfigChanged = Effect.fn("Server.reloadIfConfigChanged")(function* () {
3540
const config = yield* Config.Service
36-
const before = yield* config.getGlobal()
41+
const store = yield* InstanceStore.Service
42+
// ConfigParse throws on malformed files, surfacing here as a defect rather
43+
// than a typed error, so read via catchAllCause: a failed read must never
44+
// block invalidation, and unprovable means changed.
45+
const read = config.getGlobal().pipe(
46+
Effect.map(Option.some),
47+
Effect.catchCause((cause) =>
48+
Effect.logWarning("global config read failed during reload check", { cause: String(cause) }).pipe(
49+
Effect.as(Option.none()),
50+
),
51+
),
52+
)
53+
const before = yield* read
3754
yield* config.invalidate()
38-
const next = yield* config.getGlobal()
39-
if (JSON.stringify(before) === JSON.stringify(next)) return false
40-
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
41-
return true
55+
const next = yield* read
56+
const globalChanged =
57+
Option.isNone(before) || Option.isNone(next) || !ConfigFingerprint.canonicalEquals(before.value, next.value)
58+
if (globalChanged || (yield* store.configChanged())) {
59+
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
60+
return true
61+
}
62+
return false
4263
})
4364

4465
export * as GlobalLifecycle from "./global-lifecycle"
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { expect } from "bun:test"
2+
import { Effect } from "effect"
3+
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
4+
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
5+
import { FSUtil } from "@opencode-ai/core/fs-util"
6+
import fs from "fs/promises"
7+
import path from "path"
8+
import { ConfigFingerprint } from "@/config/fingerprint"
9+
import { tmpdirScoped } from "../fixture/fixture"
10+
import { testEffect } from "../lib/effect"
11+
12+
const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])))
13+
14+
const hash = (dir: string) => ConfigFingerprint.hashInstanceInputs(dir, dir)
15+
16+
const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text))
17+
18+
// Point Global.Path.home at the temp project so the home-level .opencode
19+
// lookup stays hermetic.
20+
const withHome = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
21+
Effect.acquireUseRelease(
22+
Effect.sync(() => {
23+
const previous = process.env.OPENCODE_TEST_HOME
24+
process.env.OPENCODE_TEST_HOME = dir
25+
return previous
26+
}),
27+
() => effect,
28+
(previous) =>
29+
Effect.sync(() => {
30+
if (previous === undefined) delete process.env.OPENCODE_TEST_HOME
31+
else process.env.OPENCODE_TEST_HOME = previous
32+
}),
33+
)
34+
35+
const inTmpProject = <A, E, R>(fn: (dir: string) => Effect.Effect<A, E, R>) =>
36+
Effect.gen(function* () {
37+
const dir = yield* tmpdirScoped()
38+
return yield* withHome(dir, fn(dir))
39+
})
40+
41+
it.effect("is stable across calls when nothing changed", () =>
42+
inTmpProject((dir) =>
43+
Effect.gen(function* () {
44+
expect(yield* hash(dir)).toBe(yield* hash(dir))
45+
}),
46+
),
47+
)
48+
49+
it.effect("ignores key reordering in the project config", () =>
50+
inTmpProject((dir) =>
51+
Effect.gen(function* () {
52+
const file = path.join(dir, "opencode.json")
53+
yield* write(file, JSON.stringify({ username: "alice", logLevel: "DEBUG" }))
54+
const before = yield* hash(dir)
55+
yield* write(file, JSON.stringify({ logLevel: "DEBUG", username: "alice" }))
56+
expect(yield* hash(dir)).toBe(before)
57+
}),
58+
),
59+
)
60+
61+
it.effect("detects project config edits", () =>
62+
inTmpProject((dir) =>
63+
Effect.gen(function* () {
64+
const file = path.join(dir, "opencode.json")
65+
yield* write(file, JSON.stringify({ username: "alice" }))
66+
const before = yield* hash(dir)
67+
yield* write(file, JSON.stringify({ username: "bob" }))
68+
expect(yield* hash(dir)).not.toBe(before)
69+
}),
70+
),
71+
)
72+
73+
it.effect("detects added agent files", () =>
74+
inTmpProject((dir) =>
75+
Effect.gen(function* () {
76+
const before = yield* hash(dir)
77+
yield* Effect.promise(() => fs.mkdir(path.join(dir, ".opencode", "agent"), { recursive: true }))
78+
yield* write(path.join(dir, ".opencode", "agent", "reviewer.md"), "review things")
79+
expect(yield* hash(dir)).not.toBe(before)
80+
}),
81+
),
82+
)
83+
84+
it.effect("ignores theme files, which are rendering-only", () =>
85+
inTmpProject((dir) =>
86+
Effect.gen(function* () {
87+
const before = yield* hash(dir)
88+
yield* Effect.promise(() => fs.mkdir(path.join(dir, ".opencode", "themes"), { recursive: true }))
89+
yield* write(path.join(dir, ".opencode", "themes", "night.json"), JSON.stringify({ theme: {} }))
90+
expect(yield* hash(dir)).toBe(before)
91+
}),
92+
),
93+
)
94+
95+
it.effect("detects plugin file changes", () =>
96+
inTmpProject((dir) =>
97+
Effect.gen(function* () {
98+
const plugin = path.join(dir, ".opencode", "plugin")
99+
yield* Effect.promise(() => fs.mkdir(plugin, { recursive: true }))
100+
const file = path.join(plugin, "x.ts")
101+
yield* write(file, "export const a = 1")
102+
const before = yield* hash(dir)
103+
yield* write(file, "export const a = 2")
104+
expect(yield* hash(dir)).not.toBe(before)
105+
}),
106+
),
107+
)
108+
109+
it.effect("treats a malformed config as a state and detects fixing it", () =>
110+
inTmpProject((dir) =>
111+
Effect.gen(function* () {
112+
const file = path.join(dir, "opencode.json")
113+
yield* write(file, "{ not json")
114+
const broken = yield* hash(dir)
115+
yield* write(file, JSON.stringify({ username: "alice" }))
116+
expect(yield* hash(dir)).not.toBe(broken)
117+
}),
118+
),
119+
)

0 commit comments

Comments
 (0)