Skip to content

Commit 3ab67f3

Browse files
authored
Stabilize watcher test readiness (#28194)
1 parent a2e6bd5 commit 3ab67f3

2 files changed

Lines changed: 54 additions & 22 deletions

File tree

packages/opencode/test/file/watcher.test.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, expect } from "bun:test"
22
import path from "path"
3+
import { realpath } from "fs/promises"
34
import { AppFileSystem } from "@opencode-ai/core/filesystem"
4-
import { ConfigProvider, Deferred, Effect, Layer, Option } from "effect"
5+
import { ConfigProvider, Deferred, Duration, Effect, Layer, Option } from "effect"
56
import { TestInstance, provideInstance } from "../fixture/fixture"
67
import { testEffect } from "../lib/effect"
78
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
@@ -78,23 +79,49 @@ function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
7879
})
7980
}
8081

81-
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
82+
function maybeNextUpdate<E>(
83+
directory: string,
84+
check: (evt: WatcherEvent) => boolean,
85+
trigger: Effect.Effect<void, E>,
86+
timeout: Duration.Input = "5 seconds",
87+
) {
8288
return Effect.acquireUseRelease(
8389
wait(directory, check),
8490
({ deferred }) =>
8591
Effect.gen(function* () {
8692
yield* trigger
87-
return yield* Deferred.await(deferred).pipe(
88-
Effect.timeoutOrElse({
89-
duration: "5 seconds",
90-
orElse: () => Effect.fail(new Error("timed out waiting for file watcher update")),
91-
}),
92-
)
93+
return yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeout))
9394
}),
9495
({ cleanup }) => Effect.sync(cleanup),
9596
)
9697
}
9798

99+
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
100+
return Effect.gen(function* () {
101+
const result = yield* maybeNextUpdate(directory, check, trigger)
102+
if (Option.isSome(result)) return result.value
103+
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
104+
})
105+
}
106+
107+
function eventuallyUpdate<E>(
108+
directory: string,
109+
check: (evt: WatcherEvent) => boolean,
110+
trigger: () => Effect.Effect<void, E>,
111+
) {
112+
return Effect.gen(function* () {
113+
while (true) {
114+
const result = yield* maybeNextUpdate(directory, check, trigger(), "250 millis")
115+
if (Option.isSome(result)) return result.value
116+
}
117+
}).pipe(
118+
Effect.timeoutOrElse({
119+
duration: "5 seconds",
120+
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
121+
}),
122+
)
123+
}
124+
98125
/** Effect that asserts no matching event arrives within `ms`. */
99126
function noUpdate<E>(
100127
directory: string,
@@ -125,22 +152,25 @@ function ready(directory: string) {
125152
const fs = yield* AppFileSystem.Service
126153
const git = yield* Git.Service
127154

128-
yield* nextUpdate(
155+
yield* eventuallyUpdate(
129156
directory,
130-
(evt) => evt.file === file && evt.event === "add",
131-
fs.writeFileString(file, "ready"),
157+
(evt) => evt.file === file,
158+
() => fs.writeFileString(file, `ready-${Math.random()}`),
132159
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
133160

134161
if (!(yield* fs.existsSafe(head))) return
135162

136-
const branch = `watch-${Math.random().toString(36).slice(2)}`
163+
const realHead = yield* Effect.promise(() => realpath(head).catch(() => head))
137164
const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text()
138-
yield* nextUpdate(
165+
yield* eventuallyUpdate(
139166
directory,
140-
(evt) => evt.file === head && evt.event !== "unlink",
141-
fs
142-
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
143-
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))),
167+
(evt) => (evt.file === head || evt.file === realHead) && evt.event !== "unlink",
168+
() => {
169+
const branch = `watch-${Math.random().toString(36).slice(2)}`
170+
return fs
171+
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
172+
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`)))
173+
},
144174
).pipe(Effect.asVoid)
145175
})
146176
}

perf/test-suite.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/
6767
| Remaining prompt behavior tests mostly do not require repository state | Removed git setup from safe loop/reference/error fixtures; restored shell queue/cancel cases | 23.400s | 19.610s | keep | Safety review found shell runner readiness depends on git-backed setup in several tests; current single rerun passes. |
6868
| Session processor effect tests do not require repository state | Removed git setup from all processor-effect temp server fixtures | 12.500s | 9.230s | keep | Two targeted reruns passed after the change: 9.61s, 9.23s. |
6969
| HTTP listen PTY ticket tests restart the same listener topology twice | Folded directory-scoped ticket regression into the broader unsafe-ticket test | 7.051s | 6.170s | keep | Two targeted reruns passed after the change: 6.76s, 6.17s; still covers mint failure and successful same-directory upgrade. |
70+
| File watcher readiness can write before async native subscriptions are active | Retried short readiness writes and accepted symlink-realpath HEAD events | failed | 4.62s | keep | Three sequential focused watcher runs passed: 4.62s, 4.57s, 4.64s; full suite no longer failed in `watcher.test.ts`. |
7071

7172
## Profiling Results
7273

@@ -107,11 +108,12 @@ Targeted 3-run baselines:
107108

108109
Full-suite sanity checks:
109110

110-
| Command | Result | Notes |
111-
| -------------------- | -------: | -------------------------------------------------------------------- |
112-
| `bun run bench:test` | 225.069s | Before continuing prompt/session work. |
113-
| `bun run bench:test` | 186.729s | After prompt, processor, and PTY wins before safety review restores. |
114-
| `bun run bench:test` | 202.317s | After restoring prompt shell coverage and SDK VCS parity coverage. |
111+
| Command | Result | Notes |
112+
| -------------------- | -------: | ----------------------------------------------------------------------------------------------------------------------------------------------- |
113+
| `bun run bench:test` | 225.069s | Before continuing prompt/session work. |
114+
| `bun run bench:test` | 186.729s | After prompt, processor, and PTY wins before safety review restores. |
115+
| `bun run bench:test` | 202.317s | After restoring prompt shell coverage and SDK VCS parity coverage. |
116+
| `bun run bench:test` | failed | Watcher blocker cleared; current run later failed in focused-passing `tool/skill.test.ts` and prompt shell timeout cases under full-suite load. |
115117

116118
## Dead Ends
117119

0 commit comments

Comments
 (0)