Skip to content

ADFA-5343 feat(server-lifecycle): process-scoped reconciler (Phases 0–2) + flap auto-recovery + in-proot content recovery - #498

Merged
luisguzman-adfa merged 10 commits into
mainfrom
feat/ADFA-5343-reconciler-flap-content
Aug 30, 2026
Merged

ADFA-5343 feat(server-lifecycle): process-scoped reconciler (Phases 0–2) + flap auto-recovery + in-proot content recovery#498
luisguzman-adfa merged 10 commits into
mainfrom
feat/ADFA-5343-reconciler-flap-content

Conversation

@luisguzman-adfa

Copy link
Copy Markdown
Collaborator

What

First stack of the server-lifecycle redesign (see ADR-5343 and ADR-5343a in
controller/docs/). Introduces the process-scoped reconciler as the single owner of
"the server should be up," the flap auto-recovery it enables, and in-proot recovery for
content services. Three cohesive parts:

Reconciler foundation (Phases 0–2). One liveness source (ServerLiveness: /proc +
/k2go-api, freshness-windowed) replaces the scattered cached-alive / apiReady /
/home reads. A desired state derived from facts that already have owners
(installed && healthy && userWantsOn && holder != STOPPED), keyed on the
EnvironmentLock holder's execution class (LIVE/STOPPED, reusing ADR-5061's
Operation.ExecutionClass). An app-scoped ServerLifecycleReconciler observes, then
actuates the module hand-off through desired (fixes the stuck hand-off, ADFA-5336);
Activities become observers.

Flap auto-recovery (D1 + D2, ADR-5343a). The relaunch grace is measured from
service downtime, not proot age, so a transient dash-node death lets pdsm self-heal
(~3 s) instead of being preempted into a KILL_AND_RELAUNCH loop; killOrphan reclaims
the orphaned front so a legitimate relaunch can rebind :8085. Also removes the dead
proot-age readers and closes the two-writer race on lastLiveness.

In-proot content recovery (dashboard). A content service wedged after a relaunch
(Kiwix "Unavailable") is recovered in place via pdsm restart <svc> through the
dash-node REST core — a loopback-only endpoint plus a server-side auto-heal watcher —
instead of relaunching proots (which orphans services off proot → epoll_wait ENOSYS).
kiwix wired first; others added per device-verify.

Why

The server-lifecycle logic had accreted overlapping states and multiple sources of truth
for the lack of a single process-scoped owner. This lands that owner and collapses the
first cluster of the debt; the reasoning and evidence are in ADR-5343 / ADR-5343a.

Verification

Device-verified on OnePlus 7T: the module hand-off, flap auto-recovery (5336) including
the orphan-reclaim, and the kiwix auto-heal (clean-kill, loopback-only security, and the
real orphan-reclaim). Unit tests cover the pure decisions (ServerLiveness phase table,
ServerReconcile desired/intent). :app:testDebugUnitTest + :app:lintDebug green;
dashboard tsc + tests green.

Follow-ups (recorded in ADR-5343a)

  • Phase 3 (deep ops + dashboard rebuild via desired) is the stacked PR on top of this.
  • php-fpm orphan disk-fill on a genuine relaunch, and the rebuild smoke-test sleep 3
    that blocks slow devices — the dashboard/rebuild follow-up.
  • D2's host-side reap and the LibraryActivity boot cluster — Phase 4.

luisguzman-adfa and others added 10 commits August 28, 2026 21:38
…er execution class

The desired-state predicate used EnvironmentLock.currentHolder == NONE, forcing the
server DOWN for every holder. But LIVE holders run against the live server and need it
UP: DOWNLOAD (ZIM/Books/Kolibri are ExecutionClass.LIVE; the device only POSTs+polls,
ZimDownloadService.java:10) and DASHBOARD (live dash-node self-update). This also
contradicted the ADR's own section 6, which already requires DASHBOARD to stay UP.

Refine the predicate to key on the holder execution class:
  desired = installed && healthy && userWantsOn
            && currentHolder.executionClass != STOPPED

Reuse the existing ADR-5061 type Operation.ExecutionClass { LIVE, STOPPED }
(Operation.java:49-61) as one property on the Holder enum (STOPPED for
CLONE/BACKUP/RESTORE/INSTALL; LIVE for DOWNLOAD/DASHBOARD/NONE) rather than adding a
parallel HolderClass. No new source of truth; NONE keeps desired=UP as before.

