Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ public final class InstallService extends Service {
private volatile boolean finished = false;
private volatile boolean started = false;

// ADFA-4898 P4: movement-based stall detection for the running module (surface only, never kills).
/** Generous, minute-scale: well above a quiet git-clone/apt phase, so a slow-but-alive install is
* never flagged. Above Freshness.STALE_MS (30s), which is tuned for ~1s-cadence REST polls. */
private static final long MODULE_STALL_MS = 120_000L;
private static final long MODULE_STALL_POLL_MS = 15_000L;
private volatile long lastModuleMovementMs = 0L; // stamped on a runrole output line OR write-dir growth
private volatile long lastModuleDirSize = -1L;
private Runnable moduleStallCheck; // main-thread poller; null when not watching

/**
* ADFA-5119: which kind of work is running, for the one question Cancel has to answer — does
* abandoning it leave the device with no system (see {@code AbandonedInstall}).
Expand Down Expand Up @@ -955,9 +964,11 @@ private void installNextModule() {
// ADFA-4435: Ansible can print its failure to stdout yet still exit 0, so the verdict
// considers the output as well as the exit code (pure, unit-tested domain object).
final AnsibleRunOutcome outcome = new AnsibleRunOutcome();
startModuleStallWatch(nextModule); // ADFA-4898 P4: watch this runrole for a stall (surface only)
prootEngine.executeInContainer(this, debianRootfs.getAbsolutePath(), installCmd, new PRootEngine.OutputListener() {
@Override
public void onOutputLine(String line) {
lastModuleMovementMs = android.os.SystemClock.elapsedRealtime(); // ADFA-4898 P4: heartbeat
outcome.observe(line);
log("[Ansible] " + line);
// ADFA-5228: advance the determinate bar as known tasks are reached. Post only on a
Expand Down Expand Up @@ -1043,19 +1054,114 @@ private static long estimateEtaSeconds(org.iiab.controller.install.domain.Runrol
}

/**
* ADFA-4435: roll back the speculative local_vars edit made before runrole, so a failed
* install is not left looking installed/enabled. Always runs {@code then} afterwards.
* ADFA-4435: roll back the speculative local_vars edit made before runrole, so a failed or
* cancelled install is not left looking installed/enabled. Always runs {@code then} afterwards.
*
* <p>ADFA-4898 P5: write {@code <mod>_install: False} / {@code <mod>_enabled: False} explicitly
* rather than only deleting the speculative lines. Both read as "not installed" (the reader keys on
* the flag being true), but an explicit False also tells a later ansible run the module is off, so a
* partially-installed module is never offered as installed. The leading sed-delete keeps it
* idempotent, and the write-before-runrole path deletes these lines before echoing True on a retry.
*/
private void revertModuleInLocalVars(String module, Runnable then) {
if (prootEngine == null) prootEngine = new PRootEngine();
String revertCmd = "sed -i -E '/^[[:space:]]*" + module + "_(install|enabled)[[:space:]]*:/d' /etc/iiab/local_vars.yml";
String revertCmd = "sed -i -E '/^[[:space:]]*" + module + "_(install|enabled)[[:space:]]*:/d' /etc/iiab/local_vars.yml && " +
"echo '" + module + "_install: False' >> /etc/iiab/local_vars.yml && " +
"echo '" + module + "_enabled: False' >> /etc/iiab/local_vars.yml";
prootEngine.executeInContainer(this, debianRootfs.getAbsolutePath(), revertCmd, new PRootEngine.OutputListener() {
@Override public void onOutputLine(String line) { }
@Override public void onProcessExit(int exitCode) { then.run(); }
@Override public void onError(String error) { then.run(); }
});
}

// ---- ADFA-4898 P4: movement-based stall watch (surface only, never kills) -------------------

/**
* Watch the running module for movement — a runrole output line (stamped in onOutputLine) OR growth
* of its on-disk write directory. When neither moves for {@link #MODULE_STALL_MS}, publish a
* "stalled" hint the live card shows; the install is never touched. Re-armed per module; the
* dir-growth backstop covers quiet network phases (e.g. calibre-web's git clone) that emit no log.
*/
private void startModuleStallWatch(final String moduleKey) {
stopModuleStallWatch();
lastModuleMovementMs = android.os.SystemClock.elapsedRealtime();
lastModuleDirSize = -1L;
ModuleQueueRepository.get().postStalled(false);
// ADFA-4898 P4: make write-dir drift visible instead of silent. If a module has no mapped dir
// (a new module added without updating moduleWriteDirRel), the watch still works off the log
// heartbeat — but this line says so in the log, so the missing backstop is noticed, not hidden.
if (moduleWriteDirRel(moduleKey) == null) {
log("[Stall] no write-dir backstop mapped for '" + moduleKey
+ "'; stall watch relies on the log heartbeat only");
}
moduleStallCheck = new Runnable() {
@Override public void run() {
if (finished || cancelled) return;
org.iiab.controller.util.AppExecutors.get().io().execute(() -> {
long size = moduleWriteDirSize(moduleKey);
if (size >= 0 && size != lastModuleDirSize) {
lastModuleDirSize = size;
lastModuleMovementMs = android.os.SystemClock.elapsedRealtime();
}
boolean fresh = org.iiab.controller.env.Freshness.fresh(
lastModuleMovementMs, android.os.SystemClock.elapsedRealtime(), MODULE_STALL_MS);
ModuleQueueRepository.get().postStalled(!fresh);
});
heldHandler.postDelayed(this, MODULE_STALL_POLL_MS);
}
};
heldHandler.postDelayed(moduleStallCheck, MODULE_STALL_POLL_MS);
}

private void stopModuleStallWatch() {
if (moduleStallCheck != null) { heldHandler.removeCallbacks(moduleStallCheck); moduleStallCheck = null; }
ModuleQueueRepository.get().postStalled(false);
}

/** Total bytes under the module's write directory on the host rootfs, or -1 if unknown/absent. */
private long moduleWriteDirSize(String key) {
String rel = moduleWriteDirRel(key);
if (rel == null || debianRootfs == null) return -1L;
File d = new File(debianRootfs, rel);
if (!d.isDirectory()) return -1L;
return boundedDirSize(d, 20000);
}

/**
* Where each module does its heavy on-disk writes (relative to the rootfs), for the stall watch's
* growth backstop. Heuristic and best-effort — keep it in sync with the ansible roles. If a key is
* unmapped or the path drifts, the watch silently falls back to the log heartbeat (never a false
* verdict); {@link #startModuleStallWatch} logs the unmapped case so that drift is visible, not silent.
*/
private static String moduleWriteDirRel(String key) {
if (key == null) return null;
switch (key) {
case "calibreweb": return "usr/local/calibre-web-py3"; // git clone + venv
case "maps": return "library/downloads/maps";
case "matomo": return "library/www/matomo";
case "kolibri": return "var/cache/apt/archives"; // chatty on stdout too; disk is the fallback
default: return null;
}
}

/** Iterative, file-capped directory size so a large tree can't make the poll expensive. */
private static long boundedDirSize(File root, int fileCap) {
long total = 0L; int count = 0;
java.util.ArrayDeque<File> stack = new java.util.ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty() && count < fileCap) {
File[] kids = stack.pop().listFiles();
if (kids == null) continue;
for (File k : kids) {
if (count >= fileCap) break;
if (k.isDirectory()) stack.push(k);
else { total += k.length(); count++; }
}
}
return total;
}

/**
* ADFA-4900: build the maps runrole command from the wizard's per-layer selection. Translates
* the selection into the maps role's local_vars (roles/maps/tasks/install_frontend.yml):
Expand Down Expand Up @@ -1096,6 +1202,7 @@ public static void retryModules(Context ctx, java.util.List<String> modules) {
private void finishModuleQueue() {
if (finished) return;
finished = true;
stopModuleStallWatch(); // ADFA-4898 P4
persistClearQueue();
// ADFA-4842: clear the durable install guard BEFORE publishing DONE so the LibraryActivity
// observer that restarts the server (canStartServer() requires !InstallGuard.inProgress) is not
Expand Down Expand Up @@ -1485,10 +1592,27 @@ private void doCancel() {
} catch (Exception ignored) {
}
if (moduleMode) {
persistClearQueue();
ModuleQueueRepository.get().postDone(
failedModules != null ? new java.util.ArrayList<>(failedModules) : new java.util.ArrayList<>());
teardown();
// ADFA-4898 P5: user-confirmed cancel of a running module install. Kill the in-flight runrole
// (proot runs with --kill-on-exit, so its container children go with it), roll back that
// module's speculative <mod>_install so a cancel is not left looking installed, and surface it
// as failed so the existing per-module Retry is offered immediately. Ordering mirrors
// finishModuleQueue: clear InstallGuard BEFORE postDone so the server-restart observer (the
// server was pdsm-stopped for the runroles) is not raced by teardown's later clear.
final String cur = ModuleQueueRepository.get().current().currentModule;
stopModuleStallWatch(); // ADFA-4898 P4
if (prootEngine != null) prootEngine.killProcess();
if (failedModules == null) failedModules = new java.util.ArrayList<>();
if (cur != null && !failedModules.contains(cur)) failedModules.add(cur);
final java.util.List<String> failedSnapshot = new java.util.ArrayList<>(failedModules);
final Runnable finishCancel = () -> {
persistClearQueue();
org.iiab.controller.InstallGuard.end(this);
ModuleQueueRepository.get().postDone(failedSnapshot);
if (!failedSnapshot.isEmpty()) postModuleFailureNotification(failedSnapshot);
teardown();
};
if (cur != null) revertModuleInLocalVars(cur, finishCancel); // best-effort rollback, then finish
else finishCancel.run();
return;
}
if (!org.iiab.controller.install.domain.AbandonedInstall.leavesNoSystem(work)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public static ModuleQueueRepository get() {
}

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

private ModuleQueueRepository() {
Expand All @@ -38,6 +41,16 @@ public LiveData<ModuleQueueState> state() {
return state;
}

/** ADFA-4898 P4: true while the current module's runrole has shown no movement (log line or write-dir
* growth) for the stall window. A surface-only hint — the install keeps running. */
public LiveData<Boolean> stalled() {
return stalled;
}

public void postStalled(boolean isStalled) {
stalled.postValue(isStalled);
}

public ModuleQueueState current() {
ModuleQueueState s = state.getValue();
return s != null ? s : ModuleQueueState.idle();
Expand All @@ -64,8 +77,8 @@ public boolean isInstalling(String moduleKey) {
public void postRunning(String currentModule, int remaining, int percent) { post(ModuleQueueState.running(currentModule, remaining, percent)); }
/** ADFA-5228: running with a determinate percent and an estimated seconds-remaining. */
public void postRunning(String currentModule, int remaining, int percent, long etaSeconds) { post(ModuleQueueState.running(currentModule, remaining, percent, etaSeconds)); }
public void postDone(List<String> failedModules) { post(ModuleQueueState.done(failedModules)); }
public void postIdle() { post(ModuleQueueState.idle()); }
public void postDone(List<String> failedModules) { stalled.postValue(false); post(ModuleQueueState.done(failedModules)); }
public void postIdle() { stalled.postValue(false); post(ModuleQueueState.idle()); }

private synchronized void post(ModuleQueueState s) {
state.postValue(s.withSeq(++seq));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c

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

logListener = new LogRepository.Listener() {
@Override public void onAppend(String line) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1321,15 +1321,26 @@ private boolean isLiveDetail(String key) {
*/
private void configureDetailBar() {
if (!showingDetail || detailKey == null || detailBackBtn == null) return;
boolean moduleFailed = detailKey.startsWith("mod:")
&& ModuleQueueRepository.get().current().didFail(detailKey.substring(4));
final boolean isModule = detailKey.startsWith("mod:");
final String moduleKey = isModule ? detailKey.substring(4) : null;
ModuleQueueState mq = ModuleQueueRepository.get().current();
boolean moduleFailed = isModule && mq.didFail(moduleKey);
boolean moduleRunning = isModule && mq.isInstalling(moduleKey);
if (moduleFailed) {
final String moduleKey = detailKey.substring(4);
detailBackBtn.setText(R.string.k2go_home_retry);
detailBackBtn.setOnClickListener(v -> ModuleRetry.fire(v, moduleKey));
detailRunBgBtn.setText(R.string.k2go_setup_back);
detailRunBgBtn.setOnClickListener(v -> backToIndex());
detailRunBgBtn.setVisibility(View.VISIBLE);
} else if (moduleRunning) {
// ADFA-4898 P5: while this module's runrole runs, offer a confirmed Cancel in the same
// secondary slot (Back stays primary). Cancel kills the runrole and surfaces the module as
// failed, so the Retry above appears on the next tick — the "immediate retry" of the ticket.
detailBackBtn.setText(R.string.k2go_setup_back);
detailBackBtn.setOnClickListener(v -> backToIndex());
detailRunBgBtn.setText(R.string.k2go_setup_cancel);
detailRunBgBtn.setOnClickListener(v -> confirmCancelModule());
detailRunBgBtn.setVisibility(View.VISIBLE);
} else {
detailBackBtn.setText(R.string.k2go_setup_back);
detailBackBtn.setOnClickListener(v -> backToIndex());
Expand All @@ -1339,6 +1350,22 @@ private void configureDetailBar() {
}
}

/**
* ADFA-4898 P5: strong confirmation before cancelling a running module install, then send
* ACTION_CANCEL. The service kills the runrole (proot --kill-on-exit), rolls back the speculative
* flag and marks the module failed; the base system is untouched and the server restarts.
*/
private void confirmCancelModule() {
new com.google.android.material.dialog.MaterialAlertDialogBuilder(this)
.setTitle(R.string.k2go_mod_cancel_title)
.setMessage(R.string.k2go_mod_cancel_body)
.setNegativeButton(R.string.k2go_mod_cancel_dismiss, null)
.setPositiveButton(R.string.k2go_mod_cancel_confirm, (d, w) ->
startService(new android.content.Intent(this, org.iiab.controller.install.presentation.InstallService.class)
.setAction(org.iiab.controller.install.presentation.InstallService.ACTION_CANCEL)))
.show();
}

private void backToIndex() {
showingDetail = false;
detailKey = null;
Expand Down
5 changes: 5 additions & 0 deletions controller/app/src/main/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1294,4 +1294,9 @@
<string name="k2go_books_load_more">تحميل المزيد</string>
<string name="k2go_books_showing_fmt">عرض %1$d كتاب</string>
<string name="k2go_books_all_fmt">هذا كل شيء · %1$d كتاب</string>
<string name="k2go_mod_phase_stalled">لا يزال يعمل — يستغرق وقتًا أطول من المعتاد. يمكنك الانتظار أو الإلغاء وإعادة المحاولة.</string>
<string name="k2go_mod_cancel_title">إيقاف التثبيت؟</string>
<string name="k2go_mod_cancel_body">يُتراجع عمّا تم حتى الآن ولن يُثبَّت المكوّن. يمكنك بدؤه من جديد لاحقًا.</string>
<string name="k2go_mod_cancel_confirm">إيقاف التثبيت</string>
<string name="k2go_mod_cancel_dismiss">متابعة التثبيت</string>
</resources>
5 changes: 5 additions & 0 deletions controller/app/src/main/res/values-az/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1314,4 +1314,9 @@
<string name="k2go_books_load_more">Daha çox yüklə</string>
<string name="k2go_books_showing_fmt">%1$d kitab göstərilir</string>
<string name="k2go_books_all_fmt">Hamısı budur · %1$d kitab</string>
<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>
<string name="k2go_mod_cancel_title">Quraşdırma dayandırılsın?</string>
<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>
<string name="k2go_mod_cancel_confirm">Quraşdırmanı dayandır</string>
<string name="k2go_mod_cancel_dismiss">Quraşdırmaya davam et</string>
</resources>
Loading
Loading