From 3ab67f3280d90e137257e8bd6f44c3f62408a8b8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 18 May 2026 12:51:46 -0400 Subject: [PATCH] Stabilize watcher test readiness (#28194) --- packages/opencode/test/file/watcher.test.ts | 64 +++++++++++++++------ perf/test-suite.md | 12 ++-- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index be56ad9f23..c205da4862 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" import path from "path" +import { realpath } from "fs/promises" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { ConfigProvider, Deferred, Effect, Layer, Option } from "effect" +import { ConfigProvider, Deferred, Duration, Effect, Layer, Option } from "effect" import { TestInstance, provideInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" @@ -78,23 +79,49 @@ function wait(directory: string, check: (evt: WatcherEvent) => boolean) { }) } -function nextUpdate(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect) { +function maybeNextUpdate( + directory: string, + check: (evt: WatcherEvent) => boolean, + trigger: Effect.Effect, + timeout: Duration.Input = "5 seconds", +) { return Effect.acquireUseRelease( wait(directory, check), ({ deferred }) => Effect.gen(function* () { yield* trigger - return yield* Deferred.await(deferred).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.fail(new Error("timed out waiting for file watcher update")), - }), - ) + return yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeout)) }), ({ cleanup }) => Effect.sync(cleanup), ) } +function nextUpdate(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect) { + return Effect.gen(function* () { + const result = yield* maybeNextUpdate(directory, check, trigger) + if (Option.isSome(result)) return result.value + return yield* Effect.fail(new Error("timed out waiting for file watcher update")) + }) +} + +function eventuallyUpdate( + directory: string, + check: (evt: WatcherEvent) => boolean, + trigger: () => Effect.Effect, +) { + return Effect.gen(function* () { + while (true) { + const result = yield* maybeNextUpdate(directory, check, trigger(), "250 millis") + if (Option.isSome(result)) return result.value + } + }).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")), + }), + ) +} + /** Effect that asserts no matching event arrives within `ms`. */ function noUpdate( directory: string, @@ -125,22 +152,25 @@ function ready(directory: string) { const fs = yield* AppFileSystem.Service const git = yield* Git.Service - yield* nextUpdate( + yield* eventuallyUpdate( directory, - (evt) => evt.file === file && evt.event === "add", - fs.writeFileString(file, "ready"), + (evt) => evt.file === file, + () => fs.writeFileString(file, `ready-${Math.random()}`), ).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid) if (!(yield* fs.existsSafe(head))) return - const branch = `watch-${Math.random().toString(36).slice(2)}` + const realHead = yield* Effect.promise(() => realpath(head).catch(() => head)) const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text() - yield* nextUpdate( + yield* eventuallyUpdate( directory, - (evt) => evt.file === head && evt.event !== "unlink", - fs - .writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n") - .pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))), + (evt) => (evt.file === head || evt.file === realHead) && evt.event !== "unlink", + () => { + const branch = `watch-${Math.random().toString(36).slice(2)}` + return fs + .writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n") + .pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))) + }, ).pipe(Effect.asVoid) }) } diff --git a/perf/test-suite.md b/perf/test-suite.md index 39c26906df..f01e11505a 100644 --- a/perf/test-suite.md +++ b/perf/test-suite.md @@ -67,6 +67,7 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/ | 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. | | 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. | | 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. | +| 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`. | ## Profiling Results @@ -107,11 +108,12 @@ Targeted 3-run baselines: Full-suite sanity checks: -| Command | Result | Notes | -| -------------------- | -------: | -------------------------------------------------------------------- | -| `bun run bench:test` | 225.069s | Before continuing prompt/session work. | -| `bun run bench:test` | 186.729s | After prompt, processor, and PTY wins before safety review restores. | -| `bun run bench:test` | 202.317s | After restoring prompt shell coverage and SDK VCS parity coverage. | +| Command | Result | Notes | +| -------------------- | -------: | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `bun run bench:test` | 225.069s | Before continuing prompt/session work. | +| `bun run bench:test` | 186.729s | After prompt, processor, and PTY wins before safety review restores. | +| `bun run bench:test` | 202.317s | After restoring prompt shell coverage and SDK VCS parity coverage. | +| `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. | ## Dead Ends