time.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import { describe, test, expect, beforeEach } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { FileTime } from "../../src/file/time"
  5. import { Instance } from "../../src/project/instance"
  6. import { Filesystem } from "../../src/util/filesystem"
  7. import { tmpdir } from "../fixture/fixture"
  8. describe("file/time", () => {
  9. const sessionID = "test-session-123"
  10. describe("read() and get()", () => {
  11. test("stores read timestamp", async () => {
  12. await using tmp = await tmpdir()
  13. const filepath = path.join(tmp.path, "file.txt")
  14. await fs.writeFile(filepath, "content", "utf-8")
  15. await Instance.provide({
  16. directory: tmp.path,
  17. fn: async () => {
  18. const before = FileTime.get(sessionID, filepath)
  19. expect(before).toBeUndefined()
  20. FileTime.read(sessionID, filepath)
  21. const after = FileTime.get(sessionID, filepath)
  22. expect(after).toBeInstanceOf(Date)
  23. expect(after!.getTime()).toBeGreaterThan(0)
  24. },
  25. })
  26. })
  27. test("tracks separate timestamps per session", async () => {
  28. await using tmp = await tmpdir()
  29. const filepath = path.join(tmp.path, "file.txt")
  30. await fs.writeFile(filepath, "content", "utf-8")
  31. await Instance.provide({
  32. directory: tmp.path,
  33. fn: async () => {
  34. FileTime.read("session1", filepath)
  35. FileTime.read("session2", filepath)
  36. const time1 = FileTime.get("session1", filepath)
  37. const time2 = FileTime.get("session2", filepath)
  38. expect(time1).toBeDefined()
  39. expect(time2).toBeDefined()
  40. },
  41. })
  42. })
  43. test("updates timestamp on subsequent reads", async () => {
  44. await using tmp = await tmpdir()
  45. const filepath = path.join(tmp.path, "file.txt")
  46. await fs.writeFile(filepath, "content", "utf-8")
  47. await Instance.provide({
  48. directory: tmp.path,
  49. fn: async () => {
  50. FileTime.read(sessionID, filepath)
  51. const first = FileTime.get(sessionID, filepath)!
  52. await new Promise((resolve) => setTimeout(resolve, 10))
  53. FileTime.read(sessionID, filepath)
  54. const second = FileTime.get(sessionID, filepath)!
  55. expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
  56. },
  57. })
  58. })
  59. })
  60. describe("assert()", () => {
  61. test("passes when file has not been modified", async () => {
  62. await using tmp = await tmpdir()
  63. const filepath = path.join(tmp.path, "file.txt")
  64. await fs.writeFile(filepath, "content", "utf-8")
  65. await Instance.provide({
  66. directory: tmp.path,
  67. fn: async () => {
  68. FileTime.read(sessionID, filepath)
  69. // Should not throw
  70. await FileTime.assert(sessionID, filepath)
  71. },
  72. })
  73. })
  74. test("throws when file was not read first", async () => {
  75. await using tmp = await tmpdir()
  76. const filepath = path.join(tmp.path, "file.txt")
  77. await fs.writeFile(filepath, "content", "utf-8")
  78. await Instance.provide({
  79. directory: tmp.path,
  80. fn: async () => {
  81. await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
  82. },
  83. })
  84. })
  85. test("throws when file was modified after read", async () => {
  86. await using tmp = await tmpdir()
  87. const filepath = path.join(tmp.path, "file.txt")
  88. await fs.writeFile(filepath, "content", "utf-8")
  89. await Instance.provide({
  90. directory: tmp.path,
  91. fn: async () => {
  92. FileTime.read(sessionID, filepath)
  93. // Wait to ensure different timestamps
  94. await new Promise((resolve) => setTimeout(resolve, 100))
  95. // Modify file after reading
  96. await fs.writeFile(filepath, "modified content", "utf-8")
  97. await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
  98. },
  99. })
  100. })
  101. test("includes timestamps in error message", async () => {
  102. await using tmp = await tmpdir()
  103. const filepath = path.join(tmp.path, "file.txt")
  104. await fs.writeFile(filepath, "content", "utf-8")
  105. await Instance.provide({
  106. directory: tmp.path,
  107. fn: async () => {
  108. FileTime.read(sessionID, filepath)
  109. await new Promise((resolve) => setTimeout(resolve, 100))
  110. await fs.writeFile(filepath, "modified", "utf-8")
  111. let error: Error | undefined
  112. try {
  113. await FileTime.assert(sessionID, filepath)
  114. } catch (e) {
  115. error = e as Error
  116. }
  117. expect(error).toBeDefined()
  118. expect(error!.message).toContain("Last modification:")
  119. expect(error!.message).toContain("Last read:")
  120. },
  121. })
  122. })
  123. test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => {
  124. await using tmp = await tmpdir()
  125. const filepath = path.join(tmp.path, "file.txt")
  126. await fs.writeFile(filepath, "content", "utf-8")
  127. await Instance.provide({
  128. directory: tmp.path,
  129. fn: async () => {
  130. const { Flag } = await import("../../src/flag/flag")
  131. const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK
  132. ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true
  133. try {
  134. // Should not throw even though file wasn't read
  135. await FileTime.assert(sessionID, filepath)
  136. } finally {
  137. ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original
  138. }
  139. },
  140. })
  141. })
  142. })
  143. describe("withLock()", () => {
  144. test("executes function within lock", async () => {
  145. await using tmp = await tmpdir()
  146. const filepath = path.join(tmp.path, "file.txt")
  147. await Instance.provide({
  148. directory: tmp.path,
  149. fn: async () => {
  150. let executed = false
  151. await FileTime.withLock(filepath, async () => {
  152. executed = true
  153. return "result"
  154. })
  155. expect(executed).toBe(true)
  156. },
  157. })
  158. })
  159. test("returns function result", async () => {
  160. await using tmp = await tmpdir()
  161. const filepath = path.join(tmp.path, "file.txt")
  162. await Instance.provide({
  163. directory: tmp.path,
  164. fn: async () => {
  165. const result = await FileTime.withLock(filepath, async () => {
  166. return "success"
  167. })
  168. expect(result).toBe("success")
  169. },
  170. })
  171. })
  172. test("serializes concurrent operations on same file", async () => {
  173. await using tmp = await tmpdir()
  174. const filepath = path.join(tmp.path, "file.txt")
  175. await Instance.provide({
  176. directory: tmp.path,
  177. fn: async () => {
  178. const order: number[] = []
  179. const op1 = FileTime.withLock(filepath, async () => {
  180. order.push(1)
  181. await new Promise((resolve) => setTimeout(resolve, 10))
  182. order.push(2)
  183. })
  184. const op2 = FileTime.withLock(filepath, async () => {
  185. order.push(3)
  186. order.push(4)
  187. })
  188. await Promise.all([op1, op2])
  189. // Operations should be serialized
  190. expect(order).toContain(1)
  191. expect(order).toContain(2)
  192. expect(order).toContain(3)
  193. expect(order).toContain(4)
  194. },
  195. })
  196. })
  197. test("allows concurrent operations on different files", async () => {
  198. await using tmp = await tmpdir()
  199. const filepath1 = path.join(tmp.path, "file1.txt")
  200. const filepath2 = path.join(tmp.path, "file2.txt")
  201. await Instance.provide({
  202. directory: tmp.path,
  203. fn: async () => {
  204. let started1 = false
  205. let started2 = false
  206. const op1 = FileTime.withLock(filepath1, async () => {
  207. started1 = true
  208. await new Promise((resolve) => setTimeout(resolve, 50))
  209. expect(started2).toBe(true) // op2 should have started while op1 is running
  210. })
  211. const op2 = FileTime.withLock(filepath2, async () => {
  212. started2 = true
  213. })
  214. await Promise.all([op1, op2])
  215. expect(started1).toBe(true)
  216. expect(started2).toBe(true)
  217. },
  218. })
  219. })
  220. test("releases lock even if function throws", async () => {
  221. await using tmp = await tmpdir()
  222. const filepath = path.join(tmp.path, "file.txt")
  223. await Instance.provide({
  224. directory: tmp.path,
  225. fn: async () => {
  226. await expect(
  227. FileTime.withLock(filepath, async () => {
  228. throw new Error("Test error")
  229. }),
  230. ).rejects.toThrow("Test error")
  231. // Lock should be released, subsequent operations should work
  232. let executed = false
  233. await FileTime.withLock(filepath, async () => {
  234. executed = true
  235. })
  236. expect(executed).toBe(true)
  237. },
  238. })
  239. })
  240. test("deadlocks on nested locks (expected behavior)", async () => {
  241. await using tmp = await tmpdir()
  242. const filepath = path.join(tmp.path, "file.txt")
  243. await Instance.provide({
  244. directory: tmp.path,
  245. fn: async () => {
  246. // Nested locks on same file cause deadlock - this is expected
  247. // The outer lock waits for inner to complete, but inner waits for outer to release
  248. const timeout = new Promise<never>((_, reject) =>
  249. setTimeout(() => reject(new Error("Deadlock detected")), 100),
  250. )
  251. const nestedLock = FileTime.withLock(filepath, async () => {
  252. return FileTime.withLock(filepath, async () => {
  253. return "inner"
  254. })
  255. })
  256. // Should timeout due to deadlock
  257. await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected")
  258. },
  259. })
  260. })
  261. })
  262. describe("stat() Filesystem.stat pattern", () => {
  263. test("reads file modification time via Filesystem.stat()", async () => {
  264. await using tmp = await tmpdir()
  265. const filepath = path.join(tmp.path, "file.txt")
  266. await fs.writeFile(filepath, "content", "utf-8")
  267. await Instance.provide({
  268. directory: tmp.path,
  269. fn: async () => {
  270. FileTime.read(sessionID, filepath)
  271. const stats = Filesystem.stat(filepath)
  272. expect(stats?.mtime).toBeInstanceOf(Date)
  273. expect(stats!.mtime.getTime()).toBeGreaterThan(0)
  274. // FileTime.assert uses this stat internally
  275. await FileTime.assert(sessionID, filepath)
  276. },
  277. })
  278. })
  279. test("detects modification via stat mtime", async () => {
  280. await using tmp = await tmpdir()
  281. const filepath = path.join(tmp.path, "file.txt")
  282. await fs.writeFile(filepath, "original", "utf-8")
  283. await Instance.provide({
  284. directory: tmp.path,
  285. fn: async () => {
  286. FileTime.read(sessionID, filepath)
  287. const originalStat = Filesystem.stat(filepath)
  288. // Wait and modify
  289. await new Promise((resolve) => setTimeout(resolve, 100))
  290. await fs.writeFile(filepath, "modified", "utf-8")
  291. const newStat = Filesystem.stat(filepath)
  292. expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
  293. await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
  294. },
  295. })
  296. })
  297. })
  298. })