Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/incremental-build/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 where sources don't change. 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) |
Expand Down
14 changes: 14 additions & 0 deletions internal/documentation/docs/pages/Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ 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 on change. In CI and other environments where the sources do not change while the server runs, that watching is pure overhead. The polling watcher is the worst case: it walks the source tree on an interval, and it is the default inside containers, where CI commonly runs.

Set `UI5_WATCH_MODE` to `off` to disable file watching entirely. Source changes will not trigger rebuilds or live reload, and neither the native nor the polling watcher is started.

```sh
UI5_WATCH_MODE=off ui5 serve
```

Resources are still built on demand, so a request for a not-yet-built resource still returns it. Only rebuild-on-change is removed. The contract is "build once, serve frozen": do not edit sources or configuration while the server runs in `off` mode. Such edits are not picked up and can leave the server serving an inconsistent result until it is restarted.

Live reload cannot work without a watcher, so `off` disables it regardless of how it was enabled (the default, `--live-reload`, or the `server.settings.liveReload` setting). When live reload was enabled, `ui5 serve` logs that it has been disabled.

### 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.
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/lib/cli/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import process from "node:process";
import baseMiddleware from "../middlewares/base.js";
import {applyProjectConfigOptions, applyWorkspaceOptions, applyBuildOptions, dedupeArray} from "../options.js";
import {getUi5DataDirOrDefault, resolveServerCertificatePaths, formatPath} from "../../dataDir.js";
import {isWatchingDisabled} from "@ui5/project/internal/build/helpers/fileWatcher";
import {getLogger} from "@ui5/logger";
const log = getLogger("cli:commands:serve");

Expand Down Expand Up @@ -208,6 +209,23 @@ serve.handler = async function(argv) {
}
}

if (isWatchingDisabled()) {
// UI5_WATCH_MODE=off: no watcher runs, so the server builds resources on demand and then
// serves that state frozen. 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 (UI5_WATCH_MODE=off). Resources are still built on demand, ` +
`but changes to sources or configuration are not picked up. Do not edit them while the ` +
`server runs.`);
if (liveReload) {
// Live reload needs a watcher to learn when to push. With watching off it can never fire,
// so disable it regardless of how it was enabled (default, --live-reload, or
// server.settings.liveReload) and tell the user why.
log.info(`Live reload is disabled because it requires file watching (UI5_WATCH_MODE=off).`);
liveReload = false;
}
}

const serverConfig = {
port,
changePortIfInUse,
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/test/lib/cli/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand All @@ -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": {
Expand Down Expand Up @@ -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;

Expand Down
55 changes: 44 additions & 11 deletions packages/project/lib/build/helpers/fileWatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <code>UI5_WATCH_MODE=off</code> 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 and
* the watching overhead (especially the polling backend's tree walk on container volumes) is pure
* cost.
*
* @private
* @module @ui5/project/build/helpers/fileWatcher
*/
Expand All @@ -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
Expand All @@ -44,25 +51,43 @@ let usePolling = null;
let nativeBackendPromise = null;

/**
* Decides whether to poll, once per process. <code>UI5_WATCH_MODE=polling|native</code> 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. <code>UI5_WATCH_MODE=off|polling|native</code>
* 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 (<code>UI5_WATCH_MODE=off</code>)
*/
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`);
Expand All @@ -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
Expand All @@ -98,7 +123,9 @@ function isRunningInContainer() {

/**
* Subscribes to filesystem changes below <code>dir</code>, matching
* <code>@parcel/watcher</code>'s <code>subscribe</code> signature and return contract.
* <code>@parcel/watcher</code>'s <code>subscribe</code> signature and return contract. When watching
* is disabled (<code>UI5_WATCH_MODE=off</code>), the returned subscription is inert: its callback is
* never invoked and <code>unsubscribe</code> is a no-op.
*
* @param {string} dir Directory to watch
* @param {Function} callback Invoked as <code>(err, events)</code>, events being
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/project/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions packages/project/test/lib/build/helpers/fileWatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading