From a997bff224aec9870309b005d6b55562c19e8b74 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 22 Sep 2026 14:26:19 +0200 Subject: [PATCH 1/3] feat(project): Add "off" mode to UI5_WATCH_MODE to disable file watching In CI and other environments where sources do not change while the server runs, file watching adds cost without benefit. The polling backend is the most expensive: it walks the source tree every 250 ms, and it is the default inside containers, where CI commonly runs. UI5_WATCH_MODE=off makes the fileWatcher facade's subscribe() return an inert subscription: the callback is never invoked and unsubscribe() is a no-op, so no backend is loaded and no filesystem is polled. All three watcher consumers (WatchHandler, ProjectDefinitionWatcher, projectGraphSettleWatcher) go through this facade, so none of them starts an OS watch handle or a poll loop. Source changes no longer trigger rebuilds or live reload while the server runs. The backend decision memoizes a mode string ("native" | "polling" | "off") instead of a boolean. shouldUsePolling() and the new isWatchingDisabled() derive from it. The "off" selection is logged at verbose, consistent with the polling and native modes. Expose fileWatcher through the package's internal exports so @ui5/cli can import isWatchingDisabled(), matching the ./internal/... export pattern the CLI already uses for ProjectDefinitionWatcher. JIRA: CPOUI5FOUNDATION-1355 --- .../project/lib/build/helpers/fileWatcher.js | 55 +++++++++++++++---- packages/project/package.json | 1 + .../test/lib/build/helpers/fileWatcher.js | 36 ++++++++++++ packages/project/test/lib/package-exports.js | 3 +- 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/packages/project/lib/build/helpers/fileWatcher.js b/packages/project/lib/build/helpers/fileWatcher.js index 53e934e9a75..149e6985246 100644 --- a/packages/project/lib/build/helpers/fileWatcher.js +++ b/packages/project/lib/build/helpers/fileWatcher.js @@ -23,6 +23,12 @@ const log = getLogger("build:helpers:fileWatcher"); * contained: when the native backend is selected but cannot load, subscribe() falls back to polling, * which needs no native code. * + * UI5_WATCH_MODE=off disables watching entirely: subscribe() returns an inert + * subscription that never invokes its callback, so no backend is loaded and no filesystem is polled. + * This is meant for CI and other environments where sources do not change while the server runs, so + * the watching overhead (especially the polling backend's tree walk on container volumes) adds cost + * without benefit. + * * @private * @module @ui5/project/build/helpers/fileWatcher */ @@ -34,8 +40,9 @@ 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 backend decision, one of "native" | "polling" | "off". Computed once per process and +// shared by every subscribe() call. +let watchBackend = null; // Memoized load of the native backend. nativeBackendPromise holds the single in-flight (or // settled) load so concurrent subscribe() calls await the same import instead of each reading @@ -44,25 +51,43 @@ let usePolling = null; let nativeBackendPromise = null; /** - * 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. + * Decides the watcher backend, once per process. UI5_WATCH_MODE=off|polling|native + * forces the choice; otherwise polling is the default inside a container and the native backend is + * the default elsewhere. * + * @returns {"native"|"polling"|"off"} The selected backend + */ +function getWatchBackend() { + return (watchBackend ??= decideBackend()); +} + +/** * @returns {boolean} True when the polling backend should be used */ export function shouldUsePolling() { - return (usePolling ??= decideBackend()); + return getWatchBackend() === "polling"; +} + +/** + * @returns {boolean} True when file watching is disabled (UI5_WATCH_MODE=off) + */ +export function isWatchingDisabled() { + return getWatchBackend() === "off"; } function decideBackend() { const mode = process.env.UI5_WATCH_MODE; + if (mode === "off") { + log.verbose(`UI5_WATCH_MODE=off: file watching is disabled`); + return "off"; + } if (mode === "polling") { log.verbose(`UI5_WATCH_MODE=polling: using polling file watcher`); - return true; + return "polling"; } if (mode === "native") { log.verbose(`UI5_WATCH_MODE=native: using native file watcher`); - return false; + return "native"; } if (mode) { log.warn(`Ignoring invalid UI5_WATCH_MODE '${mode}', detecting file watcher backend`); @@ -72,10 +97,10 @@ function decideBackend() { 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; + return "polling"; } log.verbose(`No container environment detected, using the native file watcher`); - return false; + return "native"; } // Reports whether the process runs inside a container. Checks the marker files the runtimes drop @@ -98,7 +123,9 @@ function isRunningInContainer() { /** * Subscribes to filesystem changes below dir, matching - * @parcel/watcher's subscribe signature and return contract. + * @parcel/watcher's subscribe signature and return contract. When watching + * is disabled (UI5_WATCH_MODE=off), the returned subscription is inert: its callback is + * never invoked and unsubscribe is a no-op. * * @param {string} dir Directory to watch * @param {Function} callback Invoked as (err, events), events being @@ -111,6 +138,12 @@ function isRunningInContainer() { * @returns {Promise<{unsubscribe: Function}>} Resolves once the watcher is ready */ export async function subscribe(dir, callback, opts = {}) { + if (isWatchingDisabled()) { + // Watching is off (UI5_WATCH_MODE=off). Return an inert subscription the caller tracks and + // unsubscribes exactly as a real one, but the callback is never invoked, so no backend loads + // and no rebuild or live reload is ever triggered. + return {unsubscribe: async () => {}}; + } if (!shouldUsePolling()) { const native = await loadNativeBackend(); if (native) { diff --git a/packages/project/package.json b/packages/project/package.json index 5dcde31e790..f7abb3c9084 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -19,6 +19,7 @@ "type": "module", "exports": { "./internal/build/cache/CacheManager": "./lib/build/cache/CacheManager.js", + "./internal/build/helpers/fileWatcher": "./lib/build/helpers/fileWatcher.js", "./internal/ui5Framework/cache": "./lib/ui5Framework/cache.js", "./config/Configuration": "./lib/config/Configuration.js", "./build/cache/Cache": "./lib/build/cache/Cache.js", diff --git a/packages/project/test/lib/build/helpers/fileWatcher.js b/packages/project/test/lib/build/helpers/fileWatcher.js index 6121c96db9e..8aa916de6ed 100644 --- a/packages/project/test/lib/build/helpers/fileWatcher.js +++ b/packages/project/test/lib/build/helpers/fileWatcher.js @@ -149,6 +149,42 @@ test.serial("subscribe: polling backend is loaded and used when UI5_WATCH_MODE=p } }); +test.serial("subscribe: returns an inert subscription when UI5_WATCH_MODE=off", async (t) => { + // Disabled mode must not load any backend. The native stub stands in as a tripwire: if subscribe() + // delegated, it would be called. The returned subscription is real enough to track and unsubscribe. + process.env.UI5_WATCH_MODE = "off"; + const parcelSubscribe = sinon.stub().resolves({unsubscribe: sinon.stub().resolves()}); + const watcher = await importWatcherWithParcel({ + default: {subscribe: parcelSubscribe}, subscribe: parcelSubscribe, + }); + try { + const cb = sinon.stub(); + const subscription = await watcher.subscribe("/some/dir", cb, {ignore: ["**/x/**"]}); + + t.is(parcelSubscribe.callCount, 0, "no native backend is loaded when watching is disabled"); + t.is(cb.callCount, 0, "the callback is never invoked"); + t.is(typeof subscription.unsubscribe, "function", "returns a subscription with unsubscribe()"); + await t.notThrowsAsync(subscription.unsubscribe(), "unsubscribe is a no-op that resolves"); + } finally { + esmock.purge(watcher); + } +}); + +test.serial("isWatchingDisabled: true only for UI5_WATCH_MODE=off", async (t) => { + process.env.UI5_WATCH_MODE = "off"; + let watcher = await importWatcher(); + t.true(watcher.isWatchingDisabled(), "off disables watching"); + t.false(watcher.shouldUsePolling(), "off is not polling"); + + process.env.UI5_WATCH_MODE = "polling"; + watcher = await importWatcher(); + t.false(watcher.isWatchingDisabled(), "polling does not disable watching"); + + process.env.UI5_WATCH_MODE = "native"; + watcher = await importWatcher(); + t.false(watcher.isWatchingDisabled(), "native does not disable watching"); +}); + 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"; diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index f644793a8a6..ab2cec3d4d5 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -13,7 +13,7 @@ test("export of package.json", (t) => { // Check number of definied exports test("check number of exports", (t) => { const packageJson = require("@ui5/project/package.json"); - t.is(Object.keys(packageJson.exports).length, 17); + t.is(Object.keys(packageJson.exports).length, 18); }); // Public API contract (exported modules) @@ -21,6 +21,7 @@ test("check number of exports", (t) => { "config/Configuration", "build/cache/Cache", {exportedSpecifier: "internal/build/cache/CacheManager", mappedModule: "../../lib/build/cache/CacheManager.js"}, + {exportedSpecifier: "internal/build/helpers/fileWatcher", mappedModule: "../../lib/build/helpers/fileWatcher.js"}, "specifications/Specification", "specifications/SpecificationVersion", "ui5Framework/Openui5Resolver", From 06cf0f1375951ebc7153fb4bd882d51a21602f7d Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 22 Sep 2026 14:26:27 +0200 Subject: [PATCH 2/3] refactor(cli): Disable live reload for UI5_WATCH_MODE=off Live reload needs a watcher to learn when to push a reload, so it cannot work in "off" mode. `ui5 serve` now resolves liveReload from all three sources (default, --live-reload, server.settings.liveReload) and then, when watching is disabled, forces it off regardless of how it was enabled. With liveReload false the server skips minting the WebSocket token and calling attachLiveReloadServer(), so no live-reload WebSocket server is attached and no server-side change is needed. isWatchingDisabled() is imported dynamically inside the handler, matching how the command already loads its other @ui5/project and @ui5/server dependencies. On startup in "off" mode the handler logs that file watching is disabled and that resources still build on demand but changes are not picked up while the server runs. When live reload was enabled, it additionally logs that live reload has been disabled because it requires a watcher. --- packages/cli/lib/cli/commands/serve.js | 15 +++++++ packages/cli/test/lib/cli/commands/serve.js | 47 +++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index 92f5dcabcce..3c6735cebbb 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -208,6 +208,21 @@ serve.handler = async function(argv) { } } + const {isWatchingDisabled} = await import("@ui5/project/internal/build/helpers/fileWatcher"); + if (isWatchingDisabled()) { + // Watchers are disabled. The server builds resources on demand but does not + // rebuild them on change. An edit to sources or configuration while it runs is not picked up + // and can produce an inconsistent result until the server is restarted. + log.info( + `File watching is disabled through environment configuration. ` + + `Changes to sources or project configuration will require a server restart.`); + if (liveReload) { + // Live reload needs a watcher to learn when to push. + log.info(`Live reload is disabled due to disabled file watching.`); + liveReload = false; + } + } + const serverConfig = { port, changePortIfInUse, diff --git a/packages/cli/test/lib/cli/commands/serve.js b/packages/cli/test/lib/cli/commands/serve.js index c541dda288c..6b5afaa31b0 100644 --- a/packages/cli/test/lib/cli/commands/serve.js +++ b/packages/cli/test/lib/cli/commands/serve.js @@ -87,6 +87,11 @@ test.beforeEach(async (t) => { // Definition-watcher namespace the handler injects into server.serve(). t.context.projectWatcher = {default: {create: sinon.stub()}}; + // Watch-mode facade from @ui5/project. Defaults to watching enabled. Off-mode tests override it. + t.context.fileWatcher = { + isWatchingDisabled: sinon.stub().returns(false) + }; + // Capture stray writes to stderr/stdout so failing assertions surface the // actual output instead of ava's timeout diagnostics. t.context.consoleOutput = ""; @@ -106,6 +111,7 @@ test.beforeEach(async (t) => { "@ui5/server/internal/sslUtil": t.context.sslUtil, "@ui5/project/graph": t.context.graph, "@ui5/project/internal/graph/ProjectDefinitionWatcher": t.context.projectWatcher, + "@ui5/project/internal/build/helpers/fileWatcher": t.context.fileWatcher, "open": t.context.open }, { "../../../../lib/dataDir.js": { @@ -574,6 +580,47 @@ test.serial("ui5 serve --live-reload overrides ui5.yaml liveReload setting", asy t.is(server.serve.getCall(0).args[1].liveReload, true); }); +test.serial("ui5 serve UI5_WATCH_MODE=off disables live reload (default)", async (t) => { + const {argv, serve, server, fileWatcher} = t.context; + + fileWatcher.isWatchingDisabled.returns(true); + + serve.handler(argv); + await t.context.handlerReady; + + t.is(server.serve.callCount, 1); + t.is(server.serve.getCall(0).args[1].liveReload, false, + "live reload is forced off when watching is disabled"); +}); + +test.serial("ui5 serve UI5_WATCH_MODE=off overrides --live-reload", async (t) => { + const {argv, serve, server, fileWatcher} = t.context; + + argv.liveReload = true; + fileWatcher.isWatchingDisabled.returns(true); + + serve.handler(argv); + await t.context.handlerReady; + + t.is(server.serve.callCount, 1); + t.is(server.serve.getCall(0).args[1].liveReload, false, + "off wins over an explicit --live-reload"); +}); + +test.serial("ui5 serve UI5_WATCH_MODE=off overrides ui5.yaml liveReload=true setting", async (t) => { + const {argv, serve, server, fileWatcher, getServerSettings} = t.context; + + getServerSettings.returns({liveReload: true}); + fileWatcher.isWatchingDisabled.returns(true); + + serve.handler(argv); + await t.context.handlerReady; + + t.is(server.serve.callCount, 1); + t.is(server.serve.getCall(0).args[1].liveReload, false, + "off wins over server.settings.liveReload"); +}); + test.serial("ui5 serve --include-task / --exclude-task", async (t) => { const {argv, serve, server} = t.context; From da7da797b3258cba065d9c1470fb1d6bb99a809e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Tue, 22 Sep 2026 14:26:33 +0200 Subject: [PATCH 3/3] docs: Document UI5_WATCH_MODE=off for disabling file watching Add a Troubleshooting section pointing CI users at UI5_WATCH_MODE=off, next to the existing polling/native guidance. It states what "off" removes (rebuilding on change and live reload), what it keeps (building resources on demand), and that sources and configuration must not change while the server runs, or it can return an inconsistent result until restarted. Keep the fileWatcher facade description in the incremental-build skill reference in sync: the "off" mode, the inert subscription it returns, and that the memoized decision is now a mode string behind shouldUsePolling() and isWatchingDisabled(). --- .claude/skills/incremental-build/architecture.md | 2 +- internal/documentation/docs/pages/Troubleshooting.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.claude/skills/incremental-build/architecture.md b/.claude/skills/incremental-build/architecture.md index 2c8d0b1af73..66e1d788eff 100644 --- a/.claude/skills/incremental-build/architecture.md +++ b/.claude/skills/incremental-build/architecture.md @@ -44,7 +44,7 @@ Use this table to locate source files. ALWAYS read the relevant source file befo | `RecoveryBudget` | `lib/build/helpers/RecoveryBudget.js` | Sliding-window loop protection for watcher recovery (`WATCHER_RECOVERY_MAX_ATTEMPTS` = 5 within `WATCHER_RECOVERY_WINDOW_MS` = 60000). One instance per watcher, so a fault in one does not consume the other's budget | | `watchSettle` | `lib/build/helpers/watchSettle.js` | Single source of `WATCHER_BURST_SETTLE_MS` = 550 ms, shared by every `@parcel/watcher` consumer (sized above the watcher's 500 ms coalescing cap) | | `drainSubscriptions` | `lib/build/helpers/watchSubscriptions.js` | Unsubscribes a list of subscriptions in parallel (`Promise.allSettled`), returns the failures. Used by both watchers' `destroy()` and BuildServer's recovery re-subscribe | -| `fileWatcher` | `lib/build/helpers/fileWatcher.js` | Watcher-backend facade. Exposes a `subscribe()` matching `@parcel/watcher`'s contract and picks a backend once per process: `UI5_WATCH_MODE=polling\|native` forces the choice, otherwise it auto-detects containers (`/.dockerenv`, `/run/.containerenv`, PID 1 cgroup) and uses polling there. Also falls back to polling if the native `@parcel/watcher` binding cannot load. All three watchers subscribe through this facade rather than importing `@parcel/watcher` directly | +| `fileWatcher` | `lib/build/helpers/fileWatcher.js` | Watcher-backend facade. Exposes a `subscribe()` matching `@parcel/watcher`'s contract and picks a backend once per process: `UI5_WATCH_MODE=polling\|native` forces the choice, otherwise it auto-detects containers (`/.dockerenv`, `/run/.containerenv`, PID 1 cgroup) and uses polling there. Also falls back to polling if the native `@parcel/watcher` binding cannot load. `UI5_WATCH_MODE=off` disables watching entirely: `subscribe()` returns an inert subscription (callback never invoked, `unsubscribe` a no-op) so no backend loads, for CI and other environments where sources do not change while the server runs. The memoized decision is a mode string exposed via `shouldUsePolling()` and `isWatchingDisabled()`. All three watchers subscribe through this facade rather than importing `@parcel/watcher` directly | | `pollingWatcher` | `lib/build/helpers/pollingWatcher.js` | Pure-JS polling backend. Walks the tree and diffs an `{mtimeMs, size}` snapshot every 250 ms (`DEFAULT_POLL_INTERVAL_MS`), emitting the same `{type, path}` events as the native backend. Needed on bind-mounted container volumes where inotify misses writes made from outside the container | | `TaskRunner` | `lib/build/TaskRunner.js` | Task composition, execution loop, abort handling | | `Cache` enum | `lib/build/cache/Cache.js` | Cache mode constants: `Default`, `Force`, `ReadOnly`, `Off` (CLI `--cache` option) | diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index eda8d356299..c0f6f4f59e6 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -126,6 +126,18 @@ If you encounter this problem in your container-based development setup, try set 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. ::: +### Disabling File Watching in CI + +`ui5 serve` watches the project's files and rebuilds them on change. In CI and other environments where the sources do not change while the server runs, watching might cause unnecessary CPU usage, especially if the "polling" file watcher is used. + +Set the environment variable `UI5_WATCH_MODE` to `off` in such cases, in order to disable file watching. + +```sh +UI5_WATCH_MODE=off ui5 serve +``` + +Note that this will also disable the live reload functionality of the server, since that relies on the file watching functionality. + ### 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.