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:
*
* - 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 +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(