Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions controller/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
DEBUG-ONLY overlay: registers a receiver so the delivery backbone can be exercised
from adb (see DebugDeliveryReceiver). Merged into debug builds only; never in release.
DEBUG-ONLY overlay: registers receivers so parts of the app can be exercised from adb
(delivery backbone, disk-guard device-verify). Merged into debug builds only; never in release.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
Expand All @@ -12,5 +12,13 @@
<action android:name="org.appdevforall.k2go.DEBUG_DELIVERY" />
</intent-filter>
</receiver>
<!-- K2GO-386: adb-reachable trigger for the disk-guard device-verify (DebugDiskGuardReceiver). -->
<receiver
android:name="org.appdevforall.k2go.diskguard.debug.DebugDiskGuardReceiver"
android:exported="true">
<intent-filter>
<action android:name="org.appdevforall.k2go.DEBUG_DISK_GUARD" />
</intent-filter>
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.appdevforall.k2go.diskguard.debug;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;

import org.appdevforall.k2go.diskguard.DiskGuard;

/**
* DEBUG-ONLY. K2GO-386 device-verify hook. Forces one disk-guard check with an injected floor so the
* full protective path (set desired=DOWN → reap the box → reclaim the runaway log → notify) can be
* verified on device WITHOUT first filling ~58 GB. Lives in src/debug, so it never ships in release.
*
* <p>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:
*
* <pre>
* 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
* </pre>
*
* 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading