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 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..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 @@ -38,6 +38,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.progressindicator.LinearProgressIndicator; +import android.text.Editable; + import java.math.BigInteger; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -49,6 +51,7 @@ 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 +105,19 @@ 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; // 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 private TextRowWidget rowStatState; private TextRowWidget rowStatTotalServed; private TextRowWidget rowStatTotalRefilled; @@ -137,6 +153,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 +171,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) @@ -189,6 +225,10 @@ private void initialize() { 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); } /** @@ -255,6 +295,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 +421,23 @@ 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; + runOnUiThread(() -> + updateUI(snap, crashStamp, owners, allPids, vmMap, cmaUsage)); }); } @@ -376,7 +459,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 +475,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 +493,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 +508,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 +597,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 +642,37 @@ 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 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(); + if (rowCmaEnable.isChecked() != cmaActive) { + cmaSwitchSyncing = true; + rowCmaEnable.setChecked(cmaActive); + cmaSwitchSyncing = false; + } + // 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) { + if (!cmaInputLoaded) { + sizeLinkSyncing = true; + try { + inputCmaSize.setBigValue( + BigInteger.valueOf(snap.wantWithCma * PAGE_SIZE)); + } finally { + sizeLinkSyncing = false; + } + cmaInputLoaded = true; + } + } else { + cmaInputLoaded = false; + } + } } /** @@ -599,7 +761,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 +814,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.isEnabled()) return; // only linked while CMA is on + 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 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.isEnabled()) { + 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 +864,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 +904,270 @@ private void doToggleModule() { }); } + /* ================================================================== */ + /* v11 CMA reservoir: switch + movable->CMA levers */ + /* ================================================================== */ + + 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: 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; + 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. Plain movable allocations only reach the reservoir if the + * kernel redirects movable->CMA: + *
    + *
  • 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() { + 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; + } + if (snap.cmaPbOrder < 0) { + // 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(R.string.hugepage_cma_unavailable_boot) + .setPositiveButton(android.R.string.ok, null) + .show(); + }); + return; + } + 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; + } + 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(); + }); + }); + } + + /** + * 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 = reservoirTarget(snap); + 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(); + }); + }); + } + + /** + * 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 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; + } + buildReservoirAndPromptSave(snap, lever); + }); + } + + /** + * 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 void enableCmaReservoirOnly(@NonNull HugePageModel.Snapshot snap) { + cmaBusy = true; + runOnPool(() -> buildReservoirAndPromptSave(snap, null)); + } + + /** + * 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 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); + } + runOnUiThread(() -> { + cmaInputLoaded = false; + refreshStatus(); + if (isFinishing()) { + cmaBusy = false; + return; + } + promptSaveLever(lever, target); + }); + } + + /** Flip the restrict flag on and confirm the kernel actually opened it. */ + private boolean flipFlagWorks() { + return model.setRestrictFlip(true).ok() && model.readRestrictState() == 1; + } + + /** 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)); + } + + /** + * 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(); + } + + /** + * 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); + } + runOnUiThread(() -> { + cmaBusy = false; + setCmaSwitch(true); + Toast.makeText(this, R.string.hugepage_cma_enabled, LENGTH_SHORT).show(); + cmaInputLoaded = false; + 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..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 @@ -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,218 @@ Result stopAcquire() { return t.ok() ? Result.ok("acquire") : Result.unsupported(t.error); } + /* ================================================================== */ + /* 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"; + + /** + * 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. + */ + boolean kernelIs61() { + try { + var r = runList("uname", "-r").getOutString().trim(); + return r.equals("6.1") || r.startsWith("6.1."); + } catch (Exception e) { + return false; + } + } + + /** + * 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 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_LEVER_KEY, lever); + var t = updateSettings(changes); + return t.ok() ? Result.ok("settings") : Result.failed("settings", t.error); + } + + /** Forget the persisted lever (CMA switched off, or the user declined save). */ + @NonNull + Result clearCmaLever() { + var changes = new LinkedHashMap(); + 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 + * {@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 (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)); + return t.ok() ? Result.ok("pool_want_with_cma") + : Result.failed("pool_want_with_cma", t.error); + } + /* ================================================================== */ /* 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 +692,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 +726,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 +735,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 +811,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_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 + * (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) { + 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 (lever choice, 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/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); } 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/layout/activity_hugepage.xml b/app/src/main/res/layout/activity_hugepage.xml index 44b5712..98a6e5c 100644 --- a/app/src/main/res/layout/activity_hugepage.xml +++ b/app/src/main/res/layout/activity_hugepage.xml @@ -113,17 +113,40 @@ - + + android:orientation="vertical"> + + + + + + + #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..3d1956e 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,36 @@ 停止获取 获取完成:已达成 %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 + 该厂商内核默认不让 App 使用 CMA 保留区,请选择启用方式:\n\n• 模块 CMA:只建立保留区,不改动内核,没有崩溃风险。只有在该内核本来就允许时 App 才用得到;无论如何保留区仍能服务 VM。\n\n• 移除 CMA 限制:额外解除内核限制,让任何 App 都能使用保留区,但在部分设备上这可能导致手机崩溃并重启。会先应用但「不」保存:成功的话可再选择保存让每次开机都应用,崩溃的话直接重启即可,因为没有保存。 + 移除 CMA 限制 + 模块 CMA + 移除 CMA 限制失败:该内核没有可用的开关或挂钩。 + 每次开机都应用? + 目前运行正常。要保存让它每次开机都自动应用吗?\n\n若不保存,它只会维持到下次重启为止 —— 作为安全网,以防它之后才导致设备不稳。 + 保存 + 仅此一次 + CMA 已启用 + 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..288034e 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,28 @@ 等待獲取 pid %1$d 可用 + + + 啟用 CMA + 閒置保留以 CMA 形式借給 App 使用,透過獲取取回 + 含 CMA 池大小 + 模組未載入或不支援 CMA(需 v10+) + CMA 不可用 + 本次開機模組已停用 CMA 功能(kernel 預檢/符號解析/首塊驗證未通過)。 + 如何啟用 CMA + 此廠商核心預設不讓 App 使用 CMA 保留區,請選擇啟用方式:\n\n• 模組 CMA:只建立保留區,不改動核心,沒有崩潰風險。只有在此核心本來就允許時 App 才用得到;無論如何保留區仍能服務 VM。\n\n• 移除 CMA 限制:額外解除核心限制,讓任何 App 都能使用保留區,但在部分裝置上這可能造成手機崩潰並重新開機。會先套用但「不」存檔:成功的話可再選擇存檔讓每次開機都套用,崩潰的話直接重新開機即可,因為沒有存檔。 + 移除 CMA 限制 + 模組 CMA + 移除 CMA 限制失敗:此核心沒有可用的開關或掛鉤。 + 每次開機都套用? + 目前運作正常。要存檔讓它每次開機都自動套用嗎?\n\n若不存檔,它只會維持到下次重新開機為止 —— 作為安全網,以防它之後才造成裝置不穩。 + 存檔 + 僅此一次 + CMA 已啟用 + CMA 已停用 + 寫入 CMA 目標失敗 + CMA + CMA(借出) 虛擬機器:%1$s 非追蹤中的虛擬機器 狀態:%1$c (%2$s) 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 @@ + + + + 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..165aa35 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,28 @@ 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). + 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 disabled + Failed to write the CMA target + CMA + CMA (lent) VM: %1$s Not a tracked VM State: %1$c (%2$s)