7.9 KiB
Capacity limits
Every hard ceiling on the runtime path: where it lives, what it is measured at,
what binds first, and what raising it would cost. Read this before changing
MAX_TARGET_UIDS, the wire format, or a backend's static arrays — several of
these ceilings are in third-party code we cannot move, and the interesting ones
are not where people expect.
Numbers here are measured, not estimated; see Re-measuring so they can be checked rather than trusted.
Two paths, two ceilings
The single most common mistake is treating these as one budget:
control (config) |
telemetry (stats, status) |
|
|---|---|---|
| direction | activator → backend | backend → app |
| bounds | how many apps can be protected | how many apps report counters in one read |
| overflow costs | apps left unprotected | numbers missing from a screen |
They are separate protocols with separate versions (protocol.md §3). A telemetry ceiling cannot limit how many apps are hidden, and vice versa. "We can't raise the app count because stats won't fit" conflates the two.
Control — how many apps can be protected
| constraint | where | value | binds at |
|---|---|---|---|
MAX_TARGET_UIDS |
crates/protocol/src/lib.rs, mirrored in both backends |
160 | 160 targets — currently binding |
KPM_ARGS_LEN |
kmod/third_party/KernelPatch/kernel/include/kpmodule.h |
1024 B | ~190 targets sharing one mask; fewer with many distinct masks |
ctl_write payload cap |
kmod/vpnhide_kmod.c (count > PAGE_SIZE) |
4096 B | ~800 targets |
| config parse snapshot | .ko heap; KPM serialized static scratch |
8 B/target | does not grow either kernel stack |
Measured control payloads under the v2 grammar (one shared full kernel hookmask, five-digit UIDs):
| targets | bytes | KPM (1024) | kmod (4096) |
|---|---|---|---|
| 40 | 248 | ok | ok |
| 64 | 368 | ok | ok |
| 64, half in a work profile | 400 | ok | ok |
| 120 | 648 | ok | ok |
| 160 | 848 | ok | ok |
| 200 | 1048 | over | ok |
Two things follow.
KPM_ARGS_LEN is not ours. It is a fixed buffer in KernelPatch, baked into
the user's patched boot image, and compat_strncpy_from_user truncates into it
silently. Before the v2 grammar a 64-target config was ~1.6 KB and so could
not be delivered at all — it was cut around 40 targets, mid-line, with no
signal. Grouping by hookmask and dropping the 0x prefix cut that to 368 bytes;
the mandatory end <count> record turned any remaining overflow into a loud
whole-payload rejection. See protocol.md §4.3.
The cap counts UIDs, not apps. resolver.uids_for() returns every UID a
package has across profiles, so an app present in a work profile spends two
slots. The picker allows 159 selected-app UIDs and reserves the final slot for
VPN Hide's main-profile UID. A user selecting apps present in both the main and
work profiles therefore reaches the picker limit at around 79 apps.
Telemetry — how many apps report counters
There is no fixed output app count. A uid's text cost depends on how many hooks fired for it and how large its cumulative counters grew. Representative single-record sizes from the real formatter, for 5-digit uids:
| profile | bytes/uid | approximate rows in one 4096-byte KPM page |
|---|---|---|
| 2 hooks, counts < 256 | 25 | 160 |
| 4 hooks, counts < 65k | 51 | 79 |
| 8 hooks, counts ~1e6 | 103 | 39 |
| all 12 KPM hooks, saturated u64 | ~285 | ~14 |
The two backends are not in the same position:
- KPM — each ctl0 reply remains capped at 4096 B by the module and both clients. The backend pages by ascending UID; the activator validates and aggregates every page before exposing one normal telemetry block to the app.
- kmod —
/proc/vpnhide_ctlis a realseq_operationsstream with one UID per record. There is no whole-output buffer or formatter ceiling.
KPM keeps no serialisation snapshot. Its live table stores only the 11 shared
kernel hook counters plus its optional filesystem hook rather than the 28-id global registry, and a zero UID
is the hash-table empty sentinel, so no separate stats_used array is needed.
At the current 160-target cap, including the static config parse scratch, the
resulting .bss is 19080 B. That is still below the 23120 B used by the
old 64-target layout before compact storage and cursor output.
Raising a ceiling — options and cost
Ordered by cost. None of these are scheduled; this is the menu.
Control
- The shipped cap is 160. Compact 12-hook stats storage and cursor output
keep KPM
.bssat 19080 B, and config parsing uses serialized static scratch rather than growing the KPM stack. A typical one-mask, five-digit-UID wire is 848 B and fits the KPM transport; maximum-width UIDs or many distinct masks may hit the independent byte limit sooner. The activator validates the exact formatted size and rejects the whole KPM update rather than sending a truncated target set. - Past ~190 targets on the KPM, the 1024-byte transport is the wall. The
only way through is chunking —
config-begin/config-chunk/config-commitacross several ctl0 calls — which is a control-protocol change. The.koneeds none of this; raising itsPAGE_SIZEcheck is a one-liner. - Whitelist mode makes the whole question moot for the kernel backends:
with a non-zero
defaultthe enumerated set becomes the exception list, a handful of entries, and every ceiling above stops binding. The wire already carries the mechanism (default <hookmask>); nothing emits it yet. Zygisk cannot honour the inverted meaning without injecting into every app process, so its activator rejects a non-zero default. See issue #248 for the design discussion and the reasons it should be opt-in rather than default.
Telemetry
- Bare hex in a future telemetry v2 — the same trick control v2 used:
0x3:0xf4240→3:f4240, four bytes per cell, roughly +45% uids per read at the heavy profile. Cheap in code, expensive in delivery: telemetry's reader is the app, so bumping it means shipping the APK in step with every module. Only worth bundling with some other telemetry change. - Cap by policy, not by configuration — emit the top-N uids by total and mark that it happened. The user gets numbers for the apps that did something and configures nothing.
Explicitly not recommended: a user-facing "collect stats for these apps" list. It makes the user maintain a second selection whose mistakes are invisible — no numbers looks identical to nothing happened — to solve a problem that is telemetry-only and already degrades visibly.
User-visible overflow reporting
Target-set overflow is surfaced after Save: the activator emits a stable
vpnhide-warning native_target_cap marker with the resolved UID totals, the app
captures activator stderr, and the picker shows a localized long-duration
warning instead of the normal success message. The canonical package selection
is still saved in full; the warning describes the capped native runtime
projection.
Re-measuring
Nothing here should be trusted because it is written down. The representative
control-side figure is guarded by a test — crates/protocol asserts that all
MAX_TARGET_UIDS ordinary Android app UIDs, using one shared full mask, fit in
KPM_ARGS_LEN with room for its trailing NUL. Maximum-width UIDs and arbitrary
per-app masks can cost more bytes, so the activator also checks the formatted
wire's actual length before KPM delivery.
The telemetry table is not guarded, because its input is a usage profile rather
than a constant. To redo it, format n uids × k hooks with
vpnhide_format_stats from kmod/shared/vpnhide_logic.h (a dozen lines of host
C, built with gcc -I kmod) and divide by the buffer under test. The KPM .bss
figure comes from llvm-size --format=sysv kmod/vpnhide.kpm after
python3 kmod/kpm/build.py.