Skip to content

refactor(project, server): Improve error handling and recovery#1452

Draft
RandomByte wants to merge 18 commits into
mainfrom
refactor/serve-error-handling
Draft

refactor(project, server): Improve error handling and recovery#1452
RandomByte wants to merge 18 commits into
mainfrom
refactor/serve-error-handling

Conversation

@RandomByte

Copy link
Copy Markdown
Member

No description provided.

RandomByte and others added 18 commits July 13, 2026 10:27
Classify caught task errors into three branches: abort, transient
(concurrent source change), and normal. Transient errors re-queue the
affected projects and keep held reader requests open so they resolve on
the retry. Normal errors are reported via ServeLogger. The 'error' event
stays reserved for fatal failures like a watcher crash, so the process
still exits on those.
A failed build latches its projects into a new ERRORED state and
short-circuits #getReaderForProject with the captured error. An unchanged
rebuild would reproduce the same failure, so hold the gate until real
signal arrives.

Three signals lift the gate:

- Source change on the project itself: invalidate() already clears
  #lastError.
- Source change on a dependent: _projectResourceChanged invalidate()s
  each dependent, lifting ERRORED as a side effect.
- Source change on a transitive dependency, or any successful build
  cycle: both call the new ProjectBuildStatus.clearError() (ERRORED ->
  INVALIDATED, drops #lastError without touching the reader or aborting a
  build). Without this a request resolving through a gated dependency
  would replay the captured error forever.

Correct the banner for the error path: _projectResourceChanged now also
transitions ERROR -> STALE, #processBuildRequests re-signals BUILDING per
iteration so retry progress replaces the red state, #getStaleProjectNames
excludes ERRORED projects, and the post-cycle transition preserves ERROR
for projects that stay gated.

Add an integration test for the gate plus four unit tests: a dependent's
change lifts its dependency's gate, a successful cycle lifts unrelated
gates, no signal keeps the gate closed, and a change on the ERRORED
project lifts its own gate.
Every next(err) in the middleware chain now flows through a custom
4-argument handler that responds with HTTP 500, text/plain content
type, and the error stack (falling back to the message or String(err))
as the body. Registered last so it catches errors from every earlier
middleware, including third-party custom middleware where we cannot
wrap a try/catch.

If the response has already started, delegate to Express's default
handler which closes the connection.

Console-log routing (differentiating gated build errors from unexpected
ones) stays out of this handler for now — that lands in a follow-up.
On a delta signature transition whose changedProjectResourcePaths lists a
path the delta task did not re-emit, the merge loop in recordTaskResult
replayed the previous stage's copy of that path into the new stage writer.
When a source file is deleted between builds, the delta task emits nothing
for it and the merge resurrects the pre-deletion output, so downstream
readers keep serving the file after its source is gone. Affects every
differential task (replaceCopyright, replaceVersion, replaceBuildtime,
minify).

Filter the merge by cacheInfo.changedProjectResourcePaths: if a path was
flagged changed and the task did not write it, drop the stale copy instead
of replaying it. Paths outside the delta merge as before. Track the
dropped count alongside merged count in the perf log.

Add four fail-then-succeed test buckets covering invariants across a
failed-attempt / retry boundary on one ProjectBuildCache instance:

1. #writtenResultResourcePaths accumulates across failed attempts, because
   allTasksCompleted (which clears it) never runs on a thrown build. The
   worst outcome is redundant I/O on the retry, not wrong output: the retry
   re-hashes each leaked path against its fresh reader, so unchanged content
   yields the same signature and changed content invalidates correctly. The
   test pins this behavior. Clearing the set cleanly needs a separate
   fail/abort hook, since #initSourceIndex legitimately seeds the array with
   delta paths on the first build after loading from persistent cache.
2. #currentStageSignatures reflect the retry's stages after a full retry.
3. The delta merge does not resurrect a deleted-source resource across
   the boundary.
4. #frozenSourceReader is nulled by the retry's initStages call before
   further task inputs are computed.
MonitoredReader records every byPath/byGlob call regardless of result, so
a task probing an optional file or using a glob that matches nothing still
contributes those requests. On #addRequestSet, when findExactMatch misses
and findBestParent picks a proper-subset parent, the delta can contain
only requests that resolve to zero resources. That threw "Unexpected empty
added resources for request set ID ...", reproduced by branch-switching in
OpenUI5.

These reads are not benign: whether a probed file exists can change task
output, so collapsing an empty-delta node onto its parent's signature
would serve stale results after the file's existence changes.

