From a2178cdbc8ce8d1d0cf1f7f7cc03623ed0b417fc Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 5 Aug 2026 21:51:49 +0200 Subject: [PATCH] fix(project): Add polling file watcher for containers On a bind-mounted volume inside a container, Linux inotify reports the container's own writes but not writes made to the same volume from outside the container. Podman is the common case. The incremental build derives "what changed" solely from @parcel/watcher events, so those missed events break rebuilds and live reload with no error to catch. Add a fileWatcher.js module that all three watcher consumers call instead of @parcel/watcher directly. It selects a backend once per process and exposes a subscribe() matching @parcel/watcher's exact contract, so a caller cannot tell which backend is active. The native backend stays @parcel/watcher. Both backends are imported lazily, only when selected. @parcel/watcher resolves a native binding at load time and throws when the platform's prebuilt binary is not installed (for example on an unsupported platform or after an install with --omit=optional), so a static import would break every consumer before the polling fallback could run. Loading it on demand contains that failure: when the native backend is selected but cannot load, subscribe() logs a warning and falls back to polling, which needs no native code. Parcel offers no polling subscription (its brute-force backend is query-only), so the polling backend lives in a separate pollingWatcher.js that fileWatcher.js imports lazily. It walks the tree and diffs an mtimeMs+size snapshot every 250 ms (rescheduling itself after each walk so a slow crawl cannot overlap the next), emitting the same {type, path} events the native backend would. Errors flow through the callback so each consumer's existing recovery path fires unchanged. The 250 ms interval stays below WATCHER_BURST_SETTLE_MS (550 ms) so downstream event batching still holds. Ignore globs reuse micromatch (already a direct dependency) and prune ignored directories so node_modules is never crawled. The backend is chosen once per process and memoized. UI5_WATCH_MODE forces it (polling or native); otherwise polling is the default inside a container and the native backend elsewhere. A container is detected from the runtime marker files (/.dockerenv, /run/.containerenv) and PID 1's cgroup. Detection is a heuristic: it can miss an exotic sandbox or match a host whose PID 1 cgroup carries those names, so UI5_WATCH_MODE is the escape hatch either way, documented under Troubleshooting. WatchHandler, ProjectDefinitionWatcher, and projectGraphSettleWatcher swap their parcelWatcher.subscribe call for fileWatcher.js; callback bodies, ignore globs, and settle windows are untouched. Fixes: https://github.com/UI5/cli/issues/1479 --- .../docs/pages/Troubleshooting.md | 12 ++ .../project/lib/build/helpers/WatchHandler.js | 4 +- .../project/lib/build/helpers/fileWatcher.js | 146 ++++++++++++++ .../lib/build/helpers/pollingWatcher.js | 174 +++++++++++++++++ .../lib/graph/ProjectDefinitionWatcher.js | 6 +- .../lib/graph/projectGraphSettleWatcher.js | 4 +- .../test/lib/build/BuildServer.integration.js | 5 + .../test/lib/build/helpers/WatchHandler.js | 6 +- .../test/lib/build/helpers/fileWatcher.js | 183 ++++++++++++++++++ .../test/lib/build/helpers/pollingWatcher.js | 125 ++++++++++++ .../lib/graph/ProjectDefinitionWatcher.js | 6 +- .../lib/graph/projectGraphSettleWatcher.js | 6 +- 12 files changed, 658 insertions(+), 19 deletions(-) create mode 100644 packages/project/lib/build/helpers/fileWatcher.js create mode 100644 packages/project/lib/build/helpers/pollingWatcher.js create mode 100644 packages/project/test/lib/build/helpers/fileWatcher.js create mode 100644 packages/project/test/lib/build/helpers/pollingWatcher.js diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 72e541a2779..8a8ed873630 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -114,6 +114,18 @@ Cross Environment via [cross-env](https://www.npmjs.com/package/cross-env): cross-env UI5_CLI_NO_INTERACTIVE=1 ui5 serve ``` +### File Changes Not Picked up in a Container + +When `ui5 serve` runs a build internally, it watches the project's files and rebuilds on change. On a bind-mounted volume inside a container, file system events are often only reported for the container's own writes, but not for writes made to the same volume from outside the container. If you edit files on the host and no rebuild or live reload happens inside the container (commonly observed with Podman), the "native" file watcher of the UI5 CLI is not receiving those events. + +For this reason, UI5 CLI attempts to detect a container environment and automatically switch to a "polling" file watcher. That detection is a heuristic and can be wrong in some environments. + +If you encounter this problem in your container-based development setup, try setting the `UI5_WATCH_MODE` environment variable to `polling` to force the polling watcher, or to `native` to force the native watcher. + +::: info +Polling reads the watched files on an interval, so it reports changes regardless of where they originate, at the cost of more CPU than the event-based native watcher. Use it only when the native watcher fails to detect your file changes. +::: + ### Changing UI5 CLI's Data Directory UI5 CLI's data directory is by default at `~/.ui5`. It's the place where the framework artifacts are stored. diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index da7c25a2feb..f9180081f93 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -1,6 +1,6 @@ import EventEmitter from "node:events"; -import parcelWatcher from "@parcel/watcher"; import {getLogger} from "@ui5/logger"; +import {subscribe as watchSubscribe} from "./fileWatcher.js"; import {drainSubscriptions} from "./watchUtil.js"; import {exists} from "../../utils/fsHelper.js"; const log = getLogger("build:helpers:WatchHandler"); @@ -33,7 +33,7 @@ class WatchHandler extends EventEmitter { await Promise.all(paths.map(async (path) => { let subscription; try { - subscription = await parcelWatcher.subscribe(path, (err, events) => { + subscription = await watchSubscribe(path, (err, events) => { if (err) { this.emit("error", err); return; diff --git a/packages/project/lib/build/helpers/fileWatcher.js b/packages/project/lib/build/helpers/fileWatcher.js new file mode 100644 index 00000000000..c2694c55d54 --- /dev/null +++ b/packages/project/lib/build/helpers/fileWatcher.js @@ -0,0 +1,146 @@ +import {existsSync, readFileSync} from "node:fs"; +import {getLogger} from "@ui5/logger"; + +const log = getLogger("build:helpers:fileWatcher"); + +/** + * Entry point every watcher consumer calls instead of @parcel/watcher directly. It + * selects a watcher backend once per process and exposes a subscribe matching + * @parcel/watcher's exact signature and return contract, so a caller cannot tell which + * backend is active. + * + * The native backend is @parcel/watcher. It is the default and covers the common case. + * Inside a container, Linux inotify often reports the container's own writes but not writes made to a + * mounted volume from outside the container (the common case in Podman). The incremental build + * derives "what changed" solely from watcher events, so those missed events break rebuilds and live + * reload. The polling backend in pollingWatcher.js reads the tree directly, so it sees + * every change regardless of where it originated. + * + * Both backends are imported lazily, only when selected, so neither module enters the import tree on + * the path that does not use it. @parcel/watcher resolves a native binding at load time + * and throws when the platform's prebuilt binary is not installed, so a static import here would + * break every consumer before the polling fallback could run. Loading it on demand keeps that failure + * contained: when the native backend is selected but cannot load, subscribe() falls back to polling, + * which needs no native code. + * + * @private + * @module @ui5/project/build/helpers/fileWatcher + */ + +// Marker files the container runtimes drop into the root filesystem: /.dockerenv by Docker, +// /run/.containerenv by Podman. +const CONTAINER_MARKER_FILES = ["/.dockerenv", "/run/.containerenv"]; + +// cgroup path fragments that appear only when PID 1 runs under a container runtime. +const rContainerCgroup = /\b(?:docker|libpod|containerd|kubepods)\b/; + +// Memoized backend decision. Computed once per process and shared by every subscribe() call. +let usePolling = null; + +// Memoized native backend: the @parcel/watcher module once loaded, or null when it could not load +// (e.g. no prebuilt binary for this platform). nativeBackendLoaded guards the one load attempt so a +// null result is not retried. +let nativeBackend = null; +let nativeBackendLoaded = false; + +/** + * Decides whether to poll, once per process. UI5_WATCH_MODE=polling|native forces the + * choice; otherwise polling is the default inside a container and the native backend is the default + * elsewhere. + * + * @returns {boolean} True when the polling backend should be used + */ +export function shouldUsePolling() { + return (usePolling ??= decideBackend()); +} + +function decideBackend() { + const mode = process.env.UI5_WATCH_MODE; + if (mode === "polling") { + log.verbose(`UI5_WATCH_MODE=polling: using polling file watcher`); + return true; + } + if (mode === "native") { + log.verbose(`UI5_WATCH_MODE=native: using native file watcher`); + return false; + } + if (mode) { + log.warn(`Ignoring invalid UI5_WATCH_MODE '${mode}', detecting file watcher backend`); + } + + if (isRunningInContainer()) { + log.info(`Detected a container environment: using the polling file watcher. Inside a ` + + `container, inotify often does not report changes made to a mounted volume from outside ` + + `the container. Set UI5_WATCH_MODE=native to force the native watcher.`); + return true; + } + log.verbose(`No container environment detected, using the native file watcher`); + return false; +} + +// Reports whether the process runs inside a container. Checks the marker files the runtimes drop +// into the root filesystem, then PID 1's cgroup membership. A container is a heuristic, not a +// guarantee: UI5_WATCH_MODE overrides it either way. Any filesystem error means the check could not +// prove a container, so it reports false (the native backend), which also covers non-Linux hosts +// where /proc/1/cgroup does not exist. +function isRunningInContainer() { + for (const marker of CONTAINER_MARKER_FILES) { + if (existsSync(marker)) { + return true; + } + } + try { + return rContainerCgroup.test(readFileSync("/proc/1/cgroup", "utf8")); + } catch { + return false; + } +} + +/** + * Subscribes to filesystem changes below dir, matching + * @parcel/watcher's subscribe signature and return contract. + * + * @param {string} dir Directory to watch + * @param {Function} callback Invoked as (err, events), events being + * {type: "create"|"update"|"delete", path: string} + * @param {object} [opts] + * @param {string[]} [opts.ignore] Path/glob patterns to ignore, matched relative to dir + * (same semantics as the native backend) + * @param {number} [opts.pollInterval] Poll interval in ms for the polling backend. Internal option. + * The native backend ignores it. Defaults to 250 ms. + * @returns {Promise<{unsubscribe: Function}>} Resolves once the watcher is ready + */ +export async function subscribe(dir, callback, opts = {}) { + if (!shouldUsePolling()) { + const native = await loadNativeBackend(); + if (native) { + return native.subscribe(dir, callback, opts); + } + // The native binding could not load (see loadNativeBackend). Polling needs no native code, so + // fall through to it rather than failing the watch. + } + // Loaded on demand: polling is the exception, so its module never enters the import tree when the + // native backend is used. + const {subscribe: subscribePolling} = await import("./pollingWatcher.js"); + return subscribePolling(dir, callback, opts); +} + +// Loads @parcel/watcher on demand and memoizes the result. Imported here rather than at module top +// because it resolves a native binding at load time and throws when the platform's prebuilt binary is +// not installed. A failure returns null (logged once) so subscribe() can fall back to polling instead +// of taking down every consumer. +async function loadNativeBackend() { + if (nativeBackendLoaded) { + return nativeBackend; + } + nativeBackendLoaded = true; + try { + nativeBackend = (await import("@parcel/watcher")).default; + } catch (err) { + nativeBackend = null; + log.warn(`Could not load the native file watcher (@parcel/watcher), falling back to ` + + `polling. This usually means the prebuilt binary for this platform was not installed. ` + + `Original error: ${err.message}`); + } + return nativeBackend; +} diff --git a/packages/project/lib/build/helpers/pollingWatcher.js b/packages/project/lib/build/helpers/pollingWatcher.js new file mode 100644 index 00000000000..1a72fcafe20 --- /dev/null +++ b/packages/project/lib/build/helpers/pollingWatcher.js @@ -0,0 +1,174 @@ +import {readdir, stat} from "node:fs/promises"; +import path from "node:path"; +import micromatch from "micromatch"; +import {getLogger} from "@ui5/logger"; + +const log = getLogger("build:helpers:pollingWatcher"); + +/** + * Polling backend for fileWatcher.js, used where the native @parcel/watcher + * does not deliver the events the incremental build relies on (a bind-mounted volume inside a + * container is the common case). Parcel offers no polling subscription of its own, so this walks the + * watched tree on an interval, diffs a {path -> {mtimeMs, size}} snapshot, and emits the + * same {type, path} events through the same (err, events) callback and + * {unsubscribe()} contract as the native backend. + * + * fileWatcher.js imports this module lazily, only when polling is selected, so it stays + * off the common path. See that module for the backend decision. + * + * @private + * @module @ui5/project/build/helpers/pollingWatcher + */ + +// Poll interval (ms). Kept below WATCHER_BURST_SETTLE_MS (550) so the gap between polls stays within +// a downstream settle window. +const DEFAULT_POLL_INTERVAL_MS = 250; + +/** + * Subscribes to filesystem changes below dir by polling, matching + * @parcel/watcher's subscribe signature and return contract. + * + * @param {string} dir Directory to watch + * @param {Function} callback Invoked as (err, events), events being + * {type: "create"|"update"|"delete", path: string} + * @param {object} [opts] + * @param {string[]} [opts.ignore] Path/glob patterns to ignore, matched relative to dir + * (same semantics as the native backend) + * @param {number} [opts.pollInterval] Poll interval in ms. Defaults to 250 ms. + * @returns {Promise<{unsubscribe: Function}>} Resolves once the initial snapshot is taken + */ +export async function subscribe(dir, callback, opts = {}) { + const rootDir = path.resolve(dir); + const ignore = opts.ignore ?? []; + const isIgnored = createIgnoreMatcher(rootDir, ignore); + const intervalMs = opts.pollInterval ?? DEFAULT_POLL_INTERVAL_MS; + + let stopped = false; + let timer = null; + + // Initial snapshot. Awaited before resolving so a change made right after startup is reported by + // the next poll rather than absorbed into the baseline and never reported. + let snapshot = await walk(rootDir, isIgnored); + + log.verbose(`Polling for changes in ${rootDir} every ${intervalMs} ms`); + + const scheduleNext = () => { + if (stopped) { + return; + } + timer = setTimeout(poll, intervalMs); + }; + + const poll = async () => { + timer = null; + try { + const next = await walk(rootDir, isIgnored); + if (stopped) { + return; + } + const events = diff(snapshot, next); + snapshot = next; + if (events.length) { + callback(null, events); + } + } catch (err) { + if (stopped) { + return; + } + // Report through the callback rather than throwing, matching how the native backend + // surfaces errors. + callback(err, []); + } finally { + scheduleNext(); + } + }; + + scheduleNext(); + + return { + async unsubscribe() { + stopped = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + }, + }; +} + +// Builds a predicate that tells whether an absolute path below rootDir should be ignored. Matches +// the path relative to rootDir with POSIX separators, mirroring the native backend's glob semantics. +// The matcher is compiled once per subscription so the poll loop does not recompile the globs. +function createIgnoreMatcher(rootDir, ignore) { + if (!ignore.length) { + return () => false; + } + const matchGlobs = micromatch.matcher(ignore, {dot: true}); + const normalizeSep = path.sep !== "/"; + return (absPath) => { + const rel = path.relative(rootDir, absPath); + return matchGlobs(normalizeSep ? rel.split(path.sep).join("/") : rel); + }; +} + +// Recursively walks rootDir, returning Map for every file. Ignored paths +// are skipped, and ignored directories are not descended, so a large node_modules is never crawled. +// Does not follow directory symlinks (matching the native backend), which also avoids symlink cycles. +async function walk(rootDir, isIgnored) { + const snapshot = new Map(); + const stack = [rootDir]; + while (stack.length) { + const current = stack.pop(); + let entries; + try { + entries = await readdir(current, {withFileTypes: true}); + } catch (err) { + if (err.code === "ENOENT") { + // Directory vanished between discovery and read (e.g. mid-checkout). Treat as empty. + // Its former children surface as deletes against the previous snapshot. + continue; + } + throw err; + } + for (const entry of entries) { + const entryPath = path.join(current, entry.name); + if (isIgnored(entryPath)) { + continue; + } + if (entry.isDirectory()) { + stack.push(entryPath); + } else if (entry.isFile()) { + try { + const {mtimeMs, size} = await stat(entryPath); + snapshot.set(entryPath, {mtimeMs, size}); + } catch (err) { + if (err.code === "ENOENT") { + continue; + } + throw err; + } + } + } + } + return snapshot; +} + +// Diffs two snapshots into @parcel/watcher-style events. A file present only in next -> create, +// only in prev -> delete, in both with a changed mtime or size -> update. +function diff(prev, next) { + const events = []; + for (const [filePath, meta] of next) { + const before = prev.get(filePath); + if (!before) { + events.push({type: "create", path: filePath}); + } else if (before.mtimeMs !== meta.mtimeMs || before.size !== meta.size) { + events.push({type: "update", path: filePath}); + } + } + for (const filePath of prev.keys()) { + if (!next.has(filePath)) { + events.push({type: "delete", path: filePath}); + } + } + return events; +} diff --git a/packages/project/lib/graph/ProjectDefinitionWatcher.js b/packages/project/lib/graph/ProjectDefinitionWatcher.js index 9f47ee925f4..d9a13549128 100644 --- a/packages/project/lib/graph/ProjectDefinitionWatcher.js +++ b/packages/project/lib/graph/ProjectDefinitionWatcher.js @@ -1,7 +1,7 @@ import EventEmitter from "node:events"; import path from "node:path"; -import parcelWatcher from "@parcel/watcher"; import {getLogger} from "@ui5/logger"; +import {subscribe as watchSubscribe} from "../build/helpers/fileWatcher.js"; import {drainSubscriptions, WATCHER_BURST_SETTLE_MS} from "../build/helpers/watchUtil.js"; import RecoveryBudget, { WATCHER_RECOVERY_MAX_ATTEMPTS, WATCHER_RECOVERY_WINDOW_MS, @@ -32,7 +32,7 @@ export const DEFINITION_CHANGED_SETTLE_MS = WATCHER_BURST_SETTLE_MS; * * Separate from the source {@link WatchHandler}: source events drive incremental rebuilds inside * the BuildServer, definition events drive a full re-init of the serving stack above it. The watch - * model is include-based: @parcel/watcher subscribes to each distinct definition-file directory, and + * model is include-based: the watcher subscribes to each distinct definition-file directory, and * only resolved definition-file paths can start a burst. Once started, non-definition events from * those subscriptions extend the burst's quiet window. The * node_modules/.git ignore globs only reduce OS-level watch load; @@ -146,7 +146,7 @@ class ProjectDefinitionWatcher extends EventEmitter { } async #subscribeDir(dir) { - const subscription = await parcelWatcher.subscribe(dir, (err, events) => { + const subscription = await watchSubscribe(dir, (err, events) => { if (err) { this.#recoverWatcher(err); return; diff --git a/packages/project/lib/graph/projectGraphSettleWatcher.js b/packages/project/lib/graph/projectGraphSettleWatcher.js index 3cdf93be6ba..eec8e850188 100644 --- a/packages/project/lib/graph/projectGraphSettleWatcher.js +++ b/packages/project/lib/graph/projectGraphSettleWatcher.js @@ -1,6 +1,6 @@ import path from "node:path"; -import parcelWatcher from "@parcel/watcher"; import {getLogger} from "@ui5/logger"; +import {subscribe as watchSubscribe} from "../build/helpers/fileWatcher.js"; import {drainSubscriptions, WATCHER_BURST_SETTLE_MS} from "../build/helpers/watchUtil.js"; import {findExistingDir} from "../utils/fsHelper.js"; @@ -135,7 +135,7 @@ export async function waitForProjectGraphSettled(graphs, { } await Promise.all(dirs.map(async (dir) => { - const subscription = await parcelWatcher.subscribe(dir, (err, events) => { + const subscription = await watchSubscribe(dir, (err, events) => { if (err) { finish(err); return; diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index b2176aa2bf8..61832f020e8 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -12,6 +12,11 @@ import Cache from "../../../lib/build/cache/Cache.js"; // Ensures that all logging code paths are tested setLogLevel("silly"); +// Force the native watcher backend so fileWatcher delegates verbatim to the @parcel/watcher mock +// below (esmock.p intercepts it throughout the import tree, including inside fileWatcher.js). +// Without this, the module would default to polling whenever the tests run inside a container. +process.env.UI5_WATCH_MODE = "native"; + // Mock @parcel/watcher for the entire import tree reachable from graph.js so the build server's // WatchHandler does not try to subscribe to real FSEvents/inotify/ReadDirectoryChangesW handles. // Tests fire watcher events deterministically via FixtureTester#fireWatcherEvent instead of diff --git a/packages/project/test/lib/build/helpers/WatchHandler.js b/packages/project/test/lib/build/helpers/WatchHandler.js index d1b6a04f71c..db799d1d002 100644 --- a/packages/project/test/lib/build/helpers/WatchHandler.js +++ b/packages/project/test/lib/build/helpers/WatchHandler.js @@ -10,10 +10,8 @@ test.before(async () => { subscribeStub = sinon.stub(); existsStub = sinon.stub(); WatchHandler = await esmock("../../../../lib/build/helpers/WatchHandler.js", { - "@parcel/watcher": { - default: { - subscribe: subscribeStub - } + "../../../../lib/build/helpers/fileWatcher.js": { + subscribe: subscribeStub }, "../../../../lib/utils/fsHelper.js": { exists: existsStub diff --git a/packages/project/test/lib/build/helpers/fileWatcher.js b/packages/project/test/lib/build/helpers/fileWatcher.js new file mode 100644 index 00000000000..f84a78174b9 --- /dev/null +++ b/packages/project/test/lib/build/helpers/fileWatcher.js @@ -0,0 +1,183 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; +import path from "node:path"; +import os from "node:os"; +import {mkdtemp, writeFile, rm, mkdir} from "node:fs/promises"; + +// fileWatcher selects a backend and delegates. The native path is checked by mocking @parcel/watcher, +// the polling path by driving the real (lazily imported) backend against a temp directory, and the +// backend decision by mocking node:fs to drive container detection to a known answer. The polling +// backend's own event behavior is covered by pollingWatcher's test. +// +// subscribe() imports both backends dynamically. Plain esmock mocks do not reach a dynamic import, so +// tests that must intercept @parcel/watcher use esmock.p (which does) and purge afterwards. Tests +// that only steer the sync container detection use plain esmock with a node:fs mock. + +const fileWatcherPath = "../../../../lib/build/helpers/fileWatcher.js"; + +let tmpRoot; + +test.before(async () => { + tmpRoot = await mkdtemp(path.join(os.tmpdir(), "ui5-filewatcher-test-")); +}); + +test.after.always(async () => { + await rm(tmpRoot, {recursive: true, force: true}); +}); + +test.afterEach.always(() => { + sinon.restore(); + delete process.env.UI5_WATCH_MODE; +}); + +let dirCounter = 0; +async function makeDir() { + const dir = path.join(tmpRoot, `case-${dirCounter++}`); + await mkdir(dir, {recursive: true}); + return dir; +} + +// Imports a fresh module instance (so the memoized backend decision starts unset), with a node:fs +// mock to drive container detection. Env vars set by the test are read at decision time. +async function importWatcher({existsSync, readFileSync} = {}) { + const mocks = {}; + if (existsSync || readFileSync) { + mocks["node:fs"] = { + existsSync: existsSync ?? (() => false), + readFileSync: readFileSync ?? (() => { + throw Object.assign(new Error("ENOENT"), {code: "ENOENT"}); + }), + }; + } + return esmock(fileWatcherPath, mocks); +} + +// Imports a fresh module instance with @parcel/watcher mocked across the dynamic import (esmock.p is +// the variant that reaches it). The mock is passed as a global def so it applies wherever the module +// is imported. Callers must purge the returned module when done. +function importWatcherWithParcel(parcelMock) { + return esmock.p(fileWatcherPath, {}, {"@parcel/watcher": parcelMock}); +} + +test.serial("subscribe: native delegation when UI5_WATCH_MODE=native", async (t) => { + process.env.UI5_WATCH_MODE = "native"; + const nativeSubscription = {unsubscribe: sinon.stub().resolves()}; + const parcelSubscribe = sinon.stub().resolves(nativeSubscription); + const watcher = await importWatcherWithParcel({ + default: {subscribe: parcelSubscribe}, subscribe: parcelSubscribe, + }); + try { + const cb = () => {}; + const opts = {ignore: ["**/x/**"]}; + const subscription = await watcher.subscribe("/some/dir", cb, opts); + + t.is(subscription, nativeSubscription, "returns the native subscription unchanged"); + t.true(parcelSubscribe.calledOnceWithExactly("/some/dir", cb, opts), + "delegates verbatim to the native backend"); + } finally { + esmock.purge(watcher); + } +}); + +test.serial("subscribe: falls back to polling when the native backend is unavailable", async (t) => { + // A missing prebuilt binary leaves no usable native backend. subscribe() must not fail the watch: + // it uses polling, which needs no native code. The mock stands in for that unavailable module (no + // usable default export); a real missing binary throws on import, which subscribe() also catches. + process.env.UI5_WATCH_MODE = "native"; + const watcher = await importWatcherWithParcel({default: undefined}); + const dir = await makeDir(); + let batch; + const ready = new Promise((resolve) => { + batch = resolve; + }); + const subscription = await watcher.subscribe(dir, (_err, events) => batch(events), {pollInterval: 50}); + try { + const filePath = path.join(dir, "a.js"); + await writeFile(filePath, "one"); + t.deepEqual(await ready, [{type: "create", path: filePath}], + "the polling backend reports the change after the native backend was unavailable"); + } finally { + await subscription.unsubscribe(); + esmock.purge(watcher); + } +}); + +test.serial("subscribe: polling backend is loaded and used when UI5_WATCH_MODE=polling", async (t) => { + // Drives the real lazily-imported polling backend end to end, so this also proves the dynamic + // import in subscribe() resolves. Polling mode never touches the native backend. + process.env.UI5_WATCH_MODE = "polling"; + const {subscribe} = await importWatcher(); + const dir = await makeDir(); + + let batch; + const ready = new Promise((resolve) => { + batch = resolve; + }); + const subscription = await subscribe(dir, (_err, events) => batch(events), {pollInterval: 50}); + try { + const filePath = path.join(dir, "a.js"); + await writeFile(filePath, "one"); + t.deepEqual(await ready, [{type: "create", path: filePath}], + "the polling backend reports the change"); + } finally { + await subscription.unsubscribe(); + } +}); + +test.serial("shouldUsePolling: UI5_WATCH_MODE forces the backend without inspecting the environment", async (t) => { + const existsSync = sinon.stub().returns(false); + process.env.UI5_WATCH_MODE = "polling"; + let watcher = await importWatcher({existsSync}); + t.true(watcher.shouldUsePolling(), "polling forced"); + t.is(existsSync.callCount, 0, "no environment check when forced"); + + process.env.UI5_WATCH_MODE = "native"; + watcher = await importWatcher({existsSync}); + t.false(watcher.shouldUsePolling(), "native forced"); + t.is(existsSync.callCount, 0, "no environment check when forced"); +}); + +test.serial("shouldUsePolling: a container marker file selects polling", async (t) => { + const existsSync = sinon.stub().callsFake((p) => p === "/run/.containerenv"); + const {shouldUsePolling} = await importWatcher({existsSync}); + t.true(shouldUsePolling(), "the Podman marker file selects the polling backend"); +}); + +test.serial("shouldUsePolling: a container cgroup selects polling", async (t) => { + const existsSync = sinon.stub().returns(false); + const readFileSync = sinon.stub().returns("0::/docker/2f8c...\n"); + const {shouldUsePolling} = await importWatcher({existsSync, readFileSync}); + t.true(shouldUsePolling(), "a container cgroup for PID 1 selects the polling backend"); +}); + +test.serial("shouldUsePolling: no container markers selects the native backend", async (t) => { + const existsSync = sinon.stub().returns(false); + const readFileSync = sinon.stub().returns("0::/user.slice/user-1000.slice/session-2.scope\n"); + const {shouldUsePolling} = await importWatcher({existsSync, readFileSync}); + t.false(shouldUsePolling(), "a host cgroup selects the native backend"); +}); + +test.serial("shouldUsePolling: an unreadable /proc/1/cgroup is treated as no container", async (t) => { + const existsSync = sinon.stub().returns(false); + const readFileSync = sinon.stub().throws(Object.assign(new Error("ENOENT"), {code: "ENOENT"})); + const {shouldUsePolling} = await importWatcher({existsSync, readFileSync}); + t.false(shouldUsePolling(), "a missing cgroup file (e.g. on macOS) is not a container"); +}); + +test.serial("shouldUsePolling: the decision is memoized across calls", async (t) => { + const existsSync = sinon.stub().returns(false); + const readFileSync = sinon.stub().returns("0::/docker/2f8c...\n"); + const {shouldUsePolling} = await importWatcher({existsSync, readFileSync}); + t.true(shouldUsePolling()); + t.true(shouldUsePolling()); + t.is(readFileSync.callCount, 1, "the environment is inspected once per process"); +}); + +test.serial("shouldUsePolling: an invalid UI5_WATCH_MODE falls back to environment detection", async (t) => { + process.env.UI5_WATCH_MODE = "bogus"; + const existsSync = sinon.stub().callsFake((p) => p === "/.dockerenv"); + const {shouldUsePolling} = await importWatcher({existsSync}); + t.true(shouldUsePolling(), "the invalid mode is ignored and the environment decides"); + t.true(existsSync.called, "the environment was inspected"); +}); diff --git a/packages/project/test/lib/build/helpers/pollingWatcher.js b/packages/project/test/lib/build/helpers/pollingWatcher.js new file mode 100644 index 00000000000..75947d81284 --- /dev/null +++ b/packages/project/test/lib/build/helpers/pollingWatcher.js @@ -0,0 +1,125 @@ +import test from "ava"; +import path from "node:path"; +import os from "node:os"; +import {mkdtemp, writeFile, rm, mkdir, unlink} from "node:fs/promises"; +import {subscribe} from "../../../../lib/build/helpers/pollingWatcher.js"; + +// The polling loop is exercised against a real temp directory: a filesystem poller is only as good +// as its real walk/diff/stat behavior, so faking fs would test the mock, not the watcher. subscribe +// is called directly here; fileWatcher's test covers when this backend is selected. + +let tmpRoot; + +test.before(async () => { + tmpRoot = await mkdtemp(path.join(os.tmpdir(), "ui5-pollingwatcher-test-")); +}); + +test.after.always(async () => { + await rm(tmpRoot, {recursive: true, force: true}); +}); + +let dirCounter = 0; +async function makeDir() { + const dir = path.join(tmpRoot, `case-${dirCounter++}`); + await mkdir(dir, {recursive: true}); + return dir; +} + +// Collects events from the callback and lets a test await the next batch. +function eventCollector() { + const batches = []; + let waiters = []; + const callback = (_err, events) => { + batches.push(events); + const pending = waiters; + waiters = []; + pending.forEach((resolve) => resolve()); + }; + async function waitForBatch(timeoutMs = 2000) { + if (batches.length) { + return batches.shift(); + } + await new Promise((resolve, reject) => { + waiters.push(resolve); + setTimeout(() => reject(new Error("Timed out waiting for a poll batch")), timeoutMs); + }); + return batches.shift(); + } + return {callback, waitForBatch, batches}; +} + +test("subscribe: emits create, update and delete events", async (t) => { + const dir = await makeDir(); + const {callback, waitForBatch} = eventCollector(); + + const subscription = await subscribe(dir, callback, {pollInterval: 50}); + try { + const filePath = path.join(dir, "a.js"); + await writeFile(filePath, "one"); + let batch = await waitForBatch(); + t.deepEqual(batch, [{type: "create", path: filePath}], "new file reported as create"); + + // A size change is the cheap definite signal, so the test does not rely on mtime resolution. + await writeFile(filePath, "one-longer"); + batch = await waitForBatch(); + t.deepEqual(batch, [{type: "update", path: filePath}], "changed file reported as update"); + + await unlink(filePath); + batch = await waitForBatch(); + t.deepEqual(batch, [{type: "delete", path: filePath}], "removed file reported as delete"); + } finally { + await subscription.unsubscribe(); + } +}); + +test("subscribe: ignores globbed paths and does not descend ignored dirs", async (t) => { + const dir = await makeDir(); + await mkdir(path.join(dir, "node_modules", "dep"), {recursive: true}); + const {callback, waitForBatch} = eventCollector(); + + const subscription = await subscribe(dir, callback, {ignore: ["**/node_modules/**"], pollInterval: 50}); + try { + // A write inside the ignored tree must produce no event. + await writeFile(path.join(dir, "node_modules", "dep", "index.js"), "dep"); + // A write outside it must, proving the poller is running and the ignore is selective. + const kept = path.join(dir, "kept.js"); + await writeFile(kept, "kept"); + + const batch = await waitForBatch(); + t.deepEqual(batch, [{type: "create", path: kept}], "only the non-ignored file is reported"); + } finally { + await subscription.unsubscribe(); + } +}); + +test("subscribe: coalesces multiple changes into one batch per poll", async (t) => { + const dir = await makeDir(); + const {callback, waitForBatch} = eventCollector(); + + const subscription = await subscribe(dir, callback, {pollInterval: 50}); + try { + const a = path.join(dir, "a.js"); + const b = path.join(dir, "b.js"); + await Promise.all([writeFile(a, "a"), writeFile(b, "b")]); + const batch = await waitForBatch(); + t.deepEqual(batch.map((e) => e.type).sort(), ["create", "create"], + "both creates arrive in one batch"); + t.deepEqual(batch.map((e) => e.path).sort(), [a, b].sort()); + } finally { + await subscription.unsubscribe(); + } +}); + +test("subscribe: unsubscribe stops polling and is idempotent", async (t) => { + const dir = await makeDir(); + const {callback, batches} = eventCollector(); + + const subscription = await subscribe(dir, callback, {pollInterval: 50}); + await subscription.unsubscribe(); + await subscription.unsubscribe(); // second call must be a no-op, not throw + + await writeFile(path.join(dir, "late.js"), "late"); + // Wait well past the interval. No batch should be delivered after unsubscribe. + await new Promise((resolve) => setTimeout(resolve, 200)); + t.is(batches.length, 0, "no events after unsubscribe"); +}); diff --git a/packages/project/test/lib/graph/ProjectDefinitionWatcher.js b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js index 9950db56b82..ebf385a03df 100644 --- a/packages/project/test/lib/graph/ProjectDefinitionWatcher.js +++ b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js @@ -12,10 +12,8 @@ const fixtureFile = (root, ...segments) => path.join(fixturePath(root), ...segme test.before(async () => { subscribeStub = sinon.stub(); ProjectDefinitionWatcher = await esmock("../../../lib/graph/ProjectDefinitionWatcher.js", { - "@parcel/watcher": { - default: { - subscribe: subscribeStub - } + "../../../lib/build/helpers/fileWatcher.js": { + subscribe: subscribeStub } }); }); diff --git a/packages/project/test/lib/graph/projectGraphSettleWatcher.js b/packages/project/test/lib/graph/projectGraphSettleWatcher.js index 40e7a88a4aa..312c41c75d8 100644 --- a/packages/project/test/lib/graph/projectGraphSettleWatcher.js +++ b/packages/project/test/lib/graph/projectGraphSettleWatcher.js @@ -16,10 +16,8 @@ test.before(async () => { "../../../lib/utils/fsHelper.js": { findExistingDir: findExistingDirStub }, - "@parcel/watcher": { - default: { - subscribe: subscribeStub - } + "../../../lib/build/helpers/fileWatcher.js": { + subscribe: subscribeStub } })); });