mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
fix(proof): rebind a linked missions root when its real directory is replaced (#1813)
Found in review of 64649643. With the missions root configured through a link, a structural change at its real location dropped the shallow watches below it but compared the recursive watch only with the configured path, so the recursive watch stayed on the replaced directory while the sources were reported watched, and a mission added afterwards was missed. The recursive watch now records the real location it was attached at; a change at or above either name, with or without a filename, closes it to be reattached by path, and a root whose real location has moved is reattached on the next read. The real missions root is always anchored at its parents, whatever was read, and paths under it are left to the recursive watch.
This commit is contained in:
1 parent
646496438e
commit
081c4d24e1
2 files changed
+40
-7
No files matched your search
@@ -38,9 +38,10 @@ function canonical(p: string): string | null {
|
||||
/** Where each path the evaluation read outside the missions root is observed: its parent, shallow, filtered to its
|
||||
* name (for a missing parent, the nearest existing ancestor, filtered to the next segment toward it). A directory
|
||||
* whose real location leaves the project is never watched: the nearest ancestor that stays inside is, filtered to
|
||||
* the next segment. Every watched directory, the missions root included, is also anchored at each ancestor up to
|
||||
* the next segment. Every watched directory, the missions root included (under its configured and its real name),
|
||||
* is also anchored at each ancestor up to
|
||||
* `top`, filtered to the next segment, so a replaced or re-pointed directory on the way is seen and its watch rebound. */
|
||||
function shallowWatches(missionsRoot: string, top: string, project: string, read: Iterable<string>): Shallow {
|
||||
function shallowWatches(missionsRoot: string, realRoot: string, top: string, project: string, read: Iterable<string>): Shallow {
|
||||
const out: Shallow = new Map();
|
||||
const inside = (dir: string) => { const real = canonical(dir); return real !== null && within(real, project); };
|
||||
// The project may be reached by an alias: real-location paths are bounded by the real project's parent.
|
||||
@@ -48,7 +49,7 @@ function shallowWatches(missionsRoot: string, top: string, project: string, read
|
||||
const topOf = (p: string) => (within(p, top) ? top : within(p, realTop) ? realTop : null);
|
||||
for (const file of read) {
|
||||
const target = path.resolve(file);
|
||||
if (within(target, missionsRoot)) continue;
|
||||
if (within(target, missionsRoot) || within(target, realRoot)) continue;
|
||||
let child = target, dir = path.dirname(target);
|
||||
while (dir !== child && !proofFs.exists(dir)) { child = dir; dir = path.dirname(dir); }
|
||||
const bound = topOf(dir);
|
||||
@@ -57,7 +58,8 @@ function shallowWatches(missionsRoot: string, top: string, project: string, read
|
||||
if (dir === child || dir === bound) continue;
|
||||
add(out, dir, path.basename(child));
|
||||
}
|
||||
for (const watched of [...out.keys(), missionsRoot]) {
|
||||
// The missions root is anchored under both its names: a replaced real root must rebind the recursive watch too.
|
||||
for (const watched of [...out.keys(), missionsRoot, realRoot]) {
|
||||
const bound = topOf(watched);
|
||||
if (bound === null) continue;
|
||||
for (let p = watched; p !== bound && within(p, bound); p = path.dirname(p)) {
|
||||
@@ -99,7 +101,7 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
const real = canonical(file);
|
||||
if (real !== null && real !== path.resolve(file) && within(real, project)) read.add(real);
|
||||
}
|
||||
return { hash, watches: shallowWatches(missionsRoot, top, project, read) };
|
||||
return { hash, watches: shallowWatches(missionsRoot, canonical(missionsRoot) ?? missionsRoot, top, project, read) };
|
||||
};
|
||||
// failed latches for this instance: a watcher that errored is not trusted to be replaced by a later read.
|
||||
let closed = false, failed = false, covered = false, readable = false;
|
||||
@@ -107,11 +109,13 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
let wanted: Shallow = new Map();
|
||||
const shallow = new Map<string, fs.FSWatcher>();
|
||||
let recursive: fs.FSWatcher | undefined;
|
||||
// The real location the recursive watch was attached at (it follows a linked root to that directory).
|
||||
let recursiveReal = missionsRoot;
|
||||
const notify = (next: string) => { try { bus.emit({ type: "proof.sources_changed", scope: missionsRoot, revision: next }); } catch { /* Quiet refresh remains the repair path. */ } };
|
||||
/** A directory at or below `changed` may have been replaced: its watches are dropped, to be reattached by path. */
|
||||
const stale = (changed: string) => {
|
||||
for (const [dir, watcher] of shallow) if (within(dir, changed)) { watcher.close(); shallow.delete(dir); }
|
||||
if (recursive && within(missionsRoot, changed)) { recursive.close(); recursive = undefined; }
|
||||
if (recursive && (within(missionsRoot, changed) || within(recursiveReal, changed))) { recursive.close(); recursive = undefined; }
|
||||
};
|
||||
const onEvent = (dir: string | null) => (_event: string, filename: string | Buffer | null) => {
|
||||
if (closed) return;
|
||||
@@ -140,7 +144,9 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
* longer needed (new coverage first). Returns whether anything was attached. */
|
||||
const resubscribe = (next: Shallow): boolean => {
|
||||
let added = false;
|
||||
if (!recursive) { recursive = watchDir(missionsRoot, true); added = true; }
|
||||
const real = canonical(missionsRoot) ?? missionsRoot;
|
||||
if (recursive && real !== recursiveReal) { recursive.close(); recursive = undefined; }
|
||||
if (!recursive) { recursive = watchDir(missionsRoot, true); recursiveReal = real; added = true; }
|
||||
for (const dir of next.keys()) {
|
||||
if (shallow.has(dir)) continue;
|
||||
shallow.set(dir, watchDir(dir, false));
|
||||
@@ -189,6 +195,7 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
timer.unref();
|
||||
};
|
||||
try {
|
||||
recursiveReal = canonical(missionsRoot) ?? missionsRoot;
|
||||
recursive = watchDir(missionsRoot, true);
|
||||
try { revision = evaluate(); readable = true; } catch (e) {
|
||||
if (e instanceof WatchFailure) throw e;
|
||||
|
||||
@@ -331,6 +331,32 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
|
||||
expect(watched.some((root) => !String(root).startsWith(t.f.root) && root !== path.dirname(t.f.root))).toBe(false);
|
||||
} finally { t.watch.close(); }
|
||||
});
|
||||
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, () => {
|
||||
const f = fixture(), real = path.join(f.root, "real"), linked = path.join(f.root, "linked-missions");
|
||||
fs.mkdirSync(real); fs.renameSync(f.missions, path.join(real, "missions")); fs.symlinkSync(path.join(real, "missions"), linked);
|
||||
vi.useFakeTimers();
|
||||
// Each handle with its listener; a handle opened before the replacement watches the old directory and stays silent.
|
||||
const opened: Array<{ root: string; recursive: boolean; event: (event: string, name: string | null) => void }> = [];
|
||||
vi.spyOn(fs, "watch").mockImplementation(((root: string, options: { recursive: boolean }, listener: (event: string, name: string | null) => void) => {
|
||||
opened.push({ root, recursive: options.recursive, event: listener });
|
||||
return { on: vi.fn(), close: vi.fn() };
|
||||
}) as unknown as typeof fs.watch);
|
||||
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(linked).missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
const watch = watchProofSources(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
try {
|
||||
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
|
||||
fs.renameSync(path.join(real, "missions"), path.join(real, "missions.old"));
|
||||
fs.cpSync(path.join(real, "missions.old"), path.join(real, "missions"), { recursive: true });
|
||||
const before = opened.length;
|
||||
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); vi.advanceTimersByTime(50);
|
||||
// A new mission in the replacement tree reaches clients through the live watches only.
|
||||
fs.cpSync(path.join(real, "missions", "trial"), path.join(real, "missions", "new-mission"), { recursive: true });
|
||||
for (const h of opened.slice(before)) h.event("rename", h.recursive ? "new-mission/mission.yaml" : "new-mission");
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(watch.observation().state === "unavailable" || watch.observation().revision === oracle()).toBe(true);
|
||||
expect(opened.some((h) => !h.root.startsWith(f.root) && h.root !== path.dirname(f.root))).toBe(false);
|
||||
} finally { watch.close(); }
|
||||
});
|
||||
it("QA observes replacement of an external evidence parent or honestly reports unavailable", () => {
|
||||
const t = adverseFixture();
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user