Design edit only; the Holder enum property lands with Phase 0 code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erLiveness)

Add env/domain/ServerLiveness, the single freshness-windowed liveness snapshot the
reconciler effort collapses the four scattered sources into. Pure JVM: it holds the
two observed facts (processPresent from /proc, servicesAnswering from /k2go-api) plus
a monotonic observedAtMs, and derives one Phase (UNKNOWN/DOWN/STARTING/UP). Reuses
Freshness.fresh for the one definition of "still trustworthy"; a never-observed or
stale snapshot folds to UNKNOWN (absorbs hasObservation()).

Route the 3s status poll through it (ServerController.checkServerStatus): build the
snapshot from EnvironmentProcess + RestReadiness, publish alive = (phase == UP) to
ServerStateRepository as before. The one behavior shift is that "up" now means the
services answer (/k2go-api) rather than nginx (/home), which answers before its
dash-node upstream is ready - so a flap no longer reads as spurious "up" (ADFA-5336).
Downstream is otherwise unchanged: ServerState stays a 1-bit alive fact, no consumer
touched. Delete the now-unused /home pingUrl and its two java.net imports.

JVM tests cover the four-phase truth table and the freshness boundary
(ServerLivenessTest). startEnvironment's own liveness reads are intentionally left
for Phase 4; Phase 0 is additive.

Verified: pure-domain javac compile + the 9 JVM tests pass offline. Full app
compile + lint must run via Gradle; runtime (boot/up/down on Home; flap no longer
shows spurious up) is device-only.

Rollback: revert ServerController.java; the poll returns to the /home ping (the new
ServerLiveness file is then unused).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ired-state

Introduce the one process-scoped owner of "the server should be up", first as a
LOG-ONLY observer (no actuation), plus the pure decisions it will own.

- ServerReconcile (pure JVM): desired() = installed && healthy && userWantsOn &&
  holderClass != STOPPED - a pure function of facts that already have owners, so no
  new source of truth. intent() = the coarse direction (START/STOP/WAIT/HOLD/NOOP)
  from desired vs the observed ServerLiveness.Phase; an UNKNOWN phase always HOLDs.
- EnvironmentLock.Holder now carries its Operation.ExecutionClass (ADR-5061's one
  LIVE/STOPPED type, reused - not a parallel enum). This lands the approved Task-1
  predicate fix in code: LIVE holders (download, dashboard self-update) leave desired
  UP; only STOPPED holders (clone/backup/restore/install) pull it down; NONE is LIVE.
- ServerLifecycleReconciler (app-scoped singleton, established in IIABApplication):
  observe(ctx, liveness) computes desired from SystemFactsReader + WatchdogEnable +
  currentHolder and logs desired-vs-actual -> wouldDo each tick. No actuation. It is
  FED the Phase-0 snapshot by the status poll, so there is no second liveness source.

JVM tests: ServerReconcileTest covers the desired truth table (the holder-class
dimension in particular) and the intent table.

Verified: :app:testDebugUnitTest + :app:lintDebug BUILD SUCCESSFUL locally. Device-
only: logcat "K2Go-Reconciler" on all five flows (boot, module hand-off, deep ops,
dashboard rebuild, turn-off) should show desired/actual/wouldDo matching reality with
no surprises. Still additive - the scorecard reduction lands when Phase 4 deletes the
scaffolding these parts replace.

Rollback: delete ServerReconcile + ServerLifecycleReconciler and their two call sites
(the ServerController poll seam and the IIABApplication warm-up); revert the Holder
field. Nothing depends on the observer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odule hand-off (5336)

First actuation. The module-batch hand-off stops booting + polling the server itself
and instead sets the desired state; the reconciler brings the box up and keeps it up,
re-driving a post-install flap so the hand-off can no longer strand a dead Home (5336).

Actuation via a bridge (A1), not a new boot path:
- ServerLifecycleReconciler gains an Actuator interface. The foregrounded ServerController
  registers on onResume and clears on onPause (compare-and-clear, idempotent to a
  resume/pause overlap). The reconciler owns the DECISION (desired); the boot MECHANISM
  stays the one existing, idempotent path (ServerController.startEnvironment via
  EnvironmentEnsure). No second actuator, no double-proot. Off-UI boot + own tick are Phase 4.
