diff --git a/controller/app/src/debug/AndroidManifest.xml b/controller/app/src/debug/AndroidManifest.xml
index d6db5520..05c06147 100644
--- a/controller/app/src/debug/AndroidManifest.xml
+++ b/controller/app/src/debug/AndroidManifest.xml
@@ -1,7 +1,7 @@
Exported (it is the whole point — an adb-reachable surface, unlike the app's non-exported + * services), mirroring {@link org.appdevforall.k2go.delivery.debug.DebugDeliveryReceiver}. A huge + * floor makes any real free-space reading CRITICAL, tripping the guard for real. Example: + * + *
+ * adb shell am broadcast \ + * -a org.appdevforall.k2go.DEBUG_DISK_GUARD \ + * -n org.appdevforall.k2go/org.appdevforall.k2go.diskguard.debug.DebugDiskGuardReceiver \ + * --el floor_bytes 999999999999 + *+ * + * Watch it act in logcat: {@code adb logcat -s K2Go-DiskGuard}. Because it sets desired=DOWN, the box + * stays down after the reap; re-enable the server from the app to bring it back. + */ +public final class DebugDiskGuardReceiver extends BroadcastReceiver { + + private static final String TAG = "K2Go-DiskGuard"; + + @Override + public void onReceive(Context context, Intent intent) { + final Context app = context.getApplicationContext(); + final long floor = intent.getLongExtra("floor_bytes", Long.MAX_VALUE); + Log.w(TAG, "K2GO-386: debug disk-guard test hook fired (floor_bytes=" + floor + ")"); + new Thread(() -> { + try { + DiskGuard.checkWithFloor(app, floor); + } catch (Throwable t) { + Log.w(TAG, "K2GO-386: debug disk-guard test hook failed", t); + } + }, "debug-disk-guard").start(); + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/WatchdogService.java b/controller/app/src/main/java/org/appdevforall/k2go/WatchdogService.java index 13498edc..4f283a4a 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/WatchdogService.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/WatchdogService.java @@ -26,6 +26,10 @@ import androidx.core.app.NotificationCompat; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + public class WatchdogService extends Service { private static final String TAG = "IIAB-Watchdog"; private static final String CHANNEL_ID = "watchdog_channel"; @@ -46,6 +50,11 @@ public class WatchdogService extends Service { private PowerManager.WakeLock wakeLock; private WifiManager.WifiLock wifiLock; + // K2GO-386 (barrier 2): a single background poller checks free space while the box is up and tears + // it down before ENOSPC. Started once per protected session, stopped on destroy. + private ScheduledExecutorService diskGuardPoller; + private static final long DISK_GUARD_INTERVAL_S = 25; + @Override public void onCreate() { super.onCreate(); @@ -80,6 +89,9 @@ private void startWatchdog() { // 2. Acquire CPU WakeLock to prevent sleep during heavy operations (e.g., Tar extraction, Rsync) acquireHardwareLocks(); + // K2GO-386 (barrier 2): guard free space for the life of this protected session. + startDiskGuard(); + // 3. Notify the UI (MainActivity) that the engine is protected and running IIABWatchdog.logSessionStart(this); Intent startIntent = new Intent(ACTION_STATE_STARTED); @@ -116,6 +128,28 @@ private void releaseHardwareLocks() { } } + // K2GO-386 (barrier 2): the free-space guard. One background poller ticks every + // DISK_GUARD_INTERVAL_S; on a CRITICAL reading DiskGuard tears the box down and reclaims the runaway + // log — the in-box healer cannot, it dies with the box. Started once per session (idempotent). + private void startDiskGuard() { + if (diskGuardPoller != null) return; + diskGuardPoller = Executors.newSingleThreadScheduledExecutor(); + diskGuardPoller.scheduleWithFixedDelay(() -> { + try { + org.appdevforall.k2go.diskguard.DiskGuard.check(getApplicationContext()); + } catch (Throwable t) { + Log.w(TAG, "K2GO-386: disk-guard tick failed", t); + } + }, DISK_GUARD_INTERVAL_S, DISK_GUARD_INTERVAL_S, TimeUnit.SECONDS); + } + + private void stopDiskGuard() { + if (diskGuardPoller != null) { + diskGuardPoller.shutdownNow(); + diskGuardPoller = null; + } + } + @Override public void onDestroy() { RUNNING = false; // ADFA-5343 (Phase 4b): protection is ending — clear the promoter's state signal @@ -124,6 +158,9 @@ public void onDestroy() { stopIntent.setPackage(getPackageName()); sendBroadcast(stopIntent); + // K2GO-386 (barrier 2): stop the free-space guard — this protected session (box up) is ending. + stopDiskGuard(); + // 2. Release Hardware Locks so the phone can sleep again releaseHardwareLocks(); diff --git a/controller/app/src/main/java/org/appdevforall/k2go/diskguard/DiskGuard.java b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/DiskGuard.java new file mode 100644 index 00000000..a30d2893 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/DiskGuard.java @@ -0,0 +1,160 @@ +/* + * ============================================================================ + * Name : DiskGuard.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-386 (barrier 2). The Android-side free-space guard: one + * tick reads free space (StorageProbe), asks the pure rule + * (DiskGuardPolicy), and on CRITICAL tears the box down, reclaims + * the runaway log, and warns the user. + * + * Why Android-side: the disk-fill happens when the box proot dies + * (a relaunch/restore orphans a service that busy-loops); the + * in-box healer dies with it. Device-proven (2026-09-04): only an + * outside-the-rootfs actor stops it — an in-box restart is + * unreachable, an in-proot kill does not reach the orphan, and a + * full teardown (force-stop) is what worked. So the action is a + * full box reap (EnvironmentProcess), NOT a targeted restart. + * + * Why desired=DOWN too (device-proven 2026-09-04, HD1901): a reap + * alone is undone — the ADFA-5343 reconciler relaunches the box in + * ~3s. So the guard first sets the ONE persisted intent (desired + * DOWN, via ServerLifecycleReconciler) so it stays down, THEN reaps + * for immediacy. The user re-enables the server after freeing space. + * ============================================================================ + */ +package org.appdevforall.k2go.diskguard; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.appdevforall.k2go.R; +import org.appdevforall.k2go.diskguard.domain.DiskGuardPolicy; +import org.appdevforall.k2go.env.EnvironmentProcess; +import org.appdevforall.k2go.env.ServerLifecycleReconciler; +import org.appdevforall.k2go.storage.StorageProbe; + +import java.io.File; +import java.io.FileOutputStream; + +public final class DiskGuard { + + private static final String TAG = "K2Go-DiskGuard"; + + /** Only truncate a log that is clearly a runaway, not a normal log. A runaway at a critical-low-space + * moment is many GB, so 1 GiB stays well clear of any legitimate log. */ + private static final long RUNAWAY_LOG_MIN_BYTES = 1024L * 1024 * 1024; + + private static final String CHANNEL_ID = "disk_guard_channel"; + private static final int NOTIF_ID = 7386; + + private DiskGuard() {} + + /** + * One guard tick with the default critical floor. Returns true when it acted. Safe to call + * repeatedly from a poller; a null/UNKNOWN read is a no-op. + */ + public static boolean check(Context ctx) { + return checkWithFloor(ctx, DiskGuardPolicy.CRITICAL_FLOOR_BYTES); + } + + /** + * As {@link #check} but with an explicit floor — used by the debug device-verify hook to force the + * action (a huge floor makes any real free space read CRITICAL) without filling the disk for real. + */ + public static boolean checkWithFloor(Context ctx, long floorBytes) { + if (ctx == null) return false; + Long free = StorageProbe.freeBytes(ctx); + if (DiskGuardPolicy.evaluate(free, floorBytes) != DiskGuardPolicy.Level.CRITICAL) return false; + + Log.w(TAG, "K2GO-386: free space CRITICAL (" + free + " B, floor " + floorBytes + + ") — tearing the box down to protect the device"); + + // Set desired=DOWN FIRST, through the ONE lifecycle owner (ADFA-5343). This is the persisted + // user-intent lever the server toggle already owns — reusing it (not a new "guard forced down" + // flag) keeps a single source for "should the box be up", and its lifecycle is the existing + // toggle: the user turns the server back on after freeing space. Without this the reconciler + // relaunches the box within ~3s and the runaway resumes — device-proven 2026-09-04 (HD1901): + // an in-app kill alone is undone by the reconciler; only setting desired=DOWN keeps it down. + ServerLifecycleReconciler.get().setUserWantsOn(ctx, false); + + // Then reap NOW for immediacy: the reconciler's own graceful pdsm stop takes ~40s, too slow while + // the disk is critically filling. desired=DOWN (above) is what keeps it from coming back. + boolean reaped = EnvironmentProcess.reapBox(ctx); + long reclaimed = reclaimRunawayLog(ctx); + notifyUser(ctx); + Log.w(TAG, "K2GO-386: desired=DOWN set, box reaped=" + reaped + + ", runaway log reclaimed=" + reclaimed + " B"); + return true; + } + + /** + * Truncate the biggest {@code *.log} file anywhere under the box's {@code /var/log} to reclaim the + * space the runaway consumed (a real file that persists after its writer dies). Recurses subdirectories + * (e.g. {@code /var/log/nginx/}) and only considers {@code .log} files over {@link #RUNAWAY_LOG_MIN_BYTES}, + * so a normal or non-log file is never touched. Best-effort. Returns the bytes reclaimed, or 0. + */ + private static long reclaimRunawayLog(Context ctx) { + File varLog = new File(ctx.getFilesDir(), "rootfs/installed-rootfs/iiab/var/log"); + File biggest = biggestLogUnder(varLog, null); + if (biggest == null || biggest.length() < RUNAWAY_LOG_MIN_BYTES) return 0L; + long size = biggest.length(); + try (FileOutputStream truncate = new FileOutputStream(biggest)) { + // opening for write with no append truncates to zero + Log.w(TAG, "K2GO-386: truncated runaway log " + biggest.getName() + " (" + size + " B)"); + return size; + } catch (Exception e) { + Log.w(TAG, "K2GO-386: could not truncate " + biggest.getName(), e); + return 0L; + } + } + + /** The biggest {@code *.log} regular file in the tree rooted at {@code dir}, or {@code best} if none is + * bigger. Name-filtered to {@code .log} so a non-log large file is never a candidate. Bounded to the + * small {@code /var/log} tree; best-effort (unreadable dirs are skipped). */ + private static File biggestLogUnder(File dir, File best) { + File[] entries = dir.listFiles(); + if (entries == null) return best; + for (File f : entries) { + if (f.isDirectory()) { + best = biggestLogUnder(f, best); + } else if (f.isFile() && f.getName().endsWith(".log") + && (best == null || f.length() > best.length())) { + best = f; + } + } + return best; + } + + /** Warn the user that the box was stopped to protect the device. Best-effort — a no-op if the + * POST_NOTIFICATIONS permission is not granted (API 33+); the teardown still happened. */ + private static void notifyUser(Context ctx) { + try { + NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + nm.createNotificationChannel(new NotificationChannel( + CHANNEL_ID, ctx.getString(R.string.disk_guard_notif_title), + NotificationManager.IMPORTANCE_HIGH)); + } + Notification n = new NotificationCompat.Builder(ctx, CHANNEL_ID) + .setContentTitle(ctx.getString(R.string.disk_guard_notif_title)) + .setContentText(ctx.getString(R.string.disk_guard_notif_body)) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText(ctx.getString(R.string.disk_guard_notif_body))) + .setSmallIcon(android.R.drawable.stat_sys_warning) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build(); + NotificationManagerCompat.from(ctx).notify(NOTIF_ID, n); + } catch (Exception e) { + Log.w(TAG, "K2GO-386: could not post the disk-guard notification", e); + } + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/diskguard/domain/DiskGuardPolicy.java b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/domain/DiskGuardPolicy.java new file mode 100644 index 00000000..070f7889 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/domain/DiskGuardPolicy.java @@ -0,0 +1,49 @@ +/* + * ============================================================================ + * Name : DiskGuardPolicy.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-386. The pure runtime rule for "is free space critically + * low while the server is up?" — the general disk-fill safety net. + * + * Barrier 2 of K2GO-386: a runaway box process (proven: php-fpm + * orphaned off proot, busy-looping into /var/log at ~600 MB/min) + * fills the device to ENOSPC, and the in-box healer cannot help — + * it dies with the box. Only an Android-side guard, independent of + * the box, catches it. And it catches ANY runaway, not just php: + * every disk-fill travels through one common surface, free space. + * + * The critical floor is set BELOW StorageGuard's 2 GiB op-floor: + * every app-driven op reserves >= 2 GiB headroom (StorageGuard), + * so free space only crosses below ~2 GiB when something fills the + * disk WITHOUT that headroom check — i.e. a runaway. 1.5 GiB still + * leaves runway to act before ENOSPC (~2.5 min at ~600 MB/min). + * + * Pure JVM (no android.*) so it is unit-testable; the StatFs read + * is the thin caller (StorageProbe), kept out on purpose. + * ============================================================================ + */ +package org.appdevforall.k2go.diskguard.domain; + +public final class DiskGuardPolicy { + + /** Act when free space drops below this while the server is up. Below StorageGuard's 2 GiB + * op-floor on purpose: legit ops keep >= 2 GiB free, so only a runaway crosses this line. */ + public static final long CRITICAL_FLOOR_BYTES = 1536L * 1024 * 1024; // 1.5 GiB + + public enum Level { OK, CRITICAL, UNKNOWN } + + private DiskGuardPolicy() {} + + /** Evaluate free space against the default critical floor. */ + public static Level evaluate(Long freeBytes) { + return evaluate(freeBytes, CRITICAL_FLOOR_BYTES); + } + + /** As above with an explicit floor. A null/negative read is UNKNOWN — the guard must NOT tear + * down the box on a failed read (fail-safe: the read is the uncertain half). */ + public static Level evaluate(Long freeBytes, long floorBytes) { + if (freeBytes == null || freeBytes < 0L) return Level.UNKNOWN; + return freeBytes < floorBytes ? Level.CRITICAL : Level.OK; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/env/EnvironmentProcess.java b/controller/app/src/main/java/org/appdevforall/k2go/env/EnvironmentProcess.java index e00336e7..cea87c8a 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/env/EnvironmentProcess.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/env/EnvironmentProcess.java @@ -184,6 +184,77 @@ public static boolean reapEnvironmentHttpFront() { return reaped; } + /** + * K2GO-386 (barrier 2): a FULL box teardown — kill the proot and reap the box's daemonised services. + * This is the disk-guard's recovery action when free space is critical. Device-proven (2026-09-04): a + * targeted restart or an in-proot kill does NOT stop a runaway orphaned off proot; only a full teardown + * (what {@code am force-stop} does) does. Each service runs in the app's own uid + SELinux domain, so + * {@code killProcess} reaches it — the same technique as {@link #reapEnvironmentHttpFront}. Best-effort + * and idempotent. + * + * @return true when at least one process was signalled. + */ + public static boolean reapBox(Context ctx) { + boolean any = false; + if (ctx != null) { + int proot = findPid(ctx); + if (proot > 0) { + try { + android.os.Process.killProcess(proot); + Log.i(TAG, "K2GO-386: killed the box proot, pid " + proot); + any = true; + } catch (Exception e) { + Log.w(TAG, "K2GO-386: could not kill the box proot pid " + proot, e); + } + } + } + // The box's services daemonise (setsid, reparent to init) and survive the proot; reap them by name. + return reapByNames(BOX_SERVICE_TOKENS) || any; + } + + /** Cmdline tokens of the box's daemonised services (dash-node = "node"). Scoped to us: these run only + * in the app's uid, so a match is always a box process. */ + private static final String[] BOX_SERVICE_TOKENS = + {"php-fpm", "nginx", "mariadb", "mysqld", "kolibri", "kiwix", "calibre", "node"}; + + /** Kill every non-self process whose cmdline contains any of {@code tokens} (same uid + SELinux domain + * as us, so killable). Best-effort; entries vanishing mid-scan are ignored. */ + private static boolean reapByNames(String[] tokens) { + File[] entries = new File("/proc").listFiles(); + if (entries == null) { + return false; + } + int myPid = android.os.Process.myPid(); + boolean reaped = false; + for (File dir : entries) { + int pid; + try { + pid = Integer.parseInt(dir.getName()); + } catch (NumberFormatException notAPid) { + continue; + } + if (pid == myPid) { + continue; + } + String cmd = readCmdline(new File(dir, "cmdline")); + if (cmd == null) { + continue; + } + for (String token : tokens) { + if (cmd.contains(token)) { + try { + android.os.Process.killProcess(pid); + reaped = true; + } catch (Exception ignored) { + // vanished mid-scan, or not ours to signal + } + break; + } + } + } + return reaped; + } + /** {@code /proc/