Skip to content

fix(rest): actionable 404 for a deployed-but-not-restarted component - #1806

Merged
kriszyp merged 7 commits into
mainfrom
fix/inactive-component-404
Jul 21, 2026
Merged

fix(rest): actionable 404 for a deployed-but-not-restarted component#1806
kriszyp merged 7 commits into
mainfrom
fix/inactive-component-404

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

  • deploy_component writes/extracts a new component to disk immediately, but its routes aren't registered into the live router until Harper restarts. Hitting the component's URL in that window fell through to the same generic 404 as any nonexistent path.
  • On a route miss, server/REST.ts now checks whether the URL's first segment names a directory under the components root. If it does, the response is a 404 naming the component and explaining that a restart is required (Component 'X' is deployed but Harper must be restarted before its routes are active.), instead of a bare Not found.
  • node_modules and the .deploy-aside staging directory are excluded from the check (mirrors the existing exclusion in the getComponents operation), since they live under componentsRoot but aren't themselves components.

Why

Fixes harper#674.

Note on scope: I was not able to reproduce the literal TypeError: rg.getMatch is not a function from the issue on current main — every resources.getMatch(...) call site (REST, WebSocket, GraphQL, MQTT) already guards against a route miss and returns a clean error, so a genuinely unloaded component's URL already 404s without crashing. What was still missing was the actionable part of the acceptance criteria: the 404 gave no indication that the path belonged to a deployed-but-inactive component. This PR adds that signal for the REST path (the scenario in the issue — "navigate to the component's URL" in a browser). WebSocket/GraphQL/MQTT route misses still return their existing (non-crashing, but generic) errors; extending the same messaging to those is a natural follow-up if wanted, but felt like scope creep for this fix.

Where to look

  • server/REST.ts: new findInactiveComponent() helper + the if (!entry) branch in http(). The helper only does a single fs.access stat call, and only on the already-cold "no route matched" path, so it shouldn't affect hot-path throughput (verified via the existing deploy-from-source.test.ts PUT/GET throughput benchmark, which is unaffected).
  • integrationTests/deploy/inactive-component-404.test.ts + its two fixtures: reproduces the exact scenario (pre-existing active app + a second component deployed with restart: false) and asserts both the new actionable 404 and that a truly nonexistent path is unaffected.

Test plan

  • npm run build, npm run lint:required, npx tsc --noEmit — all clean.
  • npm run test:unit:main, test:unit:resources — all passing (pre-existing test:unit:server failure in workerData-fixture.js reproduces identically on a clean main checkout; unrelated to this change).
  • New integration test passes; existing deploy-from-source.test.ts (including the throughput benchmark) passes unaffected.

Generated by Claude Sonnet 5.

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses an issue where deploying a component without restarting Harper leaves its routes unregistered. It introduces a check in server/REST.ts to identify inactive components and return an actionable 404 error, accompanied by integration tests. The review feedback suggests using stat instead of access to ensure the inactive component path is actually a directory (preventing false positives for files), and importing from node:assert/strict rather than node:assert in the test suite.

Comment thread server/REST.ts Outdated
Comment thread server/REST.ts
Comment thread integrationTests/deploy/inactive-component-404.test.ts
Comment thread server/REST.ts Outdated
Comment thread integrationTests/deploy/inactive-component-404.test.ts
@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Reviewed. One open item remains: the still-unresolved thread on server/REST.ts about the actionable 404 keying off a global restart-pending flag (misidentifying unrelated active components) was only partially addressed — see inline comment.

kriszyp and others added 6 commits July 15, 2026 18:03
… component

deploy_component writes/extracts a new component to the components root
immediately, but its routes aren't registered into the live router until
Harper restarts. Hitting the component's URL in that window previously fell
through to the same generic 404 as any nonexistent path, giving no signal
that a restart is pending.

When a route lookup misses, check whether the URL's first segment names a
directory under the components root; if so, respond with a 404 naming the
component and explaining that a restart is required, instead of the bare
"Not found". Routing for already-active components and genuinely nonexistent
paths is unaffected.

