diff --git a/apps/runtime/src/application/widget-dev-sessions.ts b/apps/runtime/src/application/widget-dev-sessions.ts index 82dd64d4..e7d62c11 100644 --- a/apps/runtime/src/application/widget-dev-sessions.ts +++ b/apps/runtime/src/application/widget-dev-sessions.ts @@ -185,6 +185,8 @@ export function createWidgetDevSessions( watch?: boolean; /** How often a session waiting on an answer looks for it. */ answerPollMs?: number; + /** How long a folder may keep failing to be looked at before its session stops (`DEV_ENGINE_ROOT_UNREADABLE_MS`). */ + rootUnreadableMs?: number; } = {}, ): WidgetDevSessions { const live = new Map(); @@ -261,13 +263,24 @@ export function createWidgetDevSessions( return refusal(400, "ROOT_NOT_LOCAL", "give a folder on a drive of this machine; a network share or device path is not developed from"); } const resolved = resolve(given); + // Only "not found" means not there: a folder an antivirus or indexer holds (`EPERM`, `EBUSY`) is there, unreadable. + const unreadable = (cause: unknown) => { + const code = (cause as NodeJS.ErrnoException).code; + return code !== undefined && code !== "ENOENT" && code !== "ENOTDIR" + ? refusal(403, "ROOT_UNREADABLE", `${resolved} cannot be read on this node (${code})`) + : undefined; + }; try { if (!statSync(resolved).isDirectory()) return refusal(400, "ROOT_NOT_A_FOLDER", `${resolved} is not a folder`); - } catch { - return refusal(404, "ROOT_NOT_FOUND", `${resolved} does not exist on this node`); + } catch (cause) { + return unreadable(cause) ?? refusal(404, "ROOT_NOT_FOUND", `${resolved} does not exist on this node`); + } + let root: string; + try { + root = realpathSync.native(resolved); + } catch (cause) { + return unreadable(cause) ?? refusal(404, "ROOT_NOT_FOUND", `${resolved} could not be resolved on this node`); } - const root = realOrUndefined(resolved); - if (root === undefined) return refusal(404, "ROOT_NOT_FOUND", `${resolved} could not be resolved on this node`); if (isRemoteOrDevicePath(root)) { return refusal(400, "ROOT_NOT_LOCAL", "the folder resolves to a network share or device path, which is not developed from"); } @@ -742,8 +755,9 @@ export function createWidgetDevSessions( }; /** - * Whether a live session's folder has gone (deleted, renamed, or replaced by another folder at the same path while it - * was watched); when it has, the session is stopped as `folder-gone` before anything is built or installed from it. A + * Whether a live session's folder has gone (deleted, renamed, or no longer a folder; another folder made at the same + * path is watched in its place by the engine); when it has, the session is stopped as `folder-gone` before anything is + * built or installed from it. A * platform watcher does not always report this (Windows reports nothing), so the check is made before each build is * followed, not only on a watcher error. The session's engine answers, since it knows which folder it watches; a folder * that could not be looked at this time (a busy or locked folder on Windows) is not taken as gone. @@ -779,6 +793,7 @@ export function createWidgetDevSessions( root: stored.root, cacheRoot: cacheRoot(), watch: options.watch !== false, + ...(options.rootUnreadableMs === undefined ? {} : { rootUnreadableMs: options.rootUnreadableMs }), generationsBefore: before, allowedIsolations: WIDGET_DEV_ALLOWED_ISOLATIONS, baseline: () => live.get(sessionId)?.baseline, @@ -793,7 +808,7 @@ export function createWidgetDevSessions( }); }, onWatchError: (error) => { - process.stderr.write(`widget dev: ${sessionId} stopped watching ${stored.root}: ${error.message}\n`); + process.stderr.write(`widget dev: ${sessionId} stopped watching ${stored.root}: ${error.message}; what it ran keeps running\n`); // Said as it is: a session whose folder is no longer watched is stopped, not live. void serial(sessionId, async () => { if (live.get(sessionId) === session) markStopped(sessionId, "watch-failed"); @@ -1033,8 +1048,9 @@ export function createWidgetDevSessions( const checked = checkRoot(stored.root, { kind: stored.initiative?.kind ?? "person" }); if (!checked.ok) { const gone = checked.code === "ROOT_NOT_FOUND" || checked.code === "ROOT_NOT_A_FOLDER"; - markStopped(stored.sessionId, gone ? "folder-gone" : "root-refused"); - process.stderr.write(`widget dev: ${stored.root} could not be watched again (${checked.code}), so its session was stopped; what it ran keeps running\n`); + // A folder that is there but cannot be read is not gone, nor refused: watching it failed. + markStopped(stored.sessionId, gone ? "folder-gone" : checked.code === "ROOT_UNREADABLE" ? "watch-failed" : "root-refused"); + process.stderr.write(`widget dev: ${stored.root} could not be watched again (${checked.code}: ${checked.message}), so its session was stopped; what it ran keeps running\n`); continue; } if (live.size >= WIDGET_DEV_LIVE_MAX) { diff --git a/apps/runtime/src/develop-widget-tool.ts b/apps/runtime/src/develop-widget-tool.ts index 4d258503..870344d8 100644 --- a/apps/runtime/src/develop-widget-tool.ts +++ b/apps/runtime/src/develop-widget-tool.ts @@ -48,7 +48,7 @@ export function describeDevSession(view: WidgetDevSessionView): string { if (activation.state === "none") lines.push("No generation runs yet."); if (view.status === "stopped" && view.stopReason !== undefined && view.stopReason !== "requested") { const why = { - "watch-failed": "watching its folder failed", + "watch-failed": "watching its folder failed (the platform stopped reporting changes, or the folder could not be read for 30 s; the node's log names the error)", "folder-gone": "its folder is gone", capacity: "the node already watches as many folders as it can", "root-refused": "its folder is no longer one it may watch", diff --git a/apps/runtime/test/widget-dev-sessions.spec.ts b/apps/runtime/test/widget-dev-sessions.spec.ts index eb8a7cef..2d8eb847 100644 --- a/apps/runtime/test/widget-dev-sessions.spec.ts +++ b/apps/runtime/test/widget-dev-sessions.spec.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import type * as fs from "node:fs"; import type * as fsPromises from "node:fs/promises"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; @@ -1021,7 +1022,7 @@ describe("a widget dev session", () => { expect(stopped).toMatchObject({ status: "stopped", stopReason: "folder-gone", activation: { state: "active", generation: 1 } }); }); - it("stops a watched session as folder-gone when its folder is deleted and made again before anything looks", async () => { + it("keeps a watched session live, and builds the new folder, when its folder is deleted and made again before anything looks", async () => { services.widgetDev?.close(); services.widgetDev = createWidgetDevSessions(() => services, { watch: true, answerPollMs: 20 }); const started = session(await call("POST", "/widget-dev/sessions", { root })); @@ -1031,8 +1032,78 @@ describe("a widget dev session", () => { // so nothing looks between the delete and the new folder; no retries are asked for, since `rmSync` would not run them. rmSync(root, { recursive: true, force: true }); writePackage("

a new folder

\n"); + const rebuilt = await eventually(started.sessionId, (view) => view.activation.state === "active" && view.activation.generation === 2); + expect(rebuilt).toMatchObject({ status: "live", activation: { state: "active", generation: 2 } }); + + // The new folder is the one watched: a save in it builds and runs. + writePackage("

saved in the new folder

\n"); + const saved = await eventually(started.sessionId, (view) => view.activation.state === "active" && view.activation.generation === 3); + expect(saved).toMatchObject({ status: "live", activation: { state: "active", generation: 3 } }); + }); + + it("keeps a watched session live when another process deletes its folder and makes it again a moment later", async () => { + services.widgetDev?.close(); + services.widgetDev = createWidgetDevSessions(() => services, { watch: true, answerPollMs: 20 }); + const started = session(await call("POST", "/widget-dev/sessions", { root })); + expect(started.status).toBe("live"); + + // As `rmdir /s /q out && xcopy source out` in a terminal: another process, and a moment with no folder at the path. + const source = join(dir, "source"); + writePackage("

made again by another process

\n", [], source); + const script = [ + "const fs = require('node:fs');", + "const [out, from] = process.argv.slice(1);", + "fs.rmSync(out, { recursive: true, force: true });", + "setTimeout(() => fs.cpSync(from, out, { recursive: true }), 400);", + ].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 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("keeps a watched session live when a build runs while another process makes its folder again, and builds it once back", async () => { + services.widgetDev?.close(); + services.widgetDev = createWidgetDevSessions(() => services, { watch: true, answerPollMs: 20 }); + const started = session(await call("POST", "/widget-dev/sessions", { root })); + expect(started.status).toBe("live"); + + const source = join(dir, "source"); + writePackage("

made again by another process

\n", [], source); + const script = [ + "const fs = require('node:fs');", + "const [out, from] = process.argv.slice(1);", + "fs.rmSync(out, { recursive: true, force: true });", + "setTimeout(() => fs.cpSync(from, out, { recursive: true }), 800);", + ].join("\n"); + 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. + 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(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 () => { + services.widgetDev?.close(); + services.widgetDev = createWidgetDevSessions(() => services, { watch: true, answerPollMs: 20, rootUnreadableMs: 300 }); + const started = session(await call("POST", "/widget-dev/sessions", { root })); + expect(started.status).toBe("live"); + + // The engine looks at the canonical path a start stores (on macOS, `/private/var/...` for a `/var/...` temp folder). + statFailure.path = realpathSync.native(root); + // Bounded: the node looks at the folder once a second, so the bound is passed on the second look at the latest. const stopped = await eventually(started.sessionId, (view) => view.status === "stopped"); - expect(stopped).toMatchObject({ status: "stopped", stopReason: "folder-gone", activation: { state: "active", generation: 1 } }); + expect(stopped).toMatchObject({ status: "stopped", stopReason: "watch-failed", activation: { state: "active", generation: 1 } }); }); it("keeps a watched session live through a folder it cannot look at for a moment", async () => { @@ -1041,7 +1112,8 @@ describe("a widget dev session", () => { const started = session(await call("POST", "/widget-dev/sessions", { root })); expect(started.status).toBe("live"); - statFailure.path = resolve(root); + // The engine looks at the canonical path a start stores (on macOS, `/private/var/...` for a `/var/...` temp folder). + statFailure.path = realpathSync.native(root); const rebuilt = await call("POST", `/widget-dev/sessions/${started.sessionId}/rebuild`); expect(rebuilt.status).toBe(200); expect(session(rebuilt).status).toBe("live"); @@ -1052,6 +1124,39 @@ describe("a widget dev session", () => { const next = await eventually(started.sessionId, (view) => view.activation.state === "active" && view.activation.generation === 2); expect(next).toMatchObject({ status: "live", activation: { state: "active", generation: 2 } }); }); + + it("stops a watched session as folder-gone when a folder above it is swapped for a link to another tree, and builds nothing there", async () => { + services.widgetDev?.close(); + services.widgetDev = createWidgetDevSessions(() => services, { watch: true, answerPollMs: 20 }); + const started = session(await call("POST", "/widget-dev/sessions", { root })); + expect(started.status).toBe("live"); + + // `projects/timer` now leads, through `projects`, to a tree the person never chose for this session. + const elsewhere = join(dir, "elsewhere"); + writePackage("

elsewhere

\n", [], join(elsewhere, "timer"), { id: "com.example.elsewhere" }); + const projects = join(dir, "projects"); + rmSync(projects, { recursive: true, force: true }); + symlinkSync(elsewhere, projects, process.platform === "win32" ? "junction" : "dir"); + + const stopped = await eventually(started.sessionId, (view) => view.status === "stopped"); + expect(stopped).toMatchObject({ status: "stopped", stopReason: "folder-gone", packageId: PACKAGE, activation: { state: "active", generation: 1 } }); + }); + + it("says a folder that is there but cannot be read cannot be read, at a start and at a resume", async () => { + // A start and a resume look at the path as given, before it is made canonical. + statFailure.path = resolve(root); + const refused = await call("POST", "/widget-dev/sessions", { root }); + expect(refused.status).toBe(403); + expect(refused.body).toMatchObject({ code: "ROOT_UNREADABLE" }); + expect((refused.body as { message: string }).message).toContain("cannot be read on this node (EPERM)"); + + writeDevSessions(join(dir, "node"), [{ sessionId: "wdev_unreadable", root, status: "live" as const, startedAt: new Date().toISOString() }]); + services.widgetDev?.close(); + services.widgetDev = createWidgetDevSessions(() => services, { watch: false }); + await services.widgetDev.resume(); + const views = ((await call("GET", "/widget-dev/sessions")).body as { sessions: WidgetDevSessionView[] }).sessions; + expect(views.find((view) => view.sessionId === "wdev_unreadable")).toMatchObject({ status: "stopped", stopReason: "watch-failed" }); + }); }); describe("the directory a widget dev session lists its builds in", () => { diff --git a/docs/open-interfaces.md b/docs/open-interfaces.md index 22b98c5d..bdd76fd3 100644 --- a/docs/open-interfaces.md +++ b/docs/open-interfaces.md @@ -870,10 +870,12 @@ and shown in the conversation in the production widget frame. Shapes are `widget | `POST /widget-dev/sessions/:id/place` `{ "conversationId", "widgetId"? }` | Place the running widget in a conversation. | | `POST /widget-dev/chosen-folders/forget` `{ "root" }` | Take back the person's choice of a folder (`widgetDevFolderForgetSchema`): it no longer lets Clark start sessions in it, or in the folders inside it. Answers `{ root, forgotten, stillCoveredBy? }`; `forgotten: false` when the folder was not chosen, so pressing twice is harmless. `stillCoveredBy` names a folder Clark may still develop in that holds this one (another chosen folder, or a `workspace.roots` value the person recorded), so Clark keeps access there until that one goes too. Sessions and what they run stay as they are. Person-only. | -Refusals: `400 ROOT_NOT_ABSOLUTE`, `400 ROOT_NOT_A_FOLDER`, `404 ROOT_NOT_FOUND`, `404 CONVERSATION_NOT_FOUND`, -`404 SESSION_NOT_FOUND`, `409 TOO_MANY_SESSIONS`, `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET`, `409 NOT_PLACED` and -`503 WIDGET_DEV_UNAVAILABLE`: +Refusals: `400 ROOT_NOT_ABSOLUTE`, `400 ROOT_NOT_A_FOLDER`, `404 ROOT_NOT_FOUND`, `403 ROOT_UNREADABLE`, +`404 CONVERSATION_NOT_FOUND`, `404 SESSION_NOT_FOUND`, `409 TOO_MANY_SESSIONS`, `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET`, +`409 NOT_PLACED` and `503 WIDGET_DEV_UNAVAILABLE`: +- `403 ROOT_UNREADABLE` is for a folder that is there but cannot be read, for example while an antivirus holds it; the + message names the error, such as `EPERM`. - `409 TOO_MANY_SESSIONS` is for a ninth live session on the node, or for a store that already holds 256 sessions that all still run what they built. Older stopped sessions that run nothing are forgotten first to make room. - `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET` and `409 NOT_PLACED` are for a place with nothing running, a widget the package @@ -958,17 +960,40 @@ node starts with no sessions; the file is never overwritten. **When watching stops.** A session whose folder can no longer be watched is marked stopped, with the reason, rather than reading as live: -- `watch-failed`: the watcher failed. -- `folder-gone`: the folder was deleted or renamed, or it was deleted and a new folder was made at the same path, which - the watcher no longer hears (the node compares the folder's device and file id with the ones it started watching). - The node checks for the folder at least once a second and before each build, because Windows reports nothing when a - watched folder is deleted. Only "not found" counts: a folder that cannot be looked at for another reason, such as an - antivirus or indexer holding it (`EPERM`, `EBUSY`), keeps the session live and is checked again. The same reason - applies when the folder is missing after a restart. +- `watch-failed`: the watcher failed; the folder could not be looked at for 30 seconds in a row for a reason other + than "not found" (see below); the folder was found under a new file id on more than 30 looks in a row, with no look + between them finding it unchanged; or, after a restart, the folder cannot be read (`ROOT_UNREADABLE`). The node's log + names the error, for example `EPERM`. +- `folder-gone`: the folder was deleted or renamed and is not back within 2 seconds, the path no longer names a folder, + or the path now leads to another folder through a symbolic link or junction (at the folder itself or at a folder + above it). The node checks for the folder at least once a second and before each build, because Windows reports + nothing when a watched folder is deleted. Only "not found" counts: a folder that cannot be looked at for another + reason, such as an antivirus or indexer holding it (`EPERM`, `EBUSY`), keeps the session live and is checked again, + for up to 30 seconds of continuous failures; after that the session stops as `watch-failed`. The same reason applies + when the folder is missing after a restart. - `capacity`: the node is already watching eight folders as it resumes. - `root-refused`: after a restart, the folder fails the same check a start makes. For example, a session Clark started whose folder is outside the widget workspace. +A folder that is still there with another identity does not stop the session. The node compares the folder's device and +file id with the ones it started watching; when they differ, the folder was made again at the same path (for example by +`rm -rf out && build`), or the filesystem gave it a new id (some FUSE mounts and network drives do). The node watches +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`. + +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 +same on Windows and macOS when no folder on the path is a link. A link swapped in at the folder or above it leads to a +folder nobody chose, so the session stops as `folder-gone` instead. A build that finds the path leading elsewhere just +before or after it copies the files fails with `FILES_LINK_REFUSED`. + +In every case, the generation that runs keeps running. + **Consent.** The policy decides each install under a **consent scope** rather than the artifact. The scope is the package id together with everything the build binds about its reach: diff --git a/docs/open-interfaces.vi.md b/docs/open-interfaces.vi.md index 6b079787..81107d9e 100644 --- a/docs/open-interfaces.vi.md +++ b/docs/open-interfaces.vi.md @@ -873,10 +873,12 @@ cuộc hội thoại bằng frame widget dùng trong bản chính thức. Dạng | `POST /widget-dev/sessions/:id/place` `{ "conversationId", "widgetId"? }` | Đặt widget đang chạy vào một cuộc hội thoại. | | `POST /widget-dev/chosen-folders/forget` `{ "root" }` | Thu hồi lựa chọn một thư mục của người dùng (`widgetDevFolderForgetSchema`): lựa chọn đó không còn cho Clark bắt đầu phiên trong thư mục đó, hay trong các thư mục bên trong nó, nữa. Trả `{ root, forgotten, stillCoveredBy? }`; `forgotten: false` khi thư mục chưa được chọn, nên bấm hai lần cũng không sao. `stillCoveredBy` nêu một thư mục Clark vẫn được phát triển và chứa thư mục này (một thư mục khác đã chọn, hoặc một giá trị `workspace.roots` người dùng đã ghi), nên Clark vẫn có quyền ở đó cho tới khi thư mục đó cũng bị thu hồi. Các phiên và những gì chúng chạy vẫn giữ nguyên. Chỉ người dùng được gọi. | -Các lần từ chối: `400 ROOT_NOT_ABSOLUTE`, `400 ROOT_NOT_A_FOLDER`, `404 ROOT_NOT_FOUND`, `404 CONVERSATION_NOT_FOUND`, -`404 SESSION_NOT_FOUND`, `409 TOO_MANY_SESSIONS`, `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET`, `409 NOT_PLACED` và -`503 WIDGET_DEV_UNAVAILABLE`: +Các lần từ chối: `400 ROOT_NOT_ABSOLUTE`, `400 ROOT_NOT_A_FOLDER`, `404 ROOT_NOT_FOUND`, `403 ROOT_UNREADABLE`, +`404 CONVERSATION_NOT_FOUND`, `404 SESSION_NOT_FOUND`, `409 TOO_MANY_SESSIONS`, `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET`, +`409 NOT_PLACED` và `503 WIDGET_DEV_UNAVAILABLE`: +- `403 ROOT_UNREADABLE` dành cho thư mục vẫn còn đó nhưng không đọc được, chẳng hạn khi phần mềm diệt virus đang giữ nó; + thông báo ghi rõ lỗi, ví dụ `EPERM`. - `409 TOO_MANY_SESSIONS` dành cho phiên đang chạy thứ chín trên node, hoặc cho kho lưu đã giữ 256 phiên mà phiên nào cũng vẫn đang chạy bản đã dựng. Các phiên đã dừng cũ hơn và không còn chạy gì sẽ bị quên trước để lấy chỗ. - `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET` và `409 NOT_PLACED` dành cho lần đặt khi chưa có gì chạy, khi gói không khai báo @@ -963,17 +965,40 @@ mới nhất, tức generation mà thao tác quay lại bản trước sẽ tr **Khi việc theo dõi dừng.** Một phiên mà thư mục không còn theo dõi được sẽ được đánh dấu là đã dừng, kèm lý do, thay vì vẫn hiện là đang chạy: -- `watch-failed`: bộ theo dõi bị lỗi. -- `folder-gone`: thư mục đã bị xoá hoặc đổi tên, hoặc bị xoá rồi một thư mục mới được tạo lại ở cùng đường dẫn, mà bộ - theo dõi không còn nghe thấy (node so sánh device và file id của thư mục với những giá trị lúc bắt đầu theo dõi). Node - kiểm tra thư mục ít nhất mỗi giây một lần và trước mỗi lần dựng, vì Windows không báo gì khi một thư mục đang được - theo dõi bị xoá. Chỉ lỗi "không tìm thấy" mới được tính: một thư mục tạm thời không xem được vì lý do khác, chẳng hạn - phần mềm diệt virus hoặc trình lập chỉ mục đang giữ nó (`EPERM`, `EBUSY`), vẫn giữ phiên đang chạy và được kiểm tra - lại. Lý do này cũng dùng khi thư mục không còn sau một lần khởi động lại. +- `watch-failed`: bộ theo dõi bị lỗi; thư mục không xem được liên tục trong 30 giây vì một lý do khác "không tìm + thấy" (xem bên dưới); thư mục mang một file id mới ở hơn 30 lần xem liên tiếp, không có lần xem nào ở giữa thấy nó + không đổi; hoặc, sau một lần khởi động lại, thư mục không đọc được (`ROOT_UNREADABLE`). Nhật ký của node ghi rõ lỗi, ví + dụ `EPERM`. +- `folder-gone`: thư mục đã bị xoá hoặc đổi tên và không trở lại trong vòng 2 giây, đường dẫn không còn là một thư mục, + hoặc đường dẫn giờ dẫn tới một thư mục khác qua một liên kết tượng trưng hay junction (ở chính thư mục hoặc ở một + thư mục phía trên nó). Node kiểm tra thư mục ít nhất mỗi giây một lần và trước mỗi lần dựng, vì Windows không báo gì + khi một thư mục đang được theo dõi bị xoá. Chỉ lỗi "không tìm thấy" mới được tính: một thư mục không xem được vì lý + do khác, chẳng hạn phần mềm diệt virus hoặc trình lập chỉ mục đang giữ nó (`EPERM`, `EBUSY`), vẫn giữ phiên đang chạy + và được kiểm tra lại, tối đa 30 giây lỗi liên tục; sau đó phiên dừng với lý do `watch-failed`. Lý do này cũng dùng + khi thư mục không còn sau một lần khởi động lại. - `capacity`: node đã theo dõi tám thư mục lúc tiếp tục các phiên. - `root-refused`: sau một lần khởi động lại, thư mục không qua được bước kiểm mà một lần bắt đầu thực hiện. Ví dụ, một phiên do Clark bắt đầu có thư mục nằm ngoài không gian widget. +Một thư mục vẫn còn đó nhưng mang định danh khác không làm phiên dừng. Node so sánh device và file id của thư mục với +những giá trị lúc bắt đầu theo dõi; khi chúng khác nhau, thư mục đã được tạo lại ở cùng đường dẫn (chẳng hạn bởi +`rm -rf out && build`), hoặc hệ thống tệp đã cấp cho nó một id mới (một số ổ FUSE và ổ mạng làm vậy). Node theo dõi thư +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`. + +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, +hai đường dẫn chỉ khác nhau về chữ hoa chữ thường được coi là một khi không có thư mục nào trên đường dẫn là liên kết. +Một liên kết được tráo vào ở thư mục hoặc phía trên nó dẫn tới một thư mục không ai chọn, nên phiên dừng với lý do +`folder-gone`. Một lần dựng thấy đường dẫn dẫn tới nơi khác ngay trước hoặc ngay sau khi chép tệp sẽ thất bại với +`FILES_LINK_REFUSED`. + +Trong mọi trường hợp, generation đang chạy vẫn tiếp tục chạy. + **Sự đồng ý.** Chính sách quyết định mỗi lần cài theo một **phạm vi đồng ý** thay vì theo artifact. Phạm vi là id gói cùng mọi thứ bản dựng ràng buộc về phạm vi tiếp cận của nó: diff --git a/packages/contracts/src/widget-dev-session.ts b/packages/contracts/src/widget-dev-session.ts index 0f4ced3b..4c6461ad 100644 --- a/packages/contracts/src/widget-dev-session.ts +++ b/packages/contracts/src/widget-dev-session.ts @@ -208,8 +208,11 @@ export type WidgetDevActivation = z.infer; * Why a session stopped watching its folder. * * - `requested`: somebody stopped it. - * - `watch-failed`: the platform stopped reporting changes (the folder was removed or cannot be watched). - * - `folder-gone`: the folder was deleted or renamed while it was watched, or was not there when the node started again. + * - `watch-failed`: the platform stopped reporting changes (the folder cannot be watched), or the folder could not be + * looked at for 30 s in a row (`EPERM`, `EBUSY`), kept changing file id, or could not be read when the node started + * again. What the session ran keeps running. + * - `folder-gone`: the folder was deleted or renamed while it was watched and not back within 2 s, its path now leads elsewhere through a link + * or junction, or it was not there when the node started again. * - `capacity`: the node already watched as many folders as it does at once when it started again. * - `root-refused`: when the node started again, the folder was no longer one the session may watch (for example a * session Clark started whose folder is outside the widget workspace and every folder the person chose, or a folder diff --git a/packages/conversation-client/src/i18n/messages-shell.ts b/packages/conversation-client/src/i18n/messages-shell.ts index bd05d33b..064a24f4 100644 --- a/packages/conversation-client/src/i18n/messages-shell.ts +++ b/packages/conversation-client/src/i18n/messages-shell.ts @@ -45,7 +45,7 @@ export const MESSAGES_SHELL_VI = { "shell.dev.more": "và {count} lỗi khác", "shell.dev.unreachable": "Không đọc được trạng thái phiên phát triển; widget vẫn chạy bản đang hiện.", "shell.dev.stoppedNothing": "Đã dừng theo dõi thư mục · chưa có bản dựng nào chạy.", - "shell.dev.stopReason.watch-failed": "Việc theo dõi thư mục bị lỗi; hãy bắt đầu lại phiên.", + "shell.dev.stopReason.watch-failed": "Việc theo dõi thư mục bị lỗi: hệ thống ngừng báo thay đổi của thư mục, hoặc thư mục không đọc được trong 30 giây (chẳng hạn khi phần mềm diệt virus đang giữ nó). Hãy bắt đầu lại phiên khi thư mục đọc được.", "shell.dev.stopReason.folder-gone": "Thư mục không còn nữa.", "shell.dev.stopReason.capacity": "Máy này đã theo dõi số thư mục tối đa; hãy dừng một phiên khác rồi bắt đầu lại.", "shell.dev.stopReason.root-refused": "Phiên không còn được theo dõi thư mục này; Clark phát triển widget trong không gian widget của mình, nên hãy chép dự án vào đó rồi bắt đầu lại.", @@ -377,7 +377,7 @@ export const MESSAGES_SHELL_EN = { "shell.dev.more": "and {count} more", "shell.dev.unreachable": "Could not read the development session; the widget keeps running what it shows.", "shell.dev.stoppedNothing": "No longer watching the folder · no build runs yet.", - "shell.dev.stopReason.watch-failed": "Watching the folder failed; start the session again.", + "shell.dev.stopReason.watch-failed": "Watching the folder failed: the system stopped reporting its changes, or the folder could not be read for 30 seconds (for example while an antivirus held it). Start the session again once the folder can be read.", "shell.dev.stopReason.folder-gone": "The folder is gone.", "shell.dev.stopReason.capacity": "This machine already watches as many folders as it can; stop another session, then start this one again.", "shell.dev.stopReason.root-refused": "This folder is no longer one the session may watch; Clark develops widgets in its widget workspace, so copy the project there and start again.", diff --git a/packages/conversation-client/test/widget-dev-status.spec.ts b/packages/conversation-client/test/widget-dev-status.spec.ts index 7a3c7da0..d5d34f0d 100644 --- a/packages/conversation-client/test/widget-dev-status.spec.ts +++ b/packages/conversation-client/test/widget-dev-status.spec.ts @@ -110,6 +110,10 @@ describe("the widget dev status line", () => { notice: true, }); expect(widgetDevStatusLine(view({ status: "stopped", stopReason: "watch-failed" }), vi).text).toContain("Việc theo dõi thư mục bị lỗi"); + // Why watching failed, and that what runs keeps running. + const failed = widgetDevStatusLine(view({ status: "stopped", stopReason: "watch-failed" }), en).text; + expect(failed).toContain("build 2 keeps running"); + expect(failed).toContain("could not be read for 30 seconds"); expect(widgetDevStatusLine(view({ status: "stopped", stopReason: "requested" }), en).notice).toBe(false); expect(widgetDevStatusLine(view({ status: "stopped", stopReason: "folder-gone", running: undefined, activation: { state: "none" } }), en).text).toBe( "No longer watching the folder · no build runs yet. The folder is gone.", diff --git a/packages/core/src/widget-dev-engine.ts b/packages/core/src/widget-dev-engine.ts index 5d2416fd..30bcd242 100644 --- a/packages/core/src/widget-dev-engine.ts +++ b/packages/core/src/widget-dev-engine.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { closeSync, lstatSync, openSync, readdirSync, statSync, watch, type BigIntStats, type FSWatcher } from "node:fs"; -import { join, relative, resolve, sep } from "node:path"; +import { closeSync, lstatSync, openSync, readdirSync, realpathSync, statSync, watch, type BigIntStats, type FSWatcher } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { WIDGET_DEV_DIAGNOSTICS_MAX, @@ -42,12 +42,33 @@ export const DEV_ENGINE_LIMITS = { maxFiles: 5_000, maxBytes: 64 * 1024 * 1024 } export const DEV_ENGINE_DEBOUNCE_MS = 150; /** How often a watched folder is checked to still be there, for a platform whose watcher does not say it went away. */ export const DEV_ENGINE_ROOT_CHECK_MS = 1_000; +/** + * How long a watched folder may keep failing to be looked at, for a reason other than not being there, before watching + * stops. An antivirus or indexer holds a folder for a moment; one that cannot be looked at for this long is not being + * watched in any useful sense (and on older Windows builds, a folder deleted while the watcher holds it reads as `EPERM` + * until the watcher lets go, which it never would). + */ +export const DEV_ENGINE_ROOT_UNREADABLE_MS = 30_000; /** * How long after watching starts the folder is built once more, for a save the platform watcher could not see yet. On * macOS, FSEvents starts watching asynchronously and reports only what happens after that, so a save right after the first * build would otherwise be lost; inotify and Windows watch from the moment `watch` returns. */ export const DEV_ENGINE_WATCH_CATCH_UP_MS = 500; +/** + * How many looks in a row may find a watched folder under a new file id (`replaced`), with no look between them finding + * the same folder again, before watching stops. A build that makes its output folder again re-arms once and the next + * look finds the new folder unchanged, however often that happens; a filesystem that gives the folder a new id on every + * look would otherwise re-watch and copy the whole folder every second, without end. + */ +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. + */ +export const DEV_ENGINE_ROOT_MISSING_GRACE_MS = 2_000; /** * Folders at the package root that are not the package: version control, and the author's installed dependencies, which * the package does not ship and which may hold thousands of files and links (a pnpm `node_modules` is junctions). Left @@ -101,12 +122,22 @@ export interface DevEngineOptions { generationsBefore?: number; /** Told about every build the watcher or `rebuild` ran, including ones that produced nothing new. */ onBuild?: (event: DevEngineEvent) => void; - /** Told when the watcher itself fails (the folder was removed, the platform cannot watch it). */ + /** + * Told when the watcher itself fails (the folder was removed, the platform cannot watch it), or when the folder could + * not be looked at for `rootUnreadableMs`; watching has stopped. + */ onWatchError?: (error: Error) => void; - /** Told once when the watched folder is no longer there (deleted or renamed); watching has stopped. */ + /** + * Told once when the watched folder is no longer there (deleted or renamed and not back within `rootMissingGraceMs`, no + * longer a folder, or reached through a link); watching has stopped. + */ onRootGone?: () => void; /** How often a watched folder is checked to still be there (`DEV_ENGINE_ROOT_CHECK_MS`). */ rootCheckMs?: number; + /** How long a folder may keep failing to be looked at before watching stops (`DEV_ENGINE_ROOT_UNREADABLE_MS`). */ + rootUnreadableMs?: number; + /** How long a watched folder may be missing before it counts as gone (`DEV_ENGINE_ROOT_MISSING_GRACE_MS`). */ + rootMissingGraceMs?: number; /** * The facet lanes a build may declare. A package with a facet in any other lane is a failed build * (`FACET_LANE_UNSUPPORTED`), not a generation. Absent, every lane builds: the standalone dev host runs none of them. @@ -115,6 +146,7 @@ export interface DevEngineOptions { } export interface DevEngine { + /** The folder's real path: `options.root` resolved once, through any link or junction it was given through. */ readonly root: string; /** The first build, which runs before anything is watched. */ readonly ready: Promise; @@ -126,8 +158,10 @@ export interface DevEngine { /** Whether the folder is being watched: false after `close`, after a watch failure, or when watching was not asked for. */ watching(): boolean; /** - * Whether the folder is gone (`devRootState`): deleted, no longer a folder, or, while it was watched, replaced by another - * folder at the same path. False when it could not be looked at for another reason. + * Whether the folder is gone (`devRootState`): deleted, no longer a folder, or another folder reached through a link or + * junction. False when another folder is at the path itself (the watcher moves to it), when it could not be looked at + * for another reason, or, while watching, when it has been missing for less than `rootMissingGraceMs` (a build that + * deletes and makes it again is not done yet). */ rootGone(): boolean; close(): void; @@ -252,27 +286,93 @@ function devRootIdentityOf(root: string): DevRootIdentity | undefined { /** The errors that say a path is not there: anything else (a busy or locked folder on Windows) may pass on the next look. */ const GONE_CODES: ReadonlySet = new Set(["ENOENT", "ENOTDIR"]); +type DevRootLook = + | { state: "present" } + | { state: "replaced"; identity: DevRootIdentity } + /** `missing`: nothing is at the path (`ENOENT`, `ENOTDIR`), which a build that makes the folder again also leaves for a moment. */ + | { state: "gone"; missing?: true } + | { state: "unknown"; code: string }; + +/** Whether a folder on `path`, or `path` itself, is a link or junction. */ +function linkOnPath(path: string): boolean { + for (let at = path; ; at = dirname(at)) { + if (lstatSync(at).isSymbolicLink()) return true; + if (dirname(at) === at) return false; + } +} + /** - * Whether a dev folder is still the folder being developed. - * - * - `gone`: nothing is at the path, something other than a folder is, or — given the `identity` it was watched with — a - * different folder is. A folder deleted and made again keeps its path but not its identity, and a watcher on the old - * one hears nothing from the new one. - * - `unknown`: the folder could not be looked at for another reason (`EPERM`, `EBUSY` while an antivirus or indexer holds - * it). That is not a folder that went away; the caller looks again next time. - * - `present`: the same folder is there. + * Whether `real` (where `root` resolves now) is `canonical`, the real path `root` was at the start. Where the filesystem + * usually ignores case (Windows, macOS), a real path that differs only in case is the same place when nothing on the + * path is a link: a folder made again as `Out` for `out` is the folder chosen, but a folder above it swapped for a link + * to a sibling named in another case (on a case-sensitive volume) is not. */ -export function devRootState(root: string, identity?: DevRootIdentity): "present" | "gone" | "unknown" { +const samePlace = (root: string, real: string, canonical: string): boolean => + real === canonical || + ((process.platform === "win32" || process.platform === "darwin") && real.toLowerCase() === canonical.toLowerCase() && !linkOnPath(root)); + +/** + * Whether `root` still leads to `canonical`: it is not itself a link or junction, and it resolves there. When it could + * not be looked at, the look that says why. + */ +function leadsTo(root: string, canonical: string | undefined): boolean | DevRootLook { + if (canonical === undefined) return false; + try { + return !lstatSync(root).isSymbolicLink() && samePlace(root, realpathSync.native(root), canonical); + } catch (cause) { + return lookFailed(cause); + } +} + +/** The canonical path of `root`, or undefined when it cannot be resolved. */ +function canonicalPathOf(root: string): string | undefined { + try { + return realpathSync.native(root); + } catch { + return undefined; + } +} + +const lookFailed = (cause: unknown): DevRootLook => { + const code = (cause as NodeJS.ErrnoException).code; + return code !== undefined && GONE_CODES.has(code) ? { state: "gone", missing: true } : { state: "unknown", code: code ?? messageOf(cause) }; +}; + +function lookAtDevRoot(root: string, identity?: DevRootIdentity, canonical?: string): DevRootLook { let stat: BigIntStats; try { stat = statSync(root, { bigint: true }); } catch (cause) { - const code = (cause as NodeJS.ErrnoException).code; - return code !== undefined && GONE_CODES.has(code) ? "gone" : "unknown"; + return lookFailed(cause); + } + if (!stat.isDirectory()) return { state: "gone" }; + if (identity !== undefined && (stat.dev !== identity.dev || stat.ino !== identity.ino)) { + // Another folder at the path is the folder being developed only when the path still leads where it led when watching + // started: the path itself, or a folder above it, swapped for a link or junction leads into a tree nobody chose. + const leads = leadsTo(root, canonical); + if (leads !== true) return leads === false ? { state: "gone" } : leads; + return { state: "replaced", identity: { dev: stat.dev, ino: stat.ino } }; } - if (!stat.isDirectory()) return "gone"; - if (identity !== undefined && (stat.dev !== identity.dev || stat.ino !== identity.ino)) return "gone"; - return "present"; + return { state: "present" }; +} + +/** + * Whether a dev folder is still the folder being developed. + * + * - `gone`: nothing is at the path, or something other than a folder is. + * - `replaced`: given the `identity` it was watched with, a different folder is at the path. A folder deleted and made + * again (`rm -rf out && build`) keeps its path but not its identity, and a watcher on the old one hears nothing from the + * new one (a watching engine waits `DEV_ENGINE_ROOT_MISSING_GRACE_MS` for a missing folder to come back); some + * filesystems (FUSE mounts without stable inode numbers, some network drives) also give a folder that is still there a + * new id. Either way there is a folder to watch, so the watcher moves to it. A different folder + * reached through a link or junction (at the path or at a folder above it), so that the path no longer resolves to + * the canonical path watching started from, is `gone` instead: it is not the folder that was chosen. + * - `unknown`: the folder could not be looked at for another reason (`EPERM`, `EBUSY` while an antivirus or indexer holds + * it). That is not a folder that went away; the caller looks again next time. + * - `present`: the same folder is there. + */ +export function devRootState(root: string, identity?: DevRootIdentity): "present" | "replaced" | "gone" | "unknown" { + return lookAtDevRoot(root, identity).state; } /** Whether two failed builds failed the same way: the same diagnostics, so a second one tells nobody anything new. */ @@ -291,7 +391,14 @@ const devFilesCodeOf = (code: string): "FILES_TOO_LARGE" | "FILES_LINK_REFUSED" code === "ARTIFACT_TOO_LARGE" ? "FILES_TOO_LARGE" : code === "ARTIFACT_SYMLINK_ESCAPE" ? "FILES_LINK_REFUSED" : "FILES_UNREADABLE"; export function startDevEngine(options: DevEngineOptions): DevEngine { - const root = resolve(options.root); + const given = resolve(options.root); + /** + * Where the folder's path resolved when the engine started: a folder at the path that resolves elsewhere is not it. A + * folder given through a link or junction (a symlinked projects folder, a junctioned workspace) is watched and built at + * this real path, so the link it was given through is not taken for a swap. + */ + const canonical = canonicalPathOf(given); + const root = canonical ?? given; const limits = options.limits ?? DEV_ENGINE_LIMITS; const now = options.now ?? (() => new Date().toISOString()); // Only the newest generation is kept: older ones are named in their build records and the caller's own state. @@ -340,7 +447,23 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { return { ok: true, manifest: pkg.manifest, widgetIds }; }; + /** + * Whether the path now leads somewhere other than where it led at the start, through a link or junction swapped in at + * the folder or above it. Checked before and after the files are taken, so the window in which a swap could have a + * foreign tree built is the copy itself; closing it fully would need reads relative to a held folder handle. + */ + const ledElsewhere = (): boolean => canonical !== undefined && leadsTo(root, canonical) === false; + const elsewhere = (trigger: WidgetDevTrigger): DevEngineEvent => + fail(trigger, [ + { + severity: "error", + code: "FILES_LINK_REFUSED", + message: "the folder's path now leads to another folder through a link or junction, so it was not built", + }, + ]); + const build = async (trigger: WidgetDevTrigger): Promise => { + if (ledElsewhere()) return elsewhere(trigger); const source = read(root); if (!source.ok) return fail(trigger, source.diagnostics); @@ -377,6 +500,7 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { } catch (cause) { return fail(trigger, [{ severity: "error", code: "FILES_UNREADABLE", message: `the files could not be read: ${messageOf(cause)}` }]); } + if (ledElsewhere()) return elsewhere(trigger); const previous = newest; if (previous !== undefined && previous.generation.digest === digest) { @@ -431,9 +555,18 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { /** * The folder watched, by identity: a folder deleted and made again at the same path before the next look is a folder - * the watcher does not hear (Windows keeps watching the deleted one), so it counts as gone. Only taken when watching. + * the watcher does not hear (Windows keeps watching the deleted one), so the watcher moves to it. Only taken when + * watching. */ let identity: DevRootIdentity | undefined; + /** When the folder first failed to be looked at, in the run of failures going on now (`DEV_ENGINE_ROOT_UNREADABLE_MS`). */ + let unreadableSince: number | undefined; + /** When the folder was first found missing, in the absence going on now (`DEV_ENGINE_ROOT_MISSING_GRACE_MS`). */ + let missingSince: number | undefined; + /** How many looks in a row found the folder under a new id, with none finding it unchanged between (`DEV_ENGINE_REARM_MAX`). */ + let rearmsInARow = 0; + const unreadableMs = options.rootUnreadableMs ?? DEV_ENGINE_ROOT_UNREADABLE_MS; + const missingGraceMs = options.rootMissingGraceMs ?? DEV_ENGINE_ROOT_MISSING_GRACE_MS; /** * The watched folder held open, on Linux and macOS, while it is watched. A folder deleted there frees its file id, and * one made again at the same path straight after is commonly given the same id back, so it would read as the same @@ -446,13 +579,8 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { let rootCheck: ReturnType | undefined; let catchUp: ReturnType | undefined; const debounceMs = options.debounceMs ?? DEV_ENGINE_DEBOUNCE_MS; - const stopWatching = (): void => { - if (timer !== undefined) clearTimeout(timer); - timer = undefined; - if (catchUp !== undefined) clearTimeout(catchUp); - catchUp = undefined; - if (rootCheck !== undefined) clearInterval(rootCheck); - rootCheck = undefined; + /** Let go of the watched folder: its watcher and, where it is held open, its handle. */ + const release = (): void => { watcher?.close(); watcher = undefined; if (pin !== undefined) { @@ -464,80 +592,173 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { pin = undefined; } }; + const stopWatching = (): void => { + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + if (catchUp !== undefined) clearTimeout(catchUp); + catchUp = undefined; + if (rootCheck !== undefined) clearInterval(rootCheck); + rootCheck = undefined; + release(); + }; + const watchFailed = (cause: unknown): void => options.onWatchError?.(cause instanceof Error ? cause : new Error(String(cause))); + /** Build after the last change of a burst, and report what it built. */ + const scheduleBuild = (): void => { + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { + 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); + }, watchFailed); + }, debounceMs); + timer.unref(); + }; + /** 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 name = typeof filename === "string" ? filename : ""; + if (!rootStillThere()) return; + if (isExcludedRootName(name.split(/[\\/]/)[0] ?? "")) return; + scheduleBuild(); + }); + watcher = next; + // The watcher never keeps a process alive by itself: the dev host's server or the node does that. + next.unref(); + next.on("error", (error) => { + // An error from a watcher already let go of (the folder was replaced and is watched anew) says nothing now. + if (watcher !== next) return; + // A folder that went away is said as that, not as a watcher failure. + if (!rootStillThere()) return; + stopWatching(); + options.onWatchError?.(error); + }); + }; /** * Whether the folder is still there, and if not, stop watching and say so. A folder deleted, renamed or replaced while * watched is not reported by every platform's watcher (Windows reports neither an event nor an error), so this is - * checked on every change and on a timer rather than left to the watcher. A folder that could not be looked at this - * time (`unknown`) is looked at again on the next tick rather than taken as gone. + * checked on every change and on a timer rather than left to the watcher. + * + * - Another folder at the path (`replaced`) is watched in place of the old one and built: it is where the person's + * files are now, whether a build made it again or the filesystem gave the same folder a new id. One reached through a + * 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. + * - 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. */ + /** + * Whether `look` found the watched folder missing for less than `rootMissingGraceMs` so far: it may be on its way back, + * so it is not gone yet. The absence is timed from the first look that found it, whichever asked. + */ + const stillAwaited = (look: DevRootLook): boolean => { + if (look.state !== "gone" || look.missing !== true || closed || watcher === undefined) return false; + const at = performance.now(); + missingSince ??= at; + return at - missingSince < missingGraceMs; + }; const rootStillThere = (): boolean => { if (closed || watcher === undefined) return !closed; - if (devRootState(root, identity) !== "gone") return true; + const look = lookAtDevRoot(root, identity, canonical); + if (look.state === "unknown") { + // A monotonic clock: a clock set back, or a machine asleep, neither stretches nor cuts short the bound. + const at = performance.now(); + unreadableSince ??= at; + if (at - unreadableSince < unreadableMs) return true; + stopWatching(); + const failingMs = Math.round(at - unreadableSince); + const failing = failingMs < 1000 ? `${String(failingMs)} ms` : `${String(Math.round(failingMs / 1000))} s`; + options.onWatchError?.(new Error(`the folder could not be looked at for ${failing} (${look.code}), so it is no longer watched`)); + return false; + } + unreadableSince = undefined; + if (stillAwaited(look)) return false; + missingSince = undefined; + if (look.state === "present") { + rearmsInARow = 0; + return true; + } + if (look.state === "replaced") { + rearmsInARow += 1; + const idOf = (id: DevRootIdentity | undefined): string => (id === undefined ? "none" : `${String(id.dev)}:${String(id.ino)}`); + if (rearmsInARow > DEV_ENGINE_REARM_MAX) { + stopWatching(); + options.onWatchError?.( + new Error( + `the folder was found under a new file id on more than ${String(DEV_ENGINE_REARM_MAX)} looks in a row (last ${idOf(identity)} to ${idOf(look.identity)}), so it is no longer watched; its filesystem may not keep file ids stable`, + ), + ); + return false; + } + process.stderr.write(`widget dev: watching ${root} anew: its file id changed from ${idOf(identity)} to ${idOf(look.identity)}\n`); + release(); + try { + identity = look.identity; + arm(); + } catch (cause) { + stopWatching(); + watchFailed(cause); + return false; + } + scheduleBuild(); + // As at the start: a save the new watcher could not see yet (FSEvents starts asynchronously) is built then. + scheduleCatchUp(); + return true; + } stopWatching(); options.onRootGone?.(); return false; }; + /** + * Build once more `DEV_ENGINE_WATCH_CATCH_UP_MS` after a watcher starts, for a save made before the platform watcher was + * live. Only news is reported: files that did not change build nothing new, and a folder that was already failing fails + * the same way, so where the watcher saw everything this is silent. A build with no news leaves the last build as it + * was, rather than relabelled as a change made now. + */ + const scheduleCatchUp = (): void => { + if (catchUp !== undefined) clearTimeout(catchUp); + catchUp = setTimeout(() => { + 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 () => { + // 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); + if (!news && before !== undefined) last = before; + return event; + }).then( + (event) => { + if (!closed && news) options.onBuild?.(event); + }, + watchFailed, + ); + }, DEV_ENGINE_WATCH_CATCH_UP_MS); + catchUp.unref(); + }; if (options.watch !== false) { try { - pin = pinRoot(root); identity = devRootIdentityOf(root); - watcher = watch(root, { recursive: true }, (_event, filename) => { - const name = typeof filename === "string" ? filename : ""; - if (!rootStillThere()) return; - if (isExcludedRootName(name.split(/[\\/]/)[0] ?? "")) return; - if (timer !== undefined) clearTimeout(timer); - timer = setTimeout(() => { - timer = undefined; - if (closed || !rootStillThere()) return; - void enqueue(() => build("change")).then( - (event) => { - if (!closed) options.onBuild?.(event); - }, - (cause: unknown) => options.onWatchError?.(cause instanceof Error ? cause : new Error(String(cause))), - ); - }, debounceMs); - timer.unref(); - }); - // The watcher never keeps a process alive by itself: the dev host's server or the node does that. - watcher.unref(); - watcher.on("error", (error) => { - // A folder that went away is said as that, not as a watcher failure. - if (!rootStillThere()) return; - stopWatching(); - options.onWatchError?.(error); - }); + arm(); rootCheck = setInterval(() => void rootStillThere(), options.rootCheckMs ?? DEV_ENGINE_ROOT_CHECK_MS); rootCheck.unref(); - // A save made before the platform watcher was live is built here. Only news is reported: files that did not change - // build nothing new, and a folder that was already failing fails the same way, so where the watcher saw everything - // this is silent. A build with no news leaves the last build as it was, rather than relabelled as a change made now. - catchUp = setTimeout(() => { - catchUp = undefined; - if (closed || timer !== undefined || !rootStillThere()) return; - let news = false; - void enqueue(async () => { - // 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); - if (!news && before !== undefined) last = before; - return event; - }).then( - (event) => { - if (!closed && news) options.onBuild?.(event); - }, - (cause: unknown) => options.onWatchError?.(cause instanceof Error ? cause : new Error(String(cause))), - ); - }, DEV_ENGINE_WATCH_CATCH_UP_MS); - catchUp.unref(); + scheduleCatchUp(); } catch (cause) { - watcher = undefined; - options.onWatchError?.(cause instanceof Error ? cause : new Error(String(cause))); + stopWatching(); + watchFailed(cause); } } @@ -552,7 +773,11 @@ export function startDevEngine(options: DevEngineOptions): DevEngine { return event; }, watching: () => watcher !== undefined && !closed, - rootGone: () => devRootState(root, identity) === "gone", + rootGone: () => { + // A build that overlaps a folder being made again fails on its missing files; the folder is not gone for that. + const look = lookAtDevRoot(root, identity, canonical); + return look.state === "gone" && !stillAwaited(look); + }, close: () => { closed = true; stopWatching(); diff --git a/packages/core/test/widget-dev-engine.spec.ts b/packages/core/test/widget-dev-engine.spec.ts index 44da2548..813f4be5 100644 --- a/packages/core/test/widget-dev-engine.spec.ts +++ b/packages/core/test/widget-dev-engine.spec.ts @@ -1,5 +1,6 @@ import type * as fs from "node:fs"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -8,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { compareDevReach, directoryEntrySchema, type PackageManifest, type WidgetDefinition } from "@clarkcant/contracts"; import { + DEV_ENGINE_REARM_MAX, DEV_ENGINE_WATCH_CATCH_UP_MS, cachedLocalSnapshotPath, devConsentScopeOf, @@ -19,6 +21,8 @@ import { removeTestDirectory } from "../../../tools/test-cleanup.ts"; /** A folder whose `stat` fails with this code, as an antivirus or indexer holding it on Windows makes it fail. */ const statFailure = vi.hoisted(() => ({ path: undefined as string | undefined, code: "EPERM" })); +/** 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 })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); @@ -26,7 +30,13 @@ vi.mock("node:fs", async (importOriginal) => { 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 }); } - return actual.statSync(path, options); + const stat = actual.statSync(path, options); + if (statNewId.path !== undefined && resolve(String(path)) === statNewId.path && stat !== undefined && typeof stat.ino === "bigint") { + // Flapping: a new id on every look, as a filesystem that keeps no file id stable at all would report. + if (statNewId.flap) statNewId.shift += 1n; + stat.ino += statNewId.flap ? statNewId.shift : statNewId.step; + } + return stat; }) as typeof actual.statSync; return { ...actual, statSync, default: { ...actual, statSync } }; }); @@ -95,6 +105,10 @@ function engineFor(root: string, cacheRoot?: string): DevEngine { afterEach(() => { statFailure.path = undefined; + statNewId.path = undefined; + statNewId.flap = false; + statNewId.shift = 0n; + statNewId.step = 1n; vi.useRealTimers(); for (const engine of engines.splice(0)) engine.close(); for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }); @@ -314,11 +328,19 @@ describe("the dev engine", () => { expect(gone).toBe(1); }); - it("notices a watched folder deleted and made again before it looks, which the watcher no longer hears", async () => { + it("watches a folder deleted and made again before it looks, and builds it, though the old watcher hears nothing", async () => { const root = tempDir("dev-engine-"); writePackage(root, "

one

"); let gone = 0; - const engine = startDevEngine({ root, watch: true, debounceMs: 30, rootCheckMs: 50, onRootGone: () => (gone += 1) }); + const failures: Error[] = []; + const engine = startDevEngine({ + root, + watch: true, + debounceMs: 30, + rootCheckMs: 50, + onRootGone: () => (gone += 1), + onWatchError: (error) => failures.push(error), + }); engines.push(engine); await engine.ready; @@ -326,13 +348,353 @@ describe("the dev engine", () => { // no retries are asked for, since `rmSync` would not run them. rmSync(root, { recursive: true, force: true }); writePackage(root, "

new folder

"); + const waitFor = async (generation: number): Promise => { + const deadline = Date.now() + 5_000; + while (engine.latest()?.generation.generation !== generation && Date.now() < deadline) await new Promise((done) => setTimeout(done, 25)); + expect(engine.latest()?.generation.generation).toBe(generation); + }; + await waitFor(2); + expect(gone).toBe(0); + expect(failures).toEqual([]); + expect(engine.watching()).toBe(true); + expect(engine.rootGone()).toBe(false); + + // The new folder is the one watched now: a save in it builds. + writeFileSync(join(root, "widgets", "main", "index.html"), "

saved in the new folder

"); + await waitFor(3); + expect(gone).toBe(0); + }); + + it("watches a folder that is still there under a new file id again, and builds it, rather than stopping", 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; + // Past the catch-up build, so the only build that follows is the one the new id asks for. + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 200)); + built.length = 0; + + statNewId.path = engine.root; const deadline = Date.now() + 5_000; - while (gone === 0 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 25)); - expect(gone).toBe(1); + while (built.length === 0 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 25)); + expect(built).toEqual(["change:unchanged"]); + expect(gone).toBe(0); + expect(engine.watching()).toBe(true); + expect(engine.rootGone()).toBe(false); + + // Watched again once: the new id is the folder's id now, and a save still builds. + await new Promise((done) => setTimeout(done, 150)); + expect(built).toEqual(["change:unchanged"]); + writeFileSync(join(root, "widgets", "main", "index.html"), "

saved

"); + const saved = Date.now() + 5_000; + while (engine.latest()?.generation.generation !== 2 && Date.now() < saved) await new Promise((done) => setTimeout(done, 25)); + expect(engine.latest()?.generation.generation).toBe(2); + }); + + /** 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"); + + /** Watch `root` until the engine says it is gone, and report what it built and said meanwhile. */ + async function watchUntilGone(root: string, swap: () => void): Promise<{ engine: DevEngine; gone: number; built: string[]; failures: Error[] }> { + const seen = { gone: 0, built: [] as string[], failures: [] as Error[] }; + const engine = startDevEngine({ + root, + watch: true, + debounceMs: 30, + rootCheckMs: 20, + onRootGone: () => (seen.gone += 1), + onBuild: (event) => seen.built.push(event.kind === "generation" ? `generation:${event.record.generation.packageId}` : event.kind), + onWatchError: (error) => seen.failures.push(error), + }); + engines.push(engine); + await engine.ready; + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 100)); + seen.built.length = 0; + + swap(); + const deadline = Date.now() + 5_000; + while (seen.gone === 0 && seen.failures.length === 0 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 20)); + // Long enough for a build the swap would have scheduled to have run. + await new Promise((done) => setTimeout(done, 200)); + return { engine, ...seen }; + } + + it("takes a folder swapped for a link to another folder as gone, rather than watching a folder nobody chose", async () => { + const base = tempDir("dev-engine-"); + const root = join(base, "chosen"); + const elsewhere = join(base, "elsewhere"); + writePackage(root, "

