From a9e2dec6355b6e27c12ee75ad453dbc9aa439b9f Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 23 Jul 2026 16:05:05 +0200 Subject: [PATCH 1/5] refactor(logger): Report stale projects separately from server state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server banner collapsed the dev server's lifecycle into one Status line whose `stale` value conflated two orthogonal things: what the server is doing (activity) and which projects are up-to-date. This meant a project that went stale but was never requested held the banner on `stale` forever. Split the two dimensions on the logger side: - Serve logger: replace `stale(changedProjects)` with `stale(staleProjects)`, emitting `serve-stale`. The stale set is reported independently of the activity state, so it can update while the server stays `ready`. - InteractiveConsole: `serve-stale` records the stale set without an activity transition; drop the STALE activity handling. - Build state + render: remove the STALE activity state; the READY line appends a `· N projects stale` count when any project is stale. --- packages/logger/lib/loggers/Serve.js | 5 +++- .../logger/lib/writers/InteractiveConsole.js | 8 ++++--- .../lib/writers/interactiveConsole/render.js | 13 ++++++---- .../writers/interactiveConsole/state/build.js | 11 ++++++--- packages/logger/test/lib/loggers/Serve.js | 23 +++++++++++++++++- .../test/lib/writers/InteractiveConsole.js | 5 ++-- .../lib/writers/interactiveConsole/render.js | 24 +++++++++++++++---- .../writers/interactiveConsole/state/build.js | 18 ++++++++++++++ 8 files changed, 88 insertions(+), 19 deletions(-) diff --git a/packages/logger/lib/loggers/Serve.js b/packages/logger/lib/loggers/Serve.js index edac075bde8..3b269cb282e 100644 --- a/packages/logger/lib/loggers/Serve.js +++ b/packages/logger/lib/loggers/Serve.js @@ -38,12 +38,15 @@ class Serve extends Logger { this.#emitStatus("serve-ready", {}, `Server ready`); } + // Emits the current stale-project set. An empty list means every project is up-to-date. stale(changedProjects) { if (!changedProjects || !Array.isArray(changedProjects)) { throw new Error("loggers/Serve#stale: Missing or incorrect changedProjects parameter"); } this.#emitStatus("serve-stale", {changedProjects}, - `Sources changed in: ${changedProjects.join(", ")}`); + changedProjects.length ? + `Stale projects: ${changedProjects.join(", ")}` : + `All projects up-to-date`); } settling(pendingProjects) { diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index 55e78334f99..7aa0ad9fb40 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -7,7 +7,7 @@ import {createHeaderState, setTool} from "./interactiveConsole/state/header.js"; import {createProjectState, setProject, enableProjectPlaceholders} from "./interactiveConsole/state/project.js"; import {createServerState, setListening, enableServerPlaceholders} from "./interactiveConsole/state/server.js"; import { - createBuildState, beginBuild, advanceToProject, setTask, transitionTo, setError, STATES, + createBuildState, beginBuild, advanceToProject, setTask, transitionTo, setError, setStale, STATES, SPINNING_STATES, enableBuildPlaceholders, } from "./interactiveConsole/state/build.js"; import { @@ -346,8 +346,10 @@ class InteractiveConsole { this.#transitionTo(STATES.READY); break; case "serve-stale": - this.#buildState.changedProjects = evt.changedProjects || []; - this.#transitionTo(STATES.STALE); + // Record the stale set and re-render without a state transition, so the count updates + // in place beneath the current status (typically `ready`). + setStale(this.#buildState, evt.changedProjects); + this.#render(); break; case "serve-settling": this.#buildState.pendingProjects = evt.pendingProjects || []; diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js index b189ba617bf..14e1c866ff4 100644 --- a/packages/logger/lib/writers/interactiveConsole/render.js +++ b/packages/logger/lib/writers/interactiveConsole/render.js @@ -8,6 +8,8 @@ import {REMOTE_CONNECTIONS_WARNING_LINES} from "./remoteConnectionsWarning.js"; const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"]; const STATE_LABEL_WIDTH = "building".length; const pad = (s) => s.padEnd(STATE_LABEL_WIDTH); +// "1 project" / "2 projects" - shared by the stale (ready) and validating counts. +const projectCount = (n) => `${n} project${n === 1 ? "" : "s"}`; // Align continuation lines (additional network URLs / extra placeholders) // under the first URL slot. The arrow, single space, and "Network: " label @@ -145,11 +147,14 @@ function renderStatusLine(state) { if (state.lastBuildHrtime) { suffix = ` ${chalk.dim("·")} ${chalk.dim("Time elapsed: " + prettyHrtime(state.lastBuildHrtime))}`; } + // Append the stale count to the ready line. + const staleCount = state.changedProjects.length; + if (staleCount > 0) { + suffix += ` ${chalk.dim("·")} ` + + chalk.yellow(`${projectCount(staleCount)} stale`); + } return `${label}${chalk.green(figures.bullet)} ${chalk.green(pad("ready"))}${suffix}`; } - case STATES.STALE: - return `${label}${chalk.yellow(figures.circle)} ${chalk.yellow(pad("stale"))} ` + - `${chalk.dim("· files changed, rebuild on next request")}`; case STATES.SETTLING: { const frame = SPINNER_FRAMES[state.spinFrame % SPINNER_FRAMES.length]; return `${label}${chalk.yellow(frame)} ${chalk.yellow(pad("settling"))} ` + @@ -164,7 +169,7 @@ function renderStatusLine(state) { ]; const count = state.validatingProjects.length; if (count > 0) { - parts.push(chalk.dim("·"), chalk.dim(`${count} project${count === 1 ? "" : "s"}`)); + parts.push(chalk.dim("·"), chalk.dim(projectCount(count))); } return `${label}${parts.join(" ")}`; } diff --git a/packages/logger/lib/writers/interactiveConsole/state/build.js b/packages/logger/lib/writers/interactiveConsole/state/build.js index 51b504112c2..65675506023 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/build.js +++ b/packages/logger/lib/writers/interactiveConsole/state/build.js @@ -5,7 +5,6 @@ export const STATES = Object.freeze({ INITIAL: "initial", STARTING: "starting", // pre-populated placeholder before the first real state arrives READY: "ready", - STALE: "stale", SETTLING: "settling", // changes seen, rebuild deferred until they quiesce BUILDING: "building", VALIDATING: "validating", @@ -26,8 +25,8 @@ export function createBuildState() { totalProjects: 0, currentProjectName: "", currentTaskName: "", - // Names of projects collected via `serve-stale` payloads — used to label - // the stale state if/when the renderer wants to. + // Names of projects currently stale, reported via `serve-stale`. The renderer surfaces + // the count alongside the ready line. changedProjects: [], // Names of projects collected via `serve-validating` payloads — used to // label the validating state if/when the renderer wants to. @@ -92,6 +91,12 @@ export function transitionTo(state, newState) { state.spinFrame = 0; } +// Record the current stale-project set, reported via `serve-stale`. Does not touch the +// activity `state` or reset the spinner: the count updates in place beneath the current status. +export function setStale(state, changedProjects) { + state.changedProjects = Array.isArray(changedProjects) ? changedProjects : []; +} + export function setError(state, message) { state.errorMessage = message || ""; transitionTo(state, STATES.ERROR); diff --git a/packages/logger/test/lib/loggers/Serve.js b/packages/logger/test/lib/loggers/Serve.js index 2c6fcebcf27..9c9ce7cf1d6 100644 --- a/packages/logger/test/lib/loggers/Serve.js +++ b/packages/logger/test/lib/loggers/Serve.js @@ -47,6 +47,17 @@ test.serial("stale emits serve-stale", (t) => { }, "Status event has expected payload"); }); +test.serial("stale emits serve-stale with an empty set", (t) => { + const {serveLogger, statusHandler} = t.context; + serveLogger.stale([]); + t.is(statusHandler.callCount, 1, "One serve-status event emitted"); + t.deepEqual(statusHandler.getCall(0).args[0], { + level: "info", + status: "serve-stale", + changedProjects: [], + }, "Status event has expected payload"); +}); + test.serial("stale: Missing parameter", (t) => { const {serveLogger} = t.context; t.throws(() => { @@ -164,11 +175,21 @@ test.serial("No event listener: stale", (t) => { t.is(logStub.callCount, 1, "_log got called once"); t.is(logStub.getCall(0).args[0], "info", "Logged with expected log-level"); t.is(logStub.getCall(0).args[1], - "Sources changed in: project.a, project.b", + "Stale projects: project.a, project.b", "Logged expected message"); t.is(logHandler.callCount, 0, "No log event emitted"); }); +test.serial("No event listener: stale with an empty set", (t) => { + const {serveLogger, statusHandler, logHandler, logStub} = t.context; + process.off(ServeLogger.SERVE_STATUS_EVENT_NAME, statusHandler); + serveLogger.stale([]); + t.is(logStub.callCount, 1, "_log got called once"); + t.is(logStub.getCall(0).args[0], "info", "Logged with expected log-level"); + t.is(logStub.getCall(0).args[1], "All projects up-to-date", "Logged expected message"); + t.is(logHandler.callCount, 0, "No log event emitted"); +}); + test.serial("No event listener: validating", (t) => { const {serveLogger, statusHandler, logHandler, logStub} = t.context; process.off(ServeLogger.SERVE_STATUS_EVENT_NAME, statusHandler); diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index 936a9dcbfa3..319c950a669 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -554,11 +554,12 @@ test.serial("serve-status: serve-build-done without a valid hrtime records null" writer.disable(); }); -test.serial("serve-status: serve-stale records changedProjects and transitions to STALE", (t) => { +test.serial("serve-status: serve-stale records changedProjects without a state transition", (t) => { const {writer} = createWriter(); + process.emit("ui5.serve-status", {status: "serve-ready"}); process.emit("ui5.serve-status", {status: "serve-stale", changedProjects: ["my.app"]}); const state = writer._getStateForTest().build; - t.is(state.state, STATES.STALE); + t.is(state.state, STATES.READY, "the stale report does not change the activity state"); t.deepEqual(state.changedProjects, ["my.app"]); writer.disable(); }); diff --git a/packages/logger/test/lib/writers/interactiveConsole/render.js b/packages/logger/test/lib/writers/interactiveConsole/render.js index 2bf541e1102..1f809bd82c0 100644 --- a/packages/logger/test/lib/writers/interactiveConsole/render.js +++ b/packages/logger/test/lib/writers/interactiveConsole/render.js @@ -246,11 +246,27 @@ test("renderBuildRegion: ready state with hrtime shows elapsed suffix", (t) => { t.regex(plain, /ready.*Time elapsed:/); }); -test("renderBuildRegion: stale state includes 'files changed' hint", (t) => { +test("renderBuildRegion: ready state appends a singular stale count", (t) => { const state = createBuildState(); - transitionTo(state, STATES.STALE); + state.changedProjects = ["library.a"]; + transitionTo(state, STATES.READY); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /ready.*·\s+1 project(?!s) stale/); +}); + +test("renderBuildRegion: ready state appends a plural stale count", (t) => { + const state = createBuildState(); + state.changedProjects = ["library.a", "library.b"]; + transitionTo(state, STATES.READY); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /ready.*·\s+2 projects stale/); +}); + +test("renderBuildRegion: ready state omits the stale count when everything is fresh", (t) => { + const state = createBuildState(); + transitionTo(state, STATES.READY); const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); - t.regex(plain, /Status\s+.+?\s+stale\s+·\s+files changed/); + t.notRegex(plain, /stale/, "no stale fragment when changedProjects is empty"); }); test("renderBuildRegion: settling state includes 'waiting for changes to settle' hint", (t) => { @@ -382,7 +398,6 @@ test("renderBuildRegion: building state renders bare project/task names when no const GLYPH_BY_STATE = [ {state: STATES.STARTING, glyph: figures.circle, name: "circle"}, {state: STATES.READY, glyph: figures.bullet, name: "bullet"}, - {state: STATES.STALE, glyph: figures.circle, name: "circle"}, {state: STATES.ERROR, glyph: figures.cross, name: "cross"}, ]; for (const {state: s, glyph, name} of GLYPH_BY_STATE) { @@ -404,7 +419,6 @@ const WIDTH = "building".length; const COLOR_BY_STATE = [ {state: STATES.STARTING, wrap: (x) => chalk.dim(x), word: "starting", name: "dim"}, {state: STATES.READY, wrap: (x) => chalk.green(x), word: "ready", name: "green"}, - {state: STATES.STALE, wrap: (x) => chalk.yellow(x), word: "stale", name: "yellow"}, {state: STATES.BUILDING, wrap: (x) => chalk.yellow(x), word: "building", name: "yellow"}, {state: STATES.VALIDATING, wrap: (x) => chalk.cyan(x), word: "validating cache", name: "cyan"}, {state: STATES.ERROR, wrap: (x) => chalk.red(x), word: "error", name: "red"}, diff --git a/packages/logger/test/lib/writers/interactiveConsole/state/build.js b/packages/logger/test/lib/writers/interactiveConsole/state/build.js index 62598ccc30b..1605b671ae5 100644 --- a/packages/logger/test/lib/writers/interactiveConsole/state/build.js +++ b/packages/logger/test/lib/writers/interactiveConsole/state/build.js @@ -7,6 +7,7 @@ import { setTask, transitionTo, setError, + setStale, enableBuildPlaceholders, STATES, } from "../../../../../lib/writers/interactiveConsole/state/build.js"; @@ -106,6 +107,23 @@ test("setError: falsy message resolves to an empty string", (t) => { t.is(state.errorMessage, ""); }); +test("setStale: records the stale set without touching the activity state", (t) => { + const state = createBuildState(); + transitionTo(state, STATES.READY); + state.spinFrame = 3; + setStale(state, ["library.a", "library.b"]); + t.deepEqual(state.changedProjects, ["library.a", "library.b"]); + t.is(state.state, STATES.READY, "activity state is unchanged"); + t.is(state.spinFrame, 3, "spinner frame is not reset"); +}); + +test("setStale: a non-array resolves to an empty set", (t) => { + const state = createBuildState(); + state.changedProjects = ["stale"]; + setStale(state, undefined); + t.deepEqual(state.changedProjects, []); +}); + test("enableBuildPlaceholders: promotes INITIAL to STARTING", (t) => { const state = createBuildState(); enableBuildPlaceholders(state); From 8def551d34978b949f725428b4b14e4b57c1ea63 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 23 Jul 2026 16:06:34 +0200 Subject: [PATCH 2/5] refactor(project): Split server activity state from stale-project tracking BuildServer exposed a single `#serverState` scalar and derived it partly by aggregating per-project build status via `#getStaleProjectNames()`: any non-fresh project (including a dependency that is merely INITIAL, never requested, with a stale cache) forced the state to STALE. Under lazy building nobody rebuilds an unrequested project, so the server wedged on `stale` and never returned to `ready`, even though every resource was still servable on request. Separate the two dimensions: - Activity (`#serverState`): remove STALE. The resting state is IDLE ("ready"); SETTLING stays (it is tied to the file-watcher settle windows). - Stale set: `#emitStale()` reports the stale-project set to the ServeLogger, de-duplicated against the last set. Called from the reconcile close, the eager source-change path, watcher recovery, and the no-initial-build startup. `#reconcileServerState` no longer falls back to STALE: fully-fresh and stale-remaining both resolve to IDLE plus a stale-set report; background validation still runs first when INITIAL projects may be cache-clean. Build triggering is unchanged; only how state is presented. --- packages/project/lib/build/BuildServer.js | 141 +++++++++++------- .../project/test/lib/build/BuildServer.js | 102 +++++++------ 2 files changed, 145 insertions(+), 98 deletions(-) diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 9fc1c5240f2..6b0fb23db72 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -56,11 +56,13 @@ const ABORTED_BUILD_RESTART_SETTLE_MS = 550; const WATCHER_RECOVERY_MAX_ATTEMPTS = 5; const WATCHER_RECOVERY_WINDOW_MS = 60000; -// The server's lifecycle state. Mutated exclusively through #setState. -// Ordering intent: IDLE -> STALE -> SETTLING -> BUILDING. +// 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 +// remain stale; those rebuild on their next reader request and never force a non-IDLE activity. +// Ordering intent: IDLE -> SETTLING -> BUILDING. const SERVER_STATES = Object.freeze({ - IDLE: "idle", // No pending requests, no recent changes, no unvalidated caches. - STALE: "stale", // Pending changes / pending requests, queue not yet flushed. + IDLE: "idle", // No build active, nothing pending. Rendered as "ready". SETTLING: "settling", // Rebuild pending, deferred until changes quiesce. No build is active // (#activeBuild === null); the deferred timer moves it to BUILDING. BUILDING: "building", // A build is in flight. @@ -121,9 +123,12 @@ class BuildServer extends EventEmitter { #rootReader; #dependenciesReader; #serveLogger = new ServeLogger("build:BuildServer"); - // Server lifecycle state. Starts as `null` so the first #setState call + // Server lifecycle activity state. Starts as `null` so the first #setState call // always emits the initial state. #serverState = null; + // The stale set most recently reported via #emitStale, serialized to a stable key (sorted + // names joined), so repeated emits of an unchanged set are dropped. `null` until the first emit. + #lastStaleKey = null; // The error captured on the last transition to ERROR, or null in any other state. // Exposed via getServeError() so the dev server can surface it on HTML navigations // regardless of which resource was requested. Cleared on every non-ERROR transition @@ -231,7 +236,10 @@ class BuildServer extends EventEmitter { } } if (this.#pendingBuildRequest.size === 0) { - // No initial build was requested. Signal "idle" / "ready" right away. + // No initial build was requested. Signal "idle" / "ready" right away. Don't report the + // stale set yet: every project is still INITIAL, and counting those as stale would be + // wrong for an up-to-date cache. The first reconcile after a reader request validates + // them and reports the real set. this.#setState(SERVER_STATES.IDLE); } } @@ -346,9 +354,11 @@ class BuildServer extends EventEmitter { this.#watcherRecoveryTimestamps.push(Date.now()); log.info(`File watcher recovered. Re-scanning all project sources.`); - // Every project is now non-fresh. Surface STALE and prompt connected clients to - // reload, which drives the lazy rebuild against the re-scanned index. - this.#setState(SERVER_STATES.STALE); + // Every project is now non-fresh. The build was quiesced above, so the server is at rest: + // report IDLE ("ready") plus the new stale set, and prompt connected clients to reload, + // which drives the lazy rebuild against the re-scanned index. + this.#setState(SERVER_STATES.IDLE); + this.#emitStale(); this.emit("sourcesChanged"); } catch (recoveryErr) { // Recreation itself failed (e.g. watch() rejected). The watcher is genuinely broken; @@ -581,18 +591,27 @@ class BuildServer extends EventEmitter { this.#resourceChangeQueue.set(project.getName(), new Set([filePath])); } - // Surface the new STALE state right away from any "quiet" state. - // IDLE and ERROR are both quiet; ERROR is sticky, but a change lifts it because the input - // tree has moved and the previous failure isn't the final word anymore. VALIDATING is quiet - // in the same sense: the change is already registered here, so reporting VALIDATING any - // longer would mislead consumers. The validation pass's finally clause guards on - // `#serverState === VALIDATING` and bails when the state has moved on, so there's no - // double-transition risk. BUILDING already implies progress, so don't disturb it. + // A source change moves project staleness: the affected project and its dependents are now + // stale. Report the new stale set right away, independently of the activity state: a change + // from a quiet IDLE must not force a non-IDLE activity (the project rebuilds lazily on its next + // reader request, per the lazy-build contract). + // + // From the quiet states, settle activity on IDLE ("ready") first: ERROR is sticky and the + // invalidate() calls above lifted the per-project gate (input tree moved), so the server-level + // error must clear too: #setState(IDLE) does that via #serveError. VALIDATING is being aborted + // for the changed project (its per-project signal fired in invalidate()); dropping to IDLE + // reports the rest state promptly rather than leaving a stale "validating" until the pass + // settles. #setState(IDLE) is a no-op from IDLE, so a lone edit doesn't re-emit ready(). + // BUILDING and SETTLING own their own transitions, so leave them be. if (this.#serverState === SERVER_STATES.IDLE || this.#serverState === SERVER_STATES.VALIDATING || this.#serverState === SERVER_STATES.ERROR) { - this.#setState(SERVER_STATES.STALE); + this.#setState(SERVER_STATES.IDLE); } + // Sweep the stale set once and reuse it below: nothing mutates #projectBuildStatus between + // here and the SETTLING transition, so the same set feeds both #emitStale and settling(). + const staleProjects = this.#getStaleProjectNames(); + this.#emitStale(staleProjects); // Reschedule a pending post-abort/transient restart so its settle window measures quiet // from this change, not from the abort. Only fires while a restart is waiting (no active @@ -607,7 +626,7 @@ class BuildServer extends EventEmitter { // of firing into a half-written one. Laziness is preserved: with nothing queued, a change // still waits for a reader request rather than provoking a speculative build. A later // reader request supersedes the settle by re-arming at the short debounce. - this.#setState(SERVER_STATES.SETTLING); + this.#setState(SERVER_STATES.SETTLING, {pendingProjects: staleProjects}); this.#triggerRequestQueue(FIRST_BUILD_SETTLE_MS); } @@ -894,6 +913,25 @@ class BuildServer extends EventEmitter { return stale; } + /** + * Reports the current stale-project set to the ServeLogger, independently of the activity + * state. Which projects are stale is orthogonal to activity (what the server is + * doing): the server can be IDLE ("ready") while some lazily-built projects remain stale. + * + * De-duplicated against the last reported set (#lastStaleKey) so repeated calls + * from the several emission sites (reconcile, eager source-change, watcher recovery) don't spam + * the banner with an unchanged signal. + */ + #emitStale() { + const staleProjects = this.#getStaleProjectNames(); + const key = staleProjects.slice().sort().join("\n"); + if (key === this.#lastStaleKey) { + return; + } + this.#lastStaleKey = key; + this.#serveLogger.stale(staleProjects); + } + /** * Aborts the in-flight background validation pass (if any) and awaits its settlement. * Swallows the resulting abort rejection - callers use this to make room for a build @@ -915,9 +953,9 @@ class BuildServer extends EventEmitter { } /** - * Single point of truth for the terminal state at the end of a producer cycle + * Single point of truth for the terminal ACTIVITY state at the end of a producer cycle * (build cycle, background validation pass). Each producer that used to open-code - * an IDLE-vs-STALE-vs-VALIDATING guard now calls this after its own work settles; + * an IDLE-vs-VALIDATING guard now calls this after its own work settles; * the reconciler picks the target state from the current fields (#activeBuild, * #pendingBuildRequest, #serverState, the stale set) rather than * trusting each producer to check them. @@ -930,19 +968,22 @@ class BuildServer extends EventEmitter { * - #serverState === SETTLING: a deferred restart is armed; the timer owns * the SETTLING -> BUILDING transition. Every SETTLING entry leaves * #pendingBuildRequest non-empty (so the check below already bails), but the - * explicit guard keeps a stray reconcile from flipping SETTLING to IDLE/STALE should that + * explicit guard keeps a stray reconcile from flipping SETTLING to IDLE should that * invariant ever break. * - #activeBuild || #pendingBuildRequest.size > 0: a build cycle owns * the next transition. Notably fires when this call comes from the post-validation * finally and releaseValidating's onBuildRequired callback * just enqueued a build for a project with pending readers. * - * Otherwise: fully-fresh -> IDLE. Any stale projects -> try - * {@link #scheduleBackgroundValidation} when mayValidate is true and - * fall back to STALE. When called from the post-validation finally - * (mayValidate=false), goes straight to STALE, since the pass just settled - * on those projects, so re-scheduling would be pointless and would recurse this - * finally into a new pass. + * Otherwise the server is at rest: activity -> IDLE ("ready"), and the stale-project set is + * reported separately via {@link #emitStale}. Latent staleness does NOT force a non-IDLE + * activity: an unrequested stale project rebuilds lazily on its next reader request, so holding + * the server out of "ready" for it would wedge the state (nobody rebuilds it, so it never clears). + * When mayValidate is true and any project is merely INITIAL (cache validity unknown + * but possibly fresh), {@link #scheduleBackgroundValidation} runs first to promote cache-clean + * projects to FRESH before the stale count is taken. The post-validation finally passes + * mayValidate=false so it does not recurse into a new pass over the projects it just + * released. * * @param {object} [opts] * @param {Array} [opts.hrtime] Build duration to forward to @@ -962,18 +1003,16 @@ class BuildServer extends EventEmitter { const staleProjects = this.#getStaleProjectNames(); log.verbose(`Reconciling server state. Stale projects: ` + `${staleProjects.length} (${staleProjects.join(", ")})`); - if (staleProjects.length === 0) { - this.#setState(SERVER_STATES.IDLE, {hrtime}); + // Some projects may be merely INITIAL (cache validity unknown but possibly fresh). Validate + // them in the background first so cache-clean ones become FRESH before the count is reported; + // the transition to VALIDATING happens inside #scheduleBackgroundValidation. + if (staleProjects.length > 0 && mayValidate && this.#scheduleBackgroundValidation({hrtime})) { return; } - // Some projects are still non-FRESH. Try to validate any that are merely - // INITIAL (cache validity unknown but possibly fresh) in the background. - // If a pass actually starts, the transition to VALIDATING happens inside - // #scheduleBackgroundValidation; otherwise we fall back to STALE. - if (mayValidate && this.#scheduleBackgroundValidation({hrtime})) { - return; - } - this.#setState(SERVER_STATES.STALE, {hrtime, staleProjects}); + // The server is at rest. Activity -> IDLE ("ready"); the stale set (which projects remain + // stale) is reported separately and does not gate "ready". + this.#setState(SERVER_STATES.IDLE, {hrtime}); + this.#emitStale(); } /** @@ -982,9 +1021,9 @@ class BuildServer extends EventEmitter { * #activeValidation so {@link #triggerRequestQueue} and {@link #destroy} can * abort it. * - * Drives the server lifecycle through VALIDATING -> IDLE/STALE on completion. Caller - * supplies the post-build hrtime so the BUILDING -> VALIDATING transition can emit a - * buildDone event with the correct duration. + * Drives the server activity state through VALIDATING -> IDLE on completion (the stale set is + * reported separately). Caller supplies the post-build hrtime so the BUILDING -> VALIDATING + * transition can emit a buildDone event with the correct duration. * * Idempotent while a validation pass is already in flight. Skipped while a build is * active; the post-build cycle-end hook will schedule the next pass. @@ -993,7 +1032,7 @@ class BuildServer extends EventEmitter { * @param {Array} [opts.hrtime] Build hrtime to forward to the VALIDATING state * transition. Only relevant when called from a post-build cycle-end hook. * @returns {boolean} True if a validation pass was actually scheduled, false otherwise. - * When false, callers may want to transition the server to STALE explicitly. + * When false, the reconciler settles the server on IDLE and reports the stale set itself. */ #scheduleBackgroundValidation({hrtime} = {}) { if (this.#destroyed || this.#activeValidation || this.#activeBuild || this.#recoveringWatcher) { @@ -1029,7 +1068,7 @@ class BuildServer extends EventEmitter { // Non-abort failure: mirror the build error path so consumers (the banner, // integration tests, the yargs fail-handler) can react. The ERROR transition // here doubles as the signal to the finally clause to skip its post-pass - // IDLE/STALE transition. + // IDLE transition. this.#setState(SERVER_STATES.ERROR, {error: err}); this.emit("error", err); }) @@ -1092,40 +1131,39 @@ class BuildServer extends EventEmitter { } /** - * Single source of truth for the server lifecycle state. Mutates + * Single source of truth for the server ACTIVITY state. Mutates * #serverState and emits the matching ServeLogger event for the * transition. A no-op when next equals the current state, so a - * burst of source changes collapses into one IDLE->STALE emission. + * burst of activity transitions collapses into one emission. Project staleness + * is reported separately via {@link #emitStale} and is not routed through here. * * Transition policy: *
    *
  • BUILDING -> IDLE: buildDone(hrtime) then ready()
  • - *
  • BUILDING -> STALE: buildDone(hrtime) then stale(names)
  • *
  • BUILDING -> VALIDATING: buildDone(hrtime) then validating(names)
  • *
  • BUILDING -> SETTLING: settling(names) only - no buildDone, since no successful * cycle closed (mirrors the BUILDING -> ERROR carve-out)
  • *
  • BUILDING -> ERROR: serveError(err) only - buildDone is skipped so * consumers don't see a successful cycle close before the error
  • - *
  • VALIDATING -> IDLE/STALE: ready()/stale() - no buildDone (no build happened)
  • + *
  • VALIDATING -> IDLE: ready() - no buildDone (no build happened)
  • *
  • * -> SETTLING: settling(names)
  • *
  • SETTLING -> BUILDING: building()
  • *
  • any -> ERROR: serveError(err)
  • - *
  • * -> IDLE/STALE/BUILDING/VALIDATING: ready()/stale()/building()/validating() as appropriate
  • + *
  • * -> IDLE/BUILDING/VALIDATING: ready()/building()/validating() as appropriate
  • *
