From 32869136ca2ab212b44d13596d35c551c21e43f1 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 20 Jul 2026 18:08:58 +0200 Subject: [PATCH 01/31] refactor(project): Document and trim discardIncrementalState resets Reorder the resets in ProjectBuildCache#discardIncrementalState so the load-bearing ones come first, each annotated with why it is required, and group the two that only exist for completeness (#sourceIndex, #cachedResultSignature) at the end under a single comment: both are overwritten before read on the next build (#sourceIndex by #initSourceIndex, #cachedResultSignature by #findResultCache). Drop the #changedProjectSourcePaths and #writtenResultResourcePaths resets. Setting #combinedIndexState to RESTORING_PROJECT_INDICES re-arms initSourceIndex, which the next build runs before validateCache and which re-clears (#changedProjectSourcePaths) and reassigns (#writtenResultResourcePaths) both fields from a fresh disk read. No behavior change: every caller follows discardIncrementalState with a full build that routes through initSourceIndex. --- .../lib/build/cache/ProjectBuildCache.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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; } /** From 29e1eb354556411ffd08acaf9c411c50931ccd9b Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 13 Jul 2026 17:15:54 +0200 Subject: [PATCH 02/31] refactor(server): Re-create the serving stack on definition changes Introduce a ServeSupervisor that owns a stable http.Server and re-creates the whole serving stack (graph + Express app + BuildServer) behind a request trampoline. serve() delegates to it and returns a reinitialize() handle alongside the existing {h2, port, close}. The BuildServer freezes a project's definition (ui5.yaml, package.json, ui5-workspace.yaml) at startup, so a branch switch rebuilds new sources against the old config. A reader-level fix cannot help: MiddlewareManager reads server.customMiddleware and resolves extensions once, baking them into the Express chain. Re-creating the stack above @ui5/server refreshes both the build model and the server config. Re-init is build-new-then-swap: the new graph and app are built before the old BuildServer is destroyed, so an invalid ui5.yaml leaves the last-good app serving. The port stays bound and live-reload clients stay connected: requests route through the trampoline to the current app, and live-reload subscribes to a relay whose upstream retargets on swap. The config-aware build signature keeps the refcounted cache warm across the swap. - Extract buildServeApp (serveApp.js) and the HTTP helpers (serveHttp.js) so the supervisor and the single-shot wrapper share one construction path. - Thread a graphFactory closure from the CLI so the graph re-resolves with identical parameters; the returned reinitialize() is captured for the future definition watcher. The definition watcher that will call reinitialize() is a follow-up. --- packages/cli/lib/cli/commands/serve.js | 47 +-- packages/cli/test/lib/cli/commands/serve.js | 9 + packages/server/lib/ServeSupervisor.js | 266 +++++++++++++++++ packages/server/lib/serveApp.js | 123 ++++++++ packages/server/lib/serveHttp.js | 136 +++++++++ packages/server/lib/server.js | 277 ++--------------- .../server/test/lib/server/ServeSupervisor.js | 282 ++++++++++++++++++ .../server/test/lib/server/reinitialize.js | 62 ++++ packages/server/test/lib/server/server.js | 189 +++++------- 9 files changed, 1007 insertions(+), 384 deletions(-) create mode 100644 packages/server/lib/ServeSupervisor.js create mode 100644 packages/server/lib/serveApp.js create mode 100644 packages/server/lib/serveHttp.js create mode 100644 packages/server/test/lib/server/ServeSupervisor.js create mode 100644 packages/server/test/lib/server/reinitialize.js diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index e70924bc53f..0f4173862bf 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -147,23 +147,31 @@ 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, - }); - } + // Single graph-construction site, reused for the initial graph and for every re-initialization + // the server performs on a project-definition change. Captures argv so the server can re-resolve + // with identical parameters without threading them across the package boundary. + 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: argv.workspace === false ? null : argv.workspace, + }); + } + }; + + // 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; @@ -221,9 +229,12 @@ 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-create the serving stack on a + // project-definition change. The returned reinitialize handle is unused for now; a future + // definition watcher will call it. 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..4a3824a21a7 100644 --- a/packages/cli/test/lib/cli/commands/serve.js +++ b/packages/cli/test/lib/cli/commands/serve.js @@ -128,6 +128,15 @@ test.serial("ui5 serve: default", async (t) => { } ]); 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) => { diff --git a/packages/server/lib/ServeSupervisor.js b/packages/server/lib/ServeSupervisor.js new file mode 100644 index 00000000000..fb44901fe09 --- /dev/null +++ b/packages/server/lib/ServeSupervisor.js @@ -0,0 +1,266 @@ +import http from "node:http"; +import process from "node:process"; +import {getRandomValues} from "node:crypto"; +import {EventEmitter} from "node:events"; +import {getLogger} from "@ui5/logger"; +import buildServeApp from "./serveApp.js"; +import attachLiveReloadServer from "./liveReload/server.js"; +import {listen, addSsl, announceListening} from "./serveHttp.js"; + +const log = getLogger("server:ServeSupervisor"); + +/** + * 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 ServeSupervisor extends EventEmitter { + #config; + #graphFactory; + #error; + + #httpServer = null; + #port = null; + #h2 = false; + + // 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; + + // 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; + + #destroyed = false; + #reinitInProgress = false; + #reinitQueued = false; + + 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 ServeSupervisor#reinitialize} to do anything + * @returns {Promise} The listening supervisor + */ + static async create(graph, config, error, {graphFactory} = {}) { + const supervisor = new ServeSupervisor(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; + this.#h2 = h2; + + 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 buildServeApp(graph, this.#config, this.#error); + + // 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); + + announceListening({port, h2, acceptRemoteConnections}); + } + + // 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; + } + 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. + this.#reinitQueued = true; + return; + } + this.#reinitInProgress = true; + try { + do { + this.#reinitQueued = false; + await this.#swap(); + } while (this.#reinitQueued && !this.#destroyed); + } finally { + this.#reinitInProgress = false; + } + } + + async #swap() { + const oldStack = this.#stack; + let newStack; + try { + const newGraph = await this.#graphFactory(); + newStack = await buildServeApp(newGraph, this.#config, this.#error); + } catch (err) { + // 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); + } + 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.#relayFrom(newStack.buildServer); + this.#sourcesChangedRelay.emit("sourcesChanged"); + // Tear down the old stack. Its BuildServer releases the source watcher and the cache + // handle; the new BuildServer already reopened the same (refcounted) cache. + await oldStack.buildServer.destroy(); + } + + getPort() { + return this.#port; + } + + get h2() { + return this.#h2; + } + + getServeError() { + return this.#stack?.buildServer.getServeError() ?? null; + } + + /** + * 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.#liveReloadHandle?.close(); + this.#detachRelay(); + this.#httpServer?.close(callback); + try { + await this.#stack?.buildServer.destroy(); + } catch (err) { + log.verbose(`Error while destroying BuildServer: ${err?.message ?? err}`); + } + } + + /** + * Generates a per-process live-reload token: random 72 bits (9 * 8 bits), base64url-encoded + * to a 12-character string. OWASP recommends at least 64 bits of entropy for session IDs: + * https://owasp.org/www-community/vulnerabilities/Insufficient_Session-ID_Length + * + * @returns {string} The token + */ + static generateWebSocketToken() { + return Buffer.from(getRandomValues(new Uint8Array(9))).toString("base64url"); + } + + // Test-only introspection into private state. Defined as a static method because private + // fields are only reachable from within the class body. Wired to __internals__ below. + static #peek(supervisor) { + return { + stack: supervisor.#stack, + currentApp: supervisor.#currentApp, + httpServer: supervisor.#httpServer, + sourcesChangedRelay: supervisor.#sourcesChangedRelay, + reinitInProgress: supervisor.#reinitInProgress, + }; + } + + static get __internals__() { + if (process.env.NODE_ENV !== "test") { + return undefined; + } + return {peek: ServeSupervisor.#peek}; + } +} + +export default ServeSupervisor; diff --git a/packages/server/lib/serveApp.js b/packages/server/lib/serveApp.js new file mode 100644 index 00000000000..8ec29e5a6bb --- /dev/null +++ b/packages/server/lib/serveApp.js @@ -0,0 +1,123 @@ +import express from "express"; +import MiddlewareManager from "./middleware/MiddlewareManager.js"; +import createErrorHandler from "./middleware/errorHandler.js"; +import {createReaderCollection} from "@ui5/fs/resourceFactory"; +import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; +import {getLogger} from "@ui5/logger"; +import Cache from "@ui5/project/build/cache/Cache"; + +const log = getLogger("server"); + +/** + * Builds the Express application and its BuildServer for a project graph, without binding + * to a port. Everything the dev server needs to answer requests is assembled here; the + * {@link module:@ui5/server.serve} wrapper and the {@link ServeSupervisor} own the listener + * so a graph swap can re-create the app behind a stable HTTP server. + * + * @private + * @module @ui5/server/serveApp + * @param {@ui5/project/graph/ProjectGraph} graph Project graph + * @param {object} config Server configuration — the resolved options passed to + * {@link module:@ui5/server.serve} (sendSAPTargetCSP, simpleIndex, + * liveReload, serveCSPReports, cache, ui5DataDir, + * includedTasks, excludedTasks), plus a stable + * webSocketToken shared across swaps + * @param {Function} [error] Error callback invoked when the BuildServer emits an error outside + * of request handling + * @returns {Promise} Resolves with {app, buildServer, liveReloadOptions} + */ +export default async function buildServeApp(graph, config, error) { + const { + sendSAPTargetCSP = false, simpleIndex = false, liveReload = false, serveCSPReports = false, + cache = Cache.Default, ui5DataDir, includedTasks, excludedTasks, webSocketToken = null, + } = config; + const rootProject = graph.getRoot(); + + const readers = []; + await graph.traverseBreadthFirst(async function({project: dep}) { + if (dep.getName() === rootProject.getName()) { + // Ignore root project + return; + } + readers.push(dep.getSourceReader("runtime")); + }); + + const dependencies = createReaderCollection({ + name: `Dependency reader collection for sources of project ${rootProject.getName()}`, + readers + }); + + const rootReader = rootProject.getSourceReader("runtime"); + + // TODO change to ReaderCollection once duplicates are sorted out + const combo = new ReaderCollectionPrioritized({ + name: "Server: Reader for sources of all projects", + readers: [rootReader, dependencies] + }); + const sources = { + rootProject: rootReader, + dependencies: dependencies, + all: combo + }; + + const initialBuildIncludedDependencies = []; + if (graph.getProject("sap.ui.core")) { + // Ensure sap.ui.core is always built initially (if present in the graph) + initialBuildIncludedDependencies.push("sap.ui.core"); + } + const buildServer = await graph.serve({ + initialBuildIncludedDependencies, + includedTasks, + excludedTasks, + cache, + ui5DataDir, + }); + + const resources = { + rootProject: buildServer.getRootReader(), + dependencies: buildServer.getDependenciesReader(), + all: buildServer.getReader(), + }; + + buildServer.on("error", (err) => { + if (typeof error === "function") { + error(err); + return; + } + log.error(`BuildServer error: ${err?.message ?? err}`); + if (err?.stack) { + log.verbose(err.stack); + } + }); + + const liveReloadOptions = { + active: liveReload, + token: webSocketToken + }; + + const middlewareManager = new MiddlewareManager({ + graph, + rootProject, + sources, + resources, + options: { + sendSAPTargetCSP, + serveCSPReports, + simpleIndex, + liveReload: liveReloadOptions, + // Consulted by the serveBuildError gate to divert HTML navigations while the + // build server is globally in ERROR. + getServeError: () => buildServer.getServeError() + } + }); + + const app = express(); + await middlewareManager.applyMiddleware(app); + // Terminal error handler for the middleware chain. Registered after applyMiddleware + // so it sits last and intercepts every next(err) — including those from custom + // middleware where we can't wrap a try/catch. Threads the live-reload config in + // so the HTML error page can embed the live-reload client script. + app.use(createErrorHandler({liveReload: liveReloadOptions})); + + return {app, buildServer, liveReloadOptions}; +} diff --git a/packages/server/lib/serveHttp.js b/packages/server/lib/serveHttp.js new file mode 100644 index 00000000000..8544de75f17 --- /dev/null +++ b/packages/server/lib/serveHttp.js @@ -0,0 +1,136 @@ +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 ServeSupervisor}, which binds the port once and swaps the + * request handler behind it. + * + * @private + * @module @ui5/server/serveHttp + */ + +/** + * 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} Resolves with the bound port and the server instance + * @private + */ +export function listen(app, port, changePortIfInUse, acceptRemoteConnections) { + return new Promise(function(resolve, reject) { + const options = {}; + + if (!acceptRemoteConnections) { + // Unless remote connections are allowed, bind to the IPv4 loopback address + options.host = "127.0.0.1"; + } // If remote connections are allowed, do not set host so the server listens on all supported interfaces + + const portScanHost = options.host || "127.0.0.1"; + let portMax; + if (changePortIfInUse) { + portMax = port + 30; + } else { + portMax = port; + } + + portscanner.findAPortNotInUse(port, portMax, portScanHost, function(error, foundPort) { + if (error) { + reject(error); + return; + } + + if (!foundPort) { + if (changePortIfInUse) { + const error = new Error( + `EADDRINUSE: Could not find available ports between ${port} and ${portMax}.`); + error.code = "EADDRINUSE"; + error.errno = "EADDRINUSE"; + error.address = portScanHost; + error.port = portMax; + reject(error); + return; + } else { + const error = new Error(`EADDRINUSE: Port ${port} is already in use.`); + error.code = "EADDRINUSE"; + error.errno = "EADDRINUSE"; + error.address = portScanHost; + error.port = portMax; + reject(error); + return; + } + } + + options.port = foundPort; + const server = app.listen(options, function() { + resolve({port: options.port, server}); + }); + + server.on("error", function(err) { + reject(err); + }); + }); + }); +} + +/** + * Adds SSL support to an express application. + * + * @param {object} parameters + * @param {object} parameters.app The original express application + * @param {string} parameters.key Path to private key to be used for https + * @param {string} parameters.cert Path to certificate to be used for for https + * @returns {Promise} The express application with SSL support + * @private + */ +export async function addSsl({app, key, cert}) { + // Using spdy as http2 server as the native http2 implementation + // from Node v8.4.0 doesn't seem to work with express + const {default: spdy} = await import("spdy"); + return spdy.createServer({cert, key}, app); +} + +/** + * Announces the bound URLs on the event bus. The server owns the network-interface + * lookup because it knows the actual bound port (which may differ from the requested + * one when changePortIfInUse is set). Consumers (@ui5/logger writers) shape their own + * display from the label/url pairs. + * + * @param {object} parameters + * @param {number} parameters.port The actual bound port + * @param {boolean} parameters.h2 Whether HTTP/2 (https) is in use + * @param {boolean} parameters.acceptRemoteConnections Whether the server binds to all interfaces + * @private + */ +export function announceListening({port, h2, acceptRemoteConnections}) { + const protocol = h2 ? "https" : "http"; + const urls = [{label: "Local", url: `${protocol}://localhost:${port}`}]; + if (acceptRemoteConnections) { + for (const addr of findNetworkInterfaceAddresses()) { + urls.push({label: "Network", url: `${protocol}://${addr}:${port}`}); + } + } + process.emit("ui5.server-listening", { + urls, + acceptRemoteConnections: !!acceptRemoteConnections, + }); +} + +// Collects all non-internal IPv4 addresses from the host's network interfaces +// so `ui5.server-listening` can list every reachable URL when the server binds +// to all interfaces. Returns an empty array if no suitable address is found. +function findNetworkInterfaceAddresses() { + const interfaces = os.networkInterfaces(); + const addresses = []; + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name] ?? []) { + if (iface.family === "IPv4" && !iface.internal) { + addresses.push(iface.address); + } + } + } + return addresses; +} diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 201b9f3a485..ed8e0773224 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -1,14 +1,5 @@ -import {getRandomValues} from "node:crypto"; -import os from "node:os"; -import process from "node:process"; -import express from "express"; -import portscanner from "portscanner"; -import MiddlewareManager from "./middleware/MiddlewareManager.js"; -import createErrorHandler from "./middleware/errorHandler.js"; -import attachLiveReloadServer from "./liveReload/server.js"; -import {createReaderCollection} from "@ui5/fs/resourceFactory"; -import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; import {getLogger} from "@ui5/logger"; +import ServeSupervisor from "./ServeSupervisor.js"; const log = getLogger("server"); /** @@ -16,90 +7,6 @@ const log = getLogger("server"); * @module @ui5/server */ -/** - * Returns a promise resolving by starting the server. - * - * @param {object} app The express application object - * @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 connections - * @returns {Promise} Returns an object containing server related information like (selected port, protocol) - * @private - */ -function _listen(app, port, changePortIfInUse, acceptRemoteConnections) { - return new Promise(function(resolve, reject) { - const options = {}; - - if (!acceptRemoteConnections) { - // Unless remote connections are allowed, bind to the IPv4 loopback address - options.host = "127.0.0.1"; - } // If remote connections are allowed, do not set host so the server listens on all supported interfaces - - const portScanHost = options.host || "127.0.0.1"; - let portMax; - if (changePortIfInUse) { - portMax = port + 30; - } else { - portMax = port; - } - - portscanner.findAPortNotInUse(port, portMax, portScanHost, function(error, foundPort) { - if (error) { - reject(error); - return; - } - - if (!foundPort) { - if (changePortIfInUse) { - const error = new Error( - `EADDRINUSE: Could not find available ports between ${port} and ${portMax}.`); - error.code = "EADDRINUSE"; - error.errno = "EADDRINUSE"; - error.address = portScanHost; - error.port = portMax; - reject(error); - return; - } else { - const error = new Error(`EADDRINUSE: Port ${port} is already in use.`); - error.code = "EADDRINUSE"; - error.errno = "EADDRINUSE"; - error.address = portScanHost; - error.port = portMax; - reject(error); - return; - } - } - - options.port = foundPort; - const server = app.listen(options, function() { - resolve({port: options.port, server}); - }); - - server.on("error", function(err) { - reject(err); - }); - }); - }); -} - -/** - * Adds SSL support to an express application. - * - * @param {object} parameters - * @param {object} parameters.app The original express application - * @param {string} parameters.key Path to private key to be used for https - * @param {string} parameters.cert Path to certificate to be used for for https - * @returns {Promise} The express application with SSL support - * @private - */ -async function _addSsl({app, key, cert}) { - // Using spdy as http2 server as the native http2 implementation - // from Node v8.4.0 doesn't seem to work with express - const {default: spdy} = await import("spdy"); - return spdy.createServer({cert, key}, app); -} - - /** * SAP target CSP middleware options * @@ -143,177 +50,49 @@ async function _addSsl({app, key, cert}) { * @param {string[]} [options.excludedTasks] A list of tasks to be excluded from the default task * execution set. * @param {Function} error Error callback. Will be called when an error occurs outside of request handling. + * @param {object} [options2] Additional options + * @param {Function} [options2.graphFactory] Async factory that re-resolves the project graph with the + * same parameters used to build the initial graph. When provided, + * the returned reinitialize re-creates the serving stack on a + * project-definition change. Omitted → reinitialize is a no-op. * @returns {Promise} Promise resolving once the server is listening. * It resolves with an object containing the port, - * h2-flag and a close function, - * which can be used to stop the server. + * h2-flag, a close function to stop the server, + * and a reinitialize function to re-create the serving stack. */ export async function serve(graph, { - port: requestedPort, changePortIfInUse = false, h2 = false, key, cert, + port, changePortIfInUse = false, h2 = false, key, cert, acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false, liveReload = false, serveCSPReports = false, cache = "Default", ui5DataDir, includedTasks, excludedTasks, -}, error) { - const rootProject = graph.getRoot(); - - const readers = []; - await graph.traverseBreadthFirst(async function({project: dep}) { - if (dep.getName() === rootProject.getName()) { - // Ignore root project - return; - } - readers.push(dep.getSourceReader("runtime")); - }); - - const dependencies = createReaderCollection({ - name: `Dependency reader collection for sources of project ${rootProject.getName()}`, - readers - }); - - const rootReader = rootProject.getSourceReader("runtime"); - - // TODO change to ReaderCollection once duplicates are sorted out - const combo = new ReaderCollectionPrioritized({ - name: "Server: Reader for sources of all projects", - readers: [rootReader, dependencies] - }); - const sources = { - rootProject: rootReader, - dependencies: dependencies, - all: combo +}, error, {graphFactory} = {}) { + // The live-reload token is generated once and shared with every serving stack the supervisor + // builds, so connected clients keep authenticating across a re-initialization. + const webSocketToken = liveReload ? ServeSupervisor.generateWebSocketToken() : null; + + const config = { + port, changePortIfInUse, h2, key, cert, + acceptRemoteConnections, sendSAPTargetCSP, + simpleIndex, liveReload, serveCSPReports, cache, + ui5DataDir, includedTasks, excludedTasks, webSocketToken, }; - const initialBuildIncludedDependencies = []; - if (graph.getProject("sap.ui.core")) { - // Ensure sap.ui.core is always built initially (if present in the graph) - initialBuildIncludedDependencies.push("sap.ui.core"); - } - const buildServer = await graph.serve({ - initialBuildIncludedDependencies, - includedTasks, - excludedTasks, - cache, - ui5DataDir, - }); - - const resources = { - rootProject: buildServer.getRootReader(), - dependencies: buildServer.getDependenciesReader(), - all: buildServer.getReader(), - }; - - buildServer.on("error", (err) => { - if (typeof error === "function") { - error(err); - return; - } - log.error(`BuildServer error: ${err?.message ?? err}`); - if (err?.stack) { - log.verbose(err.stack); - } - }); - - // Random 72 bits (9 * 8 bits), base64url-encoded to a 12-character string, should be sufficient for uniqueness. - // OWASP recommends at least 64 bits of entropy for session IDs: - // https://owasp.org/www-community/vulnerabilities/Insufficient_Session-ID_Length - const webSocketToken = liveReload ? - Buffer.from(getRandomValues(new Uint8Array(9))).toString("base64url") : - null; - - const liveReloadOptions = { - active: liveReload, - token: webSocketToken - }; - - const middlewareManager = new MiddlewareManager({ - graph, - rootProject, - sources, - resources, - options: { - sendSAPTargetCSP, - serveCSPReports, - simpleIndex, - liveReload: liveReloadOptions, - // Consulted by the serveBuildError gate to divert HTML navigations while the - // build server is globally in ERROR. - getServeError: () => buildServer.getServeError() - } - }); - - let app = express(); - await middlewareManager.applyMiddleware(app); - // Terminal error handler for the middleware chain. Registered after applyMiddleware - // so it sits last and intercepts every next(err) — including those from custom - // middleware where we can't wrap a try/catch. Threads the live-reload config in - // so the HTML error page can embed the live-reload client script. - app.use(createErrorHandler({liveReload: liveReloadOptions})); - - 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); - } - - app = await _addSsl({app, key, cert}); - } - - let port; let server; + let supervisor; try { - ({port, server} = await _listen(app, requestedPort, changePortIfInUse, acceptRemoteConnections)); + supervisor = await ServeSupervisor.create(graph, config, error, {graphFactory}); } catch (err) { - await buildServer.destroy(); + log.verbose(`Failed to start server: ${err?.message ?? err}`); throw err; } - let liveReloadHandle; - if (liveReload) { - liveReloadHandle = attachLiveReloadServer({httpServer: server, buildServer, token: webSocketToken}); - } - - // Announce the bound URLs on the event bus. The server owns the network- - // interface lookup because it knows the actual bound port (which may - // differ from the requested one when changePortIfInUse is set). Consumers - // (@ui5/logger writers) shape their own display from the label/url pairs. - const protocol = h2 ? "https" : "http"; - const urls = [{label: "Local", url: `${protocol}://localhost:${port}`}]; - if (acceptRemoteConnections) { - for (const addr of _findNetworkInterfaceAddresses()) { - urls.push({label: "Network", url: `${protocol}://${addr}:${port}`}); - } - } - process.emit("ui5.server-listening", { - urls, - acceptRemoteConnections: !!acceptRemoteConnections, - }); - return { h2, - port, + port: supervisor.getPort(), close: function(callback) { - liveReloadHandle?.close(); - buildServer.destroy().then(() => { - server.close(callback); - }, () => { - server.close(callback); - }); - } + supervisor.destroy(callback); + }, + reinitialize: function() { + return supervisor.reinitialize(); + }, }; } - -// Collects all non-internal IPv4 addresses from the host's network interfaces -// so `ui5.server-listening` can list every reachable URL when the server binds -// to all interfaces. Returns an empty array if no suitable address is found. -function _findNetworkInterfaceAddresses() { - const interfaces = os.networkInterfaces(); - const addresses = []; - for (const name of Object.keys(interfaces)) { - for (const iface of interfaces[name] ?? []) { - if (iface.family === "IPv4" && !iface.internal) { - addresses.push(iface.address); - } - } - } - return addresses; -} diff --git a/packages/server/test/lib/server/ServeSupervisor.js b/packages/server/test/lib/server/ServeSupervisor.js new file mode 100644 index 00000000000..72419a626fc --- /dev/null +++ b/packages/server/test/lib/server/ServeSupervisor.js @@ -0,0 +1,282 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; +import {EventEmitter} from "node:events"; + +// A fake BuildServer: EventEmitter plus the reader/error/destroy surface the supervisor uses. +function createBuildServer() { + const buildServer = new EventEmitter(); + buildServer.getServeError = sinon.stub().returns(null); + buildServer.destroy = sinon.stub().resolves(); + return buildServer; +} + +// Builds a mock set for esmock. Each buildServeApp invocation returns the next queued stack, so a +// test can hand out distinct {app, buildServer} pairs across the initial build and re-inits. +function createMocks({stacks, buildServeAppImpl} = {}) { + const httpServer = new EventEmitter(); + httpServer.close = sinon.stub().callsFake((cb) => cb && cb()); + + const createdHandlers = []; + const listen = sinon.stub().resolves({port: 3000, server: httpServer}); + const addSsl = sinon.stub().callsFake(async ({app}) => app); + const announceListening = sinon.stub(); + + const liveReloadHandle = {close: sinon.stub()}; + const attachLiveReloadServer = sinon.stub().returns(liveReloadHandle); + + const stackQueue = stacks ? [...stacks] : null; + const buildServeApp = sinon.stub().callsFake(async (graph, config, error) => { + if (buildServeAppImpl) { + return buildServeAppImpl(graph, config, error); + } + return stackQueue.shift(); + }); + + const httpMock = { + default: { + createServer: sinon.stub().callsFake((handler) => { + createdHandlers.push(handler); + return httpServer; + }) + } + }; + + const mocks = { + "node:http": httpMock, + "../../../lib/serveApp.js": {default: buildServeApp}, + "../../../lib/serveHttp.js": {listen, addSsl, announceListening}, + "../../../lib/liveReload/server.js": {default: attachLiveReloadServer}, + }; + + return { + mocks, httpServer, listen, addSsl, announceListening, + attachLiveReloadServer, liveReloadHandle, buildServeApp, createdHandlers, + }; +} + +function createStack(app) { + return { + app: app ?? sinon.stub(), + buildServer: createBuildServer(), + liveReloadOptions: {active: true, token: "tok"}, + }; +} + +async function importSupervisor(mocks) { + return esmock("../../../lib/ServeSupervisor.js", mocks); +} + +const baseConfig = {port: 3000, liveReload: true, webSocketToken: "tok"}; + +test.afterEach.always(() => { + sinon.restore(); +}); + +test("create() builds the initial stack, binds once, attaches live-reload to a stable relay", async (t) => { + const stack = createStack(); + const {mocks, listen, attachLiveReloadServer, buildServeApp} = createMocks({stacks: [stack]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {}); + + t.is(supervisor.getPort(), 3000); + t.true(buildServeApp.calledOnce); + t.true(listen.calledOnce, "port is bound exactly once"); + // Live-reload is attached to the stable relay, not the BuildServer directly. + t.true(attachLiveReloadServer.calledOnce); + const {buildServer: relay} = attachLiveReloadServer.firstCall.args[0]; + t.not(relay, stack.buildServer, "live-reload subscribes to the relay, not the BuildServer"); + t.true(relay instanceof EventEmitter); +}); + +test("request trampoline retargets to the swapped app after reinitialize()", async (t) => { + const app1 = sinon.stub(); + const app2 = sinon.stub(); + const stack1 = createStack(app1); + const stack2 = createStack(app2); + const graphFactory = sinon.stub().resolves({}); + const {mocks, listen, createdHandlers} = createMocks({stacks: [stack1, stack2]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + + // The stable request handler passed to http.createServer. + const trampoline = createdHandlers[0]; + trampoline("req", "res"); + t.true(app1.calledOnceWithExactly("req", "res"), "routed to app1 before swap"); + + await supervisor.reinitialize(); + + trampoline("req2", "res2"); + t.true(app2.calledOnceWithExactly("req2", "res2"), "routed to app2 after swap"); + t.true(listen.calledOnce, "the socket is not re-bound on reinitialize"); +}); + +test("reinitialize() is build-new-then-swap: new stack is built before the old is destroyed", async (t) => { + const order = []; + const stack1 = createStack(); + stack1.buildServer.destroy = sinon.stub().callsFake(async () => { + order.push("destroy-old"); + }); + const stack2 = createStack(); + const graphFactory = sinon.stub().callsFake(async () => { + order.push("graphFactory"); + return {}; + }); + const {mocks} = createMocks({ + buildServeAppImpl: async () => { + order.push("buildServeApp"); + return order.filter((s) => s === "buildServeApp").length === 1 ? stack1 : stack2; + } + }); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + order.length = 0; // drop the initial build + + await supervisor.reinitialize(); + + t.deepEqual(order, ["graphFactory", "buildServeApp", "destroy-old"], + "new graph resolved and new app built before the old BuildServer is destroyed"); +}); + +test("reinitialize() failure keeps the last-good stack serving", async (t) => { + let calls = 0; + const stack1 = createStack(); + const buildError = new Error("invalid ui5.yaml"); + const graphFactory = sinon.stub().resolves({}); + const {mocks, attachLiveReloadServer} = createMocks({ + buildServeAppImpl: async () => { + calls++; + if (calls === 1) { + return stack1; + } + throw buildError; + } + }); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const errorEvents = []; + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + supervisor.on("error", (err) => errorEvents.push(err)); + + await t.notThrowsAsync(supervisor.reinitialize(), "a broken definition does not reject"); + + const {peek} = ServeSupervisor.__internals__; + t.is(peek(supervisor).stack, stack1, "old stack is retained"); + t.false(stack1.buildServer.destroy.called, "old BuildServer is not destroyed on failure"); + t.is(errorEvents.length, 0, "no fatal 'error' event is emitted"); + t.true(attachLiveReloadServer.calledOnce); +}); + +test("live-reload subscription moves to the new BuildServer across a swap", async (t) => { + const stack1 = createStack(); + const stack2 = createStack(); + sinon.spy(stack1.buildServer, "off"); + sinon.spy(stack2.buildServer, "on"); + const graphFactory = sinon.stub().resolves({}); + const {mocks, attachLiveReloadServer} = createMocks({stacks: [stack1, stack2]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const relay = attachLiveReloadServer.firstCall.args[0].buildServer; + + await supervisor.reinitialize(); + + t.true(stack1.buildServer.off.calledWith("sourcesChanged"), "old BuildServer is detached from the relay"); + t.true(stack2.buildServer.on.calledWith("sourcesChanged"), "new BuildServer is attached to the relay"); + + // A sourcesChanged from the new BuildServer still reaches relay subscribers. + let relayed = 0; + relay.on("sourcesChanged", () => relayed++); + stack2.buildServer.emit("sourcesChanged"); + t.is(relayed, 1, "new BuildServer drives the stable relay"); + + // The detached old BuildServer no longer drives it. + stack1.buildServer.emit("sourcesChanged"); + t.is(relayed, 1, "old BuildServer no longer drives the relay"); +}); + +test("overlapping reinitialize() calls collapse into one trailing pass", async (t) => { + const stack1 = createStack(); + // Pre-created gate so resolving it does not depend on the async callback having run yet. + const firstReinitGate = Promise.withResolvers(); + let buildCalls = 0; + const graphFactory = sinon.stub().resolves({}); + const {mocks} = createMocks({ + buildServeAppImpl: async () => { + buildCalls++; + if (buildCalls === 1) { + return stack1; // initial build + } + if (buildCalls === 2) { + // First re-init: block until released to create the overlap window. + await firstReinitGate.promise; + } + return createStack(); + } + }); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + + const p1 = supervisor.reinitialize(); + const p2 = supervisor.reinitialize(); // collapses into a trailing pass while p1 is in flight + firstReinitGate.resolve(); + await Promise.all([p1, p2]); + + // One initial build + two re-init builds (the second is the single trailing pass). + t.is(buildCalls, 3, "the trailing pass runs exactly once, sequentially"); +}); + +test("destroy() closes live-reload, the socket, and the BuildServer; reinitialize() is then a no-op", async (t) => { + const stack = createStack(); + const graphFactory = sinon.stub().resolves({}); + const {mocks, httpServer, liveReloadHandle} = createMocks({stacks: [stack]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + + await new Promise((resolve) => supervisor.destroy(resolve)); + + t.true(liveReloadHandle.close.calledOnce); + t.true(httpServer.close.calledOnce); + t.true(stack.buildServer.destroy.calledOnce); + + await supervisor.reinitialize(); + t.true(graphFactory.notCalled, "reinitialize after destroy does nothing"); +}); + +test("destroy() closes the socket even when BuildServer.destroy() rejects", async (t) => { + const stack = createStack(); + stack.buildServer.destroy = sinon.stub().rejects(new Error("destroy failed")); + const {mocks, httpServer} = createMocks({stacks: [stack]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {}); + + await new Promise((resolve) => supervisor.destroy(resolve)); + t.true(httpServer.close.calledOnce, "socket is closed despite the BuildServer destroy rejection"); +}); + +test("getServeError() delegates to the current BuildServer", async (t) => { + const stack = createStack(); + const serveError = new Error("build error"); + stack.buildServer.getServeError = sinon.stub().returns(serveError); + const {mocks} = createMocks({stacks: [stack]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {}); + t.is(supervisor.getServeError(), serveError); +}); + +test("reinitialize() warns and no-ops when no graphFactory was provided", async (t) => { + const stack = createStack(); + const {mocks, buildServeApp} = createMocks({stacks: [stack]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {}); + await supervisor.reinitialize(); + t.true(buildServeApp.calledOnce, "no re-init build happens without a graphFactory"); +}); diff --git a/packages/server/test/lib/server/reinitialize.js b/packages/server/test/lib/server/reinitialize.js new file mode 100644 index 00000000000..06e14ad63f5 --- /dev/null +++ b/packages/server/test/lib/server/reinitialize.js @@ -0,0 +1,62 @@ +import test from "ava"; +import supertest from "supertest"; +import {serve} from "../../../lib/server.js"; +import {graphFromPackageDependencies} from "@ui5/project/graph"; +import {isolatedUi5DataDir} from "../../utils/buildCacheIsolation.js"; + +// Integration coverage for the ServeSupervisor swap against a real graph + BuildServer: the port +// stays bound, the same socket keeps serving, and a re-init reuses the (config-keyed, refcounted) +// build cache rather than cold-rebuilding. Uses a fixed cwd so the graphFactory re-resolves the +// same project. + +async function buildGraph() { + return graphFromPackageDependencies({ + cwd: "./test/fixtures/application.a" + }); +} + +test.serial("reinitialize() keeps the port bound and continues serving", async (t) => { + const ui5DataDir = isolatedUi5DataDir(t); + const graph = await buildGraph(); + const server = await serve(graph, { + port: 3395, + changePortIfInUse: true, + liveReload: false, + ui5DataDir, + }, undefined, {graphFactory: buildGraph}); + + const port = server.port; + const request = supertest(`http://127.0.0.1:${port}`); + + const before = await request.get("/index.html"); + t.is(before.statusCode, 200, "serves before re-init"); + + await server.reinitialize(); + + // Same port, same socket — the request client is unchanged. + const after = await request.get("/index.html"); + t.is(after.statusCode, 200, "serves after re-init on the same port"); + t.is(server.port, port, "the bound port is unchanged across re-init"); + + await new Promise((resolve) => server.close(resolve)); +}); + +test.serial("reinitialize() without a graphFactory is a no-op and keeps serving", async (t) => { + const ui5DataDir = isolatedUi5DataDir(t); + const graph = await buildGraph(); + // No 4th argument → no graphFactory. + const server = await serve(graph, { + port: 3396, + changePortIfInUse: true, + liveReload: false, + ui5DataDir, + }); + + const request = supertest(`http://127.0.0.1:${server.port}`); + await server.reinitialize(); // warns + no-ops + + const result = await request.get("/index.html"); + t.is(result.statusCode, 200, "still serving after a no-op re-init"); + + await new Promise((resolve) => server.close(resolve)); +}); diff --git a/packages/server/test/lib/server/server.js b/packages/server/test/lib/server/server.js index fc85c81c5d2..a2c26d74b54 100644 --- a/packages/server/test/lib/server/server.js +++ b/packages/server/test/lib/server/server.js @@ -1,141 +1,96 @@ import test from "ava"; import sinon from "sinon"; import esmock from "esmock"; -import {EventEmitter} from "node:events"; -function createMockGraph(mockBuildServer) { - const mockProject = { - getName: sinon.stub().returns("test.project"), - getSourceReader: sinon.stub().returns({}) +// server.js is now a thin wrapper over ServeSupervisor: it generates the live-reload token, builds +// the config, delegates to ServeSupervisor.create(), and shapes the {h2, port, close, reinitialize} +// result. These tests exercise that wrapper; the swap/relay/trampoline behavior lives in +// ServeSupervisor.js and is covered in ServeSupervisor.js. + +function createSupervisorMock({port = 3000, createRejects = null} = {}) { + const supervisor = { + getPort: sinon.stub().returns(port), + destroy: sinon.stub().callsFake((cb) => cb && cb()), + reinitialize: sinon.stub().resolves(), }; - return { - getRoot: sinon.stub().returns(mockProject), - traverseBreadthFirst: sinon.stub().resolves(), - getProject: sinon.stub().returns(null), - serve: sinon.stub().resolves(mockBuildServer) + const create = createRejects ? + sinon.stub().rejects(createRejects) : + sinon.stub().resolves(supervisor); + const ServeSupervisor = { + create, + generateWebSocketToken: sinon.stub().returns("test-token"), }; + return {supervisor, ServeSupervisor}; } -function createMockBuildServer() { - const buildServer = new EventEmitter(); - buildServer.getRootReader = sinon.stub().returns({}); - buildServer.getDependenciesReader = sinon.stub().returns({}); - buildServer.getReader = sinon.stub().returns({}); - buildServer.destroy = sinon.stub().resolves(); - return buildServer; -} - -function createMockServer() { - const mockServer = new EventEmitter(); - mockServer.close = sinon.stub().callsFake((cb) => cb()); - return mockServer; +async function importServe(ServeSupervisor) { + return esmock("../../../lib/server.js", { + "../../../lib/ServeSupervisor.js": {default: ServeSupervisor}, + }); } -function createMocks(mockServer) { - const mockApp = { - use: sinon.stub(), - listen: sinon.stub().callsFake((options, cb) => { - process.nextTick(cb); - return mockServer; - }) - }; +test.afterEach.always(() => { + sinon.restore(); +}); - return { - "express": sinon.stub().returns(mockApp), - "portscanner": { - findAPortNotInUse: sinon.stub().callsFake((port, portMax, host, cb) => { - cb(null, port); - }) - }, - "../../../lib/middleware/MiddlewareManager.js": { - default: class MockMiddlewareManager { - applyMiddleware() {} - } - }, - "@ui5/fs/resourceFactory": { - createReaderCollection: sinon.stub().returns({}) - }, - "@ui5/fs/ReaderCollectionPrioritized": { - default: class MockReaderCollectionPrioritized {} - } - }; -} +test("serve() delegates to ServeSupervisor.create and returns port/h2/close/reinitialize", async (t) => { + const {supervisor, ServeSupervisor} = createSupervisorMock({port: 3000}); + const {serve} = await importServe(ServeSupervisor); + const graph = {}; + const graphFactory = sinon.stub(); + + const result = await serve(graph, {port: 3000, h2: false, liveReload: true}, undefined, {graphFactory}); + + t.true(ServeSupervisor.create.calledOnce); + const [passedGraph, config, , options] = ServeSupervisor.create.firstCall.args; + t.is(passedGraph, graph); + t.is(options.graphFactory, graphFactory, "graphFactory is threaded through to the supervisor"); + t.is(config.webSocketToken, "test-token", "a token is generated when liveReload is active"); + t.is(result.port, 3000); + t.is(result.h2, false); + t.is(typeof result.close, "function"); + t.is(typeof result.reinitialize, "function"); + + result.reinitialize(); + t.true(supervisor.reinitialize.calledOnce, "reinitialize forwards to the supervisor"); +}); -test("server.on('error') rejects the serve promise", async (t) => { - const mockServer = createMockServer(); - const mockBuildServer = createMockBuildServer(); - const testError = new Error("server error"); - - const mockApp = { - use: sinon.stub(), - listen: sinon.stub().callsFake((options, cb) => { - // Emit error before the listen callback fires so reject() is called - process.nextTick(() => { - mockServer.emit("error", testError); - }); - return mockServer; - }) - }; +test("serve() does not generate a live-reload token when liveReload is off", async (t) => { + const {ServeSupervisor} = createSupervisorMock(); + const {serve} = await importServe(ServeSupervisor); - const mocks = { - "express": sinon.stub().returns(mockApp), - "portscanner": { - findAPortNotInUse: sinon.stub().callsFake((port, portMax, host, cb) => { - cb(null, port); - }) - }, - "../../../lib/middleware/MiddlewareManager.js": { - default: class MockMiddlewareManager { - applyMiddleware() {} - } - }, - "@ui5/fs/resourceFactory": { - createReaderCollection: sinon.stub().returns({}) - }, - "@ui5/fs/ReaderCollectionPrioritized": { - default: class MockReaderCollectionPrioritized {} - } - }; + await serve({}, {port: 3000, liveReload: false}, undefined, {}); - const {serve} = await esmock("../../../lib/server.js", mocks); - const graph = createMockGraph(mockBuildServer); - const error = await t.throwsAsync(serve(graph, {port: 3000})); - t.is(error, testError); + t.true(ServeSupervisor.generateWebSocketToken.notCalled); + const config = ServeSupervisor.create.firstCall.args[1]; + t.is(config.webSocketToken, null); }); +test("serve() close() forwards to supervisor.destroy()", async (t) => { + const {supervisor, ServeSupervisor} = createSupervisorMock(); + const {serve} = await importServe(ServeSupervisor); -test("buildServer 'error' event is forwarded to error callback", async (t) => { - const mockServer = createMockServer(); - const mockBuildServer = createMockBuildServer(); - const mocks = createMocks(mockServer); - const testError = new Error("build error"); - - const {serve} = await esmock("../../../lib/server.js", mocks); - const graph = createMockGraph(mockBuildServer); + const result = await serve({}, {port: 3000}, undefined, {}); + await new Promise((resolve) => result.close(resolve)); - const errorReceived = new Promise((resolve) => { - serve(graph, {port: 3000}, resolve).then(() => { - mockBuildServer.emit("error", testError); - }); - }); - - const err = await errorReceived; - t.is(err, testError); + t.true(supervisor.destroy.calledOnce); }); -test("close() still calls server.close when buildServer.destroy() rejects", async (t) => { - const mockServer = createMockServer(); - const mockBuildServer = createMockBuildServer(); - const mocks = createMocks(mockServer); +test("serve() rejects when ServeSupervisor.create rejects", async (t) => { + const createError = new Error("bind failed"); + const {ServeSupervisor} = createSupervisorMock({createRejects: createError}); + const {serve} = await importServe(ServeSupervisor); - mockBuildServer.destroy = sinon.stub().rejects(new Error("destroy failed")); + const err = await t.throwsAsync(serve({}, {port: 3000}, undefined, {})); + t.is(err, createError); +}); - const {serve} = await esmock("../../../lib/server.js", mocks); - const graph = createMockGraph(mockBuildServer); - const result = await serve(graph, {port: 3000}); +test("serve() works without the 4th options argument (backward compatible)", async (t) => { + const {ServeSupervisor} = createSupervisorMock(); + const {serve} = await importServe(ServeSupervisor); - await new Promise((resolve) => { - result.close(resolve); - }); - t.true(mockServer.close.calledOnce, "server.close was called despite destroy rejection"); + const result = await serve({}, {port: 3000}, undefined); + t.is(result.port, 3000); + const options = ServeSupervisor.create.firstCall.args[3]; + t.is(options.graphFactory, undefined); }); From bdb4c74e0ca66458cb61d9a2b2eb99bfe38c5e6d Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 13 Jul 2026 17:31:45 +0200 Subject: [PATCH 03/31] refactor(server): Collapse duplicated EADDRINUSE construction in listen() The changePortIfInUse if/else in listen() built the same error object twice: identical code, errno, address and port assignments differing only in the message string, and computed portMax through a mutable let. Compute the message conditionally and construct the error once; derive portMax with a ternary. Carried over verbatim from the former server.js _listen; the extraction into serveHttp.js is where it collapses. --- packages/server/lib/serveHttp.js | 47 +++++++++++--------------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/server/lib/serveHttp.js b/packages/server/lib/serveHttp.js index 8544de75f17..6cb6262d959 100644 --- a/packages/server/lib/serveHttp.js +++ b/packages/server/lib/serveHttp.js @@ -30,12 +30,7 @@ export function listen(app, port, changePortIfInUse, acceptRemoteConnections) { } // If remote connections are allowed, do not set host so the server listens on all supported interfaces const portScanHost = options.host || "127.0.0.1"; - let portMax; - if (changePortIfInUse) { - portMax = port + 30; - } else { - portMax = port; - } + const portMax = changePortIfInUse ? port + 30 : port; portscanner.findAPortNotInUse(port, portMax, portScanHost, function(error, foundPort) { if (error) { @@ -44,24 +39,15 @@ export function listen(app, port, changePortIfInUse, acceptRemoteConnections) { } if (!foundPort) { - if (changePortIfInUse) { - const error = new Error( - `EADDRINUSE: Could not find available ports between ${port} and ${portMax}.`); - error.code = "EADDRINUSE"; - error.errno = "EADDRINUSE"; - error.address = portScanHost; - error.port = portMax; - reject(error); - return; - } else { - const error = new Error(`EADDRINUSE: Port ${port} is already in use.`); - error.code = "EADDRINUSE"; - error.errno = "EADDRINUSE"; - error.address = portScanHost; - error.port = portMax; - reject(error); - return; - } + const err = new Error(changePortIfInUse ? + `EADDRINUSE: Could not find available ports between ${port} and ${portMax}.` : + `EADDRINUSE: Port ${port} is already in use.`); + err.code = "EADDRINUSE"; + err.errno = "EADDRINUSE"; + err.address = portScanHost; + err.port = portMax; + reject(err); + return; } options.port = foundPort; @@ -94,10 +80,10 @@ export async function addSsl({app, key, cert}) { } /** - * Announces the bound URLs on the event bus. The server owns the network-interface - * lookup because it knows the actual bound port (which may differ from the requested - * one when changePortIfInUse is set). Consumers (@ui5/logger writers) shape their own - * display from the label/url pairs. + * Announces the bound URLs on the event bus. The server owns the network-interface lookup + * because it knows the actual bound port (which may differ from the requested one when + * changePortIfInUse is set). Consumers (@ui5/logger writers) shape their own display from the + * label/url pairs. * * @param {object} parameters * @param {number} parameters.port The actual bound port @@ -119,9 +105,8 @@ export function announceListening({port, h2, acceptRemoteConnections}) { }); } -// Collects all non-internal IPv4 addresses from the host's network interfaces -// so `ui5.server-listening` can list every reachable URL when the server binds -// to all interfaces. Returns an empty array if no suitable address is found. +// Collects all non-internal IPv4 addresses so `ui5.server-listening` can list every reachable +// URL when the server binds to all interfaces. Empty array if none is found. function findNetworkInterfaceAddresses() { const interfaces = os.networkInterfaces(); const addresses = []; From c3795aef818a51f5cdf67d2fae5cced0b5d78bf0 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 13 Jul 2026 17:32:03 +0200 Subject: [PATCH 04/31] refactor(server): Trim ServeSupervisor to its used surface Remove three pieces of surface that no caller reaches: - The h2 getter and its #h2 backing field. serve() returns the locally destructured h2 and #init passes the local h2 to announceListening, so the getter was never read. - getServeError(). The serve-error signal reaches the middleware through serveApp.js, which closes over the BuildServer directly; the supervisor method had no production caller. - The static #peek / __internals__ introspection hole and its NODE_ENV branch. The one test that used it now asserts observable behavior: the trampoline still routes to the last-good app after a failed re-init, matching the neighboring trampoline test. Move generateWebSocketToken back inline into serve(). The supervisor never called it; server.js generated the token via the static and threaded it back in through config.webSocketToken. Keeping the generate-once-thread-through-config story in server.js, which already owns config assembly, drops the cross-file bounce and an unused static. --- packages/server/lib/ServeSupervisor.js | 41 ------------------- packages/server/lib/server.js | 8 +++- .../server/test/lib/server/ServeSupervisor.js | 21 +++------- packages/server/test/lib/server/server.js | 5 +-- 4 files changed, 15 insertions(+), 60 deletions(-) diff --git a/packages/server/lib/ServeSupervisor.js b/packages/server/lib/ServeSupervisor.js index fb44901fe09..210d4b53fa1 100644 --- a/packages/server/lib/ServeSupervisor.js +++ b/packages/server/lib/ServeSupervisor.js @@ -1,6 +1,5 @@ import http from "node:http"; import process from "node:process"; -import {getRandomValues} from "node:crypto"; import {EventEmitter} from "node:events"; import {getLogger} from "@ui5/logger"; import buildServeApp from "./serveApp.js"; @@ -30,7 +29,6 @@ class ServeSupervisor extends EventEmitter { #httpServer = null; #port = null; - #h2 = false; // The current serving stack: {app, buildServer, liveReloadOptions}. Reassigned on swap; the // trampoline reads #stack.app on every request so a swap retargets transparently. @@ -76,7 +74,6 @@ class ServeSupervisor extends EventEmitter { port: requestedPort, changePortIfInUse = false, h2 = false, key, cert, acceptRemoteConnections = false, liveReload = false, } = this.#config; - this.#h2 = h2; if (h2) { const nodeVersion = parseInt(process.versions.node.split(".")[0], 10); @@ -204,14 +201,6 @@ class ServeSupervisor extends EventEmitter { return this.#port; } - get h2() { - return this.#h2; - } - - getServeError() { - return this.#stack?.buildServer.getServeError() ?? null; - } - /** * 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 @@ -231,36 +220,6 @@ class ServeSupervisor extends EventEmitter { log.verbose(`Error while destroying BuildServer: ${err?.message ?? err}`); } } - - /** - * Generates a per-process live-reload token: random 72 bits (9 * 8 bits), base64url-encoded - * to a 12-character string. OWASP recommends at least 64 bits of entropy for session IDs: - * https://owasp.org/www-community/vulnerabilities/Insufficient_Session-ID_Length - * - * @returns {string} The token - */ - static generateWebSocketToken() { - return Buffer.from(getRandomValues(new Uint8Array(9))).toString("base64url"); - } - - // Test-only introspection into private state. Defined as a static method because private - // fields are only reachable from within the class body. Wired to __internals__ below. - static #peek(supervisor) { - return { - stack: supervisor.#stack, - currentApp: supervisor.#currentApp, - httpServer: supervisor.#httpServer, - sourcesChangedRelay: supervisor.#sourcesChangedRelay, - reinitInProgress: supervisor.#reinitInProgress, - }; - } - - static get __internals__() { - if (process.env.NODE_ENV !== "test") { - return undefined; - } - return {peek: ServeSupervisor.#peek}; - } } export default ServeSupervisor; diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index ed8e0773224..6d54fc7e558 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -1,3 +1,4 @@ +import {getRandomValues} from "node:crypto"; import {getLogger} from "@ui5/logger"; import ServeSupervisor from "./ServeSupervisor.js"; @@ -68,7 +69,12 @@ export async function serve(graph, { }, error, {graphFactory} = {}) { // The live-reload token is generated once and shared with every serving stack the supervisor // builds, so connected clients keep authenticating across a re-initialization. - const webSocketToken = liveReload ? ServeSupervisor.generateWebSocketToken() : null; + // Random 72 bits (9 * 8 bits), base64url-encoded to a 12-character string. OWASP recommends + // at least 64 bits of entropy for session IDs: + // https://owasp.org/www-community/vulnerabilities/Insufficient_Session-ID_Length + const webSocketToken = liveReload ? + Buffer.from(getRandomValues(new Uint8Array(9))).toString("base64url") : + null; const config = { port, changePortIfInUse, h2, key, cert, diff --git a/packages/server/test/lib/server/ServeSupervisor.js b/packages/server/test/lib/server/ServeSupervisor.js index 72419a626fc..ed2c43941d0 100644 --- a/packages/server/test/lib/server/ServeSupervisor.js +++ b/packages/server/test/lib/server/ServeSupervisor.js @@ -143,10 +143,11 @@ test("reinitialize() is build-new-then-swap: new stack is built before the old i test("reinitialize() failure keeps the last-good stack serving", async (t) => { let calls = 0; - const stack1 = createStack(); + const app1 = sinon.stub(); + const stack1 = createStack(app1); const buildError = new Error("invalid ui5.yaml"); const graphFactory = sinon.stub().resolves({}); - const {mocks, attachLiveReloadServer} = createMocks({ + const {mocks, attachLiveReloadServer, createdHandlers} = createMocks({ buildServeAppImpl: async () => { calls++; if (calls === 1) { @@ -163,8 +164,9 @@ test("reinitialize() failure keeps the last-good stack serving", async (t) => { await t.notThrowsAsync(supervisor.reinitialize(), "a broken definition does not reject"); - const {peek} = ServeSupervisor.__internals__; - t.is(peek(supervisor).stack, stack1, "old stack is retained"); + // The trampoline still routes to the last-good app — the failed build was not adopted. + createdHandlers[0]("req", "res"); + t.true(app1.calledOnceWithExactly("req", "res"), "requests still route to the last-good app"); t.false(stack1.buildServer.destroy.called, "old BuildServer is not destroyed on failure"); t.is(errorEvents.length, 0, "no fatal 'error' event is emitted"); t.true(attachLiveReloadServer.calledOnce); @@ -260,17 +262,6 @@ test("destroy() closes the socket even when BuildServer.destroy() rejects", asyn t.true(httpServer.close.calledOnce, "socket is closed despite the BuildServer destroy rejection"); }); -test("getServeError() delegates to the current BuildServer", async (t) => { - const stack = createStack(); - const serveError = new Error("build error"); - stack.buildServer.getServeError = sinon.stub().returns(serveError); - const {mocks} = createMocks({stacks: [stack]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); - - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {}); - t.is(supervisor.getServeError(), serveError); -}); - test("reinitialize() warns and no-ops when no graphFactory was provided", async (t) => { const stack = createStack(); const {mocks, buildServeApp} = createMocks({stacks: [stack]}); diff --git a/packages/server/test/lib/server/server.js b/packages/server/test/lib/server/server.js index a2c26d74b54..2e9a9536906 100644 --- a/packages/server/test/lib/server/server.js +++ b/packages/server/test/lib/server/server.js @@ -18,7 +18,6 @@ function createSupervisorMock({port = 3000, createRejects = null} = {}) { sinon.stub().resolves(supervisor); const ServeSupervisor = { create, - generateWebSocketToken: sinon.stub().returns("test-token"), }; return {supervisor, ServeSupervisor}; } @@ -45,7 +44,8 @@ test("serve() delegates to ServeSupervisor.create and returns port/h2/close/rein const [passedGraph, config, , options] = ServeSupervisor.create.firstCall.args; t.is(passedGraph, graph); t.is(options.graphFactory, graphFactory, "graphFactory is threaded through to the supervisor"); - t.is(config.webSocketToken, "test-token", "a token is generated when liveReload is active"); + t.is(typeof config.webSocketToken, "string", "a token is generated when liveReload is active"); + t.is(config.webSocketToken.length, 12, "the token is 72 bits base64url-encoded to 12 characters"); t.is(result.port, 3000); t.is(result.h2, false); t.is(typeof result.close, "function"); @@ -61,7 +61,6 @@ test("serve() does not generate a live-reload token when liveReload is off", asy await serve({}, {port: 3000, liveReload: false}, undefined, {}); - t.true(ServeSupervisor.generateWebSocketToken.notCalled); const config = ServeSupervisor.create.firstCall.args[1]; t.is(config.webSocketToken, null); }); From a44063576a9a86b6ccf4ea0926a4c25f6eb1a399 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 13 Jul 2026 19:47:02 +0200 Subject: [PATCH 05/31] refactor(project): Add DefinitionWatcher for project-definition files Watch the project-definition files (ui5.yaml, package.json, the workspace config, and in static-graph mode the dependency-definition file) and emit a settled, coalesced "definitionChanged" when one changes. Exported via a new "./build/helpers/DefinitionWatcher" subpath, mirroring "./build/cache/Cache". The watch model is include-based: @parcel/watcher subscribes to each distinct project root and the callback drops every event whose path is not in the resolved definition-file set. The node_modules/.git ignore globs only reduce OS-level watch load; correctness comes from the include set. The settle window is trailing-only (a re-init needs no leading edge) and sized at 550 ms to sit above @parcel/watcher's 500 ms MAX_WAIT_TIME, so a git checkout that writes several files collapses to a single emit. Error recovery mirrors BuildServer's #recoverWatcher (a re-entrancy guard plus a budget of 5 attempts / 60 s), but the budget is the watcher's own, independent of the source WatchHandler. Separate from the source WatchHandler: source events drive incremental rebuilds inside the BuildServer, definition events drive a full re-init above it. --- .../lib/build/helpers/DefinitionWatcher.js | 238 ++++++++++++ packages/project/package.json | 1 + .../lib/build/helpers/DefinitionWatcher.js | 346 ++++++++++++++++++ packages/project/test/lib/package-exports.js | 3 +- 4 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 packages/project/lib/build/helpers/DefinitionWatcher.js create mode 100644 packages/project/test/lib/build/helpers/DefinitionWatcher.js diff --git a/packages/project/lib/build/helpers/DefinitionWatcher.js b/packages/project/lib/build/helpers/DefinitionWatcher.js new file mode 100644 index 00000000000..50108ce6f0e --- /dev/null +++ b/packages/project/lib/build/helpers/DefinitionWatcher.js @@ -0,0 +1,238 @@ +import EventEmitter from "node:events"; +import path from "node:path"; +import parcelWatcher from "@parcel/watcher"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("build:helpers:DefinitionWatcher"); + +// 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 a branch switch writes ui5.yaml + package.json + sources within one +// operation; @parcel/watcher delivers that as batches up to its MAX_WAIT_TIME (500 ms) apart. +// A trailing timer, reset on each further event, collapses the whole burst into a single emit. +// Unlike the live-reload `sourcesChanged` emit, a re-init has no need for a leading edge — it is +// wasteful to re-create the serving stack on the first byte of a checkout — so this window is +// trailing-only. The value mirrors BuildServer's SOURCES_CHANGED_SETTLE_MS and must stay above +// the 500 ms watcher cap so each batch resets the window rather than terminating it. +const DEFINITION_CHANGED_SETTLE_MS = 550; + +// Loop protection for watcher recovery. A persistently failing watcher would otherwise cycle +// error → recover → error indefinitely. If more than WATCHER_RECOVERY_MAX_ATTEMPTS recoveries +// complete within WATCHER_RECOVERY_WINDOW_MS, the watcher is treated as unrecoverable and a +// terminal "error" is emitted. This budget is the DefinitionWatcher's own — independent of the +// source WatchHandler's recovery inside BuildServer. +const WATCHER_RECOVERY_MAX_ATTEMPTS = 5; +const WATCHER_RECOVERY_WINDOW_MS = 60000; + +/** + * 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. + * + * 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 directory and the + * change callback drops every event whose path is not in the resolved definition-file set. The + * node_modules/.git ignore globs only reduce OS-level watch load; + * correctness comes from the include set. + * + * @private + * @memberof @ui5/project/build/helpers + */ +class DefinitionWatcher 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 under it. Retained so recovery can re-subscribe the same set. + #watchDirs = new Map(); + + #settleTimer = null; + #lastEvent = null; + + #recovering = false; + #recoveryTimestamps = []; + #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 DefinitionWatcher(); + 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 add = (filePath) => { + const abs = path.resolve(filePath); + this.#watchedFiles.add(abs); + const dir = path.dirname(abs); + let files = this.#watchDirs.get(dir); + if (!files) { + files = new Set(); + this.#watchDirs.set(dir, files); + } + files.add(abs); + }; + + const rootName = graph.getRoot().getName(); + const rootCustomConfig = rootConfigPath ? resolve(rootConfigPath) : null; + + await graph.traverseBreadthFirst(({project}) => { + const rootPath = 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 is still + // meaningful (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 itself is a topology definition, + // editing it changes the graph exactly like package.json does. + if (dependencyDefinitionPath) { + add(resolve(dependencyDefinitionPath)); + } + } + + // 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) { + // Include-set filter: drop every event that is not a watched definition file. + if (!this.#watchedFiles.has(path.resolve(event.path))) { + continue; + } + this.#onDefinitionEvent(event.type, event.path); + } + }, {ignore: ["**/node_modules/**", "**/.git/**"]}); + 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) { + 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); + } + + const now = Date.now(); + this.#recoveryTimestamps = this.#recoveryTimestamps + .filter((ts) => now - ts < WATCHER_RECOVERY_WINDOW_MS); + if (this.#recoveryTimestamps.length >= WATCHER_RECOVERY_MAX_ATTEMPTS) { + 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. + const subscriptions = this.#subscriptions; + this.#subscriptions = []; + await Promise.allSettled(subscriptions.map((s) => s.unsubscribe())); + if (this.#destroyed) { + return; + } + await this.#subscribeAll(); + this.#recoveryTimestamps.push(Date.now()); + 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 results = await Promise.allSettled(subscriptions.map((s) => s.unsubscribe())); + const failures = results.filter((r) => r.status === "rejected").map((r) => r.reason); + if (failures.length) { + const err = new AggregateError(failures, "Failed to unsubscribe one or more definition watchers"); + this.emit("error", err); + } + } +} + +export default DefinitionWatcher; diff --git a/packages/project/package.json b/packages/project/package.json index 17b2e8957c4..62a74f35f8f 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/DefinitionWatcher": "./lib/build/helpers/DefinitionWatcher.js", "./specifications/Specification": "./lib/specifications/Specification.js", "./specifications/SpecificationVersion": "./lib/specifications/SpecificationVersion.js", "./ui5Framework/Sapui5MavenSnapshotResolver": "./lib/ui5Framework/Sapui5MavenSnapshotResolver.js", diff --git a/packages/project/test/lib/build/helpers/DefinitionWatcher.js b/packages/project/test/lib/build/helpers/DefinitionWatcher.js new file mode 100644 index 00000000000..d806e51c35d --- /dev/null +++ b/packages/project/test/lib/build/helpers/DefinitionWatcher.js @@ -0,0 +1,346 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; +import path from "node:path"; + +let DefinitionWatcher; +let subscribeStub; + +test.before(async () => { + subscribeStub = sinon.stub(); + DefinitionWatcher = await esmock("../../../../lib/build/helpers/DefinitionWatcher.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: resolves watch set to distinct roots with ui5.yaml + package.json", async (t) => { + captureCallbacks(); + const graph = createGraph( + {name: "root", rootPath: "/app"}, + [{name: "dep-a", rootPath: "/deps/a"}, {name: "dep-b", rootPath: "/deps/b"}] + ); + + const watcher = await DefinitionWatcher.create({graph}); + + const dirs = subscribeStub.getCalls().map((c) => c.args[0]).sort(); + t.deepEqual(dirs, ["/app", "/deps/a", "/deps/b"], "subscribed once per distinct root"); + for (const call of subscribeStub.getCalls()) { + t.deepEqual(call.args[2].ignore, ["**/node_modules/**", "**/.git/**"], "ignore globs passed"); + } + + await watcher.destroy(); +}); + +test.serial("create: shared parent directory is subscribed once", async (t) => { + captureCallbacks(); + // Two projects under the same directory: only one subscription for that dir. + const graph = createGraph( + {name: "root", rootPath: "/mono"}, + [{name: "dep-a", rootPath: "/mono"}] + ); + + const watcher = await DefinitionWatcher.create({graph}); + + t.is(subscribeStub.callCount, 1, "distinct directory subscribed once"); + t.is(subscribeStub.firstCall.args[0], "/mono"); + + await watcher.destroy(); +}); + +test.serial("create: custom rootConfigPath replaces root ui5.yaml and subscribes its directory", async (t) => { + captureCallbacks(); + const graph = createGraph({name: "root", rootPath: "/app"}); + + const watcher = await DefinitionWatcher.create({ + graph, rootConfigPath: "/configs/custom.yaml", cwd: "/app" + }); + + const dirs = subscribeStub.getCalls().map((c) => c.args[0]).sort(); + t.deepEqual(dirs, ["/app", "/configs"].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] === "/app").args[1]; + rootCb(null, [{type: "update", path: "/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] === "/configs").args[1]; + configCb(null, [{type: "update", path: "/configs/custom.yaml"}]); + 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 graph = createGraph({name: "root", rootPath: "/app"}); + + const watcher = await DefinitionWatcher.create({ + graph, rootConfigPath: "custom/ui5.yaml", cwd: "/app" + }); + + const dirs = subscribeStub.getCalls().map((c) => c.args[0]); + t.true(dirs.includes(path.join("/app", "custom")), "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: "/app"}); + + // null → active, use default filename ui5-workspace.yaml at cwd. + const watcher = await DefinitionWatcher.create({graph, workspaceConfigPath: null, cwd: "/ws"}); + + const wsCall = subscribeStub.getCalls().find((c) => c.args[0] === "/ws"); + 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: "/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: "/app"}); + + const watcher = await DefinitionWatcher.create({graph, workspaceConfigPath: undefined, cwd: "/ws"}); + + t.falsy(subscribeStub.getCalls().find((c) => c.args[0] === "/ws"), "workspace dir not subscribed"); + await watcher.destroy(); +}); + +test.serial("create: dependencyDefinitionPath is watched when present", async (t) => { + captureCallbacks(); + const graph = createGraph({name: "root", rootPath: "/app"}); + + const watcher = await DefinitionWatcher.create({ + graph, dependencyDefinitionPath: "deps.yaml", cwd: "/app" + }); + + const emitted = []; + watcher.on("definitionChanged", (e) => emitted.push(e)); + const appCall = subscribeStub.getCalls().find((c) => c.args[0] === "/app"); + const clock = sinon.useFakeTimers(); + appCall.args[1](null, [{type: "update", path: "/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: "/app"}); + const watcher = await DefinitionWatcher.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: "/app/src/main.js"}, + {type: "create", path: "/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: "/app"}); + const watcher = await DefinitionWatcher.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: "/app/ui5.yaml"}]); + clock.tick(600); + clock.restore(); + + t.is(emitted.length, 1, "watched ui5.yaml emits"); + t.deepEqual(emitted[0], {eventType: "update", filePath: "/app/ui5.yaml"}); + await watcher.destroy(); +}); + +test.serial("settle: a burst within the window emits definitionChanged exactly once", async (t) => { + captureCallbacks(); + const graph = createGraph({name: "root", rootPath: "/app"}); + const watcher = await DefinitionWatcher.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: "/app/ui5.yaml"}]); + clock.tick(200); + callback(null, [{type: "update", path: "/app/package.json"}]); + clock.tick(200); + callback(null, [{type: "update", path: "/app/src/main.js"}]); // filtered, but no reset either + clock.tick(200); // 200 < 550 from last watched event resets — still within window + 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: "/app/ui5.yaml"}]); + clock.tick(600); + t.is(emitted.length, 2, "later change emits again"); + clock.restore(); + + 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: "/app"}); + const watcher = await DefinitionWatcher.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: "/app"}); + const watcher = await DefinitionWatcher.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: "/app"}, + [{name: "dep", rootPath: "/dep"}] + ); + const watcher = await DefinitionWatcher.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: "/app"}, + [{name: "dep", rootPath: "/dep"}] + ); + const watcher = await DefinitionWatcher.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/package-exports.js b/packages/project/test/lib/package-exports.js index 684e8634a84..0482d955504 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, 15); }); // Public API contract (exported modules) [ "config/Configuration", "build/cache/Cache", + "build/helpers/DefinitionWatcher", "specifications/Specification", "specifications/SpecificationVersion", "ui5Framework/Openui5Resolver", From 3f26528ed08b124c5d483e844aa84a392040d3de Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 13 Jul 2026 19:47:15 +0200 Subject: [PATCH 06/31] refactor(server): Drive reinitialize from a DefinitionWatcher Wire a DefinitionWatcher into the ServeSupervisor lifecycle so an on-disk change to a definition file re-creates the serving stack. Until now reinitialize() was only callable programmatically, so a git checkout or a hand-edit of ui5.yaml did not refresh a running server. The watcher is owned by the supervisor, not the BuildServer: it outlives each swapped-out stack and is re-targeted to the new graph after every swap, since the project set or their roots may have changed. It is armed at the tail of #init (after the port is bound) and only when a graphFactory is present, since reinitialize() is otherwise a no-op. A watcher-create failure during a swap is logged and does not crash the swap; the old watcher keeps driving re-inits. destroy() stops the watcher before the socket closes, so a late event cannot start a re-init mid-teardown. rootConfigPath, workspaceConfigPath, dependencyDefinitionPath and cwd are threaded through serve() into the supervisor config so the watcher can locate a custom root config, the workspace file, and the static dependency-definition file. Existing callers omit them and degrade to watching ui5.yaml/package.json at project roots. --- packages/server/lib/ServeSupervisor.js | 50 ++++++- packages/server/lib/server.js | 9 ++ .../server/test/lib/server/ServeSupervisor.js | 133 +++++++++++++++++- .../server/test/lib/server/reinitialize.js | 54 +++++++ 4 files changed, 244 insertions(+), 2 deletions(-) diff --git a/packages/server/lib/ServeSupervisor.js b/packages/server/lib/ServeSupervisor.js index 210d4b53fa1..958aa6c4751 100644 --- a/packages/server/lib/ServeSupervisor.js +++ b/packages/server/lib/ServeSupervisor.js @@ -2,6 +2,7 @@ import http from "node:http"; import process from "node:process"; import {EventEmitter} from "node:events"; import {getLogger} from "@ui5/logger"; +import DefinitionWatcher from "@ui5/project/build/helpers/DefinitionWatcher"; import buildServeApp from "./serveApp.js"; import attachLiveReloadServer from "./liveReload/server.js"; import {listen, addSsl, announceListening} from "./serveHttp.js"; @@ -40,6 +41,11 @@ class ServeSupervisor extends 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; @@ -117,9 +123,28 @@ class ServeSupervisor extends EventEmitter { } 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 DefinitionWatcher.create({ + graph, rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd, + }); + watcher.on("definitionChanged", () => this.reinitialize()); + 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) { @@ -172,8 +197,9 @@ class ServeSupervisor extends EventEmitter { async #swap() { const oldStack = this.#stack; let newStack; + let newGraph; try { - const newGraph = await this.#graphFactory(); + newGraph = await this.#graphFactory(); newStack = await buildServeApp(newGraph, this.#config, this.#error); } catch (err) { // Keep the last-good stack serving. A subsequent valid edit will swap cleanly. @@ -192,6 +218,19 @@ class ServeSupervisor extends EventEmitter { this.#stack = newStack; 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; the old watcher then 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. await oldStack.buildServer.destroy(); @@ -211,9 +250,18 @@ class ServeSupervisor extends EventEmitter { */ async destroy(callback) { this.#destroyed = true; + // 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 belt-and-braces. + const definitionWatcher = this.#definitionWatcher; + this.#definitionWatcher = null; this.#liveReloadHandle?.close(); this.#detachRelay(); this.#httpServer?.close(callback); + try { + await definitionWatcher?.destroy(); + } catch (err) { + log.verbose(`Error while destroying definition watcher: ${err?.message ?? err}`); + } try { await this.#stack?.buildServer.destroy(); } catch (err) { diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 6d54fc7e558..78a9f74c65f 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -50,6 +50,13 @@ const log = getLogger("server"); * Takes precedence over excludedTasks. * @param {string[]} [options.excludedTasks] A list of tasks to be excluded from the default task * execution set. + * @param {string} [options.rootConfigPath] Custom config path for the root project (from --config), + * threaded to the definition watcher so it watches the right file. + * @param {string} [options.workspaceConfigPath] Workspace config path (default ui5-workspace.yaml); + * threaded to the definition watcher. Omit in static-graph mode. + * @param {string} [options.dependencyDefinitionPath] Static dependency-definition file + * (from --dependency-definition); watched when present. + * @param {string} [options.cwd] Base directory for resolving the watcher's relative paths. * @param {Function} error Error callback. Will be called when an error occurs outside of request handling. * @param {object} [options2] Additional options * @param {Function} [options2.graphFactory] Async factory that re-resolves the project graph with the @@ -66,6 +73,7 @@ export async function serve(graph, { acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false, liveReload = false, serveCSPReports = false, cache = "Default", ui5DataDir, includedTasks, excludedTasks, + rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd, }, error, {graphFactory} = {}) { // The live-reload token is generated once and shared with every serving stack the supervisor // builds, so connected clients keep authenticating across a re-initialization. @@ -81,6 +89,7 @@ export async function serve(graph, { acceptRemoteConnections, sendSAPTargetCSP, simpleIndex, liveReload, serveCSPReports, cache, ui5DataDir, includedTasks, excludedTasks, webSocketToken, + rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd, }; let supervisor; diff --git a/packages/server/test/lib/server/ServeSupervisor.js b/packages/server/test/lib/server/ServeSupervisor.js index ed2c43941d0..2f148c942df 100644 --- a/packages/server/test/lib/server/ServeSupervisor.js +++ b/packages/server/test/lib/server/ServeSupervisor.js @@ -13,7 +13,7 @@ function createBuildServer() { // Builds a mock set for esmock. Each buildServeApp invocation returns the next queued stack, so a // test can hand out distinct {app, buildServer} pairs across the initial build and re-inits. -function createMocks({stacks, buildServeAppImpl} = {}) { +function createMocks({stacks, buildServeAppImpl, definitionWatcherCreate} = {}) { const httpServer = new EventEmitter(); httpServer.close = sinon.stub().callsFake((cb) => cb && cb()); @@ -25,6 +25,22 @@ function createMocks({stacks, buildServeAppImpl} = {}) { const liveReloadHandle = {close: sinon.stub()}; const attachLiveReloadServer = sinon.stub().returns(liveReloadHandle); + // Fake DefinitionWatcher: each create() hands out a fresh EventEmitter with a destroy() stub, + // recorded so a test can assert re-targeting/teardown. A custom impl overrides create(). + const definitionWatchers = []; + const DefinitionWatcher = { + create: sinon.stub().callsFake(async (opts) => { + if (definitionWatcherCreate) { + return definitionWatcherCreate(opts); + } + const watcher = new EventEmitter(); + watcher.createOptions = opts; + watcher.destroy = sinon.stub().resolves(); + definitionWatchers.push(watcher); + return watcher; + }), + }; + const stackQueue = stacks ? [...stacks] : null; const buildServeApp = sinon.stub().callsFake(async (graph, config, error) => { if (buildServeAppImpl) { @@ -44,6 +60,7 @@ function createMocks({stacks, buildServeAppImpl} = {}) { const mocks = { "node:http": httpMock, + "@ui5/project/build/helpers/DefinitionWatcher": {default: DefinitionWatcher}, "../../../lib/serveApp.js": {default: buildServeApp}, "../../../lib/serveHttp.js": {listen, addSsl, announceListening}, "../../../lib/liveReload/server.js": {default: attachLiveReloadServer}, @@ -52,6 +69,7 @@ function createMocks({stacks, buildServeAppImpl} = {}) { return { mocks, httpServer, listen, addSsl, announceListening, attachLiveReloadServer, liveReloadHandle, buildServeApp, createdHandlers, + DefinitionWatcher, definitionWatchers, }; } @@ -271,3 +289,116 @@ test("reinitialize() warns and no-ops when no graphFactory was provided", async await supervisor.reinitialize(); t.true(buildServeApp.calledOnce, "no re-init build happens without a graphFactory"); }); + +test("definition watcher is created on create() only when a graphFactory is present", async (t) => { + const stack = createStack(); + const {mocks, DefinitionWatcher} = createMocks({stacks: [stack]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + await ServeSupervisor.create({}, baseConfig, undefined, {}); + t.true(DefinitionWatcher.create.notCalled, "no watcher without a graphFactory"); +}); + +test("definition watcher is created with the threaded config params", async (t) => { + const stack = createStack(); + const graphFactory = sinon.stub().resolves({}); + const graph = {getRoot: () => ({})}; + const config = { + ...baseConfig, + rootConfigPath: "/app/custom.yaml", + workspaceConfigPath: null, + dependencyDefinitionPath: "/app/deps.yaml", + cwd: "/app", + }; + const {mocks, DefinitionWatcher} = createMocks({stacks: [stack]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + await ServeSupervisor.create(graph, config, undefined, {graphFactory}); + + t.true(DefinitionWatcher.create.calledOnce); + const opts = DefinitionWatcher.create.firstCall.args[0]; + t.is(opts.graph, graph, "watcher gets the initial graph"); + t.is(opts.rootConfigPath, "/app/custom.yaml"); + t.is(opts.workspaceConfigPath, null); + t.is(opts.dependencyDefinitionPath, "/app/deps.yaml"); + t.is(opts.cwd, "/app"); +}); + +test("a definitionChanged event triggers reinitialize()", async (t) => { + const app2 = sinon.stub(); + const stack1 = createStack(); + const stack2 = createStack(app2); + const graphFactory = sinon.stub().resolves({}); + const {mocks, definitionWatchers, createdHandlers} = createMocks({stacks: [stack1, stack2]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + + // The watcher created on init drives the re-init. + definitionWatchers[0].emit("definitionChanged", {eventType: "update", filePath: "/app/ui5.yaml"}); + // reinitialize() is async; let the swap settle. + await new Promise((resolve) => setImmediate(resolve)); + + createdHandlers[0]("req", "res"); + t.true(app2.calledOnceWithExactly("req", "res"), "definition change swapped in the new app"); +}); + +test("watcher is re-targeted to the new graph after a swap (old destroyed, new created)", async (t) => { + const stack1 = createStack(); + const stack2 = createStack(); + const newGraph = {name: "newGraph", getRoot: () => ({})}; + const graphFactory = sinon.stub().resolves(newGraph); + const {mocks, DefinitionWatcher, definitionWatchers} = createMocks({stacks: [stack1, stack2]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + t.is(DefinitionWatcher.create.callCount, 1, "watcher created on init"); + const firstWatcher = definitionWatchers[0]; + + await supervisor.reinitialize(); + + t.is(DefinitionWatcher.create.callCount, 2, "a fresh watcher created after the swap"); + t.is(DefinitionWatcher.create.secondCall.args[0].graph, newGraph, "new watcher targets the new graph"); + t.true(firstWatcher.destroy.calledOnce, "old watcher destroyed"); +}); + +test("a watcher-create failure during swap keeps the server serving", async (t) => { + const app1 = sinon.stub(); + const stack1 = createStack(app1); + const stack2 = createStack(); + const graphFactory = sinon.stub().resolves({getRoot: () => ({})}); + let createCalls = 0; + const {mocks, createdHandlers} = createMocks({ + stacks: [stack1, stack2], + definitionWatcherCreate: async () => { + createCalls++; + if (createCalls === 1) { + const watcher = new EventEmitter(); + watcher.destroy = sinon.stub().resolves(); + return watcher; + } + throw new Error("watcher failed to arm"); + }, + }); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + + await t.notThrowsAsync(supervisor.reinitialize(), "a watcher-create failure does not reject the swap"); + + // The swap itself still committed — the new stack is serving. + createdHandlers[0]("req", "res"); + t.true(stack2.app.calledOnceWithExactly("req", "res"), "the new app serves despite the watcher failure"); +}); + +test("destroy() tears the definition watcher down", async (t) => { + const stack = createStack(); + const graphFactory = sinon.stub().resolves({}); + const {mocks, definitionWatchers} = createMocks({stacks: [stack]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + + await new Promise((resolve) => supervisor.destroy(resolve)); + t.true(definitionWatchers[0].destroy.calledOnce, "watcher destroyed on teardown"); +}); diff --git a/packages/server/test/lib/server/reinitialize.js b/packages/server/test/lib/server/reinitialize.js index 06e14ad63f5..d1ad65fa578 100644 --- a/packages/server/test/lib/server/reinitialize.js +++ b/packages/server/test/lib/server/reinitialize.js @@ -1,5 +1,7 @@ import test from "ava"; import supertest from "supertest"; +import fs from "node:fs/promises"; +import path from "node:path"; import {serve} from "../../../lib/server.js"; import {graphFromPackageDependencies} from "@ui5/project/graph"; import {isolatedUi5DataDir} from "../../utils/buildCacheIsolation.js"; @@ -41,6 +43,58 @@ test.serial("reinitialize() keeps the port bound and continues serving", async ( await new Promise((resolve) => server.close(resolve)); }); +// Integration coverage for the DefinitionWatcher trigger: editing a project's ui5.yaml on disk +// drives a real re-init through the watcher (not a programmatic reinitialize() call). Runs against +// a fixture copied to test/tmp so the edit does not mutate the checked-in fixture. +test.serial("editing ui5.yaml triggers a re-init and keeps serving on the same port", async (t) => { + const ui5DataDir = isolatedUi5DataDir(t); + const tmpProject = path.join("./test/tmp", `reinitialize-watcher-${process.pid}`); + await fs.rm(tmpProject, {recursive: true, force: true}); + await fs.cp("./test/fixtures/application.a", tmpProject, {recursive: true}); + + const buildTmpGraph = () => graphFromPackageDependencies({cwd: tmpProject}); + const graph = await buildTmpGraph(); + + // Count re-resolutions so we can await the watcher-driven one. + let graphBuilds = 0; + const graphFactory = async () => { + graphBuilds++; + return buildTmpGraph(); + }; + + const server = await serve(graph, { + port: 3397, + changePortIfInUse: true, + liveReload: false, + ui5DataDir, + cwd: tmpProject, + }, undefined, {graphFactory}); + + const port = server.port; + const request = supertest(`http://127.0.0.1:${port}`); + t.is((await request.get("/index.html")).statusCode, 200, "serves before the edit"); + + // Edit ui5.yaml on disk. The watcher's settle window (550 ms) then coalesces the change and + // drives a re-init through the graphFactory. + const ui5YamlPath = path.join(tmpProject, "ui5.yaml"); + const original = await fs.readFile(ui5YamlPath, "utf8"); + await fs.writeFile(ui5YamlPath, original + "\n# watcher-triggered edit\n"); + + // Wait for the watcher to observe the change and complete the re-init (settle + graph rebuild). + const deadline = Date.now() + 15000; + while (graphBuilds === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + t.true(graphBuilds >= 1, "the ui5.yaml edit drove at least one re-init via the watcher"); + + const after = await request.get("/index.html"); + t.is(after.statusCode, 200, "serves after the watcher-driven re-init"); + t.is(server.port, port, "the bound port is unchanged"); + + await new Promise((resolve) => server.close(resolve)); + await fs.rm(tmpProject, {recursive: true, force: true}); +}); + test.serial("reinitialize() without a graphFactory is a no-op and keeps serving", async (t) => { const ui5DataDir = isolatedUi5DataDir(t); const graph = await buildGraph(); From a4198492d360451a548977d5ab80a2f92c1ce2b1 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 13 Jul 2026 19:47:23 +0200 Subject: [PATCH 07/31] refactor(cli): Thread config paths to the definition watcher Pass the argv values that already feed buildGraph (argv.config, argv.workspaceConfig, argv.dependencyDefinition) into serverConfig so they also reach the server's definition watcher. Workspace resolution is active unless static-graph mode is used or --workspace is disabled; pass null then (undefined to skip) so the watcher applies its default ui5-workspace.yaml even when --workspace-config was not given. Static-graph mode omits the workspace, matching how buildGraph resolves the graph. --- packages/cli/lib/cli/commands/serve.js | 25 ++++++++++++++------- packages/cli/test/lib/cli/commands/serve.js | 9 ++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index 0f4173862bf..77dc3c8b3ba 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -147,9 +147,14 @@ serve.handler = async function(argv) { const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph"); - // Single graph-construction site, reused for the initial graph and for every re-initialization - // the server performs on a project-definition change. Captures argv so the server can re-resolve - // with identical parameters without threading them across the package boundary. + // 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({ @@ -164,12 +169,12 @@ serve.handler = async function(argv) { versionOverride: argv.frameworkVersion, snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback workspaceConfigPath: argv.workspaceConfig, - workspaceName: argv.workspace === false ? null : argv.workspace, + workspaceName: workspaceActive ? argv.workspace : null, }); } }; - // Build the initial graph up front — its root server settings resolve the port and + // 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(); @@ -218,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) { @@ -229,9 +239,8 @@ 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-create the serving stack on a - // project-definition change. The returned reinitialize handle is unused for now; a future - // definition watcher will call it. + // 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}); diff --git a/packages/cli/test/lib/cli/commands/serve.js b/packages/cli/test/lib/cli/commands/serve.js index 4a3824a21a7..bcf35f7f263 100644 --- a/packages/cli/test/lib/cli/commands/serve.js +++ b/packages/cli/test/lib/cli/commands/serve.js @@ -125,6 +125,9 @@ 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"); @@ -178,6 +181,9 @@ test.serial("ui5 serve --h2", async (t) => { liveReload: true, includedTasks: undefined, excludedTasks: undefined, + rootConfigPath: undefined, + workspaceConfigPath: null, + dependencyDefinitionPath: undefined, } ]); @@ -213,6 +219,9 @@ test.serial("ui5 serve --accept-remote-connections", async (t) => { liveReload: true, includedTasks: undefined, excludedTasks: undefined, + rootConfigPath: undefined, + workspaceConfigPath: null, + dependencyDefinitionPath: undefined, } ]); }); From 5f6b63ef2cb550cff0b443bc486dd375570d6f60 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 15 Jul 2026 11:34:31 +0200 Subject: [PATCH 08/31] refactor(server): Extract buildServeRouter from buildServeApp Split the router-agnostic middleware-assembly core out of buildServeApp into an exported buildServeRouter(graph, config, error) returning {router, buildServer, liveReloadOptions}. It mounts every UI5 middleware onto an express.Router() (which both an Express app and a Connect app accept via use()), stopping short of creating the express() app and the terminal error handler. buildServeApp now wraps buildServeRouter: it creates the express() app, mounts the router, and appends createErrorHandler. Its {app, buildServer, liveReloadOptions} return contract is unchanged, so ServeSupervisor is untouched. This isolates the two HTTP-server-owned concerns (the terminal error handler and, via ServeSupervisor, the live-reload WebSocket server) from the middleware core, so an embedding API can reuse the core without them. --- packages/server/lib/serveApp.js | 56 ++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/packages/server/lib/serveApp.js b/packages/server/lib/serveApp.js index 8ec29e5a6bb..a82ab81af5a 100644 --- a/packages/server/lib/serveApp.js +++ b/packages/server/lib/serveApp.js @@ -9,24 +9,25 @@ import Cache from "@ui5/project/build/cache/Cache"; const log = getLogger("server"); /** - * Builds the Express application and its BuildServer for a project graph, without binding - * to a port. Everything the dev server needs to answer requests is assembled here; the - * {@link module:@ui5/server.serve} wrapper and the {@link ServeSupervisor} own the listener - * so a graph swap can re-create the app behind a stable HTTP server. + * Builds the middleware-bearing router and its BuildServer for a project graph, without + * binding to a port and without a terminal error handler. This is the router-agnostic core + * shared between the {@link module:@ui5/server.serve} wrapper (via {@link buildServeApp}) and + * the {@link module:@ui5/server.serveMiddleware} embedding API: everything needed to answer + * requests is mounted onto an {@link https://expressjs.com/en/4x/api.html#router Express Router}, + * which both an Express application and a Connect app accept via use(). * * @private - * @module @ui5/server/serveApp * @param {@ui5/project/graph/ProjectGraph} graph Project graph - * @param {object} config Server configuration — the resolved options passed to - * {@link module:@ui5/server.serve} (sendSAPTargetCSP, simpleIndex, - * liveReload, serveCSPReports, cache, ui5DataDir, + * @param {object} config Server configuration: the resolved options + * (sendSAPTargetCSP, simpleIndex, liveReload, + * serveCSPReports, cache, ui5DataDir, * includedTasks, excludedTasks), plus a stable * webSocketToken shared across swaps * @param {Function} [error] Error callback invoked when the BuildServer emits an error outside * of request handling - * @returns {Promise} Resolves with {app, buildServer, liveReloadOptions} + * @returns {Promise} Resolves with {router, buildServer, liveReloadOptions} */ -export default async function buildServeApp(graph, config, error) { +export async function buildServeRouter(graph, config, error) { const { sendSAPTargetCSP = false, simpleIndex = false, liveReload = false, serveCSPReports = false, cache = Cache.Default, ui5DataDir, includedTasks, excludedTasks, webSocketToken = null, @@ -111,12 +112,37 @@ export default async function buildServeApp(graph, config, error) { } }); + // eslint-disable-next-line new-cap -- express.Router is a factory, not a constructor + const router = express.Router(); + await middlewareManager.applyMiddleware(router); + return {router, buildServer, liveReloadOptions}; +} + +/** + * Builds the Express application and its BuildServer for a project graph, without binding + * to a port. Wraps {@link buildServeRouter} with an {@link express} application and the + * terminal error handler, so the {@link module:@ui5/server.serve} wrapper and the + * {@link ServeSupervisor} can own the listener and re-create the app behind a stable HTTP + * server on a graph swap. The error handler and the live-reload WebSocket server are + * CLI-owned concerns and stay out of the router core used by the embedding API. + * + * @private + * @module @ui5/server/serveApp + * @param {@ui5/project/graph/ProjectGraph} graph Project graph + * @param {object} config Server configuration; see {@link buildServeRouter} + * @param {Function} [error] Error callback invoked when the BuildServer emits an error outside + * of request handling + * @returns {Promise} Resolves with {app, buildServer, liveReloadOptions} + */ +export default async function buildServeApp(graph, config, error) { + const {router, buildServer, liveReloadOptions} = await buildServeRouter(graph, config, error); + const app = express(); - await middlewareManager.applyMiddleware(app); - // Terminal error handler for the middleware chain. Registered after applyMiddleware - // so it sits last and intercepts every next(err) — including those from custom - // middleware where we can't wrap a try/catch. Threads the live-reload config in - // so the HTML error page can embed the live-reload client script. + app.use(router); + // Terminal error handler for the middleware chain. Registered after the router so it sits + // last and intercepts every next(err), including those from custom middleware we can't wrap + // in a try/catch. Threads the live-reload config in so the HTML error page can embed the + // live-reload client script. app.use(createErrorHandler({liveReload: liveReloadOptions})); return {app, buildServer, liveReloadOptions}; From 3ce9b66392a591468b501f6580605ec7ad241b0e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Wed, 15 Jul 2026 11:35:04 +0200 Subject: [PATCH 09/31] refactor(server): Add serveMiddleware API for external HTTP servers Add a public serveMiddleware(graph, options, error) returning {middleware, buildServer, close} so consumers running their own express/connect server (e.g. karma-ui5) can mount UI5's middleware with a single app.use(middleware), rather than importing @ui5/server/internal/MiddlewareManager and hand-assembling readers. serveMiddleware delegates to buildServeRouter and returns its router. It accepts only the middleware-relevant options (sendSAPTargetCSP, simpleIndex, serveCSPReports, cache, ui5DataDir, includedTasks, excludedTasks); port, h2/SSL, remote-connection, and definition-watcher options belong to the CLI-owned listener. Live-reload stays in place but dormant with no token. No terminal error handler and no live-reload WebSocket server are attached: the caller owns the HTTP server and its error handling. close() releases the BuildServer's source watcher and build-cache handle and is idempotent. A graph can be served only once, so serveMiddleware and serve must not run against the same graph. Exported as a named export from lib/server.js alongside serve(). --- packages/server/lib/serveMiddleware.js | 74 +++++++++++ packages/server/lib/server.js | 5 + packages/server/test/lib/package-exports.js | 2 + .../server/test/lib/server/serveMiddleware.js | 124 ++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 packages/server/lib/serveMiddleware.js create mode 100644 packages/server/test/lib/server/serveMiddleware.js diff --git a/packages/server/lib/serveMiddleware.js b/packages/server/lib/serveMiddleware.js new file mode 100644 index 00000000000..6b70ef39641 --- /dev/null +++ b/packages/server/lib/serveMiddleware.js @@ -0,0 +1,74 @@ +import Cache from "@ui5/project/build/cache/Cache"; +import {buildServeRouter} from "./serveApp.js"; + +/** + * Assembles the UI5 middleware for a project graph and returns it as a single connect/Express + * middleware, for mounting into an existing HTTP server rather than starting one. + * + * Unlike {@link module:@ui5/server.serve}, this does not bind a port, attach the live-reload + * WebSocket server, or install the terminal HTML error handler; those belong to the server the + * UI5 CLI owns. The caller mounts the returned middleware on its own + * express() app or Express Router/Connect app via + * app.use(middleware) and owns error handling and the HTTP listener. + * + * close must be called on teardown to release the BuildServer's source watcher and + * build-cache handle. + * + * Note: A project graph can be served only once. Do not call both serveMiddleware + * and {@link module:@ui5/server.serve} for the same graph. + * + * @public + * @alias module:@ui5/server.serveMiddleware + * @param {@ui5/project/graph/ProjectGraph} graph Project graph + * @param {object} [options] Options + * @param {boolean|module:@ui5/server.SAPTargetCSPOptions} [options.sendSAPTargetCSP=false] + * If set to true or an object, then the default (or configured) + * set of security policies that SAP and UI5 aim for (AKA 'target policies'), + * are send for any requested *.html file + * @param {boolean} [options.serveCSPReports=false] Enable CSP reports serving for request url + * '/.ui5/csp/csp-reports.json' + * @param {boolean} [options.simpleIndex=false] Use a simplified view for the server directory listing + * @param {string} [options.cache="Default"] Cache mode to use for building UI5 projects. + * @param {string} [options.ui5DataDir] Explicit UI5 data directory to use for the build cache. + * Overrides the UI5_DATA_DIR environment variable, + * the UI5 configuration file, and the default of ~/.ui5. + * @param {string[]} [options.includedTasks] A list of tasks to be added to the default execution set. + * Takes precedence over excludedTasks. + * @param {string[]} [options.excludedTasks] A list of tasks to be excluded from the default task + * execution set. + * @param {Function} [error] Error callback. Will be called when the BuildServer emits an error + * outside of request handling. + * @returns {Promise} Promise resolving with an object containing the + * middleware (a connect/Express-compatible handler to be + * mounted via app.use()), the buildServer, and a + * close function releasing the BuildServer's watcher and cache. + */ +export default async function serveMiddleware(graph, { + sendSAPTargetCSP = false, simpleIndex = false, serveCSPReports = false, cache = Cache.Default, + ui5DataDir, includedTasks, excludedTasks, +} = {}, error) { + const config = { + sendSAPTargetCSP, simpleIndex, serveCSPReports, cache, + ui5DataDir, includedTasks, excludedTasks, + // The live-reload WebSocket server belongs to the UI5 CLI's HTTP server, which this + // embedding API does not create. Inactive with no token leaves the liveReloadClient + // middleware in place but dormant (no client script injection). + liveReload: false, + webSocketToken: null, + }; + + const {router, buildServer} = await buildServeRouter(graph, config, error); + + let destroyed = false; + return { + middleware: router, + buildServer, + close: async function close() { + if (destroyed) { + return; + } + destroyed = true; + await buildServer.destroy(); + }, + }; +} diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 78a9f74c65f..55a5a356128 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -111,3 +111,8 @@ export async function serve(graph, { }, }; } + +// Public API for integrating UI5's middleware into an existing HTTP server. Re-exported here +// so it is reachable via the package's main entry alongside serve(); the @public JSDoc lives +// on the function itself in serveMiddleware.js. +export {default as serveMiddleware} from "./serveMiddleware.js"; diff --git a/packages/server/test/lib/package-exports.js b/packages/server/test/lib/package-exports.js index 076fe12bb93..61d94e289b9 100644 --- a/packages/server/test/lib/package-exports.js +++ b/packages/server/test/lib/package-exports.js @@ -20,6 +20,8 @@ test("@ui5/server", async (t) => { const actual = await import("@ui5/server"); const expected = await import("../../lib/server.js"); t.is(actual, expected, "Correct module exported"); + t.is(typeof actual.serve, "function", "serve is exported"); + t.is(typeof actual.serveMiddleware, "function", "serveMiddleware is exported"); }); // Internal modules (only to be used by @ui5/* / SAP owned packages) diff --git a/packages/server/test/lib/server/serveMiddleware.js b/packages/server/test/lib/server/serveMiddleware.js new file mode 100644 index 00000000000..480f6873306 --- /dev/null +++ b/packages/server/test/lib/server/serveMiddleware.js @@ -0,0 +1,124 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; +import express from "express"; +import supertest from "supertest"; +import {graphFromPackageDependencies} from "@ui5/project/graph"; +import serveMiddleware from "../../../lib/serveMiddleware.js"; +import {INJECT_SCRIPT_TAG} from "../../../lib/liveReload/constants.js"; +import {isolatedUi5DataDir} from "../../utils/buildCacheIsolation.js"; + +// Integration: mount the returned middleware on a caller-owned express app and serve real +// requests through supertest, without binding a port or starting a UI5-owned HTTP server. + +let app; +let close; +let request; + +test.before(async (t) => { + const graph = await graphFromPackageDependencies({ + cwd: "./test/fixtures/application.a" + }); + + const result = await serveMiddleware(graph, { + ui5DataDir: isolatedUi5DataDir(t), + }); + close = result.close; + + app = express(); + app.use(result.middleware); + request = supertest(app); +}); + +test.after.always(async () => { + await close(); +}); + +async function get(path) { + const res = await request.get(path); + if (res.error) { + throw new Error(res.error); + } + return res; +} + +test("Serves index.html through a caller-owned express app", async (t) => { + const res = await get("/index.html"); + t.is(res.statusCode, 200, "Correct HTTP status code"); + t.regex(res.headers["content-type"], /html/, "Correct content type"); + t.regex(res.text, /Application A<\/title>/, "Correct response"); +}); + +test("Serves the UI5 version info", async (t) => { + const res = await get("/resources/sap-ui-version.json"); + t.is(res.statusCode, 200, "Correct HTTP status code"); + t.regex(res.headers["content-type"], /json/, "Correct content type"); +}); + +test("Does not inject the live-reload client script", async (t) => { + const res = await get("/index.html"); + t.false(res.text.includes(INJECT_SCRIPT_TAG), + "The live-reload client script is not injected in the embedding API"); +}); + +// Unit: the module contract independent of a real graph — the returned middleware is the +// router from the shared core, close() releases the BuildServer once and is idempotent, and +// the live-reload WebSocket path is disabled. + +function createBuildServeRouterMock() { + const router = sinon.stub(); + const buildServer = { + destroy: sinon.stub().resolves(), + }; + const buildServeRouter = sinon.stub().resolves({ + router, + buildServer, + liveReloadOptions: {active: false, token: null}, + }); + return {router, buildServer, buildServeRouter}; +} + +async function importServeMiddleware(buildServeRouter) { + return esmock("../../../lib/serveMiddleware.js", { + "../../../lib/serveApp.js": {buildServeRouter}, + }); +} + +test("serveMiddleware() returns the router as middleware and disables live-reload", async (t) => { + const {router, buildServeRouter} = createBuildServeRouterMock(); + const {default: serveMiddlewareMocked} = await importServeMiddleware(buildServeRouter); + const graph = {}; + + const result = await serveMiddlewareMocked(graph, {ui5DataDir: "/tmp/data"}); + + t.true(buildServeRouter.calledOnce); + const [passedGraph, config] = buildServeRouter.firstCall.args; + t.is(passedGraph, graph, "the graph is threaded through to the shared core"); + t.is(config.liveReload, false, "live-reload is disabled for the embedding API"); + t.is(config.webSocketToken, null, "no WebSocket token is minted for the embedding API"); + t.is(config.ui5DataDir, "/tmp/data", "options are threaded into the config"); + t.is(result.middleware, router, "the shared core's router is returned as middleware"); + t.is(typeof result.close, "function"); +}); + +test("serveMiddleware() close() destroys the BuildServer once and is idempotent", async (t) => { + const {buildServer, buildServeRouter} = createBuildServeRouterMock(); + const {default: serveMiddlewareMocked} = await importServeMiddleware(buildServeRouter); + + const {close} = await serveMiddlewareMocked({}, {}); + await close(); + await close(); + + t.true(buildServer.destroy.calledOnce, "the BuildServer is destroyed exactly once"); +}); + +test("serveMiddleware() works without options", async (t) => { + const {buildServeRouter} = createBuildServeRouterMock(); + const {default: serveMiddlewareMocked} = await importServeMiddleware(buildServeRouter); + + await serveMiddlewareMocked({}); + + const config = buildServeRouter.firstCall.args[1]; + t.is(config.liveReload, false); + t.is(config.webSocketToken, null); +}); From 3d895f3e4f2e968dc14d7f2281ab1e772a8ac9e2 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Wed, 15 Jul 2026 17:08:31 +0200 Subject: [PATCH 10/31] refactor(logger): Update project region on repeated project-resolved The InteractiveConsole writer's model is single-root-project, but the root can be resolved more than once in a process: ServeSupervisor.reinitialize() re-resolves the graph on a project-definition change (a git checkout, a hand-edit of ui5.yaml) and re-emits ui5.project-resolved for the same root. projectGraphBuilder emits the event on every build. Previously the writer latched #seenProjectResolved on the first event and threw "Received duplicate ui5.project-resolved event" on the second. Because process.emit runs listeners synchronously, that throw propagated out of the graph factory into ServeSupervisor.#swap and aborted every re-init whenever the interactive writer was active (the default for `ui5 serve` in a TTY): the feature was inert. Treat a repeat as an in-place update: the framework name/version and project type may have changed across the switch, so setProject refreshes the region. This mirrors the non-interactive Console writer, which already logs a fresh info line per event. --- .../logger/lib/writers/InteractiveConsole.js | 16 ++++------ .../test/lib/writers/InteractiveConsole.js | 29 ++++++++++++------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index b0f35c737e8..ae05e81b8c4 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -97,8 +97,6 @@ class InteractiveConsole { stderr: {orig: null, partial: ""}, }; - #seenProjectResolved = false; - // Bound listeners so we can `process.off` them on stop(). #onLog; #onBuildMetadata; @@ -369,15 +367,11 @@ 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: `ServeSupervisor.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); this.#render(); } diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index 853fe032be6..1412910a53e 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -79,7 +79,7 @@ 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", { @@ -87,18 +87,25 @@ test.serial("duplicate project-resolved throws", (t) => { 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 (ServeSupervisor.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-resolved", { + 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(); }); From 08f9cb493e1d7ad8446b85e1c65aebe11170c4a1 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Wed, 15 Jul 2026 17:40:18 +0200 Subject: [PATCH 11/31] refactor(project): Keep source watcher alive across a branch switch A git checkout that moves or removes a watched source directory while `ui5 serve` is running escalated the server to a terminal ERROR. Two defects in WatchHandler, both independent of the definition-watcher re-init that is the primary branch-switch recovery path: 1. #handleWatchEvents called getVirtualPath for every event. On a diverged tree the path no longer maps into the project the watcher was armed with, so getVirtualPath threw "Unable to convert source path ... to virtual path", and the per-event catch turned that into a fatal watcher error. 2. Recovery re-subscribed over the same graph's paths; parcelWatcher.subscribe on a removed .../src directory rejected with "No such file or directory", which propagated to #setState(ERROR). Drop an event whose path no longer maps to a virtual path (verbose log), and skip a subscribe path that no longer exists on disk (warn log). The skip reads current disk state via fs access rather than matching parcel's locale-dependent, code-less message; a subscribe failure on a path that still exists is a genuine fault and still propagates into BuildServer's loop-protected recovery/escalation budget. --- .../project/lib/build/helpers/WatchHandler.js | 61 ++++++++++++--- .../project/test/lib/build/BuildServer.js | 6 ++ .../test/lib/build/helpers/WatchHandler.js | 77 ++++++++++++++++--- 3 files changed, 123 insertions(+), 21 deletions(-) diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 7ec3eebb89e..8de92fc2b98 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -1,8 +1,20 @@ import EventEmitter from "node:events"; +import {access} from "node:fs/promises"; import parcelWatcher from "@parcel/watcher"; import {getLogger} from "@ui5/logger"; 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 +41,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); })); } @@ -62,7 +88,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/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index 4311adb3798..84f9ef69b7d 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -1706,6 +1706,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; 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(); From 5cdb149c9e9f7b584e426efe69c531f470f5e585 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Thu, 16 Jul 2026 11:51:01 +0200 Subject: [PATCH 12/31] refactor(logger): Placeholder the project version during re-resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive console's Project region shows the root project version read from package.json. While `ui5 serve` runs and a definition change is known to be coming (a branch switch, a ui5.yaml edit), the displayed version is stale until the graph re-resolves: the settle window plus the resolve time, seconds on a large project. Add two process events that bracket a re-resolve. `ui5.project-resolving` blanks the version slot(s) to a dim `resolving…` placeholder while the project name and type keep showing, so the region does not collapse. This is distinct from the startup `showPlaceholders` reservation (driven by `ui5.tool-mode`), which placeholders the whole region before any data arrives. The placeholder is released one of two ways. A completed resolve arrives as `ui5.project-resolved` and repopulates the slot with the new version (via `setProject`). A re-resolve abandoned without completing arrives as `ui5.project-resolve-failed`, which releases the placeholder back to the last-known version rather than leaving it on `resolving…` indefinitely. The framework version goes stale alongside the project version on a branch switch, so it is placeholdered too, keeping the framework name. --- .../logger/lib/writers/InteractiveConsole.js | 25 +++++++++ .../lib/writers/interactiveConsole/render.js | 18 +++++-- .../interactiveConsole/state/project.js | 19 +++++++ .../test/lib/writers/InteractiveConsole.js | 53 +++++++++++++++++++ .../lib/writers/interactiveConsole/render.js | 28 +++++++++- .../interactiveConsole/state/project.js | 19 ++++++- 6 files changed, 157 insertions(+), 5 deletions(-) diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index ae05e81b8c4..188b1f1445c 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -9,6 +9,8 @@ import { setProject, setFramework, enableProjectPlaceholders, + setVersionResolving, + clearVersionResolving, } from "./interactiveConsole/state/project.js"; import {createServerState, setListening, enableServerPlaceholders} from "./interactiveConsole/state/server.js"; import { @@ -107,6 +109,8 @@ class InteractiveConsole { #onToolMode; #onProjectResolved; #onProjectFrameworkResolved; + #onProjectResolving; + #onProjectResolveFailed; #onServerListening; #onStopConsole; #onResize; @@ -297,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 = () => this.#handleProjectResolveFailed(); this.#onServerListening = (evt) => this.#handleServerListening(evt); this.#onStopConsole = () => this.disable(); this.#onResize = () => this.#handleResize(); @@ -310,6 +316,8 @@ class InteractiveConsole { process.on("ui5.tool-mode", this.#onToolMode); process.on("ui5.project-resolved", this.#onProjectResolved); process.on("ui5.project-framework-resolved", this.#onProjectFrameworkResolved); + process.on("ui5.project-resolving", 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,6 +335,8 @@ class InteractiveConsole { process.off("ui5.tool-mode", this.#onToolMode); process.off("ui5.project-resolved", this.#onProjectResolved); process.off("ui5.project-framework-resolved", this.#onProjectFrameworkResolved); + process.off("ui5.project-resolving", 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") { @@ -381,6 +391,21 @@ 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-resolved` 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-resolved`: release the + // placeholder back to the last-known version rather than leaving it on "resolving…". + #handleProjectResolveFailed() { + clearVersionResolving(this.#projectState); + this.#render(); + } + #handleServerListening(evt) { setListening(this.#serverState, evt); this.#render(); diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js index 2ee193e803d..f912dd7e704 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,14 @@ 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)}`); + if (resolving) { + // The framework version goes stale alongside the project version + // on a branch switch — placeholder it too, keeping the name. + lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name)} ${placeholder("resolving…")}`); + } else { + const frameworkVersion = framework.version ? ` ${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/project.js b/packages/logger/lib/writers/interactiveConsole/state/project.js index f0a58286fc9..c15bbee0de9 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/project.js +++ b/packages/logger/lib/writers/interactiveConsole/state/project.js @@ -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-resolving` 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-resolved`. +export function clearVersionResolving(state) { + state.versionResolving = false; +} diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index 1412910a53e..b485434ce45 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -110,6 +110,59 @@ test.serial("repeated project-resolved updates the project region in place", (t) writer.disable(); }); +test.serial("project-resolving shows a version placeholder, project-resolved clears it", (t) => { + const {writer, stderr} = createWriter(); + + process.emit("ui5.project-resolved", { + 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-resolving"); + 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-resolved", { + 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-resolved", { + name: "my.app", type: "application", version: "1.0.0", + }); + process.emit("ui5.project-resolving"); + 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(); +}); + test.serial("server-listening populates the server region", (t) => { const {writer} = createWriter(); 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/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"); +}); From af6d95a3545ff4a4882e521620647b0b5cc4c7e5 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Thu, 16 Jul 2026 11:54:09 +0200 Subject: [PATCH 13/31] refactor(project): Emit definitionChanging on the leading edge DefinitionWatcher coalesces a definition-file burst (a git checkout writes ui5.yaml + package.json + sources across batches) into a single trailing definitionChanged, a settle window after the last event. That is the right moment to re-resolve the graph, but it leaves the window between the first change and the settled emit with no signal that a re-init is coming. Emit a definitionChanging on the leading edge (the first watched event of a burst, detected as no settle timer being armed yet) before arming the timer. The trailing definitionChanged is unchanged. An owner uses this to react to the pending change (e.g. show that the resolved version is about to change) ahead of the re-resolve, without a second event per batch: further events within the window reset the settle timer but do not re-fire definitionChanging. --- .../lib/build/helpers/DefinitionWatcher.js | 51 +++++++++-------- .../lib/build/helpers/DefinitionWatcher.js | 55 +++++++++++++++++++ 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/packages/project/lib/build/helpers/DefinitionWatcher.js b/packages/project/lib/build/helpers/DefinitionWatcher.js index 50108ce6f0e..a1b70d29ef2 100644 --- a/packages/project/lib/build/helpers/DefinitionWatcher.js +++ b/packages/project/lib/build/helpers/DefinitionWatcher.js @@ -9,32 +9,32 @@ const WORKSPACE_CONFIG_DEFAULT = "ui5-workspace.yaml"; // Settle window for the `definitionChanged` event, in milliseconds. // -// A `git checkout` or a branch switch writes ui5.yaml + package.json + sources within one +// A `git checkout` or branch switch writes ui5.yaml + package.json + sources within one // operation; @parcel/watcher delivers that as batches up to its MAX_WAIT_TIME (500 ms) apart. -// A trailing timer, reset on each further event, collapses the whole burst into a single emit. -// Unlike the live-reload `sourcesChanged` emit, a re-init has no need for a leading edge — it is -// wasteful to re-create the serving stack on the first byte of a checkout — so this window is -// trailing-only. The value mirrors BuildServer's SOURCES_CHANGED_SETTLE_MS and must stay above -// the 500 ms watcher cap so each batch resets the window rather than terminating it. +// A trailing timer, reset on each further event, collapses the burst into a single 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. Mirrors BuildServer's SOURCES_CHANGED_SETTLE_MS +// and must stay above the 500 ms cap so each batch resets the window rather than terminating it. const DEFINITION_CHANGED_SETTLE_MS = 550; -// Loop protection for watcher recovery. A persistently failing watcher would otherwise cycle -// error → recover → error indefinitely. If more than WATCHER_RECOVERY_MAX_ATTEMPTS recoveries -// complete within WATCHER_RECOVERY_WINDOW_MS, the watcher is treated as unrecoverable and a -// terminal "error" is emitted. This budget is the DefinitionWatcher's own — independent of the -// source WatchHandler's recovery inside BuildServer. +// Loop protection for watcher recovery, so a persistently failing watcher does not cycle +// error → recover → error forever. More than WATCHER_RECOVERY_MAX_ATTEMPTS recoveries within +// WATCHER_RECOVERY_WINDOW_MS is treated as unrecoverable and emits a terminal "error". This +// budget is the DefinitionWatcher's own, independent of the source WatchHandler's in BuildServer. const WATCHER_RECOVERY_MAX_ATTEMPTS = 5; const WATCHER_RECOVERY_WINDOW_MS = 60000; /** - * 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 - * <code>definitionChanged</code> when one changes. + * 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 + * <code>definitionChanged</code> when one changes. A <code>definitionChanging</code> fires on the + * leading edge of a burst (the first watched event, before the settle window) so an owner can react + * to a pending change ahead of the coalesced <code>definitionChanged</code>. * * 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 directory and the - * change callback drops every event whose path is not in the resolved definition-file set. The + * 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 directory and the change + * callback drops every event whose path is not in the resolved definition-file set. The * <code>node_modules</code>/<code>.git</code> ignore globs only reduce OS-level watch load; * correctness comes from the include set. * @@ -46,8 +46,8 @@ class DefinitionWatcher extends EventEmitter { // 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<absolute file path>: the distinct directories to subscribe, each mapped to the - // definition files under it. Retained so recovery can re-subscribe the same set. + // dir -> Set<absolute file path>: the distinct directories to subscribe, each mapped to its + // definition files. Retained so recovery can re-subscribe the same set. #watchDirs = new Map(); #settleTimer = null; @@ -110,13 +110,13 @@ class DefinitionWatcher extends EventEmitter { } }); - // The workspace config lives at cwd. It may not exist yet — a create event on it is still - // meaningful (it can introduce workspace resolution on the next re-init). + // 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 itself is a topology definition, + // 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)); @@ -156,6 +156,11 @@ class DefinitionWatcher extends EventEmitter { this.#lastEvent = {eventType, filePath}; if (this.#settleTimer) { clearTimeout(this.#settleTimer); + } 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.#settleTimer = setTimeout(() => { this.#settleTimer = null; @@ -211,7 +216,7 @@ class DefinitionWatcher extends EventEmitter { } /** - * Unsubscribes all watchers. Idempotent — a second call is a no-op. Unsubscribe failures are + * Unsubscribes all watchers. Idempotent; a second call is a no-op. Unsubscribe failures are * aggregated into an <code>AggregateError</code> emitted as <code>error</code>. * * @returns {Promise<void>} Resolves once every subscription has been drained diff --git a/packages/project/test/lib/build/helpers/DefinitionWatcher.js b/packages/project/test/lib/build/helpers/DefinitionWatcher.js index d806e51c35d..5cf0d5d8251 100644 --- a/packages/project/test/lib/build/helpers/DefinitionWatcher.js +++ b/packages/project/test/lib/build/helpers/DefinitionWatcher.js @@ -252,6 +252,61 @@ test.serial("settle: a burst within the window emits definitionChanged exactly o 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: "/app"}); + const watcher = await DefinitionWatcher.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: "/app/ui5.yaml"}]); + t.is(changing.length, 1, "definitionChanging fires on the leading edge"); + t.deepEqual(changing[0], {eventType: "update", filePath: "/app/ui5.yaml"}); + 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: "/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: "/app/ui5.yaml"}]); + 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: "/app"}); + const watcher = await DefinitionWatcher.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: "/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(); From 0c9a819bd983fc11336122d8ba623e02dfcce5f3 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Thu, 16 Jul 2026 11:59:39 +0200 Subject: [PATCH 14/31] refactor(server): Signal project-resolving across a definition-driven swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive console's Project region shows the root project version. On a branch switch the definition watcher fires, the graph re-resolves, and the new version paints, but the window between the change and the resolve shows the stale version. Wire the watcher's leading-edge definitionChanging to process.emit("ui5.project-resolving"), so the Project region blanks the version slot to a "resolving…" placeholder from the moment a change is known. The swap's own graph resolution emits ui5.project-resolved and repopulates the slot with the new version naturally, so the placeholder needs no early re-resolve to clear it. The subscription is attached in #startDefinitionWatcher alongside the definitionChanged handler, so it survives watcher re-targeting after each swap. A resolve that fails never emits ui5.project-resolved, so the #swap failure path emits ui5.project-resolve-failed to release the placeholder back to the last-known version the still-serving stack resolved. --- .../lib/writers/interactiveConsole/render.js | 20 ++--- packages/server/lib/ServeSupervisor.js | 25 ++++-- .../server/test/lib/server/ServeSupervisor.js | 83 +++++++++++++++++++ 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js index f912dd7e704..2a9b90c804e 100644 --- a/packages/logger/lib/writers/interactiveConsole/render.js +++ b/packages/logger/lib/writers/interactiveConsole/render.js @@ -47,9 +47,9 @@ export function renderProjectRegion(projectState) { const framework = projectState.framework; const resolving = projectState.versionResolving; const projectType = project.type ? chalk.dim(`(${project.type})`) : ""; - // 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. + // 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) : ""); @@ -62,14 +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) { - if (resolving) { - // The framework version goes stale alongside the project version - // on a branch switch — placeholder it too, keeping the name. - lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name)} ${placeholder("resolving…")}`); - } else { - 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/server/lib/ServeSupervisor.js b/packages/server/lib/ServeSupervisor.js index 958aa6c4751..580335cfa86 100644 --- a/packages/server/lib/ServeSupervisor.js +++ b/packages/server/lib/ServeSupervisor.js @@ -141,6 +141,12 @@ class ServeSupervisor extends EventEmitter { 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-resolved` (or a failed swap releases it; see #swap). Attached here so it + // survives watcher re-targeting after each swap, mirroring the definitionChanged wiring. + watcher.on("definitionChanging", () => process.emit("ui5.project-resolving")); watcher.on("error", (err) => log.warn(`Definition watcher error: ${err?.message ?? err}`)); this.#definitionWatcher = watcher; } @@ -180,6 +186,8 @@ class ServeSupervisor extends EventEmitter { } 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-resolved`. this.#reinitQueued = true; return; } @@ -207,10 +215,15 @@ class ServeSupervisor extends EventEmitter { if (err?.stack) { log.verbose(err.stack); } + // A failed resolve never emits `ui5.project-resolved`, 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 buildServeApp, + // where the resolve already repainted the slot. + process.emit("ui5.project-resolve-failed"); return; } if (this.#destroyed) { - // Destroyed while building — discard the new stack instead of adopting it. + // Destroyed while building: discard the new stack instead of adopting it. await newStack.buildServer.destroy(); return; } @@ -219,8 +232,8 @@ class ServeSupervisor extends EventEmitter { 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; the old watcher then keeps driving re-inits. + // 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 { @@ -242,8 +255,8 @@ class ServeSupervisor extends EventEmitter { /** * 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. + * 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<void>} Resolves once teardown completes @@ -251,7 +264,7 @@ class ServeSupervisor extends EventEmitter { async destroy(callback) { this.#destroyed = true; // 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 belt-and-braces. + // The reinitialize() #destroyed guard already no-ops such an event; this is defensive. const definitionWatcher = this.#definitionWatcher; this.#definitionWatcher = null; this.#liveReloadHandle?.close(); diff --git a/packages/server/test/lib/server/ServeSupervisor.js b/packages/server/test/lib/server/ServeSupervisor.js index 2f148c942df..91da9bfe8a8 100644 --- a/packages/server/test/lib/server/ServeSupervisor.js +++ b/packages/server/test/lib/server/ServeSupervisor.js @@ -250,6 +250,49 @@ test("overlapping reinitialize() calls collapse into one trailing pass", async ( t.is(buildCalls, 3, "the trailing pass runs exactly once, sequentially"); }); +test("a queued reinitialize() does not re-resolve early; the trailing pass owns the only extra resolve", + async (t) => { + const stack1 = createStack(); + // Hold the first re-init inside buildServeApp to open an overlap window, mirroring a slow + // framework build on a large project. + const firstBuildGate = Promise.withResolvers(); + let buildCalls = 0; + const {mocks} = createMocks({ + buildServeAppImpl: async () => { + buildCalls++; + if (buildCalls === 1) { + return stack1; // initial build + } + if (buildCalls === 2) { + await firstBuildGate.promise; // first re-init: park inside the build + } + return createStack(); + } + }); + const graphFactory = sinon.stub().resolves({}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + t.is(graphFactory.callCount, 0, "no resolve on the initial build"); + + const p1 = supervisor.reinitialize(); // starts #swap(): resolves, then parks in buildServeApp + // Let the first swap reach its (blocked) build so #reinitInProgress is set. + await new Promise((resolve) => setImmediate(resolve)); + t.is(graphFactory.callCount, 1, "first swap resolved the graph"); + + // A second definitionChanged lands while the first swap's build is still in flight. The + // queued branch only sets the trailing-pass flag — it must NOT resolve the graph early. + const p2 = supervisor.reinitialize(); + await p2; + t.is(graphFactory.callCount, 1, "the queued re-init does not re-resolve while the swap builds"); + + // Release the first build; the trailing pass then re-resolves once for the swap it performs. + firstBuildGate.resolve(); + await p1; + t.is(graphFactory.callCount, 2, "only the trailing pass adds a resolve"); + t.is(buildCalls, 3, "one initial build + first swap + trailing-pass swap"); + }); + test("destroy() closes live-reload, the socket, and the BuildServer; reinitialize() is then a no-op", async (t) => { const stack = createStack(); const graphFactory = sinon.stub().resolves({}); @@ -343,6 +386,46 @@ test("a definitionChanged event triggers reinitialize()", async (t) => { t.true(app2.calledOnceWithExactly("req", "res"), "definition change swapped in the new app"); }); +test.serial("a definitionChanging event signals ui5.project-resolving (version-slot reset)", async (t) => { + const stack1 = createStack(); + const graphFactory = sinon.stub().resolves({}); + const {mocks, definitionWatchers} = createMocks({stacks: [stack1]}); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + + const emit = sinon.spy(process, "emit"); + // The watcher's leading-edge event: a re-resolve is coming, blank the version slot. + definitionWatchers[0].emit("definitionChanging", {eventType: "update", filePath: "/app/ui5.yaml"}); + + const resolving = emit.getCalls().find((c) => c.args[0] === "ui5.project-resolving"); + t.truthy(resolving, "ui5.project-resolving was emitted to reset the version slot"); +}); + +test.serial("a failed swap releases the version placeholder via ui5.project-resolve-failed", async (t) => { + const stack1 = createStack(); + let calls = 0; + const graphFactory = sinon.stub().resolves({}); + const {mocks} = createMocks({ + buildServeAppImpl: async () => { + calls++; + if (calls === 1) { + return stack1; // initial build + } + throw new Error("invalid ui5.yaml"); // the re-init build fails + } + }); + const {default: ServeSupervisor} = await importSupervisor(mocks); + + const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + + const emit = sinon.spy(process, "emit"); + await supervisor.reinitialize(); + + const released = emit.getCalls().find((c) => c.args[0] === "ui5.project-resolve-failed"); + t.truthy(released, "a failed swap emits ui5.project-resolve-failed so the placeholder does not wedge"); +}); + test("watcher is re-targeted to the new graph after a swap (old destroyed, new created)", async (t) => { const stack1 = createStack(); const stack2 = createStack(); From 1b1c5f532e28a920a90dac57a674a31b181363a2 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Mon, 20 Jul 2026 19:43:18 +0200 Subject: [PATCH 15/31] refactor(project): Hold the deferred restart against a mid-burst reader request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-abort/transient restart window (ABORTED_BUILD_RESTART_SETTLE_MS) exists to collapse a change burst delivered as multiple @parcel/watcher batches (a `git checkout`, a save-all) into one rebuild against the settled tree. All three timing paths share one timer (#processBuildRequestsTimeout), and #triggerRequestQueue unconditionally clears and re-arms it, so whoever calls last wins. While a restart is deferred, a browser's live-reload keeps requesting resources. Each request for a non-fresh project routes through BUILD_REQUEST_DEBOUNCE_MS. That re-arm supersedes the 550 ms restart window: if no further source change lands within the debounce (a gap between watcher batches), the timer fires and a build starts into the still-arriving burst, only to be aborted by the next batch. In the openui5 testsuite switching between `master` and `rel-1.120`, a single checkout produced seven builds, six of them aborted, instead of one. While #pendingDeferredRestart is set, #enqueueBuild now leaves the armed timer untouched: the project is still queued on its status and resolves when the deferred rebuild — which processes the whole pending set — runs. Leaving the timer alone (rather than re-arming it at the settle delay) also avoids the opposite failure, where a steady stream of live-reload requests would reset the window and defer the rebuild indefinitely. The window is reset only by further source changes, as before. A reader request still supersedes the shorter first-build window. --- packages/project/lib/build/BuildServer.js | 31 +++++--- .../project/test/lib/build/BuildServer.js | 77 ++++++++++++++++++- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 67ad1e55407..3f4a7398667 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -654,14 +654,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 * <code>BUILD_REQUEST_DEBOUNCE_MS</code> - even when the project is already queued. A reader - * request thereby supersedes a deferred settle window (the post-abort/transient restart at - * <code>ABORTED_BUILD_RESTART_SETTLE_MS</code>, or the first-build window at - * <code>FIRST_BUILD_SETTLE_MS</code>): 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 (<code>FIRST_BUILD_SETTLE_MS</code>): + * 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 (<code>#pendingDeferredRestart</code>). That window exists to collapse a burst delivered + * as multiple watcher batches (a <code>git checkout</code>, 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 +678,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) { diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index 84f9ef69b7d..6613420ddb8 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; From 55c725c2196abe307fe1894dfa353956e156cd90 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Tue, 21 Jul 2026 13:53:33 +0200 Subject: [PATCH 16/31] refactor(project): Extract the subscription-drain helper shared by both watchers WatchHandler.destroy() and DefinitionWatcher (both destroy() and the recovery teardown) each drained their @parcel/watcher subscriptions with the same Promise.allSettled(unsubscribe) + collect-rejections idiom. Lift it into drainSubscriptions() in helpers/watchSubscriptions.js. The helper returns the rejection reasons rather than shaping the error, so each caller keeps its own contract: destroy() aggregates the failures into an AggregateError it emits, while the DefinitionWatcher recovery path discards them (the handles are being renewed regardless). --- .../lib/build/helpers/DefinitionWatcher.js | 8 +++-- .../project/lib/build/helpers/WatchHandler.js | 6 ++-- .../lib/build/helpers/watchSubscriptions.js | 16 +++++++++ .../lib/build/helpers/watchSubscriptions.js | 36 +++++++++++++++++++ 4 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 packages/project/lib/build/helpers/watchSubscriptions.js create mode 100644 packages/project/test/lib/build/helpers/watchSubscriptions.js diff --git a/packages/project/lib/build/helpers/DefinitionWatcher.js b/packages/project/lib/build/helpers/DefinitionWatcher.js index a1b70d29ef2..56205c92d36 100644 --- a/packages/project/lib/build/helpers/DefinitionWatcher.js +++ b/packages/project/lib/build/helpers/DefinitionWatcher.js @@ -2,6 +2,7 @@ import EventEmitter from "node:events"; import path from "node:path"; import parcelWatcher from "@parcel/watcher"; import {getLogger} from "@ui5/logger"; +import {drainSubscriptions} from "./watchSubscriptions.js"; const log = getLogger("build:helpers:DefinitionWatcher"); // Default filename of the workspace configuration, resolved against cwd. @@ -198,9 +199,11 @@ class DefinitionWatcher extends EventEmitter { 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 Promise.allSettled(subscriptions.map((s) => s.unsubscribe())); + await drainSubscriptions(subscriptions); if (this.#destroyed) { return; } @@ -231,8 +234,7 @@ class DefinitionWatcher extends EventEmitter { // failure cannot leave stale handles behind to be unsubscribed twice. const subscriptions = this.#subscriptions; this.#subscriptions = []; - 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 definition watchers"); this.emit("error", err); diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 8de92fc2b98..21d27227561 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -2,6 +2,7 @@ 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 @@ -77,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); 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 <code>[]</code> 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 + * <code>unsubscribe()</code> + * @returns {Promise<Error[]>} The reasons of any rejected <code>unsubscribe()</code> 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/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([]), []); +}); From 9c06fc75231c81d2af13bdfd1935bb7285136f21 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Tue, 21 Jul 2026 14:01:36 +0200 Subject: [PATCH 17/31] refactor(project): Single-source the watcher burst-settle window SOURCES_CHANGED_SETTLE_MS, ABORTED_BUILD_RESTART_SETTLE_MS (BuildServer) and DEFINITION_CHANGED_SETTLE_MS (DefinitionWatcher) all held 550 ms for the same reason: a burst-coalescing settle window must sit above @parcel/watcher's MAX_WAIT_TIME (500 ms) so each batch resets the window rather than terminating it. The value and the paragraph explaining it were duplicated three times, and all three necessarily move together if the watcher's cap changes. Define WATCHER_BURST_SETTLE_MS once in helpers/watchSettle.js, carrying the @parcel/watcher-coalescing rationale. The three call sites alias it and keep only a one-line note on what each window coalesces (leading+trailing live-reload emit, post-abort rebuild, trailing-only re-init). The BuildServer aliases preserve the existing names, so the __internals__ test contract is unchanged. --- packages/project/lib/build/BuildServer.js | 24 +++++++------------ .../lib/build/helpers/DefinitionWatcher.js | 15 ++++++------ .../project/lib/build/helpers/watchSettle.js | 15 ++++++++++++ 3 files changed, 32 insertions(+), 22 deletions(-) create mode 100644 packages/project/lib/build/helpers/watchSettle.js diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 3f4a7398667..5022a401a21 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -3,6 +3,7 @@ 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 {getLogger} from "@ui5/logger"; import ServeLogger from "@ui5/logger/internal/loggers/Serve"; const log = getLogger("build:BuildServer"); @@ -13,14 +14,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,13 +36,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; +// 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; // 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 diff --git a/packages/project/lib/build/helpers/DefinitionWatcher.js b/packages/project/lib/build/helpers/DefinitionWatcher.js index 56205c92d36..eb1a1d06479 100644 --- a/packages/project/lib/build/helpers/DefinitionWatcher.js +++ b/packages/project/lib/build/helpers/DefinitionWatcher.js @@ -3,6 +3,7 @@ import path from "node:path"; import parcelWatcher from "@parcel/watcher"; import {getLogger} from "@ui5/logger"; import {drainSubscriptions} from "./watchSubscriptions.js"; +import {WATCHER_BURST_SETTLE_MS} from "./watchSettle.js"; const log = getLogger("build:helpers:DefinitionWatcher"); // Default filename of the workspace configuration, resolved against cwd. @@ -10,13 +11,13 @@ 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; @parcel/watcher delivers that as batches up to its MAX_WAIT_TIME (500 ms) apart. -// A trailing timer, reset on each further event, collapses the burst into a single 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. Mirrors BuildServer's SOURCES_CHANGED_SETTLE_MS -// and must stay above the 500 ms cap so each batch resets the window rather than terminating it. -const DEFINITION_CHANGED_SETTLE_MS = 550; +// A `git checkout` or branch switch writes ui5.yaml + package.json + sources within one operation; +// a trailing timer, reset on each further event, collapses that burst into a single emit. 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). +const DEFINITION_CHANGED_SETTLE_MS = WATCHER_BURST_SETTLE_MS; // Loop protection for watcher recovery, so a persistently failing watcher does not cycle // error → recover → error forever. More than WATCHER_RECOVERY_MAX_ATTEMPTS recoveries within diff --git a/packages/project/lib/build/helpers/watchSettle.js b/packages/project/lib/build/helpers/watchSettle.js new file mode 100644 index 00000000000..2db60614b9d --- /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. + * + * @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 <em>above</em> 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; From dd8809e10129b7278e8c1fa7fbf17bca9755da9e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Tue, 21 Jul 2026 14:15:16 +0200 Subject: [PATCH 18/31] refactor(project): Share the watcher recovery loop-protection budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildServer's source-watcher recovery and DefinitionWatcher each kept their own sliding-window loop protection: a `#…RecoveryTimestamps` array, the prune-to- window + `>= MAX` check, and a private copy of WATCHER_RECOVERY_MAX_ATTEMPTS / WATCHER_RECOVERY_WINDOW_MS. The bookkeeping and the two constants were identical. Lift the window accounting into RecoveryBudget (helpers/RecoveryBudget.js): withinBudget() prunes and reports whether another attempt fits, recordRecovery() marks a completed one. Each site holds its own budget instance, so a fault in one watcher does not consume the other's allowance. The divergent parts stay with each owner: the re-entrancy guard (in BuildServer `#recoveringWatcher` also gates the request queue, so it is not recovery-local), the recovery body (BuildServer quiesces the build and forces a full re-scan; DefinitionWatcher re-subscribes its directory set), and the escalation on exhaustion (a state transition to ERROR vs. a terminal "error" emit). --- packages/project/lib/build/BuildServer.js | 21 ++----- .../lib/build/helpers/DefinitionWatcher.js | 17 ++---- .../lib/build/helpers/RecoveryBudget.js | 52 +++++++++++++++++ .../test/lib/build/helpers/RecoveryBudget.js | 56 +++++++++++++++++++ 4 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 packages/project/lib/build/helpers/RecoveryBudget.js create mode 100644 packages/project/test/lib/build/helpers/RecoveryBudget.js diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 5022a401a21..35f85bced53 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -4,6 +4,7 @@ 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"); @@ -43,13 +44,6 @@ const FIRST_BUILD_SETTLE_MS = 100; // restart, not to serving a request. const ABORTED_BUILD_RESTART_SETTLE_MS = WATCHER_BURST_SETTLE_MS; -// 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; - // 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 // separately via #emitStale. A server can be IDLE ("ready") while some lazily-built projects @@ -135,10 +129,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 DefinitionWatcher's). #recoveringWatcher = false; - #watcherRecoveryTimestamps = []; + #watcherRecoveryBudget = new RecoveryBudget(); /** * Creates a new BuildServer instance @@ -290,10 +284,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.`); @@ -342,7 +333,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: diff --git a/packages/project/lib/build/helpers/DefinitionWatcher.js b/packages/project/lib/build/helpers/DefinitionWatcher.js index eb1a1d06479..bd7c6982d33 100644 --- a/packages/project/lib/build/helpers/DefinitionWatcher.js +++ b/packages/project/lib/build/helpers/DefinitionWatcher.js @@ -4,6 +4,7 @@ import parcelWatcher from "@parcel/watcher"; import {getLogger} from "@ui5/logger"; import {drainSubscriptions} from "./watchSubscriptions.js"; import {WATCHER_BURST_SETTLE_MS} from "./watchSettle.js"; +import RecoveryBudget, {WATCHER_RECOVERY_MAX_ATTEMPTS, WATCHER_RECOVERY_WINDOW_MS} from "./RecoveryBudget.js"; const log = getLogger("build:helpers:DefinitionWatcher"); // Default filename of the workspace configuration, resolved against cwd. @@ -19,13 +20,6 @@ const WORKSPACE_CONFIG_DEFAULT = "ui5-workspace.yaml"; // constant). const DEFINITION_CHANGED_SETTLE_MS = WATCHER_BURST_SETTLE_MS; -// Loop protection for watcher recovery, so a persistently failing watcher does not cycle -// error → recover → error forever. More than WATCHER_RECOVERY_MAX_ATTEMPTS recoveries within -// WATCHER_RECOVERY_WINDOW_MS is treated as unrecoverable and emits a terminal "error". This -// budget is the DefinitionWatcher's own, independent of the source WatchHandler's in BuildServer. -const WATCHER_RECOVERY_MAX_ATTEMPTS = 5; -const WATCHER_RECOVERY_WINDOW_MS = 60000; - /** * 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 @@ -56,7 +50,7 @@ class DefinitionWatcher extends EventEmitter { #lastEvent = null; #recovering = false; - #recoveryTimestamps = []; + #recoveryBudget = new RecoveryBudget(); #destroyed = false; /** @@ -186,10 +180,7 @@ class DefinitionWatcher extends EventEmitter { log.verbose(err.stack); } - const now = Date.now(); - this.#recoveryTimestamps = this.#recoveryTimestamps - .filter((ts) => now - ts < WATCHER_RECOVERY_WINDOW_MS); - if (this.#recoveryTimestamps.length >= WATCHER_RECOVERY_MAX_ATTEMPTS) { + 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.`); @@ -209,7 +200,7 @@ class DefinitionWatcher extends EventEmitter { return; } await this.#subscribeAll(); - this.#recoveryTimestamps.push(Date.now()); + this.#recoveryBudget.recordRecovery(); log.info(`Definition watcher recovered.`); } catch (recoveryErr) { log.error(`Definition watcher recovery failed: ${recoveryErr?.message ?? recoveryErr}`); diff --git a/packages/project/lib/build/helpers/RecoveryBudget.js b/packages/project/lib/build/helpers/RecoveryBudget.js new file mode 100644 index 00000000000..12a0fdbaba1 --- /dev/null +++ b/packages/project/lib/build/helpers/RecoveryBudget.js @@ -0,0 +1,52 @@ +// 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 <code>maxAttempts</code> within a + * trailing <code>windowMs</code>. Counts <em>completed</em> recoveries: check {@link withinBudget} + * before attempting one and call {@link recordRecovery} once it succeeds. + * + * Owned per watcher (the source {@link WatchHandler}'s recovery in BuildServer and the + * {@link DefinitionWatcher} 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} <code>true</code> if fewer than <code>maxAttempts</code> 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/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(); +}); From a94151202266433bb7b88746214d5e9330b72060 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Fri, 24 Jul 2026 16:05:52 +0200 Subject: [PATCH 19/31] docs(project): Update incremental build skill --- .../skills/incremental-build/architecture.md | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/.claude/skills/incremental-build/architecture.md b/.claude/skills/incremental-build/architecture.md index fc9d686b027..19a04781d43 100644 --- a/.claude/skills/incremental-build/architecture.md +++ b/.claude/skills/incremental-build/architecture.md @@ -38,7 +38,11 @@ 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 `DefinitionWatcher` then re-inits over the new graph | +| `DefinitionWatcher` | `lib/build/helpers/DefinitionWatcher.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. Owned by `@ui5/server`'s `ServeSupervisor`, not the BuildServer; exported via the `@ui5/project/build/helpers/DefinitionWatcher` subpath | +| `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 +99,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 DefinitionWatcher'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 +194,31 @@ 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** (`DefinitionWatcher`, owned by `@ui5/server`'s `ServeSupervisor`): 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`. + +### DefinitionWatcher + +`DefinitionWatcher 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. +- **Include-based model**: subscribes to each distinct directory (deduplicated across projects); the callback drops every event whose resolved path is not in `#watchedFiles`. The `node_modules`/`.git` ignore globs only reduce OS-level watch load; correctness comes from the include set. +- **Events**: + - `definitionChanging` on the leading edge (first watched event), used to placeholder the project version during re-resolution. + - `definitionChanged` on the trailing edge after `DEFINITION_CHANGED_SETTLE_MS` (= `WATCHER_BURST_SETTLE_MS`), 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 `ServeSupervisor` for the re-init/swap wiring. + ## Caching Architecture ### Cache Layers @@ -452,7 +485,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) From 2a1a945d859489b3e42f89d629333ef129e69f40 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Fri, 24 Jul 2026 17:24:49 +0200 Subject: [PATCH 20/31] refactor(server): Destroy the BuildServer when middleware assembly fails buildServeRouter() starts the BuildServer via graph.serve() before assembling the middleware. If MiddlewareManager.applyMiddleware() throws afterwards, the rejection propagated without a buildServer or close() handle, leaving the source watcher and build-cache handle alive. Wrap the middleware assembly in try/catch, destroy the BuildServer, then rethrow the original error. Covers both the serveMiddleware() embedding API and the CLI-owned buildServeApp() path, since both route through buildServeRouter(). --- packages/server/lib/serveApp.js | 84 +++++++++++---------- packages/server/test/lib/server/serveApp.js | 68 +++++++++++++++++ 2 files changed, 114 insertions(+), 38 deletions(-) create mode 100644 packages/server/test/lib/server/serveApp.js diff --git a/packages/server/lib/serveApp.js b/packages/server/lib/serveApp.js index a82ab81af5a..267de0cd584 100644 --- a/packages/server/lib/serveApp.js +++ b/packages/server/lib/serveApp.js @@ -74,48 +74,56 @@ export async function buildServeRouter(graph, config, error) { ui5DataDir, }); - const resources = { - rootProject: buildServer.getRootReader(), - dependencies: buildServer.getDependenciesReader(), - all: buildServer.getReader(), - }; + // graph.serve() above already started the BuildServer, which holds a source watcher and a + // build-cache handle. If middleware assembly below throws, the caller gets neither the + // buildServer nor a close() handle, so destroy it here before rethrowing. + try { + const resources = { + rootProject: buildServer.getRootReader(), + dependencies: buildServer.getDependenciesReader(), + all: buildServer.getReader(), + }; - buildServer.on("error", (err) => { - if (typeof error === "function") { - error(err); - return; - } - log.error(`BuildServer error: ${err?.message ?? err}`); - if (err?.stack) { - log.verbose(err.stack); - } - }); + buildServer.on("error", (err) => { + if (typeof error === "function") { + error(err); + return; + } + log.error(`BuildServer error: ${err?.message ?? err}`); + if (err?.stack) { + log.verbose(err.stack); + } + }); - const liveReloadOptions = { - active: liveReload, - token: webSocketToken - }; + const liveReloadOptions = { + active: liveReload, + token: webSocketToken + }; - const middlewareManager = new MiddlewareManager({ - graph, - rootProject, - sources, - resources, - options: { - sendSAPTargetCSP, - serveCSPReports, - simpleIndex, - liveReload: liveReloadOptions, - // Consulted by the serveBuildError gate to divert HTML navigations while the - // build server is globally in ERROR. - getServeError: () => buildServer.getServeError() - } - }); + const middlewareManager = new MiddlewareManager({ + graph, + rootProject, + sources, + resources, + options: { + sendSAPTargetCSP, + serveCSPReports, + simpleIndex, + liveReload: liveReloadOptions, + // Consulted by the serveBuildError gate to divert HTML navigations while the + // build server is globally in ERROR. + getServeError: () => buildServer.getServeError() + } + }); - // eslint-disable-next-line new-cap -- express.Router is a factory, not a constructor - const router = express.Router(); - await middlewareManager.applyMiddleware(router); - return {router, buildServer, liveReloadOptions}; + // eslint-disable-next-line new-cap -- express.Router is a factory, not a constructor + const router = express.Router(); + await middlewareManager.applyMiddleware(router); + return {router, buildServer, liveReloadOptions}; + } catch (err) { + await buildServer.destroy(); + throw err; + } } /** diff --git a/packages/server/test/lib/server/serveApp.js b/packages/server/test/lib/server/serveApp.js new file mode 100644 index 00000000000..7608821793f --- /dev/null +++ b/packages/server/test/lib/server/serveApp.js @@ -0,0 +1,68 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; + +// Unit: the shared router core owns the BuildServer once graph.serve() has started it. A failure +// while assembling the middleware must destroy that BuildServer before rethrowing, since the +// caller never receives a buildServer or close() handle to release its watcher and cache. + +function createGraph(buildServer) { + const rootProject = { + getName: () => "root.project", + getSourceReader: () => ({}), + }; + return { + getRoot: () => rootProject, + getProject: () => undefined, + traverseBreadthFirst: async () => {}, + serve: sinon.stub().resolves(buildServer), + }; +} + +function createBuildServer() { + return { + getRootReader: () => ({}), + getDependenciesReader: () => ({}), + getReader: () => ({}), + getServeError: () => undefined, + on: sinon.stub(), + destroy: sinon.stub().resolves(), + }; +} + +async function importBuildServeRouter(applyMiddleware) { + return esmock("../../../lib/serveApp.js", { + "../../../lib/middleware/MiddlewareManager.js": class MiddlewareManager { + applyMiddleware(...args) { + return applyMiddleware(...args); + } + }, + }); +} + +test("buildServeRouter() destroys the BuildServer when middleware assembly fails", async (t) => { + const buildServer = createBuildServer(); + const graph = createGraph(buildServer); + const assemblyError = new Error("applyMiddleware failed"); + const applyMiddleware = sinon.stub().rejects(assemblyError); + + const {buildServeRouter} = await importBuildServeRouter(applyMiddleware); + + const err = await t.throwsAsync(buildServeRouter(graph, {})); + t.is(err, assemblyError, "the original assembly error is rethrown"); + t.true(buildServer.destroy.calledOnce, + "the BuildServer is destroyed so its watcher and cache handle are released"); +}); + +test("buildServeRouter() returns the router and BuildServer on success", async (t) => { + const buildServer = createBuildServer(); + const graph = createGraph(buildServer); + const applyMiddleware = sinon.stub().resolves(); + + const {buildServeRouter} = await importBuildServeRouter(applyMiddleware); + + const result = await buildServeRouter(graph, {}); + t.is(result.buildServer, buildServer, "the BuildServer is returned to the caller"); + t.is(typeof result.router, "function", "an express router is returned"); + t.true(buildServer.destroy.notCalled, "the BuildServer is not destroyed on success"); +}); From b45475effd54dcf1c14f968dfa7e55ac9d43bd89 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Fri, 24 Jul 2026 17:24:54 +0200 Subject: [PATCH 21/31] refactor(server): Document the serveMiddleware embedding contract serveMiddleware() is the public embedding API: it hides the reader/resource/ middleware setup and returns a mountable middleware plus close(). Add an @example to its JSDoc showing the full lifecycle (import, app.use(middleware), close() on teardown) so third-party consumers see the supported contract in the generated API reference, without importing @ui5/server/internal/MiddlewareManager or recreating the old workaround. --- packages/server/lib/serveMiddleware.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/server/lib/serveMiddleware.js b/packages/server/lib/serveMiddleware.js index 6b70ef39641..5d123030cef 100644 --- a/packages/server/lib/serveMiddleware.js +++ b/packages/server/lib/serveMiddleware.js @@ -17,6 +17,19 @@ import {buildServeRouter} from "./serveApp.js"; * Note: A project graph can be served only once. Do not call both <code>serveMiddleware</code> * and {@link module:@ui5/server.serve} for the same graph. * + * @example + * import express from "express"; + * import {serveMiddleware} from "@ui5/server"; + * + * const app = express(); + * const {middleware, close} = await serveMiddleware(graph); + * app.use(middleware); + * const listener = app.listen(8080); + * + * // On teardown, release the BuildServer's watcher and build-cache handle. + * listener.close(); + * await close(); + * * @public * @alias module:@ui5/server.serveMiddleware * @param {@ui5/project/graph/ProjectGraph} graph Project graph From acaf426db310d0fd5ec188c0ca2d1d85bcb5c145 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Fri, 24 Jul 2026 17:44:04 +0200 Subject: [PATCH 22/31] refactor(server): Group the serving internals under a serve/ namespace The serve-prefixed module names carried a manual namespace inside a package already called @ui5/server, and the prefix read as a verb while the modules are factories and helpers. Move the internals into a serve/ directory so the prefix is gone and the names describe their contents, and reserve the top level for the two public entries (server.js, serveMiddleware.js): serveHttp.js -> serve/httpListener.js serveApp.js -> serve/stack.js ServeSupervisor.js -> serve/Supervisor.js Drop the now-redundant Serve from the moved symbols, which the namespace already scopes: the stack exports buildServeRouter -> buildRouter and buildServeApp -> buildApp, and the class ServeSupervisor -> Supervisor (logger tag server:Supervisor). No public API change; the package.json exports and the serve()/serveMiddleware() surface are untouched. --- .../Supervisor.js} | 24 ++-- .../{serveHttp.js => serve/httpListener.js} | 4 +- .../lib/{serveApp.js => serve/stack.js} | 20 +-- packages/server/lib/serveMiddleware.js | 4 +- packages/server/lib/server.js | 4 +- .../server/test/lib/server/reinitialize.js | 2 +- .../Supervisor.js} | 120 +++++++++--------- .../server/{serveApp.js => serve/stack.js} | 18 +-- .../server/test/lib/server/serveMiddleware.js | 28 ++-- packages/server/test/lib/server/server.js | 46 +++---- 10 files changed, 135 insertions(+), 135 deletions(-) rename packages/server/lib/{ServeSupervisor.js => serve/Supervisor.js} (94%) rename packages/server/lib/{serveHttp.js => serve/httpListener.js} (97%) rename packages/server/lib/{serveApp.js => serve/stack.js} (89%) rename packages/server/test/lib/server/{ServeSupervisor.js => serve/Supervisor.js} (78%) rename packages/server/test/lib/server/{serveApp.js => serve/stack.js} (73%) diff --git a/packages/server/lib/ServeSupervisor.js b/packages/server/lib/serve/Supervisor.js similarity index 94% rename from packages/server/lib/ServeSupervisor.js rename to packages/server/lib/serve/Supervisor.js index 580335cfa86..1dbeff14a00 100644 --- a/packages/server/lib/ServeSupervisor.js +++ b/packages/server/lib/serve/Supervisor.js @@ -3,11 +3,11 @@ import process from "node:process"; import {EventEmitter} from "node:events"; import {getLogger} from "@ui5/logger"; import DefinitionWatcher from "@ui5/project/build/helpers/DefinitionWatcher"; -import buildServeApp from "./serveApp.js"; -import attachLiveReloadServer from "./liveReload/server.js"; -import {listen, addSsl, announceListening} from "./serveHttp.js"; +import buildApp from "./stack.js"; +import attachLiveReloadServer from "../liveReload/server.js"; +import {listen, addSsl, announceListening} from "./httpListener.js"; -const log = getLogger("server:ServeSupervisor"); +const log = getLogger("server:Supervisor"); /** * Owns the stable HTTP front door for a served project and re-creates the serving stack @@ -23,7 +23,7 @@ const log = getLogger("server:ServeSupervisor"); * * @private */ -class ServeSupervisor extends EventEmitter { +class Supervisor extends EventEmitter { #config; #graphFactory; #error; @@ -66,11 +66,11 @@ class ServeSupervisor extends EventEmitter { * @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 ServeSupervisor#reinitialize} to do anything - * @returns {Promise<ServeSupervisor>} The listening supervisor + * required for {@link Supervisor#reinitialize} to do anything + * @returns {Promise<Supervisor>} The listening supervisor */ static async create(graph, config, error, {graphFactory} = {}) { - const supervisor = new ServeSupervisor(config, error, {graphFactory}); + const supervisor = new Supervisor(config, error, {graphFactory}); await supervisor.#init(graph); return supervisor; } @@ -91,7 +91,7 @@ class ServeSupervisor extends EventEmitter { } // Build the initial stack before binding so a construction failure surfaces to the caller. - this.#stack = await buildServeApp(graph, this.#config, this.#error); + this.#stack = await buildApp(graph, this.#config, this.#error); // Stable request handler. Reads #stack.app on every request so a swap retargets // transparently without touching the bound socket. @@ -208,7 +208,7 @@ class ServeSupervisor extends EventEmitter { let newGraph; try { newGraph = await this.#graphFactory(); - newStack = await buildServeApp(newGraph, this.#config, this.#error); + newStack = await buildApp(newGraph, this.#config, this.#error); } catch (err) { // Keep the last-good stack serving. A subsequent valid edit will swap cleanly. log.error(`Failed to re-initialize server: ${err?.message ?? err}`); @@ -217,7 +217,7 @@ class ServeSupervisor extends EventEmitter { } // A failed resolve never emits `ui5.project-resolved`, 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 buildServeApp, + // the old (still-serving) stack resolved. Harmless if the failure was in buildApp, // where the resolve already repainted the slot. process.emit("ui5.project-resolve-failed"); return; @@ -283,4 +283,4 @@ class ServeSupervisor extends EventEmitter { } } -export default ServeSupervisor; +export default Supervisor; diff --git a/packages/server/lib/serveHttp.js b/packages/server/lib/serve/httpListener.js similarity index 97% rename from packages/server/lib/serveHttp.js rename to packages/server/lib/serve/httpListener.js index 6cb6262d959..03b264086b6 100644 --- a/packages/server/lib/serveHttp.js +++ b/packages/server/lib/serve/httpListener.js @@ -3,11 +3,11 @@ import portscanner from "portscanner"; /** * HTTP-listener helpers shared between the single-shot {@link module:@ui5/server.serve} - * wrapper and the {@link ServeSupervisor}, which binds the port once and swaps the + * wrapper and the {@link Supervisor}, which binds the port once and swaps the * request handler behind it. * * @private - * @module @ui5/server/serveHttp + * @module @ui5/server/serve/httpListener */ /** diff --git a/packages/server/lib/serveApp.js b/packages/server/lib/serve/stack.js similarity index 89% rename from packages/server/lib/serveApp.js rename to packages/server/lib/serve/stack.js index 267de0cd584..53b71305739 100644 --- a/packages/server/lib/serveApp.js +++ b/packages/server/lib/serve/stack.js @@ -1,6 +1,6 @@ import express from "express"; -import MiddlewareManager from "./middleware/MiddlewareManager.js"; -import createErrorHandler from "./middleware/errorHandler.js"; +import MiddlewareManager from "../middleware/MiddlewareManager.js"; +import createErrorHandler from "../middleware/errorHandler.js"; import {createReaderCollection} from "@ui5/fs/resourceFactory"; import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; import {getLogger} from "@ui5/logger"; @@ -11,7 +11,7 @@ const log = getLogger("server"); /** * Builds the middleware-bearing router and its BuildServer for a project graph, without * binding to a port and without a terminal error handler. This is the router-agnostic core - * shared between the {@link module:@ui5/server.serve} wrapper (via {@link buildServeApp}) and + * shared between the {@link module:@ui5/server.serve} wrapper (via {@link buildApp}) and * the {@link module:@ui5/server.serveMiddleware} embedding API: everything needed to answer * requests is mounted onto an {@link https://expressjs.com/en/4x/api.html#router Express Router}, * which both an Express application and a Connect app accept via <code>use()</code>. @@ -27,7 +27,7 @@ const log = getLogger("server"); * of request handling * @returns {Promise<object>} Resolves with <code>{router, buildServer, liveReloadOptions}</code> */ -export async function buildServeRouter(graph, config, error) { +export async function buildRouter(graph, config, error) { const { sendSAPTargetCSP = false, simpleIndex = false, liveReload = false, serveCSPReports = false, cache = Cache.Default, ui5DataDir, includedTasks, excludedTasks, webSocketToken = null, @@ -128,22 +128,22 @@ export async function buildServeRouter(graph, config, error) { /** * Builds the Express application and its BuildServer for a project graph, without binding - * to a port. Wraps {@link buildServeRouter} with an {@link express} application and the + * to a port. Wraps {@link buildRouter} with an {@link express} application and the * terminal error handler, so the {@link module:@ui5/server.serve} wrapper and the - * {@link ServeSupervisor} can own the listener and re-create the app behind a stable HTTP + * {@link Supervisor} can own the listener and re-create the app behind a stable HTTP * server on a graph swap. The error handler and the live-reload WebSocket server are * CLI-owned concerns and stay out of the router core used by the embedding API. * * @private - * @module @ui5/server/serveApp + * @module @ui5/server/serve/stack * @param {@ui5/project/graph/ProjectGraph} graph Project graph - * @param {object} config Server configuration; see {@link buildServeRouter} + * @param {object} config Server configuration; see {@link buildRouter} * @param {Function} [error] Error callback invoked when the BuildServer emits an error outside * of request handling * @returns {Promise<object>} Resolves with <code>{app, buildServer, liveReloadOptions}</code> */ -export default async function buildServeApp(graph, config, error) { - const {router, buildServer, liveReloadOptions} = await buildServeRouter(graph, config, error); +export default async function buildApp(graph, config, error) { + const {router, buildServer, liveReloadOptions} = await buildRouter(graph, config, error); const app = express(); app.use(router); diff --git a/packages/server/lib/serveMiddleware.js b/packages/server/lib/serveMiddleware.js index 5d123030cef..b40e17a7761 100644 --- a/packages/server/lib/serveMiddleware.js +++ b/packages/server/lib/serveMiddleware.js @@ -1,5 +1,5 @@ import Cache from "@ui5/project/build/cache/Cache"; -import {buildServeRouter} from "./serveApp.js"; +import {buildRouter} from "./serve/stack.js"; /** * Assembles the UI5 middleware for a project graph and returns it as a single connect/Express @@ -70,7 +70,7 @@ export default async function serveMiddleware(graph, { webSocketToken: null, }; - const {router, buildServer} = await buildServeRouter(graph, config, error); + const {router, buildServer} = await buildRouter(graph, config, error); let destroyed = false; return { diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 55a5a356128..50165834345 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -1,6 +1,6 @@ import {getRandomValues} from "node:crypto"; import {getLogger} from "@ui5/logger"; -import ServeSupervisor from "./ServeSupervisor.js"; +import Supervisor from "./serve/Supervisor.js"; const log = getLogger("server"); /** @@ -94,7 +94,7 @@ export async function serve(graph, { let supervisor; try { - supervisor = await ServeSupervisor.create(graph, config, error, {graphFactory}); + supervisor = await Supervisor.create(graph, config, error, {graphFactory}); } catch (err) { log.verbose(`Failed to start server: ${err?.message ?? err}`); throw err; diff --git a/packages/server/test/lib/server/reinitialize.js b/packages/server/test/lib/server/reinitialize.js index d1ad65fa578..7fbbe48fe75 100644 --- a/packages/server/test/lib/server/reinitialize.js +++ b/packages/server/test/lib/server/reinitialize.js @@ -6,7 +6,7 @@ import {serve} from "../../../lib/server.js"; import {graphFromPackageDependencies} from "@ui5/project/graph"; import {isolatedUi5DataDir} from "../../utils/buildCacheIsolation.js"; -// Integration coverage for the ServeSupervisor swap against a real graph + BuildServer: the port +// Integration coverage for the Supervisor swap against a real graph + BuildServer: the port // stays bound, the same socket keeps serving, and a re-init reuses the (config-keyed, refcounted) // build cache rather than cold-rebuilding. Uses a fixed cwd so the graphFactory re-resolves the // same project. diff --git a/packages/server/test/lib/server/ServeSupervisor.js b/packages/server/test/lib/server/serve/Supervisor.js similarity index 78% rename from packages/server/test/lib/server/ServeSupervisor.js rename to packages/server/test/lib/server/serve/Supervisor.js index 91da9bfe8a8..35576816893 100644 --- a/packages/server/test/lib/server/ServeSupervisor.js +++ b/packages/server/test/lib/server/serve/Supervisor.js @@ -11,9 +11,9 @@ function createBuildServer() { return buildServer; } -// Builds a mock set for esmock. Each buildServeApp invocation returns the next queued stack, so a +// Builds a mock set for esmock. Each buildApp invocation returns the next queued stack, so a // test can hand out distinct {app, buildServer} pairs across the initial build and re-inits. -function createMocks({stacks, buildServeAppImpl, definitionWatcherCreate} = {}) { +function createMocks({stacks, buildAppImpl, definitionWatcherCreate} = {}) { const httpServer = new EventEmitter(); httpServer.close = sinon.stub().callsFake((cb) => cb && cb()); @@ -42,9 +42,9 @@ function createMocks({stacks, buildServeAppImpl, definitionWatcherCreate} = {}) }; const stackQueue = stacks ? [...stacks] : null; - const buildServeApp = sinon.stub().callsFake(async (graph, config, error) => { - if (buildServeAppImpl) { - return buildServeAppImpl(graph, config, error); + const buildApp = sinon.stub().callsFake(async (graph, config, error) => { + if (buildAppImpl) { + return buildAppImpl(graph, config, error); } return stackQueue.shift(); }); @@ -61,14 +61,14 @@ function createMocks({stacks, buildServeAppImpl, definitionWatcherCreate} = {}) const mocks = { "node:http": httpMock, "@ui5/project/build/helpers/DefinitionWatcher": {default: DefinitionWatcher}, - "../../../lib/serveApp.js": {default: buildServeApp}, - "../../../lib/serveHttp.js": {listen, addSsl, announceListening}, - "../../../lib/liveReload/server.js": {default: attachLiveReloadServer}, + "../../../../lib/serve/stack.js": {default: buildApp}, + "../../../../lib/serve/httpListener.js": {listen, addSsl, announceListening}, + "../../../../lib/liveReload/server.js": {default: attachLiveReloadServer}, }; return { mocks, httpServer, listen, addSsl, announceListening, - attachLiveReloadServer, liveReloadHandle, buildServeApp, createdHandlers, + attachLiveReloadServer, liveReloadHandle, buildApp, createdHandlers, DefinitionWatcher, definitionWatchers, }; } @@ -82,7 +82,7 @@ function createStack(app) { } async function importSupervisor(mocks) { - return esmock("../../../lib/ServeSupervisor.js", mocks); + return esmock("../../../../lib/serve/Supervisor.js", mocks); } const baseConfig = {port: 3000, liveReload: true, webSocketToken: "tok"}; @@ -93,13 +93,13 @@ test.afterEach.always(() => { test("create() builds the initial stack, binds once, attaches live-reload to a stable relay", async (t) => { const stack = createStack(); - const {mocks, listen, attachLiveReloadServer, buildServeApp} = createMocks({stacks: [stack]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {mocks, listen, attachLiveReloadServer, buildApp} = createMocks({stacks: [stack]}); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {}); t.is(supervisor.getPort(), 3000); - t.true(buildServeApp.calledOnce); + t.true(buildApp.calledOnce); t.true(listen.calledOnce, "port is bound exactly once"); // Live-reload is attached to the stable relay, not the BuildServer directly. t.true(attachLiveReloadServer.calledOnce); @@ -115,9 +115,9 @@ test("request trampoline retargets to the swapped app after reinitialize()", asy const stack2 = createStack(app2); const graphFactory = sinon.stub().resolves({}); const {mocks, listen, createdHandlers} = createMocks({stacks: [stack1, stack2]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); // The stable request handler passed to http.createServer. const trampoline = createdHandlers[0]; @@ -143,19 +143,19 @@ test("reinitialize() is build-new-then-swap: new stack is built before the old i return {}; }); const {mocks} = createMocks({ - buildServeAppImpl: async () => { - order.push("buildServeApp"); - return order.filter((s) => s === "buildServeApp").length === 1 ? stack1 : stack2; + buildAppImpl: async () => { + order.push("buildApp"); + return order.filter((s) => s === "buildApp").length === 1 ? stack1 : stack2; } }); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); order.length = 0; // drop the initial build await supervisor.reinitialize(); - t.deepEqual(order, ["graphFactory", "buildServeApp", "destroy-old"], + t.deepEqual(order, ["graphFactory", "buildApp", "destroy-old"], "new graph resolved and new app built before the old BuildServer is destroyed"); }); @@ -166,7 +166,7 @@ test("reinitialize() failure keeps the last-good stack serving", async (t) => { const buildError = new Error("invalid ui5.yaml"); const graphFactory = sinon.stub().resolves({}); const {mocks, attachLiveReloadServer, createdHandlers} = createMocks({ - buildServeAppImpl: async () => { + buildAppImpl: async () => { calls++; if (calls === 1) { return stack1; @@ -174,10 +174,10 @@ test("reinitialize() failure keeps the last-good stack serving", async (t) => { throw buildError; } }); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); const errorEvents = []; - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); supervisor.on("error", (err) => errorEvents.push(err)); await t.notThrowsAsync(supervisor.reinitialize(), "a broken definition does not reject"); @@ -197,9 +197,9 @@ test("live-reload subscription moves to the new BuildServer across a swap", asyn sinon.spy(stack2.buildServer, "on"); const graphFactory = sinon.stub().resolves({}); const {mocks, attachLiveReloadServer} = createMocks({stacks: [stack1, stack2]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); const relay = attachLiveReloadServer.firstCall.args[0].buildServer; await supervisor.reinitialize(); @@ -225,7 +225,7 @@ test("overlapping reinitialize() calls collapse into one trailing pass", async ( let buildCalls = 0; const graphFactory = sinon.stub().resolves({}); const {mocks} = createMocks({ - buildServeAppImpl: async () => { + buildAppImpl: async () => { buildCalls++; if (buildCalls === 1) { return stack1; // initial build @@ -237,9 +237,9 @@ test("overlapping reinitialize() calls collapse into one trailing pass", async ( return createStack(); } }); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); const p1 = supervisor.reinitialize(); const p2 = supervisor.reinitialize(); // collapses into a trailing pass while p1 is in flight @@ -253,12 +253,12 @@ test("overlapping reinitialize() calls collapse into one trailing pass", async ( test("a queued reinitialize() does not re-resolve early; the trailing pass owns the only extra resolve", async (t) => { const stack1 = createStack(); - // Hold the first re-init inside buildServeApp to open an overlap window, mirroring a slow + // Hold the first re-init inside buildApp to open an overlap window, mirroring a slow // framework build on a large project. const firstBuildGate = Promise.withResolvers(); let buildCalls = 0; const {mocks} = createMocks({ - buildServeAppImpl: async () => { + buildAppImpl: async () => { buildCalls++; if (buildCalls === 1) { return stack1; // initial build @@ -270,12 +270,12 @@ test("a queued reinitialize() does not re-resolve early; the trailing pass owns } }); const graphFactory = sinon.stub().resolves({}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); t.is(graphFactory.callCount, 0, "no resolve on the initial build"); - const p1 = supervisor.reinitialize(); // starts #swap(): resolves, then parks in buildServeApp + const p1 = supervisor.reinitialize(); // starts #swap(): resolves, then parks in buildApp // Let the first swap reach its (blocked) build so #reinitInProgress is set. await new Promise((resolve) => setImmediate(resolve)); t.is(graphFactory.callCount, 1, "first swap resolved the graph"); @@ -297,9 +297,9 @@ test("destroy() closes live-reload, the socket, and the BuildServer; reinitializ const stack = createStack(); const graphFactory = sinon.stub().resolves({}); const {mocks, httpServer, liveReloadHandle} = createMocks({stacks: [stack]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); await new Promise((resolve) => supervisor.destroy(resolve)); @@ -315,9 +315,9 @@ test("destroy() closes the socket even when BuildServer.destroy() rejects", asyn const stack = createStack(); stack.buildServer.destroy = sinon.stub().rejects(new Error("destroy failed")); const {mocks, httpServer} = createMocks({stacks: [stack]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {}); await new Promise((resolve) => supervisor.destroy(resolve)); t.true(httpServer.close.calledOnce, "socket is closed despite the BuildServer destroy rejection"); @@ -325,20 +325,20 @@ test("destroy() closes the socket even when BuildServer.destroy() rejects", asyn test("reinitialize() warns and no-ops when no graphFactory was provided", async (t) => { const stack = createStack(); - const {mocks, buildServeApp} = createMocks({stacks: [stack]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {mocks, buildApp} = createMocks({stacks: [stack]}); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {}); await supervisor.reinitialize(); - t.true(buildServeApp.calledOnce, "no re-init build happens without a graphFactory"); + t.true(buildApp.calledOnce, "no re-init build happens without a graphFactory"); }); test("definition watcher is created on create() only when a graphFactory is present", async (t) => { const stack = createStack(); const {mocks, DefinitionWatcher} = createMocks({stacks: [stack]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - await ServeSupervisor.create({}, baseConfig, undefined, {}); + await Supervisor.create({}, baseConfig, undefined, {}); t.true(DefinitionWatcher.create.notCalled, "no watcher without a graphFactory"); }); @@ -354,9 +354,9 @@ test("definition watcher is created with the threaded config params", async (t) cwd: "/app", }; const {mocks, DefinitionWatcher} = createMocks({stacks: [stack]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - await ServeSupervisor.create(graph, config, undefined, {graphFactory}); + await Supervisor.create(graph, config, undefined, {graphFactory}); t.true(DefinitionWatcher.create.calledOnce); const opts = DefinitionWatcher.create.firstCall.args[0]; @@ -373,9 +373,9 @@ test("a definitionChanged event triggers reinitialize()", async (t) => { const stack2 = createStack(app2); const graphFactory = sinon.stub().resolves({}); const {mocks, definitionWatchers, createdHandlers} = createMocks({stacks: [stack1, stack2]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + await Supervisor.create({}, baseConfig, undefined, {graphFactory}); // The watcher created on init drives the re-init. definitionWatchers[0].emit("definitionChanged", {eventType: "update", filePath: "/app/ui5.yaml"}); @@ -390,9 +390,9 @@ test.serial("a definitionChanging event signals ui5.project-resolving (version-s const stack1 = createStack(); const graphFactory = sinon.stub().resolves({}); const {mocks, definitionWatchers} = createMocks({stacks: [stack1]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + await Supervisor.create({}, baseConfig, undefined, {graphFactory}); const emit = sinon.spy(process, "emit"); // The watcher's leading-edge event: a re-resolve is coming, blank the version slot. @@ -407,7 +407,7 @@ test.serial("a failed swap releases the version placeholder via ui5.project-reso let calls = 0; const graphFactory = sinon.stub().resolves({}); const {mocks} = createMocks({ - buildServeAppImpl: async () => { + buildAppImpl: async () => { calls++; if (calls === 1) { return stack1; // initial build @@ -415,9 +415,9 @@ test.serial("a failed swap releases the version placeholder via ui5.project-reso throw new Error("invalid ui5.yaml"); // the re-init build fails } }); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); const emit = sinon.spy(process, "emit"); await supervisor.reinitialize(); @@ -432,9 +432,9 @@ test("watcher is re-targeted to the new graph after a swap (old destroyed, new c const newGraph = {name: "newGraph", getRoot: () => ({})}; const graphFactory = sinon.stub().resolves(newGraph); const {mocks, DefinitionWatcher, definitionWatchers} = createMocks({stacks: [stack1, stack2]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); t.is(DefinitionWatcher.create.callCount, 1, "watcher created on init"); const firstWatcher = definitionWatchers[0]; @@ -463,9 +463,9 @@ test("a watcher-create failure during swap keeps the server serving", async (t) throw new Error("watcher failed to arm"); }, }); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); await t.notThrowsAsync(supervisor.reinitialize(), "a watcher-create failure does not reject the swap"); @@ -478,9 +478,9 @@ test("destroy() tears the definition watcher down", async (t) => { const stack = createStack(); const graphFactory = sinon.stub().resolves({}); const {mocks, definitionWatchers} = createMocks({stacks: [stack]}); - const {default: ServeSupervisor} = await importSupervisor(mocks); + const {default: Supervisor} = await importSupervisor(mocks); - const supervisor = await ServeSupervisor.create({}, baseConfig, undefined, {graphFactory}); + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); await new Promise((resolve) => supervisor.destroy(resolve)); t.true(definitionWatchers[0].destroy.calledOnce, "watcher destroyed on teardown"); diff --git a/packages/server/test/lib/server/serveApp.js b/packages/server/test/lib/server/serve/stack.js similarity index 73% rename from packages/server/test/lib/server/serveApp.js rename to packages/server/test/lib/server/serve/stack.js index 7608821793f..a4fc0bed184 100644 --- a/packages/server/test/lib/server/serveApp.js +++ b/packages/server/test/lib/server/serve/stack.js @@ -30,9 +30,9 @@ function createBuildServer() { }; } -async function importBuildServeRouter(applyMiddleware) { - return esmock("../../../lib/serveApp.js", { - "../../../lib/middleware/MiddlewareManager.js": class MiddlewareManager { +async function importBuildRouter(applyMiddleware) { + return esmock("../../../../lib/serve/stack.js", { + "../../../../lib/middleware/MiddlewareManager.js": class MiddlewareManager { applyMiddleware(...args) { return applyMiddleware(...args); } @@ -40,28 +40,28 @@ async function importBuildServeRouter(applyMiddleware) { }); } -test("buildServeRouter() destroys the BuildServer when middleware assembly fails", async (t) => { +test("buildRouter() destroys the BuildServer when middleware assembly fails", async (t) => { const buildServer = createBuildServer(); const graph = createGraph(buildServer); const assemblyError = new Error("applyMiddleware failed"); const applyMiddleware = sinon.stub().rejects(assemblyError); - const {buildServeRouter} = await importBuildServeRouter(applyMiddleware); + const {buildRouter} = await importBuildRouter(applyMiddleware); - const err = await t.throwsAsync(buildServeRouter(graph, {})); + const err = await t.throwsAsync(buildRouter(graph, {})); t.is(err, assemblyError, "the original assembly error is rethrown"); t.true(buildServer.destroy.calledOnce, "the BuildServer is destroyed so its watcher and cache handle are released"); }); -test("buildServeRouter() returns the router and BuildServer on success", async (t) => { +test("buildRouter() returns the router and BuildServer on success", async (t) => { const buildServer = createBuildServer(); const graph = createGraph(buildServer); const applyMiddleware = sinon.stub().resolves(); - const {buildServeRouter} = await importBuildServeRouter(applyMiddleware); + const {buildRouter} = await importBuildRouter(applyMiddleware); - const result = await buildServeRouter(graph, {}); + const result = await buildRouter(graph, {}); t.is(result.buildServer, buildServer, "the BuildServer is returned to the caller"); t.is(typeof result.router, "function", "an express router is returned"); t.true(buildServer.destroy.notCalled, "the BuildServer is not destroyed on success"); diff --git a/packages/server/test/lib/server/serveMiddleware.js b/packages/server/test/lib/server/serveMiddleware.js index 480f6873306..181207847dc 100644 --- a/packages/server/test/lib/server/serveMiddleware.js +++ b/packages/server/test/lib/server/serveMiddleware.js @@ -65,34 +65,34 @@ test("Does not inject the live-reload client script", async (t) => { // router from the shared core, close() releases the BuildServer once and is idempotent, and // the live-reload WebSocket path is disabled. -function createBuildServeRouterMock() { +function createBuildRouterMock() { const router = sinon.stub(); const buildServer = { destroy: sinon.stub().resolves(), }; - const buildServeRouter = sinon.stub().resolves({ + const buildRouter = sinon.stub().resolves({ router, buildServer, liveReloadOptions: {active: false, token: null}, }); - return {router, buildServer, buildServeRouter}; + return {router, buildServer, buildRouter}; } -async function importServeMiddleware(buildServeRouter) { +async function importServeMiddleware(buildRouter) { return esmock("../../../lib/serveMiddleware.js", { - "../../../lib/serveApp.js": {buildServeRouter}, + "../../../lib/serve/stack.js": {buildRouter}, }); } test("serveMiddleware() returns the router as middleware and disables live-reload", async (t) => { - const {router, buildServeRouter} = createBuildServeRouterMock(); - const {default: serveMiddlewareMocked} = await importServeMiddleware(buildServeRouter); + const {router, buildRouter} = createBuildRouterMock(); + const {default: serveMiddlewareMocked} = await importServeMiddleware(buildRouter); const graph = {}; const result = await serveMiddlewareMocked(graph, {ui5DataDir: "/tmp/data"}); - t.true(buildServeRouter.calledOnce); - const [passedGraph, config] = buildServeRouter.firstCall.args; + t.true(buildRouter.calledOnce); + const [passedGraph, config] = buildRouter.firstCall.args; t.is(passedGraph, graph, "the graph is threaded through to the shared core"); t.is(config.liveReload, false, "live-reload is disabled for the embedding API"); t.is(config.webSocketToken, null, "no WebSocket token is minted for the embedding API"); @@ -102,8 +102,8 @@ test("serveMiddleware() returns the router as middleware and disables live-reloa }); test("serveMiddleware() close() destroys the BuildServer once and is idempotent", async (t) => { - const {buildServer, buildServeRouter} = createBuildServeRouterMock(); - const {default: serveMiddlewareMocked} = await importServeMiddleware(buildServeRouter); + const {buildServer, buildRouter} = createBuildRouterMock(); + const {default: serveMiddlewareMocked} = await importServeMiddleware(buildRouter); const {close} = await serveMiddlewareMocked({}, {}); await close(); @@ -113,12 +113,12 @@ test("serveMiddleware() close() destroys the BuildServer once and is idempotent" }); test("serveMiddleware() works without options", async (t) => { - const {buildServeRouter} = createBuildServeRouterMock(); - const {default: serveMiddlewareMocked} = await importServeMiddleware(buildServeRouter); + const {buildRouter} = createBuildRouterMock(); + const {default: serveMiddlewareMocked} = await importServeMiddleware(buildRouter); await serveMiddlewareMocked({}); - const config = buildServeRouter.firstCall.args[1]; + const config = buildRouter.firstCall.args[1]; t.is(config.liveReload, false); t.is(config.webSocketToken, null); }); diff --git a/packages/server/test/lib/server/server.js b/packages/server/test/lib/server/server.js index 2e9a9536906..1535c76a4e0 100644 --- a/packages/server/test/lib/server/server.js +++ b/packages/server/test/lib/server/server.js @@ -2,10 +2,10 @@ import test from "ava"; import sinon from "sinon"; import esmock from "esmock"; -// server.js is now a thin wrapper over ServeSupervisor: it generates the live-reload token, builds -// the config, delegates to ServeSupervisor.create(), and shapes the {h2, port, close, reinitialize} +// server.js is now a thin wrapper over Supervisor: it generates the live-reload token, builds +// the config, delegates to Supervisor.create(), and shapes the {h2, port, close, reinitialize} // result. These tests exercise that wrapper; the swap/relay/trampoline behavior lives in -// ServeSupervisor.js and is covered in ServeSupervisor.js. +// serve/Supervisor.js and is covered there. function createSupervisorMock({port = 3000, createRejects = null} = {}) { const supervisor = { @@ -16,15 +16,15 @@ function createSupervisorMock({port = 3000, createRejects = null} = {}) { const create = createRejects ? sinon.stub().rejects(createRejects) : sinon.stub().resolves(supervisor); - const ServeSupervisor = { + const Supervisor = { create, }; - return {supervisor, ServeSupervisor}; + return {supervisor, Supervisor}; } -async function importServe(ServeSupervisor) { +async function importServe(Supervisor) { return esmock("../../../lib/server.js", { - "../../../lib/ServeSupervisor.js": {default: ServeSupervisor}, + "../../../lib/serve/Supervisor.js": {default: Supervisor}, }); } @@ -32,16 +32,16 @@ test.afterEach.always(() => { sinon.restore(); }); -test("serve() delegates to ServeSupervisor.create and returns port/h2/close/reinitialize", async (t) => { - const {supervisor, ServeSupervisor} = createSupervisorMock({port: 3000}); - const {serve} = await importServe(ServeSupervisor); +test("serve() delegates to Supervisor.create and returns port/h2/close/reinitialize", async (t) => { + const {supervisor, Supervisor} = createSupervisorMock({port: 3000}); + const {serve} = await importServe(Supervisor); const graph = {}; const graphFactory = sinon.stub(); const result = await serve(graph, {port: 3000, h2: false, liveReload: true}, undefined, {graphFactory}); - t.true(ServeSupervisor.create.calledOnce); - const [passedGraph, config, , options] = ServeSupervisor.create.firstCall.args; + t.true(Supervisor.create.calledOnce); + const [passedGraph, config, , options] = Supervisor.create.firstCall.args; t.is(passedGraph, graph); t.is(options.graphFactory, graphFactory, "graphFactory is threaded through to the supervisor"); t.is(typeof config.webSocketToken, "string", "a token is generated when liveReload is active"); @@ -56,18 +56,18 @@ test("serve() delegates to ServeSupervisor.create and returns port/h2/close/rein }); test("serve() does not generate a live-reload token when liveReload is off", async (t) => { - const {ServeSupervisor} = createSupervisorMock(); - const {serve} = await importServe(ServeSupervisor); + const {Supervisor} = createSupervisorMock(); + const {serve} = await importServe(Supervisor); await serve({}, {port: 3000, liveReload: false}, undefined, {}); - const config = ServeSupervisor.create.firstCall.args[1]; + const config = Supervisor.create.firstCall.args[1]; t.is(config.webSocketToken, null); }); test("serve() close() forwards to supervisor.destroy()", async (t) => { - const {supervisor, ServeSupervisor} = createSupervisorMock(); - const {serve} = await importServe(ServeSupervisor); + const {supervisor, Supervisor} = createSupervisorMock(); + const {serve} = await importServe(Supervisor); const result = await serve({}, {port: 3000}, undefined, {}); await new Promise((resolve) => result.close(resolve)); @@ -75,21 +75,21 @@ test("serve() close() forwards to supervisor.destroy()", async (t) => { t.true(supervisor.destroy.calledOnce); }); -test("serve() rejects when ServeSupervisor.create rejects", async (t) => { +test("serve() rejects when Supervisor.create rejects", async (t) => { const createError = new Error("bind failed"); - const {ServeSupervisor} = createSupervisorMock({createRejects: createError}); - const {serve} = await importServe(ServeSupervisor); + const {Supervisor} = createSupervisorMock({createRejects: createError}); + const {serve} = await importServe(Supervisor); const err = await t.throwsAsync(serve({}, {port: 3000}, undefined, {})); t.is(err, createError); }); test("serve() works without the 4th options argument (backward compatible)", async (t) => { - const {ServeSupervisor} = createSupervisorMock(); - const {serve} = await importServe(ServeSupervisor); + const {Supervisor} = createSupervisorMock(); + const {serve} = await importServe(Supervisor); const result = await serve({}, {port: 3000}, undefined); t.is(result.port, 3000); - const options = ServeSupervisor.create.firstCall.args[3]; + const options = Supervisor.create.firstCall.args[3]; t.is(options.graphFactory, undefined); }); From baf2e27d67d0fd526051e58b657b79189dbaa155 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Fri, 24 Jul 2026 18:34:24 +0200 Subject: [PATCH 23/31] refactor: Align project-change APIs introduced on this branch Rename the branch-new DefinitionWatcher to ProjectDefinitionWatcher and move it from build helpers to graph. The watcher observes UI5 project-definition files and drives graph re-resolution. Keep the public export at @ui5/project/graph/ProjectDefinitionWatcher and update Supervisor wiring, tests, and architecture references. Also settle a checkout as one definition burst: definition files start it; later events below subscribed roots only extend the quiet window. Rename ui5.project-resolving to ui5.project-resolve-started so the process-bus lifecycle uses the resolve-started / resolve-failed family. --- .../skills/incremental-build/architecture.md | 14 ++-- .../logger/lib/writers/InteractiveConsole.js | 6 +- .../interactiveConsole/state/project.js | 2 +- .../test/lib/writers/InteractiveConsole.js | 6 +- packages/project/lib/build/BuildServer.js | 2 +- .../lib/build/helpers/RecoveryBudget.js | 6 +- .../ProjectDefinitionWatcher.js} | 80 +++++++++++++------ packages/project/package.json | 2 +- .../ProjectDefinitionWatcher.js} | 69 +++++++++++----- packages/project/test/lib/package-exports.js | 2 +- packages/server/lib/serve/Supervisor.js | 6 +- .../server/test/lib/server/reinitialize.js | 2 +- .../test/lib/server/serve/Supervisor.js | 32 ++++---- 13 files changed, 143 insertions(+), 86 deletions(-) rename packages/project/lib/{build/helpers/DefinitionWatcher.js => graph/ProjectDefinitionWatcher.js} (74%) rename packages/project/test/lib/{build/helpers/DefinitionWatcher.js => graph/ProjectDefinitionWatcher.js} (83%) diff --git a/.claude/skills/incremental-build/architecture.md b/.claude/skills/incremental-build/architecture.md index 19a04781d43..1ad440dfbb1 100644 --- a/.claude/skills/incremental-build/architecture.md +++ b/.claude/skills/incremental-build/architecture.md @@ -38,8 +38,8 @@ 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. 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 `DefinitionWatcher` then re-inits over the new graph | -| `DefinitionWatcher` | `lib/build/helpers/DefinitionWatcher.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. Owned by `@ui5/server`'s `ServeSupervisor`, not the BuildServer; exported via the `@ui5/project/build/helpers/DefinitionWatcher` subpath | +| `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. Owned by `@ui5/server`'s `Supervisor`, not the BuildServer; exported via the `@ui5/project/graph/ProjectDefinitionWatcher` subpath | | `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 | @@ -107,7 +107,7 @@ When a source file changes: 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 DefinitionWatcher'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. +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) @@ -199,15 +199,15 @@ A build request preempts an in-flight pass: `#triggerRequestQueue` awaits `#stop 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** (`DefinitionWatcher`, owned by `@ui5/server`'s `ServeSupervisor`): 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`. +- **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`. -### DefinitionWatcher +### ProjectDefinitionWatcher -`DefinitionWatcher extends EventEmitter`, modeled on `WatchHandler`. Documented here because it shares the watch helpers and settle discipline, though it is owned by `@ui5/server`. +`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. - **Include-based model**: subscribes to each distinct directory (deduplicated across projects); the callback drops every event whose resolved path is not in `#watchedFiles`. The `node_modules`/`.git` ignore globs only reduce OS-level watch load; correctness comes from the include set. @@ -217,7 +217,7 @@ Both watchers share `RecoveryBudget` (loop protection, one budget each), `drainS - **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 `ServeSupervisor` for the re-init/swap wiring. +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. ## Caching Architecture diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index 188b1f1445c..03a672f4bac 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -316,7 +316,7 @@ class InteractiveConsole { process.on("ui5.tool-mode", this.#onToolMode); process.on("ui5.project-resolved", this.#onProjectResolved); process.on("ui5.project-framework-resolved", this.#onProjectFrameworkResolved); - process.on("ui5.project-resolving", this.#onProjectResolving); + 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); @@ -335,7 +335,7 @@ class InteractiveConsole { process.off("ui5.tool-mode", this.#onToolMode); process.off("ui5.project-resolved", this.#onProjectResolved); process.off("ui5.project-framework-resolved", this.#onProjectFrameworkResolved); - process.off("ui5.project-resolving", this.#onProjectResolving); + 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); @@ -378,7 +378,7 @@ class InteractiveConsole { #handleProjectResolved(evt) { // The writer's model is single-root-project, but the root can be resolved more - // than once in a process: `ServeSupervisor.reinitialize()` re-resolves the graph + // 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`. diff --git a/packages/logger/lib/writers/interactiveConsole/state/project.js b/packages/logger/lib/writers/interactiveConsole/state/project.js index c15bbee0de9..e27cbe0dc7d 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/project.js +++ b/packages/logger/lib/writers/interactiveConsole/state/project.js @@ -10,7 +10,7 @@ export function createProjectState() { // 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-resolving` once a + // 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`. diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index b485434ce45..555804fb26c 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -91,7 +91,7 @@ test.serial("repeated project-resolved updates the project region in place", (t) framework: {name: "SAPUI5", version: "1.150.0"}, }); - // A re-init (ServeSupervisor.reinitialize) re-resolves the graph and re-emits + // 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-resolved", { @@ -124,7 +124,7 @@ test.serial("project-resolving shows a version placeholder, project-resolved cle // 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-resolving"); + 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"); @@ -151,7 +151,7 @@ test.serial("project-resolve-failed clears the placeholder back to the last-know process.emit("ui5.project-resolved", { name: "my.app", type: "application", version: "1.0.0", }); - process.emit("ui5.project-resolving"); + 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 diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 35f85bced53..b847cec42a6 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -130,7 +130,7 @@ class BuildServer extends EventEmitter { // 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). `#watcherRecoveryBudget` caps recoveries within a sliding window - // (its own budget, independent of the DefinitionWatcher's). + // (its own budget, independent of the ProjectDefinitionWatcher's). #recoveringWatcher = false; #watcherRecoveryBudget = new RecoveryBudget(); diff --git a/packages/project/lib/build/helpers/RecoveryBudget.js b/packages/project/lib/build/helpers/RecoveryBudget.js index 12a0fdbaba1..1e4d8521196 100644 --- a/packages/project/lib/build/helpers/RecoveryBudget.js +++ b/packages/project/lib/build/helpers/RecoveryBudget.js @@ -10,9 +10,9 @@ export const WATCHER_RECOVERY_WINDOW_MS = 60000; * before attempting one and call {@link recordRecovery} once it succeeds. * * Owned per watcher (the source {@link WatchHandler}'s recovery in BuildServer and the - * {@link DefinitionWatcher} 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). + * {@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 diff --git a/packages/project/lib/build/helpers/DefinitionWatcher.js b/packages/project/lib/graph/ProjectDefinitionWatcher.js similarity index 74% rename from packages/project/lib/build/helpers/DefinitionWatcher.js rename to packages/project/lib/graph/ProjectDefinitionWatcher.js index bd7c6982d33..f2ab4221b96 100644 --- a/packages/project/lib/build/helpers/DefinitionWatcher.js +++ b/packages/project/lib/graph/ProjectDefinitionWatcher.js @@ -2,42 +2,48 @@ import EventEmitter from "node:events"; import path from "node:path"; import parcelWatcher from "@parcel/watcher"; import {getLogger} from "@ui5/logger"; -import {drainSubscriptions} from "./watchSubscriptions.js"; -import {WATCHER_BURST_SETTLE_MS} from "./watchSettle.js"; -import RecoveryBudget, {WATCHER_RECOVERY_MAX_ATTEMPTS, WATCHER_RECOVERY_WINDOW_MS} from "./RecoveryBudget.js"; -const log = getLogger("build:helpers:DefinitionWatcher"); +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 trailing timer, reset on each further event, collapses that burst into a single emit. 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). -const DEFINITION_CHANGED_SETTLE_MS = WATCHER_BURST_SETTLE_MS; +// 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 * <code>definitionChanged</code> when one changes. A <code>definitionChanging</code> fires on the - * leading edge of a burst (the first watched event, before the settle window) so an owner can react - * to a pending change ahead of the coalesced <code>definitionChanged</code>. + * 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 <code>definitionChanged</code>. 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 directory and the change - * callback drops every event whose path is not in the resolved definition-file set. The - * <code>node_modules</code>/<code>.git</code> ignore globs only reduce OS-level watch load; - * correctness comes from the include set. + * model is include-based: @parcel/watcher subscribes to each distinct directory and only resolved + * definition-file paths can start a burst. Once started, non-definition events extend that burst's + * quiet window. The <code>node_modules</code>/<code>.git</code> ignore globs only reduce OS-level + * watch load; correctness comes from the include set. * * @private - * @memberof @ui5/project/build/helpers + * @memberof @ui5/project/graph */ -class DefinitionWatcher extends EventEmitter { +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. @@ -66,10 +72,10 @@ class DefinitionWatcher extends EventEmitter { * @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<DefinitionWatcher>} The armed watcher + * @returns {Promise<ProjectDefinitionWatcher>} The armed watcher */ static async create({graph, rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd} = {}) { - const watcher = new DefinitionWatcher(); + const watcher = new ProjectDefinitionWatcher(); await watcher.#resolveWatchSet({graph, rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd}); await watcher.#subscribeAll(); return watcher; @@ -133,11 +139,16 @@ class DefinitionWatcher extends EventEmitter { return; } for (const event of events) { - // Include-set filter: drop every event that is not a watched definition file. - if (!this.#watchedFiles.has(path.resolve(event.path))) { + if (this.#watchedFiles.has(path.resolve(event.path))) { + this.#onDefinitionEvent(event.type, event.path); continue; } - this.#onDefinitionEvent(event.type, event.path); + + // 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: ["**/node_modules/**", "**/.git/**"]}); this.#subscriptions.push(subscription); @@ -151,12 +162,29 @@ class DefinitionWatcher extends EventEmitter { } this.#lastEvent = {eventType, filePath}; if (this.#settleTimer) { - clearTimeout(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; @@ -234,4 +262,4 @@ class DefinitionWatcher extends EventEmitter { } } -export default DefinitionWatcher; +export default ProjectDefinitionWatcher; diff --git a/packages/project/package.json b/packages/project/package.json index 62a74f35f8f..a2833c90958 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -20,7 +20,6 @@ "exports": { "./config/Configuration": "./lib/config/Configuration.js", "./build/cache/Cache": "./lib/build/cache/Cache.js", - "./build/helpers/DefinitionWatcher": "./lib/build/helpers/DefinitionWatcher.js", "./specifications/Specification": "./lib/specifications/Specification.js", "./specifications/SpecificationVersion": "./lib/specifications/SpecificationVersion.js", "./ui5Framework/Sapui5MavenSnapshotResolver": "./lib/ui5Framework/Sapui5MavenSnapshotResolver.js", @@ -30,6 +29,7 @@ "./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/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js", "./graph": "./lib/graph/graph.js", "./package.json": "./package.json" diff --git a/packages/project/test/lib/build/helpers/DefinitionWatcher.js b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js similarity index 83% rename from packages/project/test/lib/build/helpers/DefinitionWatcher.js rename to packages/project/test/lib/graph/ProjectDefinitionWatcher.js index 5cf0d5d8251..9ea26cce9a7 100644 --- a/packages/project/test/lib/build/helpers/DefinitionWatcher.js +++ b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js @@ -3,12 +3,12 @@ import sinon from "sinon"; import esmock from "esmock"; import path from "node:path"; -let DefinitionWatcher; +let ProjectDefinitionWatcher; let subscribeStub; test.before(async () => { subscribeStub = sinon.stub(); - DefinitionWatcher = await esmock("../../../../lib/build/helpers/DefinitionWatcher.js", { + ProjectDefinitionWatcher = await esmock("../../../lib/graph/ProjectDefinitionWatcher.js", { "@parcel/watcher": { default: { subscribe: subscribeStub @@ -59,7 +59,7 @@ test.serial("create: resolves watch set to distinct roots with ui5.yaml + packag [{name: "dep-a", rootPath: "/deps/a"}, {name: "dep-b", rootPath: "/deps/b"}] ); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); const dirs = subscribeStub.getCalls().map((c) => c.args[0]).sort(); t.deepEqual(dirs, ["/app", "/deps/a", "/deps/b"], "subscribed once per distinct root"); @@ -78,7 +78,7 @@ test.serial("create: shared parent directory is subscribed once", async (t) => { [{name: "dep-a", rootPath: "/mono"}] ); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); t.is(subscribeStub.callCount, 1, "distinct directory subscribed once"); t.is(subscribeStub.firstCall.args[0], "/mono"); @@ -90,7 +90,7 @@ test.serial("create: custom rootConfigPath replaces root ui5.yaml and subscribes captureCallbacks(); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({ + const watcher = await ProjectDefinitionWatcher.create({ graph, rootConfigPath: "/configs/custom.yaml", cwd: "/app" }); @@ -121,7 +121,7 @@ test.serial("create: relative rootConfigPath is resolved against cwd", async (t) captureCallbacks(); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({ + const watcher = await ProjectDefinitionWatcher.create({ graph, rootConfigPath: "custom/ui5.yaml", cwd: "/app" }); @@ -136,7 +136,7 @@ test.serial("create: workspaceConfigPath included (default filename applied) whe const graph = createGraph({name: "root", rootPath: "/app"}); // null → active, use default filename ui5-workspace.yaml at cwd. - const watcher = await DefinitionWatcher.create({graph, workspaceConfigPath: null, cwd: "/ws"}); + const watcher = await ProjectDefinitionWatcher.create({graph, workspaceConfigPath: null, cwd: "/ws"}); const wsCall = subscribeStub.getCalls().find((c) => c.args[0] === "/ws"); t.truthy(wsCall, "workspace directory subscribed"); @@ -156,7 +156,7 @@ test.serial("create: workspaceConfigPath undefined skips the workspace file", as captureCallbacks(); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({graph, workspaceConfigPath: undefined, cwd: "/ws"}); + const watcher = await ProjectDefinitionWatcher.create({graph, workspaceConfigPath: undefined, cwd: "/ws"}); t.falsy(subscribeStub.getCalls().find((c) => c.args[0] === "/ws"), "workspace dir not subscribed"); await watcher.destroy(); @@ -166,7 +166,7 @@ test.serial("create: dependencyDefinitionPath is watched when present", async (t captureCallbacks(); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({ + const watcher = await ProjectDefinitionWatcher.create({ graph, dependencyDefinitionPath: "deps.yaml", cwd: "/app" }); @@ -185,7 +185,7 @@ test.serial("create: dependencyDefinitionPath is watched when present", async (t test.serial("filtering: non-definition file events do not emit", async (t) => { captureCallbacks(); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); const callback = subscribeStub.firstCall.args[1]; const emitted = []; @@ -206,7 +206,7 @@ test.serial("filtering: non-definition file events do not emit", async (t) => { test.serial("filtering: a watched ui5.yaml event emits", async (t) => { captureCallbacks(); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); const callback = subscribeStub.firstCall.args[1]; const emitted = []; @@ -225,7 +225,7 @@ test.serial("filtering: a watched ui5.yaml event emits", async (t) => { test.serial("settle: a burst within the window emits definitionChanged exactly once", async (t) => { captureCallbacks(); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); const callback = subscribeStub.firstCall.args[1]; const emitted = []; @@ -237,8 +237,8 @@ test.serial("settle: a burst within the window emits definitionChanged exactly o clock.tick(200); callback(null, [{type: "update", path: "/app/package.json"}]); clock.tick(200); - callback(null, [{type: "update", path: "/app/src/main.js"}]); // filtered, but no reset either - clock.tick(200); // 200 < 550 from last watched event resets — still within window + callback(null, [{type: "update", path: "/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"); @@ -252,11 +252,40 @@ test.serial("settle: a burst within the window emits definitionChanged exactly o 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("leading edge: definitionChanging fires once on the first event of a burst, before definitionChanged", async (t) => { captureCallbacks(); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); const callback = subscribeStub.firstCall.args[1]; const changing = []; @@ -292,7 +321,7 @@ test.serial("leading edge: definitionChanging fires once on the first event of a test.serial("leading edge: a filtered (non-definition) event does not fire definitionChanging", async (t) => { captureCallbacks(); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); const callback = subscribeStub.firstCall.args[1]; const changing = []; @@ -318,7 +347,7 @@ test.serial("recovery: a watcher error tears down and re-subscribes", async (t) subscribeStub.onSecondCall().resolves(sub2); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); t.is(subscribeStub.callCount, 1); cb(new Error("watcher blew up")); @@ -340,7 +369,7 @@ test.serial("recovery: loop protection escalates to error after the max attempts }); const graph = createGraph({name: "root", rootPath: "/app"}); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); const errors = []; watcher.on("error", (err) => errors.push(err)); @@ -367,7 +396,7 @@ test.serial("destroy: unsubscribes all, is idempotent", async (t) => { {name: "root", rootPath: "/app"}, [{name: "dep", rootPath: "/dep"}] ); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); await watcher.destroy(); await watcher.destroy(); @@ -388,7 +417,7 @@ test.serial("destroy: aggregates unsubscribe failures into an error", async (t) {name: "root", rootPath: "/app"}, [{name: "dep", rootPath: "/dep"}] ); - const watcher = await DefinitionWatcher.create({graph}); + const watcher = await ProjectDefinitionWatcher.create({graph}); const errorSpy = sinon.spy(); watcher.on("error", errorSpy); diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index 0482d955504..14da420ec01 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -20,7 +20,6 @@ test("check number of exports", (t) => { [ "config/Configuration", "build/cache/Cache", - "build/helpers/DefinitionWatcher", "specifications/Specification", "specifications/SpecificationVersion", "ui5Framework/Openui5Resolver", @@ -30,6 +29,7 @@ test("check number of exports", (t) => { "validation/validator", "validation/ValidationError", "graph/ProjectGraph", + "graph/ProjectDefinitionWatcher", "graph/projectGraphBuilder", {exportedSpecifier: "graph", mappedModule: "../../lib/graph/graph.js"}, ].forEach((v) => { diff --git a/packages/server/lib/serve/Supervisor.js b/packages/server/lib/serve/Supervisor.js index 1dbeff14a00..25612ab5e39 100644 --- a/packages/server/lib/serve/Supervisor.js +++ b/packages/server/lib/serve/Supervisor.js @@ -2,7 +2,7 @@ import http from "node:http"; import process from "node:process"; import {EventEmitter} from "node:events"; import {getLogger} from "@ui5/logger"; -import DefinitionWatcher from "@ui5/project/build/helpers/DefinitionWatcher"; +import ProjectDefinitionWatcher from "@ui5/project/graph/ProjectDefinitionWatcher"; import buildApp from "./stack.js"; import attachLiveReloadServer from "../liveReload/server.js"; import {listen, addSsl, announceListening} from "./httpListener.js"; @@ -137,7 +137,7 @@ class Supervisor extends EventEmitter { return; } const {rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd} = this.#config; - const watcher = await DefinitionWatcher.create({ + const watcher = await ProjectDefinitionWatcher.create({ graph, rootConfigPath, workspaceConfigPath, dependencyDefinitionPath, cwd, }); watcher.on("definitionChanged", () => this.reinitialize()); @@ -146,7 +146,7 @@ class Supervisor extends EventEmitter { // "resolving…" placeholder until the swap's own resolve repopulates it via // `ui5.project-resolved` (or a failed swap releases it; see #swap). Attached here so it // survives watcher re-targeting after each swap, mirroring the definitionChanged wiring. - watcher.on("definitionChanging", () => process.emit("ui5.project-resolving")); + watcher.on("definitionChanging", () => process.emit("ui5.project-resolve-started")); watcher.on("error", (err) => log.warn(`Definition watcher error: ${err?.message ?? err}`)); this.#definitionWatcher = watcher; } diff --git a/packages/server/test/lib/server/reinitialize.js b/packages/server/test/lib/server/reinitialize.js index 7fbbe48fe75..e26ed81adda 100644 --- a/packages/server/test/lib/server/reinitialize.js +++ b/packages/server/test/lib/server/reinitialize.js @@ -43,7 +43,7 @@ test.serial("reinitialize() keeps the port bound and continues serving", async ( await new Promise((resolve) => server.close(resolve)); }); -// Integration coverage for the DefinitionWatcher trigger: editing a project's ui5.yaml on disk +// Integration coverage for the ProjectDefinitionWatcher trigger: editing a project's ui5.yaml on disk // drives a real re-init through the watcher (not a programmatic reinitialize() call). Runs against // a fixture copied to test/tmp so the edit does not mutate the checked-in fixture. test.serial("editing ui5.yaml triggers a re-init and keeps serving on the same port", async (t) => { diff --git a/packages/server/test/lib/server/serve/Supervisor.js b/packages/server/test/lib/server/serve/Supervisor.js index 35576816893..55d3bf3a9ff 100644 --- a/packages/server/test/lib/server/serve/Supervisor.js +++ b/packages/server/test/lib/server/serve/Supervisor.js @@ -25,10 +25,10 @@ function createMocks({stacks, buildAppImpl, definitionWatcherCreate} = {}) { const liveReloadHandle = {close: sinon.stub()}; const attachLiveReloadServer = sinon.stub().returns(liveReloadHandle); - // Fake DefinitionWatcher: each create() hands out a fresh EventEmitter with a destroy() stub, + // Fake ProjectDefinitionWatcher: each create() hands out a fresh EventEmitter with a destroy() stub, // recorded so a test can assert re-targeting/teardown. A custom impl overrides create(). const definitionWatchers = []; - const DefinitionWatcher = { + const ProjectDefinitionWatcher = { create: sinon.stub().callsFake(async (opts) => { if (definitionWatcherCreate) { return definitionWatcherCreate(opts); @@ -60,7 +60,7 @@ function createMocks({stacks, buildAppImpl, definitionWatcherCreate} = {}) { const mocks = { "node:http": httpMock, - "@ui5/project/build/helpers/DefinitionWatcher": {default: DefinitionWatcher}, + "@ui5/project/graph/ProjectDefinitionWatcher": {default: ProjectDefinitionWatcher}, "../../../../lib/serve/stack.js": {default: buildApp}, "../../../../lib/serve/httpListener.js": {listen, addSsl, announceListening}, "../../../../lib/liveReload/server.js": {default: attachLiveReloadServer}, @@ -69,7 +69,7 @@ function createMocks({stacks, buildAppImpl, definitionWatcherCreate} = {}) { return { mocks, httpServer, listen, addSsl, announceListening, attachLiveReloadServer, liveReloadHandle, buildApp, createdHandlers, - DefinitionWatcher, definitionWatchers, + ProjectDefinitionWatcher, definitionWatchers, }; } @@ -335,11 +335,11 @@ test("reinitialize() warns and no-ops when no graphFactory was provided", async test("definition watcher is created on create() only when a graphFactory is present", async (t) => { const stack = createStack(); - const {mocks, DefinitionWatcher} = createMocks({stacks: [stack]}); + const {mocks, ProjectDefinitionWatcher} = createMocks({stacks: [stack]}); const {default: Supervisor} = await importSupervisor(mocks); await Supervisor.create({}, baseConfig, undefined, {}); - t.true(DefinitionWatcher.create.notCalled, "no watcher without a graphFactory"); + t.true(ProjectDefinitionWatcher.create.notCalled, "no watcher without a graphFactory"); }); test("definition watcher is created with the threaded config params", async (t) => { @@ -353,13 +353,13 @@ test("definition watcher is created with the threaded config params", async (t) dependencyDefinitionPath: "/app/deps.yaml", cwd: "/app", }; - const {mocks, DefinitionWatcher} = createMocks({stacks: [stack]}); + const {mocks, ProjectDefinitionWatcher} = createMocks({stacks: [stack]}); const {default: Supervisor} = await importSupervisor(mocks); await Supervisor.create(graph, config, undefined, {graphFactory}); - t.true(DefinitionWatcher.create.calledOnce); - const opts = DefinitionWatcher.create.firstCall.args[0]; + t.true(ProjectDefinitionWatcher.create.calledOnce); + const opts = ProjectDefinitionWatcher.create.firstCall.args[0]; t.is(opts.graph, graph, "watcher gets the initial graph"); t.is(opts.rootConfigPath, "/app/custom.yaml"); t.is(opts.workspaceConfigPath, null); @@ -386,7 +386,7 @@ test("a definitionChanged event triggers reinitialize()", async (t) => { t.true(app2.calledOnceWithExactly("req", "res"), "definition change swapped in the new app"); }); -test.serial("a definitionChanging event signals ui5.project-resolving (version-slot reset)", async (t) => { +test.serial("a definitionChanging event signals ui5.project-resolve-started (version-slot reset)", async (t) => { const stack1 = createStack(); const graphFactory = sinon.stub().resolves({}); const {mocks, definitionWatchers} = createMocks({stacks: [stack1]}); @@ -398,8 +398,8 @@ test.serial("a definitionChanging event signals ui5.project-resolving (version-s // The watcher's leading-edge event: a re-resolve is coming, blank the version slot. definitionWatchers[0].emit("definitionChanging", {eventType: "update", filePath: "/app/ui5.yaml"}); - const resolving = emit.getCalls().find((c) => c.args[0] === "ui5.project-resolving"); - t.truthy(resolving, "ui5.project-resolving was emitted to reset the version slot"); + const resolving = emit.getCalls().find((c) => c.args[0] === "ui5.project-resolve-started"); + t.truthy(resolving, "ui5.project-resolve-started was emitted to reset the version slot"); }); test.serial("a failed swap releases the version placeholder via ui5.project-resolve-failed", async (t) => { @@ -431,17 +431,17 @@ test("watcher is re-targeted to the new graph after a swap (old destroyed, new c const stack2 = createStack(); const newGraph = {name: "newGraph", getRoot: () => ({})}; const graphFactory = sinon.stub().resolves(newGraph); - const {mocks, DefinitionWatcher, definitionWatchers} = createMocks({stacks: [stack1, stack2]}); + const {mocks, ProjectDefinitionWatcher, definitionWatchers} = createMocks({stacks: [stack1, stack2]}); const {default: Supervisor} = await importSupervisor(mocks); const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); - t.is(DefinitionWatcher.create.callCount, 1, "watcher created on init"); + t.is(ProjectDefinitionWatcher.create.callCount, 1, "watcher created on init"); const firstWatcher = definitionWatchers[0]; await supervisor.reinitialize(); - t.is(DefinitionWatcher.create.callCount, 2, "a fresh watcher created after the swap"); - t.is(DefinitionWatcher.create.secondCall.args[0].graph, newGraph, "new watcher targets the new graph"); + t.is(ProjectDefinitionWatcher.create.callCount, 2, "a fresh watcher created after the swap"); + t.is(ProjectDefinitionWatcher.create.secondCall.args[0].graph, newGraph, "new watcher targets the new graph"); t.true(firstWatcher.destroy.calledOnce, "old watcher destroyed"); }); From c36cb57e61dca232d94bbc0bc5125f2f73b2a2f9 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Fri, 24 Jul 2026 18:34:50 +0200 Subject: [PATCH 24/31] refactor: Rename the pre-existing ui5.project-resolved signal ui5.project-resolved shipped on main as the single terminal resolve signal. The branch completes the lifecycle around it (a leading ui5.project-resolve-started and a ui5.project-resolve-failed), which exposed a grammatical mismatch: gerund vs past-participle vs verb-state. Rename the terminal signal to ui5.project-resolve-succeeded so the family reads as a uniform verb-state set. This is the expensive half of the alignment (renaming a name that already shipped): the emitter in projectGraphBuilder.js plus both the Console and InteractiveConsole writers and their tests. Split from the branch-introduced renames so the pre-existing contract change is reviewable on its own. --- packages/logger/lib/writers/Console.js | 4 ++-- .../logger/lib/writers/InteractiveConsole.js | 8 ++++---- .../interactiveConsole/state/project.js | 4 ++-- packages/logger/test/lib/writers/Console.js | 6 +++--- .../test/lib/writers/InteractiveConsole.js | 20 +++++++++---------- .../project/lib/graph/projectGraphBuilder.js | 2 +- .../test/lib/graph/projectGraphBuilder.js | 8 ++++---- packages/server/lib/serve/Supervisor.js | 6 +++--- 8 files changed, 29 insertions(+), 29 deletions(-) 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 03a672f4bac..a398075e444 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -314,7 +314,7 @@ 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); @@ -333,7 +333,7 @@ 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); @@ -392,14 +392,14 @@ class InteractiveConsole { } // A definition change is coming: blank the version slot(s) to a "resolving…" placeholder. A - // completed resolve arrives as `ui5.project-resolved` and repopulates them via + // 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-resolved`: release the + // 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…". #handleProjectResolveFailed() { clearVersionResolving(this.#projectState); diff --git a/packages/logger/lib/writers/interactiveConsole/state/project.js b/packages/logger/lib/writers/interactiveConsole/state/project.js index e27cbe0dc7d..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() { @@ -41,7 +41,7 @@ export function setVersionResolving(state) { } // Releases the placeholder back to the last-known version: a re-resolve was abandoned or failed -// without a completing `ui5.project-resolved`. +// 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 555804fb26c..c3c526decd5 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", @@ -82,7 +82,7 @@ test.serial("project-framework-resolved populates the framework region", (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", @@ -94,7 +94,7 @@ test.serial("repeated project-resolved updates the project region in place", (t) // 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-resolved", { + process.emit("ui5.project-resolve-succeeded", { name: "my.app", type: "library", version: "2.0.0", @@ -113,7 +113,7 @@ test.serial("repeated project-resolved updates the project region in place", (t) test.serial("project-resolving shows a version placeholder, project-resolved clears it", (t) => { const {writer, stderr} = createWriter(); - process.emit("ui5.project-resolved", { + process.emit("ui5.project-resolve-succeeded", { name: "my.app", type: "application", version: "1.0.0", @@ -130,7 +130,7 @@ test.serial("project-resolving shows a version placeholder, project-resolved cle 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-resolved", { + process.emit("ui5.project-resolve-succeeded", { name: "my.app", type: "application", version: "2.0.0", @@ -148,7 +148,7 @@ test.serial("project-resolving shows a version placeholder, project-resolved cle test.serial("project-resolve-failed clears the placeholder back to the last-known version", (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-resolve-started"); @@ -249,7 +249,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", }); @@ -362,7 +362,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", { @@ -440,7 +440,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", { @@ -1065,7 +1065,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/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/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/server/lib/serve/Supervisor.js b/packages/server/lib/serve/Supervisor.js index 25612ab5e39..20c6880cb8a 100644 --- a/packages/server/lib/serve/Supervisor.js +++ b/packages/server/lib/serve/Supervisor.js @@ -144,7 +144,7 @@ class Supervisor extends EventEmitter { // 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-resolved` (or a failed swap releases it; see #swap). Attached here so it + // `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. watcher.on("definitionChanging", () => process.emit("ui5.project-resolve-started")); watcher.on("error", (err) => log.warn(`Definition watcher error: ${err?.message ?? err}`)); @@ -187,7 +187,7 @@ class Supervisor extends EventEmitter { 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-resolved`. + // until the trailing pass resolves and repaints it via `ui5.project-resolve-succeeded`. this.#reinitQueued = true; return; } @@ -215,7 +215,7 @@ class Supervisor extends EventEmitter { if (err?.stack) { log.verbose(err.stack); } - // A failed resolve never emits `ui5.project-resolved`, so the version slot would keep + // 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. From 22987e58ac8d7f798db07e7e91ac6c66355487e6 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Mon, 27 Jul 2026 11:20:57 +0200 Subject: [PATCH 25/31] test: Stabilize watcher tests across platforms --- .../lib/graph/ProjectDefinitionWatcher.js | 118 ++++++++++-------- .../test/lib/server/serve/Supervisor.js | 32 +++-- 2 files changed, 87 insertions(+), 63 deletions(-) diff --git a/packages/project/test/lib/graph/ProjectDefinitionWatcher.js b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js index 9ea26cce9a7..61a44d19f11 100644 --- a/packages/project/test/lib/graph/ProjectDefinitionWatcher.js +++ b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js @@ -6,6 +6,9 @@ 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", { @@ -54,15 +57,18 @@ function captureCallbacks(subscription = createMockSubscription()) { test.serial("create: resolves watch set to distinct roots 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: "/app"}, - [{name: "dep-a", rootPath: "/deps/a"}, {name: "dep-b", rootPath: "/deps/b"}] + {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, ["/app", "/deps/a", "/deps/b"], "subscribed once per distinct root"); + t.deepEqual(dirs, [appRoot, depARoot, depBRoot].sort(), "subscribed once per distinct root"); for (const call of subscribeStub.getCalls()) { t.deepEqual(call.args[2].ignore, ["**/node_modules/**", "**/.git/**"], "ignore globs passed"); } @@ -72,44 +78,48 @@ test.serial("create: resolves watch set to distinct roots with ui5.yaml + packag 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: "/mono"}, - [{name: "dep-a", rootPath: "/mono"}] + {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], "/mono"); + 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 graph = createGraph({name: "root", rootPath: "/app"}); + 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: "/configs/custom.yaml", cwd: "/app" + graph, rootConfigPath: configPath, cwd: appRoot }); const dirs = subscribeStub.getCalls().map((c) => c.args[0]).sort(); - t.deepEqual(dirs, ["/app", "/configs"].sort(), "subscribes the custom config's directory too"); + 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] === "/app").args[1]; - rootCb(null, [{type: "update", path: "/app/ui5.yaml"}]); + 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] === "/configs").args[1]; - configCb(null, [{type: "update", path: "/configs/custom.yaml"}]); + 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"); @@ -119,32 +129,34 @@ test.serial("create: custom rootConfigPath replaces root ui5.yaml and subscribes test.serial("create: relative rootConfigPath is resolved against cwd", async (t) => { captureCallbacks(); - const graph = createGraph({name: "root", rootPath: "/app"}); + const appRoot = fixturePath("/app"); + const graph = createGraph({name: "root", rootPath: appRoot}); const watcher = await ProjectDefinitionWatcher.create({ - graph, rootConfigPath: "custom/ui5.yaml", cwd: "/app" + graph, rootConfigPath: "custom/ui5.yaml", cwd: appRoot }); const dirs = subscribeStub.getCalls().map((c) => c.args[0]); - t.true(dirs.includes(path.join("/app", "custom")), "relative config resolved against cwd"); + t.true(dirs.includes(path.join(appRoot, "custom")), "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: "/app"}); + 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: "/ws"}); + const watcher = await ProjectDefinitionWatcher.create({graph, workspaceConfigPath: null, cwd: workspaceRoot}); - const wsCall = subscribeStub.getCalls().find((c) => c.args[0] === "/ws"); + 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: "/ws/ui5-workspace.yaml"}]); + 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"); @@ -154,27 +166,29 @@ test.serial("create: workspaceConfigPath included (default filename applied) whe test.serial("create: workspaceConfigPath undefined skips the workspace file", async (t) => { captureCallbacks(); - const graph = createGraph({name: "root", rootPath: "/app"}); + const graph = createGraph({name: "root", rootPath: fixturePath("/app")}); + const workspaceRoot = fixturePath("/ws"); - const watcher = await ProjectDefinitionWatcher.create({graph, workspaceConfigPath: undefined, cwd: "/ws"}); + const watcher = await ProjectDefinitionWatcher.create({graph, workspaceConfigPath: undefined, cwd: workspaceRoot}); - t.falsy(subscribeStub.getCalls().find((c) => c.args[0] === "/ws"), "workspace dir not subscribed"); + 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 graph = createGraph({name: "root", rootPath: "/app"}); + const appRoot = fixturePath("/app"); + const graph = createGraph({name: "root", rootPath: appRoot}); const watcher = await ProjectDefinitionWatcher.create({ - graph, dependencyDefinitionPath: "deps.yaml", cwd: "/app" + graph, dependencyDefinitionPath: "deps.yaml", cwd: appRoot }); const emitted = []; watcher.on("definitionChanged", (e) => emitted.push(e)); - const appCall = subscribeStub.getCalls().find((c) => c.args[0] === "/app"); + const appCall = subscribeStub.getCalls().find((c) => c.args[0] === appRoot); const clock = sinon.useFakeTimers(); - appCall.args[1](null, [{type: "update", path: "/app/deps.yaml"}]); + 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"); @@ -184,7 +198,7 @@ test.serial("create: dependencyDefinitionPath is watched when present", async (t test.serial("filtering: non-definition file events do not emit", async (t) => { captureCallbacks(); - const graph = createGraph({name: "root", rootPath: "/app"}); + const graph = createGraph({name: "root", rootPath: fixturePath("/app")}); const watcher = await ProjectDefinitionWatcher.create({graph}); const callback = subscribeStub.firstCall.args[1]; @@ -193,8 +207,8 @@ test.serial("filtering: non-definition file events do not emit", async (t) => { const clock = sinon.useFakeTimers(); callback(null, [ - {type: "update", path: "/app/src/main.js"}, - {type: "create", path: "/app/node_modules/foo/package.json"} + {type: "update", path: fixtureFile("/app", "src", "main.js")}, + {type: "create", path: fixtureFile("/app", "node_modules", "foo", "package.json")} ]); clock.tick(600); clock.restore(); @@ -205,7 +219,8 @@ test.serial("filtering: non-definition file events do not emit", async (t) => { test.serial("filtering: a watched ui5.yaml event emits", async (t) => { captureCallbacks(); - const graph = createGraph({name: "root", rootPath: "/app"}); + 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]; @@ -213,18 +228,18 @@ test.serial("filtering: a watched ui5.yaml event emits", async (t) => { watcher.on("definitionChanged", (e) => emitted.push(e)); const clock = sinon.useFakeTimers(); - callback(null, [{type: "update", path: "/app/ui5.yaml"}]); + 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: "/app/ui5.yaml"}); + 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: "/app"}); + const graph = createGraph({name: "root", rootPath: fixturePath("/app")}); const watcher = await ProjectDefinitionWatcher.create({graph}); const callback = subscribeStub.firstCall.args[1]; @@ -233,18 +248,18 @@ test.serial("settle: a burst within the window emits definitionChanged exactly o const clock = sinon.useFakeTimers(); // A checkout burst: ui5.yaml + package.json + a source, spread across batches within the window. - callback(null, [{type: "update", path: "/app/ui5.yaml"}]); + callback(null, [{type: "update", path: fixtureFile("/app", "ui5.yaml")}]); clock.tick(200); - callback(null, [{type: "update", path: "/app/package.json"}]); + callback(null, [{type: "update", path: fixtureFile("/app", "package.json")}]); clock.tick(200); - callback(null, [{type: "update", path: "/app/src/main.js"}]); + 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: "/app/ui5.yaml"}]); + callback(null, [{type: "update", path: fixtureFile("/app", "ui5.yaml")}]); clock.tick(600); t.is(emitted.length, 2, "later change emits again"); clock.restore(); @@ -284,7 +299,8 @@ test.serial("settle: non-definition events extend an active definition burst", a 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: "/app"}); + 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]; @@ -295,14 +311,14 @@ test.serial("leading edge: definitionChanging fires once on the first event of a const clock = sinon.useFakeTimers(); // First watched event of a burst: definitionChanging fires immediately, definitionChanged does not. - callback(null, [{type: "update", path: "/app/ui5.yaml"}]); + callback(null, [{type: "update", path: ui5YamlPath}]); t.is(changing.length, 1, "definitionChanging fires on the leading edge"); - t.deepEqual(changing[0], {eventType: "update", filePath: "/app/ui5.yaml"}); + 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: "/app/package.json"}]); + 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. @@ -310,7 +326,7 @@ test.serial("leading edge: definitionChanging fires once on the first event of a 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: "/app/ui5.yaml"}]); + callback(null, [{type: "update", path: ui5YamlPath}]); t.is(changing.length, 2, "a new burst fires definitionChanging again"); clock.tick(600); clock.restore(); @@ -320,7 +336,7 @@ test.serial("leading edge: definitionChanging fires once on the first event of a test.serial("leading edge: a filtered (non-definition) event does not fire definitionChanging", async (t) => { captureCallbacks(); - const graph = createGraph({name: "root", rootPath: "/app"}); + const graph = createGraph({name: "root", rootPath: fixturePath("/app")}); const watcher = await ProjectDefinitionWatcher.create({graph}); const callback = subscribeStub.firstCall.args[1]; @@ -328,7 +344,7 @@ test.serial("leading edge: a filtered (non-definition) event does not fire defin watcher.on("definitionChanging", (e) => changing.push(e)); const clock = sinon.useFakeTimers(); - callback(null, [{type: "update", path: "/app/src/main.js"}]); + callback(null, [{type: "update", path: fixtureFile("/app", "src", "main.js")}]); clock.tick(600); clock.restore(); @@ -346,7 +362,7 @@ test.serial("recovery: a watcher error tears down and re-subscribes", async (t) }); subscribeStub.onSecondCall().resolves(sub2); - const graph = createGraph({name: "root", rootPath: "/app"}); + const graph = createGraph({name: "root", rootPath: fixturePath("/app")}); const watcher = await ProjectDefinitionWatcher.create({graph}); t.is(subscribeStub.callCount, 1); @@ -368,7 +384,7 @@ test.serial("recovery: loop protection escalates to error after the max attempts return s; }); - const graph = createGraph({name: "root", rootPath: "/app"}); + const graph = createGraph({name: "root", rootPath: fixturePath("/app")}); const watcher = await ProjectDefinitionWatcher.create({graph}); const errors = []; @@ -393,8 +409,8 @@ test.serial("destroy: unsubscribes all, is idempotent", async (t) => { subscribeStub.resolves(sub); const graph = createGraph( - {name: "root", rootPath: "/app"}, - [{name: "dep", rootPath: "/dep"}] + {name: "root", rootPath: fixturePath("/app")}, + [{name: "dep", rootPath: fixturePath("/dep")}] ); const watcher = await ProjectDefinitionWatcher.create({graph}); @@ -414,8 +430,8 @@ test.serial("destroy: aggregates unsubscribe failures into an error", async (t) subscribeStub.onSecondCall().resolves(subB); const graph = createGraph( - {name: "root", rootPath: "/app"}, - [{name: "dep", rootPath: "/dep"}] + {name: "root", rootPath: fixturePath("/app")}, + [{name: "dep", rootPath: fixturePath("/dep")}] ); const watcher = await ProjectDefinitionWatcher.create({graph}); diff --git a/packages/server/test/lib/server/serve/Supervisor.js b/packages/server/test/lib/server/serve/Supervisor.js index 55d3bf3a9ff..b1a917172cb 100644 --- a/packages/server/test/lib/server/serve/Supervisor.js +++ b/packages/server/test/lib/server/serve/Supervisor.js @@ -193,19 +193,19 @@ test("reinitialize() failure keeps the last-good stack serving", async (t) => { test("live-reload subscription moves to the new BuildServer across a swap", async (t) => { const stack1 = createStack(); const stack2 = createStack(); - sinon.spy(stack1.buildServer, "off"); - sinon.spy(stack2.buildServer, "on"); const graphFactory = sinon.stub().resolves({}); const {mocks, attachLiveReloadServer} = createMocks({stacks: [stack1, stack2]}); const {default: Supervisor} = await importSupervisor(mocks); const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); const relay = attachLiveReloadServer.firstCall.args[0].buildServer; + t.is(stack1.buildServer.listenerCount("sourcesChanged"), 1, "old BuildServer starts attached to the relay"); + t.is(stack2.buildServer.listenerCount("sourcesChanged"), 0, "new BuildServer is not attached before the swap"); await supervisor.reinitialize(); - t.true(stack1.buildServer.off.calledWith("sourcesChanged"), "old BuildServer is detached from the relay"); - t.true(stack2.buildServer.on.calledWith("sourcesChanged"), "new BuildServer is attached to the relay"); + t.is(stack1.buildServer.listenerCount("sourcesChanged"), 0, "old BuildServer is detached from the relay"); + t.is(stack2.buildServer.listenerCount("sourcesChanged"), 1, "new BuildServer is attached to the relay"); // A sourcesChanged from the new BuildServer still reaches relay subscribers. let relayed = 0; @@ -386,7 +386,7 @@ test("a definitionChanged event triggers reinitialize()", async (t) => { t.true(app2.calledOnceWithExactly("req", "res"), "definition change swapped in the new app"); }); -test.serial("a definitionChanging event signals ui5.project-resolve-started (version-slot reset)", async (t) => { +test("a definitionChanging event signals ui5.project-resolve-started (version-slot reset)", async (t) => { const stack1 = createStack(); const graphFactory = sinon.stub().resolves({}); const {mocks, definitionWatchers} = createMocks({stacks: [stack1]}); @@ -394,15 +394,19 @@ test.serial("a definitionChanging event signals ui5.project-resolve-started (ver await Supervisor.create({}, baseConfig, undefined, {graphFactory}); - const emit = sinon.spy(process, "emit"); + let started = false; + const onResolveStarted = () => { + started = true; + }; + process.once("ui5.project-resolve-started", onResolveStarted); // The watcher's leading-edge event: a re-resolve is coming, blank the version slot. definitionWatchers[0].emit("definitionChanging", {eventType: "update", filePath: "/app/ui5.yaml"}); + process.off("ui5.project-resolve-started", onResolveStarted); - const resolving = emit.getCalls().find((c) => c.args[0] === "ui5.project-resolve-started"); - t.truthy(resolving, "ui5.project-resolve-started was emitted to reset the version slot"); + t.true(started, "ui5.project-resolve-started was emitted to reset the version slot"); }); -test.serial("a failed swap releases the version placeholder via ui5.project-resolve-failed", async (t) => { +test("a failed swap releases the version placeholder via ui5.project-resolve-failed", async (t) => { const stack1 = createStack(); let calls = 0; const graphFactory = sinon.stub().resolves({}); @@ -419,11 +423,15 @@ test.serial("a failed swap releases the version placeholder via ui5.project-reso const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); - const emit = sinon.spy(process, "emit"); + let failed = false; + const onResolveFailed = () => { + failed = true; + }; + process.once("ui5.project-resolve-failed", onResolveFailed); await supervisor.reinitialize(); + process.off("ui5.project-resolve-failed", onResolveFailed); - const released = emit.getCalls().find((c) => c.args[0] === "ui5.project-resolve-failed"); - t.truthy(released, "a failed swap emits ui5.project-resolve-failed so the placeholder does not wedge"); + t.true(failed, "a failed swap emits ui5.project-resolve-failed so the placeholder does not wedge"); }); test("watcher is re-targeted to the new graph after a swap (old destroyed, new created)", async (t) => { From 667c5bb8b0b8a65a4473461fe091f221e95eeb04 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Mon, 27 Jul 2026 12:39:24 +0200 Subject: [PATCH 26/31] docs(project): Avoid JSDoc tag parsing in watcher docs --- packages/project/lib/build/helpers/watchSettle.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/build/helpers/watchSettle.js b/packages/project/lib/build/helpers/watchSettle.js index 2db60614b9d..3a43acaac1a 100644 --- a/packages/project/lib/build/helpers/watchSettle.js +++ b/packages/project/lib/build/helpers/watchSettle.js @@ -1,8 +1,8 @@ /** * Settle window (ms) for collapsing a filesystem-event burst into a single trailing action, shared - * by every @parcel/watcher consumer in the build layer. + * by every Parcel watcher consumer in the build layer. * - * @parcel/watcher caps its own event coalescing at MAX_WAIT_TIME (500 ms): during a continuous + * 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 <em>above</em> that cap, so each further batch resets From 758f42bec0a1e3e064c5f9332a8f90b2cf7593cc Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Tue, 28 Jul 2026 17:47:30 +0200 Subject: [PATCH 27/31] refactor(project): Add reader suspend mode for definition changes On a project-definition change (e.g. a `git checkout`), reader requests parked in source event but leaves #readerQueue untouched, and the abort/transient branches keep re-queueing and deferring the restart, so a held request waits out the whole source burst before any build completes to resolve it. Add a suspend mode the dev server engages on the leading edge of a definition change: suspendReaders(error) rejects every parked reader request immediately and makes new ones fail fast (no build enqueued); resumeReaders() lifts it. The suspend flag lives on ProjectBuildStatus, orthogonal to #state, so invalidate()/setReader()/clearError() do not touch it and the concurrent source churn of a checkout cannot flip it back. Unlike rejectReaderRequests(), suspend does not latch ERRORED or capture #lastError: the project is fine, its definition is just being re-resolved, so the server rebuilds normally once resumed. The suspend check sits after the isFresh() short-circuit in #getReaderForProject, so already-built resources keep serving during the bridge. The thrown error is carried in from the caller (the HTTP-facing wording stays in the server layer) and surfaces through serveResources -> errorHandler. --- packages/project/lib/build/BuildServer.js | 59 ++++++++ .../project/test/lib/build/BuildServer.js | 130 ++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index b847cec42a6..da6b58e2429 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -122,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. @@ -411,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 <code>git checkout</code>), 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 * @@ -469,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 @@ -1401,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/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index 6613420ddb8..9ba92861829 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -1894,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"); +}); From 4b422ee03f1beeea5fef58a4d10f8ffe5bfd3ce1 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Tue, 28 Jul 2026 17:47:59 +0200 Subject: [PATCH 28/31] refactor(project): Add project graph settler for degraded recovery After a failed re-resolve the dev server keeps the last-good stack serving. A later `git checkout` back to a supported state can resolve cleanly while the checkout is still writing sources and whole dependency roots to disk, so a naive re-resolve would swap in a graph pointing at a half-restored project. Add ProjectGraphSettler.waitForProjectGraphQuiet(graphs): a short-lived @parcel/watcher gate that watches the union of the given graphs' project roots without a node_modules ignore and resolves once those roots are quiet for one WATCHER_BURST_SETTLE_MS window. Missing roots are watched at their nearest existing ancestor, so a project still being restored is observable, and nested npm dependency layouts are covered without guessing a stable ancestor for the long-lived definition watcher. The @ui5/server Supervisor drives it (see the recovery convergence loop). Also stop the ProjectDefinitionWatcher from ignoring node_modules for project roots that live below a node_modules directory, so their own ui5.yaml and package.json changes remain observable. Regular roots keep the ignore to reduce long-lived watch load. Export ProjectGraphSettler and RecoveryBudget via the package subpaths the server consumes. --- .../lib/graph/ProjectDefinitionWatcher.js | 42 ++-- .../project/lib/graph/ProjectGraphSettler.js | 187 ++++++++++++++++ packages/project/package.json | 2 + .../lib/graph/ProjectDefinitionWatcher.js | 86 +++++++- .../test/lib/graph/ProjectGraphSettler.js | 204 ++++++++++++++++++ packages/project/test/lib/package-exports.js | 4 +- 6 files changed, 507 insertions(+), 18 deletions(-) create mode 100644 packages/project/lib/graph/ProjectGraphSettler.js create mode 100644 packages/project/test/lib/graph/ProjectGraphSettler.js diff --git a/packages/project/lib/graph/ProjectDefinitionWatcher.js b/packages/project/lib/graph/ProjectDefinitionWatcher.js index f2ab4221b96..c77094d9a92 100644 --- a/packages/project/lib/graph/ProjectDefinitionWatcher.js +++ b/packages/project/lib/graph/ProjectDefinitionWatcher.js @@ -35,10 +35,12 @@ export const DEFINITION_CHANGED_SETTLE_MS = WATCHER_BURST_SETTLE_MS; * * Separate from the source {@link WatchHandler}: source events drive incremental rebuilds inside * the BuildServer, definition events drive a full re-init of the serving stack above it. The watch - * model is include-based: @parcel/watcher subscribes to each distinct directory and only resolved - * definition-file paths can start a burst. Once started, non-definition events extend that burst's - * quiet window. The <code>node_modules</code>/<code>.git</code> ignore globs only reduce OS-level - * watch load; correctness comes from the include set. + * 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 + * <code>node_modules</code>/<code>.git</code> ignore globs only reduce OS-level watch load; + * correctness comes from the include set. Project roots below <code>node_modules</code> are watched + * without the <code>node_modules</code> ignore so their own definition files remain observable. * * @private * @memberof @ui5/project/graph @@ -48,8 +50,9 @@ class ProjectDefinitionWatcher extends EventEmitter { // 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<absolute file path>: the distinct directories to subscribe, each mapped to its - // definition files. Retained so recovery can re-subscribe the same set. + // dir -> Set<absolute file path>: 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; @@ -86,23 +89,26 @@ class ProjectDefinitionWatcher extends EventEmitter { const baseDir = cwd ? path.resolve(cwd) : process.cwd(); const resolve = (p) => (path.isAbsolute(p) ? p : path.join(baseDir, p)); - const add = (filePath) => { - const abs = path.resolve(filePath); - this.#watchedFiles.add(abs); - const dir = path.dirname(abs); + 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(abs); + 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 = project.getRootPath(); + 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. @@ -125,6 +131,14 @@ class ProjectDefinitionWatcher extends EventEmitter { } } + #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()]; @@ -150,7 +164,7 @@ class ProjectDefinitionWatcher extends EventEmitter { // and symlink targets referenced by newly-restored package.json files. this.#onNonDefinitionEvent(event.type, event.path); } - }, {ignore: ["**/node_modules/**", "**/.git/**"]}); + }, {ignore: this.#getIgnoreGlobs(dir)}); this.#subscriptions.push(subscription); } 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 <code>node_modules</code> 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<void>} 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/package.json b/packages/project/package.json index a2833c90958..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", @@ -30,6 +31,7 @@ "./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/graph/ProjectDefinitionWatcher.js b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js index 61a44d19f11..d2ef10fb4ea 100644 --- a/packages/project/test/lib/graph/ProjectDefinitionWatcher.js +++ b/packages/project/test/lib/graph/ProjectDefinitionWatcher.js @@ -55,7 +55,7 @@ function captureCallbacks(subscription = createMockSubscription()) { return callbacks; } -test.serial("create: resolves watch set to distinct roots with ui5.yaml + package.json", async (t) => { +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"); @@ -68,7 +68,7 @@ test.serial("create: resolves watch set to distinct roots with ui5.yaml + packag const watcher = await ProjectDefinitionWatcher.create({graph}); const dirs = subscribeStub.getCalls().map((c) => c.args[0]).sort(); - t.deepEqual(dirs, [appRoot, depARoot, depBRoot].sort(), "subscribed once per distinct root"); + 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"); } @@ -76,6 +76,39 @@ test.serial("create: resolves watch set to distinct roots with ui5.yaml + packag 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"); @@ -130,6 +163,7 @@ test.serial("create: custom rootConfigPath replaces root ui5.yaml and subscribes 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({ @@ -137,7 +171,16 @@ test.serial("create: relative rootConfigPath is resolved against cwd", async (t) }); const dirs = subscribeStub.getCalls().map((c) => c.args[0]); - t.true(dirs.includes(path.join(appRoot, "custom")), "relative config resolved against cwd"); + 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(); }); @@ -296,6 +339,43 @@ test.serial("settle: non-definition events extend an active definition burst", a 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(); 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/package-exports.js b/packages/project/test/lib/package-exports.js index 14da420ec01..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, 15); + 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", @@ -30,6 +31,7 @@ test("check number of exports", (t) => { "validation/ValidationError", "graph/ProjectGraph", "graph/ProjectDefinitionWatcher", + "graph/ProjectGraphSettler", "graph/projectGraphBuilder", {exportedSpecifier: "graph", mappedModule: "../../lib/graph/graph.js"}, ].forEach((v) => { From 26764b828b5f6b2cfb4024880d742b7b64252114 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Tue, 28 Jul 2026 17:48:22 +0200 Subject: [PATCH 29/31] refactor(server): Recover degraded servers by converging the graph When a definition change re-resolves the graph and the swap fails (for example a branch switch to a config the tooling cannot resolve), the last-good stack keeps serving. Two problems remained: requests either served a stale 200 / opaque 500, or stalled for seconds waiting on the old BuildServer's reader while the checkout's source burst kept aborting its builds. And once the checkout completed, a later successful-looking resolve could swap in a graph whose sources or dependency roots were still landing on disk. Divert requests while degraded, then recover by converging on a stable graph: - #degradedError, exposed via a stable #getDegradedError accessor threaded into every stack buildApp() builds. serveBuildError diverts on it first, for every request type, then falls back to the per-project getServeError gate which stays navigation-only. Set on the #swap failure path, cleared on a successful swap. - On the watcher's leading-edge definitionChanging, suspend the current BuildServer's readers so parked and incoming requests fail fast instead of hanging. resumeReaders() runs on both swap outcomes. - A failed swap self-schedules one bounded recovery after the definition-watcher settle interval. Recovery runs a convergence loop (#convergeRecoveryGraph): resolve the graph, compare its project-root set against 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 project the target branch restores late, or a dependency root neither prior graph knew, is observed for quietness once it surfaces in a resolve rather than swapped in half-restored. - RecoveryBudget bounds the self-scheduled recoveries against a persistently broken branch. 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 real definitionChanging clears any pending recovery and resets the budget; a clean swap resets it too. The diverted error renders through the existing errorHandler. The convergence check relies on Set.prototype.isSubsetOf (Node >=22.20, matching the engines field). --- .../lib/build/helpers/RecoveryBudget.js | 7 +- .../lib/middleware/MiddlewareManager.js | 14 +- .../server/lib/middleware/serveBuildError.js | 55 ++- packages/server/lib/serve/Supervisor.js | 192 ++++++++- packages/server/lib/serve/stack.js | 25 +- .../lib/server/middleware/serveBuildError.js | 57 +++ .../test/lib/server/serve/Supervisor.js | 366 +++++++++++++++++- .../server/test/lib/server/serve/stack.js | 44 ++- 8 files changed, 716 insertions(+), 44 deletions(-) diff --git a/packages/project/lib/build/helpers/RecoveryBudget.js b/packages/project/lib/build/helpers/RecoveryBudget.js index 1e4d8521196..514d6f1def0 100644 --- a/packages/project/lib/build/helpers/RecoveryBudget.js +++ b/packages/project/lib/build/helpers/RecoveryBudget.js @@ -6,8 +6,11 @@ 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 <code>maxAttempts</code> within a - * trailing <code>windowMs</code>. Counts <em>completed</em> recoveries: check {@link withinBudget} - * before attempting one and call {@link recordRecovery} once it succeeds. + * trailing <code>windowMs</code>. 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 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 (<code>serveResources</code>) only surfaces the build-error page - * when the requested path maps to the failed project. A navigation to an unaffected - * resource (the app's <code>index.html</code> 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 - * <code>next(err)</code> 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 <code>errorHandler</code>; this middleware only decides whether to divert. + * <strong>Degraded</strong> (<code>getDegradedError</code>): 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 <em>every</em> request is diverted, not only + * document navigations. This preempts the per-project gate below. * - * Registered before <code>serveResources</code> so it can preempt an otherwise-successful - * 200. It must be a normal 3-argument middleware: the 4-argument <code>errorHandler</code> - * is only reached once something upstream calls <code>next(err)</code>, which never happens - * for a navigation that would otherwise succeed. + * <strong>Global ERROR</strong> (<code>getServeError</code>): a project is in its ERROR state. + * The per-project reader (<code>serveResources</code>) only surfaces the build-error page when the + * requested path maps to the failed project. A navigation to an unaffected resource (the app's + * <code>index.html</code> 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 <code>next(err)</code> 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 <code>errorHandler</code>; 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 <code>serveResources</code> so it can preempt an otherwise-successful 200. It + * must be a normal 3-argument middleware: the 4-argument <code>errorHandler</code> is only reached + * once something upstream calls <code>next(err)</code>, 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 <code>null</code> when the server is not in ERROR. When omitted, the middleware - * passes every request through. + * error, or <code>null</code> 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 index 20c6880cb8a..0dc416f5989 100644 --- a/packages/server/lib/serve/Supervisor.js +++ b/packages/server/lib/serve/Supervisor.js @@ -1,14 +1,22 @@ 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 from "@ui5/project/graph/ProjectDefinitionWatcher"; +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. @@ -28,12 +36,19 @@ class Supervisor extends EventEmitter { #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. @@ -49,6 +64,33 @@ class Supervisor extends EventEmitter { #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(); @@ -91,7 +133,8 @@ class Supervisor extends EventEmitter { } // Build the initial stack before binding so a construction failure surfaces to the caller. - this.#stack = await buildApp(graph, this.#config, this.#error); + 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. @@ -146,7 +189,22 @@ class Supervisor extends EventEmitter { // "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. - watcher.on("definitionChanging", () => process.emit("ui5.project-resolve-started")); + // + // 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; } @@ -180,6 +238,7 @@ class Supervisor extends EventEmitter { if (this.#destroyed) { return; } + this.#clearRecoveryTimer(); if (!this.#graphFactory) { log.warn("Cannot re-initialize server: no graph factory was provided"); return; @@ -202,24 +261,129 @@ class Supervisor extends EventEmitter { } } + #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 = await this.#graphFactory(); - newStack = await buildApp(newGraph, this.#config, this.#error); + 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. - process.emit("ui5.project-resolve-failed"); + // 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) { @@ -229,6 +393,13 @@ class Supervisor extends EventEmitter { } // 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 @@ -245,7 +416,10 @@ class Supervisor extends EventEmitter { 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. + // 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(); } @@ -263,6 +437,7 @@ class Supervisor extends EventEmitter { */ 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; @@ -270,6 +445,7 @@ class Supervisor extends EventEmitter { this.#liveReloadHandle?.close(); this.#detachRelay(); this.#httpServer?.close(callback); + this.#clearRecoveryTimer(); try { await definitionWatcher?.destroy(); } catch (err) { diff --git a/packages/server/lib/serve/stack.js b/packages/server/lib/serve/stack.js index 53b71305739..5169f179eb4 100644 --- a/packages/server/lib/serve/stack.js +++ b/packages/server/lib/serve/stack.js @@ -25,9 +25,14 @@ const log = getLogger("server"); * <code>webSocketToken</code> shared across swaps * @param {Function} [error] Error callback invoked when the BuildServer emits an error outside * of request handling + * @param {Function} [getDegradedError] Accessor returning a supervisor-level error while the + * serving stack is degraded (its backing graph no longer matches the project definition after a + * failed re-resolve), or a falsy value otherwise. When set, serveBuildError diverts every request + * to the error page, since the surviving BuildServer's reader would otherwise block requests until + * the definition change's source burst settles. * @returns {Promise<object>} Resolves with <code>{router, buildServer, liveReloadOptions}</code> */ -export async function buildRouter(graph, config, error) { +export async function buildRouter(graph, config, error, getDegradedError) { const { sendSAPTargetCSP = false, simpleIndex = false, liveReload = false, serveCSPReports = false, cache = Cache.Default, ui5DataDir, includedTasks, excludedTasks, webSocketToken = null, @@ -110,9 +115,15 @@ export async function buildRouter(graph, config, error) { serveCSPReports, simpleIndex, liveReload: liveReloadOptions, - // Consulted by the serveBuildError gate to divert HTML navigations while the - // build server is globally in ERROR. - getServeError: () => buildServer.getServeError() + // The BuildServer's own per-project error. serveBuildError diverts document + // navigations to the error page while a project is globally in ERROR, leaving a + // failing subresource its per-project 500. + getServeError: () => buildServer.getServeError(), + // Supervisor-level error: a failed re-resolve left this stack serving a graph that + // no longer matches disk. serveBuildError diverts every request (not just + // navigations) when this is set, because the surviving BuildServer's reader would + // otherwise block each request until the branch switch's source burst settles. + getDegradedError, } }); @@ -140,10 +151,12 @@ export async function buildRouter(graph, config, error) { * @param {object} config Server configuration; see {@link buildRouter} * @param {Function} [error] Error callback invoked when the BuildServer emits an error outside * of request handling + * @param {Function} [getDegradedError] Accessor returning a supervisor-level error while the + * serving stack is degraded after a failed re-resolve; threaded to {@link buildRouter} * @returns {Promise<object>} Resolves with <code>{app, buildServer, liveReloadOptions}</code> */ -export default async function buildApp(graph, config, error) { - const {router, buildServer, liveReloadOptions} = await buildRouter(graph, config, error); +export default async function buildApp(graph, config, error, getDegradedError) { + const {router, buildServer, liveReloadOptions} = await buildRouter(graph, config, error, getDegradedError); const app = express(); app.use(router); diff --git a/packages/server/test/lib/server/middleware/serveBuildError.js b/packages/server/test/lib/server/middleware/serveBuildError.js index ad4acabfaf3..a11822992ea 100644 --- a/packages/server/test/lib/server/middleware/serveBuildError.js +++ b/packages/server/test/lib/server/middleware/serveBuildError.js @@ -77,3 +77,60 @@ test("Passes everything through when no accessor is supplied", (t) => { t.is(nextArg, undefined, "Without getServeError the gate is inert"); }); + +test("Degraded: diverts a document navigation, a subresource, and an XHR alike", (t) => { + // A degraded error means the whole graph is unresolvable; the surviving BuildServer would + // block any read. So every request type is diverted, unlike the per-project ERROR gate. + const degradedError = new Error("Cannot read ui5.yaml: no such file"); + const middleware = createMiddleware({getDegradedError: () => degradedError}); + + for (const [label, headers] of [ + ["document navigation", DOC_NAV], + ["subresource", {"sec-fetch-dest": "script", "accept": "*/*"}], + ["XHR/fetch", {"sec-fetch-dest": "empty", "accept": "*/*"}], + ]) { + let nextArg = "unset"; + middleware(mockReq(headers), {}, (arg) => { + nextArg = arg; + }); + t.is(nextArg, degradedError, `${label} is diverted while degraded`); + } +}); + +test("Degraded takes precedence over a per-project ERROR on a subresource", (t) => { + // With both set, the degraded gate wins and diverts even a subresource, which the ERROR + // gate alone would pass through. + const degradedError = new Error("invalid ui5.yaml"); + const serveError = new Error("build broke"); + const middleware = createMiddleware({ + getServeError: () => serveError, + getDegradedError: () => degradedError, + }); + + let nextArg = "unset"; + middleware(mockReq({"sec-fetch-dest": "script", "accept": "*/*"}), {}, (arg) => { + nextArg = arg; + }); + t.is(nextArg, degradedError, "the degraded error is forwarded, not the per-project error"); +}); + +test("Not degraded: falls back to the per-project ERROR gate", (t) => { + // getDegradedError returning falsy leaves the per-project behavior intact. + const serveError = new Error("build broke"); + const middleware = createMiddleware({ + getServeError: () => serveError, + getDegradedError: () => null, + }); + + let docNavArg = "unset"; + middleware(mockReq(DOC_NAV), {}, (arg) => { + docNavArg = arg; + }); + t.is(docNavArg, serveError, "a document navigation is diverted by the per-project gate"); + + let subresourceArg = "unset"; + middleware(mockReq({"sec-fetch-dest": "script", "accept": "*/*"}), {}, (arg) => { + subresourceArg = arg; + }); + t.is(subresourceArg, undefined, "a subresource still passes through when only globally errored"); +}); diff --git a/packages/server/test/lib/server/serve/Supervisor.js b/packages/server/test/lib/server/serve/Supervisor.js index b1a917172cb..7091646829d 100644 --- a/packages/server/test/lib/server/serve/Supervisor.js +++ b/packages/server/test/lib/server/serve/Supervisor.js @@ -8,6 +8,8 @@ function createBuildServer() { const buildServer = new EventEmitter(); buildServer.getServeError = sinon.stub().returns(null); buildServer.destroy = sinon.stub().resolves(); + buildServer.suspendReaders = sinon.stub(); + buildServer.resumeReaders = sinon.stub(); return buildServer; } @@ -40,11 +42,12 @@ function createMocks({stacks, buildAppImpl, definitionWatcherCreate} = {}) { return watcher; }), }; + const waitForProjectGraphQuiet = sinon.stub().resolves(); const stackQueue = stacks ? [...stacks] : null; - const buildApp = sinon.stub().callsFake(async (graph, config, error) => { + const buildApp = sinon.stub().callsFake(async (graph, config, error, getDegradedError) => { if (buildAppImpl) { - return buildAppImpl(graph, config, error); + return buildAppImpl(graph, config, error, getDegradedError); } return stackQueue.shift(); }); @@ -60,7 +63,11 @@ function createMocks({stacks, buildAppImpl, definitionWatcherCreate} = {}) { const mocks = { "node:http": httpMock, - "@ui5/project/graph/ProjectDefinitionWatcher": {default: ProjectDefinitionWatcher}, + "@ui5/project/graph/ProjectDefinitionWatcher": { + default: ProjectDefinitionWatcher, + DEFINITION_CHANGED_SETTLE_MS: 550, + }, + "@ui5/project/graph/ProjectGraphSettler": {waitForProjectGraphQuiet}, "../../../../lib/serve/stack.js": {default: buildApp}, "../../../../lib/serve/httpListener.js": {listen, addSsl, announceListening}, "../../../../lib/liveReload/server.js": {default: attachLiveReloadServer}, @@ -69,7 +76,7 @@ function createMocks({stacks, buildAppImpl, definitionWatcherCreate} = {}) { return { mocks, httpServer, listen, addSsl, announceListening, attachLiveReloadServer, liveReloadHandle, buildApp, createdHandlers, - ProjectDefinitionWatcher, definitionWatchers, + ProjectDefinitionWatcher, definitionWatchers, waitForProjectGraphQuiet, }; } @@ -81,10 +88,36 @@ function createStack(app) { }; } +// A minimal graph stub the Supervisor can traverse for its recovery-convergence root check +// (#graphRootPaths). Only the degraded-recovery path traverses the graph; healthy swaps hand the +// graph straight to the (mocked) buildApp and ProjectDefinitionWatcher, so a bare {} suffices there. +function createGraph(rootPaths = ["/app"]) { + return { + rootPaths, + traverseBreadthFirst: async (cb) => { + for (const rootPath of rootPaths) { + await cb({project: {getRootPath: () => rootPath}}); + } + }, + }; +} + async function importSupervisor(mocks) { return esmock("../../../../lib/serve/Supervisor.js", mocks); } +// Yields to the microtask queue until `predicate()` holds or the attempt budget is spent. The +// recovery convergence loop awaits several times per iteration (graphFactory, root traversal, the +// settle stub), so a single microtask flush is not enough to reach a given call count. +async function waitFor(predicate, attempts = 50) { + for (let i = 0; i < attempts; i++) { + if (predicate()) { + return; + } + await Promise.resolve(); + } +} + const baseConfig = {port: 3000, liveReload: true, webSocketToken: "tok"}; test.afterEach.always(() => { @@ -179,6 +212,9 @@ test("reinitialize() failure keeps the last-good stack serving", async (t) => { const errorEvents = []; const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); supervisor.on("error", (err) => errorEvents.push(err)); + // A failed swap self-schedules a recovery timer; destroy() clears it synchronously so it does not + // fire on the shared real clock (re-resolving the bare {} graph) during other concurrent tests. + t.teardown(() => supervisor.destroy()); await t.notThrowsAsync(supervisor.reinitialize(), "a broken definition does not reject"); @@ -422,6 +458,9 @@ test("a failed swap releases the version placeholder via ui5.project-resolve-fai const {default: Supervisor} = await importSupervisor(mocks); const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); + // A failed swap self-schedules a recovery timer; destroy() clears it synchronously so it does not + // fire on the shared real clock during other concurrent tests. + t.teardown(() => supervisor.destroy()); let failed = false; const onResolveFailed = () => { @@ -434,6 +473,325 @@ test("a failed swap releases the version placeholder via ui5.project-resolve-fai t.true(failed, "a failed swap emits ui5.project-resolve-failed so the placeholder does not wedge"); }); +test("a failed swap flags the stack degraded; the accessor threaded to buildApp reflects it", async (t) => { + const stack1 = createStack(); + const buildError = new Error("invalid ui5.yaml"); + let calls = 0; + const graphFactory = sinon.stub().resolves({}); + const {mocks, buildApp} = createMocks({ + buildAppImpl: async () => { + calls++; + if (calls === 1) { + return stack1; // initial build + } + throw buildError; // the re-init build fails + } + }); + const {default: Supervisor} = await importSupervisor(mocks); + + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); + // A failed swap self-schedules a recovery timer; destroy() clears it synchronously so it does not + // fire on the shared real clock during other concurrent tests. + t.teardown(() => supervisor.destroy()); + + // The stable accessor the supervisor threads into every stack it builds. + const getDegradedError = buildApp.firstCall.args[3]; + t.is(typeof getDegradedError, "function", "a getDegradedError accessor is passed to buildApp"); + t.is(getDegradedError(), null, "not degraded while the initial stack is healthy"); + + await supervisor.reinitialize(); + + t.is(getDegradedError(), buildError, + "a failed re-resolve flags the surviving stack degraded with the resolve error"); +}); + +test("a message is emitted with ui5.project-resolve-failed for the degraded status line", async (t) => { + const stack1 = createStack(); + let calls = 0; + const graphFactory = sinon.stub().resolves({}); + const {mocks} = createMocks({ + buildAppImpl: async () => { + calls++; + if (calls === 1) { + return stack1; + } + throw new Error("Cannot read ui5.yaml: no such file"); + } + }); + const {default: Supervisor} = await importSupervisor(mocks); + + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); + // A failed swap self-schedules a recovery timer; destroy() clears it synchronously so it does not + // fire on the shared real clock during other concurrent tests. + t.teardown(() => supervisor.destroy()); + + // Tests in this file run concurrently and share the global `process` emitter, so collect all + // emissions and match this test's unique message rather than grabbing the first event. + const messages = []; + const onResolveFailed = (evt) => { + messages.push(evt?.message); + }; + process.on("ui5.project-resolve-failed", onResolveFailed); + await supervisor.reinitialize(); + process.off("ui5.project-resolve-failed", onResolveFailed); + + t.true(messages.includes("Cannot read ui5.yaml: no such file"), + "the failure message rides along for the console degraded line"); +}); + +test("a successful re-resolve after a failed one clears the degraded flag", async (t) => { + const stack1 = createStack(); + const stack2 = createStack(); + let calls = 0; + // The recovery reinit runs the convergence loop, which traverses the resolved graph, so hand it a + // traversable graph with a stable root set (converges after the second resolve agrees). + const graphFactory = sinon.stub().resolves(createGraph()); + const {mocks, buildApp} = createMocks({ + buildAppImpl: async () => { + calls++; + if (calls === 1) { + return stack1; // initial build + } + if (calls === 2) { + throw new Error("invalid ui5.yaml"); // first re-init fails -> degraded + } + return stack2; // second re-init succeeds -> healthy + } + }); + const {default: Supervisor} = await importSupervisor(mocks); + + const supervisor = await Supervisor.create(createGraph(), baseConfig, undefined, {graphFactory}); + const getDegradedError = buildApp.firstCall.args[3]; + + await supervisor.reinitialize(); + t.truthy(getDegradedError(), "degraded after the failed swap"); + + await supervisor.reinitialize(); + t.is(getDegradedError(), null, "a clean swap lifts the degraded flag"); +}); + +test("a definitionChanging event suspends the current BuildServer's readers", async (t) => { + const stack1 = createStack(); + const graphFactory = sinon.stub().resolves({}); + const {mocks, definitionWatchers} = createMocks({stacks: [stack1]}); + const {default: Supervisor} = await importSupervisor(mocks); + + await Supervisor.create({}, baseConfig, undefined, {graphFactory}); + + // Leading edge of a definition-file burst: suspend the current BuildServer's readers so parked + // requests fail fast instead of hanging out the checkout's source burst. + definitionWatchers[0].emit("definitionChanging", {eventType: "update", filePath: "/app/ui5.yaml"}); + + t.true(stack1.buildServer.suspendReaders.calledOnce, "the current BuildServer's readers are suspended"); + const err = stack1.buildServer.suspendReaders.firstCall.args[0]; + t.is(err?.code, "UI5_DEFINITION_CHANGING", "suspended with a well-formed definition-change error"); + t.is(typeof err?.message, "string"); +}); + +test("definitionChanging suspends the new BuildServer after a swap (re-targeted)", async (t) => { + const stack1 = createStack(); + const stack2 = createStack(); + const graphFactory = sinon.stub().resolves({}); + const {mocks, definitionWatchers} = createMocks({stacks: [stack1, stack2]}); + const {default: Supervisor} = await importSupervisor(mocks); + + await Supervisor.create({}, baseConfig, undefined, {graphFactory}); + + // First change drives the swap to stack2. + definitionWatchers[0].emit("definitionChanged", {eventType: "update", filePath: "/app/ui5.yaml"}); + await new Promise((resolve) => setImmediate(resolve)); + + // The re-attached handler on the new watcher must suspend the NEW stack's BuildServer. + definitionWatchers[1].emit("definitionChanging", {eventType: "update", filePath: "/app/ui5.yaml"}); + t.true(stack2.buildServer.suspendReaders.calledOnce, "the swapped-in BuildServer's readers are suspended"); +}); + +test("a failed swap resumes the surviving BuildServer's readers (degraded gate takes over)", async (t) => { + const stack1 = createStack(); + let calls = 0; + const graphFactory = sinon.stub().resolves({}); + const {mocks} = createMocks({ + buildAppImpl: async () => { + calls++; + if (calls === 1) { + return stack1; // initial build + } + throw new Error("invalid ui5.yaml"); // re-init fails -> degraded + } + }); + const {default: Supervisor} = await importSupervisor(mocks); + + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); + // A failed swap self-schedules a recovery timer; destroy() clears it synchronously so it does not + // fire on the shared real clock during other concurrent tests. + t.teardown(() => supervisor.destroy()); + await supervisor.reinitialize(); + + t.true(stack1.buildServer.resumeReaders.called, + "the surviving BuildServer's readers are resumed once the degraded gate is active"); +}); + +test.serial("a persistently failing swap is auto-retried up to the recovery budget, then stops", async (t) => { + // A failed swap now self-schedules a bounded recovery (no dependence on a second definition event + // or on a string-matched error). RecoveryBudget caps the auto-retries so a deterministically broken + // branch stops cycling: 5 attempts within the window, then it stays degraded until the next change. + const stack1 = createStack(); + let buildCalls = 0; + const graphFactory = sinon.stub().resolves(createGraph()); + const {mocks, buildApp} = createMocks({ + buildAppImpl: async () => { + buildCalls++; + if (buildCalls === 1) { + return stack1; // initial build + } + throw new Error("invalid ui5.yaml"); // every re-init fails + } + }); + const {default: Supervisor} = await importSupervisor(mocks); + const supervisor = await Supervisor.create(createGraph(), baseConfig, undefined, {graphFactory}); + const getDegradedError = buildApp.firstCall.args[3]; + const clock = sinon.useFakeTimers(); + t.teardown(() => { + clock.restore(); + return supervisor.destroy(); + }); + + // First failure flags degraded and schedules the first recovery (budget attempt 1). + await supervisor.reinitialize(); + t.is(buildCalls, 2, "the first reinitialize attempt failed and left the stack degraded"); + t.truthy(getDegradedError(), "degraded after the failed swap"); + + // Drive the self-scheduled recoveries. Each fires after one settle window, fails again, and + // schedules the next until the budget (5 attempts) is spent. The 6th tick finds it exhausted. + for (let i = 0; i < 6; i++) { + await clock.tickAsync(550); + await Promise.resolve(); + } + + // Initial build + failed manual reinit + 5 budgeted recovery swaps (all failing). + t.is(buildCalls, 7, "auto-retries stop once the recovery budget is exhausted"); + t.truthy(getDegradedError(), "the stack stays degraded after the budget is spent"); +}); + +test.serial("degraded recovery re-resolves and settles until the root set converges", async (t) => { + const stack1 = createStack(); + const stack2 = createStack(); + let buildCalls = 0; + const initialGraph = createGraph(["/app"]); + // The recovery convergence loop resolves, settles, re-resolves, and swaps once two consecutive + // resolves agree on the project-root set. Here both recovery resolves see the same root set, so it + // converges after the second resolve. + const recoveryGraph = createGraph(["/app"]); + const graphFactory = sinon.stub(); + graphFactory.onFirstCall().resolves(initialGraph); // failed reinit + graphFactory.onSecondCall().resolves(recoveryGraph); // first recovery resolve + graphFactory.onThirdCall().resolves(recoveryGraph); // second resolve agrees -> converged + let releaseQuiet; + const quiet = new Promise((resolve) => { + releaseQuiet = resolve; + }); + const {mocks, ProjectDefinitionWatcher, waitForProjectGraphQuiet} = createMocks({ + buildAppImpl: async () => { + buildCalls++; + if (buildCalls === 1) { + return stack1; // initial build + } + if (buildCalls === 2) { + throw new Error("invalid ui5.yaml"); + } + return stack2; + } + }); + waitForProjectGraphQuiet.callsFake(() => quiet); + const {default: Supervisor} = await importSupervisor(mocks); + const supervisor = await Supervisor.create(initialGraph, baseConfig, undefined, {graphFactory}); + + await supervisor.reinitialize(); + t.is(buildCalls, 2, "the first reinitialize attempt failed and left the stack degraded"); + t.is(graphFactory.callCount, 1, "the failed attempt resolved the graph once"); + + const recovery = supervisor.reinitialize(); + await waitFor(() => waitForProjectGraphQuiet.called); + t.is(graphFactory.callCount, 2, "recovery resolves a candidate graph immediately"); + t.deepEqual(waitForProjectGraphQuiet.firstCall.args[0], [recoveryGraph, initialGraph], + "the just-resolved candidate and the previous last-good graph are observed for quietness"); + t.like(waitForProjectGraphQuiet.firstCall.args[1], {settleMs: 550}); + t.is(buildCalls, 2, "no replacement stack is built before the candidate graph settles"); + + releaseQuiet(); + await recovery; + t.is(graphFactory.callCount, 3, "the graph is re-resolved after the quiet window to check convergence"); + t.is(buildCalls, 3, "the healthy replacement stack is built once the root set converged"); + t.is(ProjectDefinitionWatcher.create.secondCall.args[0].graph, recoveryGraph, + "the definition watcher is re-targeted to the converged graph"); +}); + +test.serial("degraded recovery observes a target-only root across convergence iterations", async (t) => { + // The recovery gap this closes: a project introduced only by the target branch is unknown to both + // the previous last-good graph and the first candidate resolve. It surfaces on the second resolve + // once its package.json has landed; the loop must then observe it for quietness before swapping. + const stack1 = createStack(); + const stack2 = createStack(); + let buildCalls = 0; + const initialGraph = createGraph(["/repo/app"]); + const earlyGraph = createGraph(["/repo/app"]); // first recovery resolve: target-only dep not yet seen + const targetGraph = createGraph(["/repo/app", "/repo/node_modules/@scope/new"]); // dep now present + const graphFactory = sinon.stub(); + graphFactory.onFirstCall().resolves(initialGraph); // failed reinit + graphFactory.onSecondCall().resolves(earlyGraph); // iteration 1: {app} + graphFactory.onThirdCall().resolves(targetGraph); // iteration 2: {app, newDep} (grew -> settle again) + graphFactory.onCall(3).resolves(targetGraph); // iteration 3: same set -> converged + const quietCalls = []; + const {mocks, waitForProjectGraphQuiet} = createMocks({ + buildAppImpl: async () => { + buildCalls++; + if (buildCalls === 1) { + return stack1; // initial build + } + if (buildCalls === 2) { + throw new Error("invalid ui5.yaml"); + } + return stack2; + } + }); + // Record each settle's observed graphs and resolve immediately so the loop advances on microtasks. + waitForProjectGraphQuiet.callsFake(async (graphs) => { + quietCalls.push(graphs); + }); + const {default: Supervisor} = await importSupervisor(mocks); + const supervisor = await Supervisor.create(initialGraph, baseConfig, undefined, {graphFactory}); + + await supervisor.reinitialize(); + t.is(buildCalls, 2, "the first reinitialize attempt failed and left the stack degraded"); + + await supervisor.reinitialize(); + + // Iteration 1 settled on {app}; iteration 2 saw the target-only root appear and settled again with + // it included; iteration 3 resolved the same set and converged without a further settle. + t.is(quietCalls.length, 2, "the loop settled once per growing root set, then converged"); + const secondObserved = quietCalls[1].map((g) => g.rootPaths); + t.true( + secondObserved.some((roots) => roots.includes("/repo/node_modules/@scope/new")), + "the target-only dependency root is observed for quietness once it surfaces in a resolve"); + t.is(buildCalls, 3, "the converged graph (with the target-only root) is built and swapped in"); + t.is(graphFactory.callCount, 4, "one failed resolve plus three convergence resolves"); +}); + +test("a successful swap resumes the old BuildServer's readers before destroying it", async (t) => { + const stack1 = createStack(); + const stack2 = createStack(); + const graphFactory = sinon.stub().resolves({}); + const {mocks} = createMocks({stacks: [stack1, stack2]}); + const {default: Supervisor} = await importSupervisor(mocks); + + const supervisor = await Supervisor.create({}, baseConfig, undefined, {graphFactory}); + await supervisor.reinitialize(); + + t.true(stack1.buildServer.resumeReaders.calledOnce, "old BuildServer's readers are resumed"); + t.true(stack1.buildServer.resumeReaders.calledBefore(stack1.buildServer.destroy), + "resume happens before destroy"); +}); + test("watcher is re-targeted to the new graph after a swap (old destroyed, new created)", async (t) => { const stack1 = createStack(); const stack2 = createStack(); diff --git a/packages/server/test/lib/server/serve/stack.js b/packages/server/test/lib/server/serve/stack.js index a4fc0bed184..4f8518da322 100644 --- a/packages/server/test/lib/server/serve/stack.js +++ b/packages/server/test/lib/server/serve/stack.js @@ -30,9 +30,14 @@ function createBuildServer() { }; } -async function importBuildRouter(applyMiddleware) { +async function importBuildRouter(applyMiddleware, captured) { return esmock("../../../../lib/serve/stack.js", { "../../../../lib/middleware/MiddlewareManager.js": class MiddlewareManager { + constructor(params) { + if (captured) { + captured.options = params?.options; + } + } applyMiddleware(...args) { return applyMiddleware(...args); } @@ -66,3 +71,40 @@ test("buildRouter() returns the router and BuildServer on success", async (t) => t.is(typeof result.router, "function", "an express router is returned"); t.true(buildServer.destroy.notCalled, "the BuildServer is not destroyed on success"); }); + +test("buildRouter() threads getServeError and getDegradedError as separate options", async (t) => { + const buildServer = createBuildServer(); + const buildServerError = new Error("build error"); + buildServer.getServeError = () => buildServerError; + const graph = createGraph(buildServer); + const applyMiddleware = sinon.stub().resolves(); + const captured = {}; + + const degradedError = new Error("invalid ui5.yaml"); + const getDegradedError = () => degradedError; + + const {buildRouter} = await importBuildRouter(applyMiddleware, captured); + await buildRouter(graph, {}, undefined, getDegradedError); + + t.is(captured.options.getServeError(), buildServerError, + "getServeError reports only the BuildServer's own per-project error"); + t.is(captured.options.getDegradedError(), degradedError, + "getDegradedError carries the supervisor-level error separately"); +}); + +test("buildRouter() getDegradedError is undefined for the embedding path (no supervisor)", async (t) => { + const buildServer = createBuildServer(); + const buildServerError = new Error("build error"); + buildServer.getServeError = () => buildServerError; + const graph = createGraph(buildServer); + const applyMiddleware = sinon.stub().resolves(); + const captured = {}; + + const {buildRouter} = await importBuildRouter(applyMiddleware, captured); + await buildRouter(graph, {}, undefined); + + t.is(captured.options.getServeError(), buildServerError, + "the plain BuildServer error is still available"); + t.is(captured.options.getDegradedError, undefined, + "no degraded accessor is threaded when none was supplied"); +}); From 557007e2495ae891733b068ee1abd2ad9ce6e507 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Tue, 28 Jul 2026 17:48:35 +0200 Subject: [PATCH 30/31] refactor(logger): Report a degraded server state on a failed re-resolve After a failed re-resolve the server keeps serving the last-good graph, but the surviving BuildServer resettles to IDLE ("ready") on the branch switch's source churn. The console then shows green "ready" while the served graph no longer matches disk. Add a sticky degraded flag to the build region, keyed off the re-resolve signals the console already subscribes to: ui5.project-resolve-failed sets it and reuses the existing red ERROR line with the failure message, ui5.project-resolve-succeeded clears it. While degraded, #handleServeStatus suppresses the rest/activity transitions (serve-ready, serve-stale, serve-build-done, serve-settling, serve-building, serve-validating) so the old BuildServer's churn cannot repaint "ready" over the error. A genuine serve-error still passes through. The flag is orthogonal to the activity state, so it needs no new render branch; the ERROR line renders it. --- .../logger/lib/writers/InteractiveConsole.js | 23 +++++++-- .../writers/interactiveConsole/state/build.js | 17 +++++++ .../test/lib/writers/InteractiveConsole.js | 50 +++++++++++++++++++ .../writers/interactiveConsole/state/build.js | 15 ++++++ 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index a398075e444..45ad4fc56d9 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -15,7 +15,7 @@ import { 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, @@ -302,7 +302,7 @@ class InteractiveConsole { this.#onProjectResolved = (evt) => this.#handleProjectResolved(evt); this.#onProjectFrameworkResolved = (evt) => this.#handleProjectFrameworkResolved(evt); this.#onProjectResolving = () => this.#handleProjectResolving(); - this.#onProjectResolveFailed = () => this.#handleProjectResolveFailed(); + this.#onProjectResolveFailed = (evt) => this.#handleProjectResolveFailed(evt); this.#onServerListening = (evt) => this.#handleServerListening(evt); this.#onStopConsole = () => this.disable(); this.#onResize = () => this.#handleResize(); @@ -383,6 +383,9 @@ class InteractiveConsole { // 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(); } @@ -400,9 +403,13 @@ class InteractiveConsole { } // 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…". - #handleProjectResolveFailed() { + // 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(); } @@ -436,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/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/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index c3c526decd5..d8b74d71b33 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -242,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(); 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. From 6fdc32dcd34cd2f4a8ebec3ff5ddec12ce931103 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger <m.beutlberger@sap.com> Date: Tue, 28 Jul 2026 17:48:35 +0200 Subject: [PATCH 31/31] docs(project): Document degraded recovery convergence Capture the ProjectDefinitionWatcher burst model, the ProjectGraphSettler quiet gate, and the Supervisor's degraded-recovery convergence loop: resolve, settle, and re-resolve until the project-root set converges, bounded by RecoveryBudget. --- .claude/skills/incremental-build/architecture.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.claude/skills/incremental-build/architecture.md b/.claude/skills/incremental-build/architecture.md index 1ad440dfbb1..7050f0341a4 100644 --- a/.claude/skills/incremental-build/architecture.md +++ b/.claude/skills/incremental-build/architecture.md @@ -39,7 +39,8 @@ Use this table to locate source files. ALWAYS read the relevant source file befo | `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. 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. Owned by `@ui5/server`'s `Supervisor`, not the BuildServer; exported via the `@ui5/project/graph/ProjectDefinitionWatcher` subpath | +| `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 | @@ -209,16 +210,18 @@ Both watchers share `RecoveryBudget` (loop protection, one budget each), `drainS `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. -- **Include-based model**: subscribes to each distinct directory (deduplicated across projects); the callback drops every event whose resolved path is not in `#watchedFiles`. The `node_modules`/`.git` ignore globs only reduce OS-level watch load; correctness comes from the include set. +- **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 event), used to placeholder the project version during re-resolution. - - `definitionChanged` on the trailing edge after `DEFINITION_CHANGED_SETTLE_MS` (= `WATCHER_BURST_SETTLE_MS`), 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. + - `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