chosen

"); + writePackage(elsewhere, "

elsewhere

", { id: "com.example.elsewhere" }); + + const seen = await watchUntilGone(root, () => { + rmSync(root, { recursive: true, force: true }); + linkFolder(elsewhere, root); + }); + expect(seen.gone).toBe(1); + expect(seen.failures).toEqual([]); + expect(seen.built).toEqual([]); + expect(seen.engine.watching()).toBe(false); + expect(seen.engine.rootGone()).toBe(true); + expect(seen.engine.latest()?.generation.packageId).toBe("com.example.dev"); + }); + + it("takes a folder whose parent was swapped for a link to another tree as gone, rather than building that tree", async () => { + const base = tempDir("dev-engine-"); + const parent = join(base, "chosen"); + const root = join(parent, "out"); + const elsewhere = join(base, "elsewhere"); + writePackage(root, "

chosen

"); + writePackage(join(elsewhere, "out"), "

elsewhere

", { id: "com.example.elsewhere" }); + + const seen = await watchUntilGone(root, () => { + rmSync(parent, { recursive: true, force: true }); + linkFolder(elsewhere, parent); + }); + expect(seen.gone).toBe(1); + expect(seen.failures).toEqual([]); + expect(seen.built).toEqual([]); + expect(seen.engine.watching()).toBe(false); + expect(seen.engine.rootGone()).toBe(true); + expect(seen.engine.latest()?.generation.packageId).toBe("com.example.dev"); + }); + + it("stops watching, and says why, a folder whose file id keeps changing", async () => { + const root = tempDir("dev-engine-"); + writePackage(root, "

