@@ -135,6 +135,15 @@ public final class InstallService extends Service {
135135 private volatile boolean finished = false ;
136136 private volatile boolean started = false ;
137137
138+ // ADFA-4898 P4: movement-based stall detection for the running module (surface only, never kills).
139+ /** Generous, minute-scale: well above a quiet git-clone/apt phase, so a slow-but-alive install is
140+ * never flagged. Above Freshness.STALE_MS (30s), which is tuned for ~1s-cadence REST polls. */
141+ private static final long MODULE_STALL_MS = 120_000L ;
142+ private static final long MODULE_STALL_POLL_MS = 15_000L ;
143+ private volatile long lastModuleMovementMs = 0L ; // stamped on a runrole output line OR write-dir growth
144+ private volatile long lastModuleDirSize = -1L ;
145+ private Runnable moduleStallCheck ; // main-thread poller; null when not watching
146+
138147 /**
139148 * ADFA-5119: which kind of work is running, for the one question Cancel has to answer — does
140149 * abandoning it leave the device with no system (see {@code AbandonedInstall}).
@@ -955,9 +964,11 @@ private void installNextModule() {
955964 // ADFA-4435: Ansible can print its failure to stdout yet still exit 0, so the verdict
956965 // considers the output as well as the exit code (pure, unit-tested domain object).
957966 final AnsibleRunOutcome outcome = new AnsibleRunOutcome ();
967+ startModuleStallWatch (nextModule ); // ADFA-4898 P4: watch this runrole for a stall (surface only)
958968 prootEngine .executeInContainer (this , debianRootfs .getAbsolutePath (), installCmd , new PRootEngine .OutputListener () {
959969 @ Override
960970 public void onOutputLine (String line ) {
971+ lastModuleMovementMs = android .os .SystemClock .elapsedRealtime (); // ADFA-4898 P4: heartbeat
961972 outcome .observe (line );
962973 log ("[Ansible] " + line );
963974 // ADFA-5228: advance the determinate bar as known tasks are reached. Post only on a
@@ -1043,19 +1054,114 @@ private static long estimateEtaSeconds(org.iiab.controller.install.domain.Runrol
10431054 }
10441055
10451056 /**
1046- * ADFA-4435: roll back the speculative local_vars edit made before runrole, so a failed
1047- * install is not left looking installed/enabled. Always runs {@code then} afterwards.
1057+ * ADFA-4435: roll back the speculative local_vars edit made before runrole, so a failed or
1058+ * cancelled install is not left looking installed/enabled. Always runs {@code then} afterwards.
1059+ *
1060+ * <p>ADFA-4898 P5: write {@code <mod>_install: False} / {@code <mod>_enabled: False} explicitly
1061+ * rather than only deleting the speculative lines. Both read as "not installed" (the reader keys on
1062+ * the flag being true), but an explicit False also tells a later ansible run the module is off, so a
1063+ * partially-installed module is never offered as installed. The leading sed-delete keeps it
1064+ * idempotent, and the write-before-runrole path deletes these lines before echoing True on a retry.
10481065 */
10491066 private void revertModuleInLocalVars (String module , Runnable then ) {
10501067 if (prootEngine == null ) prootEngine = new PRootEngine ();
1051- String revertCmd = "sed -i -E '/^[[:space:]]*" + module + "_(install|enabled)[[:space:]]*:/d' /etc/iiab/local_vars.yml" ;
1068+ String revertCmd = "sed -i -E '/^[[:space:]]*" + module + "_(install|enabled)[[:space:]]*:/d' /etc/iiab/local_vars.yml && " +
1069+ "echo '" + module + "_install: False' >> /etc/iiab/local_vars.yml && " +
1070+ "echo '" + module + "_enabled: False' >> /etc/iiab/local_vars.yml" ;
10521071 prootEngine .executeInContainer (this , debianRootfs .getAbsolutePath (), revertCmd , new PRootEngine .OutputListener () {
10531072 @ Override public void onOutputLine (String line ) { }
10541073 @ Override public void onProcessExit (int exitCode ) { then .run (); }
10551074 @ Override public void onError (String error ) { then .run (); }
10561075 });
10571076 }
10581077
1078+ // ---- ADFA-4898 P4: movement-based stall watch (surface only, never kills) -------------------
1079+
1080+ /**
1081+ * Watch the running module for movement — a runrole output line (stamped in onOutputLine) OR growth
1082+ * of its on-disk write directory. When neither moves for {@link #MODULE_STALL_MS}, publish a
1083+ * "stalled" hint the live card shows; the install is never touched. Re-armed per module; the
1084+ * dir-growth backstop covers quiet network phases (e.g. calibre-web's git clone) that emit no log.
1085+ */
1086+ private void startModuleStallWatch (final String moduleKey ) {
1087+ stopModuleStallWatch ();
1088+ lastModuleMovementMs = android .os .SystemClock .elapsedRealtime ();
1089+ lastModuleDirSize = -1L ;
1090+ ModuleQueueRepository .get ().postStalled (false );
1091+ // ADFA-4898 P4: make write-dir drift visible instead of silent. If a module has no mapped dir
1092+ // (a new module added without updating moduleWriteDirRel), the watch still works off the log
1093+ // heartbeat — but this line says so in the log, so the missing backstop is noticed, not hidden.
1094+ if (moduleWriteDirRel (moduleKey ) == null ) {
1095+ log ("[Stall] no write-dir backstop mapped for '" + moduleKey
1096+ + "'; stall watch relies on the log heartbeat only" );
1097+ }
1098+ moduleStallCheck = new Runnable () {
1099+ @ Override public void run () {
1100+ if (finished || cancelled ) return ;
1101+ org .iiab .controller .util .AppExecutors .get ().io ().execute (() -> {
1102+ long size = moduleWriteDirSize (moduleKey );
1103+ if (size >= 0 && size != lastModuleDirSize ) {
1104+ lastModuleDirSize = size ;
1105+ lastModuleMovementMs = android .os .SystemClock .elapsedRealtime ();
1106+ }
1107+ boolean fresh = org .iiab .controller .env .Freshness .fresh (
1108+ lastModuleMovementMs , android .os .SystemClock .elapsedRealtime (), MODULE_STALL_MS );
1109+ ModuleQueueRepository .get ().postStalled (!fresh );
1110+ });
1111+ heldHandler .postDelayed (this , MODULE_STALL_POLL_MS );
1112+ }
1113+ };
1114+ heldHandler .postDelayed (moduleStallCheck , MODULE_STALL_POLL_MS );
1115+ }
1116+
1117+ private void stopModuleStallWatch () {
1118+ if (moduleStallCheck != null ) { heldHandler .removeCallbacks (moduleStallCheck ); moduleStallCheck = null ; }
1119+ ModuleQueueRepository .get ().postStalled (false );
1120+ }
1121+
1122+ /** Total bytes under the module's write directory on the host rootfs, or -1 if unknown/absent. */
1123+ private long moduleWriteDirSize (String key ) {
1124+ String rel = moduleWriteDirRel (key );
1125+ if (rel == null || debianRootfs == null ) return -1L ;
1126+ File d = new File (debianRootfs , rel );
1127+ if (!d .isDirectory ()) return -1L ;
1128+ return boundedDirSize (d , 20000 );
1129+ }
1130+
1131+ /**
1132+ * Where each module does its heavy on-disk writes (relative to the rootfs), for the stall watch's
1133+ * growth backstop. Heuristic and best-effort — keep it in sync with the ansible roles. If a key is
1134+ * unmapped or the path drifts, the watch silently falls back to the log heartbeat (never a false
1135+ * verdict); {@link #startModuleStallWatch} logs the unmapped case so that drift is visible, not silent.
1136+ */
1137+ private static String moduleWriteDirRel (String key ) {
1138+ if (key == null ) return null ;
1139+ switch (key ) {
1140+ case "calibreweb" : return "usr/local/calibre-web-py3" ; // git clone + venv
1141+ case "maps" : return "library/downloads/maps" ;
1142+ case "matomo" : return "library/www/matomo" ;
1143+ case "kolibri" : return "var/cache/apt/archives" ; // chatty on stdout too; disk is the fallback
1144+ default : return null ;
1145+ }
1146+ }
1147+
1148+ /** Iterative, file-capped directory size so a large tree can't make the poll expensive. */
1149+ private static long boundedDirSize (File root , int fileCap ) {
1150+ long total = 0L ; int count = 0 ;
1151+ java .util .ArrayDeque <File > stack = new java .util .ArrayDeque <>();
1152+ stack .push (root );
1153+ while (!stack .isEmpty () && count < fileCap ) {
1154+ File [] kids = stack .pop ().listFiles ();
1155+ if (kids == null ) continue ;
1156+ for (File k : kids ) {
1157+ if (count >= fileCap ) break ;
1158+ if (k .isDirectory ()) stack .push (k );
1159+ else { total += k .length (); count ++; }
1160+ }
1161+ }
1162+ return total ;
1163+ }
1164+
10591165 /**
10601166 * ADFA-4900: build the maps runrole command from the wizard's per-layer selection. Translates
10611167 * the selection into the maps role's local_vars (roles/maps/tasks/install_frontend.yml):
@@ -1096,6 +1202,7 @@ public static void retryModules(Context ctx, java.util.List<String> modules) {
10961202 private void finishModuleQueue () {
10971203 if (finished ) return ;
10981204 finished = true ;
1205+ stopModuleStallWatch (); // ADFA-4898 P4
10991206 persistClearQueue ();
11001207 // ADFA-4842: clear the durable install guard BEFORE publishing DONE so the LibraryActivity
11011208 // observer that restarts the server (canStartServer() requires !InstallGuard.inProgress) is not
@@ -1485,10 +1592,27 @@ private void doCancel() {
14851592 } catch (Exception ignored ) {
14861593 }
14871594 if (moduleMode ) {
1488- persistClearQueue ();
1489- ModuleQueueRepository .get ().postDone (
1490- failedModules != null ? new java .util .ArrayList <>(failedModules ) : new java .util .ArrayList <>());
1491- teardown ();
1595+ // ADFA-4898 P5: user-confirmed cancel of a running module install. Kill the in-flight runrole
1596+ // (proot runs with --kill-on-exit, so its container children go with it), roll back that
1597+ // module's speculative <mod>_install so a cancel is not left looking installed, and surface it
1598+ // as failed so the existing per-module Retry is offered immediately. Ordering mirrors
1599+ // finishModuleQueue: clear InstallGuard BEFORE postDone so the server-restart observer (the
1600+ // server was pdsm-stopped for the runroles) is not raced by teardown's later clear.
1601+ final String cur = ModuleQueueRepository .get ().current ().currentModule ;
1602+ stopModuleStallWatch (); // ADFA-4898 P4
1603+ if (prootEngine != null ) prootEngine .killProcess ();
1604+ if (failedModules == null ) failedModules = new java .util .ArrayList <>();
1605+ if (cur != null && !failedModules .contains (cur )) failedModules .add (cur );
1606+ final java .util .List <String > failedSnapshot = new java .util .ArrayList <>(failedModules );
1607+ final Runnable finishCancel = () -> {
1608+ persistClearQueue ();
1609+ org .iiab .controller .InstallGuard .end (this );
1610+ ModuleQueueRepository .get ().postDone (failedSnapshot );
1611+ if (!failedSnapshot .isEmpty ()) postModuleFailureNotification (failedSnapshot );
1612+ teardown ();
1613+ };
1614+ if (cur != null ) revertModuleInLocalVars (cur , finishCancel ); // best-effort rollback, then finish
1615+ else finishCancel .run ();
14921616 return ;
14931617 }
14941618 if (!org .iiab .controller .install .domain .AbandonedInstall .leavesNoSystem (work )) {
0 commit comments