diff --git a/packages/logger/lib/loggers/Serve.js b/packages/logger/lib/loggers/Serve.js index edac075bde8..4f6c15e7c80 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`); } - stale(changedProjects) { - if (!changedProjects || !Array.isArray(changedProjects)) { - throw new Error("loggers/Serve#stale: Missing or incorrect changedProjects parameter"); + // Emits the current stale-project set. An empty list means every project is up-to-date. + stale(staleProjects) { + if (!staleProjects || !Array.isArray(staleProjects)) { + throw new Error("loggers/Serve#stale: Missing or incorrect staleProjects parameter"); } - this.#emitStatus("serve-stale", {changedProjects}, - `Sources changed in: ${changedProjects.join(", ")}`); + this.#emitStatus("serve-stale", {staleProjects}, + staleProjects.length ? + `Stale projects: ${staleProjects.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..ae5c6c5bc04 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.staleProjects); + 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..5129471aad5 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.staleProjects.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..7a98e5f00da 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,9 +25,9 @@ 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. - changedProjects: [], + // Names of projects currently stale, reported via `serve-stale`. The renderer surfaces + // the count alongside the ready line. + staleProjects: [], // Names of projects collected via `serve-validating` payloads — used to // label the validating state if/when the renderer wants to. validatingProjects: [], @@ -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, staleProjects) { + state.staleProjects = Array.isArray(staleProjects) ? staleProjects : []; +} + 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..2e0de2cfed6 100644 --- a/packages/logger/test/lib/loggers/Serve.js +++ b/packages/logger/test/lib/loggers/Serve.js @@ -43,7 +43,18 @@ 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"); +}); + +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", + staleProjects: [], }, "Status event has expected payload"); }); @@ -52,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"); }); @@ -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..f011a09ec80 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -554,19 +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 and transitions to STALE", (t) => { +test.serial("serve-status: serve-stale records staleProjects without a state transition", (t) => { const {writer} = createWriter(); - process.emit("ui5.serve-status", {status: "serve-stale", changedProjects: ["my.app"]}); + process.emit("ui5.serve-status", {status: "serve-ready"}); + process.emit("ui5.serve-status", {status: "serve-stale", staleProjects: ["my.app"]}); const state = writer._getStateForTest().build; - t.is(state.state, STATES.STALE); - t.deepEqual(state.changedProjects, ["my.app"]); + t.is(state.state, STATES.READY, "the stale report does not change the activity state"); + 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 2bf541e1102..9b54d1b58ab 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.staleProjects = ["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.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/); +}); + +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 staleProjects 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..2a31da02d25 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"; @@ -17,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, ""); @@ -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.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.staleProjects = ["stale"]; + setStale(state, undefined); + t.deepEqual(state.staleProjects, []); +}); + test("enableBuildPlaceholders: promotes INITIAL to STARTING", (t) => { const state = createBuildState(); enableBuildPlaceholders(state); diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 9fc1c5240f2..67ad1e55407 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, 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 // regardless of which resource was requested. Cleared on every non-ERROR transition @@ -346,9 +351,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 +588,21 @@ 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. + // 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. + // 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); } + // Collect the stale set once and reuse it for #setState below + 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 +617,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 +904,27 @@ 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. + * + * @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(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 +946,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 +961,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 +996,17 @@ 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". Pass the set computed above so + // #emitStale doesn't re-sweep #projectBuildStatus. + this.#setState(SERVER_STATES.IDLE, {hrtime}); + this.#emitStale(staleProjects); } /** @@ -982,9 +1015,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 +1026,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 +1062,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 +1125,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: * * * @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 +1177,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..4311adb3798 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.staleProjects, ["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.staleProjects, ["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.staleProjects.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(