one

"); + const failures: Error[] = []; + const engine = startDevEngine({ root, watch: true, debounceMs: 30, rootCheckMs: 5, onWatchError: (error) => failures.push(error) }); + engines.push(engine); + await engine.ready; + + statNewId.path = engine.root; + statNewId.flap = true; + const deadline = Date.now() + 5_000; + while (failures.length === 0 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 20)); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toContain(`more than ${String(DEV_ENGINE_REARM_MAX)} looks in a row`); + expect(failures[0]?.message).toContain("no longer watched"); expect(engine.watching()).toBe(false); + }); + + it("keeps watching a folder made again many times, each new id followed by a look that finds it unchanged", async () => { + const root = tempDir("dev-engine-"); + writePackage(root, "

one

"); + const failures: Error[] = []; + const engine = startDevEngine({ root, watch: true, debounceMs: 30, rootCheckMs: 5, onWatchError: (error) => failures.push(error) }); + engines.push(engine); + await engine.ready; + + // Each round gives the folder one new id, which the following looks see unchanged, as a folder made again per build. + statNewId.path = engine.root; + for (let round = 1; round <= DEV_ENGINE_REARM_MAX + 10; round += 1) { + statNewId.step = BigInt(round); + await new Promise((done) => setTimeout(done, 40)); + } + expect(failures).toEqual([]); + expect(engine.watching()).toBe(true); + }); + + it("watches a folder another process deletes and makes again a moment later, rather than taking it as gone", 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

