From fb9cd6247838bcca188bb5464b200f76ee915b69 Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 12 Jul 2026 23:51:27 +0800 Subject: [PATCH 1/5] SizeUtils: keep unit fallbacks inside the allowed unit list When no unit fits exactly (findUnit) or is at least the value (findFloatUnit), fall back to the smallest unit in the *provided* list rather than hardcoding SizeUnit.B, so a restricted picker (e.g. a GiB-only list) never yields a unit outside it. For the full list units[0] is B, so the classic behaviour is unchanged. Split out of the v10 CMA reservoir work as an independent change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ARourbDczkCYsKUUMC9EFv --- .../java/cn/classfun/droidvm/lib/size/SizeUtils.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUtils.java index 81cf70c..b5870fb 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUtils.java @@ -20,7 +20,12 @@ public static SizeNumber findUnit( for (int i = units.length - 1; i >= 0; i--) if (units[i].fitsExactly(bytes)) return units[i].calcPair(bytes); - return SizeUnit.B.calcPair(bytes); + // Nothing fits exactly: fall back to the smallest *allowed* unit so a + // restricted list (e.g. a GiB-only picker) never yields a unit outside + // it. For the full list units[0] is B - identical to the old fallback. + return units.length > 0 + ? units[0].calcPair(new BigDecimal(bytes)) + : SizeUnit.B.calcPair(bytes); } @NonNull @@ -31,7 +36,10 @@ public static SizeNumber findFloatUnit( for (int i = units.length - 1; i >= 0; i--) if (units[i].isAtLeast(bytes)) return units[i].calcPair(bytes); - return SizeUnit.B.calcPair(bytes); + // See findUnit: keep the fallback inside the allowed list. + return units.length > 0 + ? units[0].calcPair(bytes) + : SizeUnit.B.calcPair(bytes); } @NonNull From 116762cd2371e19d6f4b2c1b2c53e5b03a94a7e8 Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 12 Jul 2026 23:51:27 +0800 Subject: [PATCH 2/5] SwitchRowWidget: propagate setEnabled to the child switch FrameLayout.setEnabled alone left the child MaterialSwitch enabled and directly draggable, so a "disabled" row could still fire its change listener. Override setEnabled to propagate to the switch, and gate the row's click-to-toggle on switchView.isEnabled() so a disabled switch can't be toggled by tapping the row. Split out of the v10 CMA reservoir work as an independent change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ARourbDczkCYsKUUMC9EFv --- .../ui/widgets/row/SwitchRowWidget.java | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/ui/widgets/row/SwitchRowWidget.java b/app/src/main/java/cn/classfun/droidvm/ui/widgets/row/SwitchRowWidget.java index 5b41466..5cd2dc8 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/widgets/row/SwitchRowWidget.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/widgets/row/SwitchRowWidget.java @@ -57,7 +57,9 @@ private void init(@Nullable AttributeSet attrs) { switchView = findViewById(R.id.sw_switch); initAttrs(attrs); if (isInEditMode()) return; - setOnClickListener(v -> switchView.toggle()); + setOnClickListener(v -> { + if (switchView.isEnabled()) switchView.toggle(); + }); } private void initAttrs(@Nullable AttributeSet attrs) { @@ -94,10 +96,23 @@ public boolean isChecked() { @SuppressLint("ClickableViewAccessibility") public void setSwitchEnabled(boolean enabled) { switchView.setOnTouchListener(enabled ? null : (v, e) -> true); - setOnClickListener(enabled ? v -> switchView.toggle() : null); + setOnClickListener(!enabled ? null : v -> { + if (switchView.isEnabled()) switchView.toggle(); + }); setClickable(enabled); } + /** + * FrameLayout.setEnabled alone leaves the child MaterialSwitch enabled and + * directly draggable, so a "disabled" row could still fire its change + * listener. Propagate to the switch so disabling actually greys and locks it. + */ + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + switchView.setEnabled(enabled); + } + public void setChecked(boolean checked) { switchView.setChecked(checked); } From 001db84535f31b404e37a33627653d14e959adba Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 12 Jul 2026 23:51:54 +0800 Subject: [PATCH 3/5] TextInputRowWidget: add fixed-unit (ti_unit) and hide-unit (ti_showUnit) Two new size-mode options: - ti_unit pins the picker to exactly one unit (e.g. "GiB"): values always display and are entered in it, no other unit is offered. - ti_showUnit hides the picker button in narrow layouts while the fixed/ derived unit still applies to typed values; setUnitButtonVisible toggles it at runtime. Split out of the v10 CMA reservoir work as an independent change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ARourbDczkCYsKUUMC9EFv --- .../ui/widgets/row/TextInputRowWidget.java | 58 ++++++++++++++++++- app/src/main/res/values/attrs.xml | 6 ++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/ui/widgets/row/TextInputRowWidget.java b/app/src/main/java/cn/classfun/droidvm/ui/widgets/row/TextInputRowWidget.java index f3951fb..03847de 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/widgets/row/TextInputRowWidget.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/widgets/row/TextInputRowWidget.java @@ -1,5 +1,7 @@ package cn.classfun.droidvm.ui.widgets.row; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + import android.content.Context; import android.text.Editable; import android.text.InputFilter; @@ -48,6 +50,9 @@ public final class TextInputRowWidget extends FrameLayout { private BigInteger minValue = BigInteger.ZERO; private BigInteger maxValue = BigInteger.valueOf(Long.MAX_VALUE); private BigInteger precision = BigInteger.ONE; + @Nullable + private SizeUnit fixedUnit = null; // ti_unit: pin size mode to this one unit + private boolean showUnitButton = true; // ti_showUnit: hide the picker button private final AtomicBoolean updatingValue = new AtomicBoolean(false); private Runnable onFocusLostListener; @@ -134,6 +139,14 @@ private void initAttrs(@Nullable AttributeSet attrs) { iconButtonView.setVisibility(GONE); } var tiMode = a.getInt(R.styleable.TextInputRowWidget_ti_mode, MODE_NORMAL); + var unit = a.getString(R.styleable.TextInputRowWidget_ti_unit); + if (unit != null) { + fixedUnit = SizeUnit.fromString(unit); + if (fixedUnit == null) + throw new IllegalArgumentException(fmt("Unknown ti_unit: %s", unit)); + } + showUnitButton = a.getBoolean( + R.styleable.TextInputRowWidget_ti_showUnit, true); var min = a.getString(R.styleable.TextInputRowWidget_ti_min); var max = a.getString(R.styleable.TextInputRowWidget_ti_max); if (!isInEditMode()) { @@ -150,6 +163,33 @@ private void initAttrs(@Nullable AttributeSet attrs) { } } + /** + * Fixed-unit variant of {@link #setPickerByMinMax}: min/max still bound the + * byte value, but the picker offers exactly {@code fixedUnit} - the button + * degrades to a unit tag (ROTATE over one item is a no-op). + */ + private void setPickerFixedUnit( + @NonNull SizeUnit unit, + @Nullable String min, + @Nullable String max + ) { + if (min != null) { + var parsed = SizeUtils.parseBigSize(min); + if (parsed.compareTo(BigInteger.ZERO) < 0) + throw new IllegalArgumentException("Min value must be non-negative"); + minValue = parsed; + } + if (max != null) { + var parsed = SizeUtils.parseBigSize(max); + if (parsed.compareTo(BigInteger.ZERO) < 0) + throw new IllegalArgumentException("Max value must be non-negative"); + maxValue = parsed; + } + if (minValue.compareTo(maxValue) > 0) + throw new IllegalArgumentException("Min value cannot be greater than max value"); + buttonView.setItems(unit); + } + private void setPickerByMinMax( @Nullable String min, @Nullable String max @@ -196,10 +236,13 @@ private void applyMode(int mode, @Nullable String min, @Nullable String max) { } else if (mode == MODE_SIZE) { var flags = InputType.TYPE_CLASS_NUMBER; flags |= InputType.TYPE_NUMBER_FLAG_DECIMAL; - buttonView.setVisibility(VISIBLE); + // The picker is still configured when hidden - the (fixed) unit + // keeps applying to typed values; only the button goes away. + buttonView.setVisibility(showUnitButton ? VISIBLE : GONE); if (!isInEditMode()) { buttonView.setMode(PickerButtonWidget.Mode.ROTATE); - setPickerByMinMax(min, max); + if (fixedUnit != null) setPickerFixedUnit(fixedUnit, min, max); + else setPickerByMinMax(min, max); } editText.setInputType(flags); } @@ -320,6 +363,17 @@ public void setSelection(int index) { editText.setSelection(index); } + /** + * Show/hide the size-mode unit button at runtime (see {@code ti_showUnit}). + * The picker stays configured either way - the unit keeps applying to + * typed values; only the button's width comes and goes. + */ + public void setUnitButtonVisible(boolean visible) { + showUnitButton = visible; + if (mode == MODE_SIZE) + buttonView.setVisibility(visible ? VISIBLE : GONE); + } + public void setError(@Nullable CharSequence error) { textInputLayout.setError(error); } diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index e431d4c..48e7231 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -72,6 +72,12 @@ + + + + From 33cf759b790a25e48e6f3125fbe4bc8f04db5f15 Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 12 Jul 2026 23:59:06 +0800 Subject: [PATCH 4/5] hugepage: add v10 CMA reservoir management Manage the module's CMA reservoir (pool_want_with_cma) from the hugepage screen, and fix the pieces of the existing UI that the second target broke. CMA switch + consumability probe: - A switch above "Enable Module" reads cma_probe_result from settings.prop: a recorded pass enables directly, anything else offers the probe. - The probe raises pool_want_with_cma (never lowers an existing bigger total - that would demolish reservoir the device is lending out), then empties the pool with a live pool_want=0 so its blocks flip straight into the reservoir, and lets a v3 acquire top it up as far as it can. - Coming up short is not a distinct failure: the reservoir is assembled out of the pool itself, so a fresh boot - unfragmented - simply succeeds. - pool_want is only ever written live, so an app killed mid-probe cannot leave the pool soft-disabled past a reboot; every exit restores it. - A pass pins the pool to 512 MB and keeps the rest as reservoir; a denial or unreadable result is put to the user. settings.prop writes became a locked read-modify-write so the probe thread and the pool-size save can't wipe each other's keys. Runtime insmod now matches the boot-time one: prefer the module's own load.sh (single source of truth for the preflight), else reconstruct only v9's kapi_check ABI guard. insmod args are joined via StringUtils.joinNonEmpty. Bar and captions: [VMs][available][CMA free][CMA lent][waiting], denominated by pool_want_with_cma with the reservoir counted as filled. SegmentedBar's storage bar takes a StorageSpec holder instead of an 18-argument call. Snapshot parses a refill_stat key=value map in a dedicated constructor, with field defaults standing in for the not-loaded view. Acquire gating follows the module's own rule (acquire_set): work exists when the pool is short OR the reservoir is short. A CMA-era grow kicks v3, not v1. Pool size is a GiB-only input (ti_unit), split into pool / with-CMA total when the reservoir is on, two-way linked so the pair keeps pool_want <= pool_want_with_cma. VM bar colours are keyed by rank among the live pids rather than by pid. Co-Authored-By: lateautumn233 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ARourbDczkCYsKUUMC9EFv --- .../droidvm/lib/utils/StringUtils.java | 12 + .../droidvm/ui/hugepage/HugePageActivity.java | 994 +++++++++++++++++- .../droidvm/ui/hugepage/HugePageColor.java | 65 +- .../droidvm/ui/hugepage/HugePageModel.java | 521 +++++++-- .../droidvm/ui/hugepage/HugePageProcess.java | 27 +- .../ui/hugepage/HugePageProcessActivity.java | 107 +- .../ui/hugepage/HugePageProcessAdapter.java | 15 +- .../droidvm/ui/hugepage/SegmentedBar.java | 123 ++- app/src/main/res/layout/activity_hugepage.xml | 51 +- app/src/main/res/values-night/colors.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 49 + app/src/main/res/values-zh-rTW/strings.xml | 49 + app/src/main/res/values/colors.xml | 5 + app/src/main/res/values/strings.xml | 49 + 14 files changed, 1893 insertions(+), 177 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/StringUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/StringUtils.java index 3d49a8c..649dbc1 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/StringUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/StringUtils.java @@ -189,6 +189,18 @@ public static String pathJoin(@NonNull File base, @NonNull String... children) { return pathJoin(base.getAbsolutePath(), children); } + /** Join the non-empty parts with {@code sep}; empty parts are skipped. */ + @NonNull + public static String joinNonEmpty(@NonNull String sep, @NonNull String... parts) { + var sb = new StringBuilder(); + for (var p : parts) { + if (p.isEmpty()) continue; + if (sb.length() > 0) sb.append(sep); + sb.append(p); + } + return sb.toString(); + } + @NonNull public static String fmt(String fmt, Object... args) { return new Formatter(Locale.ROOT).format(fmt, args).toString(); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java index 4433432..66b7bd6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java @@ -38,6 +38,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.progressindicator.LinearProgressIndicator; +import android.text.Editable; +import android.widget.LinearLayout; +import android.widget.ProgressBar; + import java.math.BigInteger; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -45,10 +49,13 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.daemon.DaemonConnection; import cn.classfun.droidvm.lib.size.SizeUtils; +import cn.classfun.droidvm.lib.ui.SimpleTextWatcher; import cn.classfun.droidvm.ui.widgets.row.SwitchRowWidget; import cn.classfun.droidvm.ui.widgets.row.TextInputRowWidget; import cn.classfun.droidvm.ui.widgets.row.TextRowWidget; @@ -102,6 +109,43 @@ public final class HugePageActivity extends AppCompatActivity { private TextView tvPoolTotal; private TextView tvPoolSize; private SwitchRowWidget rowModuleEnable; + // v10 CMA reservoir controls. The right input is the TOTAL with-CMA pool + // size (pool_want_with_cma), not the reservoir delta. + private TextInputRowWidget inputCmaSize; + private SwitchRowWidget rowCmaEnable; + private boolean cmaSwitchSyncing = false; // programmatic setChecked guard + private boolean cmaInputLoaded = false; // seed the CMA size input once per show + private boolean cmaBusy = false; // a probe / toggle flow is in flight + // A reservoir target persisted with no verdict recorded = a probe that asked + // for a reboot and is now waiting to be finished. Offer it once per screen. + private boolean probePromptShown = false; + // Two-way size link (pool_want <= pool_want_with_cma): which input the + // user touched last decides who yields when they cross. + private static final int SIZE_EDIT_POOL = 1; + private static final int SIZE_EDIT_CMA = 2; + private int lastSizeEdit = SIZE_EDIT_POOL; + private boolean sizeLinkSyncing = false; // programmatic setBigValue guard + /** Balloon floor (MB) for the consumability probe - `balloon 1536`. */ + private static final long BALLOON_FLOOR_MB = 1536; + private static final long BALLOON_TIMEOUT_S = 600; + /** + * How much reservoir the probe wants to measure against: + * {@code max(RAM - 8G, RAM * 0.4)}. RAM here is MemTotal - physical pages + * only, so zram/swap capacity (SwapTotal) never inflates it. Advisory, not + * a precondition: a smaller reservoir still probes, it just makes the + * verdict less reliable, and the user is warned before continuing. + */ + private static final long PROBE_KEEP_BYTES = 8L << 30; // 8 GiB + private static final double PROBE_MIN_RAM_FRACTION = 0.4; + /** + * Pool size a passing probe leaves behind. The probe has just proved apps + * can consume the reservoir, so holding a large pool would waste memory the + * reservoir would otherwise lend out: pin the pool small and let + * pool_want_with_cma (kept at the size the probe assembled) carry the rest + * as reservoir. A VM start stages pages back in on demand. + */ + private static final long PROBE_POOL_BYTES = 512L << 20; // 512 MiB + private static final long PROBE_POOL_PAGES = PROBE_POOL_BYTES / PAGE_SIZE; private TextRowWidget rowStatState; private TextRowWidget rowStatTotalServed; private TextRowWidget rowStatTotalRefilled; @@ -137,6 +181,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { tvPoolTotal = findViewById(R.id.tv_pool_total); tvPoolSize = findViewById(R.id.tv_pool_size); rowModuleEnable = findViewById(R.id.row_module_enable); + inputCmaSize = findViewById(R.id.input_cma_size); + rowCmaEnable = findViewById(R.id.row_cma_enable); rowStatState = findViewById(R.id.row_stat_state); rowStatTotalServed = findViewById(R.id.row_stat_total_served); rowStatTotalRefilled = findViewById(R.id.row_stat_total_refilled); @@ -153,6 +199,24 @@ private void initialize() { else savePoolSize(); }); rowModuleEnable.setOnCheckedChangeListener(this::doToggleModule); + rowCmaEnable.setOnCheckedChangeListener((btn, checked) -> onCmaSwitchChanged(checked)); + // Two-way link between the pool size and the with-CMA total: track who + // was edited last, reconcile whenever a field is left (and again at + // save, since tapping Save doesn't steal the EditText's focus). + inputPoolSize.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void afterTextChanged(Editable s) { + if (!sizeLinkSyncing) lastSizeEdit = SIZE_EDIT_POOL; + } + }); + inputCmaSize.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void afterTextChanged(Editable s) { + if (!sizeLinkSyncing) lastSizeEdit = SIZE_EDIT_CMA; + } + }); + inputPoolSize.setOnFocusLostListener(this::reconcileSizeLink); + inputCmaSize.setOnFocusLostListener(this::reconcileSizeLink); // One button: // not installed -> Install (open releases page) // installed, unloaded -> Enable (insmod) @@ -255,6 +319,42 @@ static void showAcquireInfo(@NonNull Context ctx, int mode, @NonNull Runnable on .show(); } + /** + * The "acquire finished" bubble, shared by both hugepage screens: how much + * the pool reached of its target, and - while the v10 reservoir is on - the + * with-CMA total too. Both matter because acquire's own stop condition + * covers both (see {@link HugePageModel.Snapshot#deficit}): a pool that hit + * its target while the reservoir is still short is not "complete", and a + * grown pool_want is filled by staging reservoir pages in, which moves the + * pool number without moving the total. + */ + @NonNull + static String acquireDoneMessage( + @NonNull Context ctx, @NonNull HugePageModel.Snapshot snap + ) { + long gotPool = snap.free + snap.lent; + long wantPool = snap.targetIdeal; + if (!snap.cmaActive()) { + return gotPool >= wantPool + ? ctx.getString(R.string.hugepage_proc_acquire_full, pageSize(wantPool)) + : ctx.getString(R.string.hugepage_proc_acquire_partial, + pageSize(gotPool), pageSize(wantPool)); + } + long gotTotal = gotPool + snap.cmaPool; + long wantTotal = snap.wantWithCma; + return (gotPool >= wantPool && gotTotal >= wantTotal) + ? ctx.getString(R.string.hugepage_proc_acquire_full_cma, + pageSize(wantPool), pageSize(wantTotal)) + : ctx.getString(R.string.hugepage_proc_acquire_partial_cma, + pageSize(gotPool), pageSize(wantPool), + pageSize(gotTotal), pageSize(wantTotal)); + } + + @NonNull + private static String pageSize(long pages) { + return SizeUtils.formatSize(pages * PAGE_SIZE); + } + /** True once the user opted out of the acquire prompt (short-press runs directly). */ static boolean skipAcquireConfirm(@NonNull Context ctx) { return ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -345,16 +445,29 @@ private void refreshStatus() { // Per-VM breakdown through the usage ladder (KO attribution, degrading // to a THP scan of running VMs), so this bar matches the usage screen. // Each segment is labelled with the friendly VM name from vmMap. + // allPids is the unfiltered owner list - the rank-based color map + // must see every pid (both screens derive ranks from the same list). List owners = new ArrayList<>(); + List allPids = new ArrayList<>(); if (snap.loaded) { for (var e : model.usage(null).entries) { + allPids.add(e.pid); if (e.pages > 0) owners.add(new long[]{e.pid, e.pages}); } } // Only fetch VM names when there are rows to label. Map vmMap = owners.isEmpty() ? new LinkedHashMap<>() : model.vmNames(false); - runOnUiThread(() -> updateUI(snap, crashStamp, owners, vmMap)); + // Reservoir occupancy for the two-tone CMA block (module caches ~1s). + var cmaUsage = snap.cmaActive() ? model.cmaUsage() : null; + // A reservoir built (or being built) toward a target nobody ever + // judged: the "save and reboot" branch of the probe left it here. + boolean probePending = !probePromptShown && !cmaBusy + && snap.cmaActive() && model.cmaProbeResult() == null; + runOnUiThread(() -> { + updateUI(snap, crashStamp, owners, allPids, vmMap, cmaUsage); + if (probePending) promptPendingProbe(); + }); }); } @@ -376,7 +489,9 @@ private void setPagesString(@NonNull TextView tv, @StringRes int str, long pages private void updateUI( @NonNull HugePageModel.Snapshot snap, boolean crashed, @NonNull List owners, - @NonNull Map vmMap + @NonNull List allPids, + @NonNull Map vmMap, + @Nullable HugePageModel.CmaUsage cmaUsage ) { if (isFinishing()) return; cardCrashWarning.setVisibility(crashed ? VISIBLE : GONE); @@ -390,11 +505,16 @@ private void updateUI( // "total" shows the desired target - the model's version-unified // want (pool_want, else v6 pool_target, else current capacity). var poolWant = snap.targetIdeal; + boolean cmaOn = snap.cmaActive(); // Apple-storage-bar style: one labelled colored block per VM // (used), then the available portion as a track-coloured gap, - // then the waiting-to-acquire (deficit) block pinned flush right. - // Each block draws its label inside if wide enough. + // then (v10) the CMA reservoir split into occupied-by-apps and + // free halves, then the waiting-to-acquire (deficit) block pinned + // flush right. Each block draws its label inside if wide enough. boolean dark = HugePageColor.isDark(this); + // Rank-based colors over the full owner list, so adjacent VM + // segments never land on near-identical hues (see HugePageColor). + var colorMap = HugePageColor.forPids(allPids, dark); int n = owners.size(); int[] usedColors = new int[n]; float[] usedValues = new float[n]; @@ -403,7 +523,8 @@ private void updateUI( for (int i = 0; i < n; i++) { int pid = (int) owners.get(i)[0]; long ownerPages = owners.get(i)[1]; - usedColors[i] = HugePageColor.forPid(pid, dark); + Integer color = colorMap.get(pid); + usedColors[i] = color != null ? color : HugePageColor.forRank(i, dark); usedValues[i] = ownerPages; String name = vmMap.get(pid); if (name == null) name = getString(R.string.hugepage_proc_pid, pid); @@ -417,21 +538,68 @@ private void updateUI( // orphaned owner-gone pages that have no segment; those surface in // the held/available gap rather than as an invisible caption delta. long used = seg; - long deficit = Math.max(0, poolWant - seg - poolAvail); + // With the reservoir on, the bar's denominator is the overall + // target pool_want_with_cma and the reservoir counts as filled. + long cmaPool = cmaOn ? snap.cmaPool : 0; + long barWant = cmaOn ? snap.wantWithCma : poolWant; + long deficit = Math.max(0, barWant - seg - poolAvail - cmaPool); + // Reservoir occupancy split (pages): still-free vs held by other + // apps right now; unknown occupancy shows one undivided free block. + boolean cmaUsageOk = cmaOn && cmaUsage != null && cmaUsage.ok; + long cmaOther = cmaUsageOk + ? Math.min(cmaPool, cmaUsage.usedMb / (PAGE_SIZE / (1024 * 1024))) + : 0; + long cmaFree = cmaPool - cmaOther; + // Avail sub-split: pages flippable to CMA as whole pageblocks vs + // not (pool_avail_cma_able); -1 = unreported, no split shown. + long availCmaAble = (cmaOn && snap.availCmaAble >= 0) + ? Math.min(poolAvail, snap.availCmaAble) : -1; + long availNonCma = availCmaAble >= 0 ? poolAvail - availCmaAble : 0; // 2x2 caption: used / available on top, total / pool-size below. - // Total = real held reserve (used + avail), shown raw - no clamp - // to the pool size, so a kernel that fails to release on shrink - // shows up as total > the size you set. + // With the reservoir on, the pool-size cell shows both targets as + // pool_want/pool_want_with_cma; the detailed cma-able and + // free/other-apps breakdowns live on the usage screen's synthetic + // available/CMA rows. Total = real held reserve (used + avail), + // shown raw - no clamp to the pool size, so a kernel that fails + // to release on shrink shows up as total > the size you set. var held = used + poolAvail; setPagesString(tvPoolUsed, R.string.hugepage_stat_pool_used, used); setPagesString(tvPoolAvail, R.string.hugepage_stat_pool_available, poolAvail); setPagesString(tvPoolTotal, R.string.hugepage_stat_pool_total, held); - setPagesString(tvPoolSize, R.string.hugepage_stat_pool_size, poolWant); - segPoolBar.setStorage(usedColors, usedValues, usedLabels, - poolAvail, fmt("%s\n%s", getString(R.string.hugepage_bar_available), SizeUtils.formatSize(poolAvail * PAGE_SIZE)), - HugePageColor.pending(this), deficit, - fmt("%s\n%s", getString(R.string.hugepage_proc_deficit), SizeUtils.formatSize(deficit * PAGE_SIZE)), - poolWant); + if (cmaOn) { + tvPoolSize.setText(getString(R.string.hugepage_stat_pool_size_cma, + poolWant, snap.wantWithCma, + SizeUtils.formatSize(poolWant * PAGE_SIZE), + SizeUtils.formatSize(snap.wantWithCma * PAGE_SIZE))); + } else { + setPagesString(tvPoolSize, R.string.hugepage_stat_pool_size, poolWant); + } + // Bar: [VMs][avail][CMA][CMA lent][waiting]. The CMA parts are two + // ordinary labelled segments; only the avail block keeps the + // single-label pure-color sub-split ([non-cma-able|normal]). + var spec = new SegmentedBar.StorageSpec(); + spec.usedColors = usedColors; + spec.usedValues = usedValues; + spec.usedLabels = usedLabels; + spec.avail = poolAvail; + spec.availLabel = fmt("%s\n%s", getString(R.string.hugepage_bar_available), + SizeUtils.formatSize(poolAvail * PAGE_SIZE)); + spec.availNonCma = Math.max(0, availNonCma); + spec.availNonCmaColor = HugePageColor.availNonCma(this); + spec.cmaFree = cmaFree; + spec.cmaFreeLabel = fmt("%s\n%s", getString(R.string.hugepage_bar_cma), + SizeUtils.formatSize(cmaFree * PAGE_SIZE)); + spec.cmaFreeColor = HugePageColor.cmaFree(this); + spec.cmaOther = cmaOther; + spec.cmaOtherLabel = fmt("%s\n%s", getString(R.string.hugepage_bar_cma_lent), + SizeUtils.formatSize(cmaOther * PAGE_SIZE)); + spec.cmaOtherColor = HugePageColor.cmaUsed(this); + spec.deficitColor = HugePageColor.pending(this); + spec.deficit = deficit; + spec.deficitLabel = fmt("%s\n%s", getString(R.string.hugepage_proc_deficit), + SizeUtils.formatSize(deficit * PAGE_SIZE)); + spec.want = barWant; + segPoolBar.setStorage(spec); } else { rowStatState.setValue(getString(R.string.hugepage_stats_unavailable)); rowStatTotalServed.setValue(null); @@ -459,14 +627,7 @@ poolAvail, fmt("%s\n%s", getString(R.string.hugepage_bar_available), SizeUtils.f // which polls). Track the kernel flag, not the optimistic mainAcquiring, so // an acquire that never actually started can't fake a "done". if (wasAcquiring && !acquiring) { - long got = snap.free + snap.lent; - long want = snap.targetIdeal; - String msg = got >= want - ? getString(R.string.hugepage_proc_acquire_full, - SizeUtils.formatSize(want * PAGE_SIZE)) - : getString(R.string.hugepage_proc_acquire_partial, - SizeUtils.formatSize(got * PAGE_SIZE), - SizeUtils.formatSize(want * PAGE_SIZE)); + String msg = acquireDoneMessage(this, snap); // Append why the acquire stopped (kernel free text from refill_stat's // acquire_stop_reason), so the user sees the reason in the bubble. String reason = snap.acquireStopReason; @@ -511,6 +672,40 @@ poolAvail, fmt("%s\n%s", getString(R.string.hugepage_bar_available), SizeUtils.f // soft-disable (want 0), so there is no version branching here. acquireEnabled = snap.loaded && snap.deficit > 0; applyAcquireState(); + + // CMA switch: only meaningful on a loaded v10 module. While a probe / + // toggle flow runs, leave the switch and the size input alone - the flow + // owns them (the reservoir flips around mid-probe and would flicker). + rowCmaEnable.setEnabled(snap.loaded && snap.hasCma); + if (!cmaBusy) { + boolean cmaActive = snap.cmaActive(); + if (rowCmaEnable.isChecked() != cmaActive) { + cmaSwitchSyncing = true; + rowCmaEnable.setChecked(cmaActive); + cmaSwitchSyncing = false; + } + // The size row shows exactly one GiB tag: on the right field while + // CMA is on (two fields, tight width), on the pool field otherwise. + inputPoolSize.setUnitButtonVisible(!cmaActive); + if (cmaActive) { + // Seed the with-CMA total input once per show (it maps 1:1 to + // pool_want_with_cma), then leave the user's typing be. + if (inputCmaSize.getVisibility() != VISIBLE || !cmaInputLoaded) { + inputCmaSize.setVisibility(VISIBLE); + sizeLinkSyncing = true; + try { + inputCmaSize.setBigValue( + BigInteger.valueOf(snap.wantWithCma * PAGE_SIZE)); + } finally { + sizeLinkSyncing = false; + } + cmaInputLoaded = true; + } + } else { + inputCmaSize.setVisibility(GONE); + cmaInputLoaded = false; + } + } } /** @@ -599,7 +794,16 @@ private void loadPoolSize() { try { var pages = Long.parseLong(cur); var bytes = BigInteger.valueOf(pages * PAGE_SIZE); - runOnUiThread(() -> inputPoolSize.setBigValue(bytes)); + runOnUiThread(() -> { + // Programmatic seed - don't count it as a user edit for + // the pool<->with-CMA size link. + sizeLinkSyncing = true; + try { + inputPoolSize.setBigValue(bytes); + } finally { + sizeLinkSyncing = false; + } + }); } catch (NumberFormatException e) { Log.w(TAG, "Failed to parse pool_want", e); } @@ -643,10 +847,42 @@ private long runningVmMemMib() { return total[0]; } + /** + * Keep the size pair consistent ({@code pool_want <= pool_want_with_cma}): + * when they cross, the field the user touched last wins - raising the pool + * above the total drags the total up; lowering the total under the pool + * shrinks the pool. Runs on focus-loss of either field and again at save. + */ + private void reconcileSizeLink() { + if (inputCmaSize.getVisibility() != VISIBLE) return; + if (!inputPoolSize.isInputValid() || !inputCmaSize.isInputValid()) return; + var pool = inputPoolSize.getBigValue(); + var withCma = inputCmaSize.getBigValue(); + if (pool.compareTo(withCma) <= 0) return; + sizeLinkSyncing = true; + try { + if (lastSizeEdit == SIZE_EDIT_CMA) inputPoolSize.setBigValue(withCma); + else inputCmaSize.setBigValue(pool); + } finally { + sizeLinkSyncing = false; + } + } + private void savePoolSize() { + reconcileSizeLink(); if (!inputPoolSize.isInputValid()) return; var bytes = inputPoolSize.getBigValue(); var pages = bytes.divide(BigInteger.valueOf(PAGE_SIZE)); + // While the reservoir is on, the right field IS the with-CMA total + // (pool_want_with_cma); the link above already keeps it >= the pool. + final long cmaPages; + if (inputCmaSize.getVisibility() == VISIBLE) { + if (!inputCmaSize.isInputValid()) return; + cmaPages = inputCmaSize.getBigValue() + .divide(BigInteger.valueOf(PAGE_SIZE)).longValue(); + } else { + cmaPages = -1; + } runOnPool(() -> { // The pool must be able to back every running VM's RAM, so it can't // be set below the sum of running VMs' configured memory. @@ -661,12 +897,17 @@ private void savePoolSize() { } // Persist for the next load and apply to the running pool where the // live knob exists (v7); v6's read-only target only lands next boot. - var res = model.saveSize(pages.longValue()); - // A grow leaves a deficit -> kick one v1 fill so the raised target + // One settings.prop rewrite carries the pool target and (while the + // reservoir is on) the with-CMA total together. + var res = model.saveTargets(pages.longValue(), cmaPages); + // A grow leaves a deficit -> kick one fill so the raised target // starts filling at once (a shrink is applied by the write itself). - // The GUI drives acquire; the model's saveSize deliberately doesn't. + // The GUI drives acquire; the model's save deliberately doesn't. + // Only the mode-2/3 sweep runs the reservoir-building Phase R + // ("mode 1 remains pool-only legacy"), so a CMA-era grow needs v3. var snap = model.state(); - if (res.ok() && snap.loaded && snap.deficit > 0) model.acquire(1); + if (res.ok() && snap.loaded && snap.deficit > 0) + model.acquire(snap.cmaActive() ? 3 : 1); boolean okSaved = res.ok(); boolean appliedNow = "pool_want".equals(res.impl); // live write actually landed runOnUiThread(() -> { @@ -696,6 +937,703 @@ private void doToggleModule() { }); } + /* ================================================================== */ + /* v10 CMA reservoir: switch + consumability probe */ + /* ================================================================== */ + + private void onCmaSwitchChanged(boolean checked) { + if (cmaSwitchSyncing) return; + if (cmaBusy) { // a flow already owns the switch + setCmaSwitch(!checked); + return; + } + if (checked) doCmaEnable(); + else doCmaDisable(); + } + + /** Programmatic switch write that doesn't re-enter the change listener. */ + private void setCmaSwitch(boolean checked) { + cmaSwitchSyncing = true; + rowCmaEnable.setChecked(checked); + cmaSwitchSyncing = false; + } + + /** End an enable flow without enabling: release the busy lock, switch off. */ + private void cancelCmaEnable() { + cmaBusy = false; + setCmaSwitch(false); + refreshStatus(); + } + + /** Switch off: demolish the reservoir now and persist off for next boot. */ + private void doCmaDisable() { + cmaBusy = true; + runOnPool(() -> { + var res = model.saveCmaTarget(0); + runOnUiThread(() -> { + cmaBusy = false; + Toast.makeText(this, res.ok() ? R.string.hugepage_cma_disabled + : R.string.hugepage_cma_toggle_failed, LENGTH_SHORT).show(); + if (!res.ok()) setCmaSwitch(true); + refreshStatus(); + }); + }); + } + + /** + * Switch on. The magisk-side {@code cma_probe_result} (settings.prop) says + * whether the consumability probe ever ran: + *
    + *
  • {@code 1} - apps can consume CMA: enable directly, no probe;
  • + *
  • {@code 0} - probed unusable: offer a re-probe;
  • + *
  • absent - never probed: explain and offer to run it.
  • + *
+ */ + private void doCmaEnable() { + cmaBusy = true; + runOnPool(() -> { + var snap = model.state(); + if (!snap.loaded || !snap.hasCma) { + runOnUiThread(() -> { + cmaBusy = false; + setCmaSwitch(false); + Toast.makeText(this, R.string.hugepage_cma_not_supported, + LENGTH_SHORT).show(); + }); + return; + } + var verdict = model.cmaProbeResult(); + if (snap.cmaPbOrder < 0) { + // The module disabled its whole CMA side this boot (preflight / + // symbols / first-block verification) - no write can help now. + // A recorded denial is one of the causes (the boot script then + // hands the module -1 preflight values): drop it, so the next + // boot comes up CMA-capable and the probe can run again. + boolean stale = verdict != null && verdict == VERDICT_DENIED; + if (stale) model.clearCmaProbeResult(); + runOnUiThread(() -> { + cmaBusy = false; + setCmaSwitch(false); + if (isFinishing()) return; + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.hugepage_cma_unavailable_title) + .setMessage(stale ? R.string.hugepage_cma_unavailable_denied + : R.string.hugepage_cma_unavailable_boot) + .setPositiveButton(android.R.string.ok, null) + .show(); + }); + return; + } + // The threshold only feeds the two dialog branches - don't pay the + // meminfo shell read when the verdict lets us enable directly. + var needBytes = (verdict != null && verdict == VERDICT_ALLOWED) + ? 0 : probeNeedBytes(model.memTotalKb()); + runOnUiThread(() -> { + if (isFinishing()) { + cmaBusy = false; + return; + } + if (verdict != null && verdict == VERDICT_ALLOWED) { + enableCmaDirect(snap); + } else if (verdict != null && verdict == VERDICT_DENIED) { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.hugepage_cma_probe_denied_title) + .setMessage(R.string.hugepage_cma_probe_denied_msg) + .setPositiveButton(R.string.hugepage_cma_probe_rerun, + (d, w) -> startCmaProbe()) + .setNegativeButton(android.R.string.cancel, + (d, w) -> cancelCmaEnable()) + .setOnCancelListener(d -> cancelCmaEnable()) + .show(); + } else { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.hugepage_cma_probe_needed_title) + .setMessage(getString(R.string.hugepage_cma_probe_needed_msg, + SizeUtils.formatSize(Math.max(0, needBytes)), + SizeUtils.formatSize(PROBE_POOL_BYTES))) + .setPositiveButton(R.string.hugepage_cma_probe_start, + (d, w) -> startCmaProbe()) + .setNegativeButton(android.R.string.cancel, + (d, w) -> cancelCmaEnable()) + .setOnCancelListener(d -> cancelCmaEnable()) + .show(); + } + }); + }); + } + + /** + * Probe already passed: restore the remembered with-CMA total and let a v3 + * acquire build the reservoir (only the mode-2/3 sweep runs Phase R; mode 1 + * is pool-only legacy). + * + *

The target is clamped to the module's {@code pool_want <= + * pool_want_with_cma} invariant, so a remembered total at or below the pool + * size enables with an empty reservoir - that is a real state (the probe + * itself produces it when the pool already holds everything), and the user + * then raises the now-visible with-CMA field. Only {@code 0} is impossible: + * it is the off sentinel. + */ + private void enableCmaDirect(@NonNull HugePageModel.Snapshot snap) { + runOnPool(() -> { + long target = Math.max(model.lastCmaTargetPages(), snap.targetIdeal); + if (target <= 0) // pool soft-disabled: fall back to the probe floor + target = pagesFor(Math.max(0, probeNeedBytes(model.memTotalKb()))); + boolean ok = target > 0 && model.saveCmaTarget(target).ok(); + if (ok) { + var s2 = model.state(); + if (s2.loaded && s2.deficit > 0) model.acquire(3); + } + boolean fOk = ok; + runOnUiThread(() -> { + cmaBusy = false; + Toast.makeText(this, fOk ? R.string.hugepage_cma_enabled + : R.string.hugepage_cma_toggle_failed, LENGTH_SHORT).show(); + if (!fOk) setCmaSwitch(false); + cmaInputLoaded = false; // reseed the CMA size input + refreshStatus(); + }); + }); + } + + /* ---- probe orchestration ---- */ + + /** Progress dialog handle for the probe worker thread. */ + private static final class ProbeUi { + @NonNull final androidx.appcompat.app.AlertDialog dialog; + @NonNull final TextView text; + + ProbeUi(@NonNull androidx.appcompat.app.AlertDialog dialog, @NonNull TextView text) { + this.dialog = dialog; + this.text = text; + } + } + + private static final int VERDICT_DENIED = 0; + private static final int VERDICT_ALLOWED = 1; + private static final int VERDICT_ABNORMAL = -1; + + /** max(RAM - 8 GiB, RAM x 0.4) in bytes; -1 when meminfo is unreadable. */ + private long probeNeedBytes(long memTotalKb) { + if (memTotalKb <= 0) return -1; + long total = memTotalKb * 1024; + return Math.max(total - PROBE_KEEP_BYTES, (long) (total * PROBE_MIN_RAM_FRACTION)); + } + + private static long pagesFor(long bytes) { + return (bytes + PAGE_SIZE - 1) / PAGE_SIZE; + } + + /** Kick off the probe worker; the switch stays under the flow's control. */ + private void startCmaProbe() { + new Thread(this::runCmaProbe, "hugepage-cma-probe").start(); + } + + /** + * The consumability probe (worker thread). Steps, per the module docs: + * precondition {@code avail >= max(RAM-7G, 40% RAM)} (guided acquire, else + * save-and-reboot); then {@code echo avail > pool_want_with_cma}, + * {@code echo 0 > pool_want} (the freed blocks flip to the reservoir), run + * {@code balloon 1536} and judge from how much CmaFree the pressure consumed + * whether this vendor lets user apps allocate from CMA. {@code pool_want} is + * restored on every path. + * + *

Only a pass is written to settings.prop ({@code cma_probe_result=1}) - + * an unreadable result asks the user, and enabling counts as a pass. A + * failure (or a decline) records nothing and clears any stale verdict, so + * the next launch can simply probe again. + */ + private void runCmaProbe() { + var cancelled = new AtomicBoolean(false); + ProbeUi ui = null; + boolean raised = false; // pool_want_with_cma raised by us + boolean zeroed = false; // pool_want emptied by us + long prevWant = -1; + try { + // Balloon pressure would squeeze (or LMK-kill) running VMs. + if (runningVmMemMib() > 0) { + probeFail(null, getString(R.string.hugepage_cma_vms_running)); + return; + } + var snap = model.state(); + prevWant = snap.targetIdeal; + long needBytes = probeNeedBytes(model.memTotalKb()); + if (needBytes <= 0) { + probeFail(null, getString(R.string.hugepage_cma_probe_failed_generic)); + return; + } + long needPages = pagesFor(needBytes); + // 1. How big a reservoir to measure against. It is NOT bounded by + // the configured pool: the probe empties the pool into the + // reservoir (pool_want=0 -> the module's shrink flips every avail + // block to CMA, instantly), so the only ceiling is the module's + // RAM-derived pool_size_max. A cap below what we want makes the + // verdict shakier, not impossible: warn and let the user go on. + long cap = model.poolSizeMax(); + long target = Math.max(needPages, Math.max(prevWant, snap.wantWithCma)); + if (cap > 0) target = Math.min(target, cap); + long goal = Math.min(needPages, target); + if (goal < needPages && !probeAsk( + getString(R.string.hugepage_cma_small_reservoir_title), + getString(R.string.hugepage_cma_small_reservoir_msg, + SizeUtils.formatSize(goal * PAGE_SIZE), + SizeUtils.formatSize(needPages * PAGE_SIZE)), + getString(R.string.hugepage_cma_probe_anyway))) { + probeCancelled(null, false); + return; + } + ui = probeProgressShow(reservoirStage(snap.cmaPool, goal), cancelled); + if (ui == null) { + probeCancelled(null, false); + return; + } + // 2. Build the reservoir. Already there (a previous run persisted the + // target and the module assembled it at boot) -> measure directly, + // touching nothing. + boolean built = snap.cmaPool >= goal; + if (!built) { + // Raise the total first: pool_want=0 would otherwise soft-disable + // the pool and hand its pages back to the buddy allocator instead + // of flipping them into the reservoir. Never lower an existing + // bigger total - that demolishes reservoir the device is lending + // out right now. + if (snap.wantWithCma < target) { + var w = model.writeWantWithCma(target); + if (!w.ok()) { + probeDismiss(ui); + probeUnavailable(getString(R.string.hugepage_cma_unavailable_write, + w.detail != null ? w.detail : "?")); + return; + } + } + raised = true; + // Empty the pool: its avail blocks flip to CMA at once (the fast + // path - a from-scratch sweep would hit the fragmentation wall). + // Only the live knob is written, so a reboot restores pool_want + // even if this app dies before the restore below. + var shrink = model.writeWant(0); + if (!shrink.ok()) { + probeRollback(); + probeDismiss(ui); + probeFail(null, getString(R.string.hugepage_cma_probe_failed_generic)); + return; + } + zeroed = true; + Thread.sleep(3000); // let the flips land + model.acquire(3); // best-effort top-up toward the target + built = waitReservoir(ui, goal, cancelled); + } + if (cancelled.get()) { + model.stopAcquire(); + probeRestore(prevWant, zeroed); + probeRollback(); + probeCancelled(ui, true); + return; + } + // Whatever actually assembled is what the balloon is judged against. + long reservoirPages = model.state().cmaPool; + if (reservoirPages <= 0) { + probeRestore(prevWant, zeroed); + probeRollback(); + probeDismiss(ui); + probeUnavailable(getString(R.string.hugepage_cma_unavailable_reservoir)); + return; + } + if (!built) { + // The runtime sweep hit the fragmentation wall. This is not a + // different failure from "it can't be built" - the module builds + // the reservoir first at init, on the cleanest memory there is, + // so the very same target simply works after a reboot. Persist + // it and pick the probe back up then (see the pending prompt) - + // or measure right now against the smaller reservoir that did + // get built, accepting a shakier verdict. + probeDismiss(ui); + ui = null; + int choice = probeAskChoice( + getString(R.string.hugepage_cma_reservoir_short_title), + getString(R.string.hugepage_cma_reservoir_short_msg, + SizeUtils.formatSize(reservoirPages * PAGE_SIZE), + SizeUtils.formatSize(goal * PAGE_SIZE)), + getString(R.string.hugepage_cma_save_reboot), + getString(R.string.hugepage_cma_probe_anyway)); + if (choice != CHOICE_NEGATIVE) { + // "I'll reboot, then probe", or dismissed. Nothing is saved: + // the probe assembles the reservoir out of the pool itself, + // so a fresh boot - where memory is unfragmented - simply + // lets the same run succeed. Undo our live writes and go. + probeRestore(prevWant, zeroed); + probeRollback(); + if (choice == CHOICE_POSITIVE) + probeToast(getString(R.string.hugepage_cma_reboot_hint)); + probeEndUi(false); + return; + } + // Probe anyway: a fresh progress dialog for the pressure stage. + ui = probeProgressShow( + getString(R.string.hugepage_cma_probe_running_balloon), cancelled); + if (ui == null) { + probeRestore(prevWant, zeroed); + probeRollback(); + probeCancelled(null, false); + return; + } + } + // 3. Pressure + judgment, measured against the reservoir that exists. + probeStage(ui, getString(R.string.hugepage_cma_probe_running_balloon)); + var out = model.runBalloon(BALLOON_FLOOR_MB, BALLOON_TIMEOUT_S); + if (cancelled.get()) { + probeRestore(prevWant, zeroed); + probeRollback(); + probeCancelled(ui, true); + return; + } + int verdict = judgeBalloon(out, reservoirPages); + probeDismiss(ui); + ui = null; + // A pass enables straight away. Anything else - a denial, or numbers + // that match neither verdict - is put to the user, because a probe + // can be wrong (a small reservoir, a vendor that only lets some + // allocation classes in). Enabling either way counts as a pass and + // is recorded, so the switch stops probing from now on; declining + // records nothing and clears any stale verdict, leaving the probe + // available next launch. + if (verdict == VERDICT_ALLOWED) { + model.setCmaProbeAllowed(); + probeEnableWithSmallPool(target); + probeToast(getString(R.string.hugepage_cma_probe_ok, + SizeUtils.formatSize(PROBE_POOL_BYTES))); + probeEndUi(true); + return; + } + boolean enable = verdict == VERDICT_DENIED + ? probeAsk(getString(R.string.hugepage_cma_probe_denied_title), + getString(R.string.hugepage_cma_probe_denied_result), + getString(R.string.hugepage_cma_probe_enable)) + : probeAskAbnormal(out, reservoirPages); + if (enable) { + model.setCmaProbeAllowed(); + probeEnableWithSmallPool(target); // also restores pool_want + probeToast(getString(R.string.hugepage_cma_enabled_pool, + SizeUtils.formatSize(PROBE_POOL_BYTES))); + probeEndUi(true); + } else { + probeRestore(prevWant, zeroed); + model.clearCmaProbeResult(); + model.saveCmaTarget(0); // demolishes the reservoir + if (zeroed) model.acquire(1); // refill the restored pool + probeEndUi(false); + } + } catch (InterruptedException e) { + probeRestore(prevWant, zeroed); + if (raised) probeRollback(); + probeCancelled(ui, ui != null); + } catch (Exception e) { + // Never leave the flow lock stuck: undo our writes and surface the + // error instead of a wedged switch. + Log.w(TAG, "CMA probe failed", e); + probeRestore(prevWant, zeroed); + if (raised) probeRollback(); + probeDismiss(ui); + probeFail(null, getString(R.string.hugepage_cma_probe_failed_generic)); + } + } + + /** Put {@code pool_want} back if the probe emptied it (live knob only). */ + private void probeRestore(long prevWant, boolean zeroed) { + if (zeroed && prevWant >= 0) model.writeWant(prevWant); + } + + /** + * The reboot half of the probe: the reservoir target survived a reboot with + * no verdict recorded, so the module has now built it on clean memory and + * the measurement can finally run. Asked once per visit; declining leaves + * the reservoir in place (it is still lent to apps) and the switch on. + */ + private void promptPendingProbe() { + if (probePromptShown || cmaBusy || isFinishing() || isDestroyed()) return; + probePromptShown = true; + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.hugepage_cma_probe_pending_title) + .setMessage(R.string.hugepage_cma_probe_pending_msg) + .setPositiveButton(R.string.hugepage_cma_probe_continue, (d, w) -> { + cmaBusy = true; + startCmaProbe(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + /** Undo the probe's only write: the raised total demolishes its reservoir. */ + private void probeRollback() { + model.writeWantWithCma(0); + } + + @NonNull + private String reservoirStage(long got, long goal) { + return getString(R.string.hugepage_cma_building, + SizeUtils.formatSize(got * PAGE_SIZE), SizeUtils.formatSize(goal * PAGE_SIZE)); + } + + /** + * The end state of an enabled probe: a {@value #PROBE_POOL_BYTES}-byte pool + * and the with-CMA total the probe assembled, persisted in one settings.prop + * rewrite so the next boot comes up the same way. This is the probe's first + * and only {@code pool_want} write: shrinking the pool hands its pages to + * the reservoir (the module's shrink path), and the v3 acquire then stages + * 512 MB back in and tops the reservoir up toward the total. + */ + private void probeEnableWithSmallPool(long total) { + model.saveTargets(PROBE_POOL_PAGES, total); + // v3: only the mode-2/3 sweep runs the reservoir-building Phase R. + model.acquire(3); + } + + /** + * Poll the reservoir toward {@code goal} pages, narrating progress. Returns + * true once {@code pool_cma} reaches it; false on stop/timeout/cancel. + */ + private boolean waitReservoir(@NonNull ProbeUi ui, long goal, + @NonNull AtomicBoolean cancelled) + throws InterruptedException { + for (int i = 0; i < 1800; i++) { // 30 min hard bound + if (cancelled.get()) return false; + var s = model.state(); + probeStage(ui, reservoirStage(s.cmaPool, goal)); + if (s.cmaPool >= goal) return true; + // Give the worker a few seconds to raise acquire_active before + // treating "not acquiring" as done-short. + if (!s.acquiring && i > 5) return false; + Thread.sleep(1000); + } + return false; + } + + /** + * Judge the balloon output against the reservoir that was built: pressure + * that consumed at least half of it means apps allocate from CMA; a tenth + * or less means they can't; anything between (or unparsable output) is + * unreadable and goes to the user. + */ + private int judgeBalloon(@Nullable Map out, long reservoirPages) { + if (out == null) return VERDICT_ABNORMAL; + long diffKb; + long heldMb; + try { + diffKb = Long.parseLong(out.getOrDefault("cma_diff_kb", "").trim()); + heldMb = Long.parseLong(out.getOrDefault("held_mb", "").trim()); + } catch (NumberFormatException e) { + return VERDICT_ABNORMAL; + } + long reservoirKb = reservoirPages * (PAGE_SIZE / 1024); + if (reservoirKb <= 0 || heldMb <= 0) return VERDICT_ABNORMAL; + if (diffKb >= reservoirKb / 2) return VERDICT_ALLOWED; + if (diffKb <= reservoirKb / 10) return VERDICT_DENIED; + return VERDICT_ABNORMAL; + } + + /* ---- probe worker <-> UI plumbing (all blocking helpers) ---- */ + + /** Blocking two-choice dialog; false on cancel/back/finish. */ + private boolean probeAsk(@NonNull String title, @NonNull String message, + @NonNull String positive) throws InterruptedException { + return probeAskChoice(title, message, positive, + getString(android.R.string.cancel)) == CHOICE_POSITIVE; + } + + /** {@link #probeAskChoice} outcomes; {@code CHOICE_NONE} = back/dismiss/gone. */ + private static final int CHOICE_NONE = 0; + private static final int CHOICE_POSITIVE = 1; + private static final int CHOICE_NEGATIVE = 2; + + /** + * Blocking dialog offering two named actions, with back/outside-tap as a + * third "neither" outcome. Both buttons are real choices - which is why + * neither is labelled Cancel by callers that need three ways out. + */ + private int probeAskChoice(@NonNull String title, @NonNull String message, + @NonNull String positive, @NonNull String negative) + throws InterruptedException { + var choice = new AtomicInteger(CHOICE_NONE); + var latch = new CountDownLatch(1); + runOnUiThread(() -> { + // isDestroyed covers rotation teardown (isFinishing stays false); + // the catch covers a window torn down mid-post - either way the + // latch MUST be counted or the worker blocks forever. + if (isFinishing() || isDestroyed()) { + latch.countDown(); + return; + } + try { + new MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(positive, (d, w) -> choice.set(CHOICE_POSITIVE)) + .setNegativeButton(negative, (d, w) -> choice.set(CHOICE_NEGATIVE)) + .setOnDismissListener(d -> latch.countDown()) + .show(); + } catch (Exception e) { + latch.countDown(); + } + }); + latch.await(); + return choice.get(); + } + + /** The "result unreadable - enable anyway?" dialog, with the raw numbers. */ + private boolean probeAskAbnormal(@Nullable Map out, long reservoirPages) + throws InterruptedException { + long diffKb = 0; + long heldMb = 0; + String stop = "?"; + if (out != null) { + try { + diffKb = Long.parseLong(out.getOrDefault("cma_diff_kb", "0").trim()); + } catch (NumberFormatException ignored) { + } + try { + heldMb = Long.parseLong(out.getOrDefault("held_mb", "0").trim()); + } catch (NumberFormatException ignored) { + } + stop = out.getOrDefault("stop_reason", "?"); + } + var choice = new AtomicInteger(0); + var latch = new CountDownLatch(1); + long fDiffKb = diffKb; + long fHeldMb = heldMb; + String fStop = stop; + runOnUiThread(() -> { + if (isFinishing() || isDestroyed()) { + latch.countDown(); + return; + } + try { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.hugepage_cma_probe_abnormal_title) + .setMessage(getString(R.string.hugepage_cma_probe_abnormal_msg, + SizeUtils.formatSize(fDiffKb * 1024), + SizeUtils.formatSize(reservoirPages * PAGE_SIZE), + SizeUtils.formatSize(fHeldMb * 1024 * 1024), + fStop)) + .setPositiveButton(R.string.hugepage_cma_probe_enable, + (d, w) -> choice.set(1)) + .setNegativeButton(R.string.hugepage_cma_probe_keep_off, null) + .setOnDismissListener(d -> latch.countDown()) + .show(); + } catch (Exception e) { + latch.countDown(); + } + }); + latch.await(); + return choice.get() == 1; + } + + /** Show the cancellable progress dialog; null when the activity is gone. */ + @Nullable + private ProbeUi probeProgressShow(@NonNull String initial, + @NonNull AtomicBoolean cancelled) + throws InterruptedException { + var holder = new java.util.concurrent.atomic.AtomicReference(); + var latch = new CountDownLatch(1); + runOnUiThread(() -> { + try { + if (isFinishing() || isDestroyed()) return; + float density = getResources().getDisplayMetrics().density; + var text = new TextView(this); + text.setText(initial); + var box = new LinearLayout(this); + box.setOrientation(LinearLayout.HORIZONTAL); + box.setGravity(android.view.Gravity.CENTER_VERTICAL); + int pad = Math.round(24 * density); + box.setPaddingRelative(pad, Math.round(16 * density), pad, 0); + var spinner = new ProgressBar(this); + box.addView(spinner, Math.round(32 * density), Math.round(32 * density)); + var lp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + lp.setMarginStart(Math.round(16 * density)); + box.addView(text, lp); + var dialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.hugepage_enable_cma) + .setView(box) + .setCancelable(false) + .setNegativeButton(android.R.string.cancel, null) + .create(); + dialog.show(); + // Cancel requests cooperative interruption; the worker decides + // when it is safe to stop, so the button must not dismiss the + // dialog. A running balloon ignores flags, so also kill it - + // its run() then returns quickly and the worker sees the flag. + var btn = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE); + if (btn != null) btn.setOnClickListener(v -> { + cancelled.set(true); + v.setEnabled(false); + runOnPool(() -> runList("pkill", "-f", + "gh-hugepage-reserve/balloon")); + }); + holder.set(new ProbeUi(dialog, text)); + } catch (Exception ignored) { + // window torn down mid-post: holder stays null = "activity gone" + } finally { + latch.countDown(); + } + }); + latch.await(); + return holder.get(); + } + + private void probeStage(@NonNull ProbeUi ui, @NonNull String msg) { + runOnUiThread(() -> ui.text.setText(msg)); + } + + private void probeDismiss(@Nullable ProbeUi ui) { + if (ui != null) runOnUiThread(ui.dialog::dismiss); + } + + private void probeToast(@NonNull String msg) { + runOnUiThread(() -> Toast.makeText(this, msg, Toast.LENGTH_LONG).show()); + } + + /** Wind the flow down: dismiss, switch off. Callers own the rollback. */ + private void probeCancelled(@Nullable ProbeUi ui, boolean toast) { + probeDismiss(ui); + if (toast) probeToast(getString(R.string.hugepage_cma_probe_cancelled)); + probeEndUi(false); + } + + /** Failure with a message dialog (or toast when {@code title} is null). */ + private void probeFail(@Nullable String title, @NonNull String message) { + runOnUiThread(() -> { + cmaBusy = false; + setCmaSwitch(false); + if (isFinishing()) return; + if (title == null) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + } else { + new MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + refreshStatus(); + }); + } + + private void probeUnavailable(@NonNull String detail) { + probeFail(getString(R.string.hugepage_cma_unavailable_title), detail); + } + + /** Release the flow lock and settle the switch to the final state. */ + private void probeEndUi(boolean enabled) { + runOnUiThread(() -> { + cmaBusy = false; + setCmaSwitch(enabled); + cmaInputLoaded = false; // reseed the with-CMA total field + loadPoolSize(); // a passing probe pins pool_want to 512 MB + refreshStatus(); + }); + } + private void confirmUnload() { new MaterialAlertDialogBuilder(this) .setTitle(R.string.hugepage_stop) diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageColor.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageColor.java index 19f71c3..7287fbe 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageColor.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageColor.java @@ -7,15 +7,32 @@ import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeSet; + import cn.classfun.droidvm.R; /** * Deterministic per-process colors for the hugepage usage bar and list icons. - * The same pid always maps to the same hue (stable across refreshes); in dark - * mode colors are light, in light mode they are dark, so they read against the - * surface either way. + * In dark mode colors are light, in light mode they are dark, so they read + * against the surface either way. + * + *

Hues are assigned by golden-angle steps over each pid's rank among the + * pids currently shown (ascending pid order), not over the raw pid. Keying + * the angle by pid could land two live VMs almost on the same hue (any pid + * difference near a multiple of 360/137.508 - e.g. 34 - wraps to within a few + * degrees), whereas consecutive ranks are always 137.5° apart, so no two of + * up to ~7 VMs come closer than ~32° (≥52° for four or fewer). Ranks + * are stable while the same VMs run - new pids are usually larger and append at + * the end - and both hugepage screens derive the map from the same owner list, + * so a VM keeps one color everywhere until the set of VMs itself changes. */ final class HugePageColor { + /** Golden angle in degrees: consecutive ranks land maximally apart. */ + private static final float GOLDEN_ANGLE = 137.508f; + private HugePageColor() { } @@ -25,9 +42,22 @@ static boolean isDark(Context ctx) { return mode == Configuration.UI_MODE_NIGHT_YES; } - /** Golden-angle hue spread keyed by pid, theme-aware saturation/value. */ - static int forPid(int pid, boolean dark) { - float hue = ((pid * 137.508f) % 360f + 360f) % 360f; + /** + * Assign a color to every pid in {@code pids} (duplicates collapse), keyed + * by ascending-pid rank. Pass the full pid list of the screen (not a + * filtered subset) so both hugepage screens agree on the ranks. + */ + @NonNull + static Map forPids(@NonNull Collection pids, boolean dark) { + var map = new LinkedHashMap(); + int rank = 0; + for (var pid : new TreeSet<>(pids)) map.put(pid, forRank(rank++, dark)); + return map; + } + + /** Golden-angle hue for one rank, theme-aware saturation/value. */ + static int forRank(int rank, boolean dark) { + float hue = ((rank * GOLDEN_ANGLE) % 360f + 360f) % 360f; float[] hsv = {hue, dark ? 0.50f : 0.72f, dark ? 0.90f : 0.55f}; return Color.HSVToColor(hsv); } @@ -36,4 +66,27 @@ static int forPid(int pid, boolean dark) { static int pending(@NonNull Context ctx) { return ContextCompat.getColor(ctx, R.color.hugepage_pending); } + + /** Reservoir portion currently occupied by other apps' allocations. */ + static int cmaUsed(@NonNull Context ctx) { + return ContextCompat.getColor(ctx, R.color.hugepage_cma_used); + } + + /** Reservoir portion still free in buddy. */ + static int cmaFree(@NonNull Context ctx) { + return ContextCompat.getColor(ctx, R.color.hugepage_cma_free); + } + + /** Available-pool portion not flippable to CMA as whole pageblocks. */ + static int availNonCma(@NonNull Context ctx) { + return ContextCompat.getColor(ctx, R.color.hugepage_avail_non_cma); + } + + /** + * Opaque gray for the synthetic "available" list row's icon - the bar's + * translucent track color is too faint as an icon tint. + */ + static int availIcon(boolean dark) { + return dark ? 0xFFB5B5B5 : 0xFF8A8A8A; + } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java index 30b7fb4..350283e 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java @@ -6,6 +6,7 @@ import static cn.classfun.droidvm.lib.utils.RunUtils.run; import static cn.classfun.droidvm.lib.utils.RunUtils.runList; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.joinNonEmpty; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import android.os.SystemClock; @@ -21,6 +22,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.LongSupplier; import java.util.function.Supplier; import java.util.regex.Pattern; @@ -56,6 +58,12 @@ final class HugePageModel { private static final String DISABLE_FILE = pathJoin(MAGISK_BASE, "disable"); private static final String SETTINGS_PROP = pathJoin(MAGISK_BASE, "settings.prop"); private static final String KO_PATH = pathJoin(MAGISK_BASE, "gh_hugepage_reserve.ko"); + /** The module's own preflight+insmod script (v10.1+); preferred load path. */ + private static final String LOAD_SCRIPT = pathJoin(MAGISK_BASE, "load.sh"); + /** ABI/BTF preflight helper the boot script feeds into insmod. */ + private static final String KAPI_CHECK = pathJoin(MAGISK_BASE, "kapi_check"); + /** insmod params are pasted into a shell line: allow only inert characters. */ + private static final Pattern SAFE_PARAM = Pattern.compile("[A-Za-z0-9_,.-]+"); /** A THP huge page is 2 MiB = 2048 KiB. */ private static final long KB_PER_PAGE = 2048; /** Default target if settings.prop has none (pages): 1024 x 2 MB = 2 GB. */ @@ -159,55 +167,117 @@ static final class UsageEntry { /** * Version-unified snapshot of module + pool state. {@code targetIdeal} is the * one "target" concept across versions: v7's {@code pool_want}, else v6's - * read-only {@code pool_target}, else current capacity. {@code deficit} is what - * is still missing toward it ({@code targetIdeal - free - lent}); the acquire - * buttons are usable exactly when {@code loaded && deficit > 0}. + * read-only {@code pool_target}, else current capacity. {@code deficit} is the + * work acquire still has - the larger of the pool shortfall and the reservoir + * shortfall (see {@link #state()}); the acquire buttons are usable exactly when + * {@code loaded && deficit > 0}. It is not the bar's waiting-to-acquire + * block, which each screen derives from its own segments. */ static final class Snapshot { - final boolean installed; // Magisk module files present - final boolean loaded; // insmod'd (sysfs node exists) - final boolean statsOk; // refill_stat was readable (false on a transient failure) - final boolean bootEnabled; // will load next boot (no Magisk disable file) - final long targetIdeal; // unified target (want): pool_want ?? pool_target ?? built - final long built; // pool_total (capacity assembled) - final long free; // pool_avail (in the pool now) - final long lent; // served (out to VMs); 0 if the module can't report it (v6) - final long deficit; // max(0, targetIdeal - free - lent) - final boolean acquiring; // an acquire worker is running - final int acquireMode; // which mode (1/2/3), or -1 if the module can't report it - final boolean hasPoolWant; // pool_want knob reported (v7 runtime-resizable) - final boolean softDisabled; // v7 pool_want <= 1 (reserve released, module stays loaded) + // Field defaults double as the not-loaded snapshot (the short ctor + // touches only installed/bootEnabled and leaves the rest as declared). + boolean installed = false; // Magisk module files present + boolean loaded = false; // insmod'd (sysfs node exists) + boolean statsOk = false; // refill_stat was readable (false on a transient failure) + boolean bootEnabled = false; // will load next boot (no Magisk disable file) + long targetIdeal = 0; // unified target (want): pool_want ?? pool_target ?? built + long built = 0; // pool_total (capacity assembled) + long free = 0; // pool_avail (in the pool now) + long lent = 0; // served (out to VMs); 0 if the module can't report it (v6) + long deficit = 0; // toward want-with-cma (v10 on) or targetIdeal + boolean acquiring = false; // an acquire worker is running + int acquireMode = -1; // which mode (1/2/3), or -1 if the module can't report it + boolean hasPoolWant = false; // pool_want knob reported (v7 runtime-resizable) + boolean softDisabled = false; // v7 pool_want <= 1 (reserve released, module stays loaded) + // v10 CMA reservoir (refill_stat additions); all -1 / 0 on pre-v10 modules. + boolean hasCma = false; // refill_stat reports pool_want_with_cma (v10 module) + long wantWithCma = 0; // total target incl. reservoir (pages); 0 = CMA off + long cmaPool = 0; // reservoir size (2 MB-page equivalents) + long availCmaAble = -1; // avail pages flippable as whole pageblocks; -1 unreported + int cmaPbOrder = -1; // pageblock order; -1 = CMA side off this boot // Raw display strings from refill_stat (pass-through, "-" when absent). - @NonNull final String state; - @NonNull final String totalServed; - @NonNull final String totalRefilled; - @NonNull final String activeVms; - @NonNull final String acquireStopReason; // why the last acquire stopped ("-" if unreported) - - private Snapshot(boolean installed, boolean loaded, boolean statsOk, boolean bootEnabled, - long targetIdeal, long built, long free, long lent, long deficit, - boolean acquiring, int acquireMode, boolean hasPoolWant, - boolean softDisabled, @NonNull String state, @NonNull String totalServed, - @NonNull String totalRefilled, @NonNull String activeVms, - @NonNull String acquireStopReason) { + @NonNull String state = "-"; + @NonNull String totalServed = "-"; + @NonNull String totalRefilled = "-"; + @NonNull String activeVms = "-"; + @NonNull String acquireStopReason = "-"; // why the last acquire stopped ("-" if unreported) + + /** + * Not-loaded snapshot: only the install + next-boot flags are known; + * every pool field keeps its default (the {@code loaded == false} view). + */ + Snapshot(boolean installed, boolean bootEnabled) { this.installed = installed; - this.loaded = loaded; - this.statsOk = statsOk; this.bootEnabled = bootEnabled; - this.targetIdeal = targetIdeal; - this.built = built; - this.free = free; - this.lent = lent; - this.deficit = deficit; - this.acquiring = acquiring; - this.acquireMode = acquireMode; - this.hasPoolWant = hasPoolWant; - this.softDisabled = softDisabled; - this.state = state; - this.totalServed = totalServed; - this.totalRefilled = totalRefilled; - this.activeVms = activeVms; - this.acquireStopReason = acquireStopReason; + } + + /** + * Loaded snapshot parsed straight from a {@code refill_stat} key=value + * map. {@code poolTargetFn} supplies v6's read-only {@code pool_target} + * lazily - it is only consulted when {@code pool_want} is absent. + */ + Snapshot(@NonNull Map s, boolean installed, boolean bootEnabled, + @NonNull LongSupplier poolTargetFn) { + this.installed = installed; + this.bootEnabled = bootEnabled; + this.loaded = true; + this.statsOk = !s.isEmpty(); // empty == the read failed transiently + this.hasPoolWant = s.containsKey("pool_want"); + this.built = getLong(s, "pool_total", 0); + this.free = getLong(s, "pool_avail", 0); + this.lent = getLong(s, "served", 0); + long rawWant = getLong(s, "pool_want", -1); + long want = rawWant; + if (want < 0) want = poolTargetFn.getAsLong(); // v6: separate read-only knob + if (want < 0) want = built; // last resort: current capacity + this.targetIdeal = want; + // v10 reservoir state; pre-v10 modules report none of these keys. + this.hasCma = s.containsKey("pool_want_with_cma"); + this.wantWithCma = getLong(s, "pool_want_with_cma", 0); + this.cmaPool = getLong(s, "pool_cma", 0); + this.availCmaAble = getLong(s, "pool_avail_cma_able", -1); + this.cmaPbOrder = (int) getLong(s, "cma_pb_order", -1); + // What acquire still has to do, mirroring the module's own "already at + // target" test (acquire_set): it runs when the POOL is short + // (avail + served < pool_want) OR the RESERVOIR is short + // (avail + served + pool_cma < pool_want_with_cma). Those are separate + // shortfalls: raising pool_want while the reservoir already covers the + // total leaves no total deficit, yet acquire must still stage pages in + // from the reservoir to fill the pool. Reporting only the total would + // grey out the acquire buttons exactly then. + long poolDeficit = Math.max(0, want - free - lent); + long reservoirDeficit = wantWithCma > 0 + ? Math.max(0, wantWithCma - free - lent - cmaPool) : 0; + this.deficit = Math.max(poolDeficit, reservoirDeficit); + this.acquiring = "1".equals(s.get("acquire_active")); + this.acquireMode = (int) getLong(s, "acquire_mode", -1); + this.softDisabled = hasPoolWant && rawWant <= 1; + this.state = s.getOrDefault("state", "-"); + this.totalServed = s.getOrDefault("total_served", "-"); + this.totalRefilled = s.getOrDefault("total_refilled", "-"); + this.activeVms = s.getOrDefault("active_vms", "-"); + this.acquireStopReason = s.getOrDefault("acquire_stop_reason", "-"); + } + + /** The v10 reservoir is on right now (module tracks a with-CMA total). */ + boolean cmaActive() { + return hasCma && wantWithCma > 0; + } + } + + /** + * Reservoir occupancy snapshot from the {@code cma_usage} knob (~1 s cache in + * the module). {@code ok == false} when the knob is absent/unreadable - the + * caller then shows the reservoir as one undivided block. Only the occupied + * amount is carried: the free part is derived from {@code pool_cma}. + */ + static final class CmaUsage { + final boolean ok; + final long usedMb; + + private CmaUsage(boolean ok, long usedMb) { + this.ok = ok; + this.usedMb = usedMb; } } @@ -224,29 +294,25 @@ Snapshot state() { boolean installed = existsSticky(MODULE_PROP); boolean bootEnabled = !shellCheckExists(DISABLE_FILE); boolean loaded = existsSticky(SYSFS_BASE); - if (!loaded) { - return new Snapshot(installed, false, false, bootEnabled, - 0, 0, 0, 0, 0, false, -1, false, false, "-", "-", "-", "-", "-"); - } + if (!loaded) return new Snapshot(installed, bootEnabled); var s = parseProp(safeRead(pathJoin(SYSFS_PARAMS, "refill_stat"))); - boolean statsOk = !s.isEmpty(); // empty == the read failed transiently - boolean hasWant = s.containsKey("pool_want"); - long built = getLong(s, "pool_total", 0); - long free = getLong(s, "pool_avail", 0); - long lent = getLong(s, "served", 0); - long rawWant = getLong(s, "pool_want", -1); - long want = rawWant; - if (want < 0) want = poolTarget(); // v6: separate read-only knob - if (want < 0) want = built; // last resort: current capacity - long deficit = Math.max(0, want - free - lent); - boolean acquiring = "1".equals(s.get("acquire_active")); - int mode = (int) getLong(s, "acquire_mode", -1); - boolean softDisabled = hasWant && rawWant <= 1; - return new Snapshot(installed, true, statsOk, bootEnabled, - want, built, free, lent, deficit, acquiring, mode, hasWant, softDisabled, - s.getOrDefault("state", "-"), s.getOrDefault("total_served", "-"), - s.getOrDefault("total_refilled", "-"), s.getOrDefault("active_vms", "-"), - s.getOrDefault("acquire_stop_reason", "-")); + return new Snapshot(s, installed, bootEnabled, this::poolTarget); + } + + /** Reservoir occupancy from {@code cma_usage}; {@code ok=false} when absent. */ + @NonNull + CmaUsage cmaUsage() { + var raw = safeRead(pathJoin(SYSFS_PARAMS, "cma_usage")); + if (raw.trim().isEmpty()) return new CmaUsage(false, 0); + // Tokens are key=value but not strictly one per line (blocks_* share a + // line), so scan word-wise instead of reusing the line parser. + var map = new LinkedHashMap(); + for (var tok : raw.split("[\\s\\n]+")) { + var parts = tok.split("=", 2); + if (parts.length == 2) map.put(parts[0].trim(), parts[1].trim()); + } + if (!map.containsKey("reservoir_mb")) return new CmaUsage(false, 0); + return new CmaUsage(true, getLong(map, "used_mb", 0)); } /** @@ -278,26 +344,6 @@ boolean koAvailable() { return existsSticky(pathJoin(SYSFS_PARAMS, "served_summary")); } - /** - * Save a new pool target of {@code pages}. Persists it to settings.prop (for - * the next boot) and applies it to the running pool if the live {@code pool_want} - * knob exists; on v6 (read-only {@code pool_target}) only the persist takes - * effect. Does not fire an acquire -- the caller drives that from the - * resulting {@link Snapshot#deficit}. - */ - @NonNull - Result saveSize(long pages) { - var persisted = writeSettings(pages); - if (!persisted.ok()) return Result.failed("settings", persisted.error); - // Best-effort live apply; the impl reports whether it actually took effect - // on the running pool ("pool_want") or only persisted for next boot - // ("settings" - v6's read-only target, or a rejected write). - var live = writeKnob("pool_want", Long.toString(pages)); - // Degraded when the live write didn't land (v6 read-only target, or a - // rejected write): only the next-boot persist took effect. - return Result.ok(live.ok() ? "pool_want" : "settings", !live.ok()); - } - /** * Bring the running pool up (true) or down (false), choosing the deepest action * the module supports: @@ -393,12 +439,211 @@ Result stopAcquire() { return t.ok() ? Result.ok("acquire") : Result.unsupported(t.error); } + /* ================================================================== */ + /* v10 CMA reservoir + consumability probe */ + /* ================================================================== */ + + /** + * The app-side probe verdict recorded in settings.prop as + * {@code cma_probe_result}: {@code 1} = apps can consume the reservoir, + * {@code 0} = they can't (the boot script then keeps the whole CMA side + * cold), {@code null} = the probe never ran. + */ + @Nullable + Integer cmaProbeResult() { + var v = parseProp(safeRead(SETTINGS_PROP)).get("cma_probe_result"); + if (v == null) return null; + try { + return Integer.parseInt(v.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Record a passed probe (see {@link #cmaProbeResult}). Only success + * is ever persisted: a failed or inconclusive probe leaves no verdict, so + * the next app launch can simply probe again. + */ + @NonNull + Result setCmaProbeAllowed() { + var changes = new LinkedHashMap(); + changes.put("cma_probe_result", "1"); + var t = updateSettings(changes); + return t.ok() ? Result.ok("settings") : Result.failed("settings", t.error); + } + + /** + * Drop any recorded verdict. Beyond "forget a failure", this un-sticks a + * legacy {@code cma_probe_result=0}: the boot script hands the module -1 + * preflight values while that key is 0, which kills the CMA side for the + * whole boot and makes a re-probe impossible until it is gone. + */ + @NonNull + Result clearCmaProbeResult() { + var changes = new LinkedHashMap(); + changes.put("cma_probe_result", null); // null value = remove the key + var t = updateSettings(changes); + return t.ok() ? Result.ok("settings") : Result.failed("settings", t.error); + } + + /** + * The last non-zero with-CMA total (pages) the user ran with, kept under an + * app-owned settings.prop key so switching CMA off (which must persist + * {@code pool_want_with_cma=0} for the boot script) doesn't forget the size. + */ + long lastCmaTargetPages() { + var s = parseProp(safeRead(SETTINGS_PROP)); + var v = s.get("pool_want_with_cma_last"); + if (v != null) try { + return Long.parseLong(v.trim()); + } catch (NumberFormatException ignored) { + } + return 0; + } + + /** + * Persist the with-CMA total for the next boot and best-effort apply it to + * the running module. A non-zero target is also remembered under the + * {@code _last} key for the next re-enable. Like {@link #saveTargets}, the + * result reports whether the live write landed ({@code degraded} when only + * the persist took effect). + */ + @NonNull + Result saveCmaTarget(long pages) { + var persisted = updateSettings(cmaTargetChanges(pages)); + if (!persisted.ok()) return Result.failed("settings", persisted.error); + var live = writeKnob("pool_want_with_cma", Long.toString(pages)); + return Result.ok(live.ok() ? "pool_want_with_cma" : "settings", !live.ok()); + } + + /** + * Persist pool target and with-CMA total in ONE settings.prop rewrite, then + * apply both live knobs ({@code pool_want} first - a value above the old + * with-CMA total drags it up, and the second write then pins it exact). + * {@code withCma < 0} leaves the CMA keys untouched and only sets the pool + * target (persisted under both {@code pool_want} and legacy + * {@code pool_target}; on v6 only the persist takes effect). Does not + * fire an acquire -- the caller drives that from {@link Snapshot#deficit}. + */ + @NonNull + Result saveTargets(long pages, long withCma) { + // Invariant (module plan.md sec.1): pool_want <= pool_want_with_cma. The kernel + // clamps a low live write itself, but the persisted pair must agree + // too or the next boot would insmod inconsistent targets. + if (withCma >= 0) withCma = Math.max(withCma, pages); + var changes = new LinkedHashMap(); + changes.put("pool_want", Long.toString(pages)); + changes.put("pool_target", Long.toString(pages)); + if (withCma >= 0) changes.putAll(cmaTargetChanges(withCma)); + var persisted = updateSettings(changes); + if (!persisted.ok()) return Result.failed("settings", persisted.error); + var live = writeKnob("pool_want", Long.toString(pages)); + boolean liveOk = live.ok(); + if (withCma >= 0) + liveOk &= writeKnob("pool_want_with_cma", Long.toString(withCma)).ok(); + return Result.ok(liveOk ? "pool_want" : "settings", !liveOk); + } + + @NonNull + private static Map cmaTargetChanges(long pages) { + var changes = new LinkedHashMap(); + changes.put("pool_want_with_cma", Long.toString(pages)); + if (pages > 0) changes.put("pool_want_with_cma_last", Long.toString(pages)); + return changes; + } + + /** Live {@code pool_want_with_cma} write only - the probe's first step. */ + @NonNull + Result writeWantWithCma(long pages) { + var t = writeKnob("pool_want_with_cma", Long.toString(pages)); + return t.ok() ? Result.ok("pool_want_with_cma") + : Result.failed("pool_want_with_cma", t.error); + } + + /** + * Live {@code pool_want} write only (no settings.prop persist), so a value + * written here is undone by a reboot. The probe uses it to empty the pool + * into the reservoir and to put it back afterwards. + */ + @NonNull + Result writeWant(long pages) { + var t = writeKnob("pool_want", Long.toString(pages)); + return t.ok() ? Result.ok("pool_want") : Result.failed("pool_want", t.error); + } + + /** MemTotal from /proc/meminfo in KiB, or -1 if unreadable. */ + long memTotalKb() { + return meminfoKb("MemTotal"); + } + + /** + * The module's RAM-derived cap on the targets (pages), read-only: + * {@code min(ram - min(ram/2, 6G), 24G)}. Both {@code pool_want} and + * {@code pool_want_with_cma} are clamped to it, so it bounds how much + * reservoir can sit on top of a given pool. -1 when unreadable (pre-v7). + */ + long poolSizeMax() { + try { + var v = shellReadFile(pathJoin(SYSFS_PARAMS, "pool_size_max")).trim(); + if (!v.isEmpty()) return Long.parseLong(v); + } catch (Exception ignored) { + } + return -1; + } + + private long meminfoKb(@NonNull String key) { + var prefix = fmt("%s:", key); + for (var line : safeRead("/proc/meminfo").split("\n")) { + if (!line.startsWith(prefix)) continue; + var digits = NON_DIGITS.matcher(line).replaceAll(""); + if (!digits.isEmpty()) try { + return Long.parseLong(digits); + } catch (NumberFormatException ignored) { + } + } + return -1; + } + + /** + * Run the module-shipped {@code balloon} pressure tool (see tools/balloon.c): + * it anon-balloons until MemAvailable drops under {@code floorMb} and prints + * {@code cma_before_kb / cma_after_kb / cma_diff_kb / held_mb / stop_reason}. + * Supervised with {@code timeout} (falling back to unsupervised where the + * command is missing) because on a kernel that does NOT let anon consume CMA + * the floor can fire very late or never. Returns the parsed key=value output, + * or {@code null} when the tool is absent, was killed, or printed nothing + * usable - the caller treats that as an unreadable verdict. + */ + @Nullable + Map runBalloon(long floorMb, long timeoutSec) { + var bin = pathJoin(MAGISK_BASE, "balloon"); + if (!existsSticky(bin)) return null; + var r = run("chmod 755 %s && timeout %d %s %d", + escapedString(bin), timeoutSec, escapedString(bin), floorMb); + if (r.getCode() == 127) // no timeout applet on this ROM + r = run("%s %d", escapedString(bin), floorMb); + if (!r.isSuccess()) return null; + var map = parseProp(r.getOutString()); + return map.containsKey("cma_diff_kb") ? map : null; + } + /* ================================================================== */ /* Ladder plumbing (internal) */ /* ================================================================== */ - /** insmod ladder: both keys, then {@code pool_want=} (v7), {@code pool_target=} (v6), bare. */ - private enum LoadImpl { BOTH, POOL_WANT, POOL_TARGET, BARE } + /** + * insmod ladder, richest first. {@code load.sh} is the module's own + * preflight+insmod (v10+) and needs nothing from us. The remaining rungs + * exist only for the released modules that predate it: v9 wants its ABI + * guard, v6..v8 take size keys alone. Each rung drops the parameter group an + * older module wouldn't recognise. + */ + private enum LoadImpl { + SCRIPT, + GUARD_BOTH, GUARD_WANT, GUARD_TARGET, // ABI guard (v9) + BOTH, POOL_WANT, POOL_TARGET, BARE // no guard (v6/v7/v8) + } /** Marker for single-implementation actions. */ private enum Only { DEFAULT } @@ -440,8 +685,32 @@ private static Usage usageOf(@NonNull List>> log) { @NonNull private List> loadLadder(long pages) { var log = new ArrayList>(); + // Best rung: the module's own load.sh - the exact preflight+insmod the + // boot script performs, so a runtime enable reproduces the boot-time + // configuration and future preflight changes need no app change. It + // reads the size from settings.prop, which is where `pages` came from. + if (existsSticky(LOAD_SCRIPT) && rung(null, log, LoadImpl.SCRIPT, () -> { + var r = run("sh %s", escapedString(LOAD_SCRIPT)); + return (r.isSuccess() && existsSticky(SYSFS_BASE)) + ? Try.ok(LoadImpl.SCRIPT, null) + : Try.fail(LoadImpl.SCRIPT, reason(r, "load.sh")); + })) return log; var w = fmt("pool_want=\"%d\"", pages); var t = fmt("pool_target=\"%d\"", pages); + // No load.sh: a released module that predates it (v6..v9). Those have no + // CMA parameters at all, so there is nothing to reconstruct here - only + // v9's ABI guard, without which an ABI-drifted symbol can kCFI-panic on + // first call. Every v10+ preflight lives in load.sh alone, so it can + // never drift from what this app passes. + var guard = kapiGuardArg(); + if (!guard.isEmpty()) { + if (rung(null, log, LoadImpl.GUARD_BOTH, + () -> insmod(LoadImpl.GUARD_BOTH, joinNonEmpty(" ", guard, w, t)))) return log; + if (rung(null, log, LoadImpl.GUARD_WANT, + () -> insmod(LoadImpl.GUARD_WANT, joinNonEmpty(" ", guard, w)))) return log; + if (rung(null, log, LoadImpl.GUARD_TARGET, + () -> insmod(LoadImpl.GUARD_TARGET, joinNonEmpty(" ", guard, t)))) return log; + } // Pass BOTH size params first. A lenient kernel silently ignores the param // the module doesn't have, so it still gets the right size via the one it // does - pool_want (v7) or pool_target (v6). This is essential: on a v6 @@ -450,7 +719,7 @@ private List> loadLadder(long pages) { // Strict kernels reject the unknown param, so fall back to each key alone, // then a bare (default-size) load. if (rung(null, log, LoadImpl.BOTH, - () -> insmod(LoadImpl.BOTH, fmt("%s %s", w, t)))) return log; + () -> insmod(LoadImpl.BOTH, joinNonEmpty(" ", w, t)))) return log; if (rung(null, log, LoadImpl.POOL_WANT, () -> insmod(LoadImpl.POOL_WANT, w))) return log; if (rung(null, log, LoadImpl.POOL_TARGET, @@ -459,6 +728,29 @@ private List> loadLadder(long pages) { return log; } + /** + * v9's ABI guard, read from the module's {@code kapi_check} helper: it + * compares the running kernel's real symbol signatures (from vmlinux BTF) + * against what this .ko expects and names the drifted ones, which insmod + * then leaves unresolved (their feature returns -ENOSYS) instead of + * kCFI-panicking on first call. "" when the helper is absent (v6..v8) or + * nothing drifted - fail-open, exactly like the v9 boot script. + */ + @NonNull + private String kapiGuardArg() { + if (!existsSticky(KAPI_CHECK)) return ""; + var out = run("%s /sys/kernel/btf/vmlinux", escapedString(KAPI_CHECK)); + for (var line : out.getOutString().split("\n")) { + line = line.trim(); + if (!line.startsWith("disable=")) continue; + var v = line.substring("disable=".length()).trim(); + // A symbol-name list, pasted into a shell line: refuse anything else. + if (!v.isEmpty() && SAFE_PARAM.matcher(v).matches()) + return fmt("disable_kapi=%s", v); + } + return ""; + } + private static Try insmod(@NonNull LoadImpl impl, @Nullable String arg) { var r = (arg == null) ? run("insmod %s", escapedString(KO_PATH)) @@ -512,11 +804,52 @@ private static Try writeKnobTry( /** Persist the target to settings.prop under both keys (whichever loader reads). */ private static Try writeSettings(long pages) { - var a = run("echo 'pool_want=%s' > %s", pages, SETTINGS_PROP); - var b = run("echo 'pool_target=%s' >> %s", pages, SETTINGS_PROP); - return (a.isSuccess() && b.isSuccess()) - ? Try.ok(Only.DEFAULT, null) - : Try.fail(Only.DEFAULT, reason(a.isSuccess() ? b : a, "settings.prop")); + var changes = new LinkedHashMap(); + changes.put("pool_want", Long.toString(pages)); + changes.put("pool_target", Long.toString(pages)); + return updateSettings(changes); + } + + /** Serializes settings.prop read-modify-write cycles within this process. */ + private static final Object SETTINGS_LOCK = new Object(); + + /** + * Read-modify-write settings.prop: apply {@code changes} (a null value + * removes that key) and keep every other key (the file also carries + * app-owned CMA state - {@code pool_want_with_cma}, {@code cma_probe_result} + * - that a blind rewrite would wipe). The boot script {@code source}s the + * file, so lines stay plain {@code key=value}. Locked so concurrent writers + * (the probe worker vs a pool-size save) can't interleave their read/write + * pairs and drop each other's keys. + */ + private static Try updateSettings(@NonNull Map changes) { + synchronized (SETTINGS_LOCK) { + String raw; + try { + raw = shellReadFile(SETTINGS_PROP); + } catch (Exception e) { + // A missing file legitimately starts empty; an EXISTING file that + // failed to read must abort - rewriting from an empty map would + // silently drop every other persisted key (probe verdict, CMA + // targets) on a transient root hiccup. + if (existsSticky(SETTINGS_PROP)) + return Try.fail(Only.DEFAULT, "settings.prop: read failed"); + raw = ""; + } + var s = parseProp(raw); + for (var e : changes.entrySet()) { + if (e.getValue() == null) s.remove(e.getKey()); + else s.put(e.getKey(), e.getValue()); + } + var content = new StringBuilder(); + for (var e : s.entrySet()) + content.append(e.getKey()).append('=').append(e.getValue()).append('\n'); + var r = run("printf '%%s' %s > %s", + escapedString(content.toString()), SETTINGS_PROP); + return r.isSuccess() + ? Try.ok(Only.DEFAULT, null) + : Try.fail(Only.DEFAULT, reason(r, "settings.prop")); + } } @NonNull diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcess.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcess.java index c8ebec8..406faec 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcess.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcess.java @@ -35,13 +35,36 @@ public final class HugePageProcess { public final int color; /** True for the synthetic "unattributed" pool usage row (no actions). */ public final boolean unknown; - /** True for the synthetic "waiting for acquire" deficit row (acquire btn). */ + /** True for the synthetic "waiting for acquire" deficit row. */ public final boolean acquire; + /** + * Free-text detail line for synthetic rows (e.g. the available row's + * cma-able/non-cma-able breakdown); null hides the line. + */ + @Nullable + public final String detail; + /** + * Draw the three acquire buttons on this row. Normally the deficit row owns + * them, but that row is hidden once nothing is waiting to be acquired - and + * acquire can still have work (staging reservoir pages into a grown pool), + * so the CMA row picks them up then. + */ + public final boolean acquireSlots; public HugePageProcess( int pid, @NonNull String comm, long servedPages, long thpKb, char state, @Nullable String vmName, boolean alive, int color, boolean unknown, boolean acquire + ) { + this(pid, comm, servedPages, thpKb, state, vmName, alive, + color, unknown, acquire, null, acquire); + } + + public HugePageProcess( + int pid, @NonNull String comm, long servedPages, + long thpKb, char state, @Nullable String vmName, boolean alive, + int color, boolean unknown, boolean acquire, @Nullable String detail, + boolean acquireSlots ) { this.pid = pid; this.comm = comm; @@ -53,6 +76,8 @@ public HugePageProcess( this.color = color; this.unknown = unknown; this.acquire = acquire; + this.detail = detail; + this.acquireSlots = acquireSlots; } /** THP occupancy expressed in 2MiB pages (-1 if unknown). */ diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessActivity.java index 20a4799..53f4e98 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessActivity.java @@ -51,6 +51,10 @@ public final class HugePageProcessActivity extends AppCompatActivity private static final long ACQUIRE_POLL_MS = 500; private static final int ACQUIRE_POLL_MAX = 1200; // ~10 min ceiling private static final long HUGE_PAGE_BYTES = 2L * 1024 * 1024; + // Fake pids for the synthetic list rows (adapter uses pid as a stable id; + // pid 0 already belongs to the "waiting for acquire" row). + private static final int PID_AVAIL = -2; + private static final int PID_CMA = -3; private final Handler handler = new Handler(Looper.getMainLooper()); private final HugePageModel model = new HugePageModel(); @@ -218,7 +222,49 @@ private void refresh() { int kernelMode = snap.acquireMode; long holdKb = snap.loaded ? snap.free * 2048 : 0; // avail long wantKb = snap.loaded ? snap.targetIdeal * 2048 : tracedKb; // want - long deficitKb = wantKb - holdKb - tracedKb; + // v10 reservoir: mirror the status screen - the bar's denominator + // becomes pool_want_with_cma and the reservoir counts as filled. + boolean cmaOn = snap.loaded && snap.cmaActive(); + var cmaUsage = cmaOn ? model.cmaUsage() : null; + long cmaKb = cmaOn ? snap.cmaPool * 2048 : 0; + long cmaOtherKb = (cmaUsage != null && cmaUsage.ok) + ? Math.min(cmaKb, cmaUsage.usedMb * 1024) : 0; + long cmaFreeKb = cmaKb - cmaOtherKb; + long availCmaAbleKb = (cmaOn && snap.availCmaAble >= 0) + ? Math.min(holdKb, snap.availCmaAble * 2048) : -1; + long availNonCmaKb = availCmaAbleKb >= 0 ? holdKb - availCmaAbleKb : 0; + long barWantKb = cmaOn ? snap.wantWithCma * 2048 : wantKb; + long deficitKb = barWantKb - holdKb - tracedKb - cmaKb; + // Synthetic rows mirroring the bar blocks: available (with its + // cma-able / non-cma-able breakdown) and the CMA reservoir + // (free / other apps). Distinct negative pids keep the adapter's + // stable ids unique (the deficit row already owns pid 0). + // The deficit row owns the acquire buttons; when it is hidden but + // acquire still has work (a grown pool_want the reservoir can stage + // in, so nothing is "waiting to be acquired"), the CMA row shows them. + boolean acquireOnCma = cmaOn && deficitKb <= 0 && snap.deficit > 0; + if (snap.loaded) { + list.add(new HugePageProcess( + PID_AVAIL, getString(R.string.hugepage_bar_available), -1, + holdKb, '?', null, true, HugePageColor.availIcon(dark), + true, false, + availCmaAbleKb >= 0 ? getString( + R.string.hugepage_proc_avail_detail, + SizeUtils.formatSize(availCmaAbleKb * 1024), + SizeUtils.formatSize(availNonCmaKb * 1024)) : null, + false)); + } + if (cmaOn) { + list.add(new HugePageProcess( + PID_CMA, getString(R.string.hugepage_bar_cma), -1, + cmaKb, '?', null, true, HugePageColor.cmaFree(this), + true, false, + (cmaUsage != null && cmaUsage.ok) ? getString( + R.string.hugepage_proc_cma_detail, + SizeUtils.formatSize(cmaFreeKb * 1024), + SizeUtils.formatSize(cmaOtherKb * 1024)) : null, + acquireOnCma)); + } if (deficitKb > 0) { list.add(new HugePageProcess( 0, getString(R.string.hugepage_proc_deficit), -1, deficitKb, @@ -230,6 +276,10 @@ private void refresh() { var fKoNow = koNow; var fTraced = tracedKb; var fHold = holdKb; + var fAvailNonCma = availCmaAbleKb >= 0 ? availNonCmaKb : 0; + var fCma = cmaKb; + var fCmaFree = cmaFreeKb; + var fBarWant = barWantKb; var fWant = wantKb; var fAcquiring = kernelAcquiring; var fMode = kernelMode; @@ -249,7 +299,8 @@ private void refresh() { int uiMode = fAcquiring ? fMode : (acquireWatching ? acquireWatchMode : -1); adapter.setAcquireState(fAcquiring || acquireWatching, uiMode); - showResult(finalList, fTraced, fHold, fWant, finalEmpty); + showResult(finalList, fTraced, fHold, fAvailNonCma, + fCma, fCmaFree, fBarWant, fWant, finalEmpty); }); }); } @@ -262,12 +313,18 @@ private List buildFromEntries( // Best-effort pid -> VM name (running VMs only); for KO rows this labels // the process, for scan rows it echoes the name the entry already carries. var vmMap = model.vmNames(false); + // Rank-based colors over the full entry list (see HugePageColor). + var pids = new ArrayList(); + for (var e : entries) pids.add(e.pid); + var colorMap = HugePageColor.forPids(pids, dark); var result = new ArrayList(); for (var e : entries) { + Integer color = colorMap.get(e.pid); result.add(new HugePageProcess( e.pid, e.comm, -1, e.pages * 2048, e.state, vmMap.getOrDefault(e.pid, e.comm), e.alive, - HugePageColor.forPid(e.pid, dark), false, false)); + color != null ? color : HugePageColor.forRank(0, dark), + false, false)); } result.sort((a, b) -> Long.compare(b.thpKb, a.thpKb)); return result; @@ -275,17 +332,21 @@ private List buildFromEntries( private void showResult( @NonNull List list, long usedKb, long availKb, + long availNonCmaKb, long cmaKb, long cmaFreeKb, long barWantKb, long wantKb, @NonNull String emptyText ) { if (isFinishing()) return; firstRefresh = false; adapter.submit(list); - // Segmented bar (shared builder): VM segments (used), then the available - // portion as a track-coloured gap, then the "waiting for acquire" deficit - // pinned flush right. This screen's bar is a plain unlabelled meter. + // Segmented bar, synced with the status screen: VM segments (used), the + // available block (with its non-cma-able left sub-split), the CMA + // reservoir block ([free|other apps] sub-split), then the "waiting for + // acquire" deficit flush right. Still a plain unlabelled meter here. + // The synthetic available/CMA rows ("unknown") are drawn via those + // dedicated blocks, not as used segments. int used = 0; - for (var p : list) if (!p.acquire) used++; + for (var p : list) if (!p.acquire && !p.unknown) used++; int[] usedColors = new int[used]; float[] usedValues = new float[used]; int deficitColor = HugePageColor.pending(this); @@ -295,14 +356,26 @@ private void showResult( if (p.acquire) { // deficit rows: colour + value deficitColor = p.color; deficit += Math.max(0, p.thpKb); - } else { // used: one segment per VM + } else if (!p.unknown) { // used: one segment per VM usedColors[u] = p.color; usedValues[u] = Math.max(0, p.thpKb); u++; } } - segBar.setStorage(usedColors, usedValues, null, - availKb, null, deficitColor, deficit, null, wantKb); + var spec = new SegmentedBar.StorageSpec(); + spec.usedColors = usedColors; + spec.usedValues = usedValues; + spec.avail = availKb; + spec.availNonCma = availNonCmaKb; + spec.availNonCmaColor = HugePageColor.availNonCma(this); + spec.cmaFree = cmaFreeKb; + spec.cmaFreeColor = HugePageColor.cmaFree(this); + spec.cmaOther = cmaKb - cmaFreeKb; + spec.cmaOtherColor = HugePageColor.cmaUsed(this); + spec.deficitColor = deficitColor; + spec.deficit = deficit; + spec.want = barWantKb; + segBar.setStorage(spec); // 2x2 caption: used / available on top, total / pool-size below. // Total = the real held reserve (used + avail = owned + traced), shown @@ -417,15 +490,11 @@ public void onAcquire(int mode) { if (finalSnap != null && finalSnap.loaded) { // Achieved = owned + traced (pool_avail + served), NOT // pool_total: after a shrink that left served pages out, - // pool_total under-counts the pages VMs already hold. - long got = finalSnap.free + finalSnap.lent; - long want = finalSnap.targetIdeal; - String msg = got >= want - ? getString(R.string.hugepage_proc_acquire_full, - fmtPages(want)) - : getString(R.string.hugepage_proc_acquire_partial, - fmtPages(got), fmtPages(want)); - Toast.makeText(this, msg, LENGTH_LONG).show(); + // pool_total under-counts the pages VMs already hold. With + // the reservoir on the message reports the with-CMA total too. + Toast.makeText(this, + HugePageActivity.acquireDoneMessage(this, finalSnap), + LENGTH_LONG).show(); } else { Toast.makeText(this, R.string.hugepage_proc_acquire_done, LENGTH_SHORT).show(); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessAdapter.java index 39f1388..1e32064 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessAdapter.java @@ -111,17 +111,24 @@ public void onBindViewHolder(@NonNull Holder holder, int position) { } if (p.unknown || p.acquire) { - // Synthetic rows: "unattributed" (no action) and "waiting for - // acquire" (deficit, with an Acquire button). + // Synthetic rows: "available"/"CMA"/"unattributed" (no action) and + // "waiting for acquire" (deficit, with an Acquire button). The + // served line doubles as their free-text detail (breakdowns). holder.title.setText(p.comm); - holder.served.setVisibility(GONE); + if (p.detail != null) { + holder.served.setVisibility(VISIBLE); + holder.served.setText(p.detail); + } else { + holder.served.setVisibility(GONE); + } holder.stateView.setVisibility(GONE); holder.btnStack.setVisibility(GONE); holder.btnStack.setOnClickListener(null); holder.itemView.setAlpha(1f); holder.btnKill.setVisibility(GONE); holder.btnKill.setOnClickListener(null); - if (p.acquire) { + // Usually the deficit row; the CMA row when that one is hidden. + if (p.acquireSlots) { bindAcquireSlot(holder.btnAcquireV1, holder.progressAcquireV1, 1); bindAcquireSlot(holder.btnAcquireV2, holder.progressAcquireV2, 2); bindAcquireSlot(holder.btnAcquireV3, holder.progressAcquireV3, 3); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/SegmentedBar.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/SegmentedBar.java index a5c864f..e81bcd8 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/SegmentedBar.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/SegmentedBar.java @@ -30,6 +30,13 @@ public final class SegmentedBar extends View { private int[] colors = new int[0]; private float[] values = new float[0]; + // Optional per-segment sub-split: the LEFT subLeftValues[i] share of segment + // i is drawn in subLeftColors[i], the rest in colors[i]. Pure color detail - + // the segment stays ONE logical block with ONE (centred) label. + @Nullable + private float[] subLeftValues = null; + @Nullable + private int[] subLeftColors = null; @Nullable private String[] labels = null; // Per-segment label data pre-split, pre-measured and pre-coloured in setData, @@ -78,9 +85,19 @@ public void setData(@NonNull int[] colors, @NonNull float[] values, float total) /** Apple-style: each wide-enough segment draws {@code labels[i]} inside it. */ public void setData(@NonNull int[] colors, @NonNull float[] values, @Nullable String[] labels, float total) { + setData(colors, values, labels, null, null, total); + } + + /** Full form: labels plus optional per-segment left sub-splits (see fields). */ + public void setData(@NonNull int[] colors, @NonNull float[] values, + @Nullable String[] labels, + @Nullable float[] subLeftValues, @Nullable int[] subLeftColors, + float total) { this.colors = colors; this.values = values; this.labels = labels; + this.subLeftValues = subLeftValues; + this.subLeftColors = subLeftColors; this.total = total; prepareLabels(); invalidate(); @@ -116,6 +133,36 @@ private void prepareLabels() { } } + /** + * Parameters for the storage-style bar - one holder instead of the long + * positional argument list. Only {@code usedColors}/{@code usedValues}/ + * {@code want} are always meaningful; the CMA and {@code availNonCma} fields + * stay 0 for the classic [used][available][waiting] bar. + */ + public static final class StorageSpec { + /** one colour per used segment */ + @NonNull public int[] usedColors = new int[0]; + /** one value per used segment (same length as usedColors) */ + @NonNull public float[] usedValues = new float[0]; + /** per-segment labels, or {@code null} for the plain (unlabelled) meter */ + @Nullable public String[] usedLabels; + public float avail; + @Nullable public String availLabel; + /** left sub-split of the available block (non-cma-able share) */ + public float availNonCma; + public int availNonCmaColor; + public float cmaFree; + @Nullable public String cmaFreeLabel; + public int cmaFreeColor; + public float cmaOther; + @Nullable public String cmaOtherLabel; + public int cmaOtherColor; + public int deficitColor; + public float deficit; + @Nullable public String deficitLabel; + public float want; + } + /** * Assemble and set a storage-style bar shared by both hugepage screens: * the {@code used} segments, then the available portion as a track-coloured @@ -135,26 +182,59 @@ public void setStorage( int deficitColor, float deficit, @Nullable String deficitLabel, float want ) { - int n = usedValues.length; - boolean withLabels = usedLabels != null; - int[] c = new int[n + 2]; - float[] v = new float[n + 2]; - String[] l = withLabels ? new String[n + 2] : null; + var s = new StorageSpec(); + s.usedColors = usedColors; + s.usedValues = usedValues; + s.usedLabels = usedLabels; + s.avail = avail; + s.availLabel = availLabel; + s.deficitColor = deficitColor; + s.deficit = deficit; + s.deficitLabel = deficitLabel; + s.want = want; + setStorage(s); + } + + /** + * v10 variant of {@link #setStorage}: the bar reads + * [VMs][available][CMA free][CMA lent][waiting]. The two CMA parts are + * ordinary labelled segments; only available carries the pure-color + * left sub-split [non-cma-able | normal] (its left {@code availNonCma} + * share draws in {@code availNonCmaColor}) under a single label. Leave the + * CMA values and {@code availNonCma} at 0 for the classic bar (the plain + * overload does exactly that). + */ + public void setStorage(@NonNull StorageSpec s) { + int n = s.usedValues.length; + boolean withLabels = s.usedLabels != null; + int[] c = new int[n + 4]; + float[] v = new float[n + 4]; + String[] l = withLabels ? new String[n + 4] : null; + float[] sv = new float[n + 4]; + int[] sc = new int[n + 4]; float seg = 0; for (int i = 0; i < n; i++) { - c[i] = usedColors[i]; - v[i] = Math.max(0, usedValues[i]); - if (withLabels) l[i] = usedLabels[i]; + c[i] = s.usedColors[i]; + v[i] = Math.max(0, s.usedValues[i]); + if (withLabels) l[i] = s.usedLabels[i]; seg += v[i]; } - float a = Math.max(0, avail); - c[n] = trackColor; // available gap + float a = Math.max(0, s.avail); + c[n] = trackColor; // available: [non-cma-able|normal] v[n] = a; - if (withLabels) l[n] = availLabel; - c[n + 1] = deficitColor; // deficit, flush right - v[n + 1] = Math.max(0, deficit); - if (withLabels) l[n + 1] = deficitLabel; - setData(c, v, l, Math.max(want, seg + a)); + sv[n] = Math.min(a, Math.max(0, s.availNonCma)); + sc[n] = s.availNonCmaColor; + if (withLabels) l[n] = s.availLabel; + c[n + 1] = s.cmaFreeColor; // CMA free in buddy + v[n + 1] = Math.max(0, s.cmaFree); + if (withLabels) l[n + 1] = s.cmaFreeLabel; + c[n + 2] = s.cmaOtherColor; // CMA lent to other apps + v[n + 2] = Math.max(0, s.cmaOther); + if (withLabels) l[n + 2] = s.cmaOtherLabel; + c[n + 3] = s.deficitColor; // deficit, flush right + v[n + 3] = Math.max(0, s.deficit); + if (withLabels) l[n + 3] = s.deficitLabel; + setData(c, v, l, sv, sc, Math.max(s.want, seg + a + v[n + 1] + v[n + 2])); } @Override @@ -181,8 +261,19 @@ protected void onDraw(@NonNull Canvas canvas) { float segW = w * (values[i] / total); if (segW <= 0f) continue; float segRight = Math.min(x + segW, w); + // Optional left sub-split: same logical segment, two fill colors. + float subW = 0f; + if (subLeftValues != null && subLeftColors != null + && i < subLeftValues.length && i < subLeftColors.length + && subLeftValues[i] > 0f && values[i] > 0f) { + subW = Math.min(segW, segW * (subLeftValues[i] / values[i])); + } + if (subW > 0f) { + paint.setColor(subLeftColors[i]); + canvas.drawRect(x, 0, Math.min(x + subW, segRight), h, paint); + } paint.setColor(colors[i]); - canvas.drawRect(x, 0, segRight, h, paint); + canvas.drawRect(Math.min(x + subW, segRight), 0, segRight, h, paint); drawLabel(canvas, i, x, segRight, h); x += segW; } diff --git a/app/src/main/res/layout/activity_hugepage.xml b/app/src/main/res/layout/activity_hugepage.xml index 44b5712..c8191a5 100644 --- a/app/src/main/res/layout/activity_hugepage.xml +++ b/app/src/main/res/layout/activity_hugepage.xml @@ -113,17 +113,42 @@ - + + android:orientation="horizontal"> + + + + + + + #FF64B5F6 #80BFAF7C + #80D9A06B + #807CC7BD + #5995A7B8 diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b577e3f..5019615 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -929,8 +929,11 @@ 已回收次数 活动虚拟机 可用: %1$d (%2$s) + 可转换CMA %1$s、不可转换CMA %2$s + 空闲 %1$s、其他 App %2$s 总共: %1$d (%2$s) 池大小: %1$d (%2$s) + 池大小:%1$d/%2$d(%3$s / %4$s) 模块未加载 回收 回收已触发 @@ -994,12 +997,58 @@ 停止获取 获取完成:已达成 %1$s 获取结束:达成 %1$s / 目标 %2$s(系统目前无法再凑出更多) + 获取完成:池 %1$s、含 CMA %2$s + 获取停止:池 %1$s / %2$s,含 CMA %3$s / %4$s(系统目前无法再搬移) 获取大页中… 模块不足,尝试 userspace 协助… 占用:%1$d 页 (%2$s) 等待获取 pid %1$d 可用 + + + 启用 CMA + 闲置保留以 CMA 形式借给 App 使用,通过获取取回 + 含 CMA 池大小 + 模块未加载或不支持 CMA(需 v10+) + CMA 不可用 + 本次开机模块已停用 CMA 功能(内核预检/符号解析/首块验证未通过)。 + 先前探测曾判定 CMA 不可用,该记录会让模块整个开机期间关闭 CMA。记录已清除,请重启后再次开启 CMA 以重新探测。 + 写入 pool_want_with_cma 被拒:%1$s + 无法组出任何 CMA 料场(headroom floor 拒绝,或区块翻转失败)。 + 需要先进行 CMA 探测 + 首次使用前需探测该厂商内核是否允许 App 使用 CMA 内存。\n\n探测会暂时清空池、把它整块转成 CMA 料场(目标约 %1$s),用 balloon 工具施加内存压力,再按 CMA 消耗量判读。结束后会还原池,进行前所有 VM 需先关闭。\n\n探测通过后,池大小会设为 %2$s,其余保留为料场借给 App,直到 VM 需要时才取回。 + 开始探测 + 直接进行探测 + 完成探测 + 料场会偏小 + 该模块总共只能监护 %1$s,少于探测想要的 %2$s,判读结果可能不准确。仍要探测吗? + 尚未完成的 CMA 探测 + 已建立 CMA 料场但还没判读。要现在执行 balloon 测量,确认 App 是否真的用得到吗? + 建立 CMA 料场中\u2026 %1$s / %2$s + 料场未达目标 + 只组出 %1$s / %2$s,运行期的 sweep 撞到碎片墙。刚开机时内存没有碎片,同一个流程就会成功;可以重启后再次开启 CMA。或者直接用已组出的 %1$s 进行判读(可能较不准确)。 + CMA 已标记为不可用 + 先前探测结果:该设备的 App 无法使用 CMA,启用只会浪费保留池。要重新探测吗? + 重新探测 + 请先关闭所有运行中的 VM 再探测 + 我重启后再探测 + 未变更任何设置。请重启后再次开启 CMA:刚开机时内存没有碎片,料场就组得起来。 + 正在施加内存压力(balloon)\u2026 可能需要数分钟 + 探测通过:App 可使用 CMA。已启用,池大小设为 %1$s,其余作为料场借出。 + 探测结果:该设备的 App 无法使用 CMA,启用只会浪费保留池。仍要启用吗?您的选择会被记录,之后不再探测。 + 判读结果异常 + 压力共消耗 CMA %1$s / 池 %2$s(balloon 持有 %3$s,停止原因:%4$s),无法明确判读。仍要启用 CMA 吗? + 启用 + 保持关闭 + 已取消探测 + CMA 探测无法运行 + CMA 已启用 + CMA 已启用,池大小设为 %1$s,其余作为料场借出。 + CMA 已停用 + 写入 CMA 目标失败 + CMA + CMA(借出) 目前没有线程占用保留的 hugepage 池。 终止 发送信号失败 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index d6d5a09..5300dd5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -929,9 +929,12 @@ 已回收次數 活動虛擬機器 可用: %1$d (%2$s) + 可轉換CMA %1$s、不可轉換CMA %2$s + 空閒 %1$s、其他 App %2$s 已使用: %1$d (%2$s) 總共: %1$d (%2$s) 池大小: %1$d (%2$s) + 池大小:%1$d/%2$d(%3$s / %4$s) 模組未載入 獲取大頁 獲取已觸發 @@ -951,6 +954,8 @@ 停止獲取 獲取完成:已達成 %1$s 獲取結束:達成 %1$s / 目標 %2$s(系統目前無法再湊出更多) + 獲取完成:池 %1$s、含 CMA %2$s + 獲取停止:池 %1$s / %2$s,含 CMA %3$s / %4$s(系統目前無法再搬移) 模組不足,嘗試 userspace 協助… 觸發獲取失敗 獲取大頁 @@ -988,6 +993,50 @@ 等待獲取 pid %1$d 可用 + + + 啟用 CMA + 閒置保留以 CMA 形式借給 App 使用,透過獲取取回 + 含 CMA 池大小 + 模組未載入或不支援 CMA(需 v10+) + CMA 不可用 + 本次開機模組已停用 CMA 功能(kernel 預檢/符號解析/首塊驗證未通過)。 + 先前探測曾判定 CMA 不可用,該紀錄會讓模組整個開機期間關閉 CMA。紀錄已清除,請重新開機後再次開啟 CMA 以重新探測。 + 寫入 pool_want_with_cma 被拒:%1$s + 無法組出任何 CMA 料場(headroom floor 拒絕,或區塊翻轉失敗)。 + 需要先進行 CMA 探測 + 首次使用前需探測此廠商核心是否允許 App 使用 CMA 記憶體。\n\n探測會暫時清空池、把它整塊轉成 CMA 料場(目標約 %1$s),用 balloon 工具施加記憶體壓力,再依 CMA 消耗量判讀。結束後會還原池,進行前所有 VM 需先關閉。\n\n探測通過後,池大小會設為 %2$s,其餘保留為料場借給 App,直到 VM 需要時才取回。 + 開始探測 + 直接進行探測 + 完成探測 + 料場會偏小 + 此模組總共只能監護 %1$s,少於探測想要的 %2$s,判讀結果可能不準確。仍要探測嗎? + 尚未完成的 CMA 探測 + 已建立 CMA 料場但還沒判讀。要現在執行 balloon 量測,確認 App 是否真的用得到嗎? + 建立 CMA 料場中\u2026 %1$s / %2$s + 料場未達目標 + 只組出 %1$s / %2$s,執行期的 sweep 撞到碎片牆。剛開機時記憶體沒有碎片,同一個流程就會成功;可以重新開機後再次開啟 CMA。或者直接用已組出的 %1$s 進行判讀(可能較不準確)。 + CMA 已標記為不可用 + 先前探測結果:此裝置的 App 無法使用 CMA,啟用只會浪費保留池。要重新探測嗎? + 重新探測 + 請先關閉所有執行中的 VM 再探測 + 我重開機後再探測 + 未變更任何設定。請重新開機後再次開啟 CMA:剛開機時記憶體沒有碎片,料場就組得起來。 + 正在施加記憶體壓力(balloon)\u2026 可能需要數分鐘 + 探測通過:App 可使用 CMA。已啟用,池大小設為 %1$s,其餘作為料場借出。 + 探測結果:此裝置的 App 無法使用 CMA,啟用只會浪費保留池。仍要啟用嗎?您的選擇會被記錄,之後不再探測。 + 判讀結果異常 + 壓力共消耗 CMA %1$s / 料場 %2$s(balloon 持有 %3$s,停止原因:%4$s),無法明確判讀。仍要啟用 CMA 嗎? + 啟用 + 維持關閉 + 已取消探測 + CMA 探測無法執行 + CMA 已啟用 + CMA 已啟用,池大小設為 %1$s,其餘作為料場借出。 + CMA 已停用 + 寫入 CMA 目標失敗 + CMA + CMA(借出) 虛擬機器:%1$s 非追蹤中的虛擬機器 狀態:%1$c (%2$s) diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index e22a015..c01c229 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -25,6 +25,11 @@ #33808080 #807D9CB3 + + #80C98B5E + #805FA79E + + #59708090 #FF4CAF50 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85f08a3..59d0166 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -966,9 +966,12 @@ Total Refilled Active VMs Available: %1$d (%2$s) + CMA convertible %1$s, non-convertible %2$s + free %1$s, other apps %2$s Used: %1$d (%2$s) Total: %1$d (%2$s) Pool size: %1$d (%2$s) + Pool size: %1$d/%2$d (%3$s / %4$s) Module not loaded Acquire Pages Acquire triggered @@ -989,6 +992,8 @@ Stop acquiring Acquire complete: reached %1$s Acquire stopped: got %1$s of %2$s (system can\'t migrate more right now) + Acquire complete: pool %1$s, with CMA %2$s + Acquire stopped: pool %1$s of %2$s, with CMA %3$s of %4$s (system can\'t migrate more right now) Module short; trying userspace assist... Acquire huge pages Pool Size @@ -1025,6 +1030,50 @@ Waiting for acquire pid %1$d Available + + + Enable CMA + Lend the idle reserve to apps as CMA; reclaimed by acquire + Pool Size with CMA + Module not loaded or has no CMA support (v10+) + CMA unavailable + The module disabled its CMA side this boot (kernel preflight / symbol resolution / first-block verification failed). + A previous probe recorded CMA as unusable, which keeps the module\'s CMA side off for the whole boot. That record has been cleared - reboot, then enable CMA again to re-probe. + Writing pool_want_with_cma was rejected: %1$s + No CMA reservoir could be assembled (the headroom floor refused it, or block flipping failed). + CMA probe required + Before first use, a probe must verify this vendor kernel lets apps allocate from CMA.\n\nIt temporarily empties the pool into a CMA reservoir (aiming for %1$s), applies memory pressure with the balloon tool, then judges from how much CMA got consumed. The pool is restored afterwards, and all VMs must be stopped first.\n\nOn success the pool is set to %2$s and the rest is kept as reservoir, lent to apps until a VM needs it. + Start probe + Probe anyway + Finish probe + Reservoir will be small + This module can only guard %1$s in total, less than the %2$s the probe wants to measure against, so the verdict may be unreliable. Probe anyway? + Unfinished CMA probe + A CMA reservoir was set up but never measured. Run the balloon measurement now to decide whether apps can really use it? + Building the CMA reservoir\u2026 %1$s / %2$s + Reservoir came up short + Assembled %1$s of %2$s - the runtime sweep hit the fragmentation wall. On a fresh boot memory is unfragmented, so the same run succeeds; reboot and turn CMA on again. Or measure right now against the %1$s that did assemble (the verdict may be less reliable). + CMA marked unusable + A previous probe found apps cannot use CMA on this device, so enabling it would only waste the reserve. Run the probe again? + Re-probe + Stop all running VMs before probing + I\'ll reboot, then probe + Nothing was changed. Reboot, then turn CMA on again - on a fresh boot memory is unfragmented and the reservoir assembles. + Applying memory pressure (balloon)\u2026 this can take several minutes + Probe passed: apps can use CMA. CMA enabled and the pool set to %1$s - the rest is lent out as reservoir. + Probe result: apps could not use CMA on this device, so enabling it would only waste the reserve. Enable anyway? Your choice is recorded and the probe will not run again. + Probe result unreadable + Pressure consumed %1$s of the %2$s reservoir (balloon held %3$s, stopped: %4$s). This matches neither verdict. Enable CMA anyway? + Enable + Keep off + Probe cancelled + CMA probe could not run + CMA enabled + CMA enabled and the pool set to %1$s - the rest is lent out as reservoir. + CMA disabled + Failed to write the CMA target + CMA + CMA (lent) VM: %1$s Not a tracked VM State: %1$c (%2$s) From 9d237f8e82ae4b6455c5b06e76a80d065eca1e8b Mon Sep 17 00:00:00 2001 From: HuJK Date: Mon, 13 Jul 2026 00:17:21 +0800 Subject: [PATCH 5/5] hugepage: adapt to v11 module (movable->CMA levers, drop probe) The v11 gh_hugepage_reserve module can open the movable->CMA redirect directly, so the app no longer needs the balloon consumability probe to decide whether apps can use the reservoir. Remove the probe subsystem entirely: runCmaProbe and all of its dialog/worker plumbing, the balloon runner, the cma_probe_result verdict, and the reboot-to-finish pending prompt. New flow when the CMA switch is turned on: - if moveable_to_cma_vender_already_allowed reads 1, the vendor kernel already redirects movable->CMA: enable directly, no risk; otherwise warn, then offer two named actions: * "Remove CMA Restriction" arms a lever LIVE ONLY (can crash/reboot the phone). On 6.1 the restrict_cma_redirect flag is side-effect-free so it is tried first (falling back to the gfp hook); on 6.6/6.12 the same key also backs cma_has_pcplist(), so the narrower gfp hook is used directly; * "Module CMA" builds the reservoir without arming any lever - no crash risk; the reserve still serves VMs via stage-in, and apps consume it only where the vendor kernel already allows movable->CMA; - once it is running, offer to save the setting to settings.prop for next boot. Splitting apply from save is the safety net: a live apply that crashes leaves nothing persisted, so the next boot comes up clean. The persisted key (cma_movable_lever) is app-owned; a future load.sh reads it. Disabling reverts whichever lever was armed (the module no-ops these when the vendor already redirects) and forgets the saved choice. Pool-size UI: the pool size and the with-CMA total are now two stacked rows instead of a cramped side-by-side pair; the with-CMA row is greyed and non-editable while CMA is off. A stale cma_probe_result from an older app version is cleared on screen load so the unchanged boot script doesn't keep the module's CMA side cold. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ARourbDczkCYsKUUMC9EFv --- .../droidvm/ui/hugepage/HugePageActivity.java | 772 ++++-------------- .../droidvm/ui/hugepage/HugePageModel.java | 199 ++--- app/src/main/res/layout/activity_hugepage.xml | 18 +- app/src/main/res/values-zh-rCN/strings.xml | 40 +- app/src/main/res/values-zh-rTW/strings.xml | 40 +- app/src/main/res/values/strings.xml | 40 +- 6 files changed, 291 insertions(+), 818 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java index 66b7bd6..261e1c5 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java @@ -39,8 +39,6 @@ import com.google.android.material.progressindicator.LinearProgressIndicator; import android.text.Editable; -import android.widget.LinearLayout; -import android.widget.ProgressBar; import java.math.BigInteger; import java.util.ArrayList; @@ -49,8 +47,6 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.daemon.DaemonConnection; @@ -115,37 +111,13 @@ public final class HugePageActivity extends AppCompatActivity { private SwitchRowWidget rowCmaEnable; private boolean cmaSwitchSyncing = false; // programmatic setChecked guard private boolean cmaInputLoaded = false; // seed the CMA size input once per show - private boolean cmaBusy = false; // a probe / toggle flow is in flight - // A reservoir target persisted with no verdict recorded = a probe that asked - // for a reboot and is now waiting to be finished. Offer it once per screen. - private boolean probePromptShown = false; + private boolean cmaBusy = false; // an enable/disable flow is in flight // Two-way size link (pool_want <= pool_want_with_cma): which input the // user touched last decides who yields when they cross. private static final int SIZE_EDIT_POOL = 1; private static final int SIZE_EDIT_CMA = 2; private int lastSizeEdit = SIZE_EDIT_POOL; private boolean sizeLinkSyncing = false; // programmatic setBigValue guard - /** Balloon floor (MB) for the consumability probe - `balloon 1536`. */ - private static final long BALLOON_FLOOR_MB = 1536; - private static final long BALLOON_TIMEOUT_S = 600; - /** - * How much reservoir the probe wants to measure against: - * {@code max(RAM - 8G, RAM * 0.4)}. RAM here is MemTotal - physical pages - * only, so zram/swap capacity (SwapTotal) never inflates it. Advisory, not - * a precondition: a smaller reservoir still probes, it just makes the - * verdict less reliable, and the user is warned before continuing. - */ - private static final long PROBE_KEEP_BYTES = 8L << 30; // 8 GiB - private static final double PROBE_MIN_RAM_FRACTION = 0.4; - /** - * Pool size a passing probe leaves behind. The probe has just proved apps - * can consume the reservoir, so holding a large pool would waste memory the - * reservoir would otherwise lend out: pin the pool small and let - * pool_want_with_cma (kept at the size the probe assembled) carry the rest - * as reservoir. A VM start stages pages back in on demand. - */ - private static final long PROBE_POOL_BYTES = 512L << 20; // 512 MiB - private static final long PROBE_POOL_PAGES = PROBE_POOL_BYTES / PAGE_SIZE; private TextRowWidget rowStatState; private TextRowWidget rowStatTotalServed; private TextRowWidget rowStatTotalRefilled; @@ -253,6 +225,10 @@ public void afterTextChanged(Editable s) { applyAcquireState(); cardCrashWarning.setOnClickListener(v -> doDismissCrash()); loadPoolSize(); + // One-time migration: drop a stale cma_probe_result from the removed + // probe, which the shipped boot script would otherwise treat as a denial + // and keep the module's CMA side cold. + runOnPool(model::clearLegacyProbeKey); } /** @@ -460,14 +436,8 @@ private void refreshStatus() { ? new LinkedHashMap<>() : model.vmNames(false); // Reservoir occupancy for the two-tone CMA block (module caches ~1s). var cmaUsage = snap.cmaActive() ? model.cmaUsage() : null; - // A reservoir built (or being built) toward a target nobody ever - // judged: the "save and reboot" branch of the probe left it here. - boolean probePending = !probePromptShown && !cmaBusy - && snap.cmaActive() && model.cmaProbeResult() == null; - runOnUiThread(() -> { - updateUI(snap, crashStamp, owners, allPids, vmMap, cmaUsage); - if (probePending) promptPendingProbe(); - }); + runOnUiThread(() -> + updateUI(snap, crashStamp, owners, allPids, vmMap, cmaUsage)); }); } @@ -673,9 +643,9 @@ private void updateUI( acquireEnabled = snap.loaded && snap.deficit > 0; applyAcquireState(); - // CMA switch: only meaningful on a loaded v10 module. While a probe / - // toggle flow runs, leave the switch and the size input alone - the flow - // owns them (the reservoir flips around mid-probe and would flicker). + // CMA switch: only meaningful on a loaded v10+ module. While an enable/ + // disable flow runs, leave the switch and the size inputs alone - the + // flow owns them (the reservoir flips around mid-flow and would flicker). rowCmaEnable.setEnabled(snap.loaded && snap.hasCma); if (!cmaBusy) { boolean cmaActive = snap.cmaActive(); @@ -684,14 +654,12 @@ private void updateUI( rowCmaEnable.setChecked(cmaActive); cmaSwitchSyncing = false; } - // The size row shows exactly one GiB tag: on the right field while - // CMA is on (two fields, tight width), on the pool field otherwise. - inputPoolSize.setUnitButtonVisible(!cmaActive); + // Two stacked size rows: the with-CMA total is editable only while + // the reservoir is on, greyed and non-editable otherwise. Seed it + // from pool_want_with_cma once each time CMA turns on. + inputCmaSize.setEnabled(cmaActive); if (cmaActive) { - // Seed the with-CMA total input once per show (it maps 1:1 to - // pool_want_with_cma), then leave the user's typing be. - if (inputCmaSize.getVisibility() != VISIBLE || !cmaInputLoaded) { - inputCmaSize.setVisibility(VISIBLE); + if (!cmaInputLoaded) { sizeLinkSyncing = true; try { inputCmaSize.setBigValue( @@ -702,7 +670,6 @@ private void updateUI( cmaInputLoaded = true; } } else { - inputCmaSize.setVisibility(GONE); cmaInputLoaded = false; } } @@ -854,7 +821,7 @@ private long runningVmMemMib() { * shrinks the pool. Runs on focus-loss of either field and again at save. */ private void reconcileSizeLink() { - if (inputCmaSize.getVisibility() != VISIBLE) return; + if (!inputCmaSize.isEnabled()) return; // only linked while CMA is on if (!inputPoolSize.isInputValid() || !inputCmaSize.isInputValid()) return; var pool = inputPoolSize.getBigValue(); var withCma = inputCmaSize.getBigValue(); @@ -873,10 +840,10 @@ private void savePoolSize() { if (!inputPoolSize.isInputValid()) return; var bytes = inputPoolSize.getBigValue(); var pages = bytes.divide(BigInteger.valueOf(PAGE_SIZE)); - // While the reservoir is on, the right field IS the with-CMA total + // While the reservoir is on, the with-CMA row IS the with-CMA total // (pool_want_with_cma); the link above already keeps it >= the pool. final long cmaPages; - if (inputCmaSize.getVisibility() == VISIBLE) { + if (inputCmaSize.isEnabled()) { if (!inputCmaSize.isInputValid()) return; cmaPages = inputCmaSize.getBigValue() .divide(BigInteger.valueOf(PAGE_SIZE)).longValue(); @@ -938,7 +905,7 @@ private void doToggleModule() { } /* ================================================================== */ - /* v10 CMA reservoir: switch + consumability probe */ + /* v11 CMA reservoir: switch + movable->CMA levers */ /* ================================================================== */ private void onCmaSwitchChanged(boolean checked) { @@ -965,10 +932,18 @@ private void cancelCmaEnable() { refreshStatus(); } - /** Switch off: demolish the reservoir now and persist off for next boot. */ + /** + * Switch off: revert whichever movable->CMA lever we armed (the module + * no-ops these writes when the vendor kernel already redirects, so this is + * safe in every case), demolish the reservoir now, and forget the saved + * lever so a future boot doesn't re-apply it. + */ private void doCmaDisable() { cmaBusy = true; runOnPool(() -> { + model.setGfpHook(false); + model.setRestrictFlip(false); + model.clearCmaLever(); var res = model.saveCmaTarget(0); runOnUiThread(() -> { cmaBusy = false; @@ -981,12 +956,15 @@ private void doCmaDisable() { } /** - * Switch on. The magisk-side {@code cma_probe_result} (settings.prop) says - * whether the consumability probe ever ran: + * Switch on. Plain movable allocations only reach the reservoir if the + * kernel redirects movable->CMA: *

    - *
  • {@code 1} - apps can consume CMA: enable directly, no probe;
  • - *
  • {@code 0} - probed unusable: offer a re-probe;
  • - *
  • absent - never probed: explain and offer to run it.
  • + *
  • the vendor kernel already redirects - enable directly, no risk;
  • + *
  • otherwise a lever must be armed, which can destabilise the kernel - + * warn, then arm it live only ({@link #tryCmaLever}); on success + * offer to save it ({@link #promptSaveLever}). Splitting arm from save + * is the safety net: if arming crashes the phone, nothing was saved and + * the next boot comes up clean.
  • *
*/ private void doCmaEnable() { @@ -1002,83 +980,57 @@ private void doCmaEnable() { }); return; } - var verdict = model.cmaProbeResult(); if (snap.cmaPbOrder < 0) { - // The module disabled its whole CMA side this boot (preflight / - // symbols / first-block verification) - no write can help now. - // A recorded denial is one of the causes (the boot script then - // hands the module -1 preflight values): drop it, so the next - // boot comes up CMA-capable and the probe can run again. - boolean stale = verdict != null && verdict == VERDICT_DENIED; - if (stale) model.clearCmaProbeResult(); + // The module turned its whole CMA side off this boot (preflight / + // symbols / first-block verification) - no lever can help now. runOnUiThread(() -> { cmaBusy = false; setCmaSwitch(false); if (isFinishing()) return; new MaterialAlertDialogBuilder(this) .setTitle(R.string.hugepage_cma_unavailable_title) - .setMessage(stale ? R.string.hugepage_cma_unavailable_denied - : R.string.hugepage_cma_unavailable_boot) + .setMessage(R.string.hugepage_cma_unavailable_boot) .setPositiveButton(android.R.string.ok, null) .show(); }); return; } - // The threshold only feeds the two dialog branches - don't pay the - // meminfo shell read when the verdict lets us enable directly. - var needBytes = (verdict != null && verdict == VERDICT_ALLOWED) - ? 0 : probeNeedBytes(model.memTotalKb()); + if (model.mtcVenderAllowed() == 1) { + // Vendor already redirects movable->CMA: nothing to arm, no risk. + enableCmaDirect(snap); + return; + } + // A lever is required, and arming it can crash the device: warn first. runOnUiThread(() -> { if (isFinishing()) { cmaBusy = false; return; } - if (verdict != null && verdict == VERDICT_ALLOWED) { - enableCmaDirect(snap); - } else if (verdict != null && verdict == VERDICT_DENIED) { - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.hugepage_cma_probe_denied_title) - .setMessage(R.string.hugepage_cma_probe_denied_msg) - .setPositiveButton(R.string.hugepage_cma_probe_rerun, - (d, w) -> startCmaProbe()) - .setNegativeButton(android.R.string.cancel, - (d, w) -> cancelCmaEnable()) - .setOnCancelListener(d -> cancelCmaEnable()) - .show(); - } else { - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.hugepage_cma_probe_needed_title) - .setMessage(getString(R.string.hugepage_cma_probe_needed_msg, - SizeUtils.formatSize(Math.max(0, needBytes)), - SizeUtils.formatSize(PROBE_POOL_BYTES))) - .setPositiveButton(R.string.hugepage_cma_probe_start, - (d, w) -> startCmaProbe()) - .setNegativeButton(android.R.string.cancel, - (d, w) -> cancelCmaEnable()) - .setOnCancelListener(d -> cancelCmaEnable()) - .show(); - } + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.hugepage_cma_warn_title) + .setMessage(R.string.hugepage_cma_warn_msg) + .setPositiveButton(R.string.hugepage_cma_remove_restriction, + (d, w) -> tryCmaLever(snap)) + .setNeutralButton(R.string.hugepage_cma_module_cma, + (d, w) -> enableCmaReservoirOnly(snap)) + .setNegativeButton(android.R.string.cancel, + (d, w) -> cancelCmaEnable()) + .setOnCancelListener(d -> cancelCmaEnable()) + .show(); }); }); } /** - * Probe already passed: restore the remembered with-CMA total and let a v3 - * acquire build the reservoir (only the mode-2/3 sweep runs Phase R; mode 1 - * is pool-only legacy). - * - *

The target is clamped to the module's {@code pool_want <= - * pool_want_with_cma} invariant, so a remembered total at or below the pool - * size enables with an empty reservoir - that is a real state (the probe - * itself produces it when the pool already holds everything), and the user - * then raises the now-visible with-CMA field. Only {@code 0} is impossible: - * it is the off sentinel. + * Vendor already redirects movable->CMA (or a saved lever is already live): + * just set the with-CMA total and let a v3 acquire build the reservoir (only + * the mode-2/3 sweep runs Phase R; mode 1 is pool-only legacy). This path + * carries no crash risk, so the target is persisted and there is no separate + * save step. */ private void enableCmaDirect(@NonNull HugePageModel.Snapshot snap) { runOnPool(() -> { - long target = Math.max(model.lastCmaTargetPages(), snap.targetIdeal); - if (target <= 0) // pool soft-disabled: fall back to the probe floor - target = pagesFor(Math.max(0, probeNeedBytes(model.memTotalKb()))); + long target = reservoirTarget(snap); boolean ok = target > 0 && model.saveCmaTarget(target).ok(); if (ok) { var s2 = model.state(); @@ -1096,541 +1048,123 @@ private void enableCmaDirect(@NonNull HugePageModel.Snapshot snap) { }); } - /* ---- probe orchestration ---- */ - - /** Progress dialog handle for the probe worker thread. */ - private static final class ProbeUi { - @NonNull final androidx.appcompat.app.AlertDialog dialog; - @NonNull final TextView text; - - ProbeUi(@NonNull androidx.appcompat.app.AlertDialog dialog, @NonNull TextView text) { - this.dialog = dialog; - this.text = text; - } - } - - private static final int VERDICT_DENIED = 0; - private static final int VERDICT_ALLOWED = 1; - private static final int VERDICT_ABNORMAL = -1; - - /** max(RAM - 8 GiB, RAM x 0.4) in bytes; -1 when meminfo is unreadable. */ - private long probeNeedBytes(long memTotalKb) { - if (memTotalKb <= 0) return -1; - long total = memTotalKb * 1024; - return Math.max(total - PROBE_KEEP_BYTES, (long) (total * PROBE_MIN_RAM_FRACTION)); - } - - private static long pagesFor(long bytes) { - return (bytes + PAGE_SIZE - 1) / PAGE_SIZE; - } - - /** Kick off the probe worker; the switch stays under the flow's control. */ - private void startCmaProbe() { - new Thread(this::runCmaProbe, "hugepage-cma-probe").start(); - } - /** - * The consumability probe (worker thread). Steps, per the module docs: - * precondition {@code avail >= max(RAM-7G, 40% RAM)} (guided acquire, else - * save-and-reboot); then {@code echo avail > pool_want_with_cma}, - * {@code echo 0 > pool_want} (the freed blocks flip to the reservoir), run - * {@code balloon 1536} and judge from how much CmaFree the pressure consumed - * whether this vendor lets user apps allocate from CMA. {@code pool_want} is - * restored on every path. - * - *

Only a pass is written to settings.prop ({@code cma_probe_result=1}) - - * an unreadable result asks the user, and enabling counts as a pass. A - * failure (or a decline) records nothing and clears any stale verdict, so - * the next launch can simply probe again. + * Arm a movable->CMA lever live only - nothing is persisted yet. On + * 6.1 the {@code restrict_cma_redirect} flag is side-effect-free, so try it + * first and fall back to the gfp hook; on 6.6/6.12 that same key also backs + * {@code cma_has_pcplist()}, so arm the narrower hook directly (and only try + * the flag as a last resort). Then build the reservoir toward a target so the + * user can watch it fill, and offer to save the lever for next boot. */ - private void runCmaProbe() { - var cancelled = new AtomicBoolean(false); - ProbeUi ui = null; - boolean raised = false; // pool_want_with_cma raised by us - boolean zeroed = false; // pool_want emptied by us - long prevWant = -1; - try { - // Balloon pressure would squeeze (or LMK-kill) running VMs. - if (runningVmMemMib() > 0) { - probeFail(null, getString(R.string.hugepage_cma_vms_running)); - return; - } - var snap = model.state(); - prevWant = snap.targetIdeal; - long needBytes = probeNeedBytes(model.memTotalKb()); - if (needBytes <= 0) { - probeFail(null, getString(R.string.hugepage_cma_probe_failed_generic)); - return; - } - long needPages = pagesFor(needBytes); - // 1. How big a reservoir to measure against. It is NOT bounded by - // the configured pool: the probe empties the pool into the - // reservoir (pool_want=0 -> the module's shrink flips every avail - // block to CMA, instantly), so the only ceiling is the module's - // RAM-derived pool_size_max. A cap below what we want makes the - // verdict shakier, not impossible: warn and let the user go on. - long cap = model.poolSizeMax(); - long target = Math.max(needPages, Math.max(prevWant, snap.wantWithCma)); - if (cap > 0) target = Math.min(target, cap); - long goal = Math.min(needPages, target); - if (goal < needPages && !probeAsk( - getString(R.string.hugepage_cma_small_reservoir_title), - getString(R.string.hugepage_cma_small_reservoir_msg, - SizeUtils.formatSize(goal * PAGE_SIZE), - SizeUtils.formatSize(needPages * PAGE_SIZE)), - getString(R.string.hugepage_cma_probe_anyway))) { - probeCancelled(null, false); - return; - } - ui = probeProgressShow(reservoirStage(snap.cmaPool, goal), cancelled); - if (ui == null) { - probeCancelled(null, false); - return; - } - // 2. Build the reservoir. Already there (a previous run persisted the - // target and the module assembled it at boot) -> measure directly, - // touching nothing. - boolean built = snap.cmaPool >= goal; - if (!built) { - // Raise the total first: pool_want=0 would otherwise soft-disable - // the pool and hand its pages back to the buddy allocator instead - // of flipping them into the reservoir. Never lower an existing - // bigger total - that demolishes reservoir the device is lending - // out right now. - if (snap.wantWithCma < target) { - var w = model.writeWantWithCma(target); - if (!w.ok()) { - probeDismiss(ui); - probeUnavailable(getString(R.string.hugepage_cma_unavailable_write, - w.detail != null ? w.detail : "?")); - return; - } - } - raised = true; - // Empty the pool: its avail blocks flip to CMA at once (the fast - // path - a from-scratch sweep would hit the fragmentation wall). - // Only the live knob is written, so a reboot restores pool_want - // even if this app dies before the restore below. - var shrink = model.writeWant(0); - if (!shrink.ok()) { - probeRollback(); - probeDismiss(ui); - probeFail(null, getString(R.string.hugepage_cma_probe_failed_generic)); - return; - } - zeroed = true; - Thread.sleep(3000); // let the flips land - model.acquire(3); // best-effort top-up toward the target - built = waitReservoir(ui, goal, cancelled); - } - if (cancelled.get()) { - model.stopAcquire(); - probeRestore(prevWant, zeroed); - probeRollback(); - probeCancelled(ui, true); - return; - } - // Whatever actually assembled is what the balloon is judged against. - long reservoirPages = model.state().cmaPool; - if (reservoirPages <= 0) { - probeRestore(prevWant, zeroed); - probeRollback(); - probeDismiss(ui); - probeUnavailable(getString(R.string.hugepage_cma_unavailable_reservoir)); - return; - } - if (!built) { - // The runtime sweep hit the fragmentation wall. This is not a - // different failure from "it can't be built" - the module builds - // the reservoir first at init, on the cleanest memory there is, - // so the very same target simply works after a reboot. Persist - // it and pick the probe back up then (see the pending prompt) - - // or measure right now against the smaller reservoir that did - // get built, accepting a shakier verdict. - probeDismiss(ui); - ui = null; - int choice = probeAskChoice( - getString(R.string.hugepage_cma_reservoir_short_title), - getString(R.string.hugepage_cma_reservoir_short_msg, - SizeUtils.formatSize(reservoirPages * PAGE_SIZE), - SizeUtils.formatSize(goal * PAGE_SIZE)), - getString(R.string.hugepage_cma_save_reboot), - getString(R.string.hugepage_cma_probe_anyway)); - if (choice != CHOICE_NEGATIVE) { - // "I'll reboot, then probe", or dismissed. Nothing is saved: - // the probe assembles the reservoir out of the pool itself, - // so a fresh boot - where memory is unfragmented - simply - // lets the same run succeed. Undo our live writes and go. - probeRestore(prevWant, zeroed); - probeRollback(); - if (choice == CHOICE_POSITIVE) - probeToast(getString(R.string.hugepage_cma_reboot_hint)); - probeEndUi(false); - return; - } - // Probe anyway: a fresh progress dialog for the pressure stage. - ui = probeProgressShow( - getString(R.string.hugepage_cma_probe_running_balloon), cancelled); - if (ui == null) { - probeRestore(prevWant, zeroed); - probeRollback(); - probeCancelled(null, false); - return; - } - } - // 3. Pressure + judgment, measured against the reservoir that exists. - probeStage(ui, getString(R.string.hugepage_cma_probe_running_balloon)); - var out = model.runBalloon(BALLOON_FLOOR_MB, BALLOON_TIMEOUT_S); - if (cancelled.get()) { - probeRestore(prevWant, zeroed); - probeRollback(); - probeCancelled(ui, true); - return; - } - int verdict = judgeBalloon(out, reservoirPages); - probeDismiss(ui); - ui = null; - // A pass enables straight away. Anything else - a denial, or numbers - // that match neither verdict - is put to the user, because a probe - // can be wrong (a small reservoir, a vendor that only lets some - // allocation classes in). Enabling either way counts as a pass and - // is recorded, so the switch stops probing from now on; declining - // records nothing and clears any stale verdict, leaving the probe - // available next launch. - if (verdict == VERDICT_ALLOWED) { - model.setCmaProbeAllowed(); - probeEnableWithSmallPool(target); - probeToast(getString(R.string.hugepage_cma_probe_ok, - SizeUtils.formatSize(PROBE_POOL_BYTES))); - probeEndUi(true); + private void tryCmaLever(@NonNull HugePageModel.Snapshot snap) { + cmaBusy = true; + runOnPool(() -> { + boolean is61 = model.kernelIs61(); + String lever = null; + if (is61 && flipFlagWorks()) lever = HugePageModel.LEVER_FLAG; + else if (model.setGfpHook(true).ok()) lever = HugePageModel.LEVER_HOOK; + else if (!is61 && flipFlagWorks()) lever = HugePageModel.LEVER_FLAG; + if (lever == null) { + runOnUiThread(() -> { + cmaBusy = false; + setCmaSwitch(false); + Toast.makeText(this, R.string.hugepage_cma_lever_failed, + LENGTH_SHORT).show(); + refreshStatus(); + }); return; } - boolean enable = verdict == VERDICT_DENIED - ? probeAsk(getString(R.string.hugepage_cma_probe_denied_title), - getString(R.string.hugepage_cma_probe_denied_result), - getString(R.string.hugepage_cma_probe_enable)) - : probeAskAbnormal(out, reservoirPages); - if (enable) { - model.setCmaProbeAllowed(); - probeEnableWithSmallPool(target); // also restores pool_want - probeToast(getString(R.string.hugepage_cma_enabled_pool, - SizeUtils.formatSize(PROBE_POOL_BYTES))); - probeEndUi(true); - } else { - probeRestore(prevWant, zeroed); - model.clearCmaProbeResult(); - model.saveCmaTarget(0); // demolishes the reservoir - if (zeroed) model.acquire(1); // refill the restored pool - probeEndUi(false); - } - } catch (InterruptedException e) { - probeRestore(prevWant, zeroed); - if (raised) probeRollback(); - probeCancelled(ui, ui != null); - } catch (Exception e) { - // Never leave the flow lock stuck: undo our writes and surface the - // error instead of a wedged switch. - Log.w(TAG, "CMA probe failed", e); - probeRestore(prevWant, zeroed); - if (raised) probeRollback(); - probeDismiss(ui); - probeFail(null, getString(R.string.hugepage_cma_probe_failed_generic)); - } - } - - /** Put {@code pool_want} back if the probe emptied it (live knob only). */ - private void probeRestore(long prevWant, boolean zeroed) { - if (zeroed && prevWant >= 0) model.writeWant(prevWant); - } - - /** - * The reboot half of the probe: the reservoir target survived a reboot with - * no verdict recorded, so the module has now built it on clean memory and - * the measurement can finally run. Asked once per visit; declining leaves - * the reservoir in place (it is still lent to apps) and the switch on. - */ - private void promptPendingProbe() { - if (probePromptShown || cmaBusy || isFinishing() || isDestroyed()) return; - probePromptShown = true; - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.hugepage_cma_probe_pending_title) - .setMessage(R.string.hugepage_cma_probe_pending_msg) - .setPositiveButton(R.string.hugepage_cma_probe_continue, (d, w) -> { - cmaBusy = true; - startCmaProbe(); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - /** Undo the probe's only write: the raised total demolishes its reservoir. */ - private void probeRollback() { - model.writeWantWithCma(0); - } - - @NonNull - private String reservoirStage(long got, long goal) { - return getString(R.string.hugepage_cma_building, - SizeUtils.formatSize(got * PAGE_SIZE), SizeUtils.formatSize(goal * PAGE_SIZE)); - } - - /** - * The end state of an enabled probe: a {@value #PROBE_POOL_BYTES}-byte pool - * and the with-CMA total the probe assembled, persisted in one settings.prop - * rewrite so the next boot comes up the same way. This is the probe's first - * and only {@code pool_want} write: shrinking the pool hands its pages to - * the reservoir (the module's shrink path), and the v3 acquire then stages - * 512 MB back in and tops the reservoir up toward the total. - */ - private void probeEnableWithSmallPool(long total) { - model.saveTargets(PROBE_POOL_PAGES, total); - // v3: only the mode-2/3 sweep runs the reservoir-building Phase R. - model.acquire(3); + buildReservoirAndPromptSave(snap, lever); + }); } /** - * Poll the reservoir toward {@code goal} pages, narrating progress. Returns - * true once {@code pool_cma} reaches it; false on stop/timeout/cancel. + * Reservoir-only: build the reservoir without arming any lever, so + * there is no crash risk. Useful on a device where the vendor kernel already + * lets apps consume CMA even though it reads as not-allowed; elsewhere the + * reserve still serves VMs via stage-in. Offers the same save step (which + * persists the target but no lever). */ - private boolean waitReservoir(@NonNull ProbeUi ui, long goal, - @NonNull AtomicBoolean cancelled) - throws InterruptedException { - for (int i = 0; i < 1800; i++) { // 30 min hard bound - if (cancelled.get()) return false; - var s = model.state(); - probeStage(ui, reservoirStage(s.cmaPool, goal)); - if (s.cmaPool >= goal) return true; - // Give the worker a few seconds to raise acquire_active before - // treating "not acquiring" as done-short. - if (!s.acquiring && i > 5) return false; - Thread.sleep(1000); - } - return false; + private void enableCmaReservoirOnly(@NonNull HugePageModel.Snapshot snap) { + cmaBusy = true; + runOnPool(() -> buildReservoirAndPromptSave(snap, null)); } /** - * Judge the balloon output against the reservoir that was built: pressure - * that consumed at least half of it means apps allocate from CMA; a tenth - * or less means they can't; anything between (or unparsable output) is - * unreadable and goes to the user. + * Build the reservoir toward a target with a live (non-persisted) write so + * the user can watch it fill, then offer to save. {@code lever} is the lever + * the caller armed, or {@code null} for the reservoir-only path. Runs on the + * pool thread. */ - private int judgeBalloon(@Nullable Map out, long reservoirPages) { - if (out == null) return VERDICT_ABNORMAL; - long diffKb; - long heldMb; - try { - diffKb = Long.parseLong(out.getOrDefault("cma_diff_kb", "").trim()); - heldMb = Long.parseLong(out.getOrDefault("held_mb", "").trim()); - } catch (NumberFormatException e) { - return VERDICT_ABNORMAL; + private void buildReservoirAndPromptSave(@NonNull HugePageModel.Snapshot snap, + @Nullable String lever) { + long target = reservoirTarget(snap); + if (target > 0) { + model.writeWantWithCma(target); + var s2 = model.state(); + if (s2.loaded && s2.deficit > 0) model.acquire(3); } - long reservoirKb = reservoirPages * (PAGE_SIZE / 1024); - if (reservoirKb <= 0 || heldMb <= 0) return VERDICT_ABNORMAL; - if (diffKb >= reservoirKb / 2) return VERDICT_ALLOWED; - if (diffKb <= reservoirKb / 10) return VERDICT_DENIED; - return VERDICT_ABNORMAL; - } - - /* ---- probe worker <-> UI plumbing (all blocking helpers) ---- */ - - /** Blocking two-choice dialog; false on cancel/back/finish. */ - private boolean probeAsk(@NonNull String title, @NonNull String message, - @NonNull String positive) throws InterruptedException { - return probeAskChoice(title, message, positive, - getString(android.R.string.cancel)) == CHOICE_POSITIVE; - } - - /** {@link #probeAskChoice} outcomes; {@code CHOICE_NONE} = back/dismiss/gone. */ - private static final int CHOICE_NONE = 0; - private static final int CHOICE_POSITIVE = 1; - private static final int CHOICE_NEGATIVE = 2; - - /** - * Blocking dialog offering two named actions, with back/outside-tap as a - * third "neither" outcome. Both buttons are real choices - which is why - * neither is labelled Cancel by callers that need three ways out. - */ - private int probeAskChoice(@NonNull String title, @NonNull String message, - @NonNull String positive, @NonNull String negative) - throws InterruptedException { - var choice = new AtomicInteger(CHOICE_NONE); - var latch = new CountDownLatch(1); runOnUiThread(() -> { - // isDestroyed covers rotation teardown (isFinishing stays false); - // the catch covers a window torn down mid-post - either way the - // latch MUST be counted or the worker blocks forever. - if (isFinishing() || isDestroyed()) { - latch.countDown(); - return; - } - try { - new MaterialAlertDialogBuilder(this) - .setTitle(title) - .setMessage(message) - .setPositiveButton(positive, (d, w) -> choice.set(CHOICE_POSITIVE)) - .setNegativeButton(negative, (d, w) -> choice.set(CHOICE_NEGATIVE)) - .setOnDismissListener(d -> latch.countDown()) - .show(); - } catch (Exception e) { - latch.countDown(); - } - }); - latch.await(); - return choice.get(); - } - - /** The "result unreadable - enable anyway?" dialog, with the raw numbers. */ - private boolean probeAskAbnormal(@Nullable Map out, long reservoirPages) - throws InterruptedException { - long diffKb = 0; - long heldMb = 0; - String stop = "?"; - if (out != null) { - try { - diffKb = Long.parseLong(out.getOrDefault("cma_diff_kb", "0").trim()); - } catch (NumberFormatException ignored) { - } - try { - heldMb = Long.parseLong(out.getOrDefault("held_mb", "0").trim()); - } catch (NumberFormatException ignored) { - } - stop = out.getOrDefault("stop_reason", "?"); - } - var choice = new AtomicInteger(0); - var latch = new CountDownLatch(1); - long fDiffKb = diffKb; - long fHeldMb = heldMb; - String fStop = stop; - runOnUiThread(() -> { - if (isFinishing() || isDestroyed()) { - latch.countDown(); + cmaInputLoaded = false; + refreshStatus(); + if (isFinishing()) { + cmaBusy = false; return; } - try { - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.hugepage_cma_probe_abnormal_title) - .setMessage(getString(R.string.hugepage_cma_probe_abnormal_msg, - SizeUtils.formatSize(fDiffKb * 1024), - SizeUtils.formatSize(reservoirPages * PAGE_SIZE), - SizeUtils.formatSize(fHeldMb * 1024 * 1024), - fStop)) - .setPositiveButton(R.string.hugepage_cma_probe_enable, - (d, w) -> choice.set(1)) - .setNegativeButton(R.string.hugepage_cma_probe_keep_off, null) - .setOnDismissListener(d -> latch.countDown()) - .show(); - } catch (Exception e) { - latch.countDown(); - } - }); - latch.await(); - return choice.get() == 1; - } - - /** Show the cancellable progress dialog; null when the activity is gone. */ - @Nullable - private ProbeUi probeProgressShow(@NonNull String initial, - @NonNull AtomicBoolean cancelled) - throws InterruptedException { - var holder = new java.util.concurrent.atomic.AtomicReference(); - var latch = new CountDownLatch(1); - runOnUiThread(() -> { - try { - if (isFinishing() || isDestroyed()) return; - float density = getResources().getDisplayMetrics().density; - var text = new TextView(this); - text.setText(initial); - var box = new LinearLayout(this); - box.setOrientation(LinearLayout.HORIZONTAL); - box.setGravity(android.view.Gravity.CENTER_VERTICAL); - int pad = Math.round(24 * density); - box.setPaddingRelative(pad, Math.round(16 * density), pad, 0); - var spinner = new ProgressBar(this); - box.addView(spinner, Math.round(32 * density), Math.round(32 * density)); - var lp = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT); - lp.setMarginStart(Math.round(16 * density)); - box.addView(text, lp); - var dialog = new MaterialAlertDialogBuilder(this) - .setTitle(R.string.hugepage_enable_cma) - .setView(box) - .setCancelable(false) - .setNegativeButton(android.R.string.cancel, null) - .create(); - dialog.show(); - // Cancel requests cooperative interruption; the worker decides - // when it is safe to stop, so the button must not dismiss the - // dialog. A running balloon ignores flags, so also kill it - - // its run() then returns quickly and the worker sees the flag. - var btn = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE); - if (btn != null) btn.setOnClickListener(v -> { - cancelled.set(true); - v.setEnabled(false); - runOnPool(() -> runList("pkill", "-f", - "gh-hugepage-reserve/balloon")); - }); - holder.set(new ProbeUi(dialog, text)); - } catch (Exception ignored) { - // window torn down mid-post: holder stays null = "activity gone" - } finally { - latch.countDown(); - } + promptSaveLever(lever, target); }); - latch.await(); - return holder.get(); - } - - private void probeStage(@NonNull ProbeUi ui, @NonNull String msg) { - runOnUiThread(() -> ui.text.setText(msg)); } - private void probeDismiss(@Nullable ProbeUi ui) { - if (ui != null) runOnUiThread(ui.dialog::dismiss); + /** Flip the restrict flag on and confirm the kernel actually opened it. */ + private boolean flipFlagWorks() { + return model.setRestrictFlip(true).ok() && model.readRestrictState() == 1; } - private void probeToast(@NonNull String msg) { - runOnUiThread(() -> Toast.makeText(this, msg, Toast.LENGTH_LONG).show()); + /** With-CMA target to build: the last saved total, else the pool size. */ + private long reservoirTarget(@NonNull HugePageModel.Snapshot snap) { + return Math.max(model.lastCmaTargetPages(), + Math.max(snap.targetIdeal, snap.built)); } - /** Wind the flow down: dismiss, switch off. Callers own the rollback. */ - private void probeCancelled(@Nullable ProbeUi ui, boolean toast) { - probeDismiss(ui); - if (toast) probeToast(getString(R.string.hugepage_cma_probe_cancelled)); - probeEndUi(false); + /** + * Step 2: the live setting didn't crash the phone. Offer to persist it so it + * re-applies at the next boot (written into Magisk's settings.prop). + * Declining keeps it running now but leaves the next boot clean - the safety + * net, in case it destabilises the device after all. {@code lever} is null on + * the reservoir-only path (only the target is persisted). + */ + private void promptSaveLever(@Nullable String lever, long target) { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.hugepage_cma_save_title) + .setMessage(R.string.hugepage_cma_save_msg) + .setPositiveButton(R.string.hugepage_cma_save_yes, + (d, w) -> finishCmaEnable(lever, target, true)) + .setNegativeButton(R.string.hugepage_cma_save_no, + (d, w) -> finishCmaEnable(lever, target, false)) + .setOnCancelListener(d -> finishCmaEnable(lever, target, false)) + .show(); } - /** Failure with a message dialog (or toast when {@code title} is null). */ - private void probeFail(@Nullable String title, @NonNull String message) { - runOnUiThread(() -> { - cmaBusy = false; - setCmaSwitch(false); - if (isFinishing()) return; - if (title == null) { - Toast.makeText(this, message, Toast.LENGTH_LONG).show(); - } else { - new MaterialAlertDialogBuilder(this) - .setTitle(title) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show(); + /** + * Settle an enabled reservoir: switch on and reseed the size input. When + * {@code save}, persist the with-CMA target and the lever choice so the next + * boot comes up the same way (a null lever clears the key = reservoir-only); + * otherwise everything stays live-only. + */ + private void finishCmaEnable(@Nullable String lever, long target, boolean save) { + runOnPool(() -> { + if (save) { + if (lever != null) model.saveCmaLever(lever); + else model.clearCmaLever(); + if (target > 0) model.saveCmaTarget(target); } - refreshStatus(); - }); - } - - private void probeUnavailable(@NonNull String detail) { - probeFail(getString(R.string.hugepage_cma_unavailable_title), detail); - } - - /** Release the flow lock and settle the switch to the final state. */ - private void probeEndUi(boolean enabled) { - runOnUiThread(() -> { - cmaBusy = false; - setCmaSwitch(enabled); - cmaInputLoaded = false; // reseed the with-CMA total field - loadPoolSize(); // a passing probe pins pool_want to 512 MB - refreshStatus(); + runOnUiThread(() -> { + cmaBusy = false; + setCmaSwitch(true); + Toast.makeText(this, R.string.hugepage_cma_enabled, LENGTH_SHORT).show(); + cmaInputLoaded = false; + refreshStatus(); + }); }); } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java index 350283e..aef1fb6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java @@ -440,53 +440,122 @@ Result stopAcquire() { } /* ================================================================== */ - /* v10 CMA reservoir + consumability probe */ + /* v11 movable->CMA levers + CMA reservoir persistence */ /* ================================================================== */ + /** settings.prop values recording which movable->CMA lever the user saved. */ + static final String LEVER_HOOK = "hook"; + static final String LEVER_FLAG = "flag"; + private static final String CMA_LEVER_KEY = "cma_movable_lever"; + + private static final String MTC_VENDER = "moveable_to_cma_vender_already_allowed"; + private static final String MTC_RESTRICT = "moveable_to_cma_restrict_cma_redirect_disabled"; + private static final String MTC_GFP_HOOK = "moveable_to_cma_gfp_cma_hook"; + /** - * The app-side probe verdict recorded in settings.prop as - * {@code cma_probe_result}: {@code 1} = apps can consume the reservoir, - * {@code 0} = they can't (the boot script then keeps the whole CMA side - * cold), {@code null} = the probe never ran. + * Whether the running kernel is 6.1. On 6.1 the {@code restrict_cma_redirect} + * static key is side-effect-free, so the enable flow prefers flipping it (a + * clean global switch); on 6.6/6.12 the same key also backs + * {@code cma_has_pcplist()}, so the narrower gfp hook is used instead. Fails + * safe to {@code false} (the hook path) when {@code uname} is unreadable. */ - @Nullable - Integer cmaProbeResult() { - var v = parseProp(safeRead(SETTINGS_PROP)).get("cma_probe_result"); - if (v == null) return null; + boolean kernelIs61() { try { - return Integer.parseInt(v.trim()); - } catch (NumberFormatException e) { - return null; + var r = runList("uname", "-r").getOutString().trim(); + return r.equals("6.1") || r.startsWith("6.1."); + } catch (Exception e) { + return false; } } /** - * Record a passed probe (see {@link #cmaProbeResult}). Only success - * is ever persisted: a failed or inconclusive probe leaves no verdict, so - * the next app launch can simply probe again. + * Read {@code moveable_to_cma_vender_already_allowed}: {@code 1} = the + * vendor kernel already redirects every movable allocation into CMA, so the + * reservoir is consumable without touching either lever; {@code 0} = it does + * not; {@code -1} = the param is absent (pre-v11 module, no lever support). + */ + int mtcVenderAllowed() { + return readIntParam(MTC_VENDER, -1); + } + + /** + * The "flag" lever: flip the kernel {@code restrict_cma_redirect} static key + * (write 1 = open movable->CMA globally). Live only - not persisted here. + */ + @NonNull + Result setRestrictFlip(boolean on) { + var t = writeKnob(MTC_RESTRICT, on ? "1" : "0"); + return t.ok() ? Result.ok(MTC_RESTRICT) : Result.failed(MTC_RESTRICT, t.error); + } + + /** Read the flag state: 1 = redirect open, 0 = blocked, -1 = unresolvable. */ + int readRestrictState() { + return readIntParam(MTC_RESTRICT, -1); + } + + /** + * The "hook" lever: arm the {@code __GFP_CMA} bypass hook (write 1 = let page + * cache / mTHP anon consume the reservoir). Live only - not persisted here. */ @NonNull - Result setCmaProbeAllowed() { + Result setGfpHook(boolean on) { + var t = writeKnob(MTC_GFP_HOOK, on ? "1" : "0"); + return t.ok() ? Result.ok(MTC_GFP_HOOK) : Result.failed(MTC_GFP_HOOK, t.error); + } + + /** Read the gfp hook arm state: 1 = armed, 0 = disarmed, -1 = absent. */ + int readGfpHook() { + return readIntParam(MTC_GFP_HOOK, -1); + } + + /** + * Persist the chosen movable->CMA lever ({@link #LEVER_HOOK} / + * {@link #LEVER_FLAG}) under an app-owned settings.prop key, so a future + * boot script can re-apply it as an insmod param. The shipped load.sh does + * not read it yet, so this only records intent - deliberate: the lever is + * applied live and only saved once it has proven it doesn't crash the boot. + */ + @NonNull + Result saveCmaLever(@NonNull String lever) { var changes = new LinkedHashMap(); - changes.put("cma_probe_result", "1"); + changes.put(CMA_LEVER_KEY, lever); var t = updateSettings(changes); return t.ok() ? Result.ok("settings") : Result.failed("settings", t.error); } - /** - * Drop any recorded verdict. Beyond "forget a failure", this un-sticks a - * legacy {@code cma_probe_result=0}: the boot script hands the module -1 - * preflight values while that key is 0, which kills the CMA side for the - * whole boot and makes a re-probe impossible until it is gone. - */ + /** Forget the persisted lever (CMA switched off, or the user declined save). */ @NonNull - Result clearCmaProbeResult() { + Result clearCmaLever() { var changes = new LinkedHashMap(); - changes.put("cma_probe_result", null); // null value = remove the key + changes.put(CMA_LEVER_KEY, null); // null value = remove the key var t = updateSettings(changes); return t.ok() ? Result.ok("settings") : Result.failed("settings", t.error); } + /** + * Drop a stale {@code cma_probe_result} left by an older app version. The + * shipped boot script still cold-starts the whole CMA side on + * {@code cma_probe_result=0}, so a leftover denial from the removed probe + * would keep v11's reservoir off; remove it on sight. No-op when absent. + */ + void clearLegacyProbeKey() { + if (!parseProp(safeRead(SETTINGS_PROP)).containsKey("cma_probe_result")) return; + var changes = new LinkedHashMap(); + changes.put("cma_probe_result", null); + updateSettings(changes); + } + + /** Read an integer sysfs param, or {@code def} when absent/unparseable. */ + private int readIntParam(@NonNull String name, int def) { + var v = safeRead(pathJoin(SYSFS_PARAMS, name)).trim(); + if (v.isEmpty()) return def; + try { + return Integer.parseInt(v); + } catch (NumberFormatException e) { + return def; + } + } + /** * The last non-zero with-CMA total (pages) the user ran with, kept under an * app-owned settings.prop key so switching CMA off (which must persist @@ -553,7 +622,12 @@ private static Map cmaTargetChanges(long pages) { return changes; } - /** Live {@code pool_want_with_cma} write only - the probe's first step. */ + /** + * Live {@code pool_want_with_cma} write only (no settings.prop persist), so + * the reservoir target set here is undone by a reboot. The enable flow uses + * it to build the reservoir at runtime before the user decides whether to + * save the movable->CMA lever. + */ @NonNull Result writeWantWithCma(long pages) { var t = writeKnob("pool_want_with_cma", Long.toString(pages)); @@ -561,73 +635,6 @@ Result writeWantWithCma(long pages) { : Result.failed("pool_want_with_cma", t.error); } - /** - * Live {@code pool_want} write only (no settings.prop persist), so a value - * written here is undone by a reboot. The probe uses it to empty the pool - * into the reservoir and to put it back afterwards. - */ - @NonNull - Result writeWant(long pages) { - var t = writeKnob("pool_want", Long.toString(pages)); - return t.ok() ? Result.ok("pool_want") : Result.failed("pool_want", t.error); - } - - /** MemTotal from /proc/meminfo in KiB, or -1 if unreadable. */ - long memTotalKb() { - return meminfoKb("MemTotal"); - } - - /** - * The module's RAM-derived cap on the targets (pages), read-only: - * {@code min(ram - min(ram/2, 6G), 24G)}. Both {@code pool_want} and - * {@code pool_want_with_cma} are clamped to it, so it bounds how much - * reservoir can sit on top of a given pool. -1 when unreadable (pre-v7). - */ - long poolSizeMax() { - try { - var v = shellReadFile(pathJoin(SYSFS_PARAMS, "pool_size_max")).trim(); - if (!v.isEmpty()) return Long.parseLong(v); - } catch (Exception ignored) { - } - return -1; - } - - private long meminfoKb(@NonNull String key) { - var prefix = fmt("%s:", key); - for (var line : safeRead("/proc/meminfo").split("\n")) { - if (!line.startsWith(prefix)) continue; - var digits = NON_DIGITS.matcher(line).replaceAll(""); - if (!digits.isEmpty()) try { - return Long.parseLong(digits); - } catch (NumberFormatException ignored) { - } - } - return -1; - } - - /** - * Run the module-shipped {@code balloon} pressure tool (see tools/balloon.c): - * it anon-balloons until MemAvailable drops under {@code floorMb} and prints - * {@code cma_before_kb / cma_after_kb / cma_diff_kb / held_mb / stop_reason}. - * Supervised with {@code timeout} (falling back to unsupervised where the - * command is missing) because on a kernel that does NOT let anon consume CMA - * the floor can fire very late or never. Returns the parsed key=value output, - * or {@code null} when the tool is absent, was killed, or printed nothing - * usable - the caller treats that as an unreadable verdict. - */ - @Nullable - Map runBalloon(long floorMb, long timeoutSec) { - var bin = pathJoin(MAGISK_BASE, "balloon"); - if (!existsSticky(bin)) return null; - var r = run("chmod 755 %s && timeout %d %s %d", - escapedString(bin), timeoutSec, escapedString(bin), floorMb); - if (r.getCode() == 127) // no timeout applet on this ROM - r = run("%s %d", escapedString(bin), floorMb); - if (!r.isSuccess()) return null; - var map = parseProp(r.getOutString()); - return map.containsKey("cma_diff_kb") ? map : null; - } - /* ================================================================== */ /* Ladder plumbing (internal) */ /* ================================================================== */ @@ -816,10 +823,10 @@ private static Try writeSettings(long pages) { /** * Read-modify-write settings.prop: apply {@code changes} (a null value * removes that key) and keep every other key (the file also carries - * app-owned CMA state - {@code pool_want_with_cma}, {@code cma_probe_result} + * app-owned CMA state - {@code pool_want_with_cma}, {@code cma_movable_lever} * - that a blind rewrite would wipe). The boot script {@code source}s the * file, so lines stay plain {@code key=value}. Locked so concurrent writers - * (the probe worker vs a pool-size save) can't interleave their read/write + * (a lever/CMA save vs a pool-size save) can't interleave their read/write * pairs and drop each other's keys. */ private static Try updateSettings(@NonNull Map changes) { @@ -830,7 +837,7 @@ private static Try updateSettings(@NonNull Map chang } catch (Exception e) { // A missing file legitimately starts empty; an EXISTING file that // failed to read must abort - rewriting from an empty map would - // silently drop every other persisted key (probe verdict, CMA + // silently drop every other persisted key (lever choice, CMA // targets) on a transient root hiccup. if (existsSticky(SETTINGS_PROP)) return Try.fail(Only.DEFAULT, "settings.prop: read failed"); diff --git a/app/src/main/res/layout/activity_hugepage.xml b/app/src/main/res/layout/activity_hugepage.xml index c8191a5..98a6e5c 100644 --- a/app/src/main/res/layout/activity_hugepage.xml +++ b/app/src/main/res/layout/activity_hugepage.xml @@ -113,18 +113,18 @@ - + + android:orientation="vertical"> + app:ti_unit="GiB" /> 模块未加载或不支持 CMA(需 v10+) CMA 不可用 本次开机模块已停用 CMA 功能(内核预检/符号解析/首块验证未通过)。 - 先前探测曾判定 CMA 不可用,该记录会让模块整个开机期间关闭 CMA。记录已清除,请重启后再次开启 CMA 以重新探测。 - 写入 pool_want_with_cma 被拒:%1$s - 无法组出任何 CMA 料场(headroom floor 拒绝,或区块翻转失败)。 - 需要先进行 CMA 探测 - 首次使用前需探测该厂商内核是否允许 App 使用 CMA 内存。\n\n探测会暂时清空池、把它整块转成 CMA 料场(目标约 %1$s),用 balloon 工具施加内存压力,再按 CMA 消耗量判读。结束后会还原池,进行前所有 VM 需先关闭。\n\n探测通过后,池大小会设为 %2$s,其余保留为料场借给 App,直到 VM 需要时才取回。 - 开始探测 - 直接进行探测 - 完成探测 - 料场会偏小 - 该模块总共只能监护 %1$s,少于探测想要的 %2$s,判读结果可能不准确。仍要探测吗? - 尚未完成的 CMA 探测 - 已建立 CMA 料场但还没判读。要现在执行 balloon 测量,确认 App 是否真的用得到吗? - 建立 CMA 料场中\u2026 %1$s / %2$s - 料场未达目标 - 只组出 %1$s / %2$s,运行期的 sweep 撞到碎片墙。刚开机时内存没有碎片,同一个流程就会成功;可以重启后再次开启 CMA。或者直接用已组出的 %1$s 进行判读(可能较不准确)。 - CMA 已标记为不可用 - 先前探测结果:该设备的 App 无法使用 CMA,启用只会浪费保留池。要重新探测吗? - 重新探测 - 请先关闭所有运行中的 VM 再探测 - 我重启后再探测 - 未变更任何设置。请重启后再次开启 CMA:刚开机时内存没有碎片,料场就组得起来。 - 正在施加内存压力(balloon)\u2026 可能需要数分钟 - 探测通过:App 可使用 CMA。已启用,池大小设为 %1$s,其余作为料场借出。 - 探测结果:该设备的 App 无法使用 CMA,启用只会浪费保留池。仍要启用吗?您的选择会被记录,之后不再探测。 - 判读结果异常 - 压力共消耗 CMA %1$s / 池 %2$s(balloon 持有 %3$s,停止原因:%4$s),无法明确判读。仍要启用 CMA 吗? - 启用 - 保持关闭 - 已取消探测 - CMA 探测无法运行 + 如何启用 CMA + 该厂商内核默认不让 App 使用 CMA 保留区,请选择启用方式:\n\n• 模块 CMA:只建立保留区,不改动内核,没有崩溃风险。只有在该内核本来就允许时 App 才用得到;无论如何保留区仍能服务 VM。\n\n• 移除 CMA 限制:额外解除内核限制,让任何 App 都能使用保留区,但在部分设备上这可能导致手机崩溃并重启。会先应用但「不」保存:成功的话可再选择保存让每次开机都应用,崩溃的话直接重启即可,因为没有保存。 + 移除 CMA 限制 + 模块 CMA + 移除 CMA 限制失败:该内核没有可用的开关或挂钩。 + 每次开机都应用? + 目前运行正常。要保存让它每次开机都自动应用吗?\n\n若不保存,它只会维持到下次重启为止 —— 作为安全网,以防它之后才导致设备不稳。 + 保存 + 仅此一次 CMA 已启用 - CMA 已启用,池大小设为 %1$s,其余作为料场借出。 CMA 已停用 写入 CMA 目标失败 CMA diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 5300dd5..288034e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1001,38 +1001,16 @@ 模組未載入或不支援 CMA(需 v10+) CMA 不可用 本次開機模組已停用 CMA 功能(kernel 預檢/符號解析/首塊驗證未通過)。 - 先前探測曾判定 CMA 不可用,該紀錄會讓模組整個開機期間關閉 CMA。紀錄已清除,請重新開機後再次開啟 CMA 以重新探測。 - 寫入 pool_want_with_cma 被拒:%1$s - 無法組出任何 CMA 料場(headroom floor 拒絕,或區塊翻轉失敗)。 - 需要先進行 CMA 探測 - 首次使用前需探測此廠商核心是否允許 App 使用 CMA 記憶體。\n\n探測會暫時清空池、把它整塊轉成 CMA 料場(目標約 %1$s),用 balloon 工具施加記憶體壓力,再依 CMA 消耗量判讀。結束後會還原池,進行前所有 VM 需先關閉。\n\n探測通過後,池大小會設為 %2$s,其餘保留為料場借給 App,直到 VM 需要時才取回。 - 開始探測 - 直接進行探測 - 完成探測 - 料場會偏小 - 此模組總共只能監護 %1$s,少於探測想要的 %2$s,判讀結果可能不準確。仍要探測嗎? - 尚未完成的 CMA 探測 - 已建立 CMA 料場但還沒判讀。要現在執行 balloon 量測,確認 App 是否真的用得到嗎? - 建立 CMA 料場中\u2026 %1$s / %2$s - 料場未達目標 - 只組出 %1$s / %2$s,執行期的 sweep 撞到碎片牆。剛開機時記憶體沒有碎片,同一個流程就會成功;可以重新開機後再次開啟 CMA。或者直接用已組出的 %1$s 進行判讀(可能較不準確)。 - CMA 已標記為不可用 - 先前探測結果:此裝置的 App 無法使用 CMA,啟用只會浪費保留池。要重新探測嗎? - 重新探測 - 請先關閉所有執行中的 VM 再探測 - 我重開機後再探測 - 未變更任何設定。請重新開機後再次開啟 CMA:剛開機時記憶體沒有碎片,料場就組得起來。 - 正在施加記憶體壓力(balloon)\u2026 可能需要數分鐘 - 探測通過:App 可使用 CMA。已啟用,池大小設為 %1$s,其餘作為料場借出。 - 探測結果:此裝置的 App 無法使用 CMA,啟用只會浪費保留池。仍要啟用嗎?您的選擇會被記錄,之後不再探測。 - 判讀結果異常 - 壓力共消耗 CMA %1$s / 料場 %2$s(balloon 持有 %3$s,停止原因:%4$s),無法明確判讀。仍要啟用 CMA 嗎? - 啟用 - 維持關閉 - 已取消探測 - CMA 探測無法執行 + 如何啟用 CMA + 此廠商核心預設不讓 App 使用 CMA 保留區,請選擇啟用方式:\n\n• 模組 CMA:只建立保留區,不改動核心,沒有崩潰風險。只有在此核心本來就允許時 App 才用得到;無論如何保留區仍能服務 VM。\n\n• 移除 CMA 限制:額外解除核心限制,讓任何 App 都能使用保留區,但在部分裝置上這可能造成手機崩潰並重新開機。會先套用但「不」存檔:成功的話可再選擇存檔讓每次開機都套用,崩潰的話直接重新開機即可,因為沒有存檔。 + 移除 CMA 限制 + 模組 CMA + 移除 CMA 限制失敗:此核心沒有可用的開關或掛鉤。 + 每次開機都套用? + 目前運作正常。要存檔讓它每次開機都自動套用嗎?\n\n若不存檔,它只會維持到下次重新開機為止 —— 作為安全網,以防它之後才造成裝置不穩。 + 存檔 + 僅此一次 CMA 已啟用 - CMA 已啟用,池大小設為 %1$s,其餘作為料場借出。 CMA 已停用 寫入 CMA 目標失敗 CMA diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59d0166..165aa35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1038,38 +1038,16 @@ Module not loaded or has no CMA support (v10+) CMA unavailable The module disabled its CMA side this boot (kernel preflight / symbol resolution / first-block verification failed). - A previous probe recorded CMA as unusable, which keeps the module\'s CMA side off for the whole boot. That record has been cleared - reboot, then enable CMA again to re-probe. - Writing pool_want_with_cma was rejected: %1$s - No CMA reservoir could be assembled (the headroom floor refused it, or block flipping failed). - CMA probe required - Before first use, a probe must verify this vendor kernel lets apps allocate from CMA.\n\nIt temporarily empties the pool into a CMA reservoir (aiming for %1$s), applies memory pressure with the balloon tool, then judges from how much CMA got consumed. The pool is restored afterwards, and all VMs must be stopped first.\n\nOn success the pool is set to %2$s and the rest is kept as reservoir, lent to apps until a VM needs it. - Start probe - Probe anyway - Finish probe - Reservoir will be small - This module can only guard %1$s in total, less than the %2$s the probe wants to measure against, so the verdict may be unreliable. Probe anyway? - Unfinished CMA probe - A CMA reservoir was set up but never measured. Run the balloon measurement now to decide whether apps can really use it? - Building the CMA reservoir\u2026 %1$s / %2$s - Reservoir came up short - Assembled %1$s of %2$s - the runtime sweep hit the fragmentation wall. On a fresh boot memory is unfragmented, so the same run succeeds; reboot and turn CMA on again. Or measure right now against the %1$s that did assemble (the verdict may be less reliable). - CMA marked unusable - A previous probe found apps cannot use CMA on this device, so enabling it would only waste the reserve. Run the probe again? - Re-probe - Stop all running VMs before probing - I\'ll reboot, then probe - Nothing was changed. Reboot, then turn CMA on again - on a fresh boot memory is unfragmented and the reservoir assembles. - Applying memory pressure (balloon)\u2026 this can take several minutes - Probe passed: apps can use CMA. CMA enabled and the pool set to %1$s - the rest is lent out as reservoir. - Probe result: apps could not use CMA on this device, so enabling it would only waste the reserve. Enable anyway? Your choice is recorded and the probe will not run again. - Probe result unreadable - Pressure consumed %1$s of the %2$s reservoir (balloon held %3$s, stopped: %4$s). This matches neither verdict. Enable CMA anyway? - Enable - Keep off - Probe cancelled - CMA probe could not run + How to enable CMA + This vendor kernel does not let apps use the CMA reserve on its own, so choose how to enable it:\n\n- Module CMA: builds the reserve without changing the kernel, so there is no crash risk. Apps can use it only where the vendor kernel already allows it; either way the reserve still serves VMs.\n\n- Remove CMA Restriction: also lifts the kernel restriction so any app can use the reserve, but on some devices this can crash the phone and force a reboot. It is applied WITHOUT saving first; if it works you can then save it for every boot, and if it crashes just reboot - nothing was saved. + Remove CMA Restriction + Module CMA + Remove CMA Restriction failed: this kernel has no usable switch or hook. + Apply on every boot? + It is working now. Save this so it comes back automatically at every boot?\n\nIf you don\'t save, it stays active only until the next reboot - a safety net in case it destabilises the device. + Save + Just this time CMA enabled - CMA enabled and the pool set to %1$s - the rest is lent out as reservoir. CMA disabled Failed to write the CMA target CMA