From 414e0b3cb950009af5f1a38a1f8a3677f38149c1 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Sat, 5 Sep 2026 14:58:00 -0600 Subject: [PATCH 1/2] K2GO-391 feat(app-backstop): active user report when the disk guard contains a problem The disk guard already reports to GlitchTip automatically. This adds the ACTIVE channel: when the guard handles a meaningful situation, it tells the user and offers a report the user sends via email -- not only waiting on GlitchTip. The guard runs in WatchdogService (no Activity), and the feedback email needs one. So notifyUser posts a notification whose tap opens LibraryActivity carrying a pre-filled diagnostic (EXTRA_DISK_GUARD_REPORT); LibraryActivity.onResume hands it to the existing feedback flow (FeedbackFab.sendFeedback, FeedbackType.BUG), the same pre-filled pattern the install-failed report uses (ADFA-5119). The extra is consumed on first resume so it never re-fires. Fired on a meaningful containment only: the escalation (box stopped) and a recurring-firehose contain. The routine low-disk reap does not notify (no nag); it is still recorded to GlitchTip. Each event kind uses its own notification id so one does not replace the other. Diagnostic carries action, reaped, reclaimed bytes, trip/streak and current free bytes; the feedback flow adds the standard envelope (version, device, ABI). New strings in strings_untranslated.xml. Authority: controller/docs/ADR-386-unattended-disk-containment.md section 12. --- .../k2go/diskguard/DiskGuard.java | 55 +++++++++++++++---- .../k2go/redesign/LibraryActivity.java | 23 ++++++++ .../main/res/values/strings_untranslated.xml | 6 +- 3 files changed, 72 insertions(+), 12 deletions(-) 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 index a276ca84c..64fa0a1ef 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/diskguard/DiskGuard.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/DiskGuard.java @@ -31,7 +31,9 @@ import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; +import android.app.PendingIntent; import android.content.Context; +import android.content.Intent; import android.os.Build; import android.os.SystemClock; import android.util.Log; @@ -88,7 +90,8 @@ public final class DiskGuard { private static final long GROWTH_MIN_BYTES = 16L * 1024 * 1024; // 16 MiB within GROWTH_PROBE_MS private static final String CHANNEL_ID = "disk_guard_channel"; - private static final int NOTIF_ID = 7386; + private static final int NOTIF_ID = 7386; // escalation (stopped and staying down) + private static final int NOTIF_ID_FIREHOSE = 7387; // firehose contain (reaped, kept alive) // Recent-trip state, in memory on purpose: it resets when the app process restarts, so a stale count // never carries across a restart. Read and written only under advanceTripState (class monitor). @@ -167,8 +170,10 @@ private static boolean run(Context ctx, long floorBytes, boolean forced) { // Last resort: the fill keeps returning after restarts. Stop and stay down through the one // persisted lever, and tell the user. The user re-enables the server after freeing space. ServerLifecycleReconciler.get().setUserWantsOn(ctx, false); - notifyUser(ctx); report(ctx, "escalated_stopped", floorBytes, reaped, reclaimed, v.tripCount); + notifyUser(ctx, NOTIF_ID, ctx.getString(R.string.disk_guard_notif_title), + ctx.getString(R.string.disk_guard_notif_body), + buildReportMessage(ctx, "escalated_stopped", reaped, reclaimed, v.tripCount)); Log.w(TAG, "K2GO-386: recurring disk pressure (trip " + v.tripCount + "): stopped and staying down"); } else { // Default: keep the system alive. Leave desired=UP and ask the reconciler to relaunch a fresh @@ -234,6 +239,9 @@ private static boolean actOnFirehose(Context ctx, int streak) { long reclaimed = reclaimRunawayLog(ctx); ServerLifecycleReconciler.get().requestReconcileNow(); report(ctx, "contained_firehose", 0L, reaped, reclaimed, streak); + notifyUser(ctx, NOTIF_ID_FIREHOSE, ctx.getString(R.string.disk_guard_firehose_title), + ctx.getString(R.string.disk_guard_firehose_body), + buildReportMessage(ctx, "contained_firehose", reaped, reclaimed, streak)); Log.w(TAG, "K2GO-386: contained recurring firehose (streak " + streak + "): reaped=" + reaped + ", reclaimed=" + reclaimed + " B, box restarting"); return true; @@ -346,10 +354,13 @@ private static long reclaimRunawayLog(Context ctx) { } /** - * 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. + * Tell the user the guard acted -- it stopped the box (escalation) or it reaped and kept the system + * alive (firehose) -- and offer a report the user sends. The tap opens the app with the pre-filled + * diagnostic (K2GO-391). Best-effort: a no-op if POST_NOTIFICATIONS is not granted (API 33+); the + * containment already happened. Each event kind passes its own notifId so one does not replace the + * other. */ - private static void notifyUser(Context ctx) { + private static void notifyUser(Context ctx, int notifId, String title, String body, String reportMessage) { try { NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); if (nm != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -357,18 +368,42 @@ private static void notifyUser(Context ctx) { CHANNEL_ID, ctx.getString(R.string.disk_guard_notif_title), NotificationManager.IMPORTANCE_HIGH)); } + // K2GO-391 / ADR-386 section 12: the guard runs in a background service, so it cannot launch + // the feedback email itself. The tap opens the app with the pre-filled diagnostic; the app + // (which has an Activity) hands it to the existing feedback flow. + Intent open = new Intent(ctx, org.appdevforall.k2go.redesign.LibraryActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP + | Intent.FLAG_ACTIVITY_CLEAR_TOP) + .putExtra(org.appdevforall.k2go.redesign.LibraryActivity.EXTRA_DISK_GUARD_REPORT, reportMessage); + PendingIntent pi = PendingIntent.getActivity(ctx, notifId, open, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 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))) + .setContentTitle(title) + .setContentText(body) + .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) .setSmallIcon(android.R.drawable.stat_sys_warning) .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pi) .setAutoCancel(true) .build(); - NotificationManagerCompat.from(ctx).notify(NOTIF_ID, n); + NotificationManagerCompat.from(ctx).notify(notifId, n); } catch (Exception e) { Log.w(TAG, "K2GO-386: could not post the disk-guard notification", e); } } + + /** + * The pre-filled body handed to the feedback email (K2GO-391): the disk-guard facts, plain and short. + * The feedback flow adds the standard envelope (app version, build, device, ABI, ...), so this only + * carries what the guard knows. English on purpose (it lands in a dev inbox / triage). + */ + private static String buildReportMessage(Context ctx, String action, boolean reaped, long reclaimed, int count) { + Long free = StorageProbe.freeBytes(ctx); + return "K2Go disk guard acted.\n" + + "action: " + action + "\n" + + "reaped: " + reaped + "\n" + + "reclaimed_bytes: " + reclaimed + "\n" + + "trip_or_streak: " + count + "\n" + + "free_bytes_now: " + (free == null ? "unknown" : String.valueOf(free)) + "\n"; + } } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/LibraryActivity.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/LibraryActivity.java index af6e431d0..5214767d9 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/LibraryActivity.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/LibraryActivity.java @@ -35,6 +35,8 @@ public class LibraryActivity extends AppCompatActivity implements ServerControll private static final long NO_SYSTEM_GATE_MS = 900L; /** Set by the Setup "Download" so the gate waits for the install to finish, not a timeout. */ public static final String EXTRA_INSTALLING = "installing"; + // K2GO-391: the disk guard's notification opens this activity with a pre-filled report to send. + public static final String EXTRA_DISK_GUARD_REPORT = "disk_guard_report"; /** ADFA-4777: preselect a bottom-nav tab on launch (e.g. from the wizard's "Copy from a phone"). */ public static final String EXTRA_TAB = "tab"; /** @@ -877,6 +879,27 @@ protected void onResume() { if (serverController != null) serverController.onResume(); if (updateController != null) updateController.registerDownloadReceiver(); maybeAutoCheckUpdate(); // ADFA-4984: deferred until the boot gate has opened + maybeStartDiskGuardReport(); // K2GO-391 + } + + /** + * K2GO-391 / ADR-386 section 12: the disk guard runs in a background service and cannot launch the + * feedback email itself, so its notification opens this activity carrying a pre-filled report. Hand it + * to the existing feedback flow here (onResume covers both a fresh start and a tap onto the running + * app). Consume the extra so a later resume -- rotation, returning from another screen -- never + * re-fires it. Posted so the screenshot capture runs after the view is laid out. + */ + private void maybeStartDiskGuardReport() { + Intent i = getIntent(); + if (i == null) return; + String msg = i.getStringExtra(EXTRA_DISK_GUARD_REPORT); + if (msg == null || msg.isEmpty()) return; + i.removeExtra(EXTRA_DISK_GUARD_REPORT); + setIntent(i); + getWindow().getDecorView().post(() -> + org.appdevforall.k2go.feedback.presentation.FeedbackFab.sendFeedback( + this, "disk-guard", + org.appdevforall.k2go.feedback.domain.FeedbackType.BUG, msg)); } @Override diff --git a/controller/app/src/main/res/values/strings_untranslated.xml b/controller/app/src/main/res/values/strings_untranslated.xml index c5d784c1f..2668ce87a 100644 --- a/controller/app/src/main/res/values/strings_untranslated.xml +++ b/controller/app/src/main/res/values/strings_untranslated.xml @@ -10,8 +10,10 @@ --> - + Storage critically low - The server was stopped to protect your device from running out of space. + The server was stopped to protect your device from running out of space. Tap to send a report. + K2Go contained unusual activity + K2Go handled a process that was using too much storage and kept your system running. Tap to send a report. From 7062af6d3906b7cdc8df26f63cc9e6b8eb4136f0 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Sat, 5 Sep 2026 15:28:17 -0600 Subject: [PATCH 2/2] K2GO-391 feat(app-backstop): richer, bounded disk-guard report (source + firehose paths) Make the report self-diagnosing without the session context, and keep it from ever carrying raw logs. - source field (low_disk | firehose_signal | debug) in both channels (Sentry + email). It disambiguates trip_or_streak: a debug run reads -1, a real firehose reads its server streak, a low-disk trip reads its count. No more cryptic -1. - firehose_paths: which log(s) were the culprit. FirehoseSignal now parses the paths the endpoint already returns; FirehoseSignalSource caps them (count and length), so a pathological signal cannot bloat the report. - Bounded by design: the report is a pointer plus a short summary, never log content. The firehose log stays on the device; the only attachment is the feedback screenshot (~100 KB). So a runaway log can never break the email/Sentry pipe. Documented in ADR-386 section 7 ("bounded by design"). formatPaths is the one shared path formatter (report + email). FirehoseSignalTest gains a paths case (null -> empty). Domain tests green (Escalation 5, Policy 4, FirehoseSignal 6). --- .../k2go/diskguard/DiskGuard.java | 51 ++++++++++++------- .../diskguard/data/FirehoseSignalSource.java | 21 +++++++- .../k2go/diskguard/domain/FirehoseSignal.java | 11 +++- .../diskguard/domain/FirehoseSignalTest.java | 26 ++++++++-- .../ADR-386-unattended-disk-containment.md | 23 ++++++--- 5 files changed, 100 insertions(+), 32 deletions(-) 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 index 64fa0a1ef..60f96e23e 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/diskguard/DiskGuard.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/DiskGuard.java @@ -59,6 +59,7 @@ import java.io.File; import java.io.FileOutputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.List; public final class DiskGuard { @@ -128,7 +129,7 @@ public static boolean checkFirehoseSignal(Context ctx) { if (ctx == null) return false; FirehoseSignal sig = FirehoseSignalSource.read(); if (sig == null || !sig.isFresh(FIREHOSE_FRESH_WINDOW_MS)) return false; // no live alert - return actOnFirehose(ctx, sig.maxStreak); + return actOnFirehose(ctx, "firehose_signal", sig.maxStreak, sig.paths); } /** @@ -138,7 +139,7 @@ public static boolean checkFirehoseSignal(Context ctx) { */ public static boolean checkFirehoseForced(Context ctx) { if (ctx == null) return false; - return actOnFirehose(ctx, -1); + return actOnFirehose(ctx, "debug", -1, Collections.emptyList()); } private static boolean run(Context ctx, long floorBytes, boolean forced) { @@ -170,16 +171,16 @@ private static boolean run(Context ctx, long floorBytes, boolean forced) { // Last resort: the fill keeps returning after restarts. Stop and stay down through the one // persisted lever, and tell the user. The user re-enables the server after freeing space. ServerLifecycleReconciler.get().setUserWantsOn(ctx, false); - report(ctx, "escalated_stopped", floorBytes, reaped, reclaimed, v.tripCount); + report(ctx, "low_disk", "escalated_stopped", floorBytes, reaped, reclaimed, v.tripCount, null); notifyUser(ctx, NOTIF_ID, ctx.getString(R.string.disk_guard_notif_title), ctx.getString(R.string.disk_guard_notif_body), - buildReportMessage(ctx, "escalated_stopped", reaped, reclaimed, v.tripCount)); + buildReportMessage(ctx, "low_disk", "escalated_stopped", reaped, reclaimed, v.tripCount, null)); Log.w(TAG, "K2GO-386: recurring disk pressure (trip " + v.tripCount + "): stopped and staying down"); } else { // Default: keep the system alive. Leave desired=UP and ask the reconciler to relaunch a fresh // box now. Report only the first trip of a spell so a thrash does not spam telemetry. ServerLifecycleReconciler.get().requestReconcileNow(); - if (v.firstOfSpell) report(ctx, "contained", floorBytes, reaped, reclaimed, v.tripCount); + if (v.firstOfSpell) report(ctx, "low_disk", "contained", floorBytes, reaped, reclaimed, v.tripCount, null); Log.w(TAG, "K2GO-386: contained disk pressure (trip " + v.tripCount + "): reaped=" + reaped + ", reclaimed=" + reclaimed + " B, box restarting"); } @@ -229,7 +230,7 @@ private static boolean deepOpActive(Context ctx) { * off-proot orphan (unlike an in-box kill), so a fresh box does not refill. The low-disk path stays * the sole escalation authority, so a firehose reap never counts toward stop-and-stay-down. */ - private static boolean actOnFirehose(Context ctx, int streak) { + private static boolean actOnFirehose(Context ctx, String source, int streak, List paths) { if (!confirmFirehoseGrowing(ctx)) return false; if (deepOpActive(ctx)) { Log.w(TAG, "K2GO-386: firehose confirmed but a deep op holds the box; not reaping this tick"); @@ -238,10 +239,10 @@ private static boolean actOnFirehose(Context ctx, int streak) { boolean reaped = EnvironmentProcess.reapBox(ctx); long reclaimed = reclaimRunawayLog(ctx); ServerLifecycleReconciler.get().requestReconcileNow(); - report(ctx, "contained_firehose", 0L, reaped, reclaimed, streak); + report(ctx, source, "contained_firehose", 0L, reaped, reclaimed, streak, paths); notifyUser(ctx, NOTIF_ID_FIREHOSE, ctx.getString(R.string.disk_guard_firehose_title), ctx.getString(R.string.disk_guard_firehose_body), - buildReportMessage(ctx, "contained_firehose", reaped, reclaimed, streak)); + buildReportMessage(ctx, source, "contained_firehose", reaped, reclaimed, streak, paths)); Log.w(TAG, "K2GO-386: contained recurring firehose (streak " + streak + "): reaped=" + reaped + ", reclaimed=" + reclaimed + " B, box restarting"); return true; @@ -312,18 +313,20 @@ private static File biggestLog(File dir) { * reporting is off or Sentry has no DSN. See IIABApplication (ADFA-4533) and ADR-386 section 7. * The user-facing, user-sent report is a separate channel (the closing K2GO-386 ticket). */ - private static void report(Context ctx, String action, long floorBytes, boolean reaped, - long reclaimed, int trip) { + private static void report(Context ctx, String source, String action, long floorBytes, boolean reaped, + long reclaimed, int count, List paths) { try { if (!CrashReportConsent.isEnabled(ctx)) return; Sentry.withScope(scope -> { scope.setLevel(SentryLevel.WARNING); scope.setTag("event", "disk_guard"); scope.setTag("action", action); + scope.setTag("source", source); scope.setTag("reaped", String.valueOf(reaped)); scope.setExtra("floor_bytes", String.valueOf(floorBytes)); scope.setExtra("reclaimed_bytes", String.valueOf(reclaimed)); - scope.setExtra("trip", String.valueOf(trip)); + scope.setExtra("trip_or_streak", String.valueOf(count)); + scope.setExtra("firehose_paths", formatPaths(paths)); Sentry.captureMessage("K2GO-386 disk-guard " + action); }); } catch (Throwable t) { @@ -331,6 +334,13 @@ private static void report(Context ctx, String action, long floorBytes, boolean } } + /** Join the firehosing paths for a report. Just short path strings -- never log content. Already + * bounded in count and length by FirehoseSignalSource; empty for the low-disk path. */ + private static String formatPaths(List paths) { + if (paths == null || paths.isEmpty()) return ""; + return String.join(", ", paths); + } + /** * 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 @@ -397,13 +407,18 @@ private static void notifyUser(Context ctx, int notifId, String title, String bo * The feedback flow adds the standard envelope (app version, build, device, ABI, ...), so this only * carries what the guard knows. English on purpose (it lands in a dev inbox / triage). */ - private static String buildReportMessage(Context ctx, String action, boolean reaped, long reclaimed, int count) { + private static String buildReportMessage(Context ctx, String source, String action, boolean reaped, + long reclaimed, int count, List paths) { Long free = StorageProbe.freeBytes(ctx); - return "K2Go disk guard acted.\n" - + "action: " + action + "\n" - + "reaped: " + reaped + "\n" - + "reclaimed_bytes: " + reclaimed + "\n" - + "trip_or_streak: " + count + "\n" - + "free_bytes_now: " + (free == null ? "unknown" : String.valueOf(free)) + "\n"; + String pathsStr = formatPaths(paths); + StringBuilder sb = new StringBuilder("K2Go disk guard acted.\n") + .append("source: ").append(source).append('\n') // low_disk | firehose_signal | debug + .append("action: ").append(action).append('\n') + .append("reaped: ").append(reaped).append('\n') + .append("reclaimed_bytes: ").append(reclaimed).append('\n') + .append("trip_or_streak: ").append(count).append('\n') + .append("free_bytes_now: ").append(free == null ? "unknown" : String.valueOf(free)).append('\n'); + if (!pathsStr.isEmpty()) sb.append("firehose_paths: ").append(pathsStr).append('\n'); + return sb.toString(); } } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/diskguard/data/FirehoseSignalSource.java b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/data/FirehoseSignalSource.java index 17a7d5963..267990408 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/diskguard/data/FirehoseSignalSource.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/data/FirehoseSignalSource.java @@ -15,6 +15,7 @@ import org.appdevforall.k2go.config.BoxEndpoints; import org.appdevforall.k2go.diskguard.domain.FirehoseSignal; +import org.json.JSONArray; import org.json.JSONObject; import java.io.ByteArrayOutputStream; @@ -22,6 +23,8 @@ import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; /** * Reads {@code GET /k2go-api/system/disk-guard/firehose} -> a {@link FirehoseSignal}, or {@code null} @@ -37,6 +40,9 @@ public final class FirehoseSignalSource { private static final String URL_PATH = BoxEndpoints.API + "/system/disk-guard/firehose"; private static final int TIMEOUT_MS = 4000; private static final int MAX_BYTES = 8 * 1024; // a handful of fields; refuse the absurd + // Bound the paths so a pathological signal cannot bloat the report (paths are short by nature). + private static final int MAX_PATHS = 10; + private static final int MAX_PATH_LEN = 200; private FirehoseSignalSource() {} @@ -50,13 +56,26 @@ public static FirehoseSignal read() { o.optBoolean("recurring", false), o.optInt("maxStreak", 0), o.optLong("lastTruncatedAtMs", 0L), - o.optLong("now", 0L)); + o.optLong("now", 0L), + parsePaths(o.optJSONArray("paths"))); } catch (Exception e) { Log.i(TAG, "K2GO-386: firehose signal read failed: " + e.getMessage()); return null; } } + /** Parse the firehosing paths, bounded in count and length. Never carries log content. */ + private static List parsePaths(JSONArray arr) { + List out = new ArrayList<>(); + if (arr == null) return out; + for (int i = 0; i < arr.length() && out.size() < MAX_PATHS; i++) { + String p = arr.optString(i, ""); + if (p.isEmpty()) continue; + out.add(p.length() > MAX_PATH_LEN ? p.substring(0, MAX_PATH_LEN) : p); + } + return out; + } + private static String httpGet(String urlStr) throws Exception { HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection(); try { diff --git a/controller/app/src/main/java/org/appdevforall/k2go/diskguard/domain/FirehoseSignal.java b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/domain/FirehoseSignal.java index b75f8f190..f4a755bff 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/diskguard/domain/FirehoseSignal.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/diskguard/domain/FirehoseSignal.java @@ -11,6 +11,9 @@ */ package org.appdevforall.k2go.diskguard.domain; +import java.util.Collections; +import java.util.List; + /** * A recurring firehose means the in-box guard truncated a runaway log on several consecutive ticks: * an off-proot orphan the box cannot stop. This signal is the app's ALERT to look; it is NOT a command @@ -18,6 +21,9 @@ * *

{@code nowMs} and {@code lastTruncatedAtMs} are both the dash-node wall-clock, so freshness is * judged in the server's own time frame -- no app-vs-server clock skew. + * + *

{@code paths} are the firehosing log paths the server reported (which log is the culprit), for the + * report. They are just short path strings -- never log content -- and the data source caps them. */ public final class FirehoseSignal { @@ -25,12 +31,15 @@ public final class FirehoseSignal { public final int maxStreak; public final long lastTruncatedAtMs; // server wall-clock of the last truncation, or 0 if never public final long nowMs; // server wall-clock when it answered + public final List paths; // firehosing log paths (bounded by the data source) - public FirehoseSignal(boolean recurring, int maxStreak, long lastTruncatedAtMs, long nowMs) { + public FirehoseSignal(boolean recurring, int maxStreak, long lastTruncatedAtMs, long nowMs, + List paths) { this.recurring = recurring; this.maxStreak = maxStreak; this.lastTruncatedAtMs = lastTruncatedAtMs; this.nowMs = nowMs; + this.paths = paths == null ? Collections.emptyList() : paths; } /** diff --git a/controller/app/src/test/java/org/appdevforall/k2go/diskguard/domain/FirehoseSignalTest.java b/controller/app/src/test/java/org/appdevforall/k2go/diskguard/domain/FirehoseSignalTest.java index 152f53b88..b8b9a6711 100644 --- a/controller/app/src/test/java/org/appdevforall/k2go/diskguard/domain/FirehoseSignalTest.java +++ b/controller/app/src/test/java/org/appdevforall/k2go/diskguard/domain/FirehoseSignalTest.java @@ -1,10 +1,14 @@ package org.appdevforall.k2go.diskguard.domain; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import org.junit.Test; +import java.util.Arrays; +import java.util.Collections; + public class FirehoseSignalTest { private static final long WINDOW = 25L * 60L * 1000L; @@ -12,33 +16,45 @@ public class FirehoseSignalTest { @Test public void recurringAndRecent_isFresh() { // last truncation 5 min before the server's now. - FirehoseSignal s = new FirehoseSignal(true, 3, 1_000_000L, 1_000_000L + 5L * 60L * 1000L); + FirehoseSignal s = new FirehoseSignal(true, 3, 1_000_000L, 1_000_000L + 5L * 60L * 1000L, + Collections.emptyList()); assertTrue(s.isFresh(WINDOW)); } @Test public void notRecurring_isNotFresh() { - FirehoseSignal s = new FirehoseSignal(false, 1, 1_000_000L, 1_000_000L + 60L * 1000L); + FirehoseSignal s = new FirehoseSignal(false, 1, 1_000_000L, 1_000_000L + 60L * 1000L, + Collections.emptyList()); assertFalse(s.isFresh(WINDOW)); } @Test public void recurringButStale_isNotFresh() { // last truncation 40 min before now: beyond the window, the firehose likely resolved. - FirehoseSignal s = new FirehoseSignal(true, 4, 1_000_000L, 1_000_000L + 40L * 60L * 1000L); + FirehoseSignal s = new FirehoseSignal(true, 4, 1_000_000L, 1_000_000L + 40L * 60L * 1000L, + Collections.emptyList()); assertFalse(s.isFresh(WINDOW)); } @Test public void neverTruncated_isNotFresh() { - FirehoseSignal s = new FirehoseSignal(true, 2, 0L, 5_000_000L); + FirehoseSignal s = new FirehoseSignal(true, 2, 0L, 5_000_000L, Collections.emptyList()); assertFalse(s.isFresh(WINDOW)); } @Test public void negativeAge_isNotFresh() { // lastTruncatedAtMs after now (clock went backwards / bad read): reject rather than trust it. - FirehoseSignal s = new FirehoseSignal(true, 2, 2_000_000L, 1_000_000L); + FirehoseSignal s = new FirehoseSignal(true, 2, 2_000_000L, 1_000_000L, Collections.emptyList()); assertFalse(s.isFresh(WINDOW)); } + + @Test + public void paths_areKept_andNullIsEmpty() { + FirehoseSignal withPaths = new FirehoseSignal(true, 2, 1_000L, 2_000L, + Arrays.asList("/var/log/php8.4-fpm.log")); + assertEquals(1, withPaths.paths.size()); + FirehoseSignal nullPaths = new FirehoseSignal(true, 2, 1_000L, 2_000L, null); + assertTrue(nullPaths.paths.isEmpty()); + } } diff --git a/controller/docs/ADR-386-unattended-disk-containment.md b/controller/docs/ADR-386-unattended-disk-containment.md index 2114262df..06baecfd0 100644 --- a/controller/docs/ADR-386-unattended-disk-containment.md +++ b/controller/docs/ADR-386-unattended-disk-containment.md @@ -182,10 +182,10 @@ analytics backbone (`AnalyticsConsent`, opt-in, default OFF -- which would silen two channels, on purpose: - **Automatic (unattended) -> GlitchTip via Sentry.** `DiskGuard.report(...)` captures a Sentry message - (tags: `event=disk_guard`, `action`, `reaped`; extras: `floor_bytes`, `reclaimed_bytes`, `trip`), gated - by `CrashReportConsent` (default ON, its own policy, no PII -- see `IIABApplication`, ADFA-4533). It is a - no-op when crash reporting is off or Sentry has no DSN. The user is informed, not tasked; recovery - already happened. DONE (this ticket). + (tags: `event=disk_guard`, `action`, `source`, `reaped`; extras: `floor_bytes`, `reclaimed_bytes`, + `trip_or_streak`, `firehose_paths`), gated by `CrashReportConsent` (default ON, its own policy, no PII -- + see `IIABApplication`, ADFA-4533). It is a no-op when crash reporting is off or Sentry has no DSN. The + user is informed, not tasked; recovery already happened. DONE (this ticket). - **Active (user-sent) -> feedback email.** The app tells the user it contained an unusual behavior and offers to send a report the user actively sends, through the existing feedback flow (`FeedbackFab` / `EmailFeedbackSender`, mailto), pre-filled with what happened -- the same pattern the install-failed @@ -193,6 +193,14 @@ two channels, on purpose: gains a tap action that opens an Activity which launches the pre-filled feedback email. This does NOT wait for GlitchTip to surface the issue; the operator can send it on the spot. +**Bounded by design -- never ship raw logs.** The report is a POINTER plus a short summary, not the log. +It carries only small metadata: `source` (`low_disk` / `firehose_signal` / `debug`, so `trip_or_streak` +reads correctly), `action`, `reaped`, `reclaimed_bytes`, `trip_or_streak`, `free_bytes_now`, and the +firehosing `firehose_paths` (which log is the culprit -- short path strings, capped in count and length by +`FirehoseSignalSource`, never log content). The firehose log itself is repeated error spam and is left on +the device; a developer pulls it separately if truly needed. The only attachment is the feedback flow's +single screenshot (~100 KB). So a runaway log can never bloat the report and break the email/Sentry pipe. + The active channel is the CLOSING piece of the K2GO-386 effort and lands in its own ticket (see section 12). ## 8. Lifecycle (who sets it, who clears it, what if a process dies) @@ -261,9 +269,10 @@ Scope: - **Reuse the feedback flow, pre-filled.** Call `FeedbackFab.sendFeedback(activity, "disk-guard", FeedbackType.BUG, prefilled)` -- the same typed, pre-filled pattern the install-failed report uses (ADFA-5119). The user sends it via email; the app fills in what happened, since the user did not cause it. -- **What the report carries.** The diagnostic already gathered for §7 (action, reaped, reclaimed bytes, - trip/streak, free bytes, the runaway log path) plus the standard feedback envelope (app version, build, - Android release, device, ABI, binaries tag), so a single email is enough to triage. +- **What the report carries.** The bounded diagnostic from §7 (`source`, action, reaped, reclaimed bytes, + trip/streak, free bytes, the firehosing paths -- capped, never log content) plus the standard feedback + envelope (app version, build, Android release, device, ABI, binaries tag), so a single small email is + enough to triage. Never the raw log (§7, "bounded by design"). - **Cadence.** Do not nag: offer the active report on a meaningful containment (an escalation, or a recurring firehose), not on every routine reap. The automatic channel still records every event.