"); + let gone = 0; + const failures: Error[] = []; + const engine = startDevEngine({ + root, + watch: true, + debounceMs: 30, + rootCheckMs: 50, + onRootGone: () => (gone += 1), + onWatchError: (error) => failures.push(error), + }); + engines.push(engine); + await engine.ready; + await new Promise((done) => setTimeout(done, DEV_ENGINE_WATCH_CATCH_UP_MS + 100)); + + // As a build tool in its own process: delete the output folder, work for a moment, then write it again. + const script = [ + "const fs = require('node:fs');", + "const [out, source] = process.argv.slice(1);", + "fs.rmSync(out, { recursive: true, force: true });", + "setTimeout(() => fs.cpSync(source, out, { recursive: true }), 400);", + ].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); + expect(gone).toBe(0); + expect(failures).toEqual([]); + expect(engine.watching()).toBe(true); + expect(engine.rootGone()).toBe(false); + + // The folder made again is the one watched now: a save in it 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.skipIf(process.platform !== "win32" && process.platform !== "darwin")( + "watches a folder made again with its name in another case, where the filesystem ignores case", + async () => { + const base = tempDir("dev-engine-"); + const root = join(base, "out"); + writePackage(root, "

one

"); + let gone = 0; + const engine = startDevEngine({ root, watch: true, debounceMs: 30, rootCheckMs: 50, onRootGone: () => (gone += 1) }); + engines.push(engine); + await engine.ready; + + rmSync(root, { recursive: true, force: true }); + writePackage(join(base, "Out"), "

made again as Out

"); + 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); + expect(gone).toBe(0); + }, + ); + + it("builds nothing from a folder whose path leads through a link swapped in after the last look", async () => { + const base = tempDir("dev-engine-"); + const parent = join(base, "chosen"); + const root = join(parent, "out"); + const elsewhere = join(base, "elsewhere"); + writePackage(root, "

chosen

"); + writePackage(join(elsewhere, "out"), "

elsewhere

", { id: "com.example.elsewhere" }); + const engine = engineFor(root); + await engine.ready; + + rmSync(parent, { recursive: true, force: true }); + linkFolder(elsewhere, parent); + const event = await engine.rebuild(); + expect(event.kind).toBe("failed"); + expect(event.build.diagnostics[0]?.code).toBe("FILES_LINK_REFUSED"); + expect(engine.latest()?.generation.packageId).toBe("com.example.dev"); + }); + + it("builds a folder given through a link or junction, as the standalone dev host does, at the real path it leads to", async () => { + const base = tempDir("dev-engine-"); + const real = join(base, "real"); + const linked = join(base, "linked"); + writePackage(real, "

one

"); + linkFolder(real, linked); + const engine = startDevEngine({ root: linked, watch: false }); + engines.push(engine); + + expect((await engine.ready).kind).toBe("generation"); + expect(engine.root).toBe(realpathSync.native(real)); + writeFileSync(join(real, "widgets", "main", "index.html"), "