- On a tick, when desired=UP and the box is not confirmed up, the reconciler calls
  ensureServerUp() through the registered actuator. START (down) and WAIT (coming up /
  stuck flap) both route there (ServerReconcile.ensuresUp); startEnvironment's
  EnvironmentEnsure then launches / leaves-in-grace / relaunches-stuck, so a healthy boot
  is never disturbed and a past-grace flap is re-driven. STOP stays with the toggle /
  deep-ops until Phase 4; desired=DOWN never yields START/WAIT, so the reconciler cannot
  fight a legitimate stop, and it will not boot during runroles (holder=INSTALL => desired
  DOWN). Gated by ServerLifecycleReconciler.ACTUATES (the rollback lever).

Hand-off rewired to observe, not poll (SetupProgressActivity):
- Removed the three boot latches (moduleRestartKicked / moduleServerUp / moduleServerFailed)
  and the copy-pasted apiReady re-boot loop (serverUpPoll), plus ensureServerUpForModules.
- "up" is now read from the one observed phase (serverObservedUp() over ServerStateRepository);
  "didn't come back in time" is a timeout on that phase (moduleServerWaitAt). There is no
  FAILED phase, so a stuck start is STARTING that the reconciler keeps re-driving and the UI
  shows "taking longer" + Finish - and Finish lands on a Home the reconciler drives live.
- onModuleBatchTerminal() sets userWantsOn once (desired=UP); InstallGuard is already cleared
  and the queue stopped before DONE, so the holder is NONE at that point. Rollback path
  (ACTUATES=false) boots once here as before.

JVM tests: ServerReconcileTest adds the ensuresUp mapping (START/WAIT drive up; NOOP/STOP/
HOLD do not).

Verified: :app:testDebugUnitTest + :app:lintDebug BUILD SUCCESSFUL locally.

Behavior changes to verify on device:
- Reconciler now boots via ANY foregrounded Activity when desired=UP and the box is down
  (generalizes keep-up; fixes flap on Home). Safe: single idempotent actuator, desired-gated.
- A module batch now sets WatchdogEnable (the persisted "server on" intent) true.
- Redirect "up" detected via the 3s poll observation (was a 2s bespoke poll) - up to 3s later.

Device matrix (must include the timeout branch, not only success):
- module install + hand-off -> live Home;
- module install + induced dash-node flap post-DONE -> auto-recovers, no manual toggle (5336);
- module install where services are slow/never answer -> "taking longer" + Finish; reconciler
  keeps trying; Finish -> Home stays driven; no dead Home;
- runroles in flight -> reconciler does NOT boot (holder STOPPED);
- ACTUATES=false -> hand-off boots itself, reconciler log-only (rollback).

Rollback: set ServerLifecycleReconciler.ACTUATES=false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (5336)

Phase-2 device verification found the flap flow regressed: actuation turned a
self-healing dash-node blip into an unrecoverable KILL_AND_RELAUNCH loop. Two
independent defects, both fixed here (ADR-5343a, approved).

D1 - escalate on service downtime, not proot age. EnvironmentEnsure.decide keyed
KILL_AND_RELAUNCH on envAgeMs >= BOOT_GRACE_MS, so a mature proot whose dash-node
blipped escalated on the first tick - before pdsm's ~3s respawn. It now escalates on
how long the services have been continuously observed down while the proot stays
present. The clock is one derived field (servicesDownSinceMs) in the single
ServerLiveness source, threaded across ticks by the poll (ServerLiveness.next),
reset on an observation gap and on boot; a stale/absent snapshot reports -1, which
decide treats as "wait". This drops envAgeMs from the decision and replaces the
fixed BOOT_GRACE_MS with SERVICE_DOWN_GRACE_MS - a reduction, realizing ADR-5343 2.6.

D2 - killOrphan reclaims the orphaned HTTP front. The box's nginx daemonises
(setsid, reparents to init) and survives a proot kill, keeping :8085 so a relaunched
proot's pdsm start cannot rebind (the loop). reapEnvironmentHttpFront() now reclaims
it. This is a compensator for a guest-side defect (services should die with the
proot); the clean end-state and the two deviations it carries are recorded in
ADR-5343a 9, to retire when the guest-side fix lands - not now.

D3 (LibraryActivity:422 second boot owner) is recorded in ADR-5343a 3, deferred to
Phase 4 (not pulled forward).

JVM tests: EnvironmentEnsureTest re-cast onto service downtime (mature-proot flap
WAITs; past-grace relaunches; unknown downtime never kills); ServerLivenessTest adds
the servicesDownSinceMs reducer (drop=0, accumulation, observation-gap reset, stale
reports -1, services-up / proot-gone clear it).

