watcher.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import { $ } from "bun"
  2. import { describe, expect } from "bun:test"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  9. import { Bus } from "@opencode-ai/core/bus"
  10. import { FSUtil } from "@opencode-ai/util/fs-util"
  11. import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
  12. import { Watcher } from "@opencode-ai/core/filesystem/watcher"
  13. import { FileSystem } from "@opencode-ai/schema/filesystem"
  14. import { Location } from "@opencode-ai/core/location"
  15. import { AbsolutePath } from "@opencode-ai/core/schema"
  16. import { location } from "../fixture/location"
  17. import { tmpdir } from "../fixture/tmpdir"
  18. import { testEffect } from "../lib/effect"
  19. type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
  20. const describeNative = process.env.CI ? describe.skip : describe
  21. const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
  22. const configLayer = Config.testLayer()
  23. describe("Watcher.testLayer", () => {
  24. it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
  25. Effect.gen(function* () {
  26. const watcher = yield* Watcher.Service
  27. const test = yield* Watcher.Test
  28. const updates = yield* watcher.subscribe({ path: "/root", type: "directory" })
  29. const received = yield* updates.pipe(
  30. Stream.take(1),
  31. Stream.runCollect,
  32. Effect.forkScoped({ startImmediately: true }),
  33. )
  34. yield* Effect.yieldNow
  35. yield* test.emit({ type: "update", path: "/root/file.md" })
  36. expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
  37. // subscriptions() reports acquired watches, so paths come back resolved.
  38. expect(yield* test.subscriptions()).toEqual([{ path: path.resolve("/root"), type: "directory" }])
  39. }).pipe(Effect.provide(Watcher.testLayer)),
  40. )
  41. })
  42. function withNative(native: Watcher.NativeInterface) {
  43. return Effect.provide(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))
  44. }
  45. function countingNative() {
  46. const counts = { subscribes: 0, unsubscribes: 0 }
  47. const native: Watcher.NativeInterface = {
  48. subscribe: () =>
  49. Effect.sync(() => {
  50. counts.subscribes++
  51. return {
  52. unsubscribe: () => {
  53. counts.unsubscribes++
  54. return Promise.resolve()
  55. },
  56. }
  57. }),
  58. }
  59. return { native, counts }
  60. }
  61. describe("Watcher lifecycle", () => {
  62. it.effect("interrupting a consumer interrupts a pending acquisition", () =>
  63. Effect.gen(function* () {
  64. const started = yield* Deferred.make<void>()
  65. const interrupted = yield* Deferred.make<void>()
  66. yield* Effect.gen(function* () {
  67. const watcher = yield* Watcher.Service
  68. const consumer = yield* watcher
  69. .subscribe({ path: "/pending", type: "directory" })
  70. .pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
  71. yield* Deferred.await(started)
  72. yield* Fiber.interrupt(consumer)
  73. expect(yield* Deferred.isDone(interrupted)).toBe(true)
  74. }).pipe(
  75. withNative({
  76. subscribe: () =>
  77. Deferred.succeed(started, undefined).pipe(
  78. Effect.andThen(Effect.never),
  79. Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
  80. ),
  81. }),
  82. )
  83. }),
  84. )
  85. it.effect("shares one subscription and releases exactly once after the final consumer", () => {
  86. const { native, counts } = countingNative()
  87. return Effect.gen(function* () {
  88. const watcher = yield* Watcher.Service
  89. const consume = () =>
  90. watcher
  91. .subscribe({ path: "/shared", type: "directory" })
  92. .pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
  93. const first = yield* consume()
  94. const second = yield* consume()
  95. yield* Effect.yieldNow
  96. expect(counts.subscribes).toBe(1)
  97. yield* Fiber.interrupt(first)
  98. expect(counts.unsubscribes).toBe(0)
  99. yield* Fiber.interrupt(second)
  100. expect(counts.subscribes).toBe(1)
  101. expect(counts.unsubscribes).toBe(1)
  102. }).pipe(withNative(native))
  103. })
  104. it.effect("scope shutdown releases an active subscription exactly once", () => {
  105. const { native, counts } = countingNative()
  106. return Effect.gen(function* () {
  107. const consumer = yield* Effect.gen(function* () {
  108. const watcher = yield* Watcher.Service
  109. const updates = yield* watcher.subscribe({ path: "/active", type: "directory" })
  110. const consumer = yield* updates.pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  111. yield* Effect.yieldNow
  112. expect(counts.subscribes).toBe(1)
  113. expect(counts.unsubscribes).toBe(0)
  114. return consumer
  115. }).pipe(withNative(native))
  116. // Closing the layer scope tears the native subscription down while the
  117. // consumer still holds a reference; the consumer's own release as its
  118. // stream ends must not tear it down a second time.
  119. yield* Fiber.join(consumer)
  120. expect(counts.unsubscribes).toBe(1)
  121. })
  122. })
  123. })
  124. function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
  125. const locationLayer = Layer.succeed(
  126. Location.Service,
  127. Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
  128. )
  129. const built = AppNodeBuilder.build(LocationWatcher.node, [
  130. [Config.node, configLayer],
  131. [Location.node, locationLayer],
  132. ...(watcher ? ([[Watcher.node, watcher]] as const) : []),
  133. ])
  134. return Effect.provide(built)
  135. }
  136. function withTmp<A, E, R>(
  137. f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
  138. options?: {
  139. vcs?: "git" | "hg"
  140. init?: (directory: string) => Promise<void>
  141. watcher?: Layer.Layer<Watcher.Service>
  142. },
  143. ) {
  144. return Effect.acquireRelease(
  145. Effect.promise(async () => {
  146. const tmp = await tmpdir()
  147. if (options?.vcs === "hg") {
  148. await fs.mkdir(path.join(tmp.path, ".hg"))
  149. return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
  150. }
  151. if (options?.vcs !== "git") return { tmp, vcs: undefined }
  152. await $`git init`.cwd(tmp.path).quiet()
  153. await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
  154. await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
  155. await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
  156. await $`git config user.name Test`.cwd(tmp.path).quiet()
  157. await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
  158. await options.init?.(tmp.path)
  159. return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
  160. }),
  161. ({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  162. ).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
  163. }
  164. describe("LocationWatcher subscriptions", () => {
  165. it.live("watches only exact Git branch metadata", () => {
  166. const subscriptions: Watcher.WatchInput[] = []
  167. const watcher = Layer.succeed(
  168. Watcher.Service,
  169. Watcher.Service.of({
  170. subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
  171. }),
  172. )
  173. return withTmp(
  174. (directory) =>
  175. Effect.gen(function* () {
  176. yield* LocationWatcher.Service
  177. yield* Effect.sync(() => subscriptions.length).pipe(
  178. Effect.filterOrFail((count) => count > 0),
  179. Effect.retry(Schedule.spaced("10 millis")),
  180. )
  181. yield* Effect.sleep("10 millis")
  182. expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
  183. }),
  184. { vcs: "git", watcher },
  185. )
  186. })
  187. it.live("watches only exact Hg branch metadata", () => {
  188. const subscriptions: Watcher.WatchInput[] = []
  189. const watcher = Layer.succeed(
  190. Watcher.Service,
  191. Watcher.Service.of({
  192. subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
  193. }),
  194. )
  195. return withTmp(
  196. (directory) =>
  197. Effect.gen(function* () {
  198. yield* LocationWatcher.Service
  199. yield* Effect.sync(() => subscriptions.length).pipe(
  200. Effect.filterOrFail((count) => count > 0),
  201. Effect.retry(Schedule.spaced("10 millis")),
  202. )
  203. yield* Effect.sleep("10 millis")
  204. expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
  205. }),
  206. { vcs: "hg", watcher },
  207. )
  208. })
  209. })
  210. function wait(check: (event: WatcherEvent) => boolean) {
  211. return Effect.gen(function* () {
  212. const bus = yield* Bus.Service
  213. const deferred = yield* Deferred.make<WatcherEvent>()
  214. const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
  215. Stream.runForEach((event) => {
  216. if (!check(event.data)) return Effect.void
  217. return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
  218. }),
  219. Effect.forkScoped,
  220. )
  221. yield* Effect.yieldNow
  222. return { deferred, fiber }
  223. })
  224. }
  225. function maybeNextUpdate<E>(
  226. check: (event: WatcherEvent) => boolean,
  227. trigger: Effect.Effect<void, E>,
  228. timeout: Duration.Input = "5 seconds",
  229. ) {
  230. return Effect.acquireUseRelease(
  231. wait(check),
  232. ({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
  233. ({ fiber }) => Fiber.interrupt(fiber),
  234. )
  235. }
  236. function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
  237. return Effect.gen(function* () {
  238. const result = yield* maybeNextUpdate(check, trigger)
  239. if (Option.isSome(result)) return result.value
  240. return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
  241. })
  242. }
  243. function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
  244. return Effect.gen(function* () {
  245. while (true) {
  246. const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
  247. if (Option.isSome(result)) return result.value
  248. }
  249. }).pipe(
  250. Effect.timeoutOrElse({
  251. duration: "5 seconds",
  252. orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
  253. }),
  254. )
  255. }
  256. function ready(file: string, eventFile = file) {
  257. return Effect.gen(function* () {
  258. const fs = yield* FSUtil.Service
  259. const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
  260. yield* eventuallyUpdate(
  261. (event) => event.file === eventFile,
  262. () => fs.writeFileString(file, content),
  263. ).pipe(Effect.asVoid)
  264. })
  265. }
  266. describeNative("LocationWatcher", () => {
  267. it.live("limits file watches to the exact target", () =>
  268. withTmp((directory) =>
  269. Effect.gen(function* () {
  270. const fs = yield* FSUtil.Service
  271. const watcher = yield* Watcher.Service
  272. const target = path.join(directory, "opencode.json")
  273. const sibling = path.join(directory, "other.json")
  274. const updates = yield* watcher.subscribe({ path: target, type: "file" })
  275. const update = yield* updates.pipe(
  276. Stream.take(1),
  277. Stream.runHead,
  278. Effect.forkScoped({ startImmediately: true }),
  279. )
  280. yield* fs.writeFileString(sibling, "sibling")
  281. const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
  282. Effect.repeat(Schedule.spaced("10 millis")),
  283. Effect.forkScoped,
  284. )
  285. const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes)))
  286. expect(event.valueOrUndefined?.path).toBe(target)
  287. }).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
  288. ),
  289. )
  290. it.live("detects creation of a missing directory target", () =>
  291. withTmp((directory) =>
  292. Effect.gen(function* () {
  293. const fs = yield* FSUtil.Service
  294. const watcher = yield* Watcher.Service
  295. const target = path.join(directory, "generated")
  296. const updates = yield* watcher.subscribe({ path: target, type: "file" })
  297. const update = yield* updates.pipe(
  298. Stream.take(1),
  299. Stream.runHead,
  300. Effect.forkScoped({ startImmediately: true }),
  301. )
  302. const creates = yield* Effect.suspend(() =>
  303. fs.remove(target, { recursive: true, force: true }).pipe(Effect.andThen(fs.ensureDir(target))),
  304. ).pipe(Effect.repeat(Schedule.spaced("10 millis")), Effect.forkScoped)
  305. const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(creates)))
  306. expect(event.valueOrUndefined?.path).toBe(target)
  307. }).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
  308. ),
  309. )
  310. it.live("publishes .git/HEAD events", () =>
  311. withTmp(
  312. (directory) =>
  313. Effect.gen(function* () {
  314. const fs = yield* FSUtil.Service
  315. const head = path.join(directory, ".git", "HEAD")
  316. const branch = `watch-${Math.random().toString(36).slice(2)}`
  317. yield* ready(head)
  318. yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
  319. expect(
  320. yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
  321. ).toEqual({ file: head, event: "change" })
  322. }),
  323. { vcs: "git" },
  324. ),
  325. )
  326. const describeSymlink = process.platform !== "win32" ? describe : describe.skip
  327. describeSymlink("symlinked .git", () => {
  328. it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
  329. withTmp(
  330. (directory) =>
  331. Effect.gen(function* () {
  332. const afs = yield* FSUtil.Service
  333. const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
  334. yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
  335. const head = path.join(directory, ".git", "HEAD")
  336. yield* ready(head, path.join(actual, "HEAD"))
  337. const branch = `watch-${Math.random().toString(36).slice(2)}`
  338. yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
  339. expect(
  340. yield* nextUpdate(
  341. (event) => event.file === path.join(actual, "HEAD"),
  342. afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
  343. ),
  344. ).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
  345. }),
  346. {
  347. vcs: "git",
  348. init: async (directory) => {
  349. const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
  350. await fs.rename(path.join(directory, ".git"), actual)
  351. await fs.symlink(actual, path.join(directory, ".git"))
  352. },
  353. },
  354. ),
  355. )
  356. })
  357. it.live("publishes .hg/branch events", () =>
  358. withTmp(
  359. (directory) =>
  360. Effect.gen(function* () {
  361. const fs = yield* FSUtil.Service
  362. const branch = path.join(directory, ".hg", "branch")
  363. yield* ready(branch)
  364. expect(
  365. yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
  366. ).toMatchObject({ file: branch })
  367. }),
  368. { vcs: "hg" },
  369. ),
  370. )
  371. })