saved

"); + const event = await engine.rebuild(); + expect(event.kind).toBe("generation"); + expect(engine.latest()?.generation.generation).toBe(2); + }); + + it("watches a folder given through a link or junction, and builds a save in it rather than refusing the link", async () => { + const base = tempDir("dev-engine-"); + const real = join(base, "real"); + const linked = join(base, "linked"); + writePackage(real, "

one

"); + linkFolder(real, linked); + let gone = 0; + const engine = startDevEngine({ root: linked, watch: true, debounceMs: 30, rootCheckMs: 20, onRootGone: () => (gone += 1) }); + engines.push(engine); + expect((await engine.ready).kind).toBe("generation"); + + writeFileSync(join(linked, "widgets", "main", "index.html"), "

saved

"); + 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); + expect(engine.lastBuild()?.ok).toBe(true); + expect(gone).toBe(0); + expect(engine.rootGone()).toBe(false); + }); + + it("does not call a watched folder gone while it may still come back, and does once the grace is over", async () => { + 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) }); + engines.push(engine); + await engine.ready; + + 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"); + expect(engine.rootGone()).toBe(false); + expect(engine.watching()).toBe(true); + + const deadline = Date.now() + 5_000; + while (gone === 0 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 20)); + expect(gone).toBe(1); expect(engine.rootGone()).toBe(true); }); + it("stops watching, and says why, a folder it has not been able to look at for the time bound", async () => { + const root = tempDir("dev-engine-"); + writePackage(root, "

one

"); + let gone = 0; + const failures: Error[] = []; + const engine = startDevEngine({ + root, + watch: true, + debounceMs: 30, + rootCheckMs: 20, + rootUnreadableMs: 300, + onRootGone: () => (gone += 1), + onWatchError: (error) => failures.push(error), + }); + engines.push(engine); + await engine.ready; + + statFailure.path = engine.root; + statFailure.code = "EPERM"; + const started = Date.now(); + const deadline = started + 5_000; + while (failures.length === 0 && Date.now() < deadline) await new Promise((done) => setTimeout(done, 20)); + expect(failures).toHaveLength(1); + expect(Date.now() - started).toBeGreaterThanOrEqual(300); + expect(failures[0]?.message).toContain("could not be looked at"); + expect(failures[0]?.message).toContain("EPERM"); + expect(engine.watching()).toBe(false); + expect(gone).toBe(0); + // Told once: the check stops with the watch. + await new Promise((done) => setTimeout(done, 150)); + expect(failures).toHaveLength(1); + }); + + it("starts the time bound again after the folder could be looked at", async () => { + const root = tempDir("dev-engine-"); + writePackage(root, "

one

"); + const failures: Error[] = []; + const engine = startDevEngine({ root, watch: true, debounceMs: 30, rootCheckMs: 20, rootUnreadableMs: 300, onWatchError: (error) => failures.push(error) }); + engines.push(engine); + await engine.ready; + + for (let round = 0; round < 3; round += 1) { + statFailure.path = engine.root; + await new Promise((done) => setTimeout(done, 200)); + statFailure.path = undefined; + await new Promise((done) => setTimeout(done, 80)); + } + expect(failures).toEqual([]); + expect(engine.watching()).toBe(true); + }); + it("keeps watching through a folder it cannot look at for a moment, rather than taking it as gone", async () => { const root = tempDir("dev-engine-"); writePackage(root, "

one

");