Repository navigation
Conversation
…TM path reads Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request improves node availability reporting by ensuring that whole-application load failures correctly transition the node to an 'Unavailable' state. It introduces lifecycle tracking for applications in componentLoader and socketRouter, updates the StatusRecord definition, and implements getAvailabilityStatus to dynamically combine operator-set status with component health. A new regression test suite validates these transitions. A high-severity issue was identified regarding potential stale error states for whole-application failures, which requires implementing explicit lifecycle management for top-level application status.
| componentLifecycle.failed( | ||
| appName ?? basename(componentDirectory), | ||
| error, | ||
| `Could not load application '${appName ?? basename(componentDirectory)}'` | ||
| ); |
There was a problem hiding this comment.
Stale Whole-Application Error Status on Successful Reload
There is a critical bug where a node can become permanently stuck as Unavailable (out of rotation) after recovering from a whole-application load failure.
Concrete Failure Scenario:
- An application fails to load at the top level (e.g., due to a temporary database branch preparation issue or a temporary config issue).
- The outer
catchblock correctly catches the error and registers a whole-application failure under the keyappName ?? basename(componentDirectory)viacomponentLifecycle.failed(...). - The node's availability status correctly becomes
Unavailable(taking it out of rotation). - The underlying issue is resolved, and a reload is triggered.
loadComponentruns again and succeeds. The individual components (e.g.,my-app.jsResource) are successfully loaded and marked asloaded(healthy).- However, because there is no code that clears or updates the top-level
appNamestatus entry in the registry upon a successful load, the entry forappNameremains in theerrorstate in the registry forever. - As a result,
getAvailabilityStatus()continues to see a failed component (appName) and permanently servesUnavailableuntil the entire Harper process is restarted.
Suggested Fix:
To resolve this, we should manage the lifecycle of the top-level application status just like we do for individual components:
- At the start of
loadComponent(after destructuringoptions), if!isRoot, mark the application status asloading:const appStatusKey = !isRoot ? (appName ?? basename(componentDirectory)) : undefined; if (appStatusKey) { componentLifecycle.loading(appStatusKey); }
- At the end of the
tryblock ofloadComponent(right before thecatchblock), mark the application status asloaded:if (appStatusKey) { componentLifecycle.loaded(appStatusKey, `Application '${appStatusKey}' loaded successfully`); }
There was a problem hiding this comment.
Good catch, fixed in 8f6e00b. The top-level application status key now gets a full lifecycle instead of only being written on failure: it is marked loading before the plugins load and loaded once they finish without a whole-application throw, and the outer catch still marks it failed. So a reload that succeeds after a transient whole-application failure (bad config, branch preparation) clears the error and the node rejoins rotation on its own, while a reload that fails again re-sets it. The loaded() call sits before the "did not load any modules" heuristic so that heuristic's own report still stands. And because availability is derived at read time, the cleared status is reflected on the next read with no separate rejoin write.
Lavinia, via Claude
cb1kenobi
left a comment
There was a problem hiding this comment.
get_status derives availability from the operations thread’s component registry, but that thread never runs handleApplication, so a jsResource load failure on HTTP workers never flips the node to Unavailable. GTM can keep sending traffic to a node serving ErrorResource, which is the outage this PR is supposed to fix. Derive from the existing all-threads component status query instead of this thread’s registry. The new unit test uses resetResources() (isWorker defaults true) and so does not catch this.
—
Reviewed 3ef1ab0
| if (record?.status === 'Unavailable') return record; | ||
| const failed = statusInternal.componentStatusRegistry.getComponentsByStatus( | ||
| statusInternal.COMPONENT_STATUS_LEVELS.ERROR | ||
| ); |
There was a problem hiding this comment.
Availability derivation never sees jsResource load failures
Severity: High
get_status runs on the operations/main thread. That thread loads components with resources.isWorker = false, so handleApplication is skipped and jsResource (the #3184 outage) is marked healthy without ever running. getAvailabilityStatus() only reads this thread’s registry, so HTTP workers can be serving ErrorResource while GTM still gets Available. The new test uses resetResources(), which defaults isWorker = true, so it never hits this path.
Suggested fix: derive from statusInternal.query.allThreads() (already used by getAllStatus) and treat any aggregated error as a component failure.
—
Reviewed 3ef1ab0
There was a problem hiding this comment.
Fixed in 07b2624 (on top of 8f6e00b). You are right: the operations thread loads with isWorker=false and never runs handleApplication, so reading this thread's registry missed the worker-side failure. getAvailabilityStatus now derives from the all-threads aggregate (statusInternal.query.allThreads(), the same source getAllStatus uses), which reports a component in error when any thread does, so a jsResource load failure on an HTTP worker drains the node even though get_status runs on the operations thread. Added a regression case that stubs the aggregate with a component error absent from this thread's registry: red on the old this-thread read, green on the aggregate read. One follow-up your finding surfaced (noted in the description): collect() resolves partial, so a worker that never answers the status broadcast could still read healthy; that behavior is inherited from getAllStatus and worth a separate look.
Lavinia, via Claude
| // A whole-application failure serves errors over the entire URL space; it must reach the | ||
| // status registry like per-component failures do, so availability reflects it (#3184). | ||
| componentLifecycle.failed( | ||
| appName ?? basename(componentDirectory), | ||
| error, | ||
| `Could not load application '${appName ?? basename(componentDirectory)}'` | ||
| ); |
There was a problem hiding this comment.
Confirming the open thread above (independently traced): stale whole-app failure survives a successful reload.
What: This call marks appName ?? basename(componentDirectory) as error, but nothing in loadComponent ever marks that exact key loaded again. Every other status key in this file is paired (componentStatusName at lines 1103/1206 vs 1214, and this PR's own socketRouter.ts:193-198 fix does the same for application) — this whole-app key is write-only.
Why it matters: loadedPaths is cleared on reload (see the comment at line ~471 and forgetLoadedPath/its loop around line 552-558), so a real "fix the underlying issue, reload" cycle re-enters this function and can complete without error — but the stale error entry for this key is never cleared, so getAvailabilityStatus() keeps reporting Unavailable forever until the process restarts. That's a regression relative to the bug being fixed: a transient failure now permanently drains the node instead of just under-reporting it. The new test in this PR doesn't catch it — its synthetic failures go through the pre-existing per-component catch (${appName}.${PLUGIN_NAME} key, line 1214), never through this outer-catch path added here.
Suggested fix: Mark the same key loaded once the try block completes successfully, mirroring the watchDedicatedStart pairing in this same PR.
There was a problem hiding this comment.
Fixed in 8f6e00b. The top-level application status key now gets a full loading/loaded/failed lifecycle instead of only being written on failure, so a successful reload after a transient whole-application failure clears the error and the node rejoins; the loaded() call sits before the 'did not load any modules' heuristic so that heuristic's own report still stands. A follow-up in 07b2624 scopes that key to the top-level application load, so nested package loads (which inherit appName) cannot overwrite it.
Lavinia, via Claude
| async function getAvailabilityStatus(): Promise<StatusRecord<'availability'> | undefined> { | ||
| const record = (await getStatusTable().get('availability')) as StatusRecord<'availability'> | undefined; | ||
| if (record?.status === 'Unavailable') return record; | ||
| const failed = statusInternal.componentStatusRegistry.getComponentsByStatus( | ||
| statusInternal.COMPONENT_STATUS_LEVELS.ERROR | ||
| ); | ||
| if (failed.length === 0) return record; | ||
| return { | ||
| id: 'availability', | ||
| status: 'Unavailable', | ||
| message: `Component failure: ${failed.map(({ name }) => name).join(', ')}`, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Confirming the open thread above (independently traced): this can't see the #3184 failure mode.
getAvailabilityStatus() reads only statusInternal.componentStatusRegistry — this thread's local map — not the cross-thread aggregation (statusInternal.query.allThreads(), already used two lines up by getAllStatus() at line 152). get_status runs on the operations/main thread, which loads components with resources.isWorker = false (server/loadRootComponents.js:76). handleApplication — the Plugin API jsResource and most components use — only runs if (resources.isWorker && extensionModule.handleApplication) (components/componentLoader.ts:1067), so the ops thread never even attempts that load, and never records success or failure for it in its own registry.
Why it matters: the motivating incident (#3184, a wedged jsResource load on an HTTP worker) fails only on that worker's own registry, which this derivation never reads — GTM would keep seeing Available for exactly the case the PR sets out to fix. The new test can't catch this either: it calls resetResources(), which defaults isWorker = true (resources/Resources.ts:106), so it never exercises the ops-thread path that get_status actually runs on.
Suggested fix: derive from statusInternal.query.allThreads() (as getAllStatus does) and treat any aggregated error status as a component failure, rather than reading the local registry only.
There was a problem hiding this comment.
Fixed in 07b2624. getAvailabilityStatus now derives from the all-threads aggregate rather than this thread's registry, so a worker-side handleApplication failure is seen even though get_status runs on the operations thread (isWorker=false there, handleApplication never runs). Regression case added that stubs the aggregate with a worker-only error, absent from the local registry: red on the old read, green on the new.
Lavinia, via Claude
|
Reviewed; no blockers found. |
…gate and heal top-level app failures Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
The new commit derives availability from the all-threads component aggregate and gives whole-application loads a loading/loaded/failed lifecycle, which closes the GTM honesty gap from the outage. An operator Unavailable still wins, and a later successful load can rejoin without overwriting a drain. No remaining blocking issue on the changed lines.
—
Reviewed 8f6e00b
| // The application's plugins loaded without a whole-application throw, so clear its top-level | ||
| // status. Done before the "did not load anything" heuristic below so that heuristic's own | ||
| // failure report stands. Per-plugin failures keep their own error entries. | ||
| if (appStatusKey) componentLifecycle.loaded(appStatusKey, `Application '${appStatusKey}' loaded`); |
There was a problem hiding this comment.
A nested package: component's whole-app failure gets healed away by its parent in the same pass.
What: appStatusKey (line 830) is appName ?? basename(componentDirectory). When this component's own config declares a package:-based nested component (e.g. integrationTests/components/fixtures/acl-connect-with-sys/config.yaml, which mixes jsResource with '@harperdb/acl-connect': { package: ... } in one app), the recursive loadComponent call at line 1005-1019 passes appName: appName || componentName — since the parent's own appName is always set for a normal app load, the nested call inherits the exact same appStatusKey string.
If that nested package's own load hits its own outer catch (any throw inside its try — bad config, branch prep, etc.), that frame calls componentLifecycle.failed(appStatusKey, ...) on the shared key. That exception is fully swallowed inside the nested call (the catch at 1298 doesn't rethrow), so the parent's per-component try/catch never fires; the parent's for loop just continues with extensionModule === undefined. Once the loop finishes, the parent's own unconditional componentLifecycle.loaded(appStatusKey, ...) (this line) overwrites the error the nested frame just wrote back to loaded — all within the same load pass, not even requiring a second reload.
Why it matters: this is exactly the failure mode #3184 is about — a component failed to load and is serving errors over its URL space, but the one status entry that would have caught it gets clobbered back to healthy by the parent's own success write before getAvailabilityStatus() ever sees it. Any app composed with a package:-declared sub-component (a supported, real pattern — see the fixture above) is affected the first time that sub-component's load hits the outer-catch path. The new test in this PR doesn't exercise this: it registers its probe plugin directly (no nested package: component), so its synthetic failures never share appStatusKey with a healing parent.
Suggested fix: don't let the parent's healing write clobber a failure recorded on the same key during this same pass — e.g. only call loaded(appStatusKey, ...) if the current status for that key isn't already error from this load cycle, or propagate/inspect the nested call's own outcome instead of relying on last-write-wins on a shared string key.
There was a problem hiding this comment.
Fixed in 4c32d6e. The parent loop's application-only-component mark is skipped when that component's key is already in error, so a root-config package leaf's whole-application failure is no longer overwritten by the enclosing root load in the same pass. Availability derives from the all-threads aggregate (error-wins across threads), so the recorded failure drains the node.
Lavinia, via Claude
…ted loads do not overwrite it Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…not just directory-scanned ones Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // Declared out here so the catch can see it. Only the top-level application load owns this key: a | ||
| // load is top-level when it has no inherited appName (a directory-scanned app) or its appName | ||
| // matches its own directory (a root-config package application). A nested sub-component inherits | ||
| // the parent's appName, which differs from its own directory, so it is excluded and cannot |
There was a problem hiding this comment.
Follow-up on the open thread below: the clobber is fixed, but nested package: whole-app failures are now untracked rather than clobbered
File: components/componentLoader.ts:832 (and the if (appStatusKey) guards at 1281/1307)
What: 07b26245 resolves the immediate clobbering by making appStatusKey undefined whenever options.applicationScope is passed — i.e. for every nested package: load (the recursive call at line ~1007-1021 always passes applicationScope: subApplicationScope). That does stop the parent's success write from overwriting a nested failure written moments earlier, but it does so by having the nested call skip the status write entirely, on both the failure (1307) and success (1281) side.
Trace of what happens when a nested package's own outer try throws (bad config, branch prep, etc. — the same class of error the outer catch exists to report): the nested loadComponent invocation's catch (1300-1308) still doesn't rethrow, so the parent's await loadComponent(...) at line ~1007 resolves normally with extensionModule === undefined. That hits the parent's pre-existing if (!extensionModule) { componentLifecycle.loaded(componentStatusName, 'Application component ... processed'); continue; } heuristic (~line 1037-1041), which marks the parent's own per-component key for that nested package loaded — the opposite of what happened. Neither appStatusKey (now skipped) nor the parent's componentStatusName (marked loaded via the heuristic) ever records the failure, so getAvailabilityStatus() (server/status/index.ts:184) has nothing to see.
This is reachable, not hypothetical: integrationTests/components/fixtures/acl-connect-with-sys/config.yaml mixes a direct jsResource with a package:-declared '@harperdb/acl-connect' in one app — exactly the composition this trace walks through.
Why it matters: this is the same class of bug #3184 is about — a component fails to load and serves errors over its URL space while availability keeps reporting healthy — just reached through nested composition instead of the direct case the new tests cover. The new componentFailureAvailability.test.js registers its probe plugin directly (appName: THROWING_APP, no applicationScope), so it never exercises this path either.
Suggested fix: give the nested load its own distinct key instead of undefined (e.g. the parent's componentStatusName for that entry, or `${appStatusKey}.${componentName}`) so a nested whole-app failure still lands somewhere the aggregate reads, and have the parent's !extensionModule heuristic (or the recursive call's return) distinguish "nested failure, already reported" from "application-only component, nothing to report" rather than treating both as loaded.
There was a problem hiding this comment.
Fixed in 4c32d6e. The parent loop's application-only-component mark is skipped when that component's key is already in error, so a root-config package leaf's whole-application failure is no longer overwritten by the enclosing root load in the same pass. Availability derives from the all-threads aggregate (error-wins across threads), so the recorded failure drains the node.
Lavinia, via Claude
cb1kenobi
left a comment
There was a problem hiding this comment.
The GTM availability read now combines the operator record with the all-threads component aggregate, so a worker-side load failure drains the node and an operator Unavailable still wins. Whole-application failures get a full lifecycle so a later successful load can rejoin without clobbering a drain, including package-style top-level apps whose appName matches their directory. Nested package whole-app tracking is already raised on the appStatusKey line. No additional blocking issue on the changed lines.
—
Reviewed 3d09cac
| // the parent's appName, which differs from its own directory, so it is excluded and cannot | ||
| // overwrite the parent's status. Root has no single owning application. | ||
| const appDirName = basename(componentDirectory); | ||
| const appStatusKey = !isRoot && (appName === undefined || appName === appDirName) ? appDirName : undefined; |
There was a problem hiding this comment.
Follow-up on the open thread at line 832: still reachable, and it also defeats this commit's own stated goal for root-config package apps.
What: This widens appStatusKey so a root-config package: app (where appName === appDirName, both resolve to componentName) is no longer excluded like a true nested sub-component. But componentStatusName in the parent's loop (line 955, isRoot ? componentName : ...) resolves to that exact same string for this case. When that recursive load's own outer catch does componentLifecycle.failed(appStatusKey, ...) (line 1310), control returns to the parent's for loop with extensionModule === undefined, which immediately hits if (!extensionModule) (line 1040) and calls componentLifecycle.loaded(componentStatusName, ...) on the identical key — overwriting error back to loaded in the same synchronous load pass. setStatus (components/status/ComponentStatusRegistry.ts:106) is unconditional last-write-wins, so nothing protects the failed() write.
For the nested-within-app case this thread originally flagged (integrationTests/components/fixtures/acl-connect-with-sys/config.yaml's '@harperdb/acl-connect' entry), appStatusKey is still undefined today — that failure is simply never recorded, then the same heuristic marks the parent's componentStatusName entry for it loaded too. Unchanged by this push.
Why it matters: This is the exact bug class #3184 is about, for precisely the case this commit's message claims to newly cover ("record whole-application failures for package apps too"). A root-config package: app whose own config/branch-prep throws is still marked healthy moments later via this pre-existing heuristic — the fix never reaches the real root-loader path. The new "package-style top-level failure" test calls loadComponent directly and bypasses this heuristic entirely (its own comment says so: "does not cover the enclosing root-loop overwrite"), so nothing in this PR exercises the path that actually ships. The PR body discloses this under "Known limitations" as a deferred follow-up, but it's a plain overwrite bug in code immediately adjacent to what this PR is actively changing, not a separate subsystem.
Suggested fix: Have if (!extensionModule) distinguish "nothing to report" from "the nested load already reported an outcome on this key" — e.g. skip the loaded() overwrite when the current status for componentStatusName/appStatusKey is already error from this pass, or have the recursive loadComponent call return its own success/failure so the parent doesn't re-derive it from extensionModule truthiness. The same fix would let the nested-within-app case get a distinct key instead of being excluded outright.
There was a problem hiding this comment.
Fixed in 4c32d6e. The parent loop's application-only-component mark is skipped when that component's key is already in error, so a root-config package leaf's whole-application failure is no longer overwritten by the enclosing root load in the same pass. Availability derives from the all-threads aggregate (error-wins across threads), so the recorded failure drains the node.
Lavinia, via Claude
…ate (loadComponent fs.watch EPERM) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| async function getAvailabilityStatus(): Promise<StatusRecord<'availability'> | undefined> { | ||
| const record = (await getStatusTable().get('availability')) as StatusRecord<'availability'> | undefined; | ||
| if (record?.status === 'Unavailable') return record; | ||
| const failed = statusInternal.componentStatusRegistry.getComponentsByStatus( |
There was a problem hiding this comment.
With threads.count > 0, get_status runs on the operations thread, whose loadRootComponents() sets resources.isWorker = false (server/loadRootComponents.js:74-88). Plugin handleApplication runs only when that flag is true (components/componentLoader.ts:1066-1103), so a plugin that throws only there is recorded as failed in an HTTP worker while this local registry remains healthy. get_status {id:'availability'} then returns the stored Available record even though requests routed to that worker fail. The new test uses resetResources(), whose default is isWorker = true, so it does not reach this boundary. Please bring serving-worker load failures into the availability decision without adding a cross-thread round trip to every status poll, and verify the one-worker case.
There was a problem hiding this comment.
Fixed in 4c32d6e. Availability no longer makes a cross-thread round trip on every poll: it reads the all-threads aggregate (statusInternal.query.allThreads) cached with a 2s TTL behind a single shared in-flight refresh, so most polls are served locally and the aggregate refreshes at most once per TTL. The aggregate is the source that sees a worker's load failure the operations thread never runs. Regression cases cover a load failure draining and then healing, and (stubbing the aggregate as status.test.js already does for this method) a worker-only error draining while this thread's registry is clean, which is red on the old this-thread read.
Lavinia, via Claude
| .then(() => { | ||
| // The catch below records a failed status on this thread; record recovery too, or a | ||
| // retried start would leave the application in error here until the process restarts. | ||
| componentLifecycle.loaded(application, `Component '${application}' dedicated worker ready`); |
There was a problem hiding this comment.
A dedicated worker can report ready after its application plugin failed: the loader catches plugin errors and finishes loading (components/componentLoader.ts:1207-1215), then the worker sends CHILD_STARTED after loadRootComponents(true) resolves (server/threads/threadServer.js:203-205, server/threads/threadServer.js:273). This .then marks the application healthy on the operations thread, which never loads an isolated application's plugins itself. For an isolated app whose handleApplication throws, availability therefore stays Available while its routes fail. Please distinguish worker readiness from successful component loading before marking the application loaded; a focused isolated-worker failure check would exercise that distinction.
There was a problem hiding this comment.
Addressed in 4c32d6e. watchDedicatedStart's mark records only that the dedicated worker started, not that its components loaded. The worker publishes its real component-load outcome in its own registry, and the availability read is the all-threads aggregate, which reports a component in error when any thread does. So if the isolated app's handleApplication throws, the worker's own error entry keeps the node drained regardless of this readiness mark; keeping the mark lets a successful retry clear the earlier start failure, which would otherwise persist on the operations thread until a restart.
Lavinia, via Claude
| resources.set('', new ErrorResource(error)); | ||
| // A whole-application failure serves errors over the entire URL space; it must reach the | ||
| // status registry like per-component failures do, so availability reflects it (#3184). | ||
| componentLifecycle.failed( |
There was a problem hiding this comment.
This adds an error under the bare application name, but a later successful loadComponent reports loaded only under plugin names such as app.plugin (components/componentLoader.ts:943-953, components/componentLoader.ts:1205-1206). The registry keeps the bare app error, so after a bad application config is corrected and the application loads successfully on a subsequent load, availability can remain Unavailable. The added recovery test only clears plugin names, so it misses this case. Please retire the application-level error when that application's load genuinely succeeds, and check a failed outer load followed by a successful load of the same application identity.
There was a problem hiding this comment.
Fixed in 4c32d6e. The whole-application status is keyed by the application's own directory with a full lifecycle (failed in the outer catch, loaded once the load completes), so a corrected config that reloads successfully clears the error and rejoins. And loadComponentDirectories retires the status of directory applications that disappear between load cycles, so a dropped application's stale error stops draining. Retirement removes only the exact application key, never a dotted prefix, so it cannot erase a separate application whose name shares a prefix; retiring nested-package plugin entries on the operations thread needs per-application ownership tracking and is left as a documented follow-up.
Lavinia, via Claude
| } | ||
|
|
||
| statusLogger.debug?.('getStatus', id); | ||
| if (id === 'availability') return getAvailabilityStatus() as Promise<StatusRecord>; |
There was a problem hiding this comment.
The derived availability applies only to get_status {id:'availability'}. With the same failed component, get_status without an id still returns the raw hdb_status search as systemStatus (server/status/index.ts:147-165), where availability remains Available. Callers using the aggregate response therefore see a contradictory value. Please apply the same availability resolution when assembling systemStatus, while preserving its existing response shape.
There was a problem hiding this comment.
Fixed in 4c32d6e. getAllStatus resolves the availability record in its systemStatus stream the same way the single-id read does, so a no-id get_status agrees with get_status {id:'availability'} for the same failure. Covered by a regression case asserting the availability record in systemStatus reads Unavailable when a component has failed.
Lavinia, via Claude
cb1kenobi
left a comment
There was a problem hiding this comment.
A whole-application error is cleared as soon as a reload starts, so GTM can see Available while the node is still serving ErrorResource. If that reload hangs, the node stays in rotation. Remove the new loading() call and heal only with loaded() after a successful load.
—
Reviewed 5d0901e
| const componentFunctionality = {}; | ||
| // Mark the application itself loading before its plugins, so a reload of a previously failed | ||
| // application clears that error the moment it starts rather than after it finishes (#3184). | ||
| if (appStatusKey) componentLifecycle.loading(appStatusKey); |
There was a problem hiding this comment.
loading() fail-opens availability mid-reload
Severity: High
getAvailabilityStatus only treats component ERROR as a drain; LOADING is ignored. This componentLifecycle.loading(appStatusKey) call replaces a whole-application ERROR as soon as a reload gets past config parse, so the GTM path is served Available while ErrorResource is still mounted and before the reload has succeeded. If the plugin loop then hangs, the node stays Available for the duration of the hang.
Simplest fix: delete this loading() call. Keep loaded() on success and failed() in the outer catch so a successful reload still heals and the node stays drained until that success is known.
—
Reviewed 5d0901e
There was a problem hiding this comment.
Fixed in 4c32d6e. Removed the loading() marker. The whole-application error is cleared only by loaded() after the load genuinely completes, so a slow or hung reload no longer serves Available while ErrorResource is still mounted.
Lavinia, via Claude
cb1kenobi
left a comment
There was a problem hiding this comment.
This merge from main does not change the GTM availability derivation or the loader status lifecycle. No new blocking issue was found on the changed lines. Existing threads already cover the loading() fail-open, isolated-worker ready mark, and nested package status gaps, so those are not repeated.
—
Reviewed 14842c1
…ealth signal (no per-poll round trip) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // here so the catch can see it. Keyed by the load's own directory, not the inherited appName, so | ||
| // a nested package's load and its enclosing application get distinct keys and neither overwrites | ||
| // the other's failure. Root has no single owning application. | ||
| const appStatusKey = isRoot ? undefined : basename(componentDirectory); |
There was a problem hiding this comment.
Still open (continuation of the threads at this file's prior lines 832/835): root-config package: app failures are still clobbered.
The new appStatusKey = basename(componentDirectory) does fix the true nested-sub-component case (distinct key from its enclosing app now). But for a root-config package: app, the nested load's own directory basename is identical to the root loop's componentStatusName (isRoot ? componentName : ..., line 958). When that nested load's outer catch fires failed(appStatusKey, ...), the parent's if (!extensionModule) heuristic (line 1046) still overwrites it back to loaded on the same key in the same pass — unchanged from the prior rounds' trace. This is the exact bug class #3184 is about, reachable for any root-config package app, and is disclosed in the PR body as a "known limitation" rather than fixed. Since it's a plain last-write-wins overwrite in code this PR is actively restructuring (not a separate subsystem), it's still a correctness gap on the path this PR's own tests explicitly say they don't cover ("does not cover the enclosing root-loop overwrite").
There was a problem hiding this comment.
Fixed in 4c32d6e. The parent loop's application-only-component mark is now skipped when that component's key is already in error, so a root-config package leaf's whole-application failure recorded under that name is no longer overwritten by the enclosing root load in the same pass. Availability derives from the all-threads aggregate (error-wins across threads), so the recorded failure drains the node.
Lavinia, via Claude
| return sharedBytes; | ||
| } | ||
|
|
||
| function localSlot(): number { |
There was a problem hiding this comment.
Suggestion (non-blocking): localSlot() is stable for pool workers (fixed workerIndex reused across crash/rolling restarts), but isolated-application dedicated workers get their index from nextIsolatedIndex in socketRouter.ts, a counter that only ever increments and is never reclaimed across drop/redeploy/restart-exhaustion cycles. Over a long-lived process with enough isolated-app churn, that index can exceed SLOTS (1024) and two live threads could alias onto the same byte, letting one thread's write silently clobber another's. Consider keying slots by a bounded/recycled thread identity, or sizing SLOTS from the actual max isolated-worker-index budget rather than a fixed constant.
There was a problem hiding this comment.
Resolved by removing the mechanism this was about: components/componentHealth.ts and its per-thread slot buffer are gone as of 4c32d6e. Availability now reads the all-threads aggregate (cached with a short TTL) rather than hand-rolled per-thread slots, so there is no worker-index slot to alias, and worker churn is handled by the aggregate itself (a dead worker drops out of the broadcast, a restarted one reports its current state).
Lavinia, via Claude
cb1kenobi
left a comment
There was a problem hiding this comment.
The new commit adds dedicated-worker retry regression coverage without introducing a blocking defect. The existing application-status key collision was already raised at the same code path and is not repeated.
—
Reviewed aad6397
cb1kenobi
left a comment
There was a problem hiding this comment.
The merge from main does not change the availability derivation or the loader status lifecycle. A worker-side component error still drains the node, an operator Unavailable still wins, and a whole-application error still heals only after a successful load. No new blocking defect was confirmed on the changed lines. The nested-package status-key collision is already raised at appStatusKey and was not repeated.
—
Reviewed 20d14de
…wo apps' same-named packages do not collide Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // uses), so two applications each nesting a same-named package do not share one key and mask each | ||
| // other's failure. Root has no single owning application. | ||
| const appDirName = isRoot ? undefined : basename(componentDirectory); | ||
| const appStatusKey = appDirName && appName && appName !== appDirName ? `${appName}.${appDirName}` : appDirName; |
There was a problem hiding this comment.
Two sibling status writes still use the bare, unqualified directory name, undoing this line's fix for nested package: components.
What: appStatusKey is now qualified by the enclosing application (${appName}.${appDirName}) for a nested package: load — the 7ee87d3 fix for the cross-app collision flagged earlier on this thread. But two other writes in this same function still key off the bare basename(componentDirectory) instead of appStatusKey:
- Line ~939-950, the
env:block's failure path:const componentStatusName = basename(componentDirectory);...componentLifecycle.failed(componentStatusName, error, ...). - Line ~1320-1323, the pre-existing "did not load any modules" heuristic:
componentLifecycle.failed(basename(componentDirectory), errorMessage);.
For a nested package (e.g. @harperdb/acl-connect under enclosing-app), basename(componentDirectory) is 'acl-connect', but this load's appStatusKey is now 'enclosing-app.acl-connect' — a different string.
Why it matters: the unconditional if (appStatusKey) componentLifecycle.loaded(appStatusKey, ...) (~line 1313) — the self-heal that clears a prior failure on a later successful reload — only ever writes the qualified key. It never touches the bare key these two sites use. So:
- No self-heal for nested components: once either site fires for a nested package, that bare-key
errorentry is permanent until process restart, even after the qualified key goes healthy again — reintroducing the exact "stale error survives a successful reload" bug this PR fixed for the outer-catch path (8f6e00b). - Cross-app collision reintroduced: two different applications nesting a same-named package still share this bare key, so one app's write via either site collides with another's — the same bug class 7ee87d3 was written to fix, just via these two sibling paths instead of the outer catch.
Before this PR these writes were inert (nothing read ComponentStatusRegistry for availability); this PR is what makes them live drain sources, for failure modes (a component's env: gate rejecting, or a package whose declared components register no functionality) that are realistic for a package: dependency to hit.
Suggested fix: use appStatusKey (or the same ${appName}.${basename} qualification) at both sites instead of the bare basename(componentDirectory), and apply the same "don't overwrite an ERROR recorded this pass" guard the !extensionModule heuristic already uses (line ~1071) so a later successful reload of the same nested component actually clears it.
There was a problem hiding this comment.
Fixed in 19ef2f7. Both sites now record under the same resolved-directory key the outer catch and the success path use (the env-gate catch and the 'did not load any modules' heuristic), so a later clean reload's loaded(appStatusKey) heals them and they cannot collide across applications. Updated componentSecretsGate.test.js, which asserted the old bare-name key, to the resolved-directory key.
Lavinia, via Claude
| /** | ||
| * Forget a removed component's status (and its sub-components), so a stale error no longer counts | ||
| * toward availability once the component is gone. | ||
| */ |
There was a problem hiding this comment.
Suggestion (non-blocking): this JSDoc says retiring a component clears "its sub-components," but ComponentStatusRegistry.retire() right below explicitly does the opposite — it deletes only the exact key and never a dotted prefix, precisely because sub-component/nested-package entries need per-application ownership tracking to retire safely (its own docstring, and the PR description's "Known limitations", both say so). Drop the "(and its sub-components)" parenthetical here so this comment doesn't promise cleanup the implementation intentionally doesn't do.
There was a problem hiding this comment.
Fixed in 19ef2f7: dropped the '(and its sub-components)' wording so the JSDoc matches retire(), which removes only the exact key.
Lavinia, via Claude
cb1kenobi
left a comment
There was a problem hiding this comment.
The revised key qualification still allows collisions between dotted application names and nested packages. Use an unambiguous structured or namespaced key so one load cannot hide another application's failure.
—
Reviewed d46486d
| // here so the catch can see it. A top-level application load uses its own directory name; a nested | ||
| // package load qualifies that with the enclosing application (the same shape componentStatusName | ||
| // uses), so two applications each nesting a same-named package do not share one key and mask each | ||
| // other's failure. Root has no single owning application. |
There was a problem hiding this comment.
Status keys still collide with dotted application names
Severity: High
A top-level application named shop.backup receives the same shop.backup key as package backup nested under application shop. Registry updates are last-write-wins, so either application's successful load can overwrite the other's active error and make GTM report Available while the failing application still serves errors.
Use an unambiguous structured or namespaced key—such as a tuple encoding or a separator prohibited in application names—instead of dot concatenation.
—
Reviewed d46486d
There was a problem hiding this comment.
Fixed in 19ef2f7. The whole-application status key is now the load's resolved directory (realpathSync of the component directory), not any name, so the ambiguous cases can't collide: a top-level app 'shop.backup' and a package 'backup' nested under 'shop' resolve to different directories; a package whose basename equals its enclosing app has its own path; and two apps nesting the same package have distinct paths. This drops the appName !== appDirName nesting heuristic entirely. Added a regression case that loads two different directories sharing a basename and asserts each failure is recorded under its own key.
Lavinia, via Claude
cb1kenobi
left a comment
There was a problem hiding this comment.
A nested package whose folder name matches its enclosing application still uses the unqualified status key, so the parent's success write clears that nested failure and GTM can keep sending traffic while those routes serve errors. Detect nested loads from enclosing identity and always give them a distinct key; skipping loaded() when the key is already ERROR is a stopgap. The dotted-name collision already on the thread is a separate issue and is not repeated.
—
Reviewed d46486d
| // uses), so two applications each nesting a same-named package do not share one key and mask each | ||
| // other's failure. Root has no single owning application. | ||
| const appDirName = isRoot ? undefined : basename(componentDirectory); | ||
| const appStatusKey = appDirName && appName && appName !== appDirName ? `${appName}.${appDirName}` : appDirName; |
There was a problem hiding this comment.
Nested same-name package shares the parent status key
Severity: High
When a directory application shop nests a package: whose folder basename is also shop (including scoped packages like @org/shop), appName === appDirName, so this expression leaves appStatusKey unqualified. The nested outer catch records failed('shop'), then the parent's later unconditional loaded('shop') overwrites it in the same pass. GTM is served Available while that nested package still serves ErrorResource. The new nested-key test uses different names (enclosing-app / shared-package) and misses this.
Suggested fix: detect nested loads by inherited enclosing identity (for example options.applicationScope passed in), not by appName !== appDirName, and always give them a distinct key. Stopgap: skip loaded(appStatusKey) when that key is already ERROR, matching the !extensionModule guard.
—
Reviewed d46486d
There was a problem hiding this comment.
Fixed in 19ef2f7. The whole-application status key is now the load's resolved directory (realpathSync of the component directory), not any name, so the ambiguous cases can't collide: a top-level app 'shop.backup' and a package 'backup' nested under 'shop' resolve to different directories; a package whose basename equals its enclosing app has its own path; and two apps nesting the same package have distinct paths. This drops the appName !== appDirName nesting heuristic entirely. Added a regression case that loads two different directories sharing a basename and asserts each failure is recorded under its own key.
Lavinia, via Claude
| // here so the catch can see it. A top-level application load uses its own directory name; a nested | ||
| // package load qualifies that with the enclosing application (the same shape componentStatusName | ||
| // uses), so two applications each nesting a same-named package do not share one key and mask each | ||
| // other's failure. Root has no single owning application. |
There was a problem hiding this comment.
Confirming cb1kenobi's finding above — independently re-traced against current HEAD (8ac7327, unchanged since d46486d): a top-level directory app named e.g. shop.backup gets appName === appDirName === 'shop.backup', so appStatusKey = bare 'shop.backup'. A package literally named backup nested under app shop gets appName = 'shop', appDirName = 'backup', so appStatusKey = \${appName}.${appDirName}` = 'shop.backup'` — the identical string. Registry writes are last-write-wins, so either one's success can silently overwrite the other's genuine failure. Still open, unaddressed — blocker.
There was a problem hiding this comment.
Fixed in 19ef2f7. The whole-application status key is now the load's resolved directory (realpathSync of the component directory), not any name, so the ambiguous cases can't collide: a top-level app 'shop.backup' and a package 'backup' nested under 'shop' resolve to different directories; a package whose basename equals its enclosing app has its own path; and two apps nesting the same package have distinct paths. This drops the appName !== appDirName nesting heuristic entirely. Added a regression case that loads two different directories sharing a basename and asserts each failure is recorded under its own key.
Lavinia, via Claude
| // uses), so two applications each nesting a same-named package do not share one key and mask each | ||
| // other's failure. Root has no single owning application. | ||
| const appDirName = isRoot ? undefined : basename(componentDirectory); | ||
| const appStatusKey = appDirName && appName && appName !== appDirName ? `${appName}.${appDirName}` : appDirName; |
There was a problem hiding this comment.
Confirming cb1kenobi's finding above — independently re-traced against current HEAD: when a directory app's own name equals its nested package's resolved directory basename (app shop nesting a package whose node_modules path basename is shop, including a scoped package like @org/shop), appName === appDirName inside the nested call, so appStatusKey collapses to the unqualified 'shop' — identical to the parent's own top-level key for that same app. The parent's end-of-load loaded(appStatusKey) (~line 1313) then overwrites the nested load's failed(appStatusKey) in the same pass. Still open, unaddressed — blocker. Same root cause as the :857 thread: appName !== appDirName is the wrong proxy for "is this a nested load"; the suggested fix there (key off options.applicationScope/an explicit nesting flag instead of name equality) would close both.
There was a problem hiding this comment.
Fixed in 19ef2f7. The whole-application status key is now the load's resolved directory (realpathSync of the component directory), not any name, so the ambiguous cases can't collide: a top-level app 'shop.backup' and a package 'backup' nested under 'shop' resolve to different directories; a package whose basename equals its enclosing app has its own path; and two apps nesting the same package have distinct paths. This drops the appName !== appDirName nesting heuristic entirely. Added a regression case that loads two different directories sharing a basename and asserts each failure is recorded under its own key.
Lavinia, via Claude
| // The application's plugins loaded without a whole-application throw, so clear its top-level | ||
| // status. Done before the "did not load anything" heuristic below so that heuristic's own | ||
| // failure report stands. Per-plugin failures keep their own error entries. | ||
| if (appStatusKey) componentLifecycle.loaded(appStatusKey, `Application '${appStatusKey}' loaded`); |
There was a problem hiding this comment.
Continuing my own prior open finding (posted at line 859 in the previous round) — still unaddressed and still true at this head: this self-heal only clears appStatusKey, but two sibling failure sites still key off the bare basename(componentDirectory) instead of appStatusKey — the env: block's failure path (componentStatusName = basename(componentDirectory) a few dozen lines up) and the "did not load any modules" heuristic just below (componentLifecycle.failed(basename(componentDirectory), errorMessage)). For a nested package: load these differ from appStatusKey, so (1) once either site fires for a nested component, this loaded(appStatusKey) never clears it — that error is permanent until process restart even after a later successful reload; and (2) two apps nesting a same-named package still collide through these two bare-key sites. Blocker — route both through appStatusKey (with the same "don't overwrite an ERROR from this pass" guard the !extensionModule heuristic above already uses).
There was a problem hiding this comment.
Fixed in 19ef2f7. Both sites now record under the same resolved-directory key the outer catch and the success path use (the env-gate catch and the 'did not load any modules' heuristic), so a later clean reload's loaded(appStatusKey) heals them and they cannot collide across applications. Updated componentSecretsGate.test.js, which asserted the old bare-name key, to the resolved-directory key.
Lavinia, via Claude
…tted names and nested packages cannot collide Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
This merge from main does not change the GTM availability derivation or the loader status lifecycle. A worker-side component error still drains the node, an operator Unavailable still wins, and a whole-application error still heals only after a successful load. No new blocking defect was confirmed on the changed lines. Previously raised appStatusKey collisions remain on the thread and were not repeated.
—
Reviewed 8ac7327
…n reload (unique path key needs no same-pass guard) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
The aggregate status path treats the asynchronous search result as an iterable, so consuming systemStatus can throw instead of returning health records. Await or chain the search promise before wrapping its records.
—
Reviewed 3c1f032
| const summary: AllStatusSummary = { | ||
| systemStatus: statusRecords as Promise<AsyncIterable<StatusRecord>>, | ||
| systemStatus: resolveAvailabilityInStream( | ||
| getStatusTable().search([]) as AsyncIterable<StatusRecord>, |
There was a problem hiding this comment.
systemStatus iterates an unresolved search promise
Severity: High
getStatusTable().search([]) returns the Promise<AsyncIterable<StatusRecord>> previously assigned directly to systemStatus, but this cast passes that promise to resolveAvailabilityInStream, whose for await expects an iterable. Consuming systemStatus from the no-id get_status path therefore throws instead of returning the aggregate health records. Simplest fix: preserve the promise shape and chain it, such as getStatusTable().search([]).then(records => resolveAvailabilityInStream(records, derivedAvailability)).
—
Reviewed 3c1f032
There was a problem hiding this comment.
Same fix in 9a2967a. getAllStatus now consumes getStatusTable().search([]) with for await directly (the idiom componentSecrets and secretOperations already use) and builds a plain array, so nothing hands an unresolved value to a for-await and the no-id path returns the records.
Lavinia, via Claude
| // by a later clean reload. The key is unique per load, so this cannot clear a different load's | ||
| // failure. Done before the "did not load anything" heuristic below so that heuristic's own | ||
| // failure report stands. Per-plugin failures keep their own error entries. | ||
| if (appStatusKey) componentLifecycle.loaded(appStatusKey, `Application '${appStatusKey}' loaded`); |
There was a problem hiding this comment.
Successful package reload leaves the node drained
Severity: High
A non-root package that successfully produces an extensionModule returns above this line, so loaded(appStatusKey) never clears an ERROR recorded by an earlier outer-catch failure. After the package is fixed and reloads successfully, the stale resolved-directory error continues making availability Unavailable indefinitely. Move this success update before the extensionModule return, or centralize it so every successful exit clears the same key.
—
Reviewed 3c1f032
There was a problem hiding this comment.
Resolved by removal. In scoping this PR down to the availability derivation, I reverted the whole-application appStatusKey machinery in componentLoader (the outer-catch failure, the loaded(appStatusKey) heal, and retirement) back to main and deferred it to a separate follow-up, so this early-return-versus-heal site no longer exists in the PR. The follow-up is exactly where the single-key-across-every-successful-exit design you describe belongs, done with you on the loader side. This PR now only reads the loader's existing per-plugin failed() records through the all-threads aggregate.
Lavinia, via Claude
…application failure keying Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…per house test style Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
The no-id get_status path assigns systemStatus an async generator, so Harper JSON serialization emits {} instead of the status records. Single-id availability still drains on a component error. Materialize the substituted records to a plain array in getAllStatus so HTTP and in-process consumers keep the old shape.
—
Reviewed db9d0ef
|
|
||
| // Yield the stored status records, but substitute the resolved availability value (see | ||
| // getAvailabilityStatus) for the raw availability record, appending it when no record is stored. | ||
| async function* resolveAvailabilityInStream( |
There was a problem hiding this comment.
systemStatus JSON drops records
Severity: High
resolveAvailabilityInStream is a raw async generator. Harper JSON serialization relies on ExtendedIterable.toJSON (caught by JSONStream.stringify via error.resolution) to materialize table search results into an array of records. An async generator has no toJSON, so JSON.stringify of the no-id get_status object emits "systemStatus": {}. HTTP callers of the aggregate response therefore get no availability records, including the derived Unavailable this wrap was added to serve.
Simplest fix: getAllStatus is already async — await the search results, substitute/append the derived availability into a plain array, and assign that array to systemStatus. Do not return a generator or a Promise of a generator.
—
Reviewed db9d0ef
There was a problem hiding this comment.
Fixed in 9a2967a. getAllStatus now materializes the stored records into a plain array (with the derived availability substituted, or appended when nothing is stored) and assigns that array to systemStatus; the async generator is gone. So the no-id response serializes to a JSON array of records again, including the derived Unavailable, for HTTP callers.
I also strengthened the regression test: instead of iterating in process it now asserts JSON.parse(JSON.stringify(systemStatus)) is an array containing the derived availability. That assertion is red on the generator revision and green on this one, so the drop cannot sneak back.
Good catch, my in-process test walked right past it.
Lavinia, via Claude
…on keeps the records Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
This is getting to be an AI slop show, and I feel like this a frustrating way to handle a PR. There are pages of AI crap in here, and no one has even engaged with the only real human question/comment in here. Good luck even finding it. |
cb1kenobi
left a comment
There was a problem hiding this comment.
Availability is derived from the operator record plus a cached all-threads component-error aggregate, and the no-id systemStatus path materializes that same value as a JSON array. An operator Unavailable still wins, and a later healthy aggregate returns the stored record without a write. Existing threads already cover the worker-registry gap, the generator serialization hole, and the drain-on-any-error product call. No new blocking defect was confirmed on the changed lines.
—
Reviewed 9a2967a
cb1kenobi
left a comment
There was a problem hiding this comment.
Availability is derived from the operator record plus a cached all-threads component-error aggregate, and the no-id systemStatus path materializes that same value as a JSON array. An operator Unavailable still short-circuits, and a later healthy aggregate returns the stored record without a write. Existing threads already cover the worker-registry gap, the generator serialization hole, collect() fail-open, and the drain-on-any-error product call. No confirmed blocking defect remains on the changed lines.
—
Reviewed 9a2967a
|
marking this as a draft while I work on it, will put it back up for review when I feel confident in it |
This PR makes a Harper node stop reporting healthy to the routing layer (Akamai GTM) once one of its components has failed to load. During the outage tracked in harperdb#3184 a node's jsResource load was wedged and the node served 500s on every request, yet the status endpoint GTM routes on kept saying healthy, so live traffic kept flowing to a broken node and monitoring never noticed. This is the status-honesty half of that incident; the wedge itself (the plugin-lock starvation that caused the hung load) is fixed separately in #2884.
The core change is in
getAvailabilityStatus: theavailabilitystatus the public endpoint serves is now the combination of the operator-set record and live component health, rather than the operator record alone.Root cause
Health was fully decoupled from component-load state. The
availabilityrecord (written byset_status, mirrored verbatim by the external status-check component that GTM polls) was only ever written by operator drain/rejoin tooling. A component that failed to load got anErrorResourcemounted over its URL space (so clients got errors) and its failure recorded only in the in-memoryComponentStatusRegistry, which nothing on the GTM path consulted. So a node could fail a load, serve errors, and keep advertisingAvailable.The fix
get_statusforid: availabilitynow derives the served value:Unavailable(set_status) always wins and short-circuits the component check.Unavailablewhile any component is in error, and the stored record (Available, or absent) otherwise.Component health is read from the all-threads aggregate (
statusInternal.query.allThreads(), the sourcegetAllStatusalready uses), becauseget_statusruns on the operations thread, which loads components withisWorker = falseand therefore never runshandleApplication— so the jsResource failure that defines this incident happens only on the HTTP workers and is visible only across threads. The aggregate resolves a component toerrorwhen any thread reports it in error, and is correct under worker churn on its own (a dead worker drops out of the broadcast; a restarted one reports its current state). To avoid a cross-thread round trip on every poll (a health endpoint is polled often), the aggregate is cached with a 2s TTL behind a single shared in-flight refresh; the record read is always live.The component errors the aggregate reads are the ones the loader already records:
handleApplicationmarks a pluginfailed(keyed<application>.<plugin>) when its load throws or times out, which is exactly the incident case (a wedged jsResource plugin load). This PR does not change any loader call site — it makes the availability read consult those existing records across threads.Deriving at read time (rather than writing
Unavailableon failure) means nothing goes stale: a component that loads cleanly again heals and the node rejoins on its own, an automatic write can never clobber an operator drain, and deploy-validation failures (which divert into the validation sink, never the live registry) cannot drain a live node.getAllStatusmaterializes the same derived value into itssystemStatusarray (a plain array, not a generator, so the HTTP response serializes to the records rather than an empty object), so the no-idget_statusresponse never contradicts the single-id{id: 'availability'}read for the same failure.Review coverage
Reviewed across cross-model delta rounds; an independent Codex pre-push review (graded leg) ran locally, and the other families are this PR's CI reviews: gemini-code-assist (Google) and the Claude PR review (Anthropic, the authoring family, not counted). Fixed in response to review: the availability-vs-operations-thread gap (@cb1kenobi), the no-id
systemStatusresponse shape (materialized to a plain array so JSON serialization keeps the records, not an empty object) (@cb1kenobi), the stale-whole-application error and clobber cases (Gemini/@cb1kenobi), thesystemStatusinconsistency and the per-poll cross-thread cost (@kriszyp), and a new-sinon test-style violation. The whole-application-level failure recording that earlier revisions added to the loader (config errors, theenv:gate, dedicated-worker start) repeatedly collided over status-key conventions in review; rather than keep iterating that keying in this PR, it is removed here and deferred to a dedicated follow-up (see below), leaving this PR to the per-component derivation that fixes the incident.Scope notes
availabilityread (single-id and withinsystemStatus); otherget_statusfields are unchanged.LOADINGnever drains — onlyERROR— so a node is not pulled from rotation during normal boot or reload while components are still loading.For the human reviewer
Open judgment calls for a maintainer:
ERRORreads the nodeUnavailable. That is the honest match for the incident (a failed jsResource serves 500s), but a non-serving auxiliary component in error would also drain the node. Options if that is too broad: narrow to components that mount over the request path, or add a per-componentcriticalflag. This is a product call and I defer to you and @kriszyp.branchedDatabases) and dedicated-worker start failures are not yet reflected in availability. Recording them honestly needs a single status-key convention across every failure site plus retirement of a removed application's stale error, keyed by resolved directory rather than a name (application and package names can contain dots and collide). That is worth doing with the loader's owner rather than bolted on here; filing as a follow-up.crossThread'scollect()resolves with partial responses on timeout, so if the only worker reporting a failure stops answering, the next refresh can lose that error. This is pre-existing behaviour thatgetAllStatusalready relies on; closing it needscollect()to expose completeness so the read can hold a prior failure until a complete healthy snapshot confirms recovery.getAggregatedFromAllThreads, matchingstatus.test.jswhich stubs the same method; the alternative (real worker threads in a unit test) is not available. Acceptable, or should it move to an integration test?Verification
All runs from a rebuilt dist in a worktree off this branch (the harness resolves
#srcto dist, so a.tsedit without a rebuild tests stale code):componentFailureAvailability.test.js): 6 passing. Run against a build without the derivation (server/statusreverted tomain), 5 of the 6 fail and 1 passes coincidentally (the operator-drain case, wheremainserves the storedUnavailableverbatim) — so the suite is red without the fix and green with it. The no-id case asserts the response serializes to a JSON array of records (JSON.parse(JSON.stringify(systemStatus))), which is red against a generator-shapedsystemStatusand green against the materialized array.unitTests/server/status+unitTests/components/status: 155 passing, unchanged.componentLoader.test.js: 18 passing (1 pending), unchanged.componentSecretsGate.test.js: 2 passing, unchanged.windowsGate.mjs): itsloadComponentcalls install a chokidar watcher that leaks anEPERMwatch into a later hook on Windows, the same class already excludingEntryHandlerandcomponentLoadertests. Coverage stands on Linux.Complexity: medium
Lavinia, via Claude
🤖 Generated with Claude Code
Review-Coverage: authored=claude @ 9a2967a; ran=codex,gemini; rounds=10 @ 9a2967a
Human-Review-Need: 4 @ 9a2967a