refactor: Handle project definition changes - #1478
Draft
RandomByte wants to merge 10 commits into
Draft
Conversation
17 tasks
RandomByte
force-pushed
the
refactor/handle-project-changes
branch
2 times, most recently
from
July 29, 2026 08:17
df28bd5 to
6fdc32d
Compare
Reorder the resets in ProjectBuildCache#discardIncrementalState so the load-bearing ones come first, each annotated with why it is required, and group the two that only exist for completeness (#sourceIndex, #cachedResultSignature) at the end under a single comment: both are overwritten before read on the next build (#sourceIndex by #initSourceIndex, #cachedResultSignature by #findResultCache). Drop the #changedProjectSourcePaths and #writtenResultResourcePaths resets. Setting #combinedIndexState to RESTORING_PROJECT_INDICES re-arms initSourceIndex, which the next build runs before validateCache and which re-clears (#changedProjectSourcePaths) and reassigns (#writtenResultResourcePaths) both fields from a fresh disk read. No behavior change: every caller follows discardIncrementalState with a full build that routes through initSourceIndex.
Extract the pieces the source watcher and the incoming project-definition watcher both need into standalone helpers, so a single source of truth backs every @parcel/watcher consumer: - watchSettle.js: single source of WATCHER_BURST_SETTLE_MS (550 ms), sized above @parcel/watcher's 500 ms MAX_WAIT_TIME so each coalesced batch resets the settle window rather than terminating it. - watchSubscriptions.js: drainSubscriptions() unsubscribes a list in parallel (Promise.allSettled) and returns the failures, shared by both watchers' destroy() and BuildServer's recovery re-subscribe. - RecoveryBudget.js: sliding-window loop protection (5 attempts / 60s), one instance per watcher so a fault in one does not consume the other's budget. Exported via the @ui5/project/build/helpers/RecoveryBudget subpath so the server's Supervisor can share the same primitive.
Add the two graph-level watchers that let a running server react to a project-definition change (a ui5.yaml / package.json edit or a git checkout that rewrites the topology), which no incremental source rebuild can absorb because it requires re-resolving the graph. - ProjectDefinitionWatcher: a @parcel/watcher consumer over the definition files of every project in the graph (package.json, ui5.yaml or --config, ui5-workspace.yaml, static dependency-definition file). Emits definitionChanging on the leading edge and definitionChanged (coalesced) on the trailing edge to drive a full serving-stack re-init. Include-based: only watched definition files start a burst; once open, every event below the watched roots resets the settle timer. Recovery mirrors the source watcher and shares RecoveryBudget / drainSubscriptions / WATCHER_BURST_SETTLE_MS. - ProjectGraphSettler: a short-lived acceptance gate for degraded recovery. Given one or more resolved graphs, it watches the union of their project roots (missing roots at their nearest existing ancestor) and resolves once those roots are quiet for WATCHER_BURST_SETTLE_MS, so a graph is only swapped in once its files have stopped landing on disk. Both are exported via subpaths for the @ui5/server Supervisor that owns them.
A git checkout that moves or removes a watched source directory while `ui5 serve` is running escalated the server to a terminal ERROR. Two defects in WatchHandler, both independent of the definition-watcher re-init that is the primary branch-switch recovery path: 1. #handleWatchEvents called getVirtualPath for every event. On a diverged tree the path no longer maps into the project the watcher was armed with, so getVirtualPath threw "Unable to convert source path ... to virtual path", and the per-event catch turned that into a fatal watcher error. 2. Recovery re-subscribed over the same graph's paths; parcelWatcher.subscribe on a removed .../src directory rejected with "No such file or directory", which propagated to #setState(ERROR). Drop an event whose path no longer maps to a virtual path (verbose log), and skip a subscribe path that no longer exists on disk (warn log). The skip reads current disk state via fs access rather than matching parcel's locale-dependent, code-less message; a subscribe failure on a path that still exists is a genuine fault and still propagates into BuildServer's loop-protected recovery budget. Also route destroy()'s subscription teardown through the shared drainSubscriptions() helper, aggregating the failures into an AggregateError.
…o BuildServer Extend the BuildServer so a definition-driven re-resolve and a multi-batch source burst (a git checkout) both resolve without hanging requests or error-cycling: - Reader-suspend gate: suspendReaders(error) / resumeReaders() let the owning server reject parked reader requests now and fast-reject incoming ones at the #getReaderForProject gate, orthogonal to the per-project state machine. It leaves #state, #lastError, and the cached reader untouched (the project is fine, its definition is just being re-resolved), so the server rebuilds normally once resumed. suspendReaders requires an Error: the gate engages on truthiness and the value is thrown to the middleware error handler. - Settle-based deferred restart: a source-change-aborted build and a build that failed while sources were still changing defer their restart on ABORTED_BUILD_RESTART_SETTLE_MS (= WATCHER_BURST_SETTLE_MS), reset by each further change, so a burst collapses into one rebuild instead of a build-abort cycle per batch. While #pendingDeferredRestart holds, a reader request queues without re-arming (only source changes reset the window), so live-reload traffic cannot defer the rebuild indefinitely nor pull it forward into a still-arriving burst. Both branches report SETTLING for the window. - Single-source the settle window on watchSettle.js's WATCHER_BURST_SETTLE_MS and share RecoveryBudget / drainSubscriptions with the definition watcher.
…rver
The BuildServer freezes a project's definition (ui5.yaml, package.json,
ui5-workspace.yaml) at startup and MiddlewareManager bakes resolved extensions
into the Express chain, so a branch switch or a config edit cannot be absorbed
by any reader-level fix. Re-resolving the graph and re-creating the whole stack
above @ui5/server refreshes both the build model and the server config.
Introduce a serve/ namespace holding the serving internals, reserving the top
level for the two public entries:
- serve/Supervisor.js owns a stable http.Server and re-creates the stack (graph
+ Express app + BuildServer) behind a request trampoline. serve() delegates to
it and returns a reinitialize() handle alongside {h2, port, close}. Re-init is
build-new-then-swap: an invalid ui5.yaml leaves the last-good app serving; the
port stays bound and live-reload clients stay connected through the trampoline
and a retargeting relay. The config-aware build signature keeps the cache warm
across the swap.
- A ProjectDefinitionWatcher owned by the supervisor drives reinitialize() from
on-disk changes. It outlives each swapped-out stack and is re-targeted at the
new graph after every swap. Armed at the tail of #init, only when a
graphFactory is present. rootConfigPath / workspaceConfigPath /
dependencyDefinitionPath / cwd are threaded through serve() so it can locate a
custom root config, the workspace file, and the static dependency definition.
- Degraded recovery: when a re-resolve fails (a broken branch, or a checkout
still landing on disk) the last-good stack keeps serving and #degradedError
diverts every request through serveBuildError. The failed swap self-schedules
one bounded recovery that converges on a stable graph via ProjectGraphSettler,
looping until two consecutive resolves agree on the project roots. Recovery
budget bounds a persistently broken branch. On the leading-edge
definitionChanging the current BuildServer's readers are suspended so requests
fail fast instead of hanging on builds the checkout keeps aborting.
- The swap lifecycle is gated behind an explicit #state machine (HEALTHY,
RESOLVING, DEGRADED, RECOVERING, DESTROYED) mutated only through #setState
against an allowed-transition table, so no path leaves a live server
suspended.
serve/httpListener.js and serve/stack.js extract the HTTP helpers and the
middleware-assembly core (buildRouter / buildApp) so the supervisor, the
single-shot wrapper, and the embedding API share one construction path.
…TTP servers
Expose the middleware-assembly core as a public serveMiddleware(graph, options)
so a consumer running its own HTTP server (a test harness, a proxy) can mount
UI5's middleware without serve() owning the socket. It reuses buildRouter from
serve/stack.js and returns {middleware, buildServer, close}, stopping short of
the terminal error handler and the live-reload WebSocket server that only a
UI5-owned http.Server needs.
Re-exported from server.js alongside serve() so it is reachable via the
package's main entry; the @public JSDoc lives on the function itself.
…n definition changes Pass buildGraph to serve() as the graphFactory so the server can re-resolve the project graph with identical parameters and re-create the serving stack when a definition file changes on disk (a git checkout, a hand-edited ui5.yaml). Thread the argv values that already feed buildGraph (argv.config, argv.workspaceConfig, argv.dependencyDefinition, cwd) into the server config so they reach the definition watcher: it watches the right root config, workspace file, and static dependency-definition file. Workspace resolution stays active unless static-graph mode is used or --workspace is disabled (pass null then so the watcher applies its default ui5-workspace.yaml); static-graph mode omits the workspace, matching how buildGraph resolves the graph.
…tate The interactive console gained a resolution lifecycle: a definition-driven swap re-resolves the graph, which may succeed with a new project version, repeat, or fail. Teach the writers to render each outcome: - Placeholder the project version during re-resolution and update the project region on a repeated project-resolved, so a swap does not leave a stale header. render.js signals project-resolving across the swap. - Report a degraded server state on a failed re-resolve (build state), so the banner reflects that the last-good stack is still serving while recovery runs. Align the resolve-signal family: ui5.project-resolved shipped on main as the single terminal signal, and this branch completes the lifecycle around it (a leading ui5.project-resolve-started and a ui5.project-resolve-failed), which exposed a grammatical mismatch. Rename the terminal signal to ui5.project-resolve-succeeded in the projectGraphBuilder emitter and both the Console and InteractiveConsole writers so the family reads as a uniform verb-state set.
…ld skill Extend the incremental-build architecture skill to cover the two-watcher model introduced on this branch: the source watcher (WatchHandler, owned by the BuildServer) versus the definition watcher (ProjectDefinitionWatcher, owned by @ui5/server's Supervisor), and why the split lets the source watcher tolerate a git checkout moving paths under it. Document the shared watch helpers (watchSettle, watchSubscriptions, RecoveryBudget), the reader-suspend gate, the settle-window discipline (WATCHER_BURST_SETTLE_MS as the single source, distinct from FIRST_BUILD_SETTLE_MS), and the degraded-recovery convergence loop.
RandomByte
force-pushed
the
refactor/handle-project-changes
branch
from
July 30, 2026 11:27
2c77a02 to
ee3f6e4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
ui5 servecould not safely react to project-definition changes while running. Changes to files likeui5.yaml,package.json, workspace config, or a static dependency definition need a fresh project graph, but the server only watched source files and served an app it had already built. Switching branches or editing config could leave the running server pointing at an out-of-date graph, source paths that had moved, or short-lived watcher/build errors.Solution
This adds a way to handle definition changes, alongside the existing handling of source changes.
ProjectDefinitionWatcherto watch the project-definition files.Supervisorto keep the HTTP server and port stable while it rebuilds the graph, Express app, andBuildServerbehind them.serveMiddlewarefor mounting UI5 middleware into another server.ProjectDefinitionWatcher and Supervisor
ProjectDefinitionWatcherwatches the files that define the project graph: projectpackage.jsonfiles,ui5.yamlor a custom--config, workspace config, and static dependency-definition files. It fires an earlydefinitionChangingsignal (for UI feedback) and, once the changes settle, adefinitionChangedsignal that tells the server to reinitialize. Settling matters for bursts of changes such as branch switches, where many files change at once.The watcher belongs to
Supervisor, not theBuildServer, so it stays alive across serving-stack swaps. After a successful reinitialization it is re-pointed at the new graph, while the source watcher keeps handling incremental rebuilds for the graph that is currently live.Supervisorkeeps the socket and the relay for the whole run. Everything else -- the serving app, theBuildServer, and theProjectDefinitionWatcher-- is rebuilt on a definition change and swapped in behind the request forwarder, without dropping the port or disconnecting open browsers.Swap lifecycle
Reinitialization follows a small state machine. State only changes through
#setState, which checks each change against a table of allowed transitions:The state decides what is allowed: whether a swap is already running, whether the server has been shut down, and which transitions are legal. A
HEALTHYswap resolves the graph once; aDEGRADEDswap runs the recovery loop described below. If severaldefinitionChangedevents arrive while a swap is running, they collapse into one final pass, so a burst of edits only resolves once against the settled files.One switch controls whether the server answers requests. When
definitionChangingfires, the currentBuildServerstops serving readers: it rejects the requests already waiting and fast-fails new ones. Serving is turned back on in a single place, used by both swap outcomes (success and failure), so no code path can accidentally leave the server stuck not answering.Degraded recovery
If a re-resolve fails, the previous server keeps running, but its graph no longer matches the files on disk. The server is marked degraded: a middleware step sends every request to the error page (not just page loads), and the interactive console shows an error status line that stays put. The next successful swap, on a valid edit, clears the mark.
A degraded server also schedules one recovery attempt on its own. The reason it retries rather than resolving once: a branch switch writes
package.json,ui5.yaml, and the source files as one operation, and on a big tree (or a slow disk, or Git LFS fetching files) those arrive in several waves. A single "wait for quiet, then retry" can land in the gap between waves and resolve against a tree that is still missing files, or missing a symlink target that a restoredpackage.jsonpoints at. So recovery loops instead (#convergeRecoveryGraph): resolve, list the project roots, and if that list changed from last time, wait for another quiet window and resolve again. It stops once two resolves in a row agree on the roots.The check is "did the root set shrink or stay the same", not "is it identical", so a branch that removes a dependency still counts as settled. Each pass hands the settler both the new graph and the last known-good one, so a project that only the target branch adds gets watched for quiet as soon as it first shows up in a resolve.
RecoveryBudgetcounts an attempt when recovery is scheduled, not when it succeeds, so a branch that never resolves uses up its 5 attempts and then stays degraded instead of retrying forever. A real edit (definitionChanging) cancels any pending retry and resets the count; a clean swap resets it too.Middleware API
The server now keeps two jobs apart: building the middleware and owning the HTTP listener. That split is what makes the graph swap work: the HTTP server stays bound to its port while a new graph, middleware stack, and
BuildServerare built and swapped in behind it.serve()still starts and owns the UI5 dev server, but under the hood it reuses smaller pieces for building the middleware, binding HTTP, and managing graph swaps.It also adds a new
serveMiddleware()API for outside consumers. It sets up the same UI5 readers/resources and the same standard and custom middleware chain thatui5 serveuses, but hands it back as connect/Express-compatible middleware instead of starting an HTTP server.Other tools can now call
app.use(result.middleware)on their own Express/connect server and stay in charge of the port, protocol, routing around UI5, and error handling. The returnedclose()function frees theBuildServer's watcher and cache when the outside server shuts down.The caller owns the port, the HTTP server, error handling, and the order of teardown.
serveMiddlewarenever binds a socket, never attaches a live-reload WebSocket server, and never installs a final error handler -- those stay with the caller's server.