watcher.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  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?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
  141. ) {
  142. return Effect.acquireRelease(
  143. Effect.promise(async () => {
  144. const tmp = await tmpdir()
  145. if (options?.vcs === "hg") {
  146. await fs.mkdir(path.join(tmp.path, ".hg"))
  147. return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
  148. }
  149. if (options?.vcs !== "git") return { tmp, vcs: undefined }
  150. await $`git init`.cwd(tmp.path).quiet()
  151. await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
  152. await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
  153. await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
  154. await $`git config user.name Test`.cwd(tmp.path).quiet()
  155. await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
  156. await options.init?.(tmp.path)
  157. return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
  158. }),
  159. ({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  160. ).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
  161. }
  162. function wait(check: (event: WatcherEvent) => boolean) {
  163. return Effect.gen(function* () {
  164. const bus = yield* Bus.Service
  165. const deferred = yield* Deferred.make<WatcherEvent>()
  166. const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
  167. Stream.runForEach((event) => {
  168. if (!check(event.data)) return Effect.void
  169. return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
  170. }),
  171. Effect.forkScoped,
  172. )
  173. yield* Effect.yieldNow
  174. return { deferred, fiber }
  175. })
  176. }
  177. function maybeNextUpdate<E>(
  178. check: (event: WatcherEvent) => boolean,
  179. trigger: Effect.Effect<void, E>,
  180. timeout: Duration.Input = "5 seconds",
  181. ) {
  182. return Effect.acquireUseRelease(
  183. wait(check),
  184. ({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
  185. ({ fiber }) => Fiber.interrupt(fiber),
  186. )
  187. }
  188. function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
  189. return Effect.gen(function* () {
  190. const result = yield* maybeNextUpdate(check, trigger)
  191. if (Option.isSome(result)) return result.value
  192. return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
  193. })
  194. }
  195. function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
  196. return Effect.gen(function* () {
  197. while (true) {
  198. const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
  199. if (Option.isSome(result)) return result.value
  200. }
  201. }).pipe(
  202. Effect.timeoutOrElse({
  203. duration: "5 seconds",
  204. orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
  205. }),
  206. )
  207. }
  208. function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
  209. return Effect.acquireUseRelease(
  210. wait(check),
  211. ({ deferred }) =>
  212. trigger.pipe(
  213. Effect.andThen(Deferred.await(deferred)),
  214. Effect.timeoutOption(`${timeout} millis`),
  215. Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
  216. ),
  217. ({ fiber }) => Fiber.interrupt(fiber),
  218. )
  219. }
  220. function ready(directory: string) {
  221. const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
  222. return Effect.gen(function* () {
  223. const fs = yield* FSUtil.Service
  224. yield* eventuallyUpdate(
  225. (event) => event.file === file,
  226. () => fs.writeFileString(file, `ready-${Math.random()}`),
  227. ).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
  228. })
  229. }
  230. describeWatcher("LocationWatcher", () => {
  231. it.live("limits file watches to the exact target", () =>
  232. withTmp((directory) =>
  233. Effect.gen(function* () {
  234. const fs = yield* FSUtil.Service
  235. const watcher = yield* Watcher.Service
  236. const target = path.join(directory, "opencode.json")
  237. const sibling = path.join(directory, "other.json")
  238. const updates = yield* watcher.subscribe({ path: target, type: "file" })
  239. const update = yield* updates.pipe(
  240. Stream.take(1),
  241. Stream.runHead,
  242. Effect.forkScoped({ startImmediately: true }),
  243. )
  244. yield* fs.writeFileString(sibling, "sibling")
  245. const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
  246. Effect.repeat(Schedule.spaced("10 millis")),
  247. Effect.forkScoped,
  248. )
  249. const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes)))
  250. expect(event.valueOrUndefined?.path).toBe(target)
  251. }).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
  252. ),
  253. )
  254. it.live("publishes root create, update, and delete events", () =>
  255. withTmp(
  256. (directory) =>
  257. Effect.gen(function* () {
  258. const fs = yield* FSUtil.Service
  259. const file = path.join(directory, "watch.txt")
  260. yield* ready(directory)
  261. for (const item of [
  262. { event: "add" as const, trigger: fs.writeFileString(file, "a") },
  263. { event: "change" as const, trigger: fs.writeFileString(file, "b") },
  264. { event: "unlink" as const, trigger: fs.remove(file) },
  265. ]) {
  266. expect(
  267. yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
  268. ).toEqual({
  269. file,
  270. event: item.event,
  271. })
  272. }
  273. }),
  274. { vcs: "git" },
  275. ),
  276. )
  277. it.live("skips non-git roots", () =>
  278. withTmp((directory) =>
  279. Effect.gen(function* () {
  280. const fs = yield* FSUtil.Service
  281. const file = path.join(directory, "plain.txt")
  282. yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
  283. }),
  284. ),
  285. )
  286. it.live("ignores dependency, VCS, and build directories at any depth", () =>
  287. withTmp(
  288. (directory) =>
  289. Effect.gen(function* () {
  290. const afs = yield* FSUtil.Service
  291. yield* ready(directory)
  292. const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
  293. const files = roots.map((root) => path.join(root, "package", "index.js"))
  294. yield* noUpdate(
  295. (event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
  296. Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
  297. concurrency: "unbounded",
  298. discard: true,
  299. }),
  300. )
  301. }),
  302. { vcs: "git" },
  303. ),
  304. )
  305. it.live("cleanup stops publishing events", () =>
  306. Effect.gen(function* () {
  307. const bus = yield* Bus.Service
  308. const fs = yield* FSUtil.Service
  309. const tmp = yield* Effect.acquireRelease(
  310. Effect.promise(() => tmpdir()),
  311. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  312. )
  313. yield* ready(tmp.path).pipe(
  314. provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
  315. Effect.scoped,
  316. )
  317. const file = path.join(tmp.path, "after-dispose.txt")
  318. yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
  319. Effect.provideService(Bus.Service, bus),
  320. )
  321. }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))),
  322. )
  323. it.live("ignores .git/index changes", () =>
  324. withTmp(
  325. (directory) =>
  326. Effect.gen(function* () {
  327. const fs = yield* FSUtil.Service
  328. const index = path.join(directory, ".git", "index")
  329. yield* ready(directory)
  330. yield* noUpdate(
  331. (event) => event.file === index,
  332. fs
  333. .writeFileString(path.join(directory, "tracked.txt"), "a")
  334. .pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
  335. )
  336. }),
  337. { vcs: "git" },
  338. ),
  339. )
  340. it.live("publishes .git/HEAD events", () =>
  341. withTmp(
  342. (directory) =>
  343. Effect.gen(function* () {
  344. const fs = yield* FSUtil.Service
  345. const head = path.join(directory, ".git", "HEAD")
  346. const branch = `watch-${Math.random().toString(36).slice(2)}`
  347. yield* ready(directory)
  348. yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
  349. expect(
  350. yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
  351. ).toMatchObject({ file: head })
  352. }),
  353. { vcs: "git" },
  354. ),
  355. )
  356. const describeSymlink = process.platform !== "win32" ? describe : describe.skip
  357. describeSymlink("symlinked .git", () => {
  358. it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
  359. withTmp(
  360. (directory) =>
  361. Effect.gen(function* () {
  362. const afs = yield* FSUtil.Service
  363. const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
  364. yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
  365. yield* ready(directory)
  366. const head = path.join(directory, ".git", "HEAD")
  367. const branch = `watch-${Math.random().toString(36).slice(2)}`
  368. yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
  369. expect(
  370. yield* nextUpdate(
  371. (event) => event.file === path.join(actual, "HEAD"),
  372. afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
  373. ),
  374. ).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
  375. }),
  376. {
  377. vcs: "git",
  378. init: async (directory) => {
  379. const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
  380. await fs.rename(path.join(directory, ".git"), actual)
  381. await fs.symlink(actual, path.join(directory, ".git"))
  382. },
  383. },
  384. ),
  385. )
  386. })
  387. it.live("publishes .hg/branch events", () =>
  388. withTmp(
  389. (directory) =>
  390. Effect.gen(function* () {
  391. const fs = yield* FSUtil.Service
  392. const branch = path.join(directory, ".hg", "branch")
  393. yield* ready(directory)
  394. expect(
  395. yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
  396. ).toMatchObject({ file: branch })
  397. }),
  398. { vcs: "hg" },
  399. ),
  400. )
  401. })