diff --git a/.claude/skills/incremental-build/architecture.md b/.claude/skills/incremental-build/architecture.md
index fc9d686b027..7050f0341a4 100644
--- a/.claude/skills/incremental-build/architecture.md
+++ b/.claude/skills/incremental-build/architecture.md
@@ -38,7 +38,12 @@ Use this table to locate source files. ALWAYS read the relevant source file befo
| `BuildContext` | `lib/build/helpers/BuildContext.js` | Global build config, project context cache |
| `getBuildSignature` | `lib/build/helpers/getBuildSignature.js` | Build signature computation: `BUILD_SIG_VERSION` + build config + project config |
| `ProjectBuildContext` | `lib/build/helpers/ProjectBuildContext.js` | Per-project bridge between builder, tasks, and cache |
-| `WatchHandler` | `lib/build/helpers/WatchHandler.js` | `@parcel/watcher`-based source path watcher; emits change events to BuildServer. The watcher coalesces its own events with a 50 ms min / 500 ms max wait, so a continuous operation is delivered as batches up to 500 ms apart |
+| `WatchHandler` | `lib/build/helpers/WatchHandler.js` | `@parcel/watcher`-based source path watcher; emits `change` events to BuildServer. The watcher coalesces its own events with a 50 ms min / 500 ms max wait, so a continuous operation is delivered as batches up to 500 ms apart. Survives a `git checkout` moving source paths: drops events whose path no longer maps (`getVirtualPath` throws) and skips a path that vanished before `subscribe` resolved, instead of escalating to a fatal error. The `ProjectDefinitionWatcher` then re-inits over the new graph |
+| `ProjectDefinitionWatcher` | `lib/graph/ProjectDefinitionWatcher.js` | `@parcel/watcher`-based watcher for project-definition files (`ui5.yaml` / `--config`, `package.json`, workspace config, static dependency-definition file). Emits `definitionChanging` (leading) and `definitionChanged` (trailing, coalesced) to drive a full serving-stack re-init. A watched definition-file event starts the burst; while the burst is open, every delivered event below the subscribed definition directories resets the settle timer. Project roots below `node_modules` are watched without the `node_modules` ignore so their own definition files remain observable. Owned by `@ui5/server`'s `Supervisor`, not the BuildServer; exported via the `@ui5/project/graph/ProjectDefinitionWatcher` subpath |
+| `ProjectGraphSettler` | `lib/graph/ProjectGraphSettler.js` | Short-lived `@parcel/watcher`-based acceptance gate for degraded server recovery. Given one or more resolved graphs, watches the union of their project roots without a `node_modules` ignore and resolves once those roots are quiet for `WATCHER_BURST_SETTLE_MS`. Missing roots are watched at their nearest existing ancestor so a project that is still being restored is observable. `Supervisor` drives it inside a convergence loop (`#convergeRecoveryGraph`), feeding it each re-resolved graph plus the last-good graph so a root that only the target branch introduces is observed once it surfaces in a resolve |
+| `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 |
| `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) |
| `ProjectBuildCache` | `lib/build/cache/ProjectBuildCache.js` | Cache orchestration per project: index management, stage lookup, result recording |
@@ -95,11 +100,15 @@ When a source file changes:
2. `_projectResourceChanged()` walks `traverseDependents()` and calls `ProjectBuildStatus.invalidate({reason, fileAddedOrRemoved})` on the affected project and every dependent. Change is queued in `#resourceChangeQueue`
3. `invalidate()` clears any latched error (lifting the ERRORED gate), aborts the running build via `AbortSignal`, and rotates the `AbortController`
4. `fileAddedOrRemoved=true` (create/delete events) additionally evicts the cached reader on the status. Pure modifies keep the reader so callers already holding its promise still resolve
-5. The build loop catches `AbortBuildError`, distinguishes abort from concurrent-change failure, and re-enqueues projects that aren't fresh. Both the source-change-aborted build and a build that *failed* while sources were still changing (the transient branch, `signal.aborted || #resourceChangeQueue.size > 0`) defer their restart until changes settle (`ABORTED_BUILD_RESTART_SETTLE_MS` = 550 ms, reset by each further change) rather than firing on the snappy request debounce — a burst delivered as multiple watcher batches then collapses into one rebuild against the settled tree instead of a build-abort cycle per batch. Both report `SETTLING` for the window's duration: the doomed build no longer parks the banner on `building` (abort) or flips it to `error` (transient failure); it reports "waiting for changes to settle" and retries once the tree is quiet. A reader request supersedes the deferred restart by enqueueing on the normal `BUILD_REQUEST_DEBOUNCE_MS` (10 ms), so serving a request is not delayed. A genuine, non-transient failure still latches ERRORED.
+5. The build loop catches `AbortBuildError` and re-enqueues projects that aren't fresh. Two branches defer their restart instead of firing on the request debounce: the source-change-aborted build, and a build that *failed* while sources were still changing (the transient branch, `signal.aborted || #resourceChangeQueue.size > 0`). The restart waits `ABORTED_BUILD_RESTART_SETTLE_MS` (= `WATCHER_BURST_SETTLE_MS` = 550 ms) of quiet, reset by each further change, so a multi-batch burst collapses into one rebuild against the settled tree. The deferral arms the queue timer and sets `#pendingDeferredRestart`. Both branches report `SETTLING` for the window (they no longer park the banner on `building` or flip it to `error`). A genuine, non-transient failure still latches ERRORED.
+
+ While `#pendingDeferredRestart` holds, a reader request does *not* supersede the window: `#enqueueBuild` queues the project and returns without re-arming, and the request resolves when the deferred rebuild runs. Pulling the restart forward would build into a still-arriving burst; resetting it per request would let live-reload traffic defer the rebuild indefinitely. Only source changes reset the window.
6. The first speculative build after a source change from a quiet state is held for a short first-build window (`FIRST_BUILD_SETTLE_MS` = 100 ms, also reported as `SETTLING`) rather than the snappy debounce — this absorbs an editor's own multi-file save fan-out (100 ms sits far below the watcher's 500 ms coalescing cap, roughly at its 50 ms floor) so a save-all doesn't fire a build into a half-written tree. It applies only to a build that is already pending (a reader request queued but not yet started); laziness is preserved — with nothing queued, a change still waits for a reader request. On its own it does not cover a multi-second `git checkout`; full coverage of that comes from the transient-failure deferral above.
7. Queued resource changes are flushed via `#flushResourceChanges()` before the next build starts (must happen before `projectBuilder.build`)
-The server also emits a `sourcesChanged` event to drive live-reload notifications. Emission is **leading-edge**: the first change of a quiet period notifies immediately (a lone edit reaches clients at the watcher's own ~50 ms latency floor with no debounce added), and a trailing settle window (`SOURCES_CHANGED_SETTLE_MS` = 550 ms, above the watcher's 500 ms cap) coalesces the remainder of a burst into one further emit. Because emission is leading-edge, the window size does not affect single-edit latency — it only controls burst coalescing.
+The server also emits a `sourcesChanged` event to drive live-reload notifications. Emission is **leading-edge**: the first change of a quiet period notifies immediately (a lone edit reaches clients at the watcher's own ~50 ms latency floor with no debounce added), and a trailing settle window (`SOURCES_CHANGED_SETTLE_MS` = `WATCHER_BURST_SETTLE_MS` = 550 ms, above the watcher's 500 ms cap) coalesces the remainder of a burst into one further emit. Because emission is leading-edge, the window size does not affect single-edit latency: it only controls burst coalescing.
+
+The three source-watcher settle windows (`SOURCES_CHANGED_SETTLE_MS`, `ABORTED_BUILD_RESTART_SETTLE_MS`, and the ProjectDefinitionWatcher's `DEFINITION_CHANGED_SETTLE_MS`) all resolve to `WATCHER_BURST_SETTLE_MS` in `watchSettle.js`, sized above `@parcel/watcher`'s 500 ms `MAX_WAIT_TIME` so each batch resets the window rather than terminating it. `FIRST_BUILD_SETTLE_MS` (100 ms) is deliberately separate: it absorbs an editor's save fan-out, not a multi-batch operation.
### State Machine (per project)
@@ -186,6 +195,33 @@ After a build cycle ends with some projects still in INITIAL (e.g. dependencies
A build request preempts an in-flight pass: `#triggerRequestQueue` awaits `#stopActiveValidation` before claiming the builder's `buildIsRunning` lock. The pass's `finally` re-invokes `#reconcileServerState({mayValidate: false})` — `mayValidate=false` prevents stack recursion into another validation pass over the projects the previous one just released.
+## Two Watchers: Source vs. Definition
+
+Two independent `@parcel/watcher` consumers feed different pipelines:
+
+- **Source watcher** (`WatchHandler`, owned by the `BuildServer`): watches source paths, emits `change` events that drive incremental rebuilds *inside* the BuildServer (the File Watch and Abort flow above).
+- **Definition watcher** (`ProjectDefinitionWatcher`, owned by `@ui5/server`'s `Supervisor`): watches project-definition files, drives a full re-init of the serving stack *above* the BuildServer. A definition change (topology, config) requires re-resolving the graph, which no incremental rebuild can do, so it re-creates the graph + Express app + BuildServer behind the stable `http.Server`.
+
+The split is why the source watcher tolerates a `git checkout` moving paths under it: the definition watcher owns the re-init that re-targets it at the new graph, so the source watcher only has to survive the churn.
+
+Both watchers share `RecoveryBudget` (loop protection, one budget each), `drainSubscriptions` (parallel unsubscribe), and `WATCHER_BURST_SETTLE_MS`.
+
+### ProjectDefinitionWatcher
+
+`ProjectDefinitionWatcher extends EventEmitter`, modeled on `WatchHandler`. Documented here because it shares the watch helpers and settle discipline, though it is owned by `@ui5/server`.
+
+- **Watch set** (`#resolveWatchSet`): traverses `graph.traverseBreadthFirst()` collecting each `project.getRootPath()`. Per project it watches `package.json` always, plus `ui5.yaml` (except the root when a custom `rootConfigPath` (`--config`) is given, which is watched instead and may live outside the root). Adds `workspaceConfigPath` (default `ui5-workspace.yaml` at cwd) when set, and `dependencyDefinitionPath` in `--dependency-definition` mode, where that file is itself a topology definition. Each distinct definition-file directory is subscribed directly.
+- **Include-based model**: only paths in `#watchedFiles` can start a definition-change burst. Once a burst has started, every delivered event below the subscribed definition directories resets the trailing settle timer. Regular project roots keep the `node_modules`/`.git` ignore globs to reduce long-lived watch load; project roots below `node_modules` omit the `node_modules` ignore so their own `ui5.yaml` and `package.json` changes remain observable. Correctness comes from the include set; broad checkout quietness after a degraded graph is handled by `ProjectGraphSettler`.
+- **Events**:
+ - `definitionChanging` on the leading edge (first watched definition event), used to placeholder the project version during re-resolution.
+ - `definitionChanged` on the trailing edge after `DEFINITION_CHANGED_SETTLE_MS` (= `WATCHER_BURST_SETTLE_MS`) of quiet across delivered events below the watched roots, coalescing a `git checkout` burst into a single re-init. Trailing-only: re-creating the stack on the first byte of a checkout would be wasted.
+- **Recovery** (`#recoverWatcher`): mirrors `BuildServer.#recoverWatcher`. A synchronous re-entrancy guard collapses parcel's per-path error storm into one recovery, `RecoveryBudget` caps attempts, exhaustion escalates to a terminal `error`. The include set is unchanged; only OS-level handles are renewed.
+- **`destroy()`**: idempotent (drains `#subscriptions` to `[]` first), aggregates unsubscribe failures into an `AggregateError` emitted as `error`.
+
+The supervisor owns the watcher because it outlives individual BuildServer instances (destroyed on every swap) and is re-targeted over the new graph after each swap. See `@ui5/server`'s `Supervisor` for the re-init/swap wiring.
+
+On a failed re-resolve `Supervisor` flags the surviving stack degraded (last-good keeps serving) and self-schedules one bounded recovery after `DEFINITION_CHANGED_SETTLE_MS`. Recovery runs a convergence loop (`#convergeRecoveryGraph`): resolve the graph, compare its project-root set to the previous iteration, and re-resolve after a `ProjectGraphSettler` quiet window until two consecutive resolves agree on the roots (a subset check, so a dependency the target branch removes still converges). Each iteration feeds the settler the just-resolved graph plus the last-good graph, so a checkout that restores a project's `package.json` before its sources, or introduces a target-only dependency root neither prior graph knew, is observed for quietness once it surfaces in a resolve rather than swapped in half-restored. This subsumes the earlier transient/deterministic distinction: a checkout race and a genuinely broken branch both fail the resolve, and both are handled by looping and settling instead of string-matching the error message. `RecoveryBudget` (5 attempts / 60s) bounds the self-scheduled recoveries against a persistently broken branch; unlike the watchers, an attempt is recorded when a recovery is scheduled (not on success), so a branch that never resolves exhausts the budget and stays degraded until the next definition change. A `definitionChanging` (real user action) clears any pending recovery and resets the budget; a clean swap resets it too.
+
## Caching Architecture
### Cache Layers
@@ -452,7 +488,7 @@ Stage metadata stored on disk includes:
## Key Architectural Patterns
1. **Lazy building**: Projects built on-demand when readers are requested
-2. **Request batching**: Multiple pending build requests processed in single batch (`BUILD_REQUEST_DEBOUNCE_MS` = 10ms debounce). A source-change-driven first build is held on a short settle window (`FIRST_BUILD_SETTLE_MS` = 100ms) to absorb editor save fan-out, and a source-change-aborted or transiently-failed build restarts on a longer window (`ABORTED_BUILD_RESTART_SETTLE_MS` = 550ms) so a burst collapses into one rebuild. Both windows report the `SETTLING` state; a reader request supersedes them at the snappy 10ms debounce.
+2. **Request batching**: Multiple pending build requests processed in single batch (`BUILD_REQUEST_DEBOUNCE_MS` = 10ms debounce). A source-change-driven first build is held on `FIRST_BUILD_SETTLE_MS` = 100ms to absorb editor save fan-out; a source-change-aborted or transiently-failed build restarts on `ABORTED_BUILD_RESTART_SETTLE_MS` (= `WATCHER_BURST_SETTLE_MS` = 550ms) so a burst collapses into one rebuild. Both windows report `SETTLING`. A reader request supersedes the first-build window at the 10ms debounce, but not the deferred post-abort/transient restart (`#pendingDeferredRestart`): the queued request waits for the deferred rebuild.
3. **Abort/retry**: File changes abort running builds; projects re-queued automatically
4. **Structural sharing**: Derived hash trees share unchanged subtrees, reducing memory
5. **Content-addressed storage**: Resources deduplicated via integrity hashes in custom CAS (synchronous path resolution, gzip-compressed)
diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js
index e70924bc53f..77dc3c8b3ba 100644
--- a/packages/cli/lib/cli/commands/serve.js
+++ b/packages/cli/lib/cli/commands/serve.js
@@ -147,23 +147,36 @@ serve.handler = async function(argv) {
const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph");
- let graph;
- if (argv.dependencyDefinition) {
- graph = await graphFromStaticFile({
- filePath: argv.dependencyDefinition,
- rootConfigPath: argv.config,
- versionOverride: argv.frameworkVersion,
- snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
- });
- } else {
- graph = await graphFromPackageDependencies({
- rootConfigPath: argv.config,
- versionOverride: argv.frameworkVersion,
- snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
- workspaceConfigPath: argv.workspaceConfig,
- workspaceName: argv.workspace === false ? null : argv.workspace,
- });
- }
+ // Workspace resolution is active unless static-graph mode is used or --workspace is disabled.
+ // Single source for both the graph resolve (below) and the watcher config, so the two cannot
+ // drift on whether workspace resolution runs.
+ const workspaceActive = !argv.dependencyDefinition && argv.workspace !== false;
+
+ // One graph-construction site, reused for the initial graph and every re-init the server
+ // performs on a project-definition change. Captures argv so the server can re-resolve with
+ // identical parameters.
+ const buildGraph = async () => {
+ if (argv.dependencyDefinition) {
+ return graphFromStaticFile({
+ filePath: argv.dependencyDefinition,
+ rootConfigPath: argv.config,
+ versionOverride: argv.frameworkVersion,
+ snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
+ });
+ } else {
+ return graphFromPackageDependencies({
+ rootConfigPath: argv.config,
+ versionOverride: argv.frameworkVersion,
+ snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
+ workspaceConfigPath: argv.workspaceConfig,
+ workspaceName: workspaceActive ? argv.workspace : null,
+ });
+ }
+ };
+
+ // Build the initial graph up front: its root server settings resolve the port and
+ // live-reload defaults below, before the server binds.
+ const graph = await buildGraph();
let port = argv.port;
let changePortIfInUse = false;
@@ -210,6 +223,11 @@ serve.handler = async function(argv) {
cache: argv.cache,
includedTasks: argv["include-task"],
excludedTasks: argv["exclude-task"],
+ // Threaded to the definition watcher so it watches the same files buildGraph resolves from.
+ // null selects the watcher's default ui5-workspace.yaml, undefined disables workspace watching.
+ rootConfigPath: argv.config,
+ workspaceConfigPath: workspaceActive ? (argv.workspaceConfig ?? null) : undefined,
+ dependencyDefinitionPath: argv.dependencyDefinition,
};
if (serverConfig.h2) {
@@ -221,9 +239,11 @@ serve.handler = async function(argv) {
const {promise: pOnError, reject} = Promise.withResolvers();
const {serve: serverServe} = await import("@ui5/server");
+ // Pass buildGraph as the graphFactory so the server can re-resolve the graph and re-create
+ // the serving stack when its definition watcher observes a project-definition change.
const {h2, port: actualPort} = await serverServe(graph, serverConfig, function(err) {
reject(err);
- });
+ }, {graphFactory: buildGraph});
if (argv.open !== undefined) {
const protocol = h2 ? "https" : "http";
diff --git a/packages/cli/test/lib/cli/commands/serve.js b/packages/cli/test/lib/cli/commands/serve.js
index ffab26787f0..bcf35f7f263 100644
--- a/packages/cli/test/lib/cli/commands/serve.js
+++ b/packages/cli/test/lib/cli/commands/serve.js
@@ -125,9 +125,21 @@ test.serial("ui5 serve: default", async (t) => {
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
+ rootConfigPath: undefined,
+ workspaceConfigPath: null,
+ dependencyDefinitionPath: undefined,
}
]);
t.is(typeof server.serve.getCall(0).args[2], "function");
+
+ // The 4th argument carries a graphFactory the server can call to re-resolve the graph on a
+ // project-definition change. It must produce the same graph via the same builder + args.
+ const options = server.serve.getCall(0).args[3];
+ t.is(typeof options.graphFactory, "function");
+ await options.graphFactory();
+ t.is(graph.graphFromPackageDependencies.callCount, 2, "graphFactory re-invokes the same builder");
+ t.deepEqual(graph.graphFromPackageDependencies.getCall(1).args, graph.graphFromPackageDependencies.getCall(0).args,
+ "graphFactory re-resolves with identical parameters");
});
test.serial("ui5 serve --h2", async (t) => {
@@ -169,6 +181,9 @@ test.serial("ui5 serve --h2", async (t) => {
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
+ rootConfigPath: undefined,
+ workspaceConfigPath: null,
+ dependencyDefinitionPath: undefined,
}
]);
@@ -204,6 +219,9 @@ test.serial("ui5 serve --accept-remote-connections", async (t) => {
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
+ rootConfigPath: undefined,
+ workspaceConfigPath: null,
+ dependencyDefinitionPath: undefined,
}
]);
});
diff --git a/packages/logger/lib/writers/Console.js b/packages/logger/lib/writers/Console.js
index 8f308704539..2f101a6c86e 100644
--- a/packages/logger/lib/writers/Console.js
+++ b/packages/logger/lib/writers/Console.js
@@ -113,7 +113,7 @@ class Console {
process.on("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent);
process.on("ui5.build-status", this._handleBuildStatusEvent);
process.on("ui5.project-build-status", this._handleProjectBuildStatusEvent);
- process.on("ui5.project-resolved", this._handleProjectResolvedEvent);
+ process.on("ui5.project-resolve-succeeded", this._handleProjectResolvedEvent);
process.on("ui5.server-listening", this._handleServerListeningEvent);
process.on("ui5.log.stop-console", this._handleStop);
}
@@ -129,7 +129,7 @@ class Console {
process.off("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent);
process.off("ui5.build-status", this._handleBuildStatusEvent);
process.off("ui5.project-build-status", this._handleProjectBuildStatusEvent);
- process.off("ui5.project-resolved", this._handleProjectResolvedEvent);
+ process.off("ui5.project-resolve-succeeded", this._handleProjectResolvedEvent);
process.off("ui5.server-listening", this._handleServerListeningEvent);
process.off("ui5.log.stop-console", this._handleStop);
if (this.#progressBarContainer) {
diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js
index b0f35c737e8..45ad4fc56d9 100644
--- a/packages/logger/lib/writers/InteractiveConsole.js
+++ b/packages/logger/lib/writers/InteractiveConsole.js
@@ -9,11 +9,13 @@ import {
setProject,
setFramework,
enableProjectPlaceholders,
+ setVersionResolving,
+ clearVersionResolving,
} from "./interactiveConsole/state/project.js";
import {createServerState, setListening, enableServerPlaceholders} from "./interactiveConsole/state/server.js";
import {
createBuildState, beginBuild, advanceToProject, setTask, transitionTo, setError, setStale, STATES,
- SPINNING_STATES, enableBuildPlaceholders,
+ SPINNING_STATES, enableBuildPlaceholders, setDegraded, clearDegraded,
} from "./interactiveConsole/state/build.js";
import {
renderHeaderRegion, renderProjectRegion, renderServerRegion, renderBuildRegion,
@@ -97,8 +99,6 @@ class InteractiveConsole {
stderr: {orig: null, partial: ""},
};
- #seenProjectResolved = false;
-
// Bound listeners so we can `process.off` them on stop().
#onLog;
#onBuildMetadata;
@@ -109,6 +109,8 @@ class InteractiveConsole {
#onToolMode;
#onProjectResolved;
#onProjectFrameworkResolved;
+ #onProjectResolving;
+ #onProjectResolveFailed;
#onServerListening;
#onStopConsole;
#onResize;
@@ -299,6 +301,8 @@ class InteractiveConsole {
this.#onToolMode = (evt) => this.#handleToolMode(evt);
this.#onProjectResolved = (evt) => this.#handleProjectResolved(evt);
this.#onProjectFrameworkResolved = (evt) => this.#handleProjectFrameworkResolved(evt);
+ this.#onProjectResolving = () => this.#handleProjectResolving();
+ this.#onProjectResolveFailed = (evt) => this.#handleProjectResolveFailed(evt);
this.#onServerListening = (evt) => this.#handleServerListening(evt);
this.#onStopConsole = () => this.disable();
this.#onResize = () => this.#handleResize();
@@ -310,8 +314,10 @@ class InteractiveConsole {
process.on("ui5.serve-status", this.#onServeStatus);
process.on("ui5.tool-info", this.#onToolInfo);
process.on("ui5.tool-mode", this.#onToolMode);
- process.on("ui5.project-resolved", this.#onProjectResolved);
+ process.on("ui5.project-resolve-succeeded", this.#onProjectResolved);
process.on("ui5.project-framework-resolved", this.#onProjectFrameworkResolved);
+ process.on("ui5.project-resolve-started", this.#onProjectResolving);
+ process.on("ui5.project-resolve-failed", this.#onProjectResolveFailed);
process.on("ui5.server-listening", this.#onServerListening);
process.on("ui5.log.stop-console", this.#onStopConsole);
if (typeof this.#stderr.on === "function") {
@@ -327,8 +333,10 @@ class InteractiveConsole {
process.off("ui5.serve-status", this.#onServeStatus);
process.off("ui5.tool-info", this.#onToolInfo);
process.off("ui5.tool-mode", this.#onToolMode);
- process.off("ui5.project-resolved", this.#onProjectResolved);
+ process.off("ui5.project-resolve-succeeded", this.#onProjectResolved);
process.off("ui5.project-framework-resolved", this.#onProjectFrameworkResolved);
+ process.off("ui5.project-resolve-started", this.#onProjectResolving);
+ process.off("ui5.project-resolve-failed", this.#onProjectResolveFailed);
process.off("ui5.server-listening", this.#onServerListening);
process.off("ui5.log.stop-console", this.#onStopConsole);
if (typeof this.#stderr.off === "function") {
@@ -369,16 +377,15 @@ class InteractiveConsole {
}
#handleProjectResolved(evt) {
- if (this.#seenProjectResolved) {
- // The writer's model is single-root-project. A second
- // `ui5.project-resolved` event means the emitter violated that
- // invariant, making subsequent event attribution ambiguous, so fail
- // fast instead of trying to deduplicate.
- throw new Error(
- `writers/InteractiveConsole: Received duplicate ui5.project-resolved event`);
- }
- this.#seenProjectResolved = true;
+ // The writer's model is single-root-project, but the root can be resolved more
+ // than once in a process: `Supervisor.reinitialize()` re-resolves the graph
+ // on a project-definition change (a `git checkout`, a hand-edit of ui5.yaml), and
+ // re-emits this event for the same root. A repeat updates the project region in
+ // place; framework data is updated through `ui5.project-framework-resolved`.
setProject(this.#projectState, evt);
+ // A completed re-resolve lifts any degraded state a prior failed re-resolve left sticky, so
+ // the next `serve-ready` from the fresh stack renders "ready" normally again.
+ clearDegraded(this.#buildState);
this.#render();
}
@@ -387,6 +394,25 @@ class InteractiveConsole {
this.#render();
}
+ // A definition change is coming: blank the version slot(s) to a "resolving…" placeholder. A
+ // completed resolve arrives as `ui5.project-resolve-succeeded` and repopulates them via
+ // #handleProjectResolved; an abandoned/failed resolve arrives as `ui5.project-resolve-failed`.
+ #handleProjectResolving() {
+ setVersionResolving(this.#projectState);
+ this.#render();
+ }
+
+ // A re-resolve was abandoned or failed without a completing `ui5.project-resolve-succeeded`: release the
+ // placeholder back to the last-known version rather than leaving it on "resolving…". A failed
+ // re-resolve also means the server is now serving a last-good graph that no longer matches disk;
+ // mark it degraded and surface the reason on the (sticky) ERROR status line.
+ #handleProjectResolveFailed(evt) {
+ clearVersionResolving(this.#projectState);
+ setDegraded(this.#buildState);
+ setError(this.#buildState, evt?.message);
+ this.#render();
+ }
+
#handleServerListening(evt) {
setListening(this.#serverState, evt);
this.#render();
@@ -417,6 +443,14 @@ class InteractiveConsole {
if (evt.status !== "serve-validating") {
this.#buildState.validatingProjects = [];
}
+ // While degraded (a re-resolve failed and the last-good stack keeps serving), the surviving
+ // BuildServer still resettles to IDLE/stale on the branch switch's source churn and would
+ // otherwise repaint "ready" over the degraded ERROR line. Suppress those transitions until a
+ // successful re-resolve clears the flag (via `ui5.project-resolve-succeeded`). A genuine
+ // `serve-error` still passes through: it keeps the line on ERROR either way.
+ if (this.#buildState.degraded && evt.status !== "serve-error") {
+ return;
+ }
switch (evt.status) {
case "serve-ready":
this.#transitionTo(STATES.READY);
diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js
index 2ee193e803d..2a9b90c804e 100644
--- a/packages/logger/lib/writers/interactiveConsole/render.js
+++ b/packages/logger/lib/writers/interactiveConsole/render.js
@@ -45,8 +45,14 @@ export function renderProjectRegion(projectState) {
if (projectState.project) {
const project = projectState.project;
const framework = projectState.framework;
+ const resolving = projectState.versionResolving;
const projectType = project.type ? chalk.dim(`(${project.type})`) : "";
- const projectVersion = project.version ? chalk.dim("v" + project.version) : "";
+ // While a re-resolve is pending, the version is about to change: show a placeholder in
+ // its slot rather than the stale value. The name and type keep showing so the region
+ // does not collapse.
+ const projectVersion = resolving ?
+ placeholder("resolving…") :
+ (project.version ? chalk.dim("v" + project.version) : "");
lines.push(`${chalk.dim("Project")} ${chalk.bold(project.name)}` +
(projectType ? ` ${projectType}` : "") +
(projectVersion ? ` ${projectVersion}` : ""));
@@ -56,8 +62,12 @@ export function renderProjectRegion(projectState) {
// making "(none)" misleading; omitting the row also avoids a stale
// "Framework resolving…" placeholder flashing before it disappears.
if (framework?.name) {
- const frameworkVersion = framework.version ? ` ${framework.version}` : "";
- lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name + frameworkVersion)}`);
+ // The framework version goes stale alongside the project version on a branch
+ // switch: placeholder it too, keeping the name.
+ const frameworkVersion = resolving ?
+ ` ${placeholder("resolving…")}` :
+ (framework.version ? chalk.bold(` ${framework.version}`) : "");
+ lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name)}${frameworkVersion}`);
}
} else {
// Placeholder mode: reserve only the Project row. The Framework row is
diff --git a/packages/logger/lib/writers/interactiveConsole/state/build.js b/packages/logger/lib/writers/interactiveConsole/state/build.js
index 7a98e5f00da..a824d2b03f5 100644
--- a/packages/logger/lib/writers/interactiveConsole/state/build.js
+++ b/packages/logger/lib/writers/interactiveConsole/state/build.js
@@ -50,6 +50,12 @@ export function createBuildState() {
// Ordered list of projects announced by build-metadata. Used to compute
// a stable 1-based `currentProjectIndex` when build-status events arrive.
projectOrder: [],
+ // True while the server is serving a last-good graph after a failed re-resolve (e.g. a
+ // branch switch to a config the tooling can't resolve). The status line reuses the ERROR
+ // rendering; this flag makes it sticky so the surviving BuildServer's `serve-ready` from
+ // source churn can't repaint "ready" over it. Set by `ui5.project-resolve-failed`, cleared
+ // by `ui5.project-resolve-succeeded`.
+ degraded: false,
};
}
@@ -102,6 +108,17 @@ export function setError(state, message) {
transitionTo(state, STATES.ERROR);
}
+// Marks the server degraded after a failed re-resolve. The caller pairs this with `setError` to
+// render the reason on the ERROR line; this flag keeps that line sticky against later `serve-ready`
+// events from the surviving BuildServer until a successful re-resolve calls `clearDegraded`.
+export function setDegraded(state) {
+ state.degraded = true;
+}
+
+export function clearDegraded(state) {
+ state.degraded = false;
+}
+
// Advance the region into a "starting" placeholder state so the Status row is
// visible from the first frame. Called from `ui5.tool-mode`. Real state
// transitions (READY/BUILDING/…) replace it.
diff --git a/packages/logger/lib/writers/interactiveConsole/state/project.js b/packages/logger/lib/writers/interactiveConsole/state/project.js
index f0a58286fc9..04d0b37afb8 100644
--- a/packages/logger/lib/writers/interactiveConsole/state/project.js
+++ b/packages/logger/lib/writers/interactiveConsole/state/project.js
@@ -1,4 +1,4 @@
-// Region 2 — root project. Populated by `ui5.project-resolved` and, when
+// Region 2 — root project. Populated by `ui5.project-resolve-succeeded` and, when
// framework usage is actually resolved for the current run, by
// `ui5.project-framework-resolved`.
export function createProjectState() {
@@ -9,11 +9,19 @@ export function createProjectState() {
// of real data. Enabled by `ui5.tool-mode` before the graph is built so
// the layout is stable from the very first frame.
showPlaceholders: false,
+ // When true, the version slot(s) render a dim "resolving…" placeholder while the
+ // project name and type keep showing. Enabled by `ui5.project-resolve-started` once a
+ // definition change is known to be coming (a `git checkout`, a ui5.yaml edit) and the
+ // resolved version is about to change. `setProject` clears it; a failed re-resolve
+ // releases it via `clearVersionResolving`.
+ versionResolving: false,
};
}
export function setProject(state, evt) {
state.project = {name: evt.name, type: evt.type, version: evt.version};
+ // A resolve completed: drop the placeholder in favour of the new version.
+ state.versionResolving = false;
}
export function setFramework(state, framework) {
@@ -26,3 +34,14 @@ export function setFramework(state, framework) {
export function enableProjectPlaceholders(state) {
state.showPlaceholders = true;
}
+
+// Blanks the version slot(s) to a "resolving…" placeholder: a re-resolve is coming.
+export function setVersionResolving(state) {
+ state.versionResolving = true;
+}
+
+// Releases the placeholder back to the last-known version: a re-resolve was abandoned or failed
+// without a completing `ui5.project-resolve-succeeded`.
+export function clearVersionResolving(state) {
+ state.versionResolving = false;
+}
diff --git a/packages/logger/test/lib/writers/Console.js b/packages/logger/test/lib/writers/Console.js
index e0bf2a9473f..834cb102f4a 100644
--- a/packages/logger/test/lib/writers/Console.js
+++ b/packages/logger/test/lib/writers/Console.js
@@ -1122,7 +1122,7 @@ test.serial("Build metadata events (same project)", (t) => {
test.serial("Project resolved event renders a one-line summary", (t) => {
const {stderrWriteStub} = t.context;
- process.emit("ui5.project-resolved", {
+ process.emit("ui5.project-resolve-succeeded", {
name: "my.app",
type: "application",
version: "1.0.0",
@@ -1134,7 +1134,7 @@ test.serial("Project resolved event renders a one-line summary", (t) => {
test.serial("Project resolved event omits the type label when the type is missing", (t) => {
const {stderrWriteStub} = t.context;
- process.emit("ui5.project-resolved", {name: "my.app", version: "1.0.0"});
+ process.emit("ui5.project-resolve-succeeded", {name: "my.app", version: "1.0.0"});
t.is(stderrWriteStub.callCount, 1);
t.is(stripAnsi(stderrWriteStub.getCall(0).args[0]),
`info Root project: my.app (1.0.0)\n`);
@@ -1142,7 +1142,7 @@ test.serial("Project resolved event omits the type label when the type is missin
test.serial("Project resolved event omits the version suffix when the version is missing", (t) => {
const {stderrWriteStub} = t.context;
- process.emit("ui5.project-resolved", {name: "my.app", type: "library"});
+ process.emit("ui5.project-resolve-succeeded", {name: "my.app", type: "library"});
t.is(stderrWriteStub.callCount, 1);
t.is(stripAnsi(stderrWriteStub.getCall(0).args[0]),
`info Root project: library project my.app\n`);
diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js
index 853fe032be6..d8b74d71b33 100644
--- a/packages/logger/test/lib/writers/InteractiveConsole.js
+++ b/packages/logger/test/lib/writers/InteractiveConsole.js
@@ -47,7 +47,7 @@ test.serial("tool-info populates the header region", (t) => {
test.serial("project-resolved populates the project region", (t) => {
const {writer} = createWriter();
- process.emit("ui5.project-resolved", {
+ process.emit("ui5.project-resolve-succeeded", {
name: "my.app",
type: "application",
version: "1.0.0",
@@ -79,26 +79,86 @@ test.serial("project-framework-resolved populates the framework region", (t) =>
writer.disable();
});
-test.serial("duplicate project-resolved throws", (t) => {
+test.serial("repeated project-resolved updates the project region in place", (t) => {
const {writer} = createWriter();
- process.emit("ui5.project-resolved", {
+ process.emit("ui5.project-resolve-succeeded", {
name: "my.app",
type: "application",
version: "1.0.0",
});
+ process.emit("ui5.project-framework-resolved", {
+ framework: {name: "SAPUI5", version: "1.150.0"},
+ });
- // The writer's model is single-root-project. A second event means the
- // caller violated the invariant.
- t.throws(() => {
- process.emit("ui5.project-resolved", {
- name: "other.app",
- type: "application",
- version: "2.0.0",
- });
- }, {
- message: /duplicate ui5\.project-resolved/,
+ // A re-init (Supervisor.reinitialize) re-resolves the graph and re-emits
+ // for the same root; the framework version and type may have changed across the
+ // switch. The writer updates the region in place rather than throwing.
+ process.emit("ui5.project-resolve-succeeded", {
+ name: "my.app",
+ type: "library",
+ version: "2.0.0",
+ });
+ process.emit("ui5.project-framework-resolved", {
+ framework: {name: "OpenUI5", version: "1.151.0"},
+ });
+
+ const state = writer._getStateForTest();
+ t.deepEqual(state.project.project, {name: "my.app", type: "library", version: "2.0.0"});
+ t.deepEqual(state.project.framework, {name: "OpenUI5", version: "1.151.0"});
+
+ writer.disable();
+});
+
+test.serial("project-resolving shows a version placeholder, project-resolved clears it", (t) => {
+ const {writer, stderr} = createWriter();
+
+ process.emit("ui5.project-resolve-succeeded", {
+ name: "my.app",
+ type: "application",
+ version: "1.0.0",
+ });
+ process.emit("ui5.project-framework-resolved", {
+ framework: {name: "SAPUI5", version: "1.150.0"},
+ });
+
+ // A definition change is coming (a branch switch): the version is stale and
+ // about to change, so the version slots render a placeholder in place.
+ process.emit("ui5.project-resolve-started");
+ t.true(writer._getStateForTest().project.versionResolving);
+ let output = stripAnsi(stderr.writes.join(""));
+ t.regex(output, /Project\s+my\.app\s+\(application\)\s+resolving…/, "version slot shows the placeholder");
+
+ // The completed resolve arrives and replaces the placeholder with the new version.
+ process.emit("ui5.project-resolve-succeeded", {
+ name: "my.app",
+ type: "application",
+ version: "2.0.0",
+ });
+ process.emit("ui5.project-framework-resolved", {
+ framework: {name: "SAPUI5", version: "1.151.0"},
+ });
+ t.false(writer._getStateForTest().project.versionResolving, "a completed resolve clears the placeholder");
+ output = stripAnsi(stderr.writes.join(""));
+ t.regex(output, /v2\.0\.0/, "the new version is shown after resolve");
+
+ writer.disable();
+});
+
+test.serial("project-resolve-failed clears the placeholder back to the last-known version", (t) => {
+ const {writer} = createWriter();
+
+ process.emit("ui5.project-resolve-succeeded", {
+ name: "my.app", type: "application", version: "1.0.0",
});
+ process.emit("ui5.project-resolve-started");
+ t.true(writer._getStateForTest().project.versionResolving);
+
+ // A re-resolve was abandoned without a project-resolved (e.g. a failed graph
+ // resolution): the placeholder is released, falling back to the last version.
+ process.emit("ui5.project-resolve-failed");
+ t.false(writer._getStateForTest().project.versionResolving);
+ t.is(writer._getStateForTest().project.project.version, "1.0.0", "last-known version is retained");
writer.disable();
});
@@ -182,6 +242,56 @@ test.serial("serve-status: serve-error transitions to ERROR and records message"
writer.disable();
});
+test.serial("project-resolve-failed marks the build region degraded and errors with the message", (t) => {
+ const {writer} = createWriter();
+
+ process.emit("ui5.serve-status", {status: "serve-ready"});
+ process.emit("ui5.project-resolve-failed", {message: "Cannot read ui5.yaml: no such file"});
+
+ const state = writer._getStateForTest();
+ t.true(state.build.degraded, "the build region is flagged degraded");
+ t.is(state.build.state, STATES.ERROR, "the status line goes to ERROR");
+ t.is(state.build.errorMessage, "Cannot read ui5.yaml: no such file", "the re-resolve reason is shown");
+
+ writer.disable();
+});
+
+test.serial("degraded state is sticky: a later serve-ready does not repaint 'ready'", (t) => {
+ const {writer} = createWriter();
+
+ process.emit("ui5.project-resolve-failed", {message: "invalid ui5.yaml"});
+ t.true(writer._getStateForTest().build.degraded);
+
+ // The surviving BuildServer resettles to IDLE on the branch switch's source churn and re-emits
+ // serve-ready/serve-stale. These must not clobber the degraded ERROR line.
+ process.emit("ui5.serve-status", {status: "serve-ready"});
+ process.emit("ui5.serve-status", {status: "serve-stale", staleProjects: ["a", "b"]});
+ process.emit("ui5.serve-status", {status: "serve-build-done", hrtime: [1, 0]});
+
+ const state = writer._getStateForTest();
+ t.is(state.build.state, STATES.ERROR, "the churn from the stale stack does not repaint 'ready'");
+ t.true(state.build.degraded, "still degraded until a successful re-resolve");
+
+ writer.disable();
+});
+
+test.serial("a successful re-resolve clears degraded; the next serve-ready renders 'ready' again", (t) => {
+ const {writer} = createWriter();
+
+ process.emit("ui5.project-resolve-failed", {message: "invalid ui5.yaml"});
+ t.true(writer._getStateForTest().build.degraded);
+
+ // The swap succeeded: the graph matches disk again.
+ process.emit("ui5.project-resolve-succeeded", {name: "my.app", type: "application", version: "1.0.0"});
+ t.false(writer._getStateForTest().build.degraded, "a completed re-resolve lifts the degraded flag");
+
+ // The fresh stack's serve-ready now transitions normally.
+ process.emit("ui5.serve-status", {status: "serve-ready"});
+ t.is(writer._getStateForTest().build.state, STATES.READY, "'ready' renders again once healthy");
+
+ writer.disable();
+});
+
test.serial("regions are order-tolerant — server before project", (t) => {
const {writer} = createWriter();
@@ -189,7 +299,7 @@ test.serial("regions are order-tolerant — server before project", (t) => {
urls: [{label: "Local", url: "http://localhost:8080"}],
acceptRemoteConnections: false,
});
- process.emit("ui5.project-resolved", {
+ process.emit("ui5.project-resolve-succeeded", {
name: "my.app", type: "application", version: "1.0.0",
});
@@ -302,7 +412,7 @@ test.serial("frame includes visible content for each populated region", (t) => {
const {writer, stderr} = createWriter();
process.emit("ui5.tool-info", {name: "UI5 CLI", version: "1.2.3"});
- process.emit("ui5.project-resolved", {
+ process.emit("ui5.project-resolve-succeeded", {
name: "my.app", type: "application", version: "1.0.0",
});
process.emit("ui5.project-framework-resolved", {
@@ -380,7 +490,7 @@ test.serial("tool-mode 'serve' placeholders are replaced by real data", (t) => {
t.regex(midOutput, /binding…/);
t.regex(midOutput, /starting/);
- process.emit("ui5.project-resolved", {
+ process.emit("ui5.project-resolve-succeeded", {
name: "my.app", type: "application", version: "1.0.0",
});
process.emit("ui5.project-framework-resolved", {
@@ -1005,7 +1115,7 @@ test.serial("#registerSignalHandlers() is a no-op when the map is already popula
// process's listeners to baseline on teardown.
const events = [
"ui5.log", "ui5.build-metadata", "ui5.build-status", "ui5.project-build-status",
- "ui5.serve-status", "ui5.tool-info", "ui5.tool-mode", "ui5.project-resolved",
+ "ui5.serve-status", "ui5.tool-info", "ui5.tool-mode", "ui5.project-resolve-succeeded",
"ui5.server-listening", "ui5.log.stop-console",
"SIGHUP", "SIGINT", "SIGTERM", "SIGBREAK", "exit",
];
diff --git a/packages/logger/test/lib/writers/interactiveConsole/render.js b/packages/logger/test/lib/writers/interactiveConsole/render.js
index 216d9950fa1..ce446b4bf63 100644
--- a/packages/logger/test/lib/writers/interactiveConsole/render.js
+++ b/packages/logger/test/lib/writers/interactiveConsole/render.js
@@ -22,7 +22,7 @@ import {
} from "../../../../lib/writers/interactiveConsole/state/build.js";
import {createHeaderState, setTool} from
"../../../../lib/writers/interactiveConsole/state/header.js";
-import {createProjectState, setProject, setFramework, enableProjectPlaceholders} from
+import {createProjectState, setProject, setFramework, enableProjectPlaceholders, setVersionResolving} from
"../../../../lib/writers/interactiveConsole/state/project.js";
import {createServerState, setListening, enableServerPlaceholders} from
"../../../../lib/writers/interactiveConsole/state/server.js";
@@ -128,6 +128,32 @@ test("renderProjectRegion: project rendered without type/version still includes
t.notRegex(plain, /bare\.project\s+\(/, "no type marker is rendered when type is absent");
});
+test("renderProjectRegion: version slot shows a placeholder while resolving, name and type stay", (t) => {
+ const state = createProjectState();
+ setProject(state, {
+ name: "my.app",
+ type: "application",
+ version: "1.0.0",
+ });
+ setFramework(state, {name: "SAPUI5", version: "1.150.0"});
+ setVersionResolving(state);
+ const plain = renderProjectRegion(state).map(stripAnsi).join("\n");
+ t.regex(plain, /Project\s+my\.app\s+\(application\)\s+resolving…/, "name and type stay, version is a placeholder");
+ t.notRegex(plain, /v1\.0\.0/, "the stale project version is not shown while resolving");
+ // The framework version goes stale alongside it — placeholdered, name kept.
+ t.regex(plain, /Framework\s+SAPUI5\s+resolving…/, "framework name stays, its version is a placeholder");
+ t.notRegex(plain, /1\.150\.0/, "the stale framework version is not shown while resolving");
+});
+
+test("renderProjectRegion: resolving over a frameworkless project placeholders only the project version", (t) => {
+ const state = createProjectState();
+ setProject(state, {name: "my.app", type: "application", version: "1.0.0"});
+ setVersionResolving(state);
+ const plain = renderProjectRegion(state).map(stripAnsi).join("\n");
+ t.regex(plain, /Project\s+my\.app\s+\(application\)\s+resolving…/);
+ t.notRegex(plain, /Framework/, "no Framework row is invented while resolving");
+});
+
// ---- renderServerRegion -------------------------------------------------------
test("renderServerRegion: empty when no urls and no placeholders", (t) => {
diff --git a/packages/logger/test/lib/writers/interactiveConsole/state/build.js b/packages/logger/test/lib/writers/interactiveConsole/state/build.js
index 2a31da02d25..ff2418e3bee 100644
--- a/packages/logger/test/lib/writers/interactiveConsole/state/build.js
+++ b/packages/logger/test/lib/writers/interactiveConsole/state/build.js
@@ -8,6 +8,8 @@ import {
transitionTo,
setError,
setStale,
+ setDegraded,
+ clearDegraded,
enableBuildPlaceholders,
STATES,
} from "../../../../../lib/writers/interactiveConsole/state/build.js";
@@ -23,6 +25,7 @@ test("createBuildState: starts in INITIAL with empty counters", (t) => {
t.is(state.spinFrame, 0);
t.is(state.errorMessage, "");
t.is(state.lastBuildHrtime, null);
+ t.is(state.degraded, false);
});
test("beginBuild: sets projectOrder, totalProjects, and projectNameWidth", (t) => {
@@ -130,6 +133,18 @@ test("enableBuildPlaceholders: promotes INITIAL to STARTING", (t) => {
t.is(state.state, STATES.STARTING);
});
+test("setDegraded / clearDegraded: toggle the degraded flag without touching the activity state", (t) => {
+ const state = createBuildState();
+ transitionTo(state, STATES.READY);
+ state.spinFrame = 4;
+ setDegraded(state);
+ t.is(state.degraded, true);
+ t.is(state.state, STATES.READY, "the flag is orthogonal to the activity state");
+ t.is(state.spinFrame, 4, "spinner frame is not reset");
+ clearDegraded(state);
+ t.is(state.degraded, false);
+});
+
test("enableBuildPlaceholders: leaves non-INITIAL states alone", (t) => {
// Real state must win: if a build has already reported progress, the
// placeholder is stale and must not overwrite it.
diff --git a/packages/logger/test/lib/writers/interactiveConsole/state/project.js b/packages/logger/test/lib/writers/interactiveConsole/state/project.js
index 6a8d180272f..96eb98c7930 100644
--- a/packages/logger/test/lib/writers/interactiveConsole/state/project.js
+++ b/packages/logger/test/lib/writers/interactiveConsole/state/project.js
@@ -1,6 +1,7 @@
import test from "ava";
-import {createProjectState, setProject, setFramework, enableProjectPlaceholders} from
+import {createProjectState, setProject, setFramework, enableProjectPlaceholders, setVersionResolving,
+ clearVersionResolving} from
"../../../../../lib/writers/interactiveConsole/state/project.js";
test("createProjectState: fresh state has no project/framework and placeholders disabled", (t) => {
@@ -8,6 +9,7 @@ test("createProjectState: fresh state has no project/framework and placeholders
project: null,
framework: null,
showPlaceholders: false,
+ versionResolving: false,
});
});
@@ -47,3 +49,18 @@ test("enableProjectPlaceholders: flips the showPlaceholders flag", (t) => {
enableProjectPlaceholders(state);
t.true(state.showPlaceholders);
});
+
+test("setVersionResolving: sets the versionResolving flag; clearVersionResolving releases it", (t) => {
+ const state = createProjectState();
+ setVersionResolving(state);
+ t.true(state.versionResolving);
+ clearVersionResolving(state);
+ t.false(state.versionResolving);
+});
+
+test("setProject: clears a pending versionResolving flag", (t) => {
+ const state = createProjectState();
+ setVersionResolving(state);
+ setProject(state, {name: "my.app", type: "application", version: "2.0.0"});
+ t.false(state.versionResolving, "a completed resolve drops the placeholder");
+});
diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js
index 67ad1e55407..da6b58e2429 100644
--- a/packages/project/lib/build/BuildServer.js
+++ b/packages/project/lib/build/BuildServer.js
@@ -3,6 +3,8 @@ import {createReaderCollectionPrioritized} from "@ui5/fs/resourceFactory";
import BuildReader from "./BuildReader.js";
import WatchHandler from "./helpers/WatchHandler.js";
import {isAbortError} from "./helpers/abort.js";
+import {WATCHER_BURST_SETTLE_MS} from "./helpers/watchSettle.js";
+import RecoveryBudget, {WATCHER_RECOVERY_MAX_ATTEMPTS, WATCHER_RECOVERY_WINDOW_MS} from "./helpers/RecoveryBudget.js";
import {getLogger} from "@ui5/logger";
import ServeLogger from "@ui5/logger/internal/loggers/Serve";
const log = getLogger("build:BuildServer");
@@ -13,14 +15,8 @@ const log = getLogger("build:BuildServer");
// well under 100 ms on small projects, where a trailing debounce would dominate edit-to-reload
// latency. The emit is therefore leading-edge (the first change of a quiet period fires
// immediately), followed by this window that coalesces the rest of a burst into one trailing emit.
-//
-// The value is tied to @parcel/watcher's MAX_WAIT_TIME (500 ms): the watcher caps its own
-// coalescing there, so a continuous operation (e.g. `git checkout`) arrives as batches up to
-// 500 ms apart rather than one quiet-terminated batch. A window below the cap would see quiet
-// between batches and emit per batch; above it, each batch resets the window so the whole
-// operation collapses to one leading + one trailing emit. Do not lower below 500 ms without
-// revisiting that relationship.
-const SOURCES_CHANGED_SETTLE_MS = 550;
+// Sized to WATCHER_BURST_SETTLE_MS so a multi-batch operation collapses (see that constant).
+const SOURCES_CHANGED_SETTLE_MS = WATCHER_BURST_SETTLE_MS;
// Debounce for the request queue. A reader request enqueues a build and triggers the queue after
// this short delay so near-simultaneous requests build together. Serving a request must not wait,
@@ -41,20 +37,12 @@ const FIRST_BUILD_SETTLE_MS = 100;
// Settle window for restarting a build that a source change aborted. When a change lands mid-build
// the running build is aborted at once, but the restart is held until changes have been quiet for
-// this long (each further change resets it). During a burst (a `git checkout`, a save-all, a bundler
-// writing many files) @parcel/watcher delivers batches up to its MAX_WAIT_TIME (500 ms) apart;
-// restarting on BUILD_REQUEST_DEBOUNCE_MS would spawn a build per batch, each aborted by the next.
-// Holding the restart above the watcher's cap collapses the burst into a single build against the
-// settled tree. Reader-request-driven builds keep the short debounce, so this delay only applies to
-// the speculative post-abort restart, not to serving a request.
-const ABORTED_BUILD_RESTART_SETTLE_MS = 550;
-
-// Loop protection for watcher recovery, so a persistently failing watcher (e.g. a watched path
-// that keeps erroring on re-subscribe, or an FS that keeps dropping events) does not cycle
-// error -> recover -> error forever. More than WATCHER_RECOVERY_MAX_ATTEMPTS recoveries within
-// WATCHER_RECOVERY_WINDOW_MS is treated as unrecoverable and escalates to the terminal ERROR state.
-const WATCHER_RECOVERY_MAX_ATTEMPTS = 5;
-const WATCHER_RECOVERY_WINDOW_MS = 60000;
+// this long (each further change resets it). Sized to WATCHER_BURST_SETTLE_MS so a burst (a `git
+// checkout`, a save-all, a bundler writing many files) collapses into a single build against the
+// settled tree rather than one aborted build per watcher batch (see that constant). Reader-request-
+// driven builds keep the short debounce, so this delay only applies to the speculative post-abort
+// restart, not to serving a request.
+const ABORTED_BUILD_RESTART_SETTLE_MS = WATCHER_BURST_SETTLE_MS;
// The server's ACTIVITY state: what the server is doing right now. Mutated exclusively through
// #setState. Orthogonal to project staleness (which projects are up-to-date), which is reported
@@ -134,6 +122,10 @@ class BuildServer extends EventEmitter {
// regardless of which resource was requested. Cleared on every non-ERROR transition
// (see #setState).
#serveError = null;
+ // The error currently-parked and new reader requests are rejected with while suspendReaders()
+ // is engaged (a project-definition change is being re-resolved). Supplied by the caller so the
+ // HTTP-facing wording lives in the server layer, not here. Null when not suspended.
+ #suspendError = null;
// Background cache validation state. `#activeValidation` is the promise of the
// currently running validation pass (or null when idle); `#validationAbort`
// is its controller, used to preempt validation when a real build is requested.
@@ -141,10 +133,10 @@ class BuildServer extends EventEmitter {
#validationAbort = null;
// Watcher recovery state. `#recoveringWatcher` guards against re-entrant recovery while a
// recovery pass is in flight (a dropped-events fault emits one error per subscribed path
- // in a synchronous burst). `#watcherRecoveryTimestamps` holds the completion times of
- // recent recoveries for the loop-protection window.
+ // in a synchronous burst). `#watcherRecoveryBudget` caps recoveries within a sliding window
+ // (its own budget, independent of the ProjectDefinitionWatcher's).
#recoveringWatcher = false;
- #watcherRecoveryTimestamps = [];
+ #watcherRecoveryBudget = new RecoveryBudget();
/**
* Creates a new BuildServer instance
@@ -296,10 +288,7 @@ class BuildServer extends EventEmitter {
// Loop protection: a persistently failing watcher would otherwise cycle forever, since
// dropped-events faults arrive via the subscription callback (not a watch() rejection)
// and so never trip the reject-based fallback below.
- const now = Date.now();
- this.#watcherRecoveryTimestamps = this.#watcherRecoveryTimestamps
- .filter((ts) => now - ts < WATCHER_RECOVERY_WINDOW_MS);
- if (this.#watcherRecoveryTimestamps.length >= WATCHER_RECOVERY_MAX_ATTEMPTS) {
+ if (!this.#watcherRecoveryBudget.withinBudget()) {
this.#recoveringWatcher = false;
log.error(`File watcher failed to recover after ${WATCHER_RECOVERY_MAX_ATTEMPTS} attempts ` +
`within ${WATCHER_RECOVERY_WINDOW_MS} ms. Giving up.`);
@@ -348,7 +337,7 @@ class BuildServer extends EventEmitter {
status.invalidate({reason: "File watcher recovery", fileAddedOrRemoved: true});
}
- this.#watcherRecoveryTimestamps.push(Date.now());
+ this.#watcherRecoveryBudget.recordRecovery();
log.info(`File watcher recovered. Re-scanning all project sources.`);
// Every project is now non-fresh. The build was quiesced above, so the server is at rest:
@@ -426,6 +415,38 @@ class BuildServer extends EventEmitter {
return this.#serveError;
}
+ /**
+ * Suspends reader serving while the project definition is being re-resolved.
+ *
+ * Rejects every currently-parked reader request and makes new ones fail fast (no build is
+ * enqueued) until {@link BuildServer#resumeReaders}. Called by the dev server on the
+ * leading-edge signal of a definition-file change (e.g. a git checkout), so
+ * requests don't hang waiting out the source burst that the change produces. Already-built
+ * resources keep serving; only requests that would otherwise park are affected. A no-op once
+ * the server is destroyed.
+ *
+ * @public
+ * @param {Error} error Rejection reason surfaced to held and incoming reader requests
+ */
+ suspendReaders(error) {
+ if (this.#destroyed) {
+ return;
+ }
+ this.#suspendError = error;
+ for (const projectBuildStatus of this.#projectBuildStatus.values()) {
+ projectBuildStatus.rejectQueuedReaders(error);
+ }
+ }
+
+ /**
+ * Resumes reader serving after a {@link BuildServer#suspendReaders}. Idempotent.
+ *
+ * @public
+ */
+ resumeReaders() {
+ this.#suspendError = null;
+ }
+
/**
* Gets a reader for the root project only
*
@@ -484,6 +505,16 @@ class BuildServer extends EventEmitter {
throw lastError;
}
+ // A project-definition change is in progress: the graph is being re-resolved and the
+ // serving stack swapped. Fail fast instead of parking on a build that the concurrent source
+ // burst keeps aborting; a parked request would otherwise hang out the whole `git checkout`.
+ // Placed after the isFresh() short-circuit so already-built resources keep serving during
+ // the bridge. The throw surfaces via serveResources -> errorHandler, the same page the
+ // supervisor's degraded gate renders once the swap outcome is known.
+ if (this.#suspendError) {
+ throw this.#suspendError;
+ }
+
const {promise, resolve, reject} = Promise.withResolvers();
// Always queue the request on the status. It owns the "who resolves this"
// contract: a running validation pass drains its own queue via setReader
@@ -654,14 +685,22 @@ class BuildServer extends EventEmitter {
}
/**
- * Enqueues a project for building and triggers the request queue at the short debounce.
+ * Enqueues a project for building and triggers the request queue.
*
- * Serving a request must not wait, so this always (re-)arms the queue at
+ * Serving a request must not wait, so this normally (re-)arms the queue at
* BUILD_REQUEST_DEBOUNCE_MS - even when the project is already queued. A reader
- * request thereby supersedes a deferred settle window (the post-abort/transient restart at
- * ABORTED_BUILD_RESTART_SETTLE_MS, or the first-build window at
- * FIRST_BUILD_SETTLE_MS): the parked build is pulled forward to the short debounce
- * rather than left waiting out the longer window.
+ * request thereby supersedes the first-build settle window (FIRST_BUILD_SETTLE_MS):
+ * the parked build is pulled forward to the short debounce rather than left waiting.
+ *
+ * The exception is a source-change-aborted or transiently-failed build waiting out its restart
+ * window (#pendingDeferredRestart). That window exists to collapse a burst delivered
+ * as multiple watcher batches (a git checkout, a save-all) into one rebuild against
+ * the settled tree. A reader request that arrives mid-burst (as a browser's live-reload does)
+ * must neither pull the restart forward (firing a build into a still-arriving burst) nor reset
+ * the window (a stream of live-reload requests would defer the rebuild indefinitely). So while a
+ * restart is deferred, leave its armed timer untouched: the request is still queued on the status
+ * and resolves when the deferred rebuild (which processes the whole pending set) runs. The
+ * window is reset only by further source changes (see {@link #_projectResourceChanged}).
*
* @param {string} projectName Name of the project to enqueue
*/
@@ -670,9 +709,14 @@ class BuildServer extends EventEmitter {
log.verbose(`Enqueuing project '${projectName}' for build`);
this.#pendingBuildRequest.add(projectName);
}
- // Always re-arm at the default debounce: an explicit reader request supersedes any longer
- // settle window an earlier source change may have armed.
- this.#triggerRequestQueue();
+ if (this.#pendingDeferredRestart) {
+ // A deferred post-abort/transient restart owns the armed timer. Don't disturb it: the
+ // project is queued above and the restart will build it against the settled tree.
+ log.verbose(`Reader request for project '${projectName}' queued behind deferred restart`);
+ return;
+ }
+ // Re-arm at the default debounce: a reader request supersedes the short first-build window.
+ this.#triggerRequestQueue(BUILD_REQUEST_DEBOUNCE_MS);
}
#triggerRequestQueue(delay = BUILD_REQUEST_DEBOUNCE_MS) {
@@ -1403,6 +1447,19 @@ class ProjectBuildStatus {
}
this.#readerQueue = [];
}
+
+ // Bridges a project-definition change: reject every queued reader request now so requests don't
+ // hang waiting out the source burst of a `git checkout`. New requests fast-reject at the
+ // BuildServer's #suspendError gate (see #getReaderForProject) until resumeReaders(). Deliberately
+ // leaves #state, #lastError, and #reader untouched, unlike rejectReaderRequests(): this is not a
+ // build failure, the project is fine, its definition is just being re-resolved. Keeping #state
+ // clean means the server rebuilds normally once resumed without an ERRORED gate to lift.
+ rejectQueuedReaders(error) {
+ for (const {reject} of this.#readerQueue) {
+ reject(error);
+ }
+ this.#readerQueue = [];
+ }
}
/**
diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js
index af84f8ae952..386d0658782 100644
--- a/packages/project/lib/build/cache/ProjectBuildCache.js
+++ b/packages/project/lib/build/cache/ProjectBuildCache.js
@@ -1283,36 +1283,36 @@ export default class ProjectBuildCache {
* #cachedFrozenSourceMetadata (re-read from the persisted cache during
* #initSourceIndex).
*
- * No-op in {@link @ui5/project/build/cache/Cache}.Off mode, where no index or result
- * cache exists to reset.
- *
* @public
*/
discardIncrementalState() {
if (this.#cacheMode === Cache.Off) {
return;
}
+ // RESTORING_PROJECT_INDICES re-arms initSourceIndex (see its guard), so the next build
+ // re-runs it before validateCache, re-globbing the source tree from scratch.
this.#combinedIndexState = INDEX_STATES.RESTORING_PROJECT_INDICES;
- this.#sourceIndex = null;
this.#taskCache.clear();
// Reset the result cache state: a prior validateCache may have moved it to
// NO_CACHE or FRESH_AND_IN_USE. The next build's validateCache asserts
// PENDING_VALIDATION after the dependency-index restore step, so any other
// value would trip that assertion.
this.#resultCacheState = RESULT_CACHE_STATES.PENDING_VALIDATION;
- this.#changedProjectSourcePaths = [];
+ // Not touched by #initSourceIndex, so this reset is required.
this.#changedDependencyResourcePaths = [];
// Derived per-build state a discarded (failed) build must not leave behind.
// #currentResultSignature drives the #findResultCache early return; #currentStageSignatures
- // drives the isInitialImport/setStage guards in #importStages; #writtenResultResourcePaths
- // is normally emptied by allTasksCompleted, which a thrown build never reaches.
+ // drives the isInitialImport/setStage guards in #importStages.
this.#currentResultSignature = undefined;
- this.#cachedResultSignature = undefined;
this.#currentStageSignatures = new Map();
- this.#writtenResultResourcePaths = [];
// Reset the stage pipeline so #importStages re-initializes stages and re-imports
// cached results instead of reusing the failed build's partial writers.
this.#project.getProjectResources().reset();
+
+ // Overwritten before read on the next build (#sourceIndex by #initSourceIndex,
+ // #cachedResultSignature by #findResultCache); reset only so this stays a complete reset.
+ this.#sourceIndex = null;
+ this.#cachedResultSignature = undefined;
}
/**
diff --git a/packages/project/lib/build/helpers/RecoveryBudget.js b/packages/project/lib/build/helpers/RecoveryBudget.js
new file mode 100644
index 00000000000..514d6f1def0
--- /dev/null
+++ b/packages/project/lib/build/helpers/RecoveryBudget.js
@@ -0,0 +1,55 @@
+// Default loop-protection budget for watcher recovery: more than WATCHER_RECOVERY_MAX_ATTEMPTS
+// recoveries within WATCHER_RECOVERY_WINDOW_MS is treated as unrecoverable by the owning watcher.
+export const WATCHER_RECOVERY_MAX_ATTEMPTS = 5;
+export const WATCHER_RECOVERY_WINDOW_MS = 60000;
+
+/**
+ * Sliding-window loop protection for watcher recovery. A watcher that keeps failing would otherwise
+ * cycle error → recover → error forever; this caps recoveries to maxAttempts within a
+ * trailing windowMs. Check {@link withinBudget} before attempting a recovery and call
+ * {@link recordRecovery} to count it against the window. Whether an attempt is recorded on success
+ * or at schedule time is the caller's choice: the watchers record on success (a completed
+ * re-subscribe), while the server's Supervisor records when it schedules a recovery, so a branch
+ * that never resolves still exhausts the budget instead of retrying forever.
+ *
+ * Owned per watcher (the source {@link WatchHandler}'s recovery in BuildServer and the
+ * {@link @ui5/project/graph/ProjectDefinitionWatcher} each hold their own), so a fault in one does
+ * not consume the other's budget. The re-entrancy guard, the recovery work, and the escalation on
+ * exhaustion stay with the owner, which knows how to escalate (a state transition, a terminal event).
+ *
+ * @private
+ * @memberof @ui5/project/build/helpers
+ */
+class RecoveryBudget {
+ #timestamps = [];
+ #maxAttempts;
+ #windowMs;
+
+ constructor(maxAttempts = WATCHER_RECOVERY_MAX_ATTEMPTS, windowMs = WATCHER_RECOVERY_WINDOW_MS) {
+ this.#maxAttempts = maxAttempts;
+ this.#windowMs = windowMs;
+ }
+
+ /**
+ * Prunes recoveries older than the window and reports whether another attempt fits the budget.
+ *
+ * @returns {boolean} true if fewer than maxAttempts recoveries remain
+ * within the trailing window
+ */
+ withinBudget() {
+ const now = Date.now();
+ this.#timestamps = this.#timestamps.filter((ts) => now - ts < this.#windowMs);
+ return this.#timestamps.length < this.#maxAttempts;
+ }
+
+ /**
+ * Records a completed recovery against the window.
+ *
+ * @returns {void}
+ */
+ recordRecovery() {
+ this.#timestamps.push(Date.now());
+ }
+}
+
+export default RecoveryBudget;
diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js
index 7ec3eebb89e..21d27227561 100644
--- a/packages/project/lib/build/helpers/WatchHandler.js
+++ b/packages/project/lib/build/helpers/WatchHandler.js
@@ -1,8 +1,21 @@
import EventEmitter from "node:events";
+import {access} from "node:fs/promises";
import parcelWatcher from "@parcel/watcher";
import {getLogger} from "@ui5/logger";
+import {drainSubscriptions} from "./watchSubscriptions.js";
const log = getLogger("build:helpers:WatchHandler");
+// Resolves true if the path is accessible, false otherwise. Used to distinguish a subscribe failure
+// caused by a vanished source path (skippable) from a genuine watcher fault (must escalate).
+async function pathExists(p) {
+ try {
+ await access(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
/**
* Context of a build process
*
@@ -29,19 +42,33 @@ class WatchHandler extends EventEmitter {
log.verbose(`Watching source path(s): ${paths.join(", ")}`);
await Promise.all(paths.map(async (path) => {
- const subscription = await parcelWatcher.subscribe(path, (err, events) => {
- if (err) {
- this.emit("error", err);
- return;
- }
- for (const event of events) {
- try {
- this.#handleWatchEvents(event.type, event.path, project);
- } catch (handlerErr) {
- this.emit("error", handlerErr);
+ let subscription;
+ try {
+ subscription = await parcelWatcher.subscribe(path, (err, events) => {
+ if (err) {
+ this.emit("error", err);
+ return;
+ }
+ for (const event of events) {
+ try {
+ this.#handleWatchEvents(event.type, event.path, project);
+ } catch (handlerErr) {
+ this.emit("error", handlerErr);
+ }
}
+ });
+ } catch (err) {
+ if (await pathExists(path)) {
+ // Path present but subscribe failed: a genuine watcher fault. Let it propagate so
+ // BuildServer's loop-protected recovery/escalation path still applies.
+ throw err;
}
- });
+ // Source path vanished (e.g. removed by a branch switch) before we could subscribe.
+ // Skip it rather than wedging the whole server in terminal ERROR; the next graph
+ // resolution re-targets the watcher. At worst the project stays stale until then.
+ log.warn(`Skipping watch on missing source path (moved/removed): ${path}`);
+ return;
+ }
this.#subscriptions.push(subscription);
}));
}
@@ -51,10 +78,7 @@ class WatchHandler extends EventEmitter {
// failure cannot leave stale handles behind to be unsubscribed twice.
const subscriptions = this.#subscriptions;
this.#subscriptions = [];
- // Run in parallel and collect failures so a single misbehaving subscription
- // cannot leak the others.
- const results = await Promise.allSettled(subscriptions.map((s) => s.unsubscribe()));
- const failures = results.filter((r) => r.status === "rejected").map((r) => r.reason);
+ const failures = await drainSubscriptions(subscriptions);
if (failures.length) {
const err = new AggregateError(failures, "Failed to unsubscribe one or more file watchers");
this.emit("error", err);
@@ -62,7 +86,18 @@ class WatchHandler extends EventEmitter {
}
#handleWatchEvents(eventType, filePath, project) {
- const resourcePath = project.getVirtualPath(filePath);
+ let resourcePath;
+ try {
+ resourcePath = project.getVirtualPath(filePath);
+ } catch (err) {
+ // Source dir moved/removed under the running watcher (e.g. git checkout): the path no
+ // longer maps into the project the watcher was armed with. Drop the event rather than
+ // escalating to a fatal watcher error; the definition watcher re-inits the serving stack
+ // over the new graph, which re-targets the watcher at the current paths.
+ log.verbose(`Dropping watch event for unmappable path ${filePath} ` +
+ `in project '${project.getName()}': ${err.message}`);
+ return;
+ }
if (log.isLevelEnabled("silly")) {
log.silly(`FS event: ${eventType} ${filePath} (as ${resourcePath} in project '${project.getName()}')`);
}
diff --git a/packages/project/lib/build/helpers/watchSettle.js b/packages/project/lib/build/helpers/watchSettle.js
new file mode 100644
index 00000000000..3a43acaac1a
--- /dev/null
+++ b/packages/project/lib/build/helpers/watchSettle.js
@@ -0,0 +1,15 @@
+/**
+ * Settle window (ms) for collapsing a filesystem-event burst into a single trailing action, shared
+ * by every Parcel watcher consumer in the build layer.
+ *
+ * The Parcel watcher caps its own event coalescing at MAX_WAIT_TIME (500 ms): during a continuous
+ * operation (a `git checkout`, an editor's save-all, a bundler writing many files) it delivers
+ * events as batches up to 500 ms apart rather than one quiet-terminated batch. A window that
+ * collapses such a burst must therefore sit above that cap, so each further batch resets
+ * the window rather than prematurely terminating it; 550 ms adds a small margin. Lowering it below
+ * 500 ms breaks the coalescing relationship.
+ *
+ * @private
+ * @type {number}
+ */
+export const WATCHER_BURST_SETTLE_MS = 550;
diff --git a/packages/project/lib/build/helpers/watchSubscriptions.js b/packages/project/lib/build/helpers/watchSubscriptions.js
new file mode 100644
index 00000000000..42533cb8e7a
--- /dev/null
+++ b/packages/project/lib/build/helpers/watchSubscriptions.js
@@ -0,0 +1,16 @@
+/**
+ * Unsubscribes every subscription in parallel and returns the failures. Callers drain their
+ * subscription list to [] before calling, so a second drain is a no-op and a partial
+ * failure cannot leave stale handles behind to be unsubscribed twice. Running in parallel and
+ * collecting failures keeps a single misbehaving subscription from leaking the others.
+ *
+ * @private
+ * @param {object[]} subscriptions Subscriptions to drain, each exposing an async
+ * unsubscribe()
+ * @returns {Promise} The reasons of any rejected unsubscribe() calls, empty
+ * when all succeeded
+ */
+export async function drainSubscriptions(subscriptions) {
+ const results = await Promise.allSettled(subscriptions.map((s) => s.unsubscribe()));
+ return results.filter((r) => r.status === "rejected").map((r) => r.reason);
+}
diff --git a/packages/project/lib/graph/ProjectDefinitionWatcher.js b/packages/project/lib/graph/ProjectDefinitionWatcher.js
new file mode 100644
index 00000000000..c77094d9a92
--- /dev/null
+++ b/packages/project/lib/graph/ProjectDefinitionWatcher.js
@@ -0,0 +1,279 @@
+import EventEmitter from "node:events";
+import path from "node:path";
+import parcelWatcher from "@parcel/watcher";
+import {getLogger} from "@ui5/logger";
+import {drainSubscriptions} from "../build/helpers/watchSubscriptions.js";
+import {WATCHER_BURST_SETTLE_MS} from "../build/helpers/watchSettle.js";
+import RecoveryBudget, {
+ WATCHER_RECOVERY_MAX_ATTEMPTS, WATCHER_RECOVERY_WINDOW_MS,
+} from "../build/helpers/RecoveryBudget.js";
+const log = getLogger("graph:ProjectDefinitionWatcher");
+
+// Default filename of the workspace configuration, resolved against cwd.
+const WORKSPACE_CONFIG_DEFAULT = "ui5-workspace.yaml";
+
+// Settle window for the `definitionChanged` event, in milliseconds.
+//
+// A `git checkout` or branch switch writes ui5.yaml + package.json + sources within one operation.
+// A watched definition-file event starts a burst; after that, every delivered event below the
+// watched roots resets the trailing timer so re-resolution waits until the whole checkout is quiet,
+// not only until definition files stop moving. Unlike BuildServer's live-reload emit, a re-init
+// needs no leading edge (re-creating the serving stack on the first byte of a checkout is wasteful),
+// so this window is trailing-only. Sized to WATCHER_BURST_SETTLE_MS so each batch resets the window
+// rather than terminating it (see that constant).
+export const DEFINITION_CHANGED_SETTLE_MS = WATCHER_BURST_SETTLE_MS;
+
+/**
+ * Watches the project-definition files (ui5.yaml, package.json, the workspace config, and, in
+ * static-graph mode, the dependency-definition file) and emits a settled, coalesced
+ * definitionChanged when one changes. A definitionChanging fires on the
+ * leading edge of a burst (the first watched definition event, before the settle window) so an owner
+ * can react to a pending change ahead of the coalesced definitionChanged. Once that
+ * burst is open, every delivered watcher event below the subscribed roots extends the settle window;
+ * this avoids resolving a graph while a checkout has restored a package.json before the source tree
+ * or symlink targets it references.
+ *
+ * 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 only resolved definition-file paths can start a burst. Once started, non-definition events
+ * delivered by those subscriptions extend that burst's quiet window. The
+ * node_modules/.git ignore globs only reduce OS-level watch load;
+ * correctness comes from the include set. Project roots below node_modules are watched
+ * without the node_modules ignore so their own definition files remain observable.
+ *
+ * @private
+ * @memberof @ui5/project/graph
+ */
+class ProjectDefinitionWatcher extends EventEmitter {
+ #subscriptions = [];
+ // Absolute paths of the definition files to react to. The subscription callback filters
+ // against this set; everything else is dropped.
+ #watchedFiles = new Set();
+ // dir -> Set: the distinct directories to subscribe, each mapped to the
+ // definition files that made the directory relevant. Retained so recovery can re-subscribe the
+ // same set.
+ #watchDirs = new Map();
+
+ #settleTimer = null;
+ #lastEvent = null;
+
+ #recovering = false;
+ #recoveryBudget = new RecoveryBudget();
+ #destroyed = false;
+
+ /**
+ * Resolves the watch set, subscribes to each distinct directory, awaits readiness, and returns
+ * the watcher. Mirrors BuildServer awaiting WatchHandler readiness before serving, so a change
+ * made immediately after startup is not missed.
+ *
+ * @param {object} options
+ * @param {@ui5/project/graph/ProjectGraph} options.graph The resolved project graph
+ * @param {string} [options.rootConfigPath] Custom config path for the root project (--config)
+ * @param {string} [options.workspaceConfigPath] Workspace config path (default ui5-workspace.yaml).
+ * Omit in static-graph mode, which does not use the workspace.
+ * @param {string} [options.dependencyDefinitionPath] Static dependency-definition file
+ * (--dependency-definition), watched when present
+ * @param {string} [options.cwd=process.cwd()] Base directory for resolving relative paths
+ * @returns {Promise} The armed watcher
+ */
+ static async create({graph, rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd} = {}) {
+ const watcher = new ProjectDefinitionWatcher();
+ await watcher.#resolveWatchSet({graph, rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd});
+ await watcher.#subscribeAll();
+ return watcher;
+ }
+
+ // Builds the dir -> {definition files} include set from the graph and the threaded paths.
+ async #resolveWatchSet({graph, rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd}) {
+ const baseDir = cwd ? path.resolve(cwd) : process.cwd();
+ const resolve = (p) => (path.isAbsolute(p) ? p : path.join(baseDir, p));
+
+ const addWatchDir = (dirPath, filePath) => {
+ const dir = path.resolve(dirPath);
+ let files = this.#watchDirs.get(dir);
+ if (!files) {
+ files = new Set();
+ this.#watchDirs.set(dir, files);
+ }
+ files.add(filePath);
+ };
+
+ const add = (filePath) => {
+ const abs = path.resolve(filePath);
+ this.#watchedFiles.add(abs);
+ addWatchDir(path.dirname(abs), abs);
+ };
+
+ const rootName = graph.getRoot().getName();
+ const rootCustomConfig = rootConfigPath ? resolve(rootConfigPath) : null;
+ await graph.traverseBreadthFirst(({project}) => {
+ const rootPath = path.resolve(project.getRootPath());
+ add(path.join(rootPath, "package.json"));
+ if (rootCustomConfig && project.getName() === rootName) {
+ // The root carries a custom --config file, which may live outside its root.
+ add(rootCustomConfig);
+ } else {
+ add(path.join(rootPath, "ui5.yaml"));
+ }
+ });
+
+ // The workspace config lives at cwd. It may not exist yet; a create event on it still
+ // matters (it can introduce workspace resolution on the next re-init).
+ if (workspaceConfigPath !== undefined) {
+ add(resolve(workspaceConfigPath || WORKSPACE_CONFIG_DEFAULT));
+ }
+
+ // Static-graph mode: the dependency-definition file is itself a topology definition;
+ // editing it changes the graph exactly like package.json does.
+ if (dependencyDefinitionPath) {
+ add(resolve(dependencyDefinitionPath));
+ }
+ }
+
+ #getIgnoreGlobs(dir) {
+ const segments = path.resolve(dir).split(/[\\/]+/);
+ if (segments.includes("node_modules")) {
+ return ["**/.git/**"];
+ }
+ return ["**/node_modules/**", "**/.git/**"];
+ }
+
+ // Subscribes to every distinct directory in parallel, resolving once all are armed.
+ async #subscribeAll() {
+ const dirs = [...this.#watchDirs.keys()];
+ log.verbose(`Watching definition file(s) in: ${dirs.join(", ")}`);
+ await Promise.all(dirs.map((dir) => this.#subscribeDir(dir)));
+ }
+
+ async #subscribeDir(dir) {
+ const subscription = await parcelWatcher.subscribe(dir, (err, events) => {
+ if (err) {
+ this.#recoverWatcher(err);
+ return;
+ }
+ for (const event of events) {
+ if (this.#watchedFiles.has(path.resolve(event.path))) {
+ this.#onDefinitionEvent(event.type, event.path);
+ continue;
+ }
+
+ // Source events must not start a re-init. But once a definition-file event has opened
+ // a burst, any further event delivered by parcel means the filesystem is not quiet yet.
+ // Reset the timer so graph creation sees the settled checkout, including source files
+ // and symlink targets referenced by newly-restored package.json files.
+ this.#onNonDefinitionEvent(event.type, event.path);
+ }
+ }, {ignore: this.#getIgnoreGlobs(dir)});
+ this.#subscriptions.push(subscription);
+ }
+
+ // Trailing-only settle: reset the timer on each event so a multi-batch operation collapses to
+ // a single emit once changes have been quiet for the window.
+ #onDefinitionEvent(eventType, filePath) {
+ if (log.isLevelEnabled("silly")) {
+ log.silly(`Definition file event: ${eventType} ${filePath}`);
+ }
+ this.#lastEvent = {eventType, filePath};
+ if (this.#settleTimer) {
+ this.#armSettleTimer();
+ } else {
+ // Leading edge of a burst: a re-init (and a version change) is now known to be coming,
+ // though the trailing `definitionChanged` is still a settle window away. Owners use
+ // this to signal the pending change ahead of the re-resolve.
+ this.emit("definitionChanging", this.#lastEvent);
+ this.#armSettleTimer();
+ }
+ }
+
+ #onNonDefinitionEvent(eventType, filePath) {
+ if (!this.#settleTimer) {
+ return;
+ }
+ if (log.isLevelEnabled("silly")) {
+ log.silly(`Definition burst extended by file event: ${eventType} ${filePath}`);
+ }
+ this.#armSettleTimer();
+ }
+
+ #armSettleTimer() {
+ if (this.#settleTimer) {
+ clearTimeout(this.#settleTimer);
+ }
+ this.#settleTimer = setTimeout(() => {
+ this.#settleTimer = null;
+ const event = this.#lastEvent;
+ this.#lastEvent = null;
+ this.emit("definitionChanged", event);
+ }, DEFINITION_CHANGED_SETTLE_MS);
+ }
+
+ // Recreates the subscriptions after a watcher error. Modeled on BuildServer.#recoverWatcher:
+ // a synchronous re-entrancy guard collapses parcel's per-path error storm into one recovery,
+ // and loop protection escalates to a terminal "error" if the watcher keeps failing.
+ async #recoverWatcher(err) {
+ // Set synchronously before the first await so re-entrant emissions bail here.
+ if (this.#destroyed || this.#recovering) {
+ return;
+ }
+ this.#recovering = true;
+ log.warn(`Definition watcher error, attempting to recover: ${err?.message ?? err}`);
+ if (err?.stack) {
+ log.verbose(err.stack);
+ }
+
+ if (!this.#recoveryBudget.withinBudget()) {
+ this.#recovering = false;
+ log.error(`Definition watcher failed to recover after ${WATCHER_RECOVERY_MAX_ATTEMPTS} attempts ` +
+ `within ${WATCHER_RECOVERY_WINDOW_MS} ms. Giving up.`);
+ this.emit("error", err);
+ return;
+ }
+
+ try {
+ // Tear down the current subscriptions and re-subscribe the same watch set. The include
+ // set (#watchedFiles / #watchDirs) is unchanged; only the OS-level handles are renewed.
+ // Teardown failures are ignored here: the handles are being discarded regardless, and a
+ // re-subscribe failure below is what determines whether recovery succeeded.
+ const subscriptions = this.#subscriptions;
+ this.#subscriptions = [];
+ await drainSubscriptions(subscriptions);
+ if (this.#destroyed) {
+ return;
+ }
+ await this.#subscribeAll();
+ this.#recoveryBudget.recordRecovery();
+ log.info(`Definition watcher recovered.`);
+ } catch (recoveryErr) {
+ log.error(`Definition watcher recovery failed: ${recoveryErr?.message ?? recoveryErr}`);
+ this.emit("error", recoveryErr);
+ } finally {
+ this.#recovering = false;
+ }
+ }
+
+ /**
+ * Unsubscribes all watchers. Idempotent; a second call is a no-op. Unsubscribe failures are
+ * aggregated into an AggregateError emitted as error.
+ *
+ * @returns {Promise} Resolves once every subscription has been drained
+ */
+ async destroy() {
+ this.#destroyed = true;
+ if (this.#settleTimer) {
+ clearTimeout(this.#settleTimer);
+ this.#settleTimer = null;
+ }
+ // Drain the subscriptions list first so a second destroy() is a no-op and a partial
+ // failure cannot leave stale handles behind to be unsubscribed twice.
+ const subscriptions = this.#subscriptions;
+ this.#subscriptions = [];
+ const failures = await drainSubscriptions(subscriptions);
+ if (failures.length) {
+ const err = new AggregateError(failures, "Failed to unsubscribe one or more definition watchers");
+ this.emit("error", err);
+ }
+ }
+}
+
+export default ProjectDefinitionWatcher;
diff --git a/packages/project/lib/graph/ProjectGraphSettler.js b/packages/project/lib/graph/ProjectGraphSettler.js
new file mode 100644
index 00000000000..a3141b324df
--- /dev/null
+++ b/packages/project/lib/graph/ProjectGraphSettler.js
@@ -0,0 +1,187 @@
+import path from "node:path";
+import {stat} from "node:fs/promises";
+import parcelWatcher from "@parcel/watcher";
+import {getLogger} from "@ui5/logger";
+import {drainSubscriptions} from "../build/helpers/watchSubscriptions.js";
+import {WATCHER_BURST_SETTLE_MS} from "../build/helpers/watchSettle.js";
+
+const log = getLogger("graph:ProjectGraphSettler");
+
+export const PROJECT_GRAPH_QUIET_SETTLE_MS = WATCHER_BURST_SETTLE_MS;
+
+function isDescendantOf(dir, parentDir) {
+ const relative = path.relative(parentDir, dir);
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative);
+}
+
+function pruneCoveredDirs(dirs) {
+ return dirs.filter((dir) => {
+ return !dirs.some((otherDir) => otherDir !== dir && isDescendantOf(dir, otherDir));
+ });
+}
+
+function toGraphArray(graphs) {
+ return Array.isArray(graphs) ? graphs : [graphs];
+}
+
+async function dirExists(dir) {
+ try {
+ return (await stat(dir)).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+async function findExistingWatchDir(dir) {
+ let current = path.resolve(dir);
+ while (!(await dirExists(current))) {
+ const parent = path.dirname(current);
+ if (parent === current) {
+ return current;
+ }
+ current = parent;
+ }
+ return current;
+}
+
+async function resolveProjectRootWatchDirs(graphs) {
+ const dirs = new Set();
+ for (const graph of toGraphArray(graphs)) {
+ await graph.traverseBreadthFirst(({project}) => {
+ dirs.add(path.resolve(project.getRootPath()));
+ });
+ }
+ const existingDirs = await Promise.all([...dirs].map((dir) => findExistingWatchDir(dir)));
+ return pruneCoveredDirs([...new Set(existingDirs)]).sort();
+}
+
+function getAbortError(signal) {
+ return signal?.reason ?? Object.assign(new Error("Project graph quiet wait aborted"), {code: "ABORT_ERR"});
+}
+
+/**
+ * Waits until the resolved graph's project roots are quiet for one watcher settle window.
+ *
+ * This is intentionally broader than {@link ProjectDefinitionWatcher}: it is a short-lived
+ * acceptance gate used after a failed re-resolve. At that point a later graph resolve might succeed
+ * while a checkout or install is still restoring sources below newly resolved project roots. The
+ * supervisor passes both the candidate graph and the previous last-good graph so roots missing from
+ * an early candidate are still observed. Missing roots are watched at their nearest existing
+ * ancestor, and no node_modules ignore is used, which scales to nested npm dependency
+ * layouts without guessing stable ancestors for the long-lived definition watcher.
+ *
+ * @param {@ui5/project/graph/ProjectGraph|@ui5/project/graph/ProjectGraph[]} graphs Graph(s) to observe
+ * @param {object} [options]
+ * @param {number} [options.settleMs=PROJECT_GRAPH_QUIET_SETTLE_MS] Quiet window in milliseconds
+ * @param {AbortSignal} [options.signal] Optional cancellation signal
+ * @returns {Promise} Resolves after the graph roots have been quiet for the settle window
+ * @private
+ * @memberof @ui5/project/graph
+ */
+export async function waitForProjectGraphQuiet(graphs, {
+ settleMs = PROJECT_GRAPH_QUIET_SETTLE_MS,
+ signal,
+} = {}) {
+ signal?.throwIfAborted();
+ const dirs = await resolveProjectRootWatchDirs(graphs);
+ if (!dirs.length) {
+ return;
+ }
+
+ log.verbose(`Waiting for project graph roots to settle: ${dirs.join(", ")}`);
+
+ const subscriptions = [];
+ let settleTimer = null;
+ let finished = false;
+ let removeAbortListener = null;
+ let resolveWait;
+ let rejectWait;
+ const wait = new Promise((resolve, reject) => {
+ resolveWait = resolve;
+ rejectWait = reject;
+ });
+
+ async function cleanup() {
+ if (settleTimer) {
+ clearTimeout(settleTimer);
+ settleTimer = null;
+ }
+ removeAbortListener?.();
+ removeAbortListener = null;
+ const failures = await drainSubscriptions(subscriptions.splice(0));
+ if (failures.length) {
+ throw new AggregateError(failures, "Failed to unsubscribe one or more graph-settle watchers");
+ }
+ }
+
+ function finish(err) {
+ if (finished) {
+ return;
+ }
+ finished = true;
+ Promise.resolve()
+ .then(cleanup)
+ .then(() => {
+ if (err) {
+ rejectWait(err);
+ } else {
+ resolveWait();
+ }
+ }, (cleanupErr) => {
+ if (err) {
+ rejectWait(new AggregateError([err, cleanupErr], "Project graph quiet wait failed"));
+ } else {
+ rejectWait(cleanupErr);
+ }
+ });
+ }
+
+ function armSettleTimer() {
+ if (finished) {
+ return;
+ }
+ if (settleTimer) {
+ clearTimeout(settleTimer);
+ }
+ settleTimer = setTimeout(() => {
+ finish();
+ }, settleMs);
+ }
+
+ try {
+ if (signal) {
+ const onAbort = () => finish(getAbortError(signal));
+ signal.addEventListener("abort", onAbort, {once: true});
+ removeAbortListener = () => signal.removeEventListener("abort", onAbort);
+ }
+
+ await Promise.all(dirs.map(async (dir) => {
+ const subscription = await parcelWatcher.subscribe(dir, (err, events) => {
+ if (err) {
+ finish(err);
+ return;
+ }
+ if (!events.length) {
+ return;
+ }
+ if (log.isLevelEnabled("silly")) {
+ for (const event of events) {
+ log.silly(`Project graph settle event: ${event.type} ${event.path}`);
+ }
+ }
+ armSettleTimer();
+ }, {ignore: ["**/.git/**"]});
+ if (finished) {
+ await subscription.unsubscribe();
+ return;
+ }
+ subscriptions.push(subscription);
+ }));
+ signal?.throwIfAborted();
+ armSettleTimer();
+ } catch (err) {
+ finish(err);
+ }
+
+ return wait;
+}
diff --git a/packages/project/lib/graph/projectGraphBuilder.js b/packages/project/lib/graph/projectGraphBuilder.js
index 4c404559ba3..1e6562bd863 100644
--- a/packages/project/lib/graph/projectGraphBuilder.js
+++ b/packages/project/lib/graph/projectGraphBuilder.js
@@ -143,7 +143,7 @@ async function projectGraphBuilder(nodeProvider, workspace) {
// traversal. Consumed by @ui5/logger writers to populate their header /
// scrollback lines. Framework information is emitted separately once a
// caller actually resolves framework usage for the current run.
- process.emit("ui5.project-resolved", {
+ process.emit("ui5.project-resolve-succeeded", {
name: rootProject.getName(),
type: rootProject.getType(),
version: rootProject.getVersion(),
diff --git a/packages/project/package.json b/packages/project/package.json
index 17b2e8957c4..c754b416180 100644
--- a/packages/project/package.json
+++ b/packages/project/package.json
@@ -20,6 +20,7 @@
"exports": {
"./config/Configuration": "./lib/config/Configuration.js",
"./build/cache/Cache": "./lib/build/cache/Cache.js",
+ "./build/helpers/RecoveryBudget": "./lib/build/helpers/RecoveryBudget.js",
"./specifications/Specification": "./lib/specifications/Specification.js",
"./specifications/SpecificationVersion": "./lib/specifications/SpecificationVersion.js",
"./ui5Framework/Sapui5MavenSnapshotResolver": "./lib/ui5Framework/Sapui5MavenSnapshotResolver.js",
@@ -29,6 +30,8 @@
"./validation/validator": "./lib/validation/validator.js",
"./validation/ValidationError": "./lib/validation/ValidationError.js",
"./graph/ProjectGraph": "./lib/graph/ProjectGraph.js",
+ "./graph/ProjectDefinitionWatcher": "./lib/graph/ProjectDefinitionWatcher.js",
+ "./graph/ProjectGraphSettler": "./lib/graph/ProjectGraphSettler.js",
"./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js",
"./graph": "./lib/graph/graph.js",
"./package.json": "./package.json"
diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js
index 4311adb3798..9ba92861829 100644
--- a/packages/project/test/lib/build/BuildServer.js
+++ b/packages/project/test/lib/build/BuildServer.js
@@ -85,9 +85,19 @@ test.beforeEach(async (t) => {
}
}
+ // BuildReader is constructed in the BuildServer constructor. Most tests don't exercise it,
+ // but the reader-request path (#getReaderForProject → #enqueueBuild) is only reachable through
+ // the buildServerInterface handed to it. Capture that interface so tests can drive reader
+ // requests directly, mirroring what BuildReader.byPath does for a resource lookup.
+ t.context.capturedInterfaces = [];
+ class BuildReader {
+ constructor(_name, _projects, buildServerInterface) {
+ t.context.capturedInterfaces.push(buildServerInterface);
+ }
+ }
+
const BuildServer = (await esmock("../../../lib/build/BuildServer.js", {
- // BuildReader is constructed in the BuildServer constructor but not exercised here.
- "../../../lib/build/BuildReader.js": class BuildReader {},
+ "../../../lib/build/BuildReader.js": BuildReader,
"../../../lib/build/helpers/WatchHandler.js": FakeWatchHandler,
})).default;
t.context.BuildServer = BuildServer;
@@ -369,6 +379,69 @@ test.serial(
await clock.tickAsync(0);
});
+test.serial(
+ "abort-restart: a reader request mid-window must not pull the deferred restart forward",
+ async (t) => {
+ const {BuildServer, graph, projectBuilder, rootProject, sinon, clock, capturedInterfaces} =
+ t.context;
+ const {ABORTED_BUILD_RESTART_SETTLE_MS, BUILD_REQUEST_DEBOUNCE_MS} =
+ BuildServer.__internals__;
+ const statusEvents = makeStatusRecorder(t);
+
+ const resolvers = [];
+ projectBuilder.build = sinon.stub().callsFake((opts, cb) => new Promise((resolve, reject) => {
+ resolvers.push(() => {
+ if (opts.signal?.aborted) {
+ const err = new Error("Build aborted");
+ err.name = "AbortError";
+ reject(err);
+ return;
+ }
+ cb("root.project", {getReader: () => ({fakeReader: true})});
+ resolve(["root.project"]);
+ });
+ }));
+
+ const buildServer = await BuildServer.create(graph, projectBuilder, true, [], []);
+ t.context.buildServer = buildServer;
+ // The interface handed to this server's BuildReaders carries #getReaderForProject — the same
+ // entry point BuildReader.byPath uses to request a project's built resources. The three
+ // readers all receive the same interface object, so any of the three just-pushed entries
+ // works; take the last one to avoid the entries recorded for the beforeEach server.
+ const {getReaderForProject} = capturedInterfaces[capturedInterfaces.length - 1];
+ await clock.tickAsync(BUILD_REQUEST_DEBOUNCE_MS);
+ t.is(projectBuilder.build.callCount, 1, "initial build started");
+
+ // A source change aborts the running build; the restart is deferred to the settle window.
+ buildServer._projectResourceChanged(rootProject, "/a.js", false);
+ resolvers[0]();
+ await clock.tickAsync(0);
+ t.is(projectBuilder.build.callCount, 1, "no restart yet — the settle window is open");
+ t.is(statusEvents[statusEvents.length - 1].status, "serve-settling",
+ "state is settling while the restart is deferred");
+
+ // A browser/live-reload request for the (now invalidated) project lands mid-window. It must
+ // wait for the settled rebuild, not provoke a build into the still-arriving burst. The
+ // request is parked; a rejection here would be an unhandled rejection, so swallow it.
+ getReaderForProject("root.project").catch(() => {});
+
+ // The burst is still in flight: the settle window has NOT elapsed. Advance past the short
+ // request debounce (well below the settle window) — no build must start.
+ await clock.tickAsync(BUILD_REQUEST_DEBOUNCE_MS);
+ t.is(projectBuilder.build.callCount, 1,
+ "a reader request must not fire a build before the deferred-restart window elapses");
+ t.is(statusEvents[statusEvents.length - 1].status, "serve-settling",
+ "still settling — the reader request did not flip the server out of the settle window");
+
+ // Only once the window fully elapses does the single deferred rebuild fire.
+ await clock.tickAsync(ABORTED_BUILD_RESTART_SETTLE_MS);
+ t.is(projectBuilder.build.callCount, 2, "single deferred rebuild after the window elapsed");
+
+ // Settle the restarted build so no promise is left dangling.
+ resolvers[1]?.();
+ await clock.tickAsync(0);
+ });
+
test.serial(
"serve-status: transient failure during a change burst reports settling, not error", async (t) => {
const {BuildServer, graph, projectBuilder, rootProject, sinon, clock} = t.context;
@@ -1706,6 +1779,12 @@ function makeRescanBuilder(sinon) {
const droppedEventsError = () =>
new Error("Events were dropped by the FSEvents client. File system must be re-scanned.");
+// Recovery outcome hinges on whether the fresh WatchHandler's watch() resolves or rejects. A
+// branch switch that removed a watched source dir is absorbed inside WatchHandler itself — the
+// vanished path is skipped, so watch() resolves and recovery settles on STALE (the "recreates
+// watcher and forces a full re-scan" test below). A watch() that genuinely rejects (a present-path
+// subscribe fault, faked here via failWatchFrom) still escalates to fatal ERROR ("failure to
+// re-subscribe" below), and the loop-protection budget still trips on a persistent fault.
test.serial(
"watcher-recovery: dropped-events error recreates watcher and forces a full re-scan", async (t) => {
const {sinon, clock, SOURCES_CHANGED_SETTLE_MS} = t.context;
@@ -1815,3 +1894,133 @@ test.serial(
t.is(errorEvents.length, 1, "a fatal error event was emitted");
t.is(errorEvents[0].message, "watch() rejected", "the re-subscription failure is surfaced");
});
+
+// ---- Reader suspend/resume (project-definition-change bridge) -------------------------------
+//
+// While a definition change is being re-resolved, the supervisor suspends the current
+// BuildServer's readers so requests fail fast instead of parking on a build the checkout's
+// source burst keeps aborting. These tests drive the reader-request path directly via the
+// captured buildServerInterface, mirroring what BuildReader.byPath does.
+
+// Builds a BuildServer with no initial build (starts IDLE) and returns it plus the captured
+// interface, so a test can enqueue reader requests. projectBuilder.build is a controllable stub.
+async function makeSuspendableBuildServer(t) {
+ const {graph, projectBuilder, sinon} = t.context;
+ const resolvers = [];
+ projectBuilder.build = sinon.stub().callsFake((_opts, cb) => new Promise((resolve, reject) => {
+ resolvers.push({resolve, reject, cb});
+ }));
+
+ let capturedInterface;
+ class CapturingBuildReader {
+ constructor(_name, _projects, buildServerInterface) {
+ capturedInterface = buildServerInterface;
+ }
+ }
+ class FakeWatchHandler {
+ constructor() {
+ this.destroy = sinon.stub().resolves();
+ this.on = sinon.stub();
+ this.watch = sinon.stub().resolves();
+ }
+ }
+ const CapturingBuildServer = (await esmock("../../../lib/build/BuildServer.js", {
+ "../../../lib/build/BuildReader.js": CapturingBuildReader,
+ "../../../lib/build/helpers/WatchHandler.js": FakeWatchHandler,
+ })).default;
+ const buildServer = await CapturingBuildServer.create(graph, projectBuilder, false, [], []);
+ t.teardown(() => buildServer.destroy());
+ return {buildServer, capturedInterface: () => capturedInterface, resolvers, projectBuilder};
+}
+
+test.serial("suspendReaders: rejects a currently-parked reader request immediately", async (t) => {
+ const {buildServer, capturedInterface} = await makeSuspendableBuildServer(t);
+
+ // Park a reader request (not fresh -> enqueues a build and awaits it). Do NOT advance the
+ // clock, so the enqueued build never starts: the request is purely parked on the queue.
+ const readerPromise = capturedInterface().getReaderForProject("root.project");
+
+ const suspendError = new Error("definition changing");
+ buildServer.suspendReaders(suspendError);
+
+ // The parked request rejects now, without advancing any settle/build window.
+ const err = await t.throwsAsync(readerPromise);
+ t.is(err, suspendError, "the parked request rejects with the supplied suspend error");
+
+ // No build had to complete to release it.
+ t.is(t.context.projectBuilder.build.callCount, 0, "no build was awaited to release the parked request");
+});
+
+test.serial("suspendReaders: new reader requests fast-reject without enqueuing a build", async (t) => {
+ const {clock} = t.context;
+ const {buildServer, capturedInterface, projectBuilder} = await makeSuspendableBuildServer(t);
+
+ const suspendError = new Error("definition changing");
+ buildServer.suspendReaders(suspendError);
+
+ const err = await t.throwsAsync(capturedInterface().getReaderForProject("root.project"));
+ t.is(err, suspendError, "a new request while suspended rejects with the suspend error");
+
+ // Fast-fail path: no build is enqueued, so the debounce firing produces no build.
+ await clock.tickAsync(1000);
+ t.is(projectBuilder.build.callCount, 0, "no build is enqueued while suspended");
+});
+
+test.serial("resumeReaders: re-enables normal build-backed reader requests", async (t) => {
+ const {clock} = t.context;
+ const {buildServer, capturedInterface, resolvers, projectBuilder} = await makeSuspendableBuildServer(t);
+
+ buildServer.suspendReaders(new Error("definition changing"));
+ await t.throwsAsync(capturedInterface().getReaderForProject("root.project"));
+
+ buildServer.resumeReaders();
+
+ // A request after resume parks and drives a build as normal.
+ const readerPromise = capturedInterface().getReaderForProject("root.project");
+ readerPromise.catch(() => {});
+ await clock.tickAsync(1000);
+ t.true(projectBuilder.build.called, "a build is enqueued again after resume");
+
+ // Complete the build so the request resolves.
+ resolvers[0].cb("root.project", {getReader: () => ({fakeReader: true})});
+ resolvers[0].resolve(["root.project"]);
+ await readerPromise;
+ t.pass();
+});
+
+test.serial("resumeReaders: is idempotent when not suspended", async (t) => {
+ const {buildServer} = await makeSuspendableBuildServer(t);
+ // A resume with no prior suspend is a harmless no-op.
+ t.notThrows(() => buildServer.resumeReaders());
+ t.notThrows(() => buildServer.resumeReaders());
+});
+
+test.serial("suspendReaders: is a no-op after destroy()", async (t) => {
+ const {buildServer, projectBuilder} = await makeSuspendableBuildServer(t);
+ await buildServer.destroy();
+
+ buildServer.suspendReaders(new Error("definition changing"));
+
+ // After destroy the status map is untouched by suspend; nothing throws and no state changes
+ // that would affect a (already torn-down) server.
+ t.is(projectBuilder.build.callCount, 0);
+ t.pass();
+});
+
+test.serial("suspend holds across a concurrent source change (invalidate does not clear it)", async (t) => {
+ const {clock, rootProject} = t.context;
+ const {buildServer, capturedInterface, projectBuilder} = await makeSuspendableBuildServer(t);
+
+ const suspendError = new Error("definition changing");
+ buildServer.suspendReaders(suspendError);
+
+ // A branch switch fires source events concurrently: _projectResourceChanged -> invalidate().
+ // invalidate() must NOT lift the suspend (it is orthogonal to project #state), so requests keep
+ // fast-rejecting rather than falling back into the parking/build path.
+ buildServer._projectResourceChanged(rootProject, "/a.js", false);
+
+ const err = await t.throwsAsync(capturedInterface().getReaderForProject("root.project"));
+ t.is(err, suspendError, "still suspended after a concurrent source change");
+ await clock.tickAsync(1000);
+ t.is(projectBuilder.build.callCount, 0, "no build enqueued while suspend holds through the churn");
+});
diff --git a/packages/project/test/lib/build/helpers/RecoveryBudget.js b/packages/project/test/lib/build/helpers/RecoveryBudget.js
new file mode 100644
index 00000000000..6869b61a344
--- /dev/null
+++ b/packages/project/test/lib/build/helpers/RecoveryBudget.js
@@ -0,0 +1,56 @@
+import test from "ava";
+import sinon from "sinon";
+import RecoveryBudget, {
+ WATCHER_RECOVERY_MAX_ATTEMPTS, WATCHER_RECOVERY_WINDOW_MS,
+} from "../../../../lib/build/helpers/RecoveryBudget.js";
+
+test.afterEach.always(() => {
+ sinon.restore();
+});
+
+test.serial("withinBudget: allows up to maxAttempts recorded recoveries, then refuses", (t) => {
+ const clock = sinon.useFakeTimers();
+ const budget = new RecoveryBudget(3, 1000);
+
+ for (let i = 0; i < 3; i++) {
+ t.true(budget.withinBudget(), `attempt ${i + 1} is within budget`);
+ budget.recordRecovery();
+ }
+ t.false(budget.withinBudget(), "the attempt past maxAttempts is refused");
+
+ clock.restore();
+});
+
+test.serial("withinBudget: recoveries older than the window no longer count", (t) => {
+ const clock = sinon.useFakeTimers();
+ const budget = new RecoveryBudget(2, 1000);
+
+ budget.recordRecovery();
+ budget.recordRecovery();
+ t.false(budget.withinBudget(), "budget exhausted within the window");
+
+ // Advance past the window: the earlier recoveries fall out and the budget frees up.
+ clock.tick(1001);
+ t.true(budget.withinBudget(), "recoveries outside the window are pruned");
+
+ clock.restore();
+});
+
+test("defaults: constructed budget uses the exported default attempts/window", (t) => {
+ const clock = sinon.useFakeTimers();
+ const budget = new RecoveryBudget();
+
+ for (let i = 0; i < WATCHER_RECOVERY_MAX_ATTEMPTS; i++) {
+ t.true(budget.withinBudget());
+ budget.recordRecovery();
+ }
+ t.false(budget.withinBudget(), "refuses past the default max attempts");
+
+ // Still refused just inside the default window, allowed just past it.
+ clock.tick(WATCHER_RECOVERY_WINDOW_MS - 1);
+ t.false(budget.withinBudget());
+ clock.tick(2);
+ t.true(budget.withinBudget());
+
+ clock.restore();
+});
diff --git a/packages/project/test/lib/build/helpers/WatchHandler.js b/packages/project/test/lib/build/helpers/WatchHandler.js
index 87f5d8e3935..ac7e1016a17 100644
--- a/packages/project/test/lib/build/helpers/WatchHandler.js
+++ b/packages/project/test/lib/build/helpers/WatchHandler.js
@@ -4,14 +4,19 @@ import esmock from "esmock";
let WatchHandler;
let subscribeStub;
+let accessStub;
test.before(async () => {
subscribeStub = sinon.stub();
+ accessStub = sinon.stub();
WatchHandler = await esmock("../../../../lib/build/helpers/WatchHandler.js", {
"@parcel/watcher": {
default: {
subscribe: subscribeStub
}
+ },
+ "node:fs/promises": {
+ access: accessStub
}
});
});
@@ -19,6 +24,7 @@ test.before(async () => {
test.afterEach.always(() => {
sinon.restore();
subscribeStub.reset();
+ accessStub.reset();
});
function createMockSubscription() {
@@ -175,7 +181,10 @@ test.serial("watch: forwards dropped-events error from watcher callback", async
await handler.destroy();
});
-test.serial("watch: emits error when handler throws", async (t) => {
+// A source dir moved/removed under the running watcher (e.g. git checkout) makes getVirtualPath
+// throw the unmappable-path error. The event is dropped — neither a `change` nor a fatal `error` is
+// emitted — so a branch switch does not escalate the server to terminal ERROR.
+test.serial("watch: drops event whose path no longer maps to a virtual path", async (t) => {
const subscription = createMockSubscription();
const callbackReady = captureCallback(subscription);
@@ -183,7 +192,8 @@ test.serial("watch: emits error when handler throws", async (t) => {
const project = {
getSourcePaths: () => ["/src"],
getVirtualPath: () => {
- throw new Error("virtual path error");
+ throw new Error(
+ "Unable to convert source path /src/file.js to virtual path for project test-project");
},
getName: () => "test-project"
};
@@ -191,14 +201,15 @@ test.serial("watch: emits error when handler throws", async (t) => {
await handler.watch([project]);
const callback = await callbackReady;
- const errorPromise = new Promise((resolve) => {
- handler.on("error", (err) => {
- t.is(err.message, "virtual path error");
- resolve();
- });
- });
+ const changeSpy = sinon.spy();
+ const errorSpy = sinon.spy();
+ handler.on("change", changeSpy);
+ handler.on("error", errorSpy);
+
callback(null, [{type: "update", path: "/src/file.js"}]);
- await errorPromise;
+
+ t.is(changeSpy.callCount, 0, "unmappable event is dropped, not forwarded as a change");
+ t.is(errorSpy.callCount, 0, "unmappable event does not escalate to a fatal error");
await handler.destroy();
});
@@ -226,6 +237,54 @@ test.serial("watch: subscribes to each source path", async (t) => {
t.true(subB.unsubscribe.calledOnce);
});
+// A branch switch can remove a watched source dir before recovery re-subscribes. parcel then
+// rejects with a bare "No such file or directory" (no code/errno), so the decision is made on
+// current disk state: a missing path is skipped rather than escalated to a fatal error.
+test.serial("watch: skips subscribe on a missing source path", async (t) => {
+ const goodSub = createMockSubscription();
+ subscribeStub.withArgs("/src").resolves(goodSub);
+ subscribeStub.withArgs("/gone").rejects(new Error("No such file or directory"));
+ // /gone no longer exists on disk; /src does.
+ accessStub.withArgs("/gone").rejects(new Error("ENOENT"));
+ accessStub.withArgs("/src").resolves();
+
+ const handler = new WatchHandler();
+ const project = {
+ getSourcePaths: () => ["/src", "/gone"],
+ getVirtualPath: (filePath) => filePath,
+ getName: () => "test-project"
+ };
+
+ const errorSpy = sinon.spy();
+ handler.on("error", errorSpy);
+
+ await t.notThrowsAsync(handler.watch([project]), "watch resolves despite the missing path");
+ t.is(errorSpy.callCount, 0, "no error emitted for a skipped missing path");
+
+ await handler.destroy();
+ t.true(goodSub.unsubscribe.calledOnce, "the surviving path was subscribed and torn down");
+});
+
+// A subscribe failure on a path that still exists is a genuine watcher fault, not a vanished
+// source dir. It must propagate so BuildServer's loop-protected recovery/escalation applies.
+test.serial("watch: rejects when subscribe fails on an existing path", async (t) => {
+ subscribeStub.withArgs("/src").rejects(new Error("EMFILE: too many open files"));
+ accessStub.withArgs("/src").resolves(); // path present → genuine fault
+
+ const handler = new WatchHandler();
+ const project = {
+ getSourcePaths: () => ["/src"],
+ getVirtualPath: (filePath) => filePath,
+ getName: () => "test-project"
+ };
+
+ await t.throwsAsync(handler.watch([project]), {
+ message: "EMFILE: too many open files"
+ }, "subscribe failure on an existing path propagates");
+
+ await handler.destroy();
+});
+
test.serial("destroy: unsubscribes subscriptions in parallel", async (t) => {
const subA = createMockSubscription();
const subB = createMockSubscription();
diff --git a/packages/project/test/lib/build/helpers/watchSubscriptions.js b/packages/project/test/lib/build/helpers/watchSubscriptions.js
new file mode 100644
index 00000000000..1fe6c6fbb33
--- /dev/null
+++ b/packages/project/test/lib/build/helpers/watchSubscriptions.js
@@ -0,0 +1,36 @@
+import test from "ava";
+import sinon from "sinon";
+import {drainSubscriptions} from "../../../../lib/build/helpers/watchSubscriptions.js";
+
+test("drainSubscriptions: unsubscribes every subscription and returns no failures on success", async (t) => {
+ const subs = [
+ {unsubscribe: sinon.stub().resolves()},
+ {unsubscribe: sinon.stub().resolves()},
+ ];
+
+ const failures = await drainSubscriptions(subs);
+
+ t.deepEqual(failures, [], "no failures when all unsubscribe cleanly");
+ t.true(subs[0].unsubscribe.calledOnce);
+ t.true(subs[1].unsubscribe.calledOnce);
+});
+
+test("drainSubscriptions: unsubscribes all in parallel even when some reject, collecting the reasons",
+ async (t) => {
+ const errA = new Error("unsub A failed");
+ const errC = new Error("unsub C failed");
+ const subs = [
+ {unsubscribe: sinon.stub().rejects(errA)},
+ {unsubscribe: sinon.stub().resolves()},
+ {unsubscribe: sinon.stub().rejects(errC)},
+ ];
+
+ const failures = await drainSubscriptions(subs);
+
+ t.deepEqual(failures, [errA, errC], "returns the reasons of the rejected unsubscribes only");
+ t.true(subs[1].unsubscribe.calledOnce, "a rejecting sibling does not prevent the others");
+ });
+
+test("drainSubscriptions: an empty list resolves to no failures", async (t) => {
+ t.deepEqual(await drainSubscriptions([]), []);
+});
diff --git a/packages/project/test/lib/graph/ProjectDefinitionWatcher.js b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js
new file mode 100644
index 00000000000..d2ef10fb4ea
--- /dev/null
+++ b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js
@@ -0,0 +1,526 @@
+import test from "ava";
+import sinon from "sinon";
+import esmock from "esmock";
+import path from "node:path";
+
+let ProjectDefinitionWatcher;
+let subscribeStub;
+
+const fixturePath = (p) => path.resolve(p);
+const fixtureFile = (root, ...segments) => path.join(fixturePath(root), ...segments);
+
+test.before(async () => {
+ subscribeStub = sinon.stub();
+ ProjectDefinitionWatcher = await esmock("../../../lib/graph/ProjectDefinitionWatcher.js", {
+ "@parcel/watcher": {
+ default: {
+ subscribe: subscribeStub
+ }
+ }
+ });
+});
+
+test.afterEach.always(() => {
+ sinon.restore();
+ subscribeStub.reset();
+});
+
+function createMockSubscription() {
+ return {
+ unsubscribe: sinon.stub().resolves()
+ };
+}
+
+// A fake graph with a root project plus the given dependency roots. Each entry is {name, rootPath}.
+function createGraph(root, deps = []) {
+ const projects = [root, ...deps];
+ return {
+ getRoot: () => ({getName: () => root.name}),
+ traverseBreadthFirst: async (cb) => {
+ for (const p of projects) {
+ await cb({project: {getName: () => p.name, getRootPath: () => p.rootPath}});
+ }
+ }
+ };
+}
+
+// Captures the change callback of the subscription for a given directory, keyed by the subscribe
+// call's first argument.
+function captureCallbacks(subscription = createMockSubscription()) {
+ const callbacks = new Map();
+ subscribeStub.callsFake(async (dir, cb) => {
+ callbacks.set(dir, cb);
+ return subscription;
+ });
+ return callbacks;
+}
+
+test.serial("create: subscribes each project definition directory with ui5.yaml + package.json", async (t) => {
+ captureCallbacks();
+ const appRoot = fixturePath("/app");
+ const depARoot = fixturePath("/deps/a");
+ const depBRoot = fixturePath("/deps/b");
+ const graph = createGraph(
+ {name: "root", rootPath: appRoot},
+ [{name: "dep-a", rootPath: depARoot}, {name: "dep-b", rootPath: depBRoot}]
+ );
+
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+
+ const dirs = subscribeStub.getCalls().map((c) => c.args[0]).sort();
+ t.deepEqual(dirs, [appRoot, depARoot, depBRoot].sort(), "each project root is subscribed directly");
+ for (const call of subscribeStub.getCalls()) {
+ t.deepEqual(call.args[2].ignore, ["**/node_modules/**", "**/.git/**"], "ignore globs passed");
+ }
+
+ await watcher.destroy();
+});
+
+test.serial("create: project roots below node_modules are watched without the node_modules ignore", async (t) => {
+ captureCallbacks();
+ const appRoot = fixturePath("/app");
+ const depRoot = fixturePath("/app/node_modules/@scope/lib");
+ const graph = createGraph(
+ {name: "root", rootPath: appRoot},
+ [{name: "@scope/lib", rootPath: depRoot}]
+ );
+
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+
+ const appCall = subscribeStub.getCalls().find((c) => c.args[0] === appRoot);
+ const depCall = subscribeStub.getCalls().find((c) => c.args[0] === depRoot);
+ t.truthy(appCall, "root project directory subscribed");
+ t.truthy(depCall, "dependency project directory subscribed");
+ t.deepEqual(appCall.args[2].ignore, ["**/node_modules/**", "**/.git/**"],
+ "regular project roots keep the node_modules ignore");
+ t.deepEqual(depCall.args[2].ignore, ["**/.git/**"],
+ "project roots inside node_modules must see their own definition files");
+
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+ const clock = sinon.useFakeTimers();
+ const depUi5Yaml = fixtureFile("/app/node_modules/@scope/lib", "ui5.yaml");
+ depCall.args[1](null, [{type: "update", path: depUi5Yaml}]);
+ clock.tick(600);
+ clock.restore();
+ t.deepEqual(emitted[0], {eventType: "update", filePath: depUi5Yaml},
+ "dependency definition event below node_modules emits");
+
+ await watcher.destroy();
+});
+
+test.serial("create: shared parent directory is subscribed once", async (t) => {
+ captureCallbacks();
+ const monoRoot = fixturePath("/mono");
+ // Two projects under the same directory: only one subscription for that dir.
+ const graph = createGraph(
+ {name: "root", rootPath: monoRoot},
+ [{name: "dep-a", rootPath: monoRoot}]
+ );
+
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+
+ t.is(subscribeStub.callCount, 1, "distinct directory subscribed once");
+ t.is(subscribeStub.firstCall.args[0], monoRoot);
+
+ await watcher.destroy();
+});
+
+test.serial("create: custom rootConfigPath replaces root ui5.yaml and subscribes its directory", async (t) => {
+ captureCallbacks();
+ const appRoot = fixturePath("/app");
+ const configPath = fixtureFile("/configs", "custom.yaml");
+ const configDir = path.dirname(configPath);
+ const graph = createGraph({name: "root", rootPath: appRoot});
+
+ const watcher = await ProjectDefinitionWatcher.create({
+ graph, rootConfigPath: configPath, cwd: appRoot
+ });
+
+ const dirs = subscribeStub.getCalls().map((c) => c.args[0]).sort();
+ t.deepEqual(dirs, [appRoot, configDir].sort(), "subscribes the custom config's directory too");
+
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+
+ const clock = sinon.useFakeTimers();
+ // The root's default ui5.yaml must NOT be watched.
+ const rootCb = subscribeStub.getCalls().find((c) => c.args[0] === appRoot).args[1];
+ rootCb(null, [{type: "update", path: fixtureFile("/app", "ui5.yaml")}]);
+ clock.tick(600);
+ t.is(emitted.length, 0, "default root ui5.yaml is not watched when a custom config is set");
+
+ // The custom config must be watched.
+ const configCb = subscribeStub.getCalls().find((c) => c.args[0] === configDir).args[1];
+ configCb(null, [{type: "update", path: configPath}]);
+ clock.tick(600);
+ clock.restore();
+ t.is(emitted.length, 1, "custom config change emits");
+
+ await watcher.destroy();
+});
+
+test.serial("create: relative rootConfigPath is resolved against cwd", async (t) => {
+ captureCallbacks();
+ const appRoot = fixturePath("/app");
+ const customConfigPath = fixtureFile("/app", "custom", "ui5.yaml");
+ const graph = createGraph({name: "root", rootPath: appRoot});
+
+ const watcher = await ProjectDefinitionWatcher.create({
+ graph, rootConfigPath: "custom/ui5.yaml", cwd: appRoot
+ });
+
+ const dirs = subscribeStub.getCalls().map((c) => c.args[0]);
+ t.true(dirs.includes(appRoot), "project root subscription covers relative config below cwd");
+
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+ const clock = sinon.useFakeTimers();
+ subscribeStub.firstCall.args[1](null, [{type: "update", path: customConfigPath}]);
+ clock.tick(600);
+ clock.restore();
+ t.deepEqual(emitted[0], {eventType: "update", filePath: customConfigPath},
+ "relative config resolved against cwd");
+
+ await watcher.destroy();
+});
+
+test.serial("create: workspaceConfigPath included (default filename applied) when set", async (t) => {
+ captureCallbacks();
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const workspaceRoot = fixturePath("/ws");
+
+ // null → active, use default filename ui5-workspace.yaml at cwd.
+ const watcher = await ProjectDefinitionWatcher.create({graph, workspaceConfigPath: null, cwd: workspaceRoot});
+
+ const wsCall = subscribeStub.getCalls().find((c) => c.args[0] === workspaceRoot);
+ t.truthy(wsCall, "workspace directory subscribed");
+
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+ const clock = sinon.useFakeTimers();
+ wsCall.args[1](null, [{type: "create", path: fixtureFile("/ws", "ui5-workspace.yaml")}]);
+ clock.tick(600);
+ clock.restore();
+ t.is(emitted.length, 1, "workspace config change emits");
+
+ await watcher.destroy();
+});
+
+test.serial("create: workspaceConfigPath undefined skips the workspace file", async (t) => {
+ captureCallbacks();
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const workspaceRoot = fixturePath("/ws");
+
+ const watcher = await ProjectDefinitionWatcher.create({graph, workspaceConfigPath: undefined, cwd: workspaceRoot});
+
+ t.falsy(subscribeStub.getCalls().find((c) => c.args[0] === workspaceRoot), "workspace dir not subscribed");
+ await watcher.destroy();
+});
+
+test.serial("create: dependencyDefinitionPath is watched when present", async (t) => {
+ captureCallbacks();
+ const appRoot = fixturePath("/app");
+ const graph = createGraph({name: "root", rootPath: appRoot});
+
+ const watcher = await ProjectDefinitionWatcher.create({
+ graph, dependencyDefinitionPath: "deps.yaml", cwd: appRoot
+ });
+
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+ const appCall = subscribeStub.getCalls().find((c) => c.args[0] === appRoot);
+ const clock = sinon.useFakeTimers();
+ appCall.args[1](null, [{type: "update", path: fixtureFile("/app", "deps.yaml")}]);
+ clock.tick(600);
+ clock.restore();
+ t.is(emitted.length, 1, "static dependency definition change emits");
+
+ await watcher.destroy();
+});
+
+test.serial("filtering: non-definition file events do not emit", async (t) => {
+ captureCallbacks();
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+ const callback = subscribeStub.firstCall.args[1];
+
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+
+ const clock = sinon.useFakeTimers();
+ callback(null, [
+ {type: "update", path: fixtureFile("/app", "src", "main.js")},
+ {type: "create", path: fixtureFile("/app", "node_modules", "foo", "package.json")}
+ ]);
+ clock.tick(600);
+ clock.restore();
+
+ t.is(emitted.length, 0, "source file and node_modules path filtered out");
+ await watcher.destroy();
+});
+
+test.serial("filtering: a watched ui5.yaml event emits", async (t) => {
+ captureCallbacks();
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const ui5YamlPath = fixtureFile("/app", "ui5.yaml");
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+ const callback = subscribeStub.firstCall.args[1];
+
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+
+ const clock = sinon.useFakeTimers();
+ callback(null, [{type: "update", path: ui5YamlPath}]);
+ clock.tick(600);
+ clock.restore();
+
+ t.is(emitted.length, 1, "watched ui5.yaml emits");
+ t.deepEqual(emitted[0], {eventType: "update", filePath: ui5YamlPath});
+ await watcher.destroy();
+});
+
+test.serial("settle: a burst within the window emits definitionChanged exactly once", async (t) => {
+ captureCallbacks();
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+ const callback = subscribeStub.firstCall.args[1];
+
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+
+ const clock = sinon.useFakeTimers();
+ // A checkout burst: ui5.yaml + package.json + a source, spread across batches within the window.
+ callback(null, [{type: "update", path: fixtureFile("/app", "ui5.yaml")}]);
+ clock.tick(200);
+ callback(null, [{type: "update", path: fixtureFile("/app", "package.json")}]);
+ clock.tick(200);
+ callback(null, [{type: "update", path: fixtureFile("/app", "src", "main.js")}]);
+ clock.tick(200); // 200 < 550 from the source event that extended the active burst
+ t.is(emitted.length, 0, "not yet emitted mid-burst");
+ clock.tick(600); // quiet past the window
+ t.is(emitted.length, 1, "collapsed to a single emit");
+
+ // A later, separate change emits again.
+ callback(null, [{type: "update", path: fixtureFile("/app", "ui5.yaml")}]);
+ clock.tick(600);
+ t.is(emitted.length, 2, "later change emits again");
+ clock.restore();
+
+ await watcher.destroy();
+});
+
+test.serial("settle: non-definition events extend an active definition burst", async (t) => {
+ captureCallbacks();
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const ui5YamlPath = fixtureFile("/app", "ui5.yaml");
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+ const callback = subscribeStub.firstCall.args[1];
+
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+
+ const clock = sinon.useFakeTimers();
+ callback(null, [{type: "update", path: ui5YamlPath}]);
+ clock.tick(500);
+ callback(null, [{type: "update", path: fixtureFile("/app", "src", "main.js")}]);
+
+ clock.tick(100);
+ t.is(emitted.length, 0, "the source event reset the timer before the original window fired");
+
+ clock.tick(449);
+ t.is(emitted.length, 0, "still waiting for the full quiet window after the source event");
+
+ clock.tick(1);
+ t.is(emitted.length, 1, "emits once the extended quiet window elapses");
+ t.deepEqual(emitted[0], {eventType: "update", filePath: ui5YamlPath},
+ "the payload remains the watched definition event");
+ clock.restore();
+
+ await watcher.destroy();
+});
+
+test.serial("settle: events below a nested dependency root extend an active definition burst", async (t) => {
+ captureCallbacks();
+ const appRoot = fixturePath("/repo/app");
+ const themeRoot = fixturePath("/repo/app/node_modules/@openui5/themelib_sap_horizon");
+ const graph = createGraph(
+ {name: "root", rootPath: appRoot},
+ [{name: "themelib_sap_horizon", rootPath: themeRoot}]
+ );
+ const ui5YamlPath = fixtureFile("/repo/app", "ui5.yaml");
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+
+ const appCallback = subscribeStub.getCalls().find((c) => c.args[0] === appRoot).args[1];
+ const themeCallback = subscribeStub.getCalls().find((c) => c.args[0] === themeRoot).args[1];
+ const emitted = [];
+ watcher.on("definitionChanged", (e) => emitted.push(e));
+
+ const clock = sinon.useFakeTimers();
+ appCallback(null, [{type: "update", path: ui5YamlPath}]);
+ clock.tick(500);
+ themeCallback(null, [{
+ type: "create",
+ path: fixtureFile("/repo/app/node_modules/@openui5/themelib_sap_horizon", "src", "Base.less")
+ }]);
+
+ clock.tick(100);
+ t.is(emitted.length, 0, "the dependency-root event reset the timer before the original window fired");
+
+ clock.tick(450);
+ t.is(emitted.length, 1, "emits once the extended quiet window elapses");
+ t.deepEqual(emitted[0], {eventType: "update", filePath: ui5YamlPath},
+ "the payload remains the watched definition event");
+ clock.restore();
+
+ await watcher.destroy();
+});
+
+test.serial("leading edge: definitionChanging fires once on the first event of a burst, before definitionChanged",
+ async (t) => {
+ captureCallbacks();
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const ui5YamlPath = fixtureFile("/app", "ui5.yaml");
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+ const callback = subscribeStub.firstCall.args[1];
+
+ const changing = [];
+ const changed = [];
+ watcher.on("definitionChanging", (e) => changing.push(e));
+ watcher.on("definitionChanged", (e) => changed.push(e));
+
+ const clock = sinon.useFakeTimers();
+ // First watched event of a burst: definitionChanging fires immediately, definitionChanged does not.
+ callback(null, [{type: "update", path: ui5YamlPath}]);
+ t.is(changing.length, 1, "definitionChanging fires on the leading edge");
+ t.deepEqual(changing[0], {eventType: "update", filePath: ui5YamlPath});
+ t.is(changed.length, 0, "definitionChanged has not fired yet");
+
+ // Further events within the window do not re-fire definitionChanging.
+ clock.tick(200);
+ callback(null, [{type: "update", path: fixtureFile("/app", "package.json")}]);
+ t.is(changing.length, 1, "definitionChanging fires once per burst, not per event");
+
+ // Once quiet, definitionChanged fires once.
+ clock.tick(600);
+ t.is(changed.length, 1, "definitionChanged fires after the settle window");
+
+ // A separate, later burst re-arms the leading edge.
+ callback(null, [{type: "update", path: ui5YamlPath}]);
+ t.is(changing.length, 2, "a new burst fires definitionChanging again");
+ clock.tick(600);
+ clock.restore();
+
+ await watcher.destroy();
+ });
+
+test.serial("leading edge: a filtered (non-definition) event does not fire definitionChanging", async (t) => {
+ captureCallbacks();
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+ const callback = subscribeStub.firstCall.args[1];
+
+ const changing = [];
+ watcher.on("definitionChanging", (e) => changing.push(e));
+
+ const clock = sinon.useFakeTimers();
+ callback(null, [{type: "update", path: fixtureFile("/app", "src", "main.js")}]);
+ clock.tick(600);
+ clock.restore();
+
+ t.is(changing.length, 0, "a non-definition file does not fire the leading edge");
+ await watcher.destroy();
+});
+
+test.serial("recovery: a watcher error tears down and re-subscribes", async (t) => {
+ const sub1 = createMockSubscription();
+ const sub2 = createMockSubscription();
+ let cb;
+ subscribeStub.onFirstCall().callsFake(async (_dir, callback) => {
+ cb = callback;
+ return sub1;
+ });
+ subscribeStub.onSecondCall().resolves(sub2);
+
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+
+ t.is(subscribeStub.callCount, 1);
+ cb(new Error("watcher blew up"));
+ // Let the async recovery settle.
+ await new Promise((resolve) => setImmediate(resolve));
+
+ t.true(sub1.unsubscribe.calledOnce, "old subscription torn down");
+ t.is(subscribeStub.callCount, 2, "re-subscribed");
+
+ await watcher.destroy();
+});
+
+test.serial("recovery: loop protection escalates to error after the max attempts", async (t) => {
+ const subs = [];
+ subscribeStub.callsFake(async () => {
+ const s = createMockSubscription();
+ subs.push(s);
+ return s;
+ });
+
+ const graph = createGraph({name: "root", rootPath: fixturePath("/app")});
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+
+ const errors = [];
+ watcher.on("error", (err) => errors.push(err));
+
+ // Drive repeated failures. Each recovery re-subscribes, exposing a fresh callback via the
+ // latest subscribe call. Trigger via the callback captured at subscribe time.
+ const callbacks = () => subscribeStub.getCalls().map((c) => c.args[1]);
+ // 5 recoveries allowed, the 6th escalates.
+ for (let i = 0; i < 6; i++) {
+ const cbs = callbacks();
+ cbs[cbs.length - 1](new Error(`fail ${i}`));
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+
+ t.is(errors.length, 1, "escalated once after exceeding the budget");
+ await watcher.destroy();
+});
+
+test.serial("destroy: unsubscribes all, is idempotent", async (t) => {
+ const sub = createMockSubscription();
+ subscribeStub.resolves(sub);
+
+ const graph = createGraph(
+ {name: "root", rootPath: fixturePath("/app")},
+ [{name: "dep", rootPath: fixturePath("/dep")}]
+ );
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+
+ await watcher.destroy();
+ await watcher.destroy();
+
+ t.is(sub.unsubscribe.callCount, subscribeStub.callCount,
+ "each subscription unsubscribed exactly once across two destroy calls");
+});
+
+test.serial("destroy: aggregates unsubscribe failures into an error", async (t) => {
+ const subA = createMockSubscription();
+ const subB = createMockSubscription();
+ subA.unsubscribe = sinon.stub().rejects(new Error("unsub A failed"));
+ subB.unsubscribe = sinon.stub().resolves();
+ subscribeStub.onFirstCall().resolves(subA);
+ subscribeStub.onSecondCall().resolves(subB);
+
+ const graph = createGraph(
+ {name: "root", rootPath: fixturePath("/app")},
+ [{name: "dep", rootPath: fixturePath("/dep")}]
+ );
+ const watcher = await ProjectDefinitionWatcher.create({graph});
+
+ const errorSpy = sinon.spy();
+ watcher.on("error", errorSpy);
+
+ await watcher.destroy();
+
+ t.is(errorSpy.callCount, 1, "single aggregated error emitted");
+ t.true(errorSpy.firstCall.args[0] instanceof AggregateError);
+ t.is(errorSpy.firstCall.args[0].errors.length, 1);
+});
diff --git a/packages/project/test/lib/graph/ProjectGraphSettler.js b/packages/project/test/lib/graph/ProjectGraphSettler.js
new file mode 100644
index 00000000000..4734116849b
--- /dev/null
+++ b/packages/project/test/lib/graph/ProjectGraphSettler.js
@@ -0,0 +1,204 @@
+import test from "ava";
+import sinon from "sinon";
+import esmock from "esmock";
+import path from "node:path";
+
+let waitForProjectGraphQuiet;
+let subscribeStub;
+let statStub;
+
+const fixturePath = (p) => path.resolve(p);
+
+test.before(async () => {
+ subscribeStub = sinon.stub();
+ statStub = sinon.stub();
+ ({waitForProjectGraphQuiet} = await esmock("../../../lib/graph/ProjectGraphSettler.js", {
+ "node:fs/promises": {
+ stat: statStub
+ },
+ "@parcel/watcher": {
+ default: {
+ subscribe: subscribeStub
+ }
+ }
+ }));
+});
+
+test.afterEach.always(() => {
+ sinon.restore();
+ subscribeStub.reset();
+ statStub.reset();
+});
+
+function createMockSubscription() {
+ return {
+ unsubscribe: sinon.stub().resolves()
+ };
+}
+
+function createGraph(projects) {
+ return {
+ traverseBreadthFirst: async (cb) => {
+ for (const p of projects) {
+ await cb({project: {getRootPath: () => p.rootPath}});
+ }
+ }
+ };
+}
+
+function markExistingDirs(dirs) {
+ const existingDirs = new Set(dirs.map((dir) => path.resolve(dir)));
+ statStub.callsFake(async (dir) => {
+ if (existingDirs.has(path.resolve(dir))) {
+ return {isDirectory: () => true};
+ }
+ const err = new Error(`ENOENT: no such file or directory, stat '${dir}'`);
+ err.code = "ENOENT";
+ throw err;
+ });
+}
+
+async function waitForSubscriptions(count, clock) {
+ for (let i = 0; i < 10; i++) {
+ if (subscribeStub.callCount >= count) {
+ return;
+ }
+ if (clock) {
+ await clock.tickAsync(0);
+ } else {
+ await Promise.resolve();
+ }
+ }
+}
+
+test.serial("waitForProjectGraphQuiet: subscribes pruned project roots without node_modules ignore", async (t) => {
+ const subscriptions = [];
+ subscribeStub.callsFake(async () => {
+ const subscription = createMockSubscription();
+ subscriptions.push(subscription);
+ return subscription;
+ });
+ const appRoot = fixturePath("/repo/app");
+ const nestedDepRoot = fixturePath("/repo/app/node_modules/@scope/lib");
+ const externalDepRoot = fixturePath("/external/lib");
+ const graph = createGraph([
+ {rootPath: appRoot},
+ {rootPath: nestedDepRoot},
+ {rootPath: externalDepRoot},
+ ]);
+ markExistingDirs([appRoot, nestedDepRoot, externalDepRoot]);
+ const clock = sinon.useFakeTimers();
+
+ const wait = waitForProjectGraphQuiet(graph, {settleMs: 550});
+ await waitForSubscriptions(2, clock);
+
+ const dirs = subscribeStub.getCalls().map((c) => c.args[0]).sort();
+ t.deepEqual(dirs, [appRoot, externalDepRoot].sort(),
+ "nested roots are covered by an already selected parent root");
+ for (const call of subscribeStub.getCalls()) {
+ t.deepEqual(call.args[2].ignore, ["**/.git/**"], "the recovery watcher observes node_modules");
+ }
+
+ await clock.tickAsync(550);
+ await wait;
+ t.true(subscriptions.every((subscription) => subscription.unsubscribe.calledOnce),
+ "subscriptions are torn down after settling");
+ clock.restore();
+});
+
+test.serial("waitForProjectGraphQuiet: observes last-good roots missing from the candidate graph", async (t) => {
+ const subscription = createMockSubscription();
+ let callback;
+ subscribeStub.callsFake(async (_dir, cb) => {
+ callback = cb;
+ return subscription;
+ });
+ const testsuiteRoot = fixturePath("/repo/src/testsuite");
+ const themeRoot = fixturePath("/repo/src/themelib_sap_horizon");
+ const srcRoot = fixturePath("/repo/src");
+ const candidateGraph = createGraph([{rootPath: testsuiteRoot}]);
+ const lastGoodGraph = createGraph([{rootPath: testsuiteRoot}, {rootPath: themeRoot}]);
+ markExistingDirs([srcRoot, testsuiteRoot]);
+ const clock = sinon.useFakeTimers();
+ let resolved = false;
+
+ const wait = waitForProjectGraphQuiet([candidateGraph, lastGoodGraph], {settleMs: 550}).then(() => {
+ resolved = true;
+ });
+ await waitForSubscriptions(1, clock);
+
+ t.is(subscribeStub.firstCall.args[0], srcRoot,
+ "the missing last-good project root is covered by its nearest existing ancestor");
+
+ await clock.tickAsync(500);
+ callback(null, [{type: "create", path: path.join(themeRoot, "src", "sap_horizon_dark", "library.less")}]);
+
+ await clock.tickAsync(100);
+ t.false(resolved, "the restored last-good root reset the timer before the original window fired");
+
+ await clock.tickAsync(450);
+ await wait;
+ t.true(resolved, "settled after the restored root quieted");
+ clock.restore();
+});
+
+// Desired behavior for a still-uncovered branch-switch edge case: if the target branch introduces
+// a project that was unknown to both the previous last-good graph and the early candidate graph,
+// recovery needs to observe it once it surfaces. The settler only watches roots it is handed, so
+// this guarantee lives one level up in the Supervisor's recovery convergence loop (which re-resolves
+// until the root set stops growing, feeding each expanded graph back to the settler). See the
+// Supervisor test "degraded recovery observes a target-only root across convergence iterations".
+
+test.serial("waitForProjectGraphQuiet: file events reset the quiet window", async (t) => {
+ const subscription = createMockSubscription();
+ let callback;
+ subscribeStub.callsFake(async (_dir, cb) => {
+ callback = cb;
+ return subscription;
+ });
+ const appRoot = fixturePath("/repo/app");
+ const graph = createGraph([{rootPath: appRoot}]);
+ markExistingDirs([appRoot]);
+ const clock = sinon.useFakeTimers();
+ let resolved = false;
+
+ const wait = waitForProjectGraphQuiet(graph, {settleMs: 550}).then(() => {
+ resolved = true;
+ });
+ await waitForSubscriptions(1, clock);
+
+ await clock.tickAsync(500);
+ callback(null, [{type: "create", path: path.join(appRoot, "node_modules/@scope/lib/src/file.js")}]);
+
+ await clock.tickAsync(100);
+ t.false(resolved, "the event reset the timer before the original window fired");
+
+ await clock.tickAsync(449);
+ t.false(resolved, "still waiting for the full quiet window after the event");
+
+ await clock.tickAsync(1);
+ await wait;
+ t.true(resolved, "settled after the extended quiet window elapsed");
+ t.true(subscription.unsubscribe.calledOnce, "subscription is torn down after settling");
+ clock.restore();
+});
+
+test.serial("waitForProjectGraphQuiet: watcher errors reject and tear down subscriptions", async (t) => {
+ const subscription = createMockSubscription();
+ let callback;
+ subscribeStub.callsFake(async (_dir, cb) => {
+ callback = cb;
+ return subscription;
+ });
+ const graph = createGraph([{rootPath: fixturePath("/repo/app")}]);
+ markExistingDirs([fixturePath("/repo/app")]);
+ const wait = waitForProjectGraphQuiet(graph, {settleMs: 550});
+ await waitForSubscriptions(1);
+
+ const err = new Error("watcher failed");
+ callback(err);
+
+ const thrown = await t.throwsAsync(wait);
+ t.is(thrown, err);
+ t.true(subscription.unsubscribe.calledOnce, "subscription is torn down after the watcher error");
+});
diff --git a/packages/project/test/lib/graph/projectGraphBuilder.js b/packages/project/test/lib/graph/projectGraphBuilder.js
index 67b36ed4647..56d46db0a17 100644
--- a/packages/project/test/lib/graph/projectGraphBuilder.js
+++ b/packages/project/test/lib/graph/projectGraphBuilder.js
@@ -966,11 +966,11 @@ test("Multiple dependencies to different module containing the same extension",
});
});
-test.serial("Emits ui5.project-resolved with the root project's shape", async (t) => {
+test.serial("Emits ui5.project-resolve-succeeded with the root project's shape", async (t) => {
const events = [];
const listener = (evt) => events.push(evt);
- process.on("ui5.project-resolved", listener);
- t.teardown(() => process.off("ui5.project-resolved", listener));
+ process.on("ui5.project-resolve-succeeded", listener);
+ t.teardown(() => process.off("ui5.project-resolve-succeeded", listener));
t.context.getRootNode.resolves(createNode({
id: "id1",
@@ -979,7 +979,7 @@ test.serial("Emits ui5.project-resolved with the root project's shape", async (t
await projectGraphBuilder(t.context.provider);
- t.is(events.length, 1, "ui5.project-resolved emitted exactly once");
+ t.is(events.length, 1, "ui5.project-resolve-succeeded emitted exactly once");
t.is(events[0].name, "root.project");
t.is(events[0].type, "library");
t.is(events[0].version, "1.0.0");
diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js
index 684e8634a84..7095e972c3e 100644
--- a/packages/project/test/lib/package-exports.js
+++ b/packages/project/test/lib/package-exports.js
@@ -13,13 +13,14 @@ 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, 14);
+ t.is(Object.keys(packageJson.exports).length, 17);
});
// Public API contract (exported modules)
[
"config/Configuration",
"build/cache/Cache",
+ "build/helpers/RecoveryBudget",
"specifications/Specification",
"specifications/SpecificationVersion",
"ui5Framework/Openui5Resolver",
@@ -29,6 +30,8 @@ test("check number of exports", (t) => {
"validation/validator",
"validation/ValidationError",
"graph/ProjectGraph",
+ "graph/ProjectDefinitionWatcher",
+ "graph/ProjectGraphSettler",
"graph/projectGraphBuilder",
{exportedSpecifier: "graph", mappedModule: "../../lib/graph/graph.js"},
].forEach((v) => {
diff --git a/packages/server/lib/middleware/MiddlewareManager.js b/packages/server/lib/middleware/MiddlewareManager.js
index a420c9e6205..f1d013f4bb1 100644
--- a/packages/server/lib/middleware/MiddlewareManager.js
+++ b/packages/server/lib/middleware/MiddlewareManager.js
@@ -255,14 +255,18 @@ class MiddlewareManager {
await this.addMiddleware("discovery", {
mountPath: "/discovery"
});
- // Diverts document navigations to the terminal error handler while the build server
- // is globally in ERROR. Placed before serveResources so it can preempt an otherwise-
- // successful 200; after liveReloadClient so the client script is still served during
- // ERROR and the error page can auto-reload once the source is fixed.
+ // Diverts requests to the terminal error handler. While a project is globally in ERROR,
+ // only document navigations are diverted (a failing subresource keeps its per-project 500).
+ // While the stack is degraded after a failed re-resolve, every request is diverted, since
+ // the surviving BuildServer would otherwise block reads until the source burst settles.
+ // Placed before serveResources so it can preempt an otherwise-successful 200; after
+ // liveReloadClient so the client script is still served and the error page can auto-reload
+ // once the source is fixed.
await this.addMiddleware("serveBuildError", {
wrapperCallback: ({middleware}) =>
() => middleware({
- getServeError: this.options.getServeError
+ getServeError: this.options.getServeError,
+ getDegradedError: this.options.getDegradedError
})
});
await this.addMiddleware("serveResources", {
diff --git a/packages/server/lib/middleware/serveBuildError.js b/packages/server/lib/middleware/serveBuildError.js
index edfa116708f..d2c004138e9 100644
--- a/packages/server/lib/middleware/serveBuildError.js
+++ b/packages/server/lib/middleware/serveBuildError.js
@@ -1,34 +1,53 @@
import isDocumentNavigation from "./helper/isDocumentNavigation.js";
/**
- * Creates a middleware that diverts browser document navigations to the terminal error
- * handler while the build server is globally in its ERROR state.
+ * Creates a middleware that diverts requests to the terminal error handler while the build
+ * server cannot serve them.
*
- * The per-project reader (serveResources) only surfaces the build-error page
- * when the requested path maps to the failed project. A navigation to an unaffected
- * resource (the app's index.html while a dependency library is broken) would
- * otherwise serve a normal 200 even though the server as a whole is unusable. This gate
- * consults the server-level error and, for document navigations only, calls
- * next(err) so the error page shows regardless of which resource was requested.
+ * Two failure modes, checked in order:
*
- * Asset/XHR/fetch loads pass through and keep their per-project behavior, so a browser
- * never receives an HTML error page for a failing subresource. Rendering stays in the
- * terminal errorHandler; this middleware only decides whether to divert.
+ * Degraded (getDegradedError): a project re-resolve failed and the
+ * last-good serving stack is kept alive, but its backing graph no longer matches the project
+ * definition on disk. The surviving build server's reader would block every request until the
+ * definition change's source burst settles, so every request is diverted, not only
+ * document navigations. This preempts the per-project gate below.
*
- * Registered before serveResources so it can preempt an otherwise-successful
- * 200. It must be a normal 3-argument middleware: the 4-argument errorHandler
- * is only reached once something upstream calls next(err), which never happens
- * for a navigation that would otherwise succeed.
+ * Global ERROR (getServeError): a project is in its ERROR state.
+ * The per-project reader (serveResources) only surfaces the build-error page when the
+ * requested path maps to the failed project. A navigation to an unaffected resource (the app's
+ * index.html while a dependency library is broken) would otherwise serve a normal 200
+ * even though the server as a whole is unusable. This gate consults the server-level error and,
+ * for document navigations only, calls next(err) so the error page shows regardless of
+ * which resource was requested. Asset/XHR/fetch loads pass through and keep their per-project
+ * behavior, so a browser never receives an HTML error page for a failing subresource.
+ *
+ * Rendering stays in the terminal errorHandler; this middleware only decides whether
+ * to divert. The error handler branches on the same document-navigation signal, so a diverted
+ * subresource gets a plain-text 500 rather than an HTML page executed as script.
+ *
+ * Registered before serveResources so it can preempt an otherwise-successful 200. It
+ * must be a normal 3-argument middleware: the 4-argument errorHandler is only reached
+ * once something upstream calls next(err), which never happens for a request that
+ * would otherwise succeed.
*
* @module @ui5/server/middleware/serveBuildError
* @param {object} parameters Parameters
* @param {Function} [parameters.getServeError] Accessor returning the captured server-level
- * error, or null when the server is not in ERROR. When omitted, the middleware
- * passes every request through.
+ * error, or null when the server is not in ERROR. When omitted, the per-project
+ * gate passes every request through.
+ * @param {Function} [parameters.getDegradedError] Accessor returning a supervisor-level error
+ * while the serving stack is degraded after a failed re-resolve, or a falsy value otherwise.
+ * When set, every request is diverted. When omitted, the degraded gate is inert.
* @returns {Function} Express middleware function
*/
-function createMiddleware({getServeError} = {}) {
+function createMiddleware({getServeError, getDegradedError} = {}) {
return function serveBuildError(req, res, next) {
+ // Degraded: divert every request, before the per-project navigation gate.
+ const degradedError = getDegradedError?.();
+ if (degradedError) {
+ next(degradedError);
+ return;
+ }
const serveError = getServeError?.();
if (serveError && isDocumentNavigation(req)) {
next(serveError);
diff --git a/packages/server/lib/serve/Supervisor.js b/packages/server/lib/serve/Supervisor.js
new file mode 100644
index 00000000000..0dc416f5989
--- /dev/null
+++ b/packages/server/lib/serve/Supervisor.js
@@ -0,0 +1,462 @@
+import http from "node:http";
+import path from "node:path";
+import process from "node:process";
+import {EventEmitter} from "node:events";
+import {getLogger} from "@ui5/logger";
+import ProjectDefinitionWatcher, {DEFINITION_CHANGED_SETTLE_MS} from "@ui5/project/graph/ProjectDefinitionWatcher";
+import {waitForProjectGraphQuiet} from "@ui5/project/graph/ProjectGraphSettler";
+import RecoveryBudget from "@ui5/project/build/helpers/RecoveryBudget";
+import buildApp from "./stack.js";
+import attachLiveReloadServer from "../liveReload/server.js";
+import {listen, addSsl, announceListening} from "./httpListener.js";
+
+const log = getLogger("server:Supervisor");
+
+// Upper bound on convergence-loop iterations within one recovery swap. Each iteration already paces
+// itself on a graph-quiet settle window, so this only guards against a pathological branch whose
+// resolved project set keeps growing; RecoveryBudget bounds the number of recovery swaps themselves.
+const RECOVERY_MAX_ITERATIONS = 10;
+
+/**
+ * Owns the stable HTTP front door for a served project and re-creates the serving stack
+ * (graph + Express app + BuildServer) when the project definition changes.
+ *
+ * The port is bound once. Every request is routed through a stable trampoline to the
+ * current Express app; a re-initialization swaps that app behind the trampoline,
+ * so the socket, the bound port, and connected live-reload clients survive the swap.
+ *
+ * Re-initialization is build-new-then-swap: the new graph is resolved and
+ * the new app built before the old one is torn down. If the new definition fails to resolve
+ * (e.g. an invalid ui5.yaml), the previous working app keeps serving.
+ *
+ * @private
+ */
+class Supervisor extends EventEmitter {
+ #config;
+ #graphFactory;
+ #error;
+
+ // Set when a re-resolve fails and the last-good stack keeps serving: the served graph no longer
+ // matches the project definition on disk. Read live by every stack's serveBuildError gate via
+ // #getDegradedError, so a stack built before the failed swap still diverts HTML navigations to
+ // the error page. Cleared once a re-resolve swaps in a healthy stack.
+ #degradedError = null;
+
+ #httpServer = null;
+ #port = null;
+
+ // The current serving stack: {app, buildServer, liveReloadOptions}. Reassigned on swap; the
+ // trampoline reads #stack.app on every request so a swap retargets transparently.
+ #stack = null;
+ #currentGraph = null;
+
+ // Stable emitter live-reload subscribes to once; its upstream is retargeted on swap
+ // so connected browsers stay connected across a re-init.
+ #sourcesChangedRelay = new EventEmitter();
+ #relayUnsubscribe = null;
+ #liveReloadHandle = null;
+
+ // Watches the project-definition files and drives reinitialize() on a change. Owned by the
+ // supervisor (not the BuildServer) so it outlives each swapped-out stack, and re-targeted to
+ // the new graph after every swap.
+ #definitionWatcher = null;
+
+ #destroyed = false;
+ #reinitInProgress = false;
+ #reinitQueued = false;
+ // Loop protection for self-scheduled recovery swaps. Unlike the watchers' use of RecoveryBudget,
+ // this records an attempt when a recovery is scheduled (not when one succeeds), so a persistently
+ // broken branch that never resolves still exhausts the budget and stops auto-retrying instead of
+ // cycling forever. Reset on each definitionChanging so a fresh user action starts with a full
+ // allowance.
+ #recoveryBudget = new RecoveryBudget();
+ // Delayed retry timer for a self-scheduled recovery after a failed swap. Cleared by any explicit
+ // reinitialize() so a real definitionChanged event supersedes the timer.
+ #recoveryTimer = null;
+ #destroyAbortController = new AbortController();
+
+ // Stable reference handed to every stack buildApp() builds. Closes over the supervisor instance
+ // (not a per-stack value), so the surviving stack's serveBuildError reads the current
+ // #degradedError on each request even though it was assembled before the failed swap.
+ #getDegradedError = () => this.#degradedError;
+
+ // Error the current BuildServer rejects held/incoming reader requests with while a definition
+ // change is being re-resolved (see the definitionChanging handler). The HTTP-facing wording
+ // lives here in the server layer; the BuildServer just forwards it to serveResources ->
+ // errorHandler. A fresh instance per call so a stack trace, if captured, points at the trigger.
+ #buildSuspendedError() {
+ const err = new Error(
+ "Project definition changed - re-resolving the project graph. " +
+ "Reload once the server is ready.");
+ err.code = "UI5_DEFINITION_CHANGING";
+ return err;
+ }
+
+ constructor(config, error, {graphFactory} = {}) {
+ super();
+ this.#config = config;
+ this.#error = error;
+ this.#graphFactory = graphFactory;
+ }
+
+ /**
+ * Creates the supervisor, builds the initial serving stack, binds the port, and attaches
+ * the live-reload WebSocket server.
+ *
+ * @param {@ui5/project/graph/ProjectGraph} graph Initial (already resolved) project graph
+ * @param {object} config Resolved server configuration
+ * @param {Function} [error] Error callback for out-of-band BuildServer errors
+ * @param {object} [options]
+ * @param {Function} [options.graphFactory] Async factory returning a fresh ProjectGraph;
+ * required for {@link Supervisor#reinitialize} to do anything
+ * @returns {Promise} The listening supervisor
+ */
+ static async create(graph, config, error, {graphFactory} = {}) {
+ const supervisor = new Supervisor(config, error, {graphFactory});
+ await supervisor.#init(graph);
+ return supervisor;
+ }
+
+ async #init(graph) {
+ const {
+ port: requestedPort, changePortIfInUse = false, h2 = false, key, cert,
+ acceptRemoteConnections = false, liveReload = false,
+ } = this.#config;
+
+ if (h2) {
+ const nodeVersion = parseInt(process.versions.node.split(".")[0], 10);
+ if (nodeVersion >= 24) {
+ log.error("ERROR: With Node v24, usage of HTTP/2 is no longer supported. " +
+ "Please check https://github.com/UI5/cli/issues/327 for updates.");
+ process.exit(1);
+ }
+ }
+
+ // Build the initial stack before binding so a construction failure surfaces to the caller.
+ this.#stack = await buildApp(graph, this.#config, this.#error, this.#getDegradedError);
+ this.#currentGraph = graph;
+
+ // Stable request handler. Reads #stack.app on every request so a swap retargets
+ // transparently without touching the bound socket.
+ const trampoline = (req, res) => this.#stack.app(req, res);
+
+ let port; let server;
+ try {
+ const listenTarget = h2 ?
+ await addSsl({app: trampoline, key, cert}) :
+ http.createServer(trampoline);
+ ({port, server} = await listen(listenTarget, requestedPort, changePortIfInUse, acceptRemoteConnections));
+ } catch (err) {
+ // Release the BuildServer (source watcher + cache handle) before rethrowing so a
+ // failed bind does not leak a running build server.
+ await this.#stack.buildServer.destroy();
+ throw err;
+ }
+ this.#httpServer = server;
+ this.#port = port;
+
+ if (liveReload) {
+ // Attach once to the stable http server, subscribed to the relay rather than the
+ // BuildServer directly, so connected clients persist across swaps.
+ this.#liveReloadHandle = attachLiveReloadServer({
+ httpServer: server,
+ buildServer: this.#sourcesChangedRelay,
+ token: this.#config.webSocketToken,
+ });
+ }
+ this.#relayFrom(this.#stack.buildServer);
+
+ // Arm the definition watcher over the initial graph, after the port is bound and the first
+ // stack is live. Only meaningful with a graphFactory (no factory → reinitialize is a no-op).
+ await this.#startDefinitionWatcher(graph);
+
+ announceListening({port, h2, acceptRemoteConnections});
+ }
+
+ // Creates a definition watcher over the given graph and wires it to reinitialize(). A no-op
+ // without a graphFactory, since reinitialize() cannot re-resolve the graph without one.
+ async #startDefinitionWatcher(graph) {
+ if (!this.#graphFactory) {
+ return;
+ }
+ const {rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd} = this.#config;
+ const watcher = await ProjectDefinitionWatcher.create({
+ graph, rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd,
+ });
+ watcher.on("definitionChanged", () => this.reinitialize());
+ // Leading edge of a definition-file burst: a re-resolve (and a likely version change) is
+ // coming. Blank the interactive console's version slot so the Project region shows a
+ // "resolving…" placeholder until the swap's own resolve repopulates it via
+ // `ui5.project-resolve-succeeded` (or a failed swap releases it; see #swap). Attached here so it
+ // survives watcher re-targeting after each swap, mirroring the definitionChanged wiring.
+ //
+ // Also suspend the current BuildServer's reader serving now, on the leading edge. The
+ // re-resolve and its degraded gate only kick in after the watcher's trailing settle window
+ // (plus the graph resolve); until then, requests would park in the BuildServer awaiting a
+ // build that the checkout's concurrent source burst keeps aborting, hanging for seconds.
+ // Suspending rejects those requests fast instead. Reads #stack.buildServer live, so it always
+ // targets the current stack; resumeReaders() is called on both #swap outcomes below.
+ watcher.on("definitionChanging", () => {
+ // A real definition change supersedes any pending self-scheduled recovery and restores a
+ // full recovery budget: the user just acted, so the next attempt should not be denied by an
+ // allowance spent on the previous branch.
+ this.#clearRecoveryTimer();
+ this.#recoveryBudget = new RecoveryBudget();
+ process.emit("ui5.project-resolve-started");
+ this.#stack.buildServer.suspendReaders(this.#buildSuspendedError());
+ });
+ watcher.on("error", (err) => log.warn(`Definition watcher error: ${err?.message ?? err}`));
+ this.#definitionWatcher = watcher;
+ }
+
+ // Forwards the current BuildServer's sourcesChanged onto the stable relay. Detaches any
+ // previous subscription first so a swapped-out BuildServer stops driving live-reload.
+ #relayFrom(buildServer) {
+ this.#detachRelay();
+ const onSourcesChanged = () => this.#sourcesChangedRelay.emit("sourcesChanged");
+ buildServer.on("sourcesChanged", onSourcesChanged);
+ this.#relayUnsubscribe = () => buildServer.off("sourcesChanged", onSourcesChanged);
+ }
+
+ #detachRelay() {
+ if (this.#relayUnsubscribe) {
+ this.#relayUnsubscribe();
+ this.#relayUnsubscribe = null;
+ }
+ }
+
+ /**
+ * Re-resolves the graph and re-creates the serving stack behind the stable HTTP server.
+ *
+ * Build-new-then-swap: on a resolution/build failure the previous stack keeps serving and
+ * the error is logged (never emitted as a fatal "error"). Overlapping calls
+ * collapse into a single trailing pass.
+ *
+ * @returns {Promise} Resolves once the swap (or the no-op) completes
+ */
+ async reinitialize() {
+ if (this.#destroyed) {
+ return;
+ }
+ this.#clearRecoveryTimer();
+ if (!this.#graphFactory) {
+ log.warn("Cannot re-initialize server: no graph factory was provided");
+ return;
+ }
+ if (this.#reinitInProgress) {
+ // Collapse overlapping requests into one trailing pass against the settled definition.
+ // The version slot stays on the "resolving…" placeholder (armed by definitionChanging)
+ // until the trailing pass resolves and repaints it via `ui5.project-resolve-succeeded`.
+ this.#reinitQueued = true;
+ return;
+ }
+ this.#reinitInProgress = true;
+ try {
+ do {
+ this.#reinitQueued = false;
+ await this.#swap();
+ } while (this.#reinitQueued && !this.#destroyed);
+ } finally {
+ this.#reinitInProgress = false;
+ }
+ }
+
+ #clearRecoveryTimer() {
+ if (this.#recoveryTimer) {
+ clearTimeout(this.#recoveryTimer);
+ this.#recoveryTimer = null;
+ }
+ }
+
+ // Schedules one delayed recovery swap after a failed re-resolve left the stack degraded. Called
+ // unconditionally from the #swap catch: a transient checkout race and a deterministic bad branch
+ // look identical here (the resolve either threw or produced a graph pointing at sources still
+ // landing), so recovery is attempted for both and bounded by RecoveryBudget. The attempt is
+ // recorded now, not on success, so a branch that never resolves still exhausts the budget and
+ // stops retrying (staying degraded until the next definitionChanging opens a fresh allowance).
+ #scheduleDegradedRecovery() {
+ if (this.#destroyed || !this.#recoveryBudget.withinBudget()) {
+ return;
+ }
+ this.#recoveryBudget.recordRecovery();
+ this.#recoveryTimer = setTimeout(() => {
+ this.#recoveryTimer = null;
+ this.reinitialize();
+ }, DEFINITION_CHANGED_SETTLE_MS);
+ }
+
+ // Collects the resolved graph's project root paths as an absolute-path set, for the convergence
+ // check in #convergeRecoveryGraph.
+ async #graphRootPaths(graph) {
+ const roots = new Set();
+ await graph.traverseBreadthFirst(({project}) => {
+ roots.add(path.resolve(project.getRootPath()));
+ });
+ return roots;
+ }
+
+ // Resolves a recovery graph, then settles and re-resolves until two consecutive resolves agree on
+ // the project-root set. A checkout to a branch with a different dependency set can restore a
+ // project's package.json before its sources (or a target-only dependency root the previous graphs
+ // never saw), so a single resolve can produce a graph pointing at a half-restored project. Each
+ // iteration feeds the settler the just-resolved graph plus the last-good graph, so a root that only
+ // appears in the target branch is watched once it surfaces in a resolve, and the next settle waits
+ // for its sources. Convergence is a subset check, not equality: a dependency the target branch
+ // removes shrinks the root set, which still converges.
+ //
+ // Returns the converged graph, or null when superseded by a newer definitionChanged (#reinitQueued)
+ // or aborted by destroy(), in which case #swap adopts nothing. Repeated #graphFactory() calls each
+ // emit `ui5.project-resolve-succeeded`, so the interactive console's version slot repaints per
+ // iteration; harmless and pre-existing (the prior one-shot settle already resolved twice).
+ async #convergeRecoveryGraph() {
+ let prevRoots = null;
+ let resolved;
+ for (let i = 0; i < RECOVERY_MAX_ITERATIONS; i++) {
+ if (this.#destroyed || this.#reinitQueued) {
+ return null;
+ }
+ this.#destroyAbortController.signal.throwIfAborted();
+ resolved = await this.#graphFactory();
+ if (this.#destroyed) {
+ return null;
+ }
+ const roots = await this.#graphRootPaths(resolved);
+ if (prevRoots && roots.isSubsetOf(prevRoots)) {
+ return resolved;
+ }
+ prevRoots = roots;
+ try {
+ await waitForProjectGraphQuiet([resolved, this.#currentGraph], {
+ settleMs: DEFINITION_CHANGED_SETTLE_MS,
+ signal: this.#destroyAbortController.signal,
+ });
+ } catch (err) {
+ if (err?.code === "ABORT_ERR") {
+ return null;
+ }
+ throw err;
+ }
+ }
+ return resolved;
+ }
+
+ async #swap() {
+ const oldStack = this.#stack;
+ // Recovery mode: the surviving stack is degraded from a prior failed swap, so the next resolve
+ // might still observe a checkout in flight. Converge on a stable root set before building
+ // instead of resolving once. A healthy swap (definition edit on a working project) takes the
+ // single-resolve fast path.
+ const wasDegraded = !!this.#degradedError;
+ let newStack;
+ let newGraph;
+ try {
+ newGraph = wasDegraded ? await this.#convergeRecoveryGraph() : await this.#graphFactory();
+ if (this.#destroyed || !newGraph) {
+ // Destroyed, aborted, or superseded by a newer definitionChanged: adopt nothing.
+ return;
+ }
+ newStack = await buildApp(newGraph, this.#config, this.#error, this.#getDegradedError);
+ } catch (err) {
+ if (this.#destroyed) {
+ return;
+ }
+ // Keep the last-good stack serving. A subsequent valid edit will swap cleanly.
+ log.error(`Failed to re-initialize server: ${err?.message ?? err}`);
+ if (err?.stack) {
+ log.verbose(err.stack);
+ }
+ // Flag the surviving stack degraded: its graph no longer matches the definition on disk.
+ // Every stack's serveBuildError gate reads this via #getDegradedError, so HTML navigations
+ // now divert to the error page instead of serving a stale 200 or an opaque 500.
+ this.#degradedError = err;
+ // The leading-edge definitionChanging suspended the old (still-serving) BuildServer's
+ // readers. Now that #degradedError is set, serveBuildError diverts every incoming request
+ // at the middleware, before it reaches the BuildServer, so the reader-level suspend is no
+ // longer needed. Resume it so the flag is not left engaged on a server that keeps serving:
+ // if the user then checks out a valid branch, its readers work normally again.
+ oldStack.buildServer.resumeReaders();
+ // A failed resolve never emits `ui5.project-resolve-succeeded`, so the version slot would keep
+ // the "resolving…" placeholder indefinitely. Release it back to the last-known version
+ // the old (still-serving) stack resolved. Harmless if the failure was in buildApp,
+ // where the resolve already repainted the slot. The message drives the interactive
+ // console's degraded status line.
+ process.emit("ui5.project-resolve-failed", {message: err?.message ?? String(err)});
+ // Schedule one bounded recovery attempt. A transient checkout race recovers on the retry;
+ // a persistently broken branch exhausts the budget and stays degraded until the next change.
+ this.#scheduleDegradedRecovery();
+ return;
+ }
+ if (this.#destroyed) {
+ // Destroyed while building: discard the new stack instead of adopting it.
+ await newStack.buildServer.destroy();
+ return;
+ }
+ // Swap: retarget the trampoline, move live-reload to the new BuildServer, notify clients.
+ this.#stack = newStack;
+ this.#currentGraph = newGraph;
+ // The new stack resolved cleanly: lift any degraded flag a prior failed swap left behind so
+ // the serveBuildError gate stops diverting once the graph matches disk again. Restore a full
+ // recovery budget too: a clean swap means the branch is no longer broken, so a later failure
+ // should start its own recovery allowance rather than inherit this episode's spent attempts.
+ this.#degradedError = null;
+ this.#recoveryBudget = new RecoveryBudget();
+ this.#relayFrom(newStack.buildServer);
+ this.#sourcesChangedRelay.emit("sourcesChanged");
+ // Re-target the definition watcher to the new graph: the project set or their roots may
+ // have changed. A create failure here must not crash the swap; keep serving and log,
+ // mirroring the build-failure path above, so the old watcher keeps driving re-inits.
+ const oldWatcher = this.#definitionWatcher;
+ this.#definitionWatcher = null;
+ try {
+ await this.#startDefinitionWatcher(newGraph);
+ await oldWatcher?.destroy();
+ } catch (err) {
+ log.warn(`Failed to re-target definition watcher: ${err?.message ?? err}`);
+ // Keep the old watcher driving re-inits if the new one failed to arm.
+ this.#definitionWatcher ??= oldWatcher;
+ }
+ // Tear down the old stack. Its BuildServer releases the source watcher and the cache
+ // handle; the new BuildServer already reopened the same (refcounted) cache. Resume its
+ // readers first so a suspend from definitionChanging is not left engaged (defensive: destroy
+ // follows immediately, but keeps the invariant that no path leaves a live server suspended).
+ oldStack.buildServer.resumeReaders();
+ await oldStack.buildServer.destroy();
+ }
+
+ getPort() {
+ return this.#port;
+ }
+
+ /**
+ * Stops the server: closes live-reload, the HTTP socket, and the current BuildServer.
+ * Mirrors the tolerant teardown of the single-shot wrapper: the socket is closed even if
+ * the BuildServer's destroy rejects.
+ *
+ * @param {Function} [callback] Invoked once the HTTP server has closed
+ * @returns {Promise} Resolves once teardown completes
+ */
+ async destroy(callback) {
+ this.#destroyed = true;
+ this.#destroyAbortController.abort();
+ // Stop the definition watcher early so a late event cannot start a re-init mid-teardown.
+ // The reinitialize() #destroyed guard already no-ops such an event; this is defensive.
+ const definitionWatcher = this.#definitionWatcher;
+ this.#definitionWatcher = null;
+ this.#liveReloadHandle?.close();
+ this.#detachRelay();
+ this.#httpServer?.close(callback);
+ this.#clearRecoveryTimer();
+ try {
+ await definitionWatcher?.destroy();
+ } catch (err) {
+ log.verbose(`Error while destroying definition watcher: ${err?.message ?? err}`);
+ }
+ try {
+ await this.#stack?.buildServer.destroy();
+ } catch (err) {
+ log.verbose(`Error while destroying BuildServer: ${err?.message ?? err}`);
+ }
+ }
+}
+
+export default Supervisor;
diff --git a/packages/server/lib/serve/httpListener.js b/packages/server/lib/serve/httpListener.js
new file mode 100644
index 00000000000..03b264086b6
--- /dev/null
+++ b/packages/server/lib/serve/httpListener.js
@@ -0,0 +1,121 @@
+import os from "node:os";
+import portscanner from "portscanner";
+
+/**
+ * HTTP-listener helpers shared between the single-shot {@link module:@ui5/server.serve}
+ * wrapper and the {@link Supervisor}, which binds the port once and swaps the
+ * request handler behind it.
+ *
+ * @private
+ * @module @ui5/server/serve/httpListener
+ */
+
+/**
+ * Binds an HTTP/HTTPS server to a free port and resolves once it is listening.
+ *
+ * @param {object} app The express application (or spdy server) to listen with
+ * @param {number} port Desired port to listen to
+ * @param {boolean} changePortIfInUse If true and the port is already in use, an unused port is searched
+ * @param {boolean} acceptRemoteConnections If true, listens to remote connections and not only to localhost
+ * @returns {Promise