Skip to content

Commit bbe5db0

Browse files
committed
fix(core): restart file watches whose native watch ended
1 parent cffc6bb commit bbe5db0

3 files changed

Lines changed: 70 additions & 19 deletions

File tree

‎packages/core/src/filesystem/file-watch.ts‎

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -64,36 +64,48 @@ export const layer = (options?: Options) =>
6464
const bus = yield* Bus.Service
6565
const fs = yield* FSUtil.Service
6666
const scope = yield* Effect.scope
67-
const lock = Semaphore.makeUnsafe(1)
67+
const lock = yield* Semaphore.make(1)
6868
const locations = new Map<string, Map<string, Entry>>()
6969
const locationKey = (location: Location.Ref) => JSON.stringify([location.directory, location.workspaceID])
7070

71+
const remove = (key: string, file: string) => {
72+
const files = locations.get(key)
73+
const entry = files?.get(file)
74+
if (!files || !entry) return
75+
files.delete(file)
76+
if (files.size === 0) locations.delete(key)
77+
return entry
78+
}
79+
7180
const stop = (key: string, file: string) =>
7281
Effect.suspend(() => {
73-
const files = locations.get(key)
74-
const entry = files?.get(file)
75-
if (!files || !entry) return Effect.void
76-
files.delete(file)
77-
if (files.size === 0) locations.delete(key)
82+
const entry = remove(key, file)
83+
if (!entry) return Effect.void
7884
return Scope.close(entry.scope, Exit.void)
7985
})
8086

81-
const start = Effect.fnUntraced(function* (input: Input) {
87+
const start = Effect.fnUntraced(function* (key: string, input: Input) {
8288
const child = yield* Scope.fork(scope)
8389
yield* Effect.gen(function* () {
8490
const updates = yield* watcher.subscribe({ path: input.file, type: "file" })
8591
yield* Stream.runForEach(updates, () =>
86-
fs
87-
.existsSafe(input.file)
88-
.pipe(
89-
Effect.flatMap((exists) =>
90-
bus.publish(
91-
FileSystem.Event.Changed,
92-
{ file: input.file, event: exists ? "change" : "unlink" },
93-
{ location: input.location },
94-
),
95-
),
96-
),
92+
Effect.gen(function* () {
93+
const exists = yield* fs.existsSafe(input.file)
94+
yield* bus.publish(
95+
FileSystem.Event.Changed,
96+
{ file: input.file, event: exists ? "change" : "unlink" },
97+
{ location: input.location },
98+
)
99+
}),
100+
)
101+
// The native watch ended (e.g. its directory vanished): forget it so the next watch starts a new one.
102+
// The scope closes from another fiber because closing it interrupts this one.
103+
yield* lock.withPermit(
104+
Effect.suspend(() => {
105+
if (locations.get(key)?.get(input.file)?.scope !== child) return Effect.void
106+
remove(key, input.file)
107+
return Scope.close(child, Exit.void).pipe(Effect.forkIn(scope), Effect.asVoid)
108+
}),
97109
)
98110
}).pipe(
99111
Effect.catchCauseIf(
@@ -132,7 +144,7 @@ export const layer = (options?: Options) =>
132144
const soonest = [...full].reduce((a, b) => (b[1].expires < a[1].expires ? b : a))
133145
yield* stop(key, soonest[0])
134146
}
135-
const entry = { expires, scope: yield* start(input) }
147+
const entry = { expires, scope: yield* start(key, input) }
136148
const files = locations.get(key) ?? new Map<string, Entry>()
137149
files.set(input.file, entry)
138150
locations.set(key, files)

‎packages/core/test/filesystem/file-watch.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,35 @@ describe("FileWatch", () => {
106106
)
107107
})
108108

109+
describe("FileWatch dead watches", () => {
110+
const counts = { subscribes: 0 }
111+
// A native watch that cannot start, like one on a directory that no longer exists.
112+
const failing = testEffect(
113+
FileWatch.layer().pipe(
114+
Layer.provide(
115+
Watcher.layer().pipe(
116+
Layer.provide(
117+
Layer.succeed(
118+
Watcher.Native,
119+
Watcher.Native.of({ subscribe: () => Effect.sync(() => void counts.subscribes++) }),
120+
),
121+
),
122+
),
123+
),
124+
Layer.provide(services),
125+
),
126+
)
127+
failing.effect("starts a new watch when the previous one ended", () =>
128+
Effect.gen(function* () {
129+
const fileWatch = yield* FileWatch.Service
130+
yield* fileWatch.watch(file("a.ts"))
131+
yield* Effect.repeat(Effect.yieldNow, { times: 10 })
132+
yield* fileWatch.watch(file("a.ts"))
133+
expect(counts.subscribes).toBe(2)
134+
}),
135+
)
136+
})
137+
109138
describe("FileWatch disabled", () => {
110139
const disabled = testEffect(
111140
FileWatch.layer({ enabled: false }).pipe(Layer.provide(Watcher.testLayer), Layer.provide(services)),

‎packages/core/test/filesystem/watcher.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ function countingNative() {
5454
}
5555

5656
describe("Watcher lifecycle", () => {
57+
it.live("ends the stream when the watched directory is missing", () =>
58+
withTmp((directory) =>
59+
Effect.gen(function* () {
60+
const watcher = yield* Watcher.Service
61+
const updates = yield* watcher.subscribe({ path: path.join(directory, "missing", "a.ts"), type: "file" })
62+
expect(Option.isNone(yield* Stream.runHead(updates))).toBe(true)
63+
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
64+
),
65+
)
66+
5767
it.effect("signals readiness after acquisition and buffers updates published by the ready callback", () =>
5868
Effect.gen(function* () {
5969
const publish = yield* Deferred.make<(update: Watcher.Update) => void>()

0 commit comments

Comments
 (0)