Skip to content

refactor: Handle project definition changes - #1478

Draft
RandomByte wants to merge 10 commits into
mainfrom
refactor/handle-project-changes
Draft

refactor: Handle project definition changes#1478
RandomByte wants to merge 10 commits into
mainfrom
refactor/handle-project-changes

Conversation

@RandomByte

@RandomByte RandomByte commented Jul 24, 2026

Copy link
Copy Markdown
Member

Problem

ui5 serve could not safely react to project-definition changes while running. Changes to files like ui5.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.

  • Add ProjectDefinitionWatcher to watch the project-definition files.
  • Add Supervisor to keep the HTTP server and port stable while it rebuilds the graph, Express app, and BuildServer behind them.
  • Build the new server before tearing down the old one, so a failed rebuild leaves the last working server running.
  • Recover a broken server by retrying until the new graph is stable, so switching branches mid-checkout (sources still being written) resolves cleanly on a retry instead of serving a half-restored project.
  • Stop answering requests as soon as a definition change starts, so requests fail fast instead of hanging until the checkout finishes writing files.
  • Re-point live reload and the file watchers at the new graph after a successful swap.
  • Make source watching survive branch switches and moved paths.
  • Update the interactive console while the project is being re-resolved, including an error status line that stays until the server recovers.
  • Add serveMiddleware for mounting UI5 middleware into another server.

ProjectDefinitionWatcher and Supervisor

ProjectDefinitionWatcher watches the files that define the project graph: project package.json files, ui5.yaml or a custom --config, workspace config, and static dependency-definition files. It fires an early definitionChanging signal (for UI feedback) and, once the changes settle, a definitionChanged signal 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 the BuildServer, 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.

  CLI
   |
   v
serve()  [server.js]
   |
   v
Supervisor  (owns the stable HTTP socket)
   |
   +-- binds port once; request forwarder routes every request to #stack.app
   |
   +-- ProjectDefinitionWatcher  (@ui5/project)
   |    watches ui5.yaml, package.json, workspace config
   |    definitionChanging --> blank version in console; stop answering requests
   |    definitionChanged  --> Supervisor.reinitialize()
   |
   +-- #stack  { app, BuildServer }   <-- swapped on reinit; forwarder retargets silently
   |    |
   |    +-- BuildServer  (@ui5/project)
   |         watches source files; emits sourcesChanged
   |
   +-- #sourcesChangedRelay  (stable; live-reload clients stay connected across swaps)

Supervisor keeps the socket and the relay for the whole run. Everything else -- the serving app, the BuildServer, and the ProjectDefinitionWatcher -- 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:

HEALTHY    --(definition change)--------> RESOLVING
RESOLVING  --(resolve+build ok)---------> HEALTHY
RESOLVING  --(resolve/build failed)-----> DEGRADED
DEGRADED   --(scheduled recovery)-------> RECOVERING
RECOVERING --(converged+built)----------> HEALTHY
RECOVERING --(still failing)------------> DEGRADED
any        --(destroy)------------------> DESTROYED  (terminal)

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 HEALTHY swap resolves the graph once; a DEGRADED swap runs the recovery loop described below. If several definitionChanged events 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 definitionChanging fires, the current BuildServer stops 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 restored package.json points 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.

DEGRADED (last-good stack serving)
   |
   +-- schedule one recovery (RecoveryBudget: 5 attempts / 60s)
   |
   v
#convergeRecoveryGraph()  loop, bounded by RECOVERY_MAX_ITERATIONS
   |
   +-- resolve graph via graphFactory()
   +-- collect project-root set
   +-- roots subset of previous?  --yes--> converged: build + swap --> HEALTHY
   |                              --no-->  feed [candidate, last-good] to
   |                                       ProjectGraphSettler, await quiet, re-resolve
   v
 (budget exhausted / branch never resolves) --> stay DEGRADED until next definition change

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. RecoveryBudget counts 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 BuildServer are 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 that ui5 serve uses, 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 returned close() function frees the BuildServer's watcher and cache when the outside server shuts down.

Third-party server (e.g. Vite, custom Express app)
   |
   | const {middleware, close} = await serveMiddleware(graph, options)
   v
serveMiddleware()  [server.js re-export -> serveMiddleware.js]
   |
   +-- calls buildRouter()  [serve/stack.js]
   |    |
   |    +-- starts BuildServer  (@ui5/project)
   |    +-- assembles UI5 middleware chain onto an Express Router
   |    +-- returns { router, buildServer }
   |
   +-- returns { middleware: router, buildServer, close() }


Third-party app                       @ui5/server
+-------------------------+           +--------------------+
| const app = express()   |           |                    |
| app.use(middleware)  ---+---------> | UI5 router         |
| app.use(errorHandler)   |           | (build-on-request, |
| app.listen(8080)        |           |  index, CSP, ...)  |
|                         |           +--------------------+
| // teardown:            |
| listener.close()        |
| await close()        ---+---------> BuildServer.destroy()
+-------------------------+           (releases source watcher
                                       and build-cache handle)

The caller owns the port, the HTTP server, error handling, and the order of teardown. serveMiddleware never binds a socket, never attaches a live-reload WebSocket server, and never installs a final error handler -- those stay with the caller's server.

@RandomByte
RandomByte force-pushed the refactor/handle-project-changes branch 2 times, most recently from df28bd5 to 6fdc32d Compare July 29, 2026 08:17
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
RandomByte force-pushed the refactor/handle-project-changes branch from 2c77a02 to ee3f6e4 Compare July 30, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant