Kindness mode: stop the stopped proxy, survive being killed, clean up UPnP by munzzyy · Pull Request #1807 · guardianproject/orbot-android · GitHub
Skip to content

Kindness mode: stop the stopped proxy, survive being killed, clean up UPnP - #1807

Merged
bitmold merged 10 commits into
guardianproject:masterfrom
munzzyy:kindness-stays-on
Sep 15, 2026
Merged

bitmold merged 10 commits into
guardianproject:masterfrom
munzzyy:kindness-stays-on

Conversation

@munzzyy

@munzzyy munzzyy commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Kindness mode has a habit of quietly not being on anymore. This traces the three ways that happens and closes each of them.

The stopped proxy that keeps running (#1183). enableProxy() takes its lock, then does the real work in a coroutine that has no lock at all. A stopProxy() that lands before that coroutine runs sees proxy == null, does nothing, and the coroutine then starts a proxy nothing owns. The service stops and restarts the proxy on every network callback, so this window gets hit in normal use, and an orphaned proxy is a plausible source of the CPU drain reported there. Starts now carry a generation number: if a stop or a newer start won while the ports were being mapped, the late start closes its own mappings and never calls start().

There is a second copy of the same race one layer down. IPtProxy's Start() flips its isRunning flag inside a goroutine it spawns, and its Stop() is a no-op until that flag is set, so a fast stop is silently ignored and the proxy comes up anyway:

go func() {
    sp.isRunning = true
    err := sp.proxy.Start()
    ...
}()

func (sp *SnowflakeProxy) Stop() {
    if sp.isRunning {
        ...

That matches cohosh's shutdown race hypothesis in the issue. The app cannot fix the library, but it can stop dropping its reference too early: after stop(), the wrapper watches the instance for two seconds and stops it again if it came up late. The remainder, if any, is upstream in IPtProxy.

The service that stays dead (#1799, #1783). START_STICKY is the only thing that brought the service back, and sticky restarts get throttled, abandoned after repeated kills, and never delivered at all on some builds. The only re-arm paths were opening the app or toggling the switch, which is why people find the proxy off hours later. The pref is already the user's standing intent, so it gets honored now: OnBootReceiver starts the service after a reboot when kindness mode is on, and a 15 minute WorkManager watchdog (the dependency was already in the app) restarts it when it is wanted, allowed in this region, and not running. The watchdog cancels itself when the user turns kindness mode off. A background start denial on 31+ is caught and left for the next boot or app open. This will not resurrect the service inside a stopped GrapheneOS work profile, nothing can, but within a running profile the watchdog covers the kill-and-forget case.

The port mappings that outlive the process (#1795). Mapped UPnP ports lived in one in-memory list, so any abnormal termination left them open on the router until the lease expired, with nothing on the next launch knowing they existed. The mapping is written to prefs before the proxy starts, cleared on clean release, and releaseStalePorts() closes whatever a dead run left behind on the next service start.

Fixes #1183, fixes #1799, fixes #1795. Helps #1783 within what the platform allows.

Compatible with #1805: this deliberately stays out of initNetworkCallbacks(), so both apply cleanly in either order.

Verified with the unit suite (eleven new tests: the start-commit and watchdog decision tables, and the port record parsing) and assembleFullpermDebug, both clean. I do not have a device farm, so the multi-hour soak that #1799 describes is exactly the test this needs from someone who does. Happy to adjust anything.

One thing I noticed but left alone, since it is outside these issues: the preference listener in SnowflakeProxyService.onCreate returns early unless a key equals two different constants at once, so the region kill switch and the camo refresh there look unreachable. Can file separately if useful.

…s restarts guardianproject#1183 guardianproject#1795

enableProxy() did its real work in an unsynchronized coroutine, so a stop
arriving before that coroutine ran was a no-op and the proxy it then
started belonged to nobody. The service stops and restarts the proxy on
every network event, which made that window easy to hit. Starts now carry
a generation number and back off if a stop or newer start won the race.
IPtProxy has the same shape internally (start() flips isRunning inside
its own goroutine, stop() is a no-op until then), so a stop also watches
for a couple of seconds and puts down a proxy that came up late.

Mapped UPnP ports were only tracked in memory, so a killed process left
them open on the router until the lease expired. The mapping is written
to prefs before the proxy starts and cleared on release; releaseStalePorts()
closes anything a dead run left behind.
…t#1799 guardianproject#1783

START_STICKY restarts get throttled and some OEMs never deliver them, so
a killed SnowflakeProxyService stayed dead until someone opened the app
and noticed. The pref is already the user's standing intent, so honor it:
OnBootReceiver starts the service after a reboot when kindness mode is
on, and a 15 minute WorkManager watchdog restarts it if it is wanted,
allowed in this region, and not running. The watchdog cancels itself when
the user turns kindness mode off, and a denied background start is left
for the next boot or app open rather than crashing.

The service also releases stale UPnP mappings at startup now, closing the
loop on what a killed process left mapped.
@meenbeese

Copy link
Copy Markdown
Contributor

@munzzyy

munzzyy commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @meenbeese, that makes sense for the counter reset. A PeriodicWorkRequest is a lot of machinery to zero one integer, and dropping the dependency for that is fair.

The watchdog here does a different job: re-arming Kindness Mode after the service gets killed while the user still wants it running. START_STICKY on its own wasn't enough in testing (that's the note in KindnessWatchdogWorker), so it ended up as a Worker.

If the plan is to drop androidx.work across the board, what would you want for the periodic re-arm instead, an AlarmManager setExactAndAllowWhileIdle loop? I'll rework this around whatever the project lands on, as long as it keeps surviving a kill. Reading through #1802 now.

@meenbeese

Copy link
Copy Markdown
Contributor

The plan is to drop AndroidX Work at the moment. Can you look at JobScheduler or a simple foreground service as replacement options? I didn't really look at the code that much but one of those should work here.

@munzzyy

munzzyy commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Makes sense. JobScheduler is a clean fit, since that's what WorkManager runs on underneath, so I can drop the androidx.work dependency and keep the same survive-a-kill behavior.

One note on the foreground-service option: the watchdog exists to come back after the service is already gone (force-stop, OEM battery killers), and a plain foreground service doesn't get re-armed once it's killed. That's the START_STICKY gap the Worker was covering, so I'll go the JobScheduler route rather than a bare service.

I'll rework it onto JobScheduler and push an update here.

Swaps the Kindness Mode watchdog off androidx.work per feedback on this
PR. WorkManager runs on top of JobScheduler anyway, so the actual
survive-a-kill behavior doesn't change, but we drop a dependency this
one file was pulling in just for a periodic check. The androidx.work
gradle dependency itself stays for now since Prefs and
ResetSnowflakesServedWeeklyWorker still use it; that's getting removed
in a separate PR.

shouldRestart() keeps the same signature and logic, just moved onto
KindnessWatchdogJob. The job registers in the manifest like any other
JobService and gets scheduled/cancelled through JobScheduler directly.
@munzzyy

munzzyy commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the JobScheduler version (2cc1be6). The watchdog is a JobService now, a 15-minute periodic job with setPersisted so it still comes back after a reboot, and the same shouldRestart logic and tests carried over.

I left androidx.work in the build for now, since the weekly snowflake-count reset still uses it. Once #1802 lands and that goes away, the dependency drops out entirely. Compiles clean and the unit tests pass.

@meenbeese meenbeese left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@bitmold

bitmold commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

This one is exciting, but a bit involved - I'll test and review this soon. If this works as well as you think it does, and touches all of these longstanding issues you referenced above, then this is a very special PR. Thanks again <3

@bitmold bitmold self-assigned this Sep 13, 2026
@bitmold bitmold added the snowlfake proxy AKA kindness mode - using Orbot to help others connect to Tor Network via Snowflake Bridges label Sep 13, 2026
bitmold added a commit to munzzyy/orbot-android that referenced this pull request Sep 14, 2026
- for guardianproject#1795 the possibly stale UPnP ports issue, move the call to clean up lingering ports directly into SnowflakeProxyWrapper's constructor to prevent a possible future regression
- also move the call to start the new KindnessWatchdogJob there
- various lint fixes
- renamed unclear methods, removed superflous helper methods,
- add comments for unclear magic return false's and such things
bitmold added a commit to munzzyy/orbot-android that referenced this pull request Sep 14, 2026
- for guardianproject#1795 the possibly stale UPnP ports issue, move the call to clean up lingering ports directly into SnowflakeProxyWrapper's constructor to prevent a possible future regression
- also move the call to start the new KindnessWatchdogJob there
- various lint fixes
- renamed unclear methods, removed superflous helper methods,
- add comments for unclear magic return false's and such things
bitmold added a commit to munzzyy/orbot-android that referenced this pull request Sep 15, 2026
- for guardianproject#1795 the possibly stale UPnP ports issue, move the call to clean up lingering ports directly into SnowflakeProxyWrapper's constructor to prevent a possible future regression
- also move the call to start the new KindnessWatchdogJob there
- various lint fixes
- renamed unclear methods, removed superflous helper methods,
- add comments for unclear magic return false's and such things
- add test handling integer overflow
- for guardianproject#1795 the possibly stale UPnP ports issue, move the call to clean up lingering ports directly into SnowflakeProxyWrapper's constructor to prevent a possible future regression
- also move the call to start the new KindnessWatchdogJob there
- various lint fixes
- renamed unclear methods, removed superflous helper methods,
- add comments for unclear magic return false's and such things
- add test handling integer overflow
- add logs, especially to KindnessWatchdogJob
- remove unhelpful and cluttering log from recent work in PreferenceProvider.kt
@bitmold
bitmold merged commit f82ca6f into guardianproject:master Sep 15, 2026
@bitmold

bitmold commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

This is great work. I extended it a bit, merged it to master, and then added some stability tweaks of my own to kindness mode.

It's easy to see that it fixes:

I am inclined to agree with your theory about #1183 above, but I don't feel comfortable closing that issue yet. Until we get a lot more testing and various other people measuring and observing their CPU usage.

@munzzyy

munzzyy commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

snowlfake proxy AKA kindness mode - using Orbot to help others connect to Tor Network via Snowflake Bridges

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] 20% CPU overhead in kindness mode [BUG] Kindness mode turning off and no connections Stale UPnP Mappings via In-Memory Tracking

3 participants