diff --git a/controller/app/src/main/java/org/iiab/controller/IIABApplication.java b/controller/app/src/main/java/org/iiab/controller/IIABApplication.java index 62ff3a87c..fe3349335 100644 --- a/controller/app/src/main/java/org/iiab/controller/IIABApplication.java +++ b/controller/app/src/main/java/org/iiab/controller/IIABApplication.java @@ -29,6 +29,9 @@ public void onCreate() { super.onCreate(); // ADFA-4640: wire up persistence for the server log (survives app restarts). org.iiab.controller.LogRepository.get().init(this); + // ADFA-5343 (Phase 1): establish the app-scoped server-lifecycle reconciler. Log-only for now, + // fed by the status poll; it gains its own tick + WatchdogService promotion in a later phase. + org.iiab.controller.env.ServerLifecycleReconciler.get(); // We inject Conscrypt as the app's primary security provider try { Security.insertProviderAt(Conscrypt.newProvider(), 1); diff --git a/controller/app/src/main/java/org/iiab/controller/ServerController.java b/controller/app/src/main/java/org/iiab/controller/ServerController.java index 074203a2f..9a821e36d 100644 --- a/controller/app/src/main/java/org/iiab/controller/ServerController.java +++ b/controller/app/src/main/java/org/iiab/controller/ServerController.java @@ -27,20 +27,20 @@ import org.iiab.controller.util.AppExecutors; import java.io.File; -import java.net.HttpURLConnection; -import java.net.URL; -public class ServerController { +public class ServerController implements org.iiab.controller.env.ServerLifecycleReconciler.Actuator { private static final String TAG = "IIAB-ServerController"; private static final int CHECK_INTERVAL_MS = 3000; /** - * ADFA-5103: how long "environment alive, services not answering" is read as "still starting" - * rather than "stuck", before ensure-up is allowed to kill it. Must comfortably exceed the - * observed 3.5 s mid-boot window that got the earlier kill reverted; kept well under a normal - * boot-to-services time so a genuinely stuck orphan still recovers on a Retry. + * ADFA-5103 / ADFA-5343a (D1): how long the services may be continuously observed down (proot + * present) before ensure-up escalates from "still coming up / pdsm will respawn it" to "stuck → + * relaunch". Timed from the service drop, not the proot's age (ADR-5343a): a mature proot whose + * dash-node blips stays well under this and self-heals via pdsm, while a boot — services down since + * the proot started — is still protected for this long (comfortably over the 3.5 s mid-boot window + * that got the earlier kill reverted, and over a normal boot-to-services time). */ - private static final long BOOT_GRACE_MS = 20_000L; + private static final long SERVICE_DOWN_GRACE_MS = 20_000L; /** Activity-side callbacks the server lifecycle needs. */ public interface Host { @@ -85,6 +85,19 @@ default void onStartupProgress(String service) {} // and both LAUNCH — the synchronous main-thread serialisation that used to prevent that is gone. // This flag restores it: a concurrent call is a no-op until the launch (or the no-op) resolves. private volatile boolean ensuring = false; + // ADFA-5343a (D1): the last liveness snapshot from the poll, threaded so servicesDownSinceMs + // measures CONTINUOUS observed downtime (reset on an observation gap). Read by the ensure-up + // decision to key the kill on service downtime, not proot age. + // + // ADFA-5343 (Phase 2): TWO writers, not one — the poll advances it, and doLaunchEnvironment resets + // it to null when a fresh proot starts (so the new proot gets its full grace, not the old one's + // inherited downtime). They are serialised by livenessLock: the poll reads prev and writes the next + // snapshot atomically under the lock (AFTER probing, so the ~2.5s network probe never holds it), and + // the boot reset takes the same lock — so a reset can never be clobbered by a poll that had read prev + // before it. Still volatile, for the lock-free read on the ensure-up decision path. + private volatile org.iiab.controller.env.domain.ServerLiveness lastLiveness; + /** Guards the read-prev-then-write of {@link #lastLiveness} against the boot-time reset (Phase 2). */ + private final Object livenessLock = new Object(); private static final java.util.regex.Pattern PDSM_SVC = java.util.regex.Pattern.compile("\\[pdsm:([^\\]]+)\\]"); private final Handler timeoutHandler = new Handler(android.os.Looper.getMainLooper()); @@ -116,10 +129,23 @@ public void onResume() { updateConnectivityStatus(); // instant refresh when returning to the app serverCheckHandler.removeCallbacks(serverCheckRunnable); serverCheckHandler.post(serverCheckRunnable); + // ADFA-5343 (Phase 2): register as the foreground actuator the reconciler drives. Whichever + // Activity is resumed owns this slot; the reconciler boots through it (the one existing boot). + org.iiab.controller.env.ServerLifecycleReconciler.get().setActuator(this); } public void onPause() { serverCheckHandler.removeCallbacks(serverCheckRunnable); + // ADFA-5343 (Phase 2): release the actuator slot — but clearActuator only clears if we still + // hold it, so a resume/pause overlap does not wipe the next Activity's registration. + org.iiab.controller.env.ServerLifecycleReconciler.get().clearActuator(this); + } + + /** ADFA-5343 (Phase 2): the reconciler's boot entry point. Delegates to the one existing, idempotent + * boot path so no second actuator is introduced. */ + @Override + public void ensureServerUp() { + startEnvironment(); } public String getCurrentTargetUrl() { return currentTargetUrl; } @@ -141,27 +167,36 @@ private void updateServerAlive(boolean nowAlive) { // The repository is updated by the poll (checkServerStatus) right after this. } - private boolean pingUrl(String urlStr) { - try { - URL url = new URL(urlStr); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setUseCaches(false); - conn.setConnectTimeout(1500); - conn.setReadTimeout(1500); - conn.setRequestMethod("GET"); - return (conn.getResponseCode() >= 200 && conn.getResponseCode() < 400); - } catch (Exception e) { - return false; - } - } - // --- status poll ------------------------------------------------------------ private void checkServerStatus() { if (host.isNegotiating()) return; AppExecutors.get().io().execute(() -> { - boolean localAlive = pingUrl(BoxEndpoints.BASE + "/home"); + // ADFA-5343 (Phase 0): one honest liveness snapshot instead of a single /home ping. + // nginx answers /home before its dash-node upstream is ready, so a restarting engine read + // as "up" (the flap). servicesAnswering probes /k2go-api (the usable signal); processPresent + // (/proc) is recorded for the richer phase the reconciler will consume in a later phase. + // alive stays a 1-bit fact (phase == UP) so ServerStateRepository and every reader are + // unchanged here — the only shift is that "up" now means the services answer, not nginx. + long now = android.os.SystemClock.elapsedRealtime(); + // Probe OUTSIDE the lock — RestReadiness.apiReady() can block ~2.5s and must never hold + // livenessLock (that would stall a concurrent boot reset). + boolean processPresent = org.iiab.controller.env.EnvironmentProcess.isRunning(activity); + boolean servicesAnswering = org.iiab.controller.redesign.RestReadiness.apiReady(); + // ADFA-5343a (D1) / ADFA-5343 (Phase 2): read prev and write the next snapshot atomically + // under livenessLock, so a boot-time reset (doLaunchEnvironment) is never clobbered by this + // poll writing a prev it had read before the reset. next() still measures CONTINUOUS downtime + // and resets it on an observation gap (a stale prev — e.g. the app was backgrounded). + org.iiab.controller.env.domain.ServerLiveness liveness; + synchronized (livenessLock) { + liveness = org.iiab.controller.env.domain.ServerLiveness.next( + lastLiveness, processPresent, servicesAnswering, now, + org.iiab.controller.env.domain.ServerLiveness.DEFAULT_FRESH_MS); + lastLiveness = liveness; + } + boolean localAlive = + liveness.phase(now) == org.iiab.controller.env.domain.ServerLiveness.Phase.UP; updateServerAlive(localAlive); @@ -170,6 +205,11 @@ private void checkServerStatus() { final SystemState sysState = SystemStateEvaluator.evaluate(activity, localAlive); ServerStateRepository.get().post(ServerState.of(localAlive, sysState)); + // ADFA-5343 (Phase 1): feed the same snapshot to the log-only reconciler — no second liveness + // source, no actuation. It logs desired-vs-actual each poll. Removing this line + the class is + // the full rollback. + org.iiab.controller.env.ServerLifecycleReconciler.get().observe(activity, liveness); + // STATE MACHINE: Has the target state been reached? Boolean target = host.getTargetServerState(); if (target != null && ServerStateRepository.get().current().alive == target) { @@ -272,33 +312,41 @@ public void startEnvironment() { // pure and unit-tested on the JVM. `ensuring` is cleared by doLaunchEnvironment() on the // launch paths and here on the no-op paths, so it is released exactly once. AppExecutors.get().io().execute(() -> { + long now = android.os.SystemClock.elapsedRealtime(); boolean envAlive = org.iiab.controller.env.EnvironmentProcess.isRunning(activity); - long ageMs = envAlive ? org.iiab.controller.env.EnvironmentProcess.environmentAgeMs(activity) : -1L; - // ADFA-5280: decide on FRESH liveness, not the cached ServerStateRepository.alive. - // Right after a module batch's `pdsm stop`, the cache still reads TRUE until the 3s poll - // catches up, so decide() returned NOOP_HEALTHY and the box was never relaunched (Home + // ADFA-5280: decide on FRESH liveness, not the cached ServerStateRepository.alive. Right + // after a module batch's `pdsm stop`, the cache still reads TRUE until the 3s poll catches + // up, so a stale read returned NOOP_HEALTHY and the box was never relaunched (Home // "Couldn't start" until a manual Retry). A live probe reads a just-stopped server as down - // at once (connection refused); a genuinely-healthy env still answers true -> NOOP_HEALTHY, - // so this never double-boots. Safe here: this block already runs off the main thread. + // at once; a genuinely-healthy env still answers true -> NOOP_HEALTHY, so this never + // double-boots. Safe here: this block already runs off the main thread. boolean servicesAlive = org.iiab.controller.redesign.RestReadiness.apiReady(); + // ADFA-5343a (D1): escalate on SERVICE downtime, not proot age. The continuous-downtime clock + // lives in the one liveness source (threaded by the poll); a stale/absent snapshot reports + // -1, which decide() treats as "wait, do not kill". A mature proot whose dash-node just + // blipped is a small downtime -> WAIT (pdsm respawns, ~3s); only a service down past the + // grace is a stuck environment worth relaunching. + org.iiab.controller.env.domain.ServerLiveness ll = lastLiveness; + long servicesDownMs = (ll == null) ? -1L + : ll.servicesDownMs(now, org.iiab.controller.env.domain.ServerLiveness.DEFAULT_FRESH_MS); org.iiab.controller.env.domain.EnvironmentEnsure.Action action = org.iiab.controller.env.domain.EnvironmentEnsure.decide( - envAlive, ageMs, servicesAlive, BOOT_GRACE_MS); + envAlive, servicesAlive, servicesDownMs, SERVICE_DOWN_GRACE_MS); switch (action) { case LAUNCH: activity.runOnUiThread(this::doLaunchEnvironment); break; case KILL_AND_RELAUNCH: - android.util.Log.i(TAG, "ADFA-5103: environment alive but services down past boot" - + " grace (age " + ageMs + "ms) — killing the orphan and relaunching"); + android.util.Log.i(TAG, "ADFA-5343a: services down " + servicesDownMs + "ms (past the" + + " grace) on a live proot — reclaiming the orphaned environment and relaunching"); org.iiab.controller.env.EnvironmentProcess.killOrphan(activity); activity.runOnUiThread(this::doLaunchEnvironment); break; case NOOP_HEALTHY: case WAIT_BOOT_GRACE: default: - android.util.Log.i(TAG, "ADFA-5103: ensure-up is a no-op (" + action + ", age " - + ageMs + "ms) — not stacking a second proot"); + android.util.Log.i(TAG, "ADFA-5103: ensure-up is a no-op (" + action + + ", servicesDown " + servicesDownMs + "ms) — not stacking a second proot"); ensuring = false; break; } @@ -316,6 +364,13 @@ private void doLaunchEnvironment() { File rootfsDir = new File(activity.getFilesDir(), "rootfs/installed-rootfs/iiab"); host.addToLog(activity.getString(R.string.log_server_booting_native)); host.onStartupBegan(); // ADFA-4837: fill the pre-pdsm silent window + // ADFA-5343a (D1): a fresh environment is starting — restart the service-downtime clock so the + // new proot gets its full boot grace. Without this a KILL_AND_RELAUNCH keeps the accumulated + // downtime and re-kills the booting proot every tick, before its services can come up. + // ADFA-5343 (Phase 2): under livenessLock so the poll cannot clobber this reset with a snapshot + // whose prev it read before the reset (the two-writer race). Runs on the UI thread; the critical + // section is a single field write, so it never blocks on the poll's probe. + synchronized (livenessLock) { lastLiveness = null; } createFakeSysData(rootfsDir); if (serverEngine != null) serverEngine.killProcess(); serverEngine = new PRootEngine(); diff --git a/controller/app/src/main/java/org/iiab/controller/env/EnvironmentLock.java b/controller/app/src/main/java/org/iiab/controller/env/EnvironmentLock.java index a20abdc18..f813dfd91 100644 --- a/controller/app/src/main/java/org/iiab/controller/env/EnvironmentLock.java +++ b/controller/app/src/main/java/org/iiab/controller/env/EnvironmentLock.java @@ -38,6 +38,8 @@ import android.content.Context; +import org.iiab.controller.system.domain.Operation; + import java.io.BufferedReader; import java.io.File; import java.io.FileReader; @@ -51,8 +53,28 @@ public enum Owner { INSTALL, MODULE, BACKUP, RESTORE, CLONE } /** ADFA-5146: what is actually holding the environment, for a refusal message that names the * real cause instead of always saying "an install". ADFA-5333: DASHBOARD = a live dash-node update - * is in flight; it restarts the server, so nothing that touches the server may start on top of it. */ - public enum Holder { CLONE, BACKUP, RESTORE, INSTALL, DOWNLOAD, DASHBOARD, NONE } + * is in flight; it restarts the server, so nothing that touches the server may start on top of it. + * + *
ADFA-5343: each holder carries its {@link Operation.ExecutionClass} — reusing ADR-5061's one + * LIVE/STOPPED type, not a parallel one. STOPPED holders run the box down (pdsm stop + a transient + * proot: clone/backup/restore/install); LIVE holders run against the live server (download, dashboard + * self-update). NONE means no holder is forcing the box down. The server-lifecycle desired-state + * predicate reads this: desired stays UP unless a STOPPED-class holder is in force. */ + public enum Holder { + CLONE(Operation.ExecutionClass.STOPPED), + BACKUP(Operation.ExecutionClass.STOPPED), + RESTORE(Operation.ExecutionClass.STOPPED), + INSTALL(Operation.ExecutionClass.STOPPED), + DOWNLOAD(Operation.ExecutionClass.LIVE), + DASHBOARD(Operation.ExecutionClass.LIVE), + NONE(Operation.ExecutionClass.LIVE); + + public final Operation.ExecutionClass executionClass; + + Holder(Operation.ExecutionClass executionClass) { + this.executionClass = executionClass; + } + } // Owner marker: line 1 = Owner.name(), line 2 = epoch millis, line 3 = session token. private static final String MARKER = ".env_lock"; diff --git a/controller/app/src/main/java/org/iiab/controller/env/EnvironmentProcess.java b/controller/app/src/main/java/org/iiab/controller/env/EnvironmentProcess.java index c82ff20f6..859fa9ad2 100644 --- a/controller/app/src/main/java/org/iiab/controller/env/EnvironmentProcess.java +++ b/controller/app/src/main/java/org/iiab/controller/env/EnvironmentProcess.java @@ -47,6 +47,11 @@ public final class EnvironmentProcess { private static final String TAG = "K2Go-Env"; + /** ADFA-5343a (D2): the box's nginx HTTP port ({@code config/BoxEndpoints.java:21}). Its listener is + * the reparented orphan that keeps the port after a proot kill; reclaiming it is what lets a fresh + * proot's {@code pdsm start} rebind. */ + private static final int ENV_HTTP_PORT = 8085; + private EnvironmentProcess() { } @@ -98,74 +103,6 @@ public static boolean isRunning(Context ctx) { return ctx != null && findPid(ctx) > 0; } - /** - * The running environment proot's age in ms, or {@code -1} when there is none or its start time - * cannot be read. - * - *
ADFA-5103: the boot grace is measured from the proot's own age, not from when this process
- * launched it, so a young proot is protected whether we started it or a force-closed predecessor
- * did — the observed 3.5 s double-boot after Android restored the Activity stack. Read from
- * {@code /proc/ Found by cmdline, not by the port. An app cannot read {@code /proc/net/tcp} on modern
+ * Android (blocked since API 29), so the socket→pid path is unavailable here; nginx is instead
+ * matched on {@code /proc/ It is fed the single {@link ServerLiveness} snapshot the status poll already builds (Phase 0),
+ * so there is no second liveness source; it reads the desired-state inputs itself — all facts that
+ * already have owners — and logs {@code desired vs actual → wouldDo} each tick. Nothing downstream
+ * depends on it, so the whole phase reverts by deleting this class and its two call sites (the poll
+ * seam and the IIABApplication warm-up).
+ */
+public final class ServerLifecycleReconciler {
+
+ private static final String TAG = "K2Go-Reconciler";
+
+ private static final ServerLifecycleReconciler INSTANCE = new ServerLifecycleReconciler();
+
+ public static ServerLifecycleReconciler get() {
+ return INSTANCE;
+ }
+
+ /**
+ * ADFA-5343 (Phase 2): master switch for actuation. {@code true} = the reconciler drives the server
+ * up through the foreground actuator; {@code false} = log-only (Phase 1) and the hand-off boots
+ * itself. The rollback lever for the first actuation — flip to {@code false} to revert behavior
+ * without reverting code.
+ */
+ public static final boolean ACTUATES = true;
+
+ /**
+ * ADFA-5343 (Phase 2): the single actuator the reconciler drives — the foregrounded
+ * {@code ServerController}, which registers on resume. The reconciler owns the DECISION (desired);
+ * the boot MECHANISM stays in the one existing, tested path
+ * ({@code ServerController.startEnvironment} via {@code EnvironmentEnsure}) until Phase 4 relocates
+ * it off-UI and deletes the toggle. No second boot path is introduced.
+ */
+ public interface Actuator {
+ /** Ensure the server is up. Idempotent and self-gating: a no-op if already up or still inside
+ * its boot grace, a relaunch only for a stuck past-grace proot. */
+ void ensureServerUp();
+ }
+
+ private volatile ServerLiveness lastLiveness;
+ private volatile boolean lastDesiredUp;
+ private volatile Actuator actuator;
+
+ private ServerLifecycleReconciler() {
+ }
+
+ /** The foregrounded {@code ServerController} registers here in {@code onResume}. */
+ public synchronized void setActuator(Actuator a) {
+ this.actuator = a;
+ }
+
+ /**
+ * Cleared in {@code onPause} — but only if {@code a} is still the current one, so a resume/pause
+ * overlap (the next Activity registered before the previous paused) does not clear the live
+ * registration. Idempotent.
+ */
+ public synchronized void clearActuator(Actuator a) {
+ if (this.actuator == a) {
+ this.actuator = null;
+ }
+ }
+
+ /**
+ * A tick: compute desired, compare to the observed liveness, and log the action that would follow.
+ * No actuation in Phase 1. Called on the poll's worker thread; {@code synchronized} keeps the
+ * "single-threaded tick" invariant if two polls ever overlap.
+ *
+ * @param ctx any context (the poll's Activity today).
+ * @param liveness the snapshot the poll just read — reused, not re-probed.
+ */
+ public synchronized void observe(Context ctx, ServerLiveness liveness) {
+ if (ctx == null || liveness == null) {
+ return;
+ }
+ ServerLiveness.Phase actual = liveness.phase(SystemClock.elapsedRealtime());
+
+ SystemFacts facts = SystemFactsReader.read(ctx);
+ boolean userWantsOn = new Preferences(ctx).getWatchdogEnable();
+ EnvironmentLock.Holder holder = EnvironmentLock.currentHolder(ctx);
+ Operation.ExecutionClass holderClass = holder.executionClass;
+
+ boolean desiredUp = ServerReconcile.desired(
+ facts.isInstalled(), facts.isHealthy(), userWantsOn, holderClass);
+ ServerReconcile.Intent wouldDo = ServerReconcile.intent(desiredUp, actual);
+
+ this.lastLiveness = liveness;
+ this.lastDesiredUp = desiredUp;
+
+ // ADFA-5343 (Phase 2): actuate only the "bring it up" direction. START (down) and WAIT (still
+ // coming up, or a stuck flap) both route to ensureServerUp(); its EnvironmentEnsure decides
+ // launch / leave-in-grace / relaunch-stuck, so a healthy boot is never disturbed and a
+ // past-grace flap is re-driven wherever the app is foregrounded (the 5336 fix). STOP stays with
+ // the toggle / deep-ops until Phase 4; and desired=DOWN yields neither START nor WAIT, so the
+ // reconciler can never fight a legitimate stop.
+ boolean ensureUp = ACTUATES && ServerReconcile.ensuresUp(wouldDo);
+ Actuator a = ensureUp ? this.actuator : null;
+
+ Log.i(TAG, "ADFA-5343 reconcile: desired=" + (desiredUp ? "UP" : "DOWN")
+ + " actual=" + actual + " wouldDo=" + wouldDo
+ + (ensureUp ? (a != null ? " -> ensureServerUp()" : " -> (no foreground actuator)") : "")
+ + " [installed=" + facts.isInstalled() + " healthy=" + facts.isHealthy()
+ + " userWantsOn=" + userWantsOn + " holder=" + holder + "/" + holderClass + "]");
+
+ if (a != null) {
+ a.ensureServerUp();
+ }
+ }
+
+ /** The last snapshot observed, or null before the first tick. For later phases / diagnostics. */
+ public ServerLiveness lastLiveness() {
+ return lastLiveness;
+ }
+
+ /** The last desired verdict. For later phases / diagnostics. */
+ public boolean lastDesiredUp() {
+ return lastDesiredUp;
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/env/domain/EnvironmentEnsure.java b/controller/app/src/main/java/org/iiab/controller/env/domain/EnvironmentEnsure.java
index 234dab654..a2ba1371a 100644
--- a/controller/app/src/main/java/org/iiab/controller/env/domain/EnvironmentEnsure.java
+++ b/controller/app/src/main/java/org/iiab/controller/env/domain/EnvironmentEnsure.java
@@ -38,32 +38,45 @@ public enum Action {
LAUNCH,
/** Alive and its services answer — a redundant start is a no-op. */
NOOP_HEALTHY,
- /** Alive, services not answering yet, but still inside its boot grace — leave it to finish. */
+ /** Alive, services not answering yet, but still inside the grace — leave it (pdsm respawns). */
WAIT_BOOT_GRACE,
- /** Alive, services not answering, past its boot grace — a stuck orphan; end it and relaunch. */
+ /** Alive, services down past the grace — a genuinely stuck environment; end it and relaunch. */
KILL_AND_RELAUNCH
}
/**
- * @param envAlive whether an environment proot of ours is running (from {@code /proc}).
- * @param envAgeMs the running proot's age in ms, or a negative value when it is unknown
- * (no proot, or its start time could not be read).
- * @param servicesAlive whether the box's services answer (the cached HTTP-ping fact).
- * @param bootGraceMs how long "alive but not answering" is read as "still starting" rather
- * than "stuck".
+ * ADFA-5343 (delta ADR-5343a, D1): the escalation clock is service downtime, not proot age.
+ * Keying on age fired {@code KILL_AND_RELAUNCH} on the first tick after a mature proot's dash-node
+ * blipped — before pdsm's ~3 s respawn — turning a self-healing flap into an unrecoverable loop
+ * (ADFA-5336, device-confirmed). Timed from the service drop instead, a flap stays inside the grace
+ * and self-heals via {@code WAIT_BOOT_GRACE} → {@code NOOP_HEALTHY}; only a service that stays down
+ * past the grace (pdsm could not bring it back) is a stuck environment worth relaunching.
+ *
+ * The single case that got the first ADFA-5103 attempt reverted — a proot killed 3.5 s into its
+ * own boot — is still protected: during a boot the services have been down since the proot started,
+ * so {@code servicesDownMs} is that same small elapsed time and stays under the grace. An unknown
+ * downtime ({@code < 0}: a stale/never-observed snapshot) is never killed, the same fail-safe.
+ *
+ * @param envAlive whether an environment proot of ours is running (from {@code /proc}).
+ * @param servicesAlive whether the box's services answer ({@code /k2go-api}, fresh).
+ * @param servicesDownMs how long the services have been continuously observed down while the
+ * proot stayed present ({@link ServerLiveness#servicesDownMs}), or a
+ * negative value when that is not a trustworthy fact.
+ * @param serviceDownGraceMs how long "alive but not answering" is read as "still coming up / pdsm
+ * will respawn it" rather than "stuck".
*/
- public static Action decide(boolean envAlive, long envAgeMs, boolean servicesAlive,
- long bootGraceMs) {
+ public static Action decide(boolean envAlive, boolean servicesAlive, long servicesDownMs,
+ long serviceDownGraceMs) {
if (!envAlive) {
return Action.LAUNCH;
}
if (servicesAlive) {
return Action.NOOP_HEALTHY;
}
- // Alive, services down. Kill only a proot we can be sure is past its boot — never on an
- // unknown age, because the mistake that got the first attempt reverted was killing one
- // mid-boot, and "cannot confirm it is old" must fall on the side of not killing.
- if (envAgeMs < 0 || envAgeMs < bootGraceMs) {
+ // Alive, services down. Kill only when we can be sure they have stayed down past the grace —
+ // never on an unknown downtime, because "cannot confirm it is stuck" must fall on the side of
+ // waiting (pdsm may still be respawning the service).
+ if (servicesDownMs < 0 || servicesDownMs < serviceDownGraceMs) {
return Action.WAIT_BOOT_GRACE;
}
return Action.KILL_AND_RELAUNCH;
diff --git a/controller/app/src/main/java/org/iiab/controller/env/domain/ServerLiveness.java b/controller/app/src/main/java/org/iiab/controller/env/domain/ServerLiveness.java
new file mode 100644
index 000000000..62d1727c6
--- /dev/null
+++ b/controller/app/src/main/java/org/iiab/controller/env/domain/ServerLiveness.java
@@ -0,0 +1,176 @@
+/*
+ * ============================================================================
+ * Name : ServerLiveness.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : ADFA-5343 (Phase 0). One honest snapshot of "is the server up?",
+ * replacing the four scattered liveness sources with a single
+ * freshness-windowed reading. Pure JVM (no android.*, no HTTP): it
+ * holds the two observed facts + when they were observed, and
+ * derives one phase. The impure probes (/proc, /k2go-api) are read
+ * by the caller and passed in, so this stays unit-testable on the
+ * JVM like EnvironmentEnsure and Freshness.
+ * ============================================================================
+ */
+package org.iiab.controller.env.domain;
+
+import org.iiab.controller.env.Freshness;
+
+/**
+ * "Is the server up?", answered once, from two facts read one way.
+ *
+ * The app used to answer this four different ways (a cached {@code /home} ping, a "have we polled
+ * yet?" bool, a fresh {@code /k2go-api} probe, and a {@code /proc} read), and reading {@code /home}
+ * as "up" is the root of the post-install flap: nginx answers {@code /home} before its dash-node
+ * upstream is ready, so a restarting engine still reads "up" (ADFA-5336). Here there is one snapshot:
+ *
+ * {@code UNKNOWN} absorbs the old "have we polled yet?" bool: a never-observed ({@code
+ * observedAtMs <= 0}) or a stale snapshot is {@code UNKNOWN}, not a false {@code DOWN}.
+ */
+public final class ServerLiveness {
+
+ /**
+ * How long a snapshot is trusted before it ages back to {@code UNKNOWN}. The status poll refreshes
+ * every ~3 s (ServerController.CHECK_INTERVAL_MS), so three intervals means "the poll stopped
+ * feeding us" rather than a normal gap — long enough not to flap on one slow probe, short enough
+ * that a genuinely stale reading is not mistaken for a live one.
+ */
+ public static final long DEFAULT_FRESH_MS = 9_000L;
+
+ /** The derived state. One bit of "desired" is a separate concern (the reconciler); this is only
+ * the observed "actual". */
+ public enum Phase {
+ /** No trustworthy observation: never polled, or the last snapshot has gone stale. */
+ UNKNOWN,
+ /** Neither the proot nor the services are present. */
+ DOWN,
+ /** The proot is up but the services do not answer yet (booting, within grace elsewhere). */
+ STARTING,
+ /** The services answer {@code /k2go-api} — genuinely usable. */
+ UP
+ }
+
+ private final boolean processPresent;
+ private final boolean servicesAnswering;
+ private final long observedAtMs;
+ private final long servicesDownSinceMs;
+
+ private ServerLiveness(boolean processPresent, boolean servicesAnswering, long observedAtMs,
+ long servicesDownSinceMs) {
+ this.processPresent = processPresent;
+ this.servicesAnswering = servicesAnswering;
+ this.observedAtMs = observedAtMs;
+ this.servicesDownSinceMs = servicesDownSinceMs;
+ }
+
+ /**
+ * A history-less snapshot. {@code servicesDownSinceMs} is seeded from this one observation
+ * (down "since now" when the proot is present but its services do not answer), so a lone snapshot
+ * reports ~0 downtime. The continuous-downtime clock is threaded by {@link #next}; prefer it on the
+ * poll path so a flap is measured across ticks rather than re-started every tick.
+ *
+ * @param processPresent our environment proot is alive (from {@code /proc}).
+ * @param servicesAnswering dash-node answers {@code /k2go-api}.
+ * @param observedAtMs when these were read, from a monotonic clock
+ * ({@code SystemClock.elapsedRealtime}); {@code 0} means "never".
+ */
+ public static ServerLiveness of(boolean processPresent, boolean servicesAnswering,
+ long observedAtMs) {
+ long downSince = (!servicesAnswering && processPresent && observedAtMs > 0) ? observedAtMs : 0L;
+ return new ServerLiveness(processPresent, servicesAnswering, observedAtMs, downSince);
+ }
+
+ /**
+ * ADFA-5343 (delta ADR-5343a, D1): the next snapshot in a continuous poll stream, carrying how long
+ * the services have been down while the proot stayed present — the clock that tells a flap
+ * (dash-node briefly gone, pdsm about to respawn it) from a genuinely stuck environment. This
+ * replaces keying the kill decision on proot age (which fires instantly on a mature proot; ADFA-5336
+ * regression): downtime is measured from the service drop, not the proot's birth.
+ *
+ * Guardrail — observed-continuous, not wall-calendar. The clock is carried forward only
+ * across a fresh previous observation. If the poll stopped feeding us (the app was
+ * backgrounded, {@code prev} is stale by {@link Freshness}), the streak is broken and the clock
+ * resets to {@code nowMs} — otherwise a long background gap would read as long downtime and
+ * re-drive the kill loop the moment the app returns.
+ *
+ * @param prev the previous snapshot, or {@code null} on the first tick.
+ * @param processPresent our environment proot is alive now.
+ * @param servicesAnswering dash-node answers {@code /k2go-api} now.
+ * @param nowMs the monotonic clock these were read at.
+ * @param freshnessMs how long {@code prev} stays trustworthy for carrying the streak.
+ */
+ public static ServerLiveness next(ServerLiveness prev, boolean processPresent,
+ boolean servicesAnswering, long nowMs, long freshnessMs) {
+ long downSince;
+ if (servicesAnswering || !processPresent) {
+ downSince = 0L; // up, or the proot is gone (that is DOWN → LAUNCH, not a downtime to time)
+ } else {
+ boolean continuousStreak = prev != null
+ && prev.processPresent && !prev.servicesAnswering
+ && prev.servicesDownSinceMs > 0L
+ && Freshness.fresh(prev.observedAtMs, nowMs, freshnessMs); // no observation gap
+ downSince = continuousStreak ? prev.servicesDownSinceMs : nowMs;
+ }
+ return new ServerLiveness(processPresent, servicesAnswering, nowMs, downSince);
+ }
+
+ public boolean processPresent() { return processPresent; }
+ public boolean servicesAnswering() { return servicesAnswering; }
+ public long observedAtMs() { return observedAtMs; }
+
+ /**
+ * How long the services have been continuously observed down (proot present), or {@code -1} when
+ * that is not a fact to act on: the snapshot itself is stale (a background gap — never time a kill
+ * off a reading the poll has not refreshed), or the services are up / the proot is gone. A caller
+ * treats {@code -1} as "wait, do not kill", the same fail-safe as an unknown proot age was.
+ *
+ * @param nowMs the same monotonic clock the snapshot was stamped from.
+ * @param freshnessMs how long the snapshot stays trustworthy (a stale one reports {@code -1}).
+ */
+ public long servicesDownMs(long nowMs, long freshnessMs) {
+ if (!Freshness.fresh(observedAtMs, nowMs, freshnessMs)) {
+ return -1L;
+ }
+ if (servicesDownSinceMs <= 0L) {
+ return -1L;
+ }
+ return Math.max(0L, nowMs - servicesDownSinceMs);
+ }
+
+ /** The phase using the default freshness window. */
+ public Phase phase(long nowMs) {
+ return phase(nowMs, DEFAULT_FRESH_MS);
+ }
+
+ /**
+ * The phase at {@code nowMs}. A never-observed ({@code observedAtMs <= 0}) or stale snapshot is
+ * {@code UNKNOWN} — {@link Freshness#fresh} is the one definition of "still trustworthy" (0 is
+ * never fresh, so both fold here). Otherwise {@code servicesAnswering} wins ({@code UP});
+ * else a present proot is still {@code STARTING}; else {@code DOWN}.
+ *
+ * @param nowMs the same monotonic clock {@code observedAtMs} was stamped from.
+ * @param freshnessMs how long the snapshot stays trustworthy.
+ */
+ public Phase phase(long nowMs, long freshnessMs) {
+ if (!Freshness.fresh(observedAtMs, nowMs, freshnessMs)) {
+ return Phase.UNKNOWN;
+ }
+ if (servicesAnswering) {
+ return Phase.UP;
+ }
+ if (processPresent) {
+ return Phase.STARTING;
+ }
+ return Phase.DOWN;
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/env/domain/ServerReconcile.java b/controller/app/src/main/java/org/iiab/controller/env/domain/ServerReconcile.java
new file mode 100644
index 000000000..9f8fcf07b
--- /dev/null
+++ b/controller/app/src/main/java/org/iiab/controller/env/domain/ServerReconcile.java
@@ -0,0 +1,106 @@
+/*
+ * ============================================================================
+ * Name : ServerReconcile.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : ADFA-5343. The two pure decisions the server-lifecycle reconciler
+ * makes: (1) desired — should the server be up? — a pure function of
+ * facts that already have owners, and (2) intent — given desired vs
+ * the observed liveness phase, which direction would the owner move.
+ * No android.*, so both are unit-tested on the JVM like
+ * EnvironmentEnsure and Freshness.
+ * ============================================================================
+ */
+package org.iiab.controller.env.domain;
+
+import org.iiab.controller.system.domain.Operation;
+
+/**
+ * "Should the server be up, and what would we do about it" — decided in one place.
+ *
+ * {@link #desired} is the process-scoped truth the app was missing: one owner of "the server
+ * should be up," derived from facts that already have owners (installed/healthy from the fact reader,
+ * the persisted user intent, and which operation holds the environment). It creates no new source of
+ * truth. {@link #intent} is the coarse direction the reconciler would move given that desire and the
+ * observed {@link ServerLiveness.Phase}.
+ */
+public final class ServerReconcile {
+
+ private ServerReconcile() {
+ }
+
+ /** The direction the reconciler would move on a tick. Log-only in Phase 1; the actuator acts on it
+ * in a later phase, refining a {@code START} into the safe how (launch / wait-grace / kill-orphan)
+ * via {@link EnvironmentEnsure} — so this gates the direction, that decides the mechanics. */
+ public enum Intent {
+ /** desired up, nothing running — would launch. */
+ START,
+ /** desired down, something running — would stop. */
+ STOP,
+ /** desired up, still coming up — leave it to finish. */
+ WAIT,
+ /** liveness not yet trustworthy (UNKNOWN) — never act on an unobserved/stale snapshot. */
+ HOLD,
+ /** actual already matches desired — nothing to do. */
+ NOOP
+ }
+
+ /**
+ * Should the server be up? Up iff the system is present and whole, the user wants it on, and
+ * no STOPPED-class holder is forcing it down. LIVE-class holders (a live download, a dashboard
+ * self-update) run against the live server, so they leave desired UP; only STOPPED holders
+ * (clone/backup/restore/install, which pdsm-stop the box) pull it down.
+ *
+ * @param installed a rootfs is present and no install is running over it.
+ * @param healthy the last install was not left half-finished.
+ * @param userWantsOn the persisted user intent (today {@code Preferences.WatchdogEnable}).
+ * @param holderClass the execution class of the current environment holder
+ * ({@code EnvironmentLock.currentHolder().executionClass}); {@code NONE} is LIVE.
+ */
+ public static boolean desired(boolean installed, boolean healthy, boolean userWantsOn,
+ Operation.ExecutionClass holderClass) {
+ return installed && healthy && userWantsOn
+ && holderClass != Operation.ExecutionClass.STOPPED;
+ }
+
+ /**
+ * The coarse direction to move, given desired vs the observed phase. An {@code UNKNOWN} phase always
+ * yields {@link Intent#HOLD}: a never-observed or stale snapshot is not a fact to act on.
+ */
+ public static Intent intent(boolean desiredUp, ServerLiveness.Phase actual) {
+ if (actual == ServerLiveness.Phase.UNKNOWN) {
+ return Intent.HOLD;
+ }
+ if (desiredUp) {
+ switch (actual) {
+ case UP:
+ return Intent.NOOP;
+ case STARTING:
+ return Intent.WAIT;
+ case DOWN:
+ default:
+ return Intent.START;
+ }
+ }
+ // desired down
+ switch (actual) {
+ case DOWN:
+ return Intent.NOOP;
+ case UP:
+ case STARTING:
+ default:
+ return Intent.STOP;
+ }
+ }
+
+ /**
+ * Whether an {@link Intent} means "drive the server up now". {@code START} (down) and {@code WAIT}
+ * (still coming up, or a stuck flap) both do — the reconciler routes both to the idempotent,
+ * self-gating boot, which decides launch / leave-in-grace / relaunch-stuck. {@code NOOP} / {@code
+ * STOP} / {@code HOLD} do not (STOP is owned elsewhere until a later phase; HOLD never acts on an
+ * unobserved snapshot).
+ */
+ public static boolean ensuresUp(Intent intent) {
+ return intent == Intent.START || intent == Intent.WAIT;
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java b/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java
index 42b5a0c00..ac9c815f1 100644
--- a/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java
+++ b/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java
@@ -122,10 +122,12 @@ public class SetupProgressActivity extends AppCompatActivity implements org.iiab
// just to issue that start; the server proot is process-scoped so it survives into LibraryActivity.
private org.iiab.controller.ServerController serverController;
private Boolean targetServerState = null; // ServerController.Host state
- private boolean moduleRestartKicked = false; // handleServerLaunchClick issued once
- private boolean moduleServerUp = false; // REST core answered after the restart
- private boolean moduleServerFailed = false; // restart timed out — surface as failure, not silent success
- private long moduleRestartAt = 0L; // elapsedRealtime when the restart was kicked
+ // ADFA-5343 (Phase 2): the post-batch server restart is owned by the reconciler now, not this screen.
+ // The three boot latches (moduleRestartKicked/moduleServerUp/moduleServerFailed) are gone: "up" is
+ // read from the one observed server phase (serverObservedUp()), and "didn't come back in time" is a
+ // timeout on that phase anchored below — there is no FAILED phase, so a stuck flap is STARTING the
+ // reconciler keeps re-driving, and Finish still lands on a Home the reconciler drives live (5336).
+ private long moduleServerWaitAt = 0L; // elapsedRealtime when the post-batch wait began (0 = not yet)
private org.iiab.controller.util.EllipsisAnimator statusEllipsis; // ADFA-4842: animated "…" on the amber wait line
private int px(int dp) { return Math.round(dp * getResources().getDisplayMetrics().density); }
@@ -236,7 +238,6 @@ protected void onResume() {
protected void onPause() {
super.onPause();
main.removeCallbacks(readyPoll);
- main.removeCallbacks(serverUpPoll); // ADFA-4842
if (statusEllipsis != null) statusEllipsis.stop(); // ADFA-4842
cancelRedirect();
if (serverController != null) serverController.onPause(); // ADFA-4842: stop the status poll (not the server)
@@ -291,16 +292,24 @@ public void onBackPressed() {
/** ADFA-4919/4842: is a proot stage still blocking the index? True while a runrole is queued/running
* AND, for a module batch, through the post-DONE server restart — the user must not background or
* Back out (there is no "Run in background" for proot) until the server is confirmed back up
- * (moduleServerUp) or the wait times out. */
+ * (serverObservedUp) or the wait times out. */
private boolean prootActive() {
ModuleQueueState mq = ModuleQueueRepository.get().current();
boolean mapsTerminal = mapsStartFailed || (mapsInSession() && mq.phase == ModuleQueueState.Phase.DONE);
boolean mapsActive = mapsInSession() && !mapsTerminal;
// A module session stays active from its runroles through the server restart that follows.
- boolean moduleActive = (moduleInSession() || moduleStartFailed) && !moduleServerUp;
+ boolean moduleActive = (moduleInSession() || moduleStartFailed) && !serverObservedUp();
return mapsActive || moduleActive;
}
+ /** ADFA-5343 (Phase 2): the server is observed up — the 3s poll saw /k2go-api answer. Replaces the
+ * moduleServerUp boot latch: the fact is read from the one published observation
+ * ({@link org.iiab.controller.ServerStateRepository}), not tracked per screen. */
+ private boolean serverObservedUp() {
+ return org.iiab.controller.ServerStateRepository.get().hasObservation()
+ && org.iiab.controller.ServerStateRepository.get().current().alive;
+ }
+
/** ADFA-4919: is the proot (maps) stage part of THIS install session? Latched from the DURABLE
* module queue (app-scoped) + wishlist, so a fresh index instance — e.g. reopened from the
* notification while the queue is still running — still shows the stage, hides "Run in
@@ -401,12 +410,13 @@ private boolean rebuildInSession() {
// ADFA-4842: a MODULE (solo-proot) install stops the server and runs its OWN proot — there is
// no REST engine to wait for, and we must NEVER try to "start services" (a second proot) mid-
// runrole. Skip the REST readiness gate entirely: the runrole queue drives progress, and the
- // server is (re)started only AFTER the queue is DONE (ensureServerUpForModules, via render()).
- // REST and REST+proot (mixed) keep their serialized apiReady path below, untouched.
+ // server is (re)started only AFTER the queue is DONE — now by the reconciler (desired=UP),
+ // observed here via render(). REST and REST+proot (mixed) keep their serialized apiReady path
+ // below, untouched.
if (moduleInSession()) {
orchestrateStep(); // drains on first entry; harmless no-op once the queue is running
render();
- if (!moduleServerUp) main.postDelayed(readyPoll, READY_POLL_MS);
+ if (!serverObservedUp()) main.postDelayed(readyPoll, READY_POLL_MS);
return;
}
// ADFA-5074: nothing to start means nothing to wait for. The readiness probe exists so
@@ -648,15 +658,27 @@ private void render() {
// ADFA-4919: a proot module is queued/running (the gate is active).
boolean prootActive = prootActive();
if (contextText != null) contextText.setText(prootActive ? R.string.k2go_setup_context_proot : R.string.k2go_setup_context);
- // ADFA-4842: a terminal MODULE batch stopped the server for its runroles, so the server must be
- // brought back before we can finish. Kick that here for ANY terminal module session —
- // independent of the noRest/REST branch below — so ensureServerUpForModules() (and its 45s
- // safety timeout) always runs and moduleServerUp / prootActive() can never hang. Idempotent.
+ // ADFA-4842/5343: a terminal MODULE batch stopped the server for its runroles, so the server must
+ // come back before we can finish. Record the intent here for ANY terminal module session —
+ // independent of the noRest/REST branch below — so onModuleBatchTerminal() (which sets desired=UP
+ // and anchors the wait timeout) always runs and serverObservedUp() / prootActive() can never hang.
+ // Idempotent (guarded by the wait anchor).
boolean queueTerminalNotRunning = prootTerminal && !ModuleQueueRepository.get().isRunning();
- if (moduleShown && queueTerminalNotRunning) ensureServerUpForModules();
+ if (moduleShown && queueTerminalNotRunning) onModuleBatchTerminal();
+
+ // ADFA-5343 (Phase 2): the module server state read from the observed phase, not a boot latch.
+ // up = the poll observed /k2go-api answering;
+ // slow = still not up past the wait timeout — a stuck flap the reconciler keeps re-driving,
+ // surfaced so the user isn't left staring (Finish lands on a Home it drives live);
+ // settling = terminal, not up yet, still within the timeout ((re)starting).
+ boolean batchServerUp = moduleShown && queueTerminalNotRunning && serverObservedUp();
+ boolean batchAwaitingServer = moduleShown && queueTerminalNotRunning && !serverObservedUp();
+ boolean batchServerSlow = batchAwaitingServer && moduleServerWaitAt != 0L
+ && SystemClock.elapsedRealtime() - moduleServerWaitAt > SERVER_UP_TIMEOUT_MS;
+ boolean batchServerSettling = batchAwaitingServer && !batchServerSlow;
boolean allComplete;
- boolean moduleServerSettled = moduleServerUp || moduleServerFailed; // ADFA-4842: up, or gave up (failure)
+ boolean moduleServerSettled = batchServerUp || batchServerSlow; // ADFA-5343: up, or gave up waiting here
if (noRest && prootShown) {
// proot-only: complete when the queue is terminal — plus, for a module batch, once the server
// is back (up) or the restart has failed (a dead home that wakes up seconds later is exactly
@@ -702,12 +724,12 @@ private void render() {
boolean moduleFailed = moduleFlow && prootFailed > 0;
// Amber "working" while a module install runs, its post-DONE restart is pending, or the batch
// ended with a failed module (kept on the same amber install line, never a green success).
- boolean amberWaiting = !moduleServerFailed && (moduleFailed || (moduleFlow ? !moduleServerUp : !servicesReady));
- tint(dot, (amberWaiting || moduleServerFailed) ? R.color.k2go_amber : R.color.k2go_leaf);
+ boolean amberWaiting = !batchServerSlow && (moduleFailed || (moduleFlow ? !batchServerUp : !servicesReady));
+ tint(dot, (amberWaiting || batchServerSlow) ? R.color.k2go_amber : R.color.k2go_leaf);
int statusRes;
- if (moduleServerFailed) statusRes = R.string.k2go_setup_slow; // couldn't bring services online
- else if (moduleRestartKicked && !moduleServerUp) statusRes = R.string.k2go_setup_starting; // (re)starting the server
- else if (moduleFlow && !moduleServerUp) statusRes = R.string.install_busy_modules; // runroles in flight
+ if (batchServerSlow) statusRes = R.string.k2go_setup_slow; // couldn't bring services online in time
+ else if (batchServerSettling) statusRes = R.string.k2go_setup_starting; // reconciler is (re)starting the server
+ else if (moduleFlow && !batchServerUp) statusRes = R.string.install_busy_modules; // runroles in flight
else if (moduleFailed) statusRes = R.string.install_busy_modules; // ADFA-4898: keep the amber install header; failure + Retry are per-module below
else if (moduleFlow) statusRes = R.string.k2go_setup_adding; // module done + server up
else if (!servicesReady) statusRes = (readyPolls >= SLOW_AFTER_POLLS ? R.string.k2go_setup_slow : R.string.k2go_setup_starting);
@@ -752,8 +774,8 @@ && getLifecycle().getCurrentState().isAtLeast(androidx.lifecycle.Lifecycle.State
// Bottom controls. ADFA-4842: a failed post-module server restart counts as a failure (Finish +
// note), never a silent success — so the user is told, not dropped on a dead Home.
- boolean success = allComplete && failedTotal == 0 && !moduleServerFailed;
- boolean failure = allComplete && (failedTotal > 0 || moduleServerFailed);
+ boolean success = allComplete && failedTotal == 0 && !batchServerSlow;
+ boolean failure = allComplete && (failedTotal > 0 || batchServerSlow);
if (success && !redirectCancelled) {
show(redirect, true); show(cancel, true);
show(finishBtn, false); show(finishNote, false); show(runBgBtn, false);
@@ -1202,46 +1224,21 @@ private void cancelRedirect() {
main.removeCallbacks(goHomeRunnable);
redirectScheduled = false;
}
- /** ADFA-4842: after a module batch (the server was pdsm-stopped for the runroles), start the server
- * once and poll the REST core until it answers. render() gates completion/redirect on
- * moduleServerUp, so we only leave for the Library once the system is actually live. */
- private void ensureServerUpForModules() {
- if (moduleServerUp || moduleServerFailed || moduleRestartKicked) return;
- moduleRestartKicked = true;
- moduleRestartAt = SystemClock.elapsedRealtime();
- // ADFA-4842: MECHANISM (proot ≠ REST — do NOT "simplify" this to the toggle; read
- // ServerController.startEnvironment() first). Each module runrole runs in its own proot with
- // --kill-on-exit: clean start → its tasks → clean stop, so the environment is DOWN when the batch
- // finishes (that is the correct per-module cycle, especially with several modules in series). After
- // the LAST module the INDEX is the actuator: it brings the environment back UNCONDITIONALLY.
- // We must NOT call handleServerLaunchClick here (it is a TOGGLE): the cached alive can still read
- // TRUE the instant after the runrole proot exits, and the toggle would then STOP instead of start
- // (that was the "Stopping IIAB environment gracefully → dead Home" bug from the device log).
- // startEnvironment() always starts. The server proot is process-scoped, so it survives into
- // LibraryActivity, which only MONITORS it — Home never starts the server.
- serverController.startEnvironment();
- main.postDelayed(serverUpPoll, READY_POLL_MS);
- }
-
- private final Runnable serverUpPoll = new Runnable() {
- @Override public void run() {
- if (isFinishing() || moduleServerUp || moduleServerFailed) return;
- AppExecutors.get().io().execute(() -> {
- final boolean up = RestReadiness.apiReady();
- main.post(() -> {
- if (isFinishing() || moduleServerUp || moduleServerFailed) return;
- if (up) { moduleServerUp = true; render(); } // REST core answered → complete → redirect
- else if (SystemClock.elapsedRealtime() - moduleRestartAt > SERVER_UP_TIMEOUT_MS) {
- // ADFA-4842: the environment didn't come online in time. Do NOT declare success and
- // drop the user on a dead Home (the old behavior); surface it as a FAILURE so the
- // index shows the Finish/error state (like an Ansible failure). Home is a monitor —
- // it won't recover this, so the honest thing is to tell the user here.
- moduleServerFailed = true; render();
- } else main.postDelayed(serverUpPoll, READY_POLL_MS);
- });
- });
+ /** ADFA-5343 (Phase 2): a module batch stopped the server for its runroles; when the batch is
+ * terminal the server should come back. We no longer boot + poll here. We record the intent
+ * (userWantsOn) once — the lock is already released and the durable guard cleared before the queue
+ * publishes DONE, so desired flips UP — and the reconciler drives it up and keeps it up (re-driving
+ * a flap), so a redirect to Home lands on a live system rather than a dead one (5336). The wait
+ * timestamp anchors the "taking longer" UI. When actuation is disabled (rollback), we boot once here
+ * as before (and Home is a monitor again, so 5336 is not fixed in that mode). */
+ private void onModuleBatchTerminal() {
+ if (moduleServerWaitAt != 0L) return; // once — also the timeout anchor
+ moduleServerWaitAt = SystemClock.elapsedRealtime();
+ new org.iiab.controller.Preferences(this).setWatchdogEnable(true); // persisted intent → desired = UP
+ if (!org.iiab.controller.env.ServerLifecycleReconciler.ACTUATES) {
+ serverController.startEnvironment(); // rollback path: reconciler is log-only, boot here
}
- };
+ }
private void goHome(boolean clearSessions) {
cancelRedirect();
@@ -1251,9 +1248,10 @@ private void goHome(boolean clearSessions) {
// ADFA-4919: the natural end of installing is the Library — go there directly and clear the
// install screens above it. Both the wizard and Get More launch from LibraryActivity, so
// CLEAR_TOP + SINGLE_TOP lands on the existing Library (dropping Get More + this index). Only
- // success/Finish reach here; "Run in background" (REST) still finish()es in place. ADFA-4842: a
- // module batch already restarted the server and waited for it here (ensureServerUpForModules), so
- // the reused Library is live on arrival — no cold-boot recreate needed.
+ // success/Finish reach here; "Run in background" (REST) still finish()es in place. ADFA-5343: a
+ // module batch set desired=UP; the reconciler brings the server up and keeps re-driving it wherever
+ // the app is, so the reused Library is (or becomes) live on arrival — even Finish under a slow/flap
+ // start lands on a Home the reconciler drives up, not a dead one (5336).
startActivity(new android.content.Intent(this, LibraryActivity.class)
.addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP | android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
.putExtra(LibraryActivity.EXTRA_TAB, R.id.nav_library)); // ADFA-4842: land on Home, not the launching tab (Settings)
diff --git a/controller/app/src/test/java/org/iiab/controller/env/domain/EnvironmentEnsureTest.java b/controller/app/src/test/java/org/iiab/controller/env/domain/EnvironmentEnsureTest.java
index 754a125f7..9889c4fc8 100644
--- a/controller/app/src/test/java/org/iiab/controller/env/domain/EnvironmentEnsureTest.java
+++ b/controller/app/src/test/java/org/iiab/controller/env/domain/EnvironmentEnsureTest.java
@@ -8,9 +8,11 @@
* Unit tests for {@link EnvironmentEnsure}.
*
* The stakes are the same lopsided ones as the matcher's: the wrong verdict here either stacks a
- * second proot over a live one or SIGKILLs one mid-boot. The single case that got the first attempt
- * reverted — a proot killed 3.5 s into its own start — is {@link #aYoungUnansweringProotIsLeftToBoot}
- * and {@link #anUnknownAgeIsNeverKilled}.
+ * second proot over a live one or SIGKILLs one that pdsm would have healed. Two cases anchor the
+ * decision: a proot 3.5 s into its own boot must be left alone ({@link #aBootingProotIsLeftToFinish},
+ * {@link #anUnknownDowntimeIsNeverKilled}), and — the ADFA-5336 regression this delta fixes — a mature
+ * proot whose dash-node just blipped must WAIT for pdsm's respawn, not be killed
+ * ({@link #aMatureProotWhoseServiceJustDroppedWaitsForRespawn}).
*/
public class EnvironmentEnsureTest {
@@ -19,45 +21,56 @@ public class EnvironmentEnsureTest {
@Test
public void nothingRunningLaunches() {
assertEquals(EnvironmentEnsure.Action.LAUNCH,
- EnvironmentEnsure.decide(false, -1L, false, GRACE));
+ EnvironmentEnsure.decide(false, false, -1L, GRACE));
}
@Test
public void aliveAndAnsweringIsANoOp() {
// The redundant-call case: five of the six callers fire "ensure it is up" when it already is.
+ // servicesAlive short-circuits, so the downtime argument is irrelevant.
assertEquals(EnvironmentEnsure.Action.NOOP_HEALTHY,
- EnvironmentEnsure.decide(true, 90_000L, true, GRACE));
+ EnvironmentEnsure.decide(true, true, -1L, GRACE));
}
@Test
- public void aYoungUnansweringProotIsLeftToBoot() {
- // 3.5 s in, services not up yet — exactly the proot the earlier wiring killed. Never touch it.
+ public void aBootingProotIsLeftToFinish() {
+ // 3.5 s in, services not up yet — services have been down since the proot started, which is
+ // exactly the proot the earlier wiring killed. Under the grace → never touch it.
assertEquals(EnvironmentEnsure.Action.WAIT_BOOT_GRACE,
- EnvironmentEnsure.decide(true, 3_500L, false, GRACE));
+ EnvironmentEnsure.decide(true, false, 3_500L, GRACE));
}
@Test
- public void anUnknownAgeIsNeverKilled() {
- // If /proc/ The four-way truth table plus the freshness boundary. The case that matters most is the flap
+ * ({@link #servicesDownWithProotUpIsStartingNotUp}): a restarting dash-node behind a live nginx must
+ * read {@code STARTING}, never {@code UP} — reading {@code /home} as "up" is exactly what the single
+ * {@code servicesAnswering} signal removes (ADFA-5336).
+ */
+public class ServerLivenessTest {
+
+ private static final long NOW = 100_000L;
+ private static final long FRESH = ServerLiveness.DEFAULT_FRESH_MS;
+
+ @Test
+ public void neverObservedIsUnknown() {
+ // observedAtMs == 0: the poll has not run — not a false DOWN (absorbs the old hasObservation()).
+ assertEquals(ServerLiveness.Phase.UNKNOWN,
+ ServerLiveness.of(false, false, 0L).phase(NOW, FRESH));
+ assertEquals(ServerLiveness.Phase.UNKNOWN,
+ ServerLiveness.of(true, true, 0L).phase(NOW, FRESH));
+ }
+
+ @Test
+ public void servicesAnsweringIsUp() {
+ assertEquals(ServerLiveness.Phase.UP,
+ ServerLiveness.of(true, true, NOW).phase(NOW, FRESH));
+ }
+
+ @Test
+ public void servicesDownWithProotUpIsStartingNotUp() {
+ // The flap: proot alive, /k2go-api not answering yet. STARTING, never UP.
+ assertEquals(ServerLiveness.Phase.STARTING,
+ ServerLiveness.of(true, false, NOW).phase(NOW, FRESH));
+ }
+
+ @Test
+ public void nothingPresentIsDown() {
+ assertEquals(ServerLiveness.Phase.DOWN,
+ ServerLiveness.of(false, false, NOW).phase(NOW, FRESH));
+ }
+
+ @Test
+ public void servicesAnsweringWinsOverAbsentProot() {
+ // Defensive: /proc missed the proot but /k2go-api answers — the usable signal wins, still UP.
+ assertEquals(ServerLiveness.Phase.UP,
+ ServerLiveness.of(false, true, NOW).phase(NOW, FRESH));
+ }
+
+ @Test
+ public void aFreshWindowedSnapshotWithinTheWindowIsTrusted() {
+ // Stamped FRESH-1 ms ago: still trustworthy, so the observed facts stand (UP).
+ assertEquals(ServerLiveness.Phase.UP,
+ ServerLiveness.of(true, true, NOW - (FRESH - 1)).phase(NOW, FRESH));
+ }
+
+ @Test
+ public void theFreshnessBoundaryStillTrustsAtExactlyTheWindow() {
+ // At exactly the window it is still fresh (Freshness.fresh uses <=), so UP holds.
+ assertEquals(ServerLiveness.Phase.UP,
+ ServerLiveness.of(true, true, NOW - FRESH).phase(NOW, FRESH));
+ }
+
+ @Test
+ public void aStaleSnapshotAgesBackToUnknown() {
+ // One ms past the window: the poll stopped feeding us — UNKNOWN, not a stale true "UP".
+ assertEquals(ServerLiveness.Phase.UNKNOWN,
+ ServerLiveness.of(true, true, NOW - (FRESH + 1)).phase(NOW, FRESH));
+ }
+
+ @Test
+ public void defaultWindowMatchesTheExplicitOne() {
+ // The convenience phase(now) uses DEFAULT_FRESH_MS.
+ ServerLiveness live = ServerLiveness.of(true, false, NOW);
+ assertEquals(live.phase(NOW, ServerLiveness.DEFAULT_FRESH_MS), live.phase(NOW));
+ }
+
+ // --- ADFA-5343a (D1): service-downtime clock ---------------------------------------------------
+
+ @Test
+ public void servicesDownMsIsZeroTheTickTheServicesDrop() {
+ // First tick where the proot is up but /k2go-api stops answering: downtime starts at 0.
+ ServerLiveness up = ServerLiveness.of(true, true, 1_000L);
+ ServerLiveness dropped = ServerLiveness.next(up, true, false, 4_000L, FRESH);
+ assertEquals(0L, dropped.servicesDownMs(4_000L, FRESH));
+ }
+
+ @Test
+ public void servicesDownMsAccumulatesAcrossFreshTicks() {
+ // A continuous streak of fresh 3 s ticks accumulates real downtime from the drop, not per-tick.
+ ServerLiveness t0 = ServerLiveness.next(null, true, false, 1_000L, FRESH); // dropped at 1000
+ ServerLiveness t1 = ServerLiveness.next(t0, true, false, 4_000L, FRESH);
+ ServerLiveness t2 = ServerLiveness.next(t1, true, false, 7_000L, FRESH);
+ assertEquals(3_000L, t1.servicesDownMs(4_000L, FRESH));
+ assertEquals(6_000L, t2.servicesDownMs(7_000L, FRESH));
+ }
+
+ @Test
+ public void theClockResetsOnAnObservationGap() {
+ // GUARDRAIL: the poll went quiet longer than the freshness window (app backgrounded). The
+ // previous snapshot is stale, so the streak breaks and downtime restarts from now — a calendar
+ // gap must not read as downtime and re-drive the kill loop when the app returns.
+ ServerLiveness before = ServerLiveness.next(null, true, false, 1_000L, FRESH);
+ long afterGap = 1_000L + FRESH + 1L; // one ms past the window since `before`
+ ServerLiveness resumed = ServerLiveness.next(before, true, false, afterGap, FRESH);
+ assertEquals(0L, resumed.servicesDownMs(afterGap, FRESH)); // reset, not FRESH+1 of "downtime"
+ }
+
+ @Test
+ public void aStaleSnapshotReportsUnknownDowntime() {
+ // Read side of the same guardrail: even a snapshot that WAS timing downtime reports -1 once it
+ // is itself stale — never time a kill off a reading the poll has not refreshed.
+ ServerLiveness dropped = ServerLiveness.of(true, false, 1_000L); // down since 1000
+ assertEquals(-1L, dropped.servicesDownMs(1_000L + FRESH + 1L, FRESH));
+ }
+
+ @Test
+ public void servicesAnsweringClearsTheClock() {
+ ServerLiveness down = ServerLiveness.next(null, true, false, 1_000L, FRESH);
+ ServerLiveness up = ServerLiveness.next(down, true, true, 4_000L, FRESH);
+ assertEquals(-1L, up.servicesDownMs(4_000L, FRESH));
+ }
+
+ @Test
+ public void aGoneProotIsNotATrackedDowntime() {
+ // Proot absent → DOWN → LAUNCH is the caller's business; there is no service downtime to time.
+ ServerLiveness down = ServerLiveness.next(null, true, false, 1_000L, FRESH);
+ ServerLiveness gone = ServerLiveness.next(down, false, false, 4_000L, FRESH);
+ assertEquals(-1L, gone.servicesDownMs(4_000L, FRESH));
+ }
+}
diff --git a/controller/app/src/test/java/org/iiab/controller/env/domain/ServerReconcileTest.java b/controller/app/src/test/java/org/iiab/controller/env/domain/ServerReconcileTest.java
new file mode 100644
index 000000000..af4627430
--- /dev/null
+++ b/controller/app/src/test/java/org/iiab/controller/env/domain/ServerReconcileTest.java
@@ -0,0 +1,122 @@
+package org.iiab.controller.env.domain;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.iiab.controller.system.domain.Operation;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link ServerReconcile}.
+ *
+ * Two tables. {@code desired} is the process-scoped "should it be up?" — the case that matters is
+ * the holder-class dimension (ADFA-5343): a LIVE holder (download / dashboard self-update) must leave
+ * desired UP, only a STOPPED holder pulls it down. {@code intent} is the coarse direction, whose one
+ * rule worth stating is that an UNKNOWN phase never provokes an action.
+ */
+public class ServerReconcileTest {
+
+ private static final Operation.ExecutionClass LIVE = Operation.ExecutionClass.LIVE;
+ private static final Operation.ExecutionClass STOPPED = Operation.ExecutionClass.STOPPED;
+
+ // --- desired -----------------------------------------------------------------
+
+ @Test
+ public void desiredUpWhenUsableWantedAndNoStoppedHolder() {
+ assertTrue(ServerReconcile.desired(true, true, true, LIVE));
+ }
+
+ @Test
+ public void aStoppedHolderForcesDesiredDown() {
+ // clone / backup / restore / install pdsm-stop the box: desired must be DOWN even if wanted on.
+ assertFalse(ServerReconcile.desired(true, true, true, STOPPED));
+ }
+
+ @Test
+ public void aLiveHolderLeavesDesiredUp() {
+ // The Task-1 fix: a live download / dashboard self-update runs against the live server, so it
+ // must NOT force the server down (the old currentHolder == NONE predicate got this wrong).
+ assertTrue(ServerReconcile.desired(true, true, true, LIVE));
+ }
+
+ @Test
+ public void notInstalledIsNeverDesiredUp() {
+ assertFalse(ServerReconcile.desired(false, true, true, LIVE));
+ }
+
+ @Test
+ public void unhealthyIsNeverDesiredUp() {
+ // Installed but half-finished: the recovery dialog's world, nothing should run against it.
+ assertFalse(ServerReconcile.desired(true, false, true, LIVE));
+ }
+
+ @Test
+ public void userOffIsNeverDesiredUp() {
+ assertFalse(ServerReconcile.desired(true, true, false, LIVE));
+ }
+
+ // --- intent ------------------------------------------------------------------
+
+ @Test
+ public void unknownPhaseAlwaysHolds() {
+ assertEquals(ServerReconcile.Intent.HOLD,
+ ServerReconcile.intent(true, ServerLiveness.Phase.UNKNOWN));
+ assertEquals(ServerReconcile.Intent.HOLD,
+ ServerReconcile.intent(false, ServerLiveness.Phase.UNKNOWN));
+ }
+
+ @Test
+ public void desiredUpStartsWhenDown() {
+ assertEquals(ServerReconcile.Intent.START,
+ ServerReconcile.intent(true, ServerLiveness.Phase.DOWN));
+ }
+
+ @Test
+ public void desiredUpWaitsWhileStarting() {
+ assertEquals(ServerReconcile.Intent.WAIT,
+ ServerReconcile.intent(true, ServerLiveness.Phase.STARTING));
+ }
+
+ @Test
+ public void desiredUpIsNoopWhenUp() {
+ assertEquals(ServerReconcile.Intent.NOOP,
+ ServerReconcile.intent(true, ServerLiveness.Phase.UP));
+ }
+
+ @Test
+ public void desiredDownStopsWhenUp() {
+ assertEquals(ServerReconcile.Intent.STOP,
+ ServerReconcile.intent(false, ServerLiveness.Phase.UP));
+ }
+
+ @Test
+ public void desiredDownStopsWhileStarting() {
+ // Caught mid-boot but no longer wanted (a holder just acquired): still STOP.
+ assertEquals(ServerReconcile.Intent.STOP,
+ ServerReconcile.intent(false, ServerLiveness.Phase.STARTING));
+ }
+
+ @Test
+ public void desiredDownIsNoopWhenDown() {
+ assertEquals(ServerReconcile.Intent.NOOP,
+ ServerReconcile.intent(false, ServerLiveness.Phase.DOWN));
+ }
+
+ // --- ensuresUp (which intents drive a boot) ----------------------------------
+
+ @Test
+ public void startAndWaitEnsureUp() {
+ // START (down) and WAIT (coming up / stuck flap) both route to the idempotent ensureServerUp.
+ assertTrue(ServerReconcile.ensuresUp(ServerReconcile.Intent.START));
+ assertTrue(ServerReconcile.ensuresUp(ServerReconcile.Intent.WAIT));
+ }
+
+ @Test
+ public void noopStopHoldDoNotEnsureUp() {
+ assertFalse(ServerReconcile.ensuresUp(ServerReconcile.Intent.NOOP));
+ assertFalse(ServerReconcile.ensuresUp(ServerReconcile.Intent.STOP));
+ assertFalse(ServerReconcile.ensuresUp(ServerReconcile.Intent.HOLD));
+ }
+}
diff --git a/controller/docs/ADR-5343-server-lifecycle-reconciler.md b/controller/docs/ADR-5343-server-lifecycle-reconciler.md
index 64bf988a4..330f41d7e 100644
--- a/controller/docs/ADR-5343-server-lifecycle-reconciler.md
+++ b/controller/docs/ADR-5343-server-lifecycle-reconciler.md
@@ -203,18 +203,37 @@ lifecycle - and make everything else an **observer** or an **intent-setter**.
holder wants it down:
```
- desired = (SystemFacts.installed ? SystemFacts.healthy)
- ? userWantsOn
- ? EnvironmentLock.currentHolder == NONE
+ desired = SystemFacts.installed && SystemFacts.healthy
+ && userWantsOn
+ && EnvironmentLock.currentHolder.executionClass != STOPPED
```
Every input already exists: `installed`/`healthy` come from `system/data/SystemFactsReader.java:72-89`
- (which already folds `InstallGuard` + `InterruptedInstallDetector`); "a holder wants it down" is
- exactly `env/EnvironmentLock.currentHolder()` (`env/EnvironmentLock.java:172-188`, already the one
- enumerator of CLONE/BACKUP/RESTORE/INSTALL/DOWNLOAD/DASHBOARD). `userWantsOn` is the persisted
+ (which already folds `InstallGuard` + `InterruptedInstallDetector`); `userWantsOn` is the persisted
intent that `Preferences.WatchdogEnable` is *already* standing in for today
- (`ServerController.java:342,461`). **No new source of truth is created - desired is a pure function
- of existing facts.**
+ (`ServerController.java:342,461`).
+
+ The last term keys on the **holder's execution class, not on `== NONE`.** Not every holder wants the
+ server down. ADR-5061 **already** owns that split in one pure-JVM type,
+ `system/domain/Operation.ExecutionClass { LIVE, STOPPED }` (`system/domain/Operation.java:49-61`),
+ whose own doc is exactly our distinction: `LIVE` = "the box stays up; the device POSTs and polls the
+ in-server REST core"; `STOPPED` = "the box goes down: `pdsm stop`, then Ansible in a transient proot."
+ - **STOPPED holders want it DOWN** — CLONE, BACKUP, RESTORE, INSTALL each `pdsm stop` the box and run
+ a transient `proot` runrole (`deepop/DeepOpService.java:124,127`; `redesign/CloneFragment.java:475,1322`).
+ - **LIVE holders run *against* the live server and want it UP** — DOWNLOAD (the device only POSTs +
+ polls; the work runs on the live server, `redesign/ZimDownloadService.java:10`) and DASHBOARD (a live
+ dash-node self-update, `EnvironmentLock.java:184-186`, ADFA-5333). Forcing `desired=DOWN` for these
+ two would stop the very server they depend on — and would directly contradict §6, which already says
+ `DASHBOARD` must stay `UP` (expect only a blip).
+
+ **Reuse that type; do not add a parallel one.** Give the existing `Holder` enum one property that
+ returns `Operation.ExecutionClass` (STOPPED for CLONE/BACKUP/RESTORE/INSTALL; LIVE for
+ DOWNLOAD/DASHBOARD, and for `NONE` — no holder is forcing the box down). `EnvironmentLock.currentHolder()`
+ (`env/EnvironmentLock.java:172-188`) stays the one enumerator of holders; `desired` asks the returned
+ holder its class instead of comparing to a magic `NONE`. A new `HolderClass` enum would be a second
+ type saying what `ExecutionClass` already says — the same duplicate-truth this ADR exists to remove.
+ **No new source of truth is created — `desired` is a pure function of existing facts, the LIVE/STOPPED
+ vocabulary has one owner (ADR-5061), and the holder just names its class.**
2. **One liveness source.** A single `ServerLiveness` snapshot, freshness-windowed, replacing the four:
@@ -243,8 +262,9 @@ lifecycle - and make everything else an **observer** or an **intent-setter**.
published `ServerPhase` from the reconciler (the way `onNewIntent` already only monitors,
`redesign/LibraryActivity.java:870`). The user button calls `reconciler.setUserWantsOn(boolean)` -
it *sets desired*, it does not start-XOR-stop on a cache. Deep operations call
- `EnvironmentLock.acquire(...)` (already the signal) and the reconciler observes the holder and
- stops; on `release(...)` it observes `NONE` and brings the box back **wherever the app is** - no
+ `EnvironmentLock.acquire(...)` (already the signal); the reconciler observes a **STOPPED-class**
+ holder (`executionClass == STOPPED`) and stops, while a LIVE-class holder (DOWNLOAD/DASHBOARD) leaves
+ `desired=UP`. On `release(...)` it observes `NONE` and brings the box back **wherever the app is** - no
Activity owns the reboot.
5. **Home for the owner.** The reconciler is an **app-scoped singleton** (created in
@@ -282,7 +302,7 @@ stateDiagram-v2
STARTING --> UP: liveness: servicesAnswering (NOOP_HEALTHY)
STARTING --> STARTING: reconcile: WAIT (progress fresh)
STARTING --> DOWN: reconcile: no progress kill, relaunch next tick
- UP --> STOPPING: reconcile(desired=DOWN): a holder wants it down / user off
+ UP --> STOPPING: reconcile(desired=DOWN): a STOPPED-class holder wants it down / user off
UP --> STARTING: liveness: !servicesAnswering && desired=UP (auto-heals flap)
STOPPING --> DOWN: pdsm stop exits
DOWN --> UP: reconcile keeps desired=UP until achieved
diff --git a/controller/docs/ADR-5343a-flap-recovery-delta.md b/controller/docs/ADR-5343a-flap-recovery-delta.md
new file mode 100644
index 000000000..8d772771e
--- /dev/null
+++ b/controller/docs/ADR-5343a-flap-recovery-delta.md
@@ -0,0 +1,152 @@
+# ADR-5343a - Flap auto-recovery: correcting the Phase-2 actuation (delta to ADR-5343)
+
+**Status:** Approved (2026-08-29) - D1 + D2 approved for implementation (including the two deviations, §9); D3 deferred to Phase 4 (recorded, not pulled forward).
+**Date:** 2026-08-29
+**Deciders:** Luis (sign-off required).
+**Ticket:** ADFA-5343 (Task under Epic ADFA-1028). Revises **ADR-5343** (`controller/docs/ADR-5343-server-lifecycle-reconciler.md`); resolves the open bug **ADFA-5336** whose Phase-2-v1 implementation regressed.
+**Scope of this delta:** it revises exactly three things in ADR-5343 - the �2.6 grace (concretizes it), the �5 collapse row for 5336, and the �7 Phase-2 migration row. Everything else in ADR-5343 stands.
+
+> Method note. Produced read-only against Phase-2 commit `1f5cb0f6`, with the mechanism re-confirmed on device `a026a310` (OnePlus7T). Every structural claim cites `File.java:line`. The reduction gate (ADR-5343 �8 / `CLAUDE.local.md`) still binds: this delta must not add a state, flag, source of truth, or "who may act" special-case.
+
+---
+
+## 1. What Phase-2 device verification found
+
+ADR-5343's reasoning layer verified correct on every flow (one liveness source, the holder-execution-class `desired` predicate, monitoractuator split). But the **key** flow - flap auto-recovery (ADFA-5336) - **regressed**: Phase-2 actuation turns a *self-healing* service flap into an *unrecoverable* loop.
+
+**A/B evidence (same box, same induction `kill
+ *
+ *
+ *