Skip to content
Merged
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
12 changes: 12 additions & 0 deletions internal/documentation/docs/pages/Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/project/lib/build/helpers/WatchHandler.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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;
Expand Down
146 changes: 146 additions & 0 deletions packages/project/lib/build/helpers/fileWatcher.js
Original file line number Diff line number Diff line change
@@ -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 <code>@parcel/watcher</code> directly. It
* selects a watcher backend once per process and exposes a <code>subscribe</code> matching
* <code>@parcel/watcher</code>'s exact signature and return contract, so a caller cannot tell which
* backend is active.
*
* The native backend is <code>@parcel/watcher</code>. 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 <code>pollingWatcher.js</code> 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. <code>@parcel/watcher</code> 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. <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.
*
* @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 <code>dir</code>, matching
* <code>@parcel/watcher</code>'s <code>subscribe</code> signature and return contract.
*
* @param {string} dir Directory to watch
* @param {Function} callback Invoked as <code>(err, events)</code>, events being
* <code>{type: "create"|"update"|"delete", path: string}</code>
* @param {object} [opts]
* @param {string[]} [opts.ignore] Path/glob patterns to ignore, matched relative to <code>dir</code>
* (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;
}
174 changes: 174 additions & 0 deletions packages/project/lib/build/helpers/pollingWatcher.js
Original file line number Diff line number Diff line change
@@ -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 <code>fileWatcher.js</code>, used where the native <code>@parcel/watcher</code>
* 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 <code>{path -> {mtimeMs, size}}</code> snapshot, and emits the
* same <code>{type, path}</code> events through the same <code>(err, events)</code> callback and
* <code>{unsubscribe()}</code> contract as the native backend.
*
* <code>fileWatcher.js</code> 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 <code>dir</code> by polling, matching
* <code>@parcel/watcher</code>'s <code>subscribe</code> signature and return contract.
*
* @param {string} dir Directory to watch
* @param {Function} callback Invoked as <code>(err, events)</code>, events being
* <code>{type: "create"|"update"|"delete", path: string}</code>
* @param {object} [opts]
* @param {string[]} [opts.ignore] Path/glob patterns to ignore, matched relative to <code>dir</code>
* (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<absPath, {mtimeMs, size}> 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;
}
6 changes: 3 additions & 3 deletions packages/project/lib/graph/ProjectDefinitionWatcher.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
* <code>node_modules</code>/<code>.git</code> ignore globs only reduce OS-level watch load;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading