Skip to content

Commit d3211ee

Browse files
Merge pull request #494 from appdevforall/feat/ADFA-4898-stall-cancel
ADFA-4898 (follow-up): in-flight controls for a running proot module install — confirmed cancel + stall hint
2 parents 859e7bc + e09a1a0 commit d3211ee

38 files changed

Lines changed: 356 additions & 12 deletions

File tree

‎controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java‎

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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)) {

‎controller/app/src/main/java/org/iiab/controller/install/presentation/ModuleQueueRepository.java‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ public static ModuleQueueRepository get() {
2929
}
3030

3131
private final MutableLiveData<ModuleQueueState> state = new MutableLiveData<>(ModuleQueueState.idle());
32+
/** ADFA-4898 P4: movement-based stall hint for the current module (surface only, never auto-kill).
33+
* Kept separate from the immutable ModuleQueueState so a transient hint never rewrites the queue. */
34+
private final MutableLiveData<Boolean> stalled = new MutableLiveData<>(false);
3235
private long seq = 0L;
3336

3437
private ModuleQueueRepository() {
@@ -38,6 +41,16 @@ public LiveData<ModuleQueueState> state() {
3841
return state;
3942
}
4043

44+
/** ADFA-4898 P4: true while the current module's runrole has shown no movement (log line or write-dir
45+
* growth) for the stall window. A surface-only hint — the install keeps running. */
46+
public LiveData<Boolean> stalled() {
47+
return stalled;
48+
}
49+
50+
public void postStalled(boolean isStalled) {
51+
stalled.postValue(isStalled);
52+
}
53+
4154
public ModuleQueueState current() {
4255
ModuleQueueState s = state.getValue();
4356
return s != null ? s : ModuleQueueState.idle();
@@ -64,8 +77,8 @@ public boolean isInstalling(String moduleKey) {
6477
public void postRunning(String currentModule, int remaining, int percent) { post(ModuleQueueState.running(currentModule, remaining, percent)); }
6578
/** ADFA-5228: running with a determinate percent and an estimated seconds-remaining. */
6679
public void postRunning(String currentModule, int remaining, int percent, long etaSeconds) { post(ModuleQueueState.running(currentModule, remaining, percent, etaSeconds)); }
67-
public void postDone(List<String> failedModules) { post(ModuleQueueState.done(failedModules)); }
68-
public void postIdle() { post(ModuleQueueState.idle()); }
80+
public void postDone(List<String> failedModules) { stalled.postValue(false); post(ModuleQueueState.done(failedModules)); }
81+
public void postIdle() { stalled.postValue(false); post(ModuleQueueState.idle()); }
6982

7083
private synchronized void post(ModuleQueueState s) {
7184
state.postValue(s.withSeq(++seq));

‎controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
8484

8585
updateStatus();
8686
ModuleQueueRepository.get().state().observe(getViewLifecycleOwner(), st -> updateStatus());
87+
// ADFA-4898 P4: surface a "seems stalled" hint over the frozen status line while this module is
88+
// the one running and no movement (log line / write-dir growth) has arrived for the stall window.
89+
// Surface only — the install keeps going; a new log line or updateStatus restores the live line.
90+
ModuleQueueRepository.get().stalled().observe(getViewLifecycleOwner(), isStalled -> {
91+
if (Boolean.TRUE.equals(isStalled) && installing() && !terminalDone) {
92+
status.setText(getString(R.string.k2go_mod_phase_stalled));
93+
} else {
94+
updateStatus();
95+
}
96+
});
8797

8898
logListener = new LogRepository.Listener() {
8999
@Override public void onAppend(String line) {

‎controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,15 +1321,26 @@ private boolean isLiveDetail(String key) {
13211321
*/
13221322
private void configureDetailBar() {
13231323
if (!showingDetail || detailKey == null || detailBackBtn == null) return;
1324-
boolean moduleFailed = detailKey.startsWith("mod:")
1325-
&& ModuleQueueRepository.get().current().didFail(detailKey.substring(4));
1324+
final boolean isModule = detailKey.startsWith("mod:");
1325+
final String moduleKey = isModule ? detailKey.substring(4) : null;
1326+
ModuleQueueState mq = ModuleQueueRepository.get().current();
1327+
boolean moduleFailed = isModule && mq.didFail(moduleKey);
1328+
boolean moduleRunning = isModule && mq.isInstalling(moduleKey);
13261329
if (moduleFailed) {
1327-
final String moduleKey = detailKey.substring(4);
13281330
detailBackBtn.setText(R.string.k2go_home_retry);
13291331
detailBackBtn.setOnClickListener(v -> ModuleRetry.fire(v, moduleKey));
13301332
detailRunBgBtn.setText(R.string.k2go_setup_back);
13311333
detailRunBgBtn.setOnClickListener(v -> backToIndex());
13321334
detailRunBgBtn.setVisibility(View.VISIBLE);
1335+
} else if (moduleRunning) {
1336+
// ADFA-4898 P5: while this module's runrole runs, offer a confirmed Cancel in the same
1337+
// secondary slot (Back stays primary). Cancel kills the runrole and surfaces the module as
1338+
// failed, so the Retry above appears on the next tick — the "immediate retry" of the ticket.
1339+
detailBackBtn.setText(R.string.k2go_setup_back);
1340+
detailBackBtn.setOnClickListener(v -> backToIndex());
1341+
detailRunBgBtn.setText(R.string.k2go_setup_cancel);
1342+
detailRunBgBtn.setOnClickListener(v -> confirmCancelModule());
1343+
detailRunBgBtn.setVisibility(View.VISIBLE);
13331344
} else {
13341345
detailBackBtn.setText(R.string.k2go_setup_back);
13351346
detailBackBtn.setOnClickListener(v -> backToIndex());
@@ -1339,6 +1350,22 @@ private void configureDetailBar() {
13391350
}
13401351
}
13411352

1353+
/**
1354+
* ADFA-4898 P5: strong confirmation before cancelling a running module install, then send
1355+
* ACTION_CANCEL. The service kills the runrole (proot --kill-on-exit), rolls back the speculative
1356+
* flag and marks the module failed; the base system is untouched and the server restarts.
1357+
*/
1358+
private void confirmCancelModule() {
1359+
new com.google.android.material.dialog.MaterialAlertDialogBuilder(this)
1360+
.setTitle(R.string.k2go_mod_cancel_title)
1361+
.setMessage(R.string.k2go_mod_cancel_body)
1362+
.setNegativeButton(R.string.k2go_mod_cancel_dismiss, null)
1363+
.setPositiveButton(R.string.k2go_mod_cancel_confirm, (d, w) ->
1364+
startService(new android.content.Intent(this, org.iiab.controller.install.presentation.InstallService.class)
1365+
.setAction(org.iiab.controller.install.presentation.InstallService.ACTION_CANCEL)))
1366+
.show();
1367+
}
1368+
13421369
private void backToIndex() {
13431370
showingDetail = false;
13441371
detailKey = null;

‎controller/app/src/main/res/values-ar/strings.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,4 +1294,9 @@
12941294
<string name="k2go_books_load_more">تحميل المزيد</string>
12951295
<string name="k2go_books_showing_fmt">عرض %1$d كتاب</string>
12961296
<string name="k2go_books_all_fmt">هذا كل شيء · %1$d كتاب</string>
1297+
<string name="k2go_mod_phase_stalled">لا يزال يعمل — يستغرق وقتًا أطول من المعتاد. يمكنك الانتظار أو الإلغاء وإعادة المحاولة.</string>
1298+
<string name="k2go_mod_cancel_title">إيقاف التثبيت؟</string>
1299+
<string name="k2go_mod_cancel_body">يُتراجع عمّا تم حتى الآن ولن يُثبَّت المكوّن. يمكنك بدؤه من جديد لاحقًا.</string>
1300+
<string name="k2go_mod_cancel_confirm">إيقاف التثبيت</string>
1301+
<string name="k2go_mod_cancel_dismiss">متابعة التثبيت</string>
12971302
</resources>

‎controller/app/src/main/res/values-az/strings.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,4 +1314,9 @@
13141314
<string name="k2go_books_load_more">Daha çox yüklə</string>
13151315
<string name="k2go_books_showing_fmt">%1$d kitab göstərilir</string>
13161316
<string name="k2go_books_all_fmt">Hamısı budur · %1$d kitab</string>
1317+
<string name="k2go_mod_phase_stalled">Hələ də işləyir — bu, adətən olduğundan daha uzun çəkir. Gözləyə, yaxud ləğv edib yenidən cəhd edə bilərsiniz.</string>
1318+
<string name="k2go_mod_cancel_title">Quraşdırma dayandırılsın?</string>
1319+
<string name="k2go_mod_cancel_body">İndiyədək edilənlər geri qaytarılır və modul quraşdırılmayacaq. Onu sonra yenidən başlada bilərsiniz.</string>
1320+
<string name="k2go_mod_cancel_confirm">Quraşdırmanı dayandır</string>
1321+
<string name="k2go_mod_cancel_dismiss">Quraşdırmaya davam et</string>
13171322
</resources>

0 commit comments

Comments
 (0)