Replace the throw with a marker scheme:

- On an empty-resolving delta, derive an empty tree from the parent
  (deriveTree([])) and record the added-request keys on
  metadata.unresolvedKeys. For an all-empty root recording, keep the empty
  ResourceIndex and populate unresolvedKeys with every request.
  Partially-resolving deltas record only the unresolved subset.
- Route getIndexSignatures() and #addRequestSet's returned signature
  through a new #computeNodeSignature: the pure tree hash when
  unresolvedKeys is empty, otherwise sha256(treeHash \0 sorted keys).
- In updateIndices, drop keys from unresolvedKeys as their request
  resolves to a real resource. Once drained, the signature collapses to
  the pure tree hash — matching a same-shape recording with the resource
  present.
- Persist unresolvedKeys on root and delta entries through
  toCacheObject/fromCache.

SharedHashTree is untouched: deriveTree([]) already produces a distinct
ResourceIndex whose tree hashes to the parent's, so the tree-identity map
keys stay distinct across nodes.

Rewrite the three repro tests to assert no-throw plus signature
distinctness; add convergence, cache round-trip, empty-root
discrimination, and BuildTaskCache-shape coverage.
…estManager

#collectUnresolvedKeys and #drainUnresolvedKeys both branched on
request.type === 'path' vs 'patterns', with the path branch scanning the
path array via Array.includes. Extract the check into
#requestResolvesAgainstPaths and back the path lookup with a Set; the
patterns branch still gets the array form it needs for micromatch.
@parcel/watcher can report that OS-level FS events were dropped and its
event cache is unreliable ("Events were dropped by the FSEvents client.
File system must be re-scanned."). The build cache derives what changed
solely from watcher events fed through resourcesChanged(), so dropped
events leave the change signal incomplete and cached results can be served
stale. Any WatchHandler error was previously terminal — the server went to
ERROR with no recovery.

Treat a watcher error as recoverable: recreate the watch subscriptions,
force a full source re-scan of every project, and return the server to
STALE so the next reader request rebuilds against a re-indexed tree.

- Extract ProjectBuildCache.resetForFullRescan() from the reset block
  allTasksCompleted() already used for build-end source drift. It re-arms
  initSourceIndex() to re-glob and diff against the persisted index,
  catching changes the watcher never reported.
- Add ProjectBuilder.forceFullRescan() (guarded by #buildIsRunning),
  delegating through BuildContext / ProjectBuildContext.
- BuildServer.#recoverWatcher() orchestrates recovery: guarded against
  re-entrant error storms, quiesces validation and the active build,
  recreates the watcher, forces the re-scan, invalidates all projects, and
  drains parked reader requests. #triggerRequestQueue and
  #scheduleBackgroundValidation are suppressed during recovery so nothing
  races the builder lock. A sliding-window counter escalates to terminal
  ERROR if recovery keeps failing, since dropped events arrive via the
  subscription callback rather than a watch() rejection and would
  otherwise loop forever.

emit("error") stays reserved for the terminal fallback; a successful
recovery emits sourcesChanged so clients reload.
A build-task failure (e.g. a LESS syntax error from buildThemes) left the
reused in-memory build state corrupted, so `ui5 serve` kept returning the
same error after the source was fixed, until a server restart.

When a task throws, runTasks rejects before allTasksCompleted() runs. The
project's ProjectResources keeps the partial stage writers from the broken
source, and #currentResultSignature keeps the last successful build's
value. On the next build the recovered source signature matches the
retained #currentResultSignature, so #findResultCache short-circuits,
marks the project usesCache, and serves the stale broken stages. Dependent
theme libraries then recompile against that and fail the same way.

Discard the failing project's in-memory state on a genuine (non-abort)
failure so a later rebuild re-imports clean stages from the persistent
cache. ProjectBuilder#build tracks the in-flight context and calls
resetForFullRescan() on it; abort and SourceChangedDuringBuildError are
excluded via isAbortError.

Harden resetForFullRescan() against the same corruption: also clear
#currentResultSignature, #cachedResultSignature, #currentStageSignatures,
and #writtenResultResourcePaths, and reset the stage pipeline via the new
ProjectResources.reset(). Otherwise the #findResultCache early return would
still fire and skip re-importing the cached stages after a reset.
…settle

sourcesChanged drives live-reload, so a lone edit should reach clients
fast. On small projects the triggered build finishes well under 100 ms, so
the previous 100 ms trailing debounce dominated edit-to-reload latency.

Emit on the leading edge: the first change of a quiet period notifies
immediately, and a trailing settle window coalesces the rest of a burst
into one further emit. A single edit now reaches clients at the watcher's
own latency floor.

Raise the window to 550 ms, above @parcel/watcher's 500 ms coalescing cap.
The watcher delivers a continuous operation like `git checkout` as batches
up to 500 ms apart; a window below the cap would emit per batch, while one
above it lets each batch reset the window so the whole operation collapses
to one leading plus one trailing emit. With leading-edge emission the
window size no longer affects single-edit latency.

Rename SOURCES_CHANGED_DEBOUNCE_MS to SOURCES_CHANGED_SETTLE_MS.
When a source change lands mid-build, the build is aborted and its
projects re-queued. The restart used the same 10 ms debounce as a reader
request, so a burst of changes restarted a build per batch, each aborted
by the next.

@parcel/watcher caps its coalescing at 500 ms, so a continuous operation
(git checkout, save-all, a bundler writing many files) arrives as batches
up to 500 ms apart, producing a build -> abort -> build cycle for the
whole operation.

Hold the post-abort restart until changes are quiet for 550 ms (above the
watcher's cap), resetting the window on each further change via
#_projectResourceChanged, so the burst collapses into a single rebuild.
The delay is scoped to the speculative restart: a reader request still
enqueues on BUILD_REQUEST_DEBOUNCE_MS and supersedes a pending restart, so
serving a request is unaffected.

Extract the inline 10 ms request-queue debounce into
BUILD_REQUEST_DEBOUNCE_MS and give #triggerRequestQueue a delay parameter.
During a `git checkout` the serve status could stay on `building` or flip
to red `error` while nothing ran, until the checkout finished. The banner
claimed a build was in progress when #activeBuild was already null.

The deferred post-abort restart had no state to report: during its settle
window the server is waiting for changes to stop, but SERVER_STATES had no
value meaning that, so it showed whatever the last transition left.

Add a SETTLING lifecycle state — changes seen, rebuild pending, holding
until changes go quiet. It sits between STALE and BUILDING with no active
build (#activeBuild === null); the deferred timer moves it to BUILDING
when the window elapses.

Route both deferral sites through it:

- Post-abort restart reports SETTLING instead of leaving the banner on
  `building`.
- Failure-with-pending-changes (the signal.aborted ||
  #resourceChangeQueue.size > 0 branch) now defers and reports like the
  abort path instead of parking on the failed build's state. This fixes
  the `git checkout` case: the doomed first build reports SETTLING and
  retries once the tree is quiet instead of a spurious serve-error.

Genuine non-transient failures still latch ERRORED. BUILDING -> SETTLING
skips the buildDone emission (like BUILDING -> ERROR), and
#reconcileServerState bails on SETTLING so the deferred-restart timer owns
the SETTLING -> BUILDING transition.

ServeLogger gains settling(pendingProjects) emitting serve-settling, and
the interactive banner renders a spinning "settling · waiting for changes
to settle" line.
The first build after a quiet period fired on BUILD_REQUEST_DEBOUNCE_MS
(10 ms). On a multi-file operation the tree is half-written when the first
watcher event lands, so the build fires into an incomplete tree and fails
transiently.

Give the first speculative build a 100 ms settle window
(FIRST_BUILD_SETTLE_MS) instead. This absorbs an editor's multi-file save
fan-out; 100 ms sits well below @parcel/watcher's 500 ms cap and near its
50 ms floor, so single-edit latency is barely affected. The window reports
SETTLING.

Applies only when a build is already pending (a reader request queued but
not started); laziness is preserved, since with nothing queued a source
change still waits for a reader request. An explicit reader request
supersedes the window by re-arming the queue at the snappy debounce, so
serving a request is never delayed.

Kept separate from the SETTLING reporting change so a regression in
single-edit latency can be bisected cleanly.
transitionTo() in state/build.js resets spinFrame to 0 on every
transition, and no other serve-* branch sets it at the call site. Drop the
redundant assignment and rely on the helper.
…chines

architecture.md had fallen behind BuildServer.js. Rewrite the affected
sections to match the implementation:

- Replace the 3-state per-project sketch (INITIAL / INVALIDATED / FRESH)
  with the actual 6-state ProjectBuildStatus diagram, adding VALIDATING,
  BUILDING, and ERRORED, and note which transitions each state accepts.
- Add a Server lifecycle section for SERVER_STATES (IDLE / STALE /
  BUILDING / VALIDATING / ERROR) driven by #setState and
  #reconcileServerState, including SETTLING between STALE and BUILDING and
  the BUILDING -> SETTLING edge.
- Document background cache validation
  (#scheduleBackgroundValidation / #runBackgroundValidation) promoting
  INITIAL projects to FRESH, and the reader-request wait-on-validation
  path in #getReaderForProject.
- Add a Startup section for BuildServer.create() awaiting WatchHandler
  readiness (the Windows ReadDirectoryChangesW race).
- Expand Build Request Flow with the ERRORED short-circuit, the VALIDATING
  wait, #stopActiveValidation, markBuilding, the transient-vs-non-transient
  failure split, and FIRST_BUILD_SETTLE_MS.
- Expand File Watch and Abort with fileAddedOrRemoved reader eviction,
  leading-edge sourcesChanged (SOURCES_CHANGED_SETTLE_MS), the deferred
  post-abort restart (ABORTED_BUILD_RESTART_SETTLE_MS), the first-build
  100 ms window, and error clearing on invalidate.
- Correct the WatchHandler entry from chokidar to @parcel/watcher and
  record its 50 ms / 500 ms coalescing wait behind the debounce values.
- Add an Error gating section for ERRORED, #lastError, and the
  transient-error carve-out.
- Point the BuildServer row in the component table at ProjectBuildStatus
  and SERVER_STATES.
…igation

While the build server is errored, a top-level HTML navigation now renders
an error page with the live-reload client attached instead of a plain-text
stack trace, so the tab reloads itself once the source is fixed. The
terminal error handler previously answered every failure with plain text,
losing the live-reload script serveResources.js injects, so a fixed source
emitted sourcesChanged but the tab had no client listening.

Content-negotiate in createErrorHandler: for a top-level HTML navigation,
render errorPage.html with the error message and stack HTML-escaped, and
embed INJECT_SCRIPT_TAG when liveReload.active. Non-HTML requests keep the
text/plain stack response. The script tag reuses the constant
serveResources.js injects, so there is one source of truth. On the next
sourcesChanged the client calls location.reload(), landing on the
recovered build or a fresh error page.

Negotiate on Sec-Fetch-Dest and an explicit text/html Accept rather than
req.accepts("html"): a wildcard Accept returns "html", which would serve
the HTML page to every failing subresource load and leave a <script> tag
to run the error page as JavaScript. Sec-Fetch-Dest ("document" only for
top-level navigations) wins when present; an explicit text/html Accept is
the fallback, so an older prefetch sending Accept: text/html for a
subresource still gets plain text. isDocumentNavigation is extracted to
helper/ so the errorHandler and the gate below share this logic. The
template is loaded once at module load via readFileSync, matching
serveIndex/directory.html.

Surface the page on any navigation while errored, not only when the
requested path maps to the failed project. Previously, navigating to the
app's index.html while a dependency library build was broken served a
normal 200. Add a serveBuildError gate middleware before serveResources:
for document navigations it consults the server-level error and diverts to
the errorHandler via next(err); subresource loads pass through and keep
their per-project behavior. The gate is a 3-argument middleware, since the
4-argument errorHandler is only reached once something calls next(err),
which a would-be-200 navigation never does.

- BuildServer: capture the error on entry to ERROR, clear it on every
  other transition, expose it via getServeError(). Transient/aborted
  builds report SETTLING, so getServeError() returns null during that
  window.
- Thread getServeError from server.js through MiddlewareManager options.
The error handler hand-rolled a five-character escape map. @ui5/server
already depends on escape-html and uses it in serveIndex.cjs; it escapes
the same ["'&<>] set to identical strings. Import it and drop the local
HTML_ESCAPES map and escapeHtml function.
The delta merge in recordTaskResult scanned writtenResourcePaths with
Array.includes once per previous-stage resource — O(n*m) string
comparisons on every incremental serve rebuild, scaling with a project's
total written-resource count. Build a Set from the paths for the
membership check; the array stays for the ordered downstream uses.
#setState already defaults the STALE and SETTLING project lists to
#getStaleProjectNames(). Three call sites passed exactly that default
explicitly. Drop the argument and rely on the ?? fallback inside
#setState. The post-build STALE call keeps its argument — it forwards an
already-computed set alongside hrtime.
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.

2 participants