Skip to content

Commit 64936b3

Browse files
Apply PR #17640: refactor(file-time): effectify FileTimeService with Semaphore locks
2 parents 0e2d7fd + 434173a commit 64936b3

File tree

5 files changed

+107
-138
lines changed

5 files changed

+107
-138
lines changed

packages/opencode/src/effect/instances.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { QuestionService } from "@/question/service"
66
import { PermissionService } from "@/permission/service"
77
import { FileWatcherService } from "@/file/watcher"
88
import { VcsService } from "@/project/vcs"
9+
import { FileTimeService } from "@/file/time"
910
import { Instance } from "@/project/instance"
1011

1112
export { InstanceContext } from "./instance-context"
@@ -16,6 +17,7 @@ export type InstanceServices =
1617
| ProviderAuthService
1718
| FileWatcherService
1819
| VcsService
20+
| FileTimeService
1921

2022
function lookup(directory: string) {
2123
const project = Instance.project
@@ -24,8 +26,9 @@ function lookup(directory: string) {
2426
Layer.fresh(QuestionService.layer),
2527
Layer.fresh(PermissionService.layer),
2628
Layer.fresh(ProviderAuthService.layer),
27-
Layer.fresh(FileWatcherService.layer),
29+
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
2830
Layer.fresh(VcsService.layer),
31+
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
2932
).pipe(Layer.provide(ctx))
3033
}
3134

packages/opencode/src/file/time.ts

Lines changed: 73 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,88 @@
1-
import { Instance } from "../project/instance"
21
import { Log } from "../util/log"
3-
import { Flag } from "../flag/flag"
2+
import { Flag } from "@/flag/flag"
43
import { Filesystem } from "../util/filesystem"
4+
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
5+
import { runPromiseInstance } from "@/effect/runtime"
56

6-
export namespace FileTime {
7-
const log = Log.create({ service: "file.time" })
8-
// Per-session read times plus per-file write locks.
9-
// All tools that overwrite existing files should run their
10-
// assert/read/write/update sequence inside withLock(filepath, ...)
11-
// so concurrent writes to the same file are serialized.
12-
export const state = Instance.state(() => {
13-
const read: {
14-
[sessionID: string]: {
15-
[path: string]: Date | undefined
7+
const log = Log.create({ service: "file.time" })
8+
9+
export namespace FileTimeService {
10+
export interface Service {
11+
readonly read: (sessionID: string, file: string) => Effect.Effect<void>
12+
readonly get: (sessionID: string, file: string) => Effect.Effect<Date | undefined>
13+
readonly assert: (sessionID: string, filepath: string) => Effect.Effect<void>
14+
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
15+
}
16+
}
17+
18+
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
19+
"@opencode/FileTime",
20+
) {
21+
static readonly layer = Layer.effect(
22+
FileTimeService,
23+
Effect.gen(function* () {
24+
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
25+
const reads: { [sessionID: string]: { [path: string]: Date | undefined } } = {}
26+
const locks = new Map<string, Semaphore.Semaphore>()
27+
28+
function getLock(filepath: string) {
29+
let lock = locks.get(filepath)
30+
if (!lock) {
31+
lock = Semaphore.makeUnsafe(1)
32+
locks.set(filepath, lock)
33+
}
34+
return lock
1635
}
17-
} = {}
18-
const locks = new Map<string, Promise<void>>()
19-
return {
20-
read,
21-
locks,
22-
}
23-
})
2436

37+
return FileTimeService.of({
38+
read: Effect.fn("FileTimeService.read")(function* (sessionID: string, file: string) {
39+
log.info("read", { sessionID, file })
40+
reads[sessionID] = reads[sessionID] || {}
41+
reads[sessionID][file] = new Date()
42+
}),
43+
44+
get: Effect.fn("FileTimeService.get")(function* (sessionID: string, file: string) {
45+
return reads[sessionID]?.[file]
46+
}),
47+
48+
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: string, filepath: string) {
49+
if (disableCheck) return
50+
51+
const time = reads[sessionID]?.[filepath]
52+
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
53+
const mtime = Filesystem.stat(filepath)?.mtime
54+
if (mtime && mtime.getTime() > time.getTime() + 50) {
55+
throw new Error(
56+
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
57+
)
58+
}
59+
}),
60+
61+
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
62+
const lock = getLock(filepath)
63+
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
64+
}),
65+
})
66+
}),
67+
)
68+
}
69+
70+
// Legacy facade — callers don't need to change
71+
export namespace FileTime {
2572
export function read(sessionID: string, file: string) {
26-
log.info("read", { sessionID, file })
27-
const { read } = state()
28-
read[sessionID] = read[sessionID] || {}
29-
read[sessionID][file] = new Date()
73+
// Fire-and-forget — callers never await this
74+
runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
3075
}
3176

3277
export function get(sessionID: string, file: string) {
33-
return state().read[sessionID]?.[file]
34-
}
35-
36-
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
37-
const current = state()
38-
const currentLock = current.locks.get(filepath) ?? Promise.resolve()
39-
let release: () => void = () => {}
40-
const nextLock = new Promise<void>((resolve) => {
41-
release = resolve
42-
})
43-
const chained = currentLock.then(() => nextLock)
44-
current.locks.set(filepath, chained)
45-
await currentLock
46-
try {
47-
return await fn()
48-
} finally {
49-
release()
50-
if (current.locks.get(filepath) === chained) {
51-
current.locks.delete(filepath)
52-
}
53-
}
78+
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
5479
}
5580

5681
export async function assert(sessionID: string, filepath: string) {
57-
if (Flag.OPENCODE_DISABLE_FILETIME_CHECK === true) {
58-
return
59-
}
82+
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
83+
}
6084

61-
const time = get(sessionID, filepath)
62-
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
63-
const mtime = Filesystem.stat(filepath)?.mtime
64-
// Allow a 50ms tolerance for Windows NTFS timestamp fuzziness / async flushing
65-
if (mtime && mtime.getTime() > time.getTime() + 50) {
66-
throw new Error(
67-
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
68-
)
69-
}
85+
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
86+
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
7087
}
7188
}

packages/opencode/src/file/watcher.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
7272
FileWatcherService,
7373
Effect.gen(function* () {
7474
const instance = yield* InstanceContext
75-
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init })
75+
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER)
76+
return FileWatcherService.of({ init })
7677

7778
log.info("init", { directory: instance.directory })
7879

packages/opencode/src/flag/flag.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ export namespace Flag {
6161
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
6262
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
6363
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
64-
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
64+
export const OPENCODE_DISABLE_FILETIME_CHECK = Config.boolean("OPENCODE_DISABLE_FILETIME_CHECK").pipe(
65+
Config.withDefault(false),
66+
)
6567
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
6668
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
6769
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")

0 commit comments

Comments
 (0)