* * @param {string} next One of the SERVER_STATES values. * @param {object} [opts] * @param {Array} [opts.hrtime] Build duration as a [seconds, nanoseconds] * tuple produced by process.hrtime(start); required when leaving - * BUILDING for IDLE/STALE/VALIDATING. - * @param {string[]} [opts.staleProjects] Stale project names; required when transitioning to STALE. + * BUILDING for IDLE/VALIDATING. * @param {string[]} [opts.pendingProjects] Names of projects awaiting the deferred rebuild; * used when transitioning to SETTLING. Defaults to the stale project set. * @param {string[]} [opts.validatingProjects] Names of projects undergoing background cache * validation; required when transitioning to VALIDATING. * @param {Error} [opts.error] Error instance; required when transitioning to ERROR. */ - #setState(next, {hrtime, staleProjects, pendingProjects, validatingProjects, error} = {}) { + #setState(next, {hrtime, pendingProjects, validatingProjects, error} = {}) { if (this.#serverState === next) { return; } @@ -1145,9 +1183,6 @@ class BuildServer extends EventEmitter { case SERVER_STATES.IDLE: this.#serveLogger.ready(); break; - case SERVER_STATES.STALE: - this.#serveLogger.stale(staleProjects ?? this.#getStaleProjectNames()); - break; case SERVER_STATES.SETTLING: this.#serveLogger.settling(pendingProjects ?? this.#getStaleProjectNames()); break; diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index eb0bc41dfbb..69fc8801ea8 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -198,23 +198,22 @@ test.serial("serve-status: serve-ready emitted when no initial build is requeste t.is(readyCalls.length, 1, "serve-ready emitted once when no initial build is enqueued"); }); -test.serial("serve-status: serve-stale emitted on sources change", (t) => { - const {buildServer, rootProject, clock, sinon, SOURCES_CHANGED_SETTLE_MS} = t.context; - const statusHandler = sinon.stub(); - process.on("ui5.serve-status", statusHandler); - t.teardown(() => process.off("ui5.serve-status", statusHandler)); +test.serial("serve-status: serve-stale reports the stale set on sources change", async (t) => { + const {rootProject, clock, SOURCES_CHANGED_SETTLE_MS} = t.context; + // Build root to FRESH first, so the change below actually moves the stale set ([] -> [root.project]) + // rather than being a no-op on an already-stale INITIAL project. + const {statusEvents} = await runInitialBuildCycle(t); + const {buildServer} = t.context; buildServer._projectResourceChanged(rootProject, "/foo.js", false); clock.tick(SOURCES_CHANGED_SETTLE_MS); - const staleCalls = statusHandler.getCalls() - .filter((c) => c.args[0]?.status === "serve-stale"); - t.is(staleCalls.length, 1, "serve-stale emitted exactly once for one window"); - t.deepEqual(staleCalls[0].args[0], { - level: "info", - status: "serve-stale", - changedProjects: ["root.project"], - }, "serve-stale event has expected payload"); + const staleCalls = statusEvents.filter((e) => e.status === "serve-stale"); + // The last serve-stale emit reports the now-stale project. (An initial "all up-to-date" emit may + // precede it from the build cycle close.) + const last = staleCalls.at(-1); + t.deepEqual(last.changedProjects, ["root.project"], + "serve-stale reports the now-stale project"); }); // Helper: drive a full build cycle through the 10ms request-queue debounce. @@ -266,12 +265,10 @@ test.serial("serve-status: initial build cycle emits building → buildDone → const idxReady = seq.indexOf("serve-ready"); t.true(idxBuilding >= 0 && idxBuildDone > idxBuilding && idxReady > idxBuildDone, `Expected building → buildDone → ready, got: ${seq.join(", ")}`); - t.false(seq.slice(idxBuilding, idxReady + 1).includes("serve-stale"), - "no intermediate stale emission during the cycle"); }); test.serial( - "serve-status: source change mid-build emits settling at cycle end (not stale)", async (t) => { + "serve-status: source change mid-build settles activity on SETTLING at cycle end", async (t) => { const {BuildServer, graph, projectBuilder, rootProject, sinon, clock} = t.context; const statusEvents = makeStatusRecorder(t); @@ -299,14 +296,14 @@ test.serial( await clock.tickAsync(0); const seq = statusEvents.map((e) => e.status); - // The aborted cycle defers its restart, so the end-of-cycle state is SETTLING - // (rebuild pending, held until changes quiesce) rather than STALE. No buildDone - // is emitted on BUILDING → SETTLING — no successful cycle closed. + // The aborted cycle defers its restart, so the terminal activity state is SETTLING + // (rebuild pending, held until changes quiesce), not IDLE ("ready"). No buildDone + // is emitted on BUILDING → SETTLING — no successful cycle closed. The mid-build change + // reports the now-stale project via serve-stale, but that rides the stale-set channel and + // does not gate the terminal activity. const settlingCalls = seq.filter((s) => s === "serve-settling"); t.is(settlingCalls.length, 1, `Expected exactly one settling emission at cycle end, got sequence: ${seq.join(", ")}`); - t.false(seq.includes("serve-stale"), - `No stale emission on the aborted cycle; got: ${seq.join(", ")}`); t.false(seq.includes("serve-build-done"), `No buildDone on BUILDING → SETTLING; got: ${seq.join(", ")}`); t.is(seq[seq.length - 1], "serve-settling", @@ -624,9 +621,9 @@ test.serial("serve-status: build failure emits serveError and no orphan building const lastBuildingIdx = seq.lastIndexOf("serve-building"); const errorIdx = seq.indexOf("serve-error"); t.true(errorIdx > lastBuildingIdx, "serve-error follows serve-building"); - // The error must remain the terminal state of the cycle — no serve-stale or - // serve-ready may follow it, as that would clear the red banner while the - // project is still gated on its captured error. + // The error must remain the terminal activity state of the cycle — no serve-ready may + // follow it, as that would clear the red banner while the project is still gated on its + // captured error. t.is(seq[seq.length - 1], "serve-error", "serve-error is the final status of a failed cycle"); }); @@ -725,21 +722,18 @@ test.serial( const validatingEvt = statusEvents[idxValidating]; t.deepEqual(validatingEvt.validatingProjects, ["library.x"], "validating event carries the project being validated"); - - // No STALE in between — VALIDATING is the post-build state when caches can still be fresh. - t.false(seq.slice(idxBuildDone, idxReady).includes("serve-stale"), - `No stale emission between buildDone and ready; got: ${seq.join(", ")}`); }); // When background validation finds a stale cache (usesCache=false), the project stays -// INITIAL and the validation pass must end on STALE, not IDLE. +// INITIAL and the validation pass must end on IDLE ("ready") with the project reported stale via +// serve-stale: the server can still serve everything; library.x rebuilds on its next request. test.serial( - "serve-status: VALIDATING → STALE when validation finds a stale cache", async (t) => { + "serve-status: VALIDATING -> ready + serve-stale when validation finds a stale cache", async (t) => { const {BuildServer, sinon, clock} = t.context; const {graph} = makeGraphWithLib(); - // usesCache=false → project must stay INITIAL → final state is STALE. + // usesCache=false -> project stays INITIAL -> reported stale via serve-stale, activity ready. const projectBuilder = { closeCacheManager: sinon.stub(), resourcesChanged: sinon.stub(), @@ -765,10 +759,11 @@ test.serial( const seq = statusEvents.map((e) => e.status); t.true(seq.includes("serve-validating"), `validating emitted; got: ${seq.join(", ")}`); - t.true(seq.indexOf("serve-stale") > seq.indexOf("serve-validating"), - `stale follows validating; got: ${seq.join(", ")}`); - t.false(seq.includes("serve-ready"), - `No ready when validation found stale cache; got: ${seq.join(", ")}`); + t.true(seq.lastIndexOf("serve-ready") > seq.indexOf("serve-validating"), + `ready follows validating; got: ${seq.join(", ")}`); + const stale = statusEvents.filter((e) => e.status === "serve-stale").at(-1); + t.deepEqual(stale.changedProjects, ["library.x"], + "the still-stale dependency is reported via serve-stale"); }); // When validateCaches itself rejects with a non-abort error, the failure must be @@ -823,12 +818,14 @@ test.serial( `validating emitted before the failure; got: ${seq.join(", ")}`); t.true(seq.indexOf("serve-error") > seq.indexOf("serve-validating"), `serve-error follows serve-validating; got: ${seq.join(", ")}`); - // Crucial: a failed validation must NOT settle on ready or stale. + // Crucial: a failed validation is terminal on ERROR. It must neither transition to ready + // (which would clear the red banner) nor emit an orthogonal stale report (the post-pass + // finally skips its rest-state reporting once the pass has errored). const lastError = seq.lastIndexOf("serve-error"); t.is(seq.indexOf("serve-ready", lastError + 1), -1, `No ready after a failed validation; got: ${seq.join(", ")}`); t.is(seq.indexOf("serve-stale", lastError + 1), -1, - `No stale after a failed validation; got: ${seq.join(", ")}`); + `No stale report after a failed validation; got: ${seq.join(", ")}`); }); // A reader request issued while a project is in VALIDATING must NOT enqueue a build @@ -1026,9 +1023,9 @@ test.serial( }); // When a source change lands during a validation pass, the server must transition -// VALIDATING → STALE right away rather than waiting for the pass to finish. +// VALIDATING -> ready + serve-stale right away rather than waiting for the pass to finish. test.serial( - "serve-status: source change during VALIDATING transitions to STALE eagerly", async (t) => { + "serve-status: source change during VALIDATING reports the stale set eagerly", async (t) => { const {BuildServer, sinon, clock} = t.context; const rootProject = {getName: () => "root.project"}; @@ -1073,15 +1070,22 @@ test.serial( // Source change on a project currently in the validation set. This invalidates // the project (firing its per-project abort signal which the validation pass is - // composing with) AND triggers the new VALIDATING → STALE branch in - // _projectResourceChanged. + // composing with) AND drops activity out of VALIDATING while reporting the new stale set + // via _projectResourceChanged. buildServer._projectResourceChanged(libProject, "/x.js", false); - // STALE must land synchronously — before the validation pass is even released. + // The stale report must land synchronously, before the validation pass is even released, and + // activity leaves VALIDATING for the resting IDLE ("ready"): staleness rides its own channel + // and does not gate activity. const seqMid = statusEvents.map((e) => e.status); const staleIdx = seqMid.indexOf("serve-stale", validatingIdx); t.true(staleIdx > validatingIdx, `Expected serve-stale promptly after the change; got: ${seqMid.join(", ")}`); + t.true(seqMid.indexOf("serve-ready", validatingIdx) > validatingIdx, + `Expected activity to leave VALIDATING for ready; got: ${seqMid.join(", ")}`); + const stale = statusEvents.find((e, i) => e.status === "serve-stale" && i >= validatingIdx); + t.true(stale.changedProjects.includes("library.x"), + "the changed project is reported stale"); // Release validation so the test can tear down cleanly. releaseValidation(); @@ -1439,7 +1443,8 @@ test.serial( // (2) User edits a file in root.project. Fix 2 walks // getTransitiveDependencies(root.project) and clears library.a's ERRORED - // gate, since the user's activity signals retry intent. + // gate, since the user's activity signals retry intent. The change lifts the sticky ERROR + // activity to ready and reports the new stale set. buildServer._projectResourceChanged(rootProject, "/index.html", false); await drainUntil(clock, statusEvents, (e) => e.status === "serve-stale"); @@ -1720,7 +1725,10 @@ test.serial( // Fire the dropped-events error on the initial watcher. watchHandlers[0].emitError(droppedEventsError()); - await drainUntil(clock, statusEvents, (e) => e.status === "serve-stale"); + // Recovery is async; drain until it has recreated the watcher. Keying on the serve-stale + // event is not enough (the build cycle close already emitted one) so wait on the + // recovery-specific effect (the fresh watcher subscription). + await drainUntil(clock, statusEvents, () => watchHandlers.length === 2); t.true(watchHandlers[0].destroy.calledOnce, "old watcher destroyed"); t.is(watchHandlers.length, 2, "a fresh watcher was created"); @@ -1732,7 +1740,11 @@ test.serial( t.true(sourcesChanged.calledOnce, "sourcesChanged emitted so clients reload"); const seq = statusEvents.map((e) => e.status); - t.is(seq[seq.length - 1], "serve-stale", "server settles on STALE after recovery"); + t.is(seq[seq.length - 1], "serve-ready", + "server settles on ready after recovery, everything is servable, stale projects " + + "rebuild lazily on request"); + t.true(seq.includes("serve-stale"), + `the stale set was reported after the re-scan; got: ${seq.join(", ")}`); }); test.serial( From 21b5e8533dbfadff4a7c7e67faa80554c3a9138b Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 23 Jul 2026 16:08:03 +0200 Subject: [PATCH 3/5] perf(project): Avoid recomputing the stale set in #emitStale The reconciler already computes the stale set; #emitStale swept #projectBuildStatus again for the same result. Let it accept a pre-computed set and pass the reconciler's, halving the scans on that path. Other call sites still recompute. --- packages/project/lib/build/BuildServer.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 6b0fb23db72..c9b2217edc7 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -921,9 +921,11 @@ class BuildServer extends EventEmitter { * De-duplicated against the last reported set (#lastStaleKey) so repeated calls * from the several emission sites (reconcile, eager source-change, watcher recovery) don't spam * the banner with an unchanged signal. + * + * @param {string[]} [staleProjects] Pre-computed stale set, passed by callers that already ran + * {@link #getStaleProjectNames} to avoid a second sweep; recomputed here when omitted. */ - #emitStale() { - const staleProjects = this.#getStaleProjectNames(); + #emitStale(staleProjects = this.#getStaleProjectNames()) { const key = staleProjects.slice().sort().join("\n"); if (key === this.#lastStaleKey) { return; @@ -1010,9 +1012,10 @@ class BuildServer extends EventEmitter { return; } // The server is at rest. Activity -> IDLE ("ready"); the stale set (which projects remain - // stale) is reported separately and does not gate "ready". + // stale) is reported separately and does not gate "ready". Pass the set computed above so + // #emitStale doesn't re-sweep #projectBuildStatus. this.#setState(SERVER_STATES.IDLE, {hrtime}); - this.#emitStale(); + this.#emitStale(staleProjects); } /** From 426fa0163c633e5492cacdc3133e6e90ff137f86 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 23 Jul 2026 18:01:26 +0200 Subject: [PATCH 4/5] refactor(logger): Rename stale terminology to staleProjects The Serve#stale() parameter was named changedProjects, framing the input as a diff of what changed. The method reports the full current stale set, so staleProjects names it for what it is. The serve-stale event payload key and the InteractiveConsole writer state (setStale, render, the build-state field) follow suit, matching the sibling event keys pendingProjects and validatingProjects. --- packages/logger/lib/loggers/Serve.js | 12 ++++++------ packages/logger/lib/writers/InteractiveConsole.js | 2 +- .../logger/lib/writers/interactiveConsole/render.js | 2 +- .../lib/writers/interactiveConsole/state/build.js | 6 +++--- packages/logger/test/lib/loggers/Serve.js | 6 +++--- .../logger/test/lib/writers/InteractiveConsole.js | 8 ++++---- .../test/lib/writers/interactiveConsole/render.js | 6 +++--- .../lib/writers/interactiveConsole/state/build.js | 8 ++++---- packages/project/test/lib/build/BuildServer.js | 6 +++--- 9 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/logger/lib/loggers/Serve.js b/packages/logger/lib/loggers/Serve.js index 3b269cb282e..4f6c15e7c80 100644 --- a/packages/logger/lib/loggers/Serve.js +++ b/packages/logger/lib/loggers/Serve.js @@ -39,13 +39,13 @@ class Serve extends Logger { } // Emits the current stale-project set. An empty list means every project is up-to-date. - stale(changedProjects) { - if (!changedProjects || !Array.isArray(changedProjects)) { - throw new Error("loggers/Serve#stale: Missing or incorrect changedProjects parameter"); + stale(staleProjects) { + if (!staleProjects || !Array.isArray(staleProjects)) { + throw new Error("loggers/Serve#stale: Missing or incorrect staleProjects parameter"); } - this.#emitStatus("serve-stale", {changedProjects}, - changedProjects.length ? - `Stale projects: ${changedProjects.join(", ")}` : + this.#emitStatus("serve-stale", {staleProjects}, + staleProjects.length ? + `Stale projects: ${staleProjects.join(", ")}` : `All projects up-to-date`); } diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index 7aa0ad9fb40..ae5c6c5bc04 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -348,7 +348,7 @@ class InteractiveConsole { case "serve-stale": // Record the stale set and re-render without a state transition, so the count updates // in place beneath the current status (typically `ready`). - setStale(this.#buildState, evt.changedProjects); + setStale(this.#buildState, evt.staleProjects); this.#render(); break; case "serve-settling": diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js index 14e1c866ff4..5129471aad5 100644 --- a/packages/logger/lib/writers/interactiveConsole/render.js +++ b/packages/logger/lib/writers/interactiveConsole/render.js @@ -148,7 +148,7 @@ function renderStatusLine(state) { suffix = ` ${chalk.dim("·")} ${chalk.dim("Time elapsed: " + prettyHrtime(state.lastBuildHrtime))}`; } // Append the stale count to the ready line. - const staleCount = state.changedProjects.length; + const staleCount = state.staleProjects.length; if (staleCount > 0) { suffix += ` ${chalk.dim("·")} ` + chalk.yellow(`${projectCount(staleCount)} stale`); diff --git a/packages/logger/lib/writers/interactiveConsole/state/build.js b/packages/logger/lib/writers/interactiveConsole/state/build.js index 65675506023..7a98e5f00da 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/build.js +++ b/packages/logger/lib/writers/interactiveConsole/state/build.js @@ -27,7 +27,7 @@ export function createBuildState() { currentTaskName: "", // Names of projects currently stale, reported via `serve-stale`. The renderer surfaces // the count alongside the ready line. - changedProjects: [], + staleProjects: [], // Names of projects collected via `serve-validating` payloads — used to // label the validating state if/when the renderer wants to. validatingProjects: [], @@ -93,8 +93,8 @@ export function transitionTo(state, newState) { // Record the current stale-project set, reported via `serve-stale`. Does not touch the // activity `state` or reset the spinner: the count updates in place beneath the current status. -export function setStale(state, changedProjects) { - state.changedProjects = Array.isArray(changedProjects) ? changedProjects : []; +export function setStale(state, staleProjects) { + state.staleProjects = Array.isArray(staleProjects) ? staleProjects : []; } export function setError(state, message) { diff --git a/packages/logger/test/lib/loggers/Serve.js b/packages/logger/test/lib/loggers/Serve.js index 9c9ce7cf1d6..2e0de2cfed6 100644 --- a/packages/logger/test/lib/loggers/Serve.js +++ b/packages/logger/test/lib/loggers/Serve.js @@ -43,7 +43,7 @@ test.serial("stale emits serve-stale", (t) => { t.deepEqual(statusHandler.getCall(0).args[0], { level: "info", status: "serve-stale", - changedProjects: ["project.a", "project.b"], + staleProjects: ["project.a", "project.b"], }, "Status event has expected payload"); }); @@ -54,7 +54,7 @@ test.serial("stale emits serve-stale with an empty set", (t) => { t.deepEqual(statusHandler.getCall(0).args[0], { level: "info", status: "serve-stale", - changedProjects: [], + staleProjects: [], }, "Status event has expected payload"); }); @@ -63,7 +63,7 @@ test.serial("stale: Missing parameter", (t) => { t.throws(() => { serveLogger.stale(); }, { - message: "loggers/Serve#stale: Missing or incorrect changedProjects parameter", + message: "loggers/Serve#stale: Missing or incorrect staleProjects parameter", }, "Threw with expected error message"); }); diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index 319c950a669..f011a09ec80 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -554,20 +554,20 @@ test.serial("serve-status: serve-build-done without a valid hrtime records null" writer.disable(); }); -test.serial("serve-status: serve-stale records changedProjects without a state transition", (t) => { +test.serial("serve-status: serve-stale records staleProjects without a state transition", (t) => { const {writer} = createWriter(); process.emit("ui5.serve-status", {status: "serve-ready"}); - process.emit("ui5.serve-status", {status: "serve-stale", changedProjects: ["my.app"]}); + process.emit("ui5.serve-status", {status: "serve-stale", staleProjects: ["my.app"]}); const state = writer._getStateForTest().build; t.is(state.state, STATES.READY, "the stale report does not change the activity state"); - t.deepEqual(state.changedProjects, ["my.app"]); + t.deepEqual(state.staleProjects, ["my.app"]); writer.disable(); }); test.serial("serve-status: serve-stale without a payload falls back to an empty list", (t) => { const {writer} = createWriter(); process.emit("ui5.serve-status", {status: "serve-stale"}); - t.deepEqual(writer._getStateForTest().build.changedProjects, []); + t.deepEqual(writer._getStateForTest().build.staleProjects, []); writer.disable(); }); diff --git a/packages/logger/test/lib/writers/interactiveConsole/render.js b/packages/logger/test/lib/writers/interactiveConsole/render.js index 1f809bd82c0..9b54d1b58ab 100644 --- a/packages/logger/test/lib/writers/interactiveConsole/render.js +++ b/packages/logger/test/lib/writers/interactiveConsole/render.js @@ -248,7 +248,7 @@ test("renderBuildRegion: ready state with hrtime shows elapsed suffix", (t) => { test("renderBuildRegion: ready state appends a singular stale count", (t) => { const state = createBuildState(); - state.changedProjects = ["library.a"]; + state.staleProjects = ["library.a"]; transitionTo(state, STATES.READY); const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); t.regex(plain, /ready.*·\s+1 project(?!s) stale/); @@ -256,7 +256,7 @@ test("renderBuildRegion: ready state appends a singular stale count", (t) => { test("renderBuildRegion: ready state appends a plural stale count", (t) => { const state = createBuildState(); - state.changedProjects = ["library.a", "library.b"]; + state.staleProjects = ["library.a", "library.b"]; transitionTo(state, STATES.READY); const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); t.regex(plain, /ready.*·\s+2 projects stale/); @@ -266,7 +266,7 @@ test("renderBuildRegion: ready state omits the stale count when everything is fr const state = createBuildState(); transitionTo(state, STATES.READY); const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); - t.notRegex(plain, /stale/, "no stale fragment when changedProjects is empty"); + t.notRegex(plain, /stale/, "no stale fragment when staleProjects is empty"); }); test("renderBuildRegion: settling state includes 'waiting for changes to settle' hint", (t) => { diff --git a/packages/logger/test/lib/writers/interactiveConsole/state/build.js b/packages/logger/test/lib/writers/interactiveConsole/state/build.js index 1605b671ae5..2a31da02d25 100644 --- a/packages/logger/test/lib/writers/interactiveConsole/state/build.js +++ b/packages/logger/test/lib/writers/interactiveConsole/state/build.js @@ -18,7 +18,7 @@ test("createBuildState: starts in INITIAL with empty counters", (t) => { t.is(state.currentProjectIndex, 0); t.is(state.totalProjects, 0); t.deepEqual(state.projectOrder, []); - t.deepEqual(state.changedProjects, []); + t.deepEqual(state.staleProjects, []); t.deepEqual(state.validatingProjects, []); t.is(state.spinFrame, 0); t.is(state.errorMessage, ""); @@ -112,16 +112,16 @@ test("setStale: records the stale set without touching the activity state", (t) transitionTo(state, STATES.READY); state.spinFrame = 3; setStale(state, ["library.a", "library.b"]); - t.deepEqual(state.changedProjects, ["library.a", "library.b"]); + t.deepEqual(state.staleProjects, ["library.a", "library.b"]); t.is(state.state, STATES.READY, "activity state is unchanged"); t.is(state.spinFrame, 3, "spinner frame is not reset"); }); test("setStale: a non-array resolves to an empty set", (t) => { const state = createBuildState(); - state.changedProjects = ["stale"]; + state.staleProjects = ["stale"]; setStale(state, undefined); - t.deepEqual(state.changedProjects, []); + t.deepEqual(state.staleProjects, []); }); test("enableBuildPlaceholders: promotes INITIAL to STARTING", (t) => { diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index 69fc8801ea8..4311adb3798 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -212,7 +212,7 @@ test.serial("serve-status: serve-stale reports the stale set on sources change", // The last serve-stale emit reports the now-stale project. (An initial "all up-to-date" emit may // precede it from the build cycle close.) const last = staleCalls.at(-1); - t.deepEqual(last.changedProjects, ["root.project"], + t.deepEqual(last.staleProjects, ["root.project"], "serve-stale reports the now-stale project"); }); @@ -762,7 +762,7 @@ test.serial( t.true(seq.lastIndexOf("serve-ready") > seq.indexOf("serve-validating"), `ready follows validating; got: ${seq.join(", ")}`); const stale = statusEvents.filter((e) => e.status === "serve-stale").at(-1); - t.deepEqual(stale.changedProjects, ["library.x"], + t.deepEqual(stale.staleProjects, ["library.x"], "the still-stale dependency is reported via serve-stale"); }); @@ -1084,7 +1084,7 @@ test.serial( t.true(seqMid.indexOf("serve-ready", validatingIdx) > validatingIdx, `Expected activity to leave VALIDATING for ready; got: ${seqMid.join(", ")}`); const stale = statusEvents.find((e, i) => e.status === "serve-stale" && i >= validatingIdx); - t.true(stale.changedProjects.includes("library.x"), + t.true(stale.staleProjects.includes("library.x"), "the changed project is reported stale"); // Release validation so the test can tear down cleanly. From 9e1106011725d37dc3d47ac274a64094a4cb96ad Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 24 Jul 2026 10:03:59 +0200 Subject: [PATCH 5/5] refactor(project): Cleanup comments --- packages/project/lib/build/BuildServer.js | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index c9b2217edc7..67ad1e55407 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -126,8 +126,8 @@ class BuildServer extends EventEmitter { // Server lifecycle activity state. Starts as `null` so the first #setState call // always emits the initial state. #serverState = null; - // The stale set most recently reported via #emitStale, serialized to a stable key (sorted - // names joined), so repeated emits of an unchanged set are dropped. `null` until the first emit. + // The stale set most recently reported via #emitStale, so repeated emits of an + // unchanged set are dropped. #lastStaleKey = null; // The error captured on the last transition to ERROR, or null in any other state. // Exposed via getServeError() so the dev server can surface it on HTML navigations @@ -236,10 +236,7 @@ class BuildServer extends EventEmitter { } } if (this.#pendingBuildRequest.size === 0) { - // No initial build was requested. Signal "idle" / "ready" right away. Don't report the - // stale set yet: every project is still INITIAL, and counting those as stale would be - // wrong for an up-to-date cache. The first reconcile after a reader request validates - // them and reports the real set. + // No initial build was requested. Signal "idle" / "ready" right away. this.#setState(SERVER_STATES.IDLE); } } @@ -591,25 +588,19 @@ class BuildServer extends EventEmitter { this.#resourceChangeQueue.set(project.getName(), new Set([filePath])); } - // A source change moves project staleness: the affected project and its dependents are now - // stale. Report the new stale set right away, independently of the activity state: a change - // from a quiet IDLE must not force a non-IDLE activity (the project rebuilds lazily on its next - // reader request, per the lazy-build contract). - // // From the quiet states, settle activity on IDLE ("ready") first: ERROR is sticky and the // invalidate() calls above lifted the per-project gate (input tree moved), so the server-level // error must clear too: #setState(IDLE) does that via #serveError. VALIDATING is being aborted // for the changed project (its per-project signal fired in invalidate()); dropping to IDLE // reports the rest state promptly rather than leaving a stale "validating" until the pass - // settles. #setState(IDLE) is a no-op from IDLE, so a lone edit doesn't re-emit ready(). + // settles. // BUILDING and SETTLING own their own transitions, so leave them be. if (this.#serverState === SERVER_STATES.IDLE || this.#serverState === SERVER_STATES.VALIDATING || this.#serverState === SERVER_STATES.ERROR) { this.#setState(SERVER_STATES.IDLE); } - // Sweep the stale set once and reuse it below: nothing mutates #projectBuildStatus between - // here and the SETTLING transition, so the same set feeds both #emitStale and settling(). + // Collect the stale set once and reuse it for #setState below const staleProjects = this.#getStaleProjectNames(); this.#emitStale(staleProjects);