diff --git a/apps/runtime/test/widget-dev-sessions.spec.ts b/apps/runtime/test/widget-dev-sessions.spec.ts index 2d8eb847..66f6fe44 100644 --- a/apps/runtime/test/widget-dev-sessions.spec.ts +++ b/apps/runtime/test/widget-dev-sessions.spec.ts @@ -1080,17 +1080,19 @@ describe("a widget dev session", () => { const child = spawn(process.execPath, ["-e", script, root, source], { stdio: "ignore" }); const exited = new Promise((done) => child.on("exit", done)); - // A build that runs while no folder is at the path: it fails on the missing files, and the session stays live. + // A rebuild asked for while no folder is at the path waits for it rather than failing on the missing files, and + // answers with the one build made once it is back. const deadline = Date.now() + 5_000; while (existsSync(root) && Date.now() < deadline) await new Promise((done) => setTimeout(done, 5)); expect(existsSync(root)).toBe(false); const during = await call("POST", `/widget-dev/sessions/${started.sessionId}/rebuild`); expect(during.status).toBe(200); - expect(session(during)).toMatchObject({ status: "live", lastBuild: { ok: false, trigger: "rebuild" }, activation: { state: "active", generation: 1 } }); - + expect(session(during)).toMatchObject({ + status: "live", + lastBuild: { ok: true, trigger: "rebuild", generation: 2 }, + activation: { state: "active", generation: 2 }, + }); expect(await exited).toBe(0); - const rebuilt = await eventually(started.sessionId, (view) => view.status === "stopped" || (view.activation.state === "active" && view.activation.generation === 2)); - expect(rebuilt).toMatchObject({ status: "live", activation: { state: "active", generation: 2 } }); }); it("stops a watched session as watch-failed when its folder cannot be looked at for the time bound, and keeps what runs", async () => { diff --git a/docs/open-interfaces.md b/docs/open-interfaces.md index 8c107961..a2af54f5 100644 --- a/docs/open-interfaces.md +++ b/docs/open-interfaces.md @@ -995,10 +995,16 @@ file id with the ones it started watching; when they differ, the folder was made the folder now at that path and builds it, as it builds a saved change, and logs the old and new ids. A folder that is missing when the node looks, because a build deleted it and has not made it again yet, is looked for -again for 2 seconds before the session stops. No build starts meanwhile. A build already running when the folder went, -or a rebuild asked for in those 2 seconds, fails on the missing files and leaves the session live; the folder is built -again once it is back. A build that takes longer than 2 seconds to make the folder again stops the session as -`folder-gone`. +again for 2 seconds before the session stops. No build starts meanwhile, and none is reported as failed: a build +already running when the folder went, a save, or a rebuild asked for in those 2 seconds waits, and the folder is built +once when it is back. A rebuild answers with that build. A build that takes longer than 2 seconds to make the folder +again stops the session as `folder-gone`, and a rebuild that was waiting answers that the folder was missing. + +A change the platform reports for a folder inside the package, rather than for a file, does not start a build. Windows +reports one the first time a build lists a folder made a moment ago, and a file added, removed or saved is reported +under its own name. For about a second after a folder made again is watched anew, a build that finds the same files as +the build before it is not reported: macOS can report the writes that made the folder after the watch began. A save +made in that time changes the files, so it is built and reported. A folder chosen through a link or junction is watched at the real path it led to when the session started. A folder made again counts only while its path still resolves to that real path. Paths that differ only in case count as the diff --git a/docs/open-interfaces.vi.md b/docs/open-interfaces.vi.md index 2a9610fc..e12e8c73 100644 --- a/docs/open-interfaces.vi.md +++ b/docs/open-interfaces.vi.md @@ -1002,9 +1002,16 @@ những giá trị lúc bắt đầu theo dõi; khi chúng khác nhau, thư mụ mục hiện nằm ở đường dẫn đó và dựng nó, giống như khi dựng một thay đổi đã lưu, và ghi id cũ lẫn id mới vào nhật ký. Một thư mục không có ở đó khi node xem, vì một lần dựng đã xoá nó mà chưa tạo lại, sẽ được tìm lại trong 2 giây trước -khi phiên dừng. Trong lúc đó không có lần dựng mới nào bắt đầu. Một lần dựng đang chạy khi thư mục biến mất, hoặc một -lần dựng lại được yêu cầu trong 2 giây đó, sẽ thất bại vì thiếu tệp và phiên vẫn chạy; thư mục được dựng lại khi nó trở -lại. Một lần dựng cần lâu hơn 2 giây để tạo lại thư mục sẽ làm phiên dừng với lý do `folder-gone`. +khi phiên dừng. Trong lúc đó không có lần dựng mới nào bắt đầu, và không lần dựng nào bị báo là thất bại: một lần dựng +đang chạy khi thư mục biến mất, một lần lưu, hoặc một lần dựng lại được yêu cầu trong 2 giây đó sẽ chờ, và thư mục được +dựng một lần khi nó trở lại. Một lần dựng lại sẽ trả về chính lần dựng đó. Một lần dựng cần lâu hơn 2 giây để tạo lại +thư mục sẽ làm phiên dừng với lý do `folder-gone`, và một lần dựng lại đang chờ sẽ trả lời rằng thư mục đã không còn. + +Một thay đổi mà nền tảng báo cho một thư mục bên trong gói, thay vì cho một tệp, không khởi động lần dựng nào. Windows +báo một thay đổi như vậy vào lần đầu một lần dựng liệt kê một thư mục vừa được tạo, còn một tệp được thêm, xoá hay lưu +thì được báo bằng chính tên của nó. Trong khoảng một giây sau khi một thư mục được tạo lại bắt đầu được theo dõi lại, +một lần dựng thấy các tệp giống hệt lần dựng trước sẽ không được báo: macOS có thể báo những lần ghi đã tạo ra thư mục +sau khi việc theo dõi bắt đầu. Một lần lưu trong khoảng đó làm thay đổi tệp, nên vẫn được dựng và được báo. Một thư mục được chọn qua một liên kết hay junction được theo dõi ở đường dẫn thật mà nó dẫn tới lúc phiên bắt đầu. Một thư mục được tạo lại chỉ được tính khi đường dẫn của nó vẫn phân giải về đúng đường dẫn thật đó. Trên Windows và macOS, diff --git a/packages/core/src/widget-dev-engine.ts b/packages/core/src/widget-dev-engine.ts index 30bcd242..b2d3c7ce 100644 --- a/packages/core/src/widget-dev-engine.ts +++ b/packages/core/src/widget-dev-engine.ts @@ -66,7 +66,8 @@ export const DEV_ENGINE_REARM_MAX = 30; * How long a watched folder may be missing before it counts as gone. A build that deletes its output folder and makes it * again (`rm -rf out && build`, `rmdir /s /q out && xcopy src out`) leaves the path empty for a moment, and the old * watcher reports the deletion straight away; a folder back at the same real path within this time is watched anew - * rather than stopping the session. Nothing is built while the folder is missing. + * rather than stopping the session. Nothing is built while the folder is missing: a build asked for meanwhile, or one that + * fails because the folder went while it ran, is held, and the folder is built once when it is back. */ export const DEV_ENGINE_ROOT_MISSING_GRACE_MS = 2_000; /** @@ -153,7 +154,11 @@ export interface DevEngine { /** The newest successful generation. */ latest(): DevGenerationRecord | undefined; lastBuild(): WidgetDevBuild | undefined; - /** Build now, after any build already running. */ + /** + * Build now, after any build already running. While a watched folder is being made again, this waits for the build + * that runs once it is back (reported through `onBuild` by that build, not twice), or, should it not come back within + * the grace, settles with why it was not built. + */ rebuild(trigger?: WidgetDevTrigger): Promise; /** Whether the folder is being watched: false after `close`, after a watch failure, or when watching was not asked for. */ watching(): boolean; @@ -257,6 +262,20 @@ function sizeOf(root: string): number { const messageOf = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause)); +/** + * Whether `path` is a folder (not a link to one). A `change` the watcher reports for a folder is about the folder's own + * entry, not its files: Windows reports one when a build first lists a folder made a moment ago (its last-access time is + * set), so building on it would build the files just built again. A file added, removed or saved in a folder is + * reported under the file's own name. + */ +function isFolderAt(path: string): boolean { + try { + return lstatSync(path, { throwIfNoEntry: false })?.isDirectory() === true; + } catch { + return false; + } +} + /** Holds `root` open where that keeps its file id from being given to a folder made again there; undefined elsewhere. */ function pinRoot(root: string): number | undefined { if (process.platform === "win32") return undefined; @@ -545,13 +564,98 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { return { kind: "generation", record, build: last }; }; - // Builds run one at a time, in order: a change that lands during a build is built after it. - let queue: Promise = build("start"); - const ready = queue; - const enqueue = (job: () => Promise): Promise => { - queue = queue.then(job, job); - return queue; + /** + * A build put off while the watched folder is being made again (`hold`), and those waiting on it: a rebuild or first build + * asked for meanwhile settles with the build that runs once the folder is back, or, should it not come back, with why it + * was not built. + */ + type Held = { promise: Promise; settle: (event: DevEngineEvent) => void; failure: DevEngineEvent; trigger?: WidgetDevTrigger }; + let held: Held | undefined; + /** How many absences or re-arms the watched folder has had: a failed build during which this moved failed on a folder being made again. */ + let disturbances = 0; + + /** + * What a build attempt came to: an event to report, with the held build it ends (settled by `done` once the event is + * reported, so a rebuild waiting on it sees what that report made of it), or a build held until the folder is back. + */ + type Attempt = { event: DevEngineEvent; ends?: Held } | { held: Promise }; + const done = (outcome: Attempt): void => { + if ("event" in outcome) outcome.ends?.settle(outcome.event); + }; + + /** + * Put a build off while the folder is being made again, rather than building (or reporting a failure from) missing or + * half-written files: the build that runs once the folder is back is the one reported. Not while the folder is not + * watched: nothing would bring that build about. + */ + const hold = (trigger: WidgetDevTrigger, failure?: DevEngineEvent): Attempt => { + if (closed || watcher === undefined) { + const event = failure ?? missingBuild(trigger); + last = event.build; + return { event }; + } + if (held === undefined) { + let settle: (event: DevEngineEvent) => void = () => undefined; + const promise = new Promise((resolve) => (settle = resolve)); + held = { promise, settle, failure: failure ?? missingBuild(trigger) }; + } else if (failure !== undefined) { + held.failure = failure; + } + // A rebuild asked for meanwhile is built as a rebuild once the folder is back, so it asks again what it would have asked. + if (trigger !== "change") held.trigger = trigger; + return { held: held.promise }; + }; + /** A build that did not run because the folder was missing, as a failure that says so. */ + const missingBuild = (trigger: WidgetDevTrigger): DevEngineEvent => ({ + kind: "failed", + build: { + ok: false, + at: now(), + trigger, + diagnostics: [{ severity: "error", code: "FILES_UNREADABLE", message: "the folder was missing, so it was not built" }], + }, + }); + /** Settle a held build with the folder's last word on it, when the folder will not be built anymore (gone, or closed). */ + const releaseHeld = (): void => { + const waiting = held; + held = undefined; + if (waiting === undefined) return; + last = waiting.failure.build; + waiting.settle(waiting.failure); + }; + + /** + * Whether the watched folder is being made again now: missing within the grace, or already back under a new id (which + * is watched anew here, and that re-arm schedules the one build the new folder gets). + */ + const recreating = (): boolean => { + if (closed || watcher === undefined) return false; + const look = lookAtDevRoot(root, identity, canonical); + // Not when watching it anew failed instead: there is nothing to wait for then. + if (look.state === "replaced") return rootStillThere(); + // A build about to read the folder that is there covers a return the next look would otherwise build for. + if (look.state === "present") missingSince = undefined; + return stillAwaited(look); + }; + + /** Build, unless the folder is being made again; a build that failed because it was is held rather than reported. */ + const attempt = async (trigger: WidgetDevTrigger): Promise => { + if (recreating()) return hold(trigger); + const before = last; + const seen = disturbances; + const event = await build(held?.trigger ?? trigger); + // The counter first: a folder that went and came back while the files were read, and was watched anew meanwhile, + // looks present now, so only the counter says this failure was the absence. A look now is still needed for an + // absence no look has seen yet, and it is taken only after the build has failed, since it may watch the folder anew. + if (event.kind === "failed" && (disturbances !== seen || recreating())) { + last = before; + return hold(trigger, event); + } + const ends = held; + held = undefined; + return ends === undefined ? { event } : { event, ends }; }; + const eventOf = (outcome: Attempt): DevEngineEvent | Promise => ("event" in outcome ? outcome.event : outcome.held); /** * The folder watched, by identity: a folder deleted and made again at the same path before the next look is a folder @@ -600,8 +704,35 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { if (rootCheck !== undefined) clearInterval(rootCheck); rootCheck = undefined; release(); + releaseHeld(); }; const watchFailed = (cause: unknown): void => options.onWatchError?.(cause instanceof Error ? cause : new Error(String(cause))); + /** + * The moments after the folder is watched anew. A new watcher reports more than the saves made after it: FSEvents on + * macOS can deliver the writes that made the folder, from just before the stream started, and Windows reports folders the + * first build lists. Until `until`, once the build the re-arm asked for has been reported, a change build is reported + * only when it is news, as the catch-up build is; a save made meanwhile changes the files, which is news. + */ + let settling: { until: number; reported: boolean } | undefined; + /** Whether a change build starting now is past the first report after a re-arm and inside its settling window. */ + const settlingNow = (): boolean => { + if (settling === undefined) return false; + if (performance.now() >= settling.until) { + settling = undefined; + return false; + } + return settling.reported; + }; + const reportBuild = (event: DevEngineEvent): void => { + if (settling !== undefined) settling.reported = true; + options.onBuild?.(event); + }; + /** Whether a build says anything the last one did not: new files, a new failure, or the end of a failure. */ + const isNews = (before: WidgetDevBuild | undefined, event: DevEngineEvent): boolean => + event.kind === "generation" || + (event.kind === "failed" && !sameFailure(before, event.build)) || + // Files back to the newest generation after a failed build: the failure is over, which is news. + (event.kind === "unchanged" && before?.ok === false); /** Build after the last change of a burst, and report what it built. */ const scheduleBuild = (): void => { if (timer !== undefined) clearTimeout(timer); @@ -609,8 +740,20 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { timer = undefined; // A folder watched anew by this look has its own build scheduled; building here as well would build it twice. if (closed || !rootStillThere() || timer !== undefined) return; - void enqueue(() => build("change")).then((event) => { - if (!closed) options.onBuild?.(event); + void enqueue(async (): Promise<{ outcome: Attempt; report: boolean }> => { + const quiet = settlingNow(); + const before = last; + const outcome = await attempt("change"); + if (!("event" in outcome)) return { outcome, report: false }; + const report = !quiet || outcome.ends !== undefined || isNews(before, outcome.event); + if (!report && before !== undefined) last = before; + return { outcome, report }; + }).then(({ outcome, report }) => { + try { + if (!closed && report && "event" in outcome) reportBuild(outcome.event); + } finally { + done(outcome); + } }, watchFailed); }, debounceMs); timer.unref(); @@ -618,8 +761,9 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { /** Watch the folder at `root` now, held open where that keeps its id (`pinRoot`). */ const arm = (): void => { pin = pinRoot(root); - const next = watch(root, { recursive: true }, (_event, filename) => { + const next = watch(root, { recursive: true }, (event, filename) => { const name = typeof filename === "string" ? filename : ""; + if (event === "change" && name !== "" && isFolderAt(join(root, name))) return; if (!rootStillThere()) return; if (isExcludedRootName(name.split(/[\\/]/)[0] ?? "")) return; scheduleBuild(); @@ -646,10 +790,10 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { * link or junction is `gone`. More than `DEV_ENGINE_REARM_MAX` of these in a row, with no look between them finding * the folder unchanged, stops watching as a watch failure. * - A folder missing from the path (`gone`, not found) counts as gone only once it has been missing for - * `rootMissingGraceMs`: until then no build starts (`rootGone` says not gone either, so a build already running that - * fails on the missing files leaves the caller's session live) and the path is looked at again, and a folder back at - * the same real path is watched anew (`replaced`) and built. Anything else that is `gone` (not a folder, or reached through a link) is - * gone at once. + * `rootMissingGraceMs`: until then no build starts and `rootGone` says not gone either; a build already running that + * fails on the missing files is held rather than reported (`attempt`). The path is looked at again, and a folder back + * at the same real path is watched anew (`replaced`) and built once. Anything else that is `gone` (not a folder, or + * reached through a link) is gone at once. * - A folder that could not be looked at this time (`unknown`) is looked at again on the next tick rather than taken as * gone, until it has failed for `rootUnreadableMs` in a row: then watching stops as a watch failure that says why. */ @@ -660,7 +804,10 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { const stillAwaited = (look: DevRootLook): boolean => { if (look.state !== "gone" || look.missing !== true || closed || watcher === undefined) return false; const at = performance.now(); - missingSince ??= at; + if (missingSince === undefined) { + missingSince = at; + disturbances += 1; + } return at - missingSince < missingGraceMs; }; const rootStillThere = (): boolean => { @@ -679,9 +826,12 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { } unreadableSince = undefined; if (stillAwaited(look)) return false; + const returned = missingSince !== undefined; missingSince = undefined; if (look.state === "present") { rearmsInARow = 0; + // Back as the very folder that went (renamed away and back): what changed meanwhile, or a build held, is built now. + if (returned) scheduleBuild(); return true; } if (look.state === "replaced") { @@ -698,6 +848,7 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { } process.stderr.write(`widget dev: watching ${root} anew: its file id changed from ${idOf(identity)} to ${idOf(look.identity)}\n`); release(); + disturbances += 1; try { identity = look.identity; arm(); @@ -706,6 +857,7 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { watchFailed(cause); return false; } + settling = { until: performance.now() + 2 * DEV_ENGINE_WATCH_CATCH_UP_MS + debounceMs, reported: false }; scheduleBuild(); // As at the start: a save the new watcher could not see yet (FSEvents starts asynchronously) is built then. scheduleCatchUp(); @@ -727,28 +879,45 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { catchUp = undefined; // A change already waiting to build reads the folder anyway; so does the build of a folder this look watched anew. if (closed || timer !== undefined || !rootStillThere() || timer !== undefined) return; - let news = false; - void enqueue(async () => { + void enqueue(async (): Promise<{ outcome: Attempt; news: boolean }> => { // Read once every build before it has settled, the first one included: a first build slower than the catch-up // delay has not set the last build when the timer fires, and its failure would otherwise be news twice. const before = last; - const event = await build("change"); - news = - event.kind === "generation" || - (event.kind === "failed" && !sameFailure(before, event.build)) || - // Files back to the newest generation after a failed build: the failure is over, which is news. - (event.kind === "unchanged" && before?.ok === false); + const outcome = await attempt("change"); + // Held while the folder is made again: the build once it is back is reported by whoever runs it. + if (!("event" in outcome)) return { outcome, news: false }; + // The build a rebuild was held for is reported, whatever it found. + const news = outcome.ends !== undefined || isNews(before, outcome.event); if (!news && before !== undefined) last = before; - return event; + return { outcome, news }; }).then( - (event) => { - if (!closed && news) options.onBuild?.(event); + ({ outcome, news }) => { + try { + if (!closed && news && "event" in outcome) reportBuild(outcome.event); + } finally { + done(outcome); + } }, watchFailed, ); }, DEV_ENGINE_WATCH_CATCH_UP_MS); catchUp.unref(); }; + + // Builds run one at a time, in order: a change that lands during a build is built after it. The first one starts + // before anything is watched. + let queue: Promise = attempt("start"); + // The first build is not reported through `onBuild`: whoever started the engine reads it here. + const ready = (queue as Promise).then((outcome) => { + done(outcome); + return eventOf(outcome); + }); + const enqueue = (job: () => Promise): Promise => { + const next = queue.then(job, job); + queue = next; + return next; + }; + if (options.watch !== false) { try { identity = devRootIdentityOf(root); @@ -768,13 +937,19 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { latest: () => newest, lastBuild: () => last, rebuild: async (trigger = "rebuild") => { - const event = await enqueue(() => build(trigger)); - if (!closed) options.onBuild?.(event); - return event; + const outcome = await enqueue(() => attempt(trigger)); + // Held while the folder is made again: the build that runs once it is back reports itself, and this settles with it. + if (!("event" in outcome)) return outcome.held; + try { + if (!closed) reportBuild(outcome.event); + } finally { + done(outcome); + } + return outcome.event; }, watching: () => watcher !== undefined && !closed, rootGone: () => { - // A build that overlaps a folder being made again fails on its missing files; the folder is not gone for that. + // A folder being made again is not gone: a build that overlaps it is held until it is back. const look = lookAtDevRoot(root, identity, canonical); return look.state === "gone" && !stillAwaited(look); }, diff --git a/packages/core/test/widget-dev-engine.spec.ts b/packages/core/test/widget-dev-engine.spec.ts index 813f4be5..f388bd35 100644 --- a/packages/core/test/widget-dev-engine.spec.ts +++ b/packages/core/test/widget-dev-engine.spec.ts @@ -24,8 +24,19 @@ const statFailure = vi.hoisted(() => ({ path: undefined as string | undefined, c /** A folder whose file id reads as another one, as a FUSE mount without stable inode numbers reports a folder still there. */ const statNewId = vi.hoisted(() => ({ path: undefined as string | undefined, flap: false, shift: 0n, step: 1n })); +/** Runs once just before this file is read: a build reading its manifest, at the moment something else changes the folder. */ +const beforeRead = vi.hoisted(() => ({ path: undefined as string | undefined, run: undefined as (() => void) | undefined })); + vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); + const readFileSync = ((path: fs.PathOrFileDescriptor, options?: Parameters[1]) => { + if (beforeRead.run !== undefined && typeof path === "string" && resolve(path) === beforeRead.path) { + const run = beforeRead.run; + beforeRead.run = undefined; + run(); + } + return actual.readFileSync(path, options); + }) as typeof actual.readFileSync; const statSync = ((path: fs.PathLike, options?: fs.StatSyncOptions) => { if (statFailure.path !== undefined && resolve(String(path)) === statFailure.path) { throw Object.assign(new Error(`${statFailure.code}: operation not permitted, stat '${String(path)}'`), { code: statFailure.code }); @@ -38,7 +49,7 @@ vi.mock("node:fs", async (importOriginal) => { } return stat; }) as typeof actual.statSync; - return { ...actual, statSync, default: { ...actual, statSync } }; + return { ...actual, statSync, readFileSync, default: { ...actual, statSync, readFileSync } }; }); /** @@ -104,6 +115,8 @@ function engineFor(root: string, cacheRoot?: string): DevEngine { } afterEach(() => { + beforeRead.path = undefined; + beforeRead.run = undefined; statFailure.path = undefined; statNewId.path = undefined; statNewId.flap = false; @@ -401,6 +414,45 @@ describe("the dev engine", () => { expect(engine.latest()?.generation.generation).toBe(2); }); + it("reports no unchanged build for writes a new watcher reports late, and still builds a save made right after", async () => { + const root = tempDir("dev-engine-"); + writePackage(root, "

one

"); + const built: string[] = []; + const engine = startDevEngine({ + root, + watch: true, + debounceMs: 30, + rootCheckMs: 20, + onBuild: (event) => built.push(`${event.build.trigger}:${event.kind}`), + }); + engines.push(engine); + await engine.ready; + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 200)); + built.length = 0; + + // Watched anew under a new id: the build the re-arm asks for is reported. + statNewId.path = engine.root; + const deadline = Date.now() + 5_000; + while (built.length === 0 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 10)); + expect(built).toEqual(["change:unchanged"]); + const reported = engine.lastBuild(); + + // The same bytes written again, as FSEvents reports the writes that made a folder from just before its stream began: + // the build it causes finds nothing new, and says nothing. + writeFileSync(join(root, "widgets", "main", "index.html"), "

one

"); + await new Promise((done) => setTimeout(done, 200)); + expect(built).toEqual(["change:unchanged"]); + expect(engine.lastBuild()).toBe(reported); + + // A real save in the same moments is news, and builds. + writeFileSync(join(root, "widgets", "main", "index.html"), "

saved right after

"); + const saved = Date.now() + 5_000; + while (engine.latest()?.generation.generation !== 2 && Date.now() < saved) await new Promise((done) => setTimeout(done, 10)); + expect(engine.latest()?.generation.generation).toBe(2); + await new Promise((done) => setTimeout(done, 100)); + expect(built).toEqual(["change:unchanged", "change:generation"]); + }); + /** A link to a folder: a junction on Windows, which needs no privilege, and a symbolic link elsewhere. */ const linkFolder = (target: string, path: string): void => symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir"); @@ -628,20 +680,202 @@ describe("the dev engine", () => { const root = tempDir("dev-engine-"); writePackage(root, "

one

"); let gone = 0; - const engine = startDevEngine({ root, watch: true, debounceMs: 30, rootCheckMs: 20, rootMissingGraceMs: 400, onRootGone: () => (gone += 1) }); + const built: string[] = []; + const engine = startDevEngine({ + root, + watch: true, + debounceMs: 30, + rootCheckMs: 20, + rootMissingGraceMs: 400, + onRootGone: () => (gone += 1), + onBuild: (event) => built.push(event.kind), + }); engines.push(engine); await engine.ready; + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 100)); + const good = engine.lastBuild(); rmSync(root, { recursive: true, force: true }); - // A build that overlaps the absence fails on the missing files, and the folder is not gone for that. - expect((await engine.rebuild()).kind).toBe("failed"); + // A rebuild asked for while the folder may still come back waits for it rather than failing on the missing files. + let settled = false; + const rebuilt = engine.rebuild().finally(() => (settled = true)); + await new Promise((done) => setTimeout(done, 150)); + expect(settled).toBe(false); expect(engine.rootGone()).toBe(false); expect(engine.watching()).toBe(true); + expect(engine.lastBuild()).toBe(good); - const deadline = Date.now() + 5_000; - while (gone === 0 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 20)); + // Not back within the grace: the rebuild settles with why nothing was built, and the folder is gone. + const event = await rebuilt; + expect(event.kind).toBe("failed"); + expect(event.build.diagnostics).toEqual([expect.objectContaining({ code: "FILES_UNREADABLE" })]); expect(gone).toBe(1); expect(engine.rootGone()).toBe(true); + expect(built).toEqual([]); + }); + + it("holds a build that fails because the folder went while it ran, and builds the folder once when it is back", async () => { + const root = tempDir("dev-engine-"); + writePackage(root, "

one

"); + let gone = 0; + const built: string[] = []; + const engine = startDevEngine({ + root, + watch: true, + debounceMs: 30, + rootCheckMs: 20, + onRootGone: () => (gone += 1), + onBuild: (event) => built.push(`${event.build.trigger}:${event.kind}`), + }); + engines.push(engine); + await engine.ready; + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 100)); + built.length = 0; + const good = engine.lastBuild(); + + // The folder goes just as the rebuild reads it, so that build fails on the missing files. + beforeRead.path = join(engine.root, "clarkcant.json"); + beforeRead.run = () => rmSync(engine.root, { recursive: true, force: true }); + let settled = false; + const rebuilt = engine.rebuild().finally(() => (settled = true)); + await new Promise((done) => setTimeout(done, 200)); + expect(beforeRead.run).toBeUndefined(); + expect(settled).toBe(false); + expect(built).toEqual([]); + expect(engine.lastBuild()).toBe(good); + expect(engine.rootGone()).toBe(false); + + writePackage(root, "

made again

"); + const event = await rebuilt; + expect(event.kind).toBe("generation"); + expect(event.build.trigger).toBe("rebuild"); + // Past the catch-up build of the new watcher, and any change the build itself made the platform report. + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 300)); + expect(built).toEqual(["rebuild:generation"]); + expect(engine.latest()?.generation.generation).toBe(2); + expect(gone).toBe(0); + }); + + it("builds a folder another process makes again exactly once, with no failed build, though rebuilds overlap the absence", async () => { + const base = tempDir("dev-engine-"); + const root = join(base, "out"); + const source = join(base, "source"); + writePackage(root, "

one

"); + writePackage(source, "

made again by another process

"); + const cacheRoot = tempDir("dev-engine-cache-"); + let gone = 0; + const failures: Error[] = []; + const built: string[] = []; + const engine = startDevEngine({ + root, + cacheRoot, + watch: true, + debounceMs: 30, + rootCheckMs: 50, + onRootGone: () => (gone += 1), + onWatchError: (error) => failures.push(error), + onBuild: (event) => built.push(`${event.build.trigger}:${event.kind}`), + }); + engines.push(engine); + await engine.ready; + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 100)); + built.length = 0; + + // As a build tool in its own process: delete the output folder, work for a moment, then write it again. It writes the + // folder again once the rebuilds below have been asked for (or after 1.5 s, inside the grace), so a slow runner cannot + // let the folder come back before they are. + const go = join(base, "go"); + const script = [ + "const fs = require('node:fs');", + "const [out, source, go] = process.argv.slice(1);", + "fs.rmSync(out, { recursive: true, force: true });", + "const until = Date.now() + 1500;", + "const wait = () => (fs.existsSync(go) || Date.now() > until ? fs.cpSync(source, out, { recursive: true }) : setTimeout(wait, 10));", + "wait();", + ].join("\n"); + const child = spawn(process.execPath, ["-e", script, root, source, go], { stdio: "ignore" }); + const exited = new Promise((done) => child.on("exit", done)); + const missing = Date.now() + 5_000; + while (existsSync(root) && Date.now() < missing) await new Promise((done) => setTimeout(done, 5)); + // Rebuilds asked for while the folder is missing, as saves and a person asking again would. + const rebuilds: Promise<{ kind: string }>[] = []; + for (let round = 0; round < 4; round += 1) { + rebuilds.push(engine.rebuild()); + await new Promise((done) => setTimeout(done, 60)); + } + expect(existsSync(root)).toBe(false); + writeFileSync(go, ""); + expect(await exited).toBe(0); + + const events = await Promise.all(rebuilds); + expect(events.map((event) => event.kind)).toEqual(["generation", "generation", "generation", "generation"]); + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 300)); + expect(built).toEqual(["rebuild:generation"]); + expect(engine.latest()?.generation.generation).toBe(2); + expect(engine.lastBuild()?.ok).toBe(true); + expect(gone).toBe(0); + expect(failures).toEqual([]); + expect(engine.watching()).toBe(true); + }); + + it("builds a folder another process made again once, though the build's own reading makes the platform report changes", async () => { + const base = tempDir("dev-engine-"); + const root = join(base, "out"); + const source = join(base, "source"); + writePackage(root, "

one

"); + writePackage(source, "

made again by another process

"); + const built: string[] = []; + const engine = startDevEngine({ root, watch: true, debounceMs: 30, rootCheckMs: 50, onBuild: (event) => built.push(`${event.build.trigger}:${event.kind}`) }); + engines.push(engine); + await engine.ready; + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 100)); + built.length = 0; + + const script = [ + "const fs = require('node:fs');", + "const [out, source] = process.argv.slice(1);", + "fs.rmSync(out, { recursive: true, force: true });", + "fs.cpSync(source, out, { recursive: true });", + ].join("\n"); + const child = spawn(process.execPath, ["-e", script, root, source], { stdio: "ignore" }); + expect(await new Promise((done) => child.on("exit", done))).toBe(0); + const deadline = Date.now() + 5_000; + while (engine.latest()?.generation.generation !== 2 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 25)); + expect(engine.latest()?.generation.generation).toBe(2); + // Past the catch-up build of the new watcher, and any change listing the new folders made the platform report. + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 300)); + expect(built).toEqual(["change:generation"]); + + // A save right after the folder is watched anew still builds. + writeFileSync(join(root, "widgets", "main", "index.html"), "

saved

"); + const saved = Date.now() + 5_000; + while (engine.latest()?.generation.generation !== 3 && Date.now() < saved) await new Promise((done) => setTimeout(done, 25)); + expect(engine.latest()?.generation.generation).toBe(3); + }); + + it("builds a save that adds a folder, once, and a save right after it", async () => { + const root = tempDir("dev-engine-"); + writePackage(root, "

one

"); + const built: string[] = []; + const engine = startDevEngine({ root, watch: true, debounceMs: 30, onBuild: (event) => built.push(`${event.build.trigger}:${event.kind}`) }); + engines.push(engine); + await engine.ready; + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 100)); + built.length = 0; + + mkdirSync(join(root, "widgets", "main", "parts", "deeper"), { recursive: true }); + writeFileSync(join(root, "widgets", "main", "parts", "deeper", "part.js"), "export const part = 1;"); + const deadline = Date.now() + 5_000; + while (engine.latest()?.generation.generation !== 2 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 25)); + expect(engine.latest()?.generation.generation).toBe(2); + await new Promise((done) => setTimeout(done, 400)); + expect(built).toEqual(["change:generation"]); + + // A save right after still builds. + writeFileSync(join(root, "widgets", "main", "parts", "deeper", "part.js"), "export const part = 2;"); + const saved = Date.now() + 5_000; + while (engine.latest()?.generation.generation !== 3 && Date.now() < saved) await new Promise((done) => setTimeout(done, 25)); + expect(engine.latest()?.generation.generation).toBe(3); }); it("stops watching, and says why, a folder it has not been able to look at for the time bound", async () => {