Refs #674

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… detection

These directories live under componentsRoot but aren't themselves deployed
components; matching them would produce a misleading 404 message. Mirrors
the same exclusion already used by the getComponents operation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t coverage

- findInactiveComponent: use stat/isDirectory instead of access, so a stray
  non-directory file directly under the components root (README.md, .DS_Store,
  etc.) can't produce a false-positive actionable message.
- Gate the actionable message behind an authenticated super_user request
  (request?.user?.role?.permission?.super_user), matching the exact pattern
  already used to gate the OpenAPI JSON endpoint in this same file. Without
  this, an unauthenticated caller could use the response difference (actionable
  vs. generic 404) as an oracle for which component directories exist on disk --
  getComponents already treats that as super_user-only information elsewhere.
- New tests: a non-directory file under components root gets the generic 404;
  node_modules/.deploy-aside stay excluded.
- Import fix: node:assert/strict is restricted by this repo's own lint rule
  (AGENTS.md test style) -- the bot's suggestion was wrong for this repo. Uses
  plain node:assert with explicit strictEqual/deepStrictEqual instead.

Also corrected a flawed test added in this pass: an "unauthenticated request"
test using a bare fetch() with no Authorization header does not actually
exercise a non-super_user path in this integration harness -- Harper's
AUTHORIZE_LOCAL bypass treats loopback requests (127.0.0.x/::1) as an implicit
super_user regardless of whether an auth header is present
(security/auth.ts's `bypassUser ?? getSuperUser()` branch), so "no auth
header" and "super_user" are indistinguishable from a local integration test.
Replaced it with a real non-super_user role+user, authenticated as that role,
which does exercise the gate. Sanity-checked both the stat/isDirectory fix and
the auth-gate: temporarily reverted each, confirmed the corresponding new test
fails with a clear assertion error, then restored the fix and confirmed all 6
tests pass. Also ran deploy-from-source.test.ts (incl. its throughput
benchmark) unmodified to confirm no regression to hot-path routing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per review: only return the "deployed but Harper must be restarted"
404 when a restart is genuinely pending (restartNeeded()), not for any
URL segment that happens to name a directory under componentsRoot.
Without a pending restart, a matching directory is either an
already-active component or not a live component, so fall back to the
generic 404.

Tests now toggle the real restart-needed signal (mutating a watched
file in the already-loaded active app, which is what actually sets the
flag -- a never-loaded deployed component has no watcher) and assert
both sides of the gate: the same deployed-but-inactive component
returns a generic 404 with no restart pending and the actionable 404
once one is. fixture-active-app gains a jsResource file so it has a
live watcher to drive that signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A component deployed with restart:false is written to disk but its
routes are not live until Harper restarts. Previously this left
get_status.restartRequired false and (with the new gate) the
actionable inactive-component 404 never fired for the harper#674
scenario -- a freshly deployed, never-loaded component has no file
watcher, so nothing called requestRestart().

Set the flag explicitly in the restart:false deploy branch, mirroring
how a watched-file change flips the same restart-needed shared buffer.
This is the setter only: it does not itself restart (matching a
watched-file change; in DEV_MODE auto-reload reacts exactly as it
would to any file change). The restart:true and rolling paths are
unchanged -- they already restart. The call sits in the per-node
branch, so a peer applying the replicated deploy sets its own flag the
same way (no new cross-node signal).

Tests: a fresh deploy_component restart:false now asserts
get_status.restartRequired true and drives the actionable 404 without
an unrelated restart pending; the "no restart pending" case uses the
already-active app's directory at boot. Reverts the fixture-active-app
watched-file addition from the previous commit -- no longer needed
since the deploy itself sets the flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Only a brand-new component (no prior directory under componentsRoot)
gets an unconditional requestRestart() from deployComponent now.
Application#isNewComponent, set by extractApplication based on whether
it had to rename an existing directory aside, is the new/existing
signal — no new state-tracking needed.

An existing, already-active component's redeploy is left to its own
running file watcher (Scope/EntryHandler via deployLifecycle.ts's
deploy:start/deploy:end), since some updates (e.g. static files only)
may not need a restart at all, and when one is genuinely needed the
watcher's post-deploy rescan already requests it independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the fix/inactive-component-404 branch from 6d5ba5a to 4ccd8b0 Compare July 16, 2026 00:05
Comment thread server/REST.ts
… to")

Per Kris: a restart is not always strictly required, and the message keys off the
global restartNeeded() flag which can be set by a prior deploy_component restart:false
(cb1kenobi review thread) -- "may need to" avoids over-asserting a restart is required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread server/REST.ts
@kriszyp
kriszyp requested a review from cb1kenobi July 17, 2026 00:41
@kriszyp

kriszyp commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

Decision: going with the current global-restartNeeded() approach for now (approved). The window where an unrelated deploy_component --restart:false could cause the actionable 404 to mislabel a still-active component's sub-path miss is bounded, and the softened "may need to be restarted" wording avoids over-asserting. Scoping the gate to the specific deployed-but-unloaded component (rather than the global flag) is a reasonable future refinement, but not a blocker for this fix. Resolving these threads.

— Claude

@kriszyp
kriszyp merged commit 79a6439 into main Jul 21, 2026
99 of 101 checks passed
@kriszyp
kriszyp deleted the fix/inactive-component-404 branch July 21, 2026 23:01
dawsontoth added a commit that referenced this pull request Jul 21, 2026
The merge with main brought harper#674/#1806: a deploy_component with
restart:false must set get_status restartRequired for a genuinely-new
(never-loaded) component, scoped so a redeploy of an already-active
component stays quiet. Main implemented this only in the one-shot deploy
path (requestRestart() gated on Application.isNewComponent).

Two-phase deploy — the default whenever the system db is replicated — never
carried that behavior, so inactive-component-404.test.ts failed: a fresh
deploy left restartRequired false and the actionable 404 never surfaced.

Two-phase also can't set isNewComponent the way the one-shot path does:
stageApplication extracts into a fresh staging dir, so extractApplication
never sees the live directory and isNewComponent stayed default-true for
everything (which would wrongly mark a restart on a redeploy). Fix both:

- activateApplication now sets isNewComponent from whether the live dir
  existed BEFORE the swap (the two-phase equivalent of extract's in-place
  check) — true for a first deploy, false for a redeploy.
- Extract markRestartRequiredForNewComponent() and call it on every
  no-restart path: one-shot (unchanged behavior), two-phase origin,
  activate-existing, and the per-node peer activate — so a new component
  deployed cluster-wide with restart:false reports restartRequired on
  every node, matching the one-shot peer behavior.

Unit coverage: deployStaging asserts activate sets isNewComponent true for
a first-ever activate and false when replacing an existing live version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp pushed a commit that referenced this pull request Jul 27, 2026
…1934)

Since #1806 (5.2.0-beta.2), redeploying an already-active component recreates
the file watchers instead of raising restartRequired, so a changed file
re-emits as a fresh `add`. #1820 handled this for jsResource; loadEnv and
fastifyRoutes still had the bare `if (eventType !== 'add') requestRestart()`
gate, so a redeployed `.env` (or route file) was silently ignored — the new env
values reached only the scanning thread, running workers kept the old ones,
get_status stayed restartRequired:false, and the post-deploy restart was
skipped (observed in the field on a replicated 5.2.0-beta.2 cluster).

Extract the #1820 tracking into a shared `handleEntryWithRedeployRestart` helper
(re-add of a known file, change/unlink, or a file missing from the post-deploy
re-scan all request a restart) and route loadEnv + fastifyRoutes through it.
jsResource keeps its inline copy (it additionally rejects non-file entries
before the gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
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.

deploy_component without restart: TypeError instead of actionable error when accessing new component URL

2 participants