Verified: :app:testDebugUnitTest + :app:lintDebug BUILD SUCCESSFUL locally.
Device (a026a310, Luis): flow 2 auto-recovers to UP and rebinds :8085; flow 3
(timeout) verified by hand; flows 1/4/5/6 no regression.

Review follow-ups (surfaced, not folded in to keep this to the approved delta):
EnvironmentProcess.environmentAgeMs/readStartTicks are now dead (D1 dropped
envAgeMs); the lastLiveness reset in doLaunchEnvironment has a second writer (the
comment's "single writer" is inaccurate) with a low-probability lost-reset race.

Rollback: ServerLifecycleReconciler.ACTUATES=false (device-proven self-healing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…haned by D1

ADR-5343a D1 replaced the proot-age escalation grace with a service-downtime clock,
dropping envAgeMs from EnvironmentEnsure.decide. That left EnvironmentProcess.
environmentAgeMs() (public, no remaining callers) and its only user readStartTicks()
(private) as dead code. Remove both - the reduction D1 implies. No behavior change;
killOrphan / reapEnvironmentHttpFront / isRunning are untouched.

Verified: :app:compileDebugJavaWithJavac + :app:lintDebug BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eness (Phase 2)

lastLiveness has two writers, not one (the review's finding, correcting the stale
'single writer' comment): the poll advances it, and doLaunchEnvironment resets it to
null so a fresh proot gets its full grace instead of the old one's inherited service
downtime. Before this, the poll read prev (outside any lock), then wrote next(prev,..)
~2.5s later after apiReady() - so a reset that landed during that window was clobbered
by the poll's stale prev, and the next ensure-up tick could KILL_AND_RELAUNCH the
freshly-booting proot (the 5336 loop, in miniature).

Serialise the two writers with livenessLock: probe OUTSIDE the lock (apiReady may
block ~2.5s and must not hold it), then read prev and write the next snapshot
atomically INSIDE it; doLaunchEnvironment's reset takes the same lock. A reset can no
longer be clobbered by a poll that read prev before it. No lock is ever held across
the network probe, and the reset's critical section is a single field write, so the UI
thread never stalls. The ensure-up decision keeps its lock-free volatile read.

Verified: :app:testDebugUnitTest + :app:lintDebug BUILD SUCCESSFUL locally.
Device-only (touches the reset path): re-run Flow 2 (kill dash-node on a mature proot)
- auto-recovers to UP, rebinds :8085, no relaunch loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…recovery (ADR-5343a §10)

A content service wedged after an environment relaunch loses proot's syscall
emulation (epoll_wait -> ENOSYS) and stops serving (kiwix "Unavailable").
Recover it in place, inside the one living proot, via `pdsm restart <svc>` through
the dashboard REST core -- never a host-side reap or a proot relaunch (ADR-5343a §10).

- services.ts: exact-match allowlist mirroring upstream pdsm_installed_services
  (dash-node excluded) + setsid-detached restartService actuator.
- routes.ts: POST /system/service/:svc/restart (loopback-only, like all /k2go-api).
- service-heal.ts: in-proot watcher that HEAD-probes the content tree and heals a
  present-but-wedged service (404 = not installed -> left alone), cooldown-bounded.
- Pure allowlist/cooldown/probe-classification unit-tested; the wedge -> auto-restart
  recovery is device-only (verified by hand).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…start endpoint

REST-facing change (POST /system/service/:svc/restart) — the CHANGELOG rule bumps
the version so the app's update-check surfaces it. Missed in 981b214; corrected forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ck boundary, absent-vs-down, kiwix-only watcher, service source-of-truth)

Align §10 with the implemented dashboard recovery (981b214):
- auto-heal is server-side in dash-node; the restart endpoint is loopback-only,
  so captive-portal clients reflect status but cannot trigger a restart; manual
  Retry is the on-box Android card (ADFA-4842).
- probe classifies absent (404) vs down (5xx/timeout) so an uninstalled service
  is never restart-looped.
- the watcher wires kiwix only today; others added to WATCHED per device-verify.
- supported-service list source of truth = iiab/iiab proot_services
  (pdsm_installed_services); dash-node is the k2go-side exception.
@luisguzman-adfa
luisguzman-adfa merged commit bf1b2b6 into main Aug 30, 2026
6 checks passed
@luisguzman-adfa
luisguzman-adfa deleted the feat/ADFA-5343-reconciler-flap-content branch August 30, 2026 06:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant