Skip to content

Commit eb5a61b

Browse files
committed
merge: TUI preferences watcher baseline/watch/reconcile handshake (C1 bug)
2 parents 9e76351 + f411f21 commit eb5a61b

2 files changed

Lines changed: 94 additions & 19 deletions

File tree

‎packages/plugin/src/shared/tui-preferences.test.ts‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2-
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2+
import { mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
4-
import { join } from "node:path";
4+
import { basename, join } from "node:path";
55
import { parse } from "comment-json";
66
import {
7+
__resetTuiPreferencesWatchTestHooks,
8+
__setTuiPreferencesWatchTestHooks,
79
computeEffectiveOrder,
810
DEFAULT_PREFS,
911
DEFAULT_SLOT_ORDER,
@@ -13,6 +15,7 @@ import {
1315
readTuiPreferencesFile,
1416
resolveMagicContextPrefs,
1517
TUI_PREFS_FILE_ENV,
18+
watchTuiPreferences,
1619
} from "./tui-preferences";
1720

1821
let dir: string;
@@ -28,6 +31,7 @@ beforeEach(async () => {
2831
});
2932

3033
afterEach(async () => {
34+
__resetTuiPreferencesWatchTestHooks();
3135
for (const key of ENV_KEYS) {
3236
if (savedEnv[key] === undefined) delete process.env[key];
3337
else process.env[key] = savedEnv[key];
@@ -79,6 +83,52 @@ describe("readTuiPreferencesFile (tolerant)", () => {
7983
});
8084
});
8185

86+
describe("watchTuiPreferences", () => {
87+
test("notifies when the initial read resolves after an atomic replacement", async () => {
88+
const previous = `{"magic-context":{"sections":{"memory":true}}}\n`;
89+
const next = `{"magic-context":{"sections":{"memory":false}}}\n`;
90+
await writeFile(file, previous, "utf8");
91+
92+
let resolveInitialRead!: (text: string) => void;
93+
const pendingInitialRead = new Promise<string>((resolve) => {
94+
resolveInitialRead = resolve;
95+
});
96+
let emitWatchEvent!: (event: string, filename: string | null) => void;
97+
let readCount = 0;
98+
__setTuiPreferencesWatchTestHooks({
99+
readFile: () => {
100+
readCount += 1;
101+
return readCount === 1 ? pendingInitialRead : Promise.resolve(next);
102+
},
103+
watch: (_directory, listener) => {
104+
emitWatchEvent = listener;
105+
return { close() {} };
106+
},
107+
});
108+
109+
const observed = new Promise<{ memory: boolean }>((resolve) => {
110+
const stop = watchTuiPreferences(() => {
111+
void readTuiPreferencesFile().then((root) => {
112+
stop();
113+
resolve({ memory: resolveMagicContextPrefs(root).sections.memory });
114+
});
115+
});
116+
});
117+
118+
const replacement = `${file}.external.tmp`;
119+
await writeFile(replacement, next, "utf8");
120+
await rename(replacement, file);
121+
emitWatchEvent("rename", basename(file));
122+
resolveInitialRead(next);
123+
124+
const result = await Promise.race([
125+
observed,
126+
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 300)),
127+
]);
128+
expect(result).toEqual({ memory: false });
129+
});
130+
});
131+
82132
describe("resolveMagicContextPrefs (per-key validation)", () => {
83133
test("missing key → full defaults clone", () => {
84134
expect(resolveMagicContextPrefs({})).toEqual(DEFAULT_PREFS);

‎packages/plugin/src/shared/tui-preferences.ts‎

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,28 @@ export function queueTuiPreferenceUpdate(
250250

251251
const WATCH_DEBOUNCE_MS = 150;
252252

253+
type WatchReadFile = (file: string) => Promise<string>;
254+
type WatchDirectory = (
255+
directory: string,
256+
listener: (event: string, filename: string | null) => void,
257+
) => { close(): void };
258+
259+
let watchReadFile: WatchReadFile = (file) => readFile(file, "utf8");
260+
let watchDirectory: WatchDirectory = (directory, listener) => watch(directory, listener);
261+
262+
export function __setTuiPreferencesWatchTestHooks(hooks: {
263+
readFile?: WatchReadFile;
264+
watch?: WatchDirectory;
265+
}): void {
266+
watchReadFile = hooks.readFile ?? ((file) => readFile(file, "utf8"));
267+
watchDirectory = hooks.watch ?? ((directory, listener) => watch(directory, listener));
268+
}
269+
270+
export function __resetTuiPreferencesWatchTestHooks(): void {
271+
watchReadFile = (file) => readFile(file, "utf8");
272+
watchDirectory = (directory, listener) => watch(directory, listener);
273+
}
274+
253275
// Watches the DIRECTORY, not the file: editors and our own atomic writes replace
254276
// the file via rename, which kills file-level watchers.
255277
//
@@ -266,33 +288,36 @@ export function watchTuiPreferences(onChange: () => void): () => void {
266288
const name = basename(file);
267289
let timer: ReturnType<typeof setTimeout> | null = null;
268290
let lastSeen: string | null = null;
269-
// Seed asynchronously; a real change before the seed resolves still wins
270-
// because the debounce re-reads fresh and compares against `lastSeen` (null
271-
// → does not match → fires).
272-
void readFile(file, "utf8")
273-
.then((text) => {
274-
if (lastSeen === null) lastSeen = text;
275-
})
276-
.catch(() => {});
277291
try {
278-
const watcher = watch(dirname(file), (_event, filename) => {
292+
lastSeen = readFileSync(file, "utf8");
293+
} catch {
294+
// A missing or temporarily unreadable baseline is retried after registration.
295+
}
296+
const reconcile = (): void => {
297+
void watchReadFile(file)
298+
.catch(() => null)
299+
.then((text) => {
300+
if (text === null || text === lastSeen) return;
301+
lastSeen = text;
302+
onChange();
303+
});
304+
};
305+
try {
306+
const watcher = watchDirectory(dirname(file), (_event, filename) => {
279307
const isOurs =
280308
filename === name ||
281309
(filename?.startsWith(`${name}.`) && filename.endsWith(".tmp"));
282310
if (filename != null && !isOurs) return;
283311
if (timer) clearTimeout(timer);
284312
timer = setTimeout(() => {
285313
timer = null;
286-
void readFile(file, "utf8")
287-
.catch(() => null)
288-
.then((text) => {
289-
if (text === null) return;
290-
if (text === lastSeen) return;
291-
lastSeen = text;
292-
onChange();
293-
});
314+
reconcile();
294315
}, WATCH_DEBOUNCE_MS);
295316
});
317+
// Reconcile after registration: a change between the baseline read and
318+
// watcher installation is observed here, while later changes schedule
319+
// the same reconciliation path through the watcher callback above.
320+
reconcile();
296321
return () => {
297322
if (timer) clearTimeout(timer);
298323
watcher.close();

0 commit comments

Comments
 (0)