watcher.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  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. const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
  20. type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
  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.subscribe({ path: "/pending", type: "directory" }).pipe(
  69. Effect.flatMap(Stream.runDrain),
  70. Effect.forkScoped({ startImmediately: true }),
  71. )
  72. yield* Deferred.await(started)
  73. yield* Fiber.interrupt(consumer)
  74. expect(yield* Deferred.isDone(interrupted)).toBe(true)
  75. }).pipe(
  76. withNative({
  77. subscribe: () =>
  78. Deferred.succeed(started, undefined).pipe(
  79. Effect.andThen(Effect.never),
  80. Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
  81. ),
  82. }),
  83. )
  84. }),
  85. )
  86. it.effect("shares one subscription and releases exactly once after the final consumer", () => {
  87. const { native, counts } = countingNative()
  88. return Effect.gen(function* () {
  89. const watcher = yield* Watcher.Service
  90. const consume = () =>
  91. watcher.subscribe({ path: "/shared", type: "directory" }).pipe(
  92. Effect.flatMap(Stream.runDrain),
  93. Effect.forkScoped({ startImmediately: true }),
  94. )
  95. const first = yield* consume()
  96. const second = yield* consume()
  97. yield* Effect.yieldNow
  98. expect(counts.subscribes).toBe(1)
  99. yield* Fiber.interrupt(first)
  100. expect(counts.unsubscribes).toBe(0)
  101. yield* Fiber.interrupt(second)
  102. expect(counts.subscribes).toBe(1)
  103. expect(counts.unsubscribes).toBe(1)
  104. }).pipe(withNative(native))
  105. })
  106. it.effect("scope shutdown releases an active subscription exactly once", () => {
  107. const { native, counts } = countingNative()
  108. return Effect.gen(function* () {
  109. const consumer = yield* Effect.gen(function* () {
  110. const watcher = yield* Watcher.Service
  111. const updates = yield* watcher.subscribe({ path: "/active", type: "directory" })
  112. const consumer = yield* updates.pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  113. yield* Effect.yieldNow
  114. expect(counts.subscribes).toBe(1)
  115. expect(counts.unsubscribes).toBe(0)
  116. return consumer
  117. }).pipe(withNative(native))
  118. // Closing the layer scope tears the native subscription down while the
  119. // consumer still holds a reference; the consumer's own release as its
  120. // stream ends must not tear it down a second time.
  121. yield* Fiber.join(consumer)
  122. expect(counts.unsubscribes).toBe(1)
  123. })
  124. })
  125. })
  126. function provide(directory: string, vcs?: Location.Interface["vcs"]) {
  127. const locationLayer = Layer.succeed(
  128. Location.Service,
  129. Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
  130. )
  131. return Effect.provide(
  132. AppNodeBuilder.build(LocationWatcher.node, [
  133. [Config.node, configLayer],
  134. [Location.node, locationLayer],
  135. ]),
  136. )
  137. }
  138. function withTmp<A, E, R>(
  139. f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
  140. options?: { git?: boolean; init?: (directory: string) => Promise<void> },
  141. ) {
  142. return Effect.acquireRelease(
  143. Effect.promise(async () => {
  144. const tmp = await tmpdir()
  145. if (!options?.git) return { tmp, vcs: undefined }
  146. await $`git init`.cwd(tmp.path).quiet()
  147. await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
  148. await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
  149. await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
  150. await $`git config user.name Test`.cwd(tmp.path).quiet()
  151. await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
  152. await options.init?.(tmp.path)
  153. return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
  154. }),
  155. ({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  156. ).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
  157. }
  158. function wait(check: (event: WatcherEvent) => boolean) {
  159. return Effect.gen(function* () {
  160. const bus = yield* Bus.Service
  161. const deferred = yield* Deferred.make<WatcherEvent>()
  162. const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
  163. Stream.runForEach((event) => {
  164. if (!check(event.data)) return Effect.void
  165. return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
  166. }),
  167. Effect.forkScoped,
  168. )
  169. yield* Effect.yieldNow
  170. return { deferred, fiber }
  171. })
  172. }
  173. function maybeNextUpdate<E>(
  174. check: (event: WatcherEvent) => boolean,
  175. trigger: Effect.Effect<void, E>,
  176. timeout: Duration.Input = "5 seconds",
  177. ) {
  178. return Effect.acquireUseRelease(
  179. wait(check),
  180. ({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
  181. ({ fiber }) => Fiber.interrupt(fiber),
  182. )
  183. }
  184. function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
  185. return Effect.gen(function* () {
  186. const result = yield* maybeNextUpdate(check, trigger)
  187. if (Option.isSome(result)) return result.value
  188. return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
  189. })
  190. }
  191. function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
  192. return Effect.gen(function* () {
  193. while (true) {
  194. const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
  195. if (Option.isSome(result)) return result.value
  196. }
  197. }).pipe(
  198. Effect.timeoutOrElse({
  199. duration: "5 seconds",
  200. orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
  201. }),
  202. )
  203. }
  204. function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
  205. return Effect.acquireUseRelease(
  206. wait(check),
  207. ({ deferred }) =>
  208. trigger.pipe(
  209. Effect.andThen(Deferred.await(deferred)),
  210. Effect.timeoutOption(`${timeout} millis`),
  211. Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
  212. ),
  213. ({ fiber }) => Fiber.interrupt(fiber),
  214. )
  215. }
  216. function ready(directory: string) {
  217. const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
  218. return Effect.gen(function* () {
  219. const fs = yield* FSUtil.Service
  220. yield* eventuallyUpdate(
  221. (event) => event.file === file,
  222. () => fs.writeFileString(file, `ready-${Math.random()}`),
  223. ).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
  224. })
  225. }
  226. describeWatcher("LocationWatcher", () => {
  227. it.live("limits file watches to the exact target", () =>
  228. withTmp((directory) =>
  229. Effect.gen(function* () {
  230. const fs = yield* FSUtil.Service
  231. const watcher = yield* Watcher.Service
  232. const target = path.join(directory, "opencode.json")
  233. const sibling = path.join(directory, "other.json")
  234. const updates = yield* watcher.subscribe({ path: target, type: "file" })
  235. const update = yield* updates.pipe(
  236. Stream.take(1),
  237. Stream.runHead,
  238. Effect.forkScoped({ startImmediately: true }),
  239. )
  240. yield* fs.writeFileString(sibling, "sibling")
  241. const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
  242. Effect.repeat(Schedule.spaced("10 millis")),
  243. Effect.forkScoped,
  244. )
  245. const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes)))
  246. expect(event.valueOrUndefined?.path).toBe(target)
  247. }).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
  248. ),
  249. )
  250. it.live("publishes root create, update, and delete events", () =>
  251. withTmp(
  252. (directory) =>
  253. Effect.gen(function* () {
  254. const fs = yield* FSUtil.Service
  255. const file = path.join(directory, "watch.txt")
  256. yield* ready(directory)
  257. for (const item of [
  258. { event: "add" as const, trigger: fs.writeFileString(file, "a") },
  259. { event: "change" as const, trigger: fs.writeFileString(file, "b") },
  260. { event: "unlink" as const, trigger: fs.remove(file) },
  261. ]) {
  262. expect(
  263. yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
  264. ).toEqual({
  265. file,
  266. event: item.event,
  267. })
  268. }
  269. }),
  270. { git: true },
  271. ),
  272. )
  273. it.live("skips non-git roots", () =>
  274. withTmp((directory) =>
  275. Effect.gen(function* () {
  276. const fs = yield* FSUtil.Service
  277. const file = path.join(directory, "plain.txt")
  278. yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
  279. }),
  280. ),
  281. )
  282. it.live("ignores dependency, VCS, and build directories at any depth", () =>
  283. withTmp(
  284. (directory) =>
  285. Effect.gen(function* () {
  286. const afs = yield* FSUtil.Service
  287. yield* ready(directory)
  288. const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
  289. const files = roots.map((root) => path.join(root, "package", "index.js"))
  290. yield* noUpdate(
  291. (event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
  292. Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
  293. concurrency: "unbounded",
  294. discard: true,
  295. }),
  296. )
  297. }),
  298. { git: true },
  299. ),
  300. )
  301. it.live("cleanup stops publishing events", () =>
  302. Effect.gen(function* () {
  303. const bus = yield* Bus.Service
  304. const fs = yield* FSUtil.Service
  305. const tmp = yield* Effect.acquireRelease(
  306. Effect.promise(() => tmpdir()),
  307. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  308. )
  309. yield* ready(tmp.path).pipe(
  310. provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
  311. Effect.scoped,
  312. )
  313. const file = path.join(tmp.path, "after-dispose.txt")
  314. yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
  315. Effect.provideService(Bus.Service, bus),
  316. )
  317. }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))),
  318. )
  319. it.live("ignores .git/index changes", () =>
  320. withTmp(
  321. (directory) =>
  322. Effect.gen(function* () {
  323. const fs = yield* FSUtil.Service
  324. const index = path.join(directory, ".git", "index")
  325. yield* ready(directory)
  326. yield* noUpdate(
  327. (event) => event.file === index,
  328. fs
  329. .writeFileString(path.join(directory, "tracked.txt"), "a")
  330. .pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
  331. )
  332. }),
  333. { git: true },
  334. ),
  335. )
  336. it.live("publishes .git/HEAD events", () =>
  337. withTmp(
  338. (directory) =>
  339. Effect.gen(function* () {
  340. const fs = yield* FSUtil.Service
  341. const head = path.join(directory, ".git", "HEAD")
  342. const branch = `watch-${Math.random().toString(36).slice(2)}`
  343. yield* ready(directory)
  344. yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
  345. expect(
  346. yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
  347. ).toMatchObject({ file: head })
  348. }),
  349. { git: true },
  350. ),
  351. )
  352. const describeSymlink = process.platform !== "win32" ? describe : describe.skip
  353. describeSymlink("symlinked .git", () => {
  354. it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
  355. withTmp(
  356. (directory) =>
  357. Effect.gen(function* () {
  358. const afs = yield* FSUtil.Service
  359. const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
  360. yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
  361. yield* ready(directory)
  362. const head = path.join(directory, ".git", "HEAD")
  363. const branch = `watch-${Math.random().toString(36).slice(2)}`
  364. yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
  365. expect(
  366. yield* nextUpdate(
  367. (event) => event.file === path.join(actual, "HEAD"),
  368. afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
  369. ),
  370. ).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
  371. }),
  372. {
  373. git: true,
  374. init: async (directory) => {
  375. const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
  376. await fs.rename(path.join(directory, ".git"), actual)
  377. await fs.symlink(actual, path.join(directory, ".git"))
  378. },
  379. },
  380. ),
  381. )
  382. })
  383. })