Alert on disabled modules, and retry repos that were unreachable at startup #27
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/external-module-retry"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #26 — options 3 (notify) and 4 (background retry + late load). Option 1 (serve the existing checkout when a fetch fails) and option 2 (retry on the startup path) are deliberately not here; see below.
Option 3 — a failed module says so
Every entry in
host.failednow raises a notification afterDeno.serve(packages/server/src/modules/alerts.ts). The two open questions in the issue both resolved to "follow the precedent that already exists":module: "core"—PushServicealready raises its delivery alert that way. The module a row names is the one that would have to render it, and a module that never loaded renders nothing.link— leftnull. Every client resolveslinkmodule-relative (/m/<module>/…on the web,module/<name>?path=…on Android), and/systemis a shell route none of them can reach that way. Pointing there would need a link-contract change across core and the mobile repo, so the message names the page instead and the palette finds it. Worth doing separately if the bell grows more core-owned rows.Keyed by repo slug (credential-free, and stable across a
namethat changes from slug to manifest name once a clone works), so a restart loop updates one row rather than stacking one per boot. Error text goes throughredactSecrets.Option 4 — retry, and load into the running server
startExternalModuleRetries(modules/external-retry.ts) runs afterDeno.serve, never before: a second of backoff on the startup path is a second the whole UI is unreachable. Backoff5s → 15s → 45s → 2m → 5m → 10m, then it gives up and says so on the same notification row. On success the module is loaded into the runningModuleHostand the alert becomes an info-level "recovered".Only network-shaped clone failures are retried.
stage: "clone"covers both "cannot resolve host" and "the remote said 403", exactly as the issue notes, so the split is made on git's stderr (isTransientGitError) — an allow-list of network fragments, so anything the remote actually answered is left alone.The app-assembly change
This is the part the issue called out as a change to how the app is assembled, and it is: module routers are no longer mounted into the app directly. A hono router refuses new routes once its matcher is built (
SmartRouter.addthrows), and anything registered after the/api/*404 would be shadowed by it anyway. So/api/mod/*delegates to a child app that owns the full path and is rebuilt wheneverModuleHostfiresonModuleLoaded.The child is handed the original
Request. Rewriting it — which is whatapp.mountdoes — would break every module WebSocket, becauseDeno.upgradeWebSocketonly accepts the objectDeno.serveproduced. Docker's exec/attach terminals live on exactly this path.Verification
deno fmt --check,deno lint,deno task check, 97 server tests green. Four new tests inpackages/server/tests/external_modules_test.ts, including one that builds the app with no modules, answers a request through it (so the matcher is built), then loads a module and gets200from its route.End-to-end against a real server, with a
git daemonstarted only after boot:GET /api/mod/testmod/ping→200 {"pong":"testmod"}(route mounted after serve)ws://…/api/mod/testmod/ws→ echoes, so upgrades survive the indirectionerror "Module … is not running … Retrying in the background."→info "Module "testmod" recovered", one row throughout/api/core/systemflipsfailed→loaded, stale failure record goneNot in scope, on purpose
ModuleFailureshape that does not disable the module, which this PR does not introduce./api/core/modulesonce at boot, so a late module's API is live immediately and its pages appear on the next load. The recovery notification says so.ctx.tasks.onInterruptedfor a late module misses this boot's frozen batch —tasks.start()hands it back once. Registration otherwise works after start (declareSchedulealready handles it). Noted indocs/modules.md.Reviewed the diff
main…feat/external-module-retry(04a5226). Verified the PR's claims locally:deno task checkclean, 97 server tests pass.The design is sound. The child-app indirection for
/api/mod/*is the right call, and the WebSocket reasoning (hand the childc.req.raw, neverapp.mount) is correct —csrfProtectdoesn't touch the body, nothing readsc.env, and the outer security-header middleware still applies, so the indirection is behaviour-preserving. The findings below are all in the retry state machine, which is also the part with no test.Blocking
1. A duplicate name is reported as a recovery —
packages/server/src/modules/external-retry.ts:112load()throwsduplicate module name "X"precisely when a module named X is already inloaded(host.ts:296). So on a name collision this probe is true by definition: the log says "external module loaded after retry", the bell gets the info-level "recovered" row, andhost.failedsimultaneously holds the fresh duplicate failure./systemsays failed, the bell says fixed.The comment at :121 names this case ("or the name collides") but the branch can't be reached for it. Check
host.failed.some((f) => f.configuredAs === ref.url)after the load instead of probingloaded.2. The re-alert suppression does not suppress —
external-retry.ts:100outcome.failure.error !== previous.errorassumes git's stderr is stable across attempts. It isn't — curl embeds timing:That is this PR's own fixture (
tests/external_modules_test.ts:33). The text differs on nearly every attempt, so the alert re-raises on all six.NotificationStore.createon an existing key runsUPDATE … created_at = now(notifications/store.ts:92), so the row jumps to the top of the bell each time and re-publishescreated, which wakes every registered device throughPushService. That is exactly the behaviour the comment says it avoids.Compare something stable — stage plus the matched transient fragment — or only alert on the final attempt.
3. A hanging clone, which is the case this feature exists for, is never retried —
external.ts:197+isTransientGitErrorgit()aborts at 120 s viaAbortSignal.timeout. The kill yields a non-zero exit with partial or empty stderr, so the message isgit clone failed:plus nothing that matchesTRANSIENT_GIT_ERRORS—isRetryableFailurereturns false and no retry is ever scheduled.A half-up VPN or a packet-dropping firewall times out; it does not answer
Connection refused. The doc comment onBACKOFF_MSnames "a VPN link that is seconds away from working" as a target case, and that case is the one that falls through. Detect the abort explicitly (out.signal, or a flag on the thrown error) rather than pattern-matching stderr for it.Should fix
4. The retry loop has no test
The four new tests cover
isTransientGitError,isRetryableFailure, alert dedupe and the late mount. None of them callsstartExternalModuleRetries.ExternalRetryDeps.backoffMs(external-retry.ts:23) is documented as "override the schedule (tests)" and no test uses it — the seam is dead code as it stands. All three blockers above live inside the one function nothing exercises.5. Unbounded notification body —
alerts.ts:60At stage
frontend-build,failure.erroris the entire builder stdout+stderr (external.ts:220); a vite failure is kilobytes. That goes straight into the notificationmessage, into DuckDB, and over SSE to every admin. Existing code truncates in the same situation:push/fcm.ts:149(.slice(0, 300)),docker/backend/update.ts:239(.slice(0, 200)).6. Wrong give-up text —
alerts.ts:47againis false both at the last attempt and when the error stopped being transient. So attempt 2 coming back403produces "Gave up after 2 attempts — restart once the repository is reachable." The repository is reachable; the credential is wrong. Split the two exits.7. Retry eligibility is computed twice —
main.ts:200andexternal-retry.ts:131Two independent evaluations of
isRetryableFailureoverhost.failed, plus the ref lookup repeated in three places. If they ever drift, the bell promises "Retrying in the background" for a module that nothing retries. HavestartExternalModuleRetriesreport which failures it took and drive the alert flag from that.Nits
tests/external_modules_test.ts— the comment "and the registry the shell reads names it too" sits directly aboveassertEquals(registry.length, 0). It states the opposite of the assertion, and the assertion holds before the load as well, so it asserts nothing.external-retry.ts:86— passesdeps.lograther than theretrychild logger toprepareExternalModule, so retry-time clone logs are indistinguishable from startup ones.external-retry.ts:110-119— nostoppedcheck afterawait host.loadExternal(…). A module canregister()scheduler jobs and task definitions afterscheduler.stop()/tasks.stop()during shutdown, andalertModuleRecoveredcan write to a closing DB.Deno.exit(0)covers it in practice; the guard is one line.CLAUDE.md— "Deno.serveis the last thingmain.tsdoes" is no longer true after this PR: the alert loop, the retry start and the signal handlers all follow it.All seven acted on in
3bc2c38, plus the nits. Thanks — every blocker was real, and the three of them share the cause you named: the one function nothing exercised.Blocking
1. Duplicate name reported as a recovery. Correct, and the probe was true by definition. The success path now asks the host what happened rather than looking the name up in
loaded:A collision now ends on the failure alert with
stopped: "permanent", so/systemand the bell say the same thing. Test:a clone into a name collision is not a recoveryloads a module under the name first, then lets the retry clone into it.2. The suppression did not suppress. Also correct, and it was this PR's own fixture that proved it.
isTransientGitErroris now built ontransientGitErrorKind, which returns the matched fragment, and the comparison is overstage + kind:Asserted by counting
createdpublishes on the bus rather than by inspecting the row: two refusals ofgit://127.0.0.1:1/…, one write. On the old comparison that test sees two.3. A hanging clone was never retried. The one that mattered most — the case
BACKOFF_MS's own comment advertises.git()now owns its controller instead ofAbortSignal.timeout, and states the reason itself, since the kill leaves nothing to infer it from:GIT_TIMEOUT_FRAGMENTheadsTRANSIENT_GIT_ERRORS, so the classification stays in one list. The detection is the abort flag; the fragment is only how the fact reachesModuleFailure, which carries a string.Should fix
4. No test for the loop. Three now, all driving
startExternalModuleRetriesthrough thebackoffMsseam, which is no longer dead:a repo that comes back is loaded into the running host— a realgit initrepo on disk, cloned by the real prepare path, loaded into a liveModuleHost; asserts the module runs,host.failedis empty and the row flipped to the recovery.a clone into a name collision is not a recovery— blocker 1.the same fault twice is one row; running out of attempts says so— blocker 2 and the exhausted exit.No network: the success path clones a local path, the failure path is a refused loopback connect.
5. Unbounded body. Truncated at 300 characters (with whitespace collapsed, which also fixes the newline git's own errors carry into the bell). Asserted against a 5 kB builder error.
6. Wrong give-up text. Split into
stopped: "exhausted" | "permanent"— the second reads "Stopped after 2 attempts — this is not a network failure and will not clear on its own", which also covers the collision from blocker 1, not just a rejected credential.7. Computed twice.
startExternalModuleRetriesreturns{ taken, stop }andmain.tsdrives the alert offretries.taken.has(...). It starts before the alert loop now, which is safe by a whole backoff and is the only ordering where a single decision is possible.Nits
All four: the retry child logger goes to
prepareExternalModule(ext:retry:<slug>in the logs below),if (stopped) returnafter the load, the CLAUDE.md sentence rewritten to say what actually followsDeno.serve, and the registry assertion made real — the test module registers a UI schema, so/api/core/modulesis asserted empty before the load and naming the module after it.Verification
deno fmt --check,deno lint,deno task check, 100 server tests green (97 + 3).Live boot against an unreachable repo, checking the part the unit tests cannot (the
main.tswiring):/api/core/notificationsholds one row —"clone: … errno=Connection refused. Retrying in the background. Details on the system page.", on one line, and unchanged by attempt 1 — and/api/core/systemholds onefailedentry for the same repo throughout.Re-reviewed at
14c1af5(3bc2c38+14c1af5). Every finding from the previous round is addressed, and addressed properly rather than papered over:takenreplaces the second eligibility evaluation, andmain.tsnow drives the "Retrying in the background." promise off the loop that keeps it;failureSignaturekeys on the matched fragment instead of git's message, so curl'safter 2 msno longer reads as a new fault — and the test asserts exactly one bell write across two identical refusals, which is the property that matters;host.failedinstead of probingloaded, with a test that constructs the collision;briefly()caps at 300 chars, and it runs afterredactSecrets, which is the order that matters;exhaustedvspermanentare separate exits with separate text;git()owns itsAbortControllerso the hang is legible downstream.Verified locally:
deno task checkclean, 100 server tests pass.buildSystemInfoalready redacts every failure field, so "Details on the system page." does not point at a leak.What is left below is the tail. None of it blocks the design; #1 and #2 are real defects in paths this PR now makes reachable.
Medium
1.
runBuilderstill throws away its own kill —packages/server/src/modules/external.ts:256The bug
git()was just fixed for, thirteen lines further down and untouched:A hung vite build is killed,
.output()resolves with a non-zero code and empty piped output, and the message isfrontend build failed:followed by nothing. That string is now a notification body, so the bell renderswith no reason in it at all.
briefly()was added for the opposite failure of this same path — kilobytes of stack — and this is the other end of it. Same fix asgit(): own controller, state the timeout in the message.2. A throw inside
attemptOncekills the chain while the bell still promises a retry —external-retry.ts:97Log only — no reschedule, no alert update.
prepareExternalModuleis documented as never throwing, and inside itstrythat holds, but the twoDeno.mkdircalls sit outside it (external.ts:89-92). ENOSPC, a read-only/data, an EACCES after a permissions change: the rejection escapesprepareExternalModuleentirely, the chain ends, and the notification reads "Retrying in the background." forever with nothing running.That is the same broken promise the
takenrefactor exists to eliminate, reached by a different route. Either move the mkdirs inside the try (they become aclone-stage failure, which the loop already handles) or have the.catchflip the alert tostopped.3.
main.tsiterateshost.failedwithawaitin the body while the retry loop splices itStarting the retries before the alert loop is the right call for
taken, but it also meansclearExternalFailure(host.ts:249-255) can nowsplicethe array thatis iterating.
backoff[0]is 5 s so it does not bite today, but entries can be skipped, and a fast recovery's info row can be overwritten by the startup error row — which was impossible under the old ordering.for (const failure of [...host.failed])closes it.4. CI: the derived image's cache key ignores the recipe —
.forgejo/deno.sh:50-62The tag is keyed to the base ref alone. Change the
RUNline — add curl, dropsafe.directory, swap the package set — with the toolchain unchanged, and a long-lived runner keeps serving the old image becausedocker image inspectsucceeds. The comment defends the FROM pin at length; the layer sitting on top of it has no cache key at all. Fold a hash of the heredoc into the tag.Related, smaller:
set -eumakes this build fatal for everydeno.shcaller, sodeps.yml— which runstest --allow-read tools/dep-check/and has no use for git — now fails whenever the Debian mirrors are unreachable from the runner. The unpinnedapt-get install gititself is consistent withdocker/Dockerfile:28-31, so no objection there; the difference is that one runs per release and this one runs per cold runner.Low
config.ts:117does not dedupe,slugifymaps both to the samesrcDir, andtakenbeing a Set hides the duplication whileschedule()still runs once per failure — concurrentgit fetch/checkoutin one tree isindex.lockcontention.external-retry.ts:173also takes the first matching ref, so a#devfailure would retry with the#mainref. Guarding the schedule loop with the same Set covers both.BACKOFF_MS. Every taken repo fires at exactly 5 s / 15 s / 45 s. One forge, N repos, N simultaneous clones per round.git():233checksabort.signal.abortedbefore the exit code. A clone that finishes in the same tick the 120 s timer fires is reported as a timeout. Conservative rather than wrong, butout.code !== 0 && abort.signal.abortedcosts nothing.GIT_TIMEOUT_FRAGMENTis"timed out after", and it is first inTRANSIENT_GIT_ERRORS. curl also writesOperation timed out after 30001 milliseconds, so a genuine remote timeout is now labelled with the self-kill fragment. Both are transient, so the retry decision is right and only the logged signature misleads — but the fragment is serving as both classifier and identity, and something likegit-selfkill:would keep those apart.All eight acted on in
a92334a(server) +8340b67(CI).Medium
1.
runBuilderthrew away its own kill. Fixed the same waygit()was, and it was the same bug: ownAbortController, and the message states the timeout because the killed process leaves nothing to infer it from.Whatever partial output there is stays on the message, so
briefly()is still the thing that bounds it rather than the kill.2. A throw inside
attemptOnceended the chain. Both halves of your either/or, because they close different holes:Deno.mkdircalls move insideprepareExternalModule'stry, so ENOSPC / read-only/data/ EACCES becomes an ordinaryclone-stage failure — non-transient, so the loop exits throughstopped: "permanent"like any other;.catchnow flips the alert tostoppedinstead of only logging, so a rejection from anywhere else still ends with the bell saying what happened rather than promising a retry forever. Guarded by thestoppedflag, since shutdown must not write to a closing DB.Test:
a data dir that cannot be written stops the chain rather than promising a retry— a regular file where the data directory should be, which is ENOTDIR for every uid including root, so it needs neither a full disk nor a chmod that CI's root would ignore.3.
host.failedmutated under the alert loop.for (const failure of [...host.failed]). The comment says what you did — 5 s of backoff means nothing recovers inside that loop today, and the copy is what keeps that timing from being load-bearing.4. The derived image's cache key. The tag is a hash of the whole heredoc now:
The recipe still contains the FROM, so a toolchain bump misses exactly as before; changing the
RUNline now misses too.On the
set -euhalf: the build is no longer fatal for callers that never wanted git. A failure warns and falls back to the toolchain as pinned, so an unreachable Debian mirror failsdeno task cion git — the thing that actually needs it — instead of failingdeps.yml, which only reads files.Low
if (taken.has(failure.configuredAs)) continue;in the schedule loop. One chain per URL, and since the ref lookup takes the first entry for that URL, the chain that runs and the ref it uses are now the same entry. Test asserts one chain and one alert write from two recorded failures.out.code !== 0 && abort.signal.aborted— a clone that finishes in the tick the timer fires keeps its result.GIT_TIMEOUT_FRAGMENT = "timed out after"→GIT_SELFKILL_FRAGMENT = "killed after no answer for". curl'sOperation timed out after 30001 millisecondsstays matched by"operation timed out", so the retry decision is unchanged and the two stop sharing an identity. Both strings are now in the transient-fragment test.Verification
deno fmt --check,deno lint,deno task check, 102 server tests green (100 + 2)..forgejo/deno.sh --versionend to end: new tag built,git version 2.47.3inside it, and the tag changes when theRUNline does.Re-reviewed at
8340b67(a92334a+8340b67). Everything from the last round is fixed, and the fixes went to the cause rather than the symptom:runBuilderowns itsAbortControllerand names the timeout, so a killed vite build no longer producesfrontend build failed:and nothing;Deno.mkdircalls moved insideprepareExternalModule's try, which makes the function honest about never throwing instead of the.catchcompensating for it — and the.catchflips the alert tostoppedanyway, which is the right belt-and-braces;main.tsiterates a copy ofhost.failed, with the timing written down as not load-bearing;index.lock;git()checks the exit code before the abort flag, so a clone that lands in the same tick as the timer keeps its work;GIT_SELFKILL_FRAGMENTis"killed after no answer for", which keeps curl's ownOperation timed out after 30001 millisecondsa distinct fault — and the test now asserts both.The new tests are the good kind: the ENOSPC-shaped one uses a regular file where the data directory should be (ENOTDIR for root too, no privileges needed), and the dedupe one counts bus writes rather than inspecting internals.
deno task checkclean, 102 tests pass.Approving. Two follow-ups below; neither blocks this.
1. A crash-killed clone wedges the work tree permanently —
external.ts:178-186(syncRepo)syncRepopicks clone-vs-fetch from the presence of<srcDir>/.git, never from its validity. Measured:AbortControllersends. .. .gitSo the new 120 s self-kill is clean, which was worth confirming given this PR is what makes that path reachable. SIGKILL is the other case: an OOM kill,
docker stoppast the grace period, a host reset mid-clone. With a partial.giton disk, every git command insyncRepoanswersThat matches no fragment in
TRANSIENT_GIT_ERRORS, soisRetryableFailureis false, the retry loop never takes it, and the module stays disabled on that boot and every boot after it — with no action named in the notification that would clear it.Strictly pre-existing: the wedge predates this branch. Worth a follow-up anyway, because this PR is the one that makes "the container heals itself" the contract, and this is the single clone-stage failure it can never heal. Probing
git rev-parse --git-dirinstead ofstat(.git), and removingsrcDirbefore falling back to a fresh clone, closes it.2. The soft CI fallback re-opens the silent-skip hole this file complains about —
.forgejo/deno.sh:66-73deno.sh:38-41documents that without git,docker/backend/commit_test.ts"silently never ran at all", and that test still guards itself (commit_test.ts:17,25—ignore: !hasGit). The new fallback warns on stderr and setsCI_IMAGE=$IMAGE, so an unreachable Debian mirror puts that suite straight back to silently skipping with CI green.The new external-module tests do fail loudly — they shell out to git unguarded — so something catches it today. That is the accident of which suite happens to exist, not a property of the design. Either have the wrapper export a "git was expected" variable that turns
hasGitskips into failures, or keep the soft fallback only for the callers that declare no git dependency.Smaller
external-retry.ts:108-123— the rescue alert spreads the previous failure, so any escaped error carries that attempt's stage (clone:) whatever its origin, andhost.failedis not updated, so/systemand the bell would disagree. With the mkdirs moved this should now be unreachable, which is the right amount of defense; it just contradicts itself if it ever fires..forgejo/deno.sh— nothing removes supersededopsdeck-deno-ci:*tags, so every recipe or toolchain change leaves a full Deno image behind on a long-lived runner.RECIPE_KEY—{ sha256sum 2>/dev/null || cksum; }yields a different tag on a runner withoutsha256sum, so a mixed fleet builds the same recipe twice;cksumis also a CRC32 where the comment reasons about a hash.Checked and fine: the unquoted heredoc collapses the
\-continuedRUNinto a single line, which is a valid Dockerfile and unchanged in behaviour from before this commit.julian referenced this pull request2026-08-16 13:31:24 +02:00