Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion packages/workspace-fs/src/watch-file.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from "bun:test";
import { afterEach, describe, expect, it, spyOn } from "bun:test";
import * as fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -54,6 +55,46 @@ describe("watchSingleFile", () => {
await waitFor(events, (e) => e.kind === "update");
}, 15_000);

it("emits update on a same-size rewrite that restores the mtime", async () => {
const file = await createTempFile();
const before = await fs.stat(file);
const events: FsWatchEvent[] = [];
disposers.push(
watchSingleFile(file, (e) => events.push(e), { debounceMs: 25 }),
);
await new Promise((r) => setTimeout(r, 150));

await fs.writeFile(file, "changed");
await fs.utimes(file, before.atimeMs / 1000, before.mtimeMs / 1000);
await waitFor(events, (e) => e.kind === "update", 1_000);
});

it("emits update on a watch event even when the file signature is unchanged", async () => {
const realWatch = fsSync.watch;
const listeners: Array<() => void> = [];
const watchSpy = spyOn(fsSync, "watch").mockImplementation(((
target: string,
listener: () => void,
) => {
listeners.push(listener);
return realWatch(target, () => {});
}) as typeof fsSync.watch);
const file = await createTempFile();
const events: FsWatchEvent[] = [];
try {
disposers.push(
watchSingleFile(file, (e) => events.push(e), { debounceMs: 5 }),
);
await new Promise((r) => setTimeout(r, 50));
expect(listeners).toHaveLength(1);

listeners[0]?.();
await waitFor(events, (e) => e.kind === "update", 1_000);
} finally {
watchSpy.mockRestore();
}
});

it("survives an atomic save (write-temp + rename over the path)", async () => {
const file = await createTempFile();
const events: FsWatchEvent[] = [];
Expand Down Expand Up @@ -149,4 +190,43 @@ describe("watchSingleFile", () => {
await fs.writeFile(file, "born");
await waitFor(events, (e) => e.kind === "create");
}, 15_000);

it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"stays silent while polling an unchanged file it cannot watch",
async () => {
const file = await createTempFile();
await fs.chmod(file, 0o000);
const events: FsWatchEvent[] = [];
disposers.push(
watchSingleFile(file, (e) => events.push(e), {
debounceMs: 5,
pollMs: 20,
}),
);
await new Promise((r) => setTimeout(r, 150));
expect(events).toEqual([]);

await fs.utimes(file, new Date(), new Date(Date.now() + 60_000));
await waitFor(events, (e) => e.kind === "update");
},
);

it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"emits update on a permission change while polling",
async () => {
const file = await createTempFile();
await fs.chmod(file, 0o000);
const events: FsWatchEvent[] = [];
disposers.push(
watchSingleFile(file, (e) => events.push(e), {
debounceMs: 5,
pollMs: 20,
}),
);
await new Promise((r) => setTimeout(r, 150));

await fs.chmod(file, 0o200);
await waitFor(events, (e) => e.kind === "update", 1_000);
},
);
});
29 changes: 25 additions & 4 deletions packages/workspace-fs/src/watch-file.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FSWatcher, watch } from "node:fs";
import { type FSWatcher, type Stats, watch } from "node:fs";
import { stat } from "node:fs/promises";
import {
clearInterval,
Expand All @@ -11,6 +11,9 @@ import type { FsWatchEvent } from "./types";
const DEBOUNCE_MS = 75;
const RECREATION_POLL_MS = 2_000;

const fileSignature = (stats: Stats) =>
`${stats.ino}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.size}`;

export interface WatchSingleFileOptions {
debounceMs?: number;
/** How often to poll for the file to (re)appear while it's absent. */
Expand Down Expand Up @@ -44,10 +47,13 @@ export function watchSingleFile(
let watchedIno: bigint | number | null = null;
/** mtime last observed while the path is a directory (poll dedupe). */
let dirMtimeMs: number | null = null;
let fileSig: string | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let settling = false;
let settleQueued = false;
/** A watch event arrived that no settle has handled yet. */
let watchEventPending = false;

const emit = (kind: "create" | "update" | "delete", isDirectory: boolean) => {
onEvent({ kind, absolutePath, isDirectory });
Expand Down Expand Up @@ -76,11 +82,11 @@ export function watchSingleFile(
const installWatcher = (ino: bigint | number): boolean => {
closeWatcher();
try {
const installed = watch(absolutePath, () => scheduleSettle());
const installed = watch(absolutePath, onWatchEvent);
installed.on("error", () => {
// A dead watch must not be mistaken for a live same-inode one.
if (watcher === installed) closeWatcher();
scheduleSettle();
onWatchEvent();
});
watcher = installed;
watchedIno = ino;
Expand All @@ -92,6 +98,11 @@ export function watchSingleFile(
}
};

const onWatchEvent = () => {
watchEventPending = true;
scheduleSettle();
};

const scheduleSettle = () => {
if (disposed || debounceTimer) return;
const timer = setTimeout(() => {
Expand All @@ -111,6 +122,8 @@ export function watchSingleFile(
return;
}
settling = true;
const fromWatch = watchEventPending;
watchEventPending = false;
try {
const stats = await stat(absolutePath).catch(() => null);
if (disposed) return;
Expand All @@ -124,6 +137,7 @@ export function watchSingleFile(
// deaf. Poll instead until the path is a plain file again.
// Emit only on transition or mtime change; a poll tick with
// nothing new must stay silent or consumers reload forever.
fileSig = null;
closeWatcher();
startPolling();
if (!existed) {
Expand All @@ -136,6 +150,9 @@ export function watchSingleFile(
return;
}
dirMtimeMs = null;
const sig = fileSignature(stats);
const changed = sig !== fileSig;
fileSig = sig;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
stopPolling();
// Re-install only when the inode behind the path changed (atomic
// save replaced it — the old watch follows the dead inode). A
Expand All @@ -147,10 +164,13 @@ export function watchSingleFile(
startPolling();
}
}
emit(existed ? "update" : "create", false);
// Coarse-timestamp filesystems (HFS+) can leave the signature
// unchanged across a real write, so only poll ticks dedupe.
if (changed || fromWatch) emit(existed ? "update" : "create", false);
} else {
const existed = exists;
exists = false;
fileSig = null;
closeWatcher();
startPolling();
if (existed) {
Expand All @@ -172,6 +192,7 @@ export function watchSingleFile(
const stats = await stat(absolutePath).catch(() => null);
if (disposed) return;
exists = stats !== null;
if (stats?.isFile()) fileSig = fileSignature(stats);
if (!stats || !installWatcher(stats.ino)) {
startPolling();
}
Expand Down
Loading