Skip to content

Build config off the main thread; register tuic/juicity import schemes#118

Merged
hawkff merged 3 commits into
mainfrom
fix/mainthread-buildconfig-and-quic-schemes
Jul 3, 2026
Merged

Build config off the main thread; register tuic/juicity import schemes#118
hawkff merged 3 commits into
mainfrom
fix/mainthread-buildconfig-and-quic-schemes

Conversation

@hawkff

@hawkff hawkff commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Follow-up to #117. Device testing surfaced the main-thread-DB site the Plan 027 flag was designed to catch: the connect path built the config (synchronous group/profile DAO reads via buildConfig) inside runOnMainDispatcher, so with the allowance off in debug, starting a profile threw Cannot access database on the main thread and the service failed to start.

Fixes

  • BaseService: run proxy.init() (which calls buildConfig -> groupDao.getById and other DAO reads) on onDefaultDispatcher instead of the main dispatcher; notification/state/UI calls stay on main. init() is suspend and touches no UI.
  • AndroidManifest: add tuic and juicity to the profile-import VIEW intent-filter for parity (the Formats.kt parser already handles both; only the manifest deep-link entry was missing, so they imported only via QR/paste before).

Testing

  • Namespace CI (unit, lint+spotless, guards, APK).
  • Device: connect a TUIC and a Juicity profile with the allowance off (debug) — service starts, no main-thread-DB error, egress works.

Greptile Summary

This PR fixes a main-thread database access crash introduced by Plan 027 (which removes the main-thread-DB allowance in debug builds): proxy.init() — and the buildConfig / DAO reads it calls — is now dispatched to onDefaultDispatcher inside onStartConnect, and the TrafficLooper.stop() DB flush in ProxyInstance.close() is moved to Dispatchers.Default via runBlocking. It also completes the profile-import intent-filter by registering tuic, juicity, hysteria2, hy2, vless, anytls, and snell URI schemes that Formats.kt already handled but the manifest was missing.

  • BaseService.kt: proxy.init() is wrapped in onDefaultDispatcher { }, keeping all UI/state calls on Main; resolveSelectorReloadTag() is extracted to perform the selector DAO reads off-Main before reloadInner() is posted to Main.
  • ProxyInstance.kt: runBlocking in close() gains Dispatchers.Default so the final traffic-stat flush does not touch the DB on the Main thread.
  • AndroidManifest.xml: Seven missing URI schemes are added to the VIEW intent-filter, giving them first-class deep-link import on par with QR/paste.

Confidence Score: 5/5

The changes are safe to merge — the dispatcher refactoring is correct and the manifest additions are straightforward.

The core fix (moving proxy.init() to onDefaultDispatcher and looper.stop() to Dispatchers.Default) correctly targets the DB-on-main crash without introducing regressions. The selector reload path still re-verifies state on the Main dispatcher before acting, so stale off-thread reads are benign. The only inaccuracy is a comment claiming displayProfileName is computed "off the main thread" when ProxyInstance is still constructed on Main — worth fixing for clarity but does not affect runtime behavior beyond the pre-existing genTitle/showGroupInNotification case.

BaseService.kt — the misleading comment on line 552 and the residual on-main genTitle call (when showGroupInNotification is enabled) are worth a follow-up cleanup pass.

Important Files Changed

Filename Overview
app/src/main/AndroidManifest.xml Adds tuic, juicity, hysteria2, hy2, vless, anytls, and snell URI schemes to the profile-import VIEW intent-filter, aligning the manifest with what Formats.kt already parses.
app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt Moves proxy.init() to onDefaultDispatcher to fix main-thread DB access; extracts resolveSelectorReloadTag() to do DAO reads off-main; comment on displayProfileName (line 552) incorrectly claims it is computed "off the main thread" when ProxyInstance is constructed on Main.
app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt Changes runBlocking to runBlocking(Dispatchers.Default) so the final traffic-stat flush in looper.stop() happens on a background thread, satisfying the Plan 027 main-thread-DB restriction.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Main as Main Thread
    participant Default as Default Dispatcher
    participant DB as SagerDatabase

    Main->>Default: runOnDefaultDispatcher
    Default->>DB: proxyDao.getById(selectedProxy)
    DB-->>Default: profile
    Default->>Main: onMainDispatcher / onStartConnect
    Main->>Main: ProxyInstance constructor + genTitle
    Main->>Main: runOnMainDispatcher - preInit
    Main->>Default: onDefaultDispatcher - proxy.init
    Default->>DB: buildConfig groupDao/proxyDao reads
    DB-->>Default: config built
    Default-->>Main: resume
    Main->>Main: startProcesses changeState Connected

    Note over Main,Default: On stop
    Main->>Default: runBlocking Dispatchers.Default looper.stop
    Default->>DB: traffic stat flush
    DB-->>Default: done
    Default-->>Main: unblock

    Note over Main,Default: reload path
    Default->>DB: resolveSelectorReloadTag proxyDao groupDao
    DB-->>Default: selectorTag
    Default->>Main: onMainDispatcher reloadInner
    Main->>Main: box.selectOutbound(selectorTag)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Main as Main Thread
    participant Default as Default Dispatcher
    participant DB as SagerDatabase

    Main->>Default: runOnDefaultDispatcher
    Default->>DB: proxyDao.getById(selectedProxy)
    DB-->>Default: profile
    Default->>Main: onMainDispatcher / onStartConnect
    Main->>Main: ProxyInstance constructor + genTitle
    Main->>Main: runOnMainDispatcher - preInit
    Main->>Default: onDefaultDispatcher - proxy.init
    Default->>DB: buildConfig groupDao/proxyDao reads
    DB-->>Default: config built
    Default-->>Main: resume
    Main->>Main: startProcesses changeState Connected

    Note over Main,Default: On stop
    Main->>Default: runBlocking Dispatchers.Default looper.stop
    Default->>DB: traffic stat flush
    DB-->>Default: done
    Default-->>Main: unblock

    Note over Main,Default: reload path
    Default->>DB: resolveSelectorReloadTag proxyDao groupDao
    DB-->>Default: selectorTag
    Default->>Main: onMainDispatcher reloadInner
    Main->>Main: box.selectOutbound(selectorTag)
Loading

Reviews (3): Last reviewed commit: "fix(manifest): register all parsed impor..." | Re-trigger Greptile

…rt schemes

The connect path ran proxy.init() -> buildConfig() (synchronous group/profile DAO
reads) inside runOnMainDispatcher, so with the main-thread-DB allowance removed in debug
(Plan 027) starting a profile threw 'Cannot access database on the main thread' and the
service failed to start. Wrap proxy.init() in onDefaultDispatcher so the config build and
its DAO reads run off the UI thread; the surrounding notification/state/UI calls stay on
main. This is the main-thread site the 027 flag was designed to surface (device-caught
on a real connect).

Also register the tuic and juicity schemes in the profile-import VIEW intent-filter for
parity with the other protocols (the parser in Formats.kt already handles both; only the
manifest deep-link entry was missing, so they previously imported only via QR/paste).
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds new profile import URI schemes and moves selector reload, proxy initialization, and proxy teardown work onto background dispatchers.

Changes

Manifest and service lifecycle updates

Layer / File(s) Summary
Add profile import URI schemes
app/src/main/AndroidManifest.xml
Additional <data android:scheme=... /> entries were added to the profile_import intent-filter.
Resolve selector reload tag off main thread
app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
reload() now resolves a selector tag before calling reloadInner(), and reloadInner() uses that tag for the connected fast-path instead of doing its own selector lookup.
Dispatch proxy lifecycle work
app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt, app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt
proxy.init() now runs via onDefaultDispatcher, and ProxyInstance.close() uses runBlocking(Dispatchers.Default) for its blocking teardown.
Estimated code review effort: 4 (Complex) ~45 minutes

Possibly related PRs

Poem

A rabbit hops through schemes anew,
off-thread the selectors softly brew.
Init and close no longer race,
they dart in background, quick and with grace.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title matches the main changes: moving config work off the main thread and adding import schemes.
Description check ✅ Passed The description is directly related to the manifest and dispatcher changes in the pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

Comment thread app/src/main/AndroidManifest.xml
… thread

Device StrictMode (allowance off) surfaced three more main-thread DAO sites beyond
buildConfig:
- ProxyInstance.close() ran the final traffic flush (persist -> updateTraffic /
  addLifetimeTraffic) via runBlocking on the main thread during teardown; dispatch it on
  Dispatchers.Default so the DAO writes run off the UI thread.
- The connect block called ServiceNotification.genTitle(profile) (a groupDao read) on the
  main dispatcher; reuse proxy.displayProfileName, which is already computed off-main at
  ProxyInstance construction.
- reloadInner() did canReloadSelector()/getById on the main thread; resolve the selector
  fast-path tag off-main in the caller (resolveSelectorReloadTag) and pass it in, so
  reloadInner touches no DB on the UI thread.

TUIC verified end-to-end on device after these: service starts, egress flows through the
tuic outbound, no 'Cannot access database on the main thread'.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt (1)

551-560: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Move ProxyInstance construction off the main dispatcher
ProxyInstance(profile, this) is created inside onMainDispatcher { ... }, so displayProfileName = ServiceNotification.genTitle(profile) still runs on the main thread. When DataStore.showGroupInNotification is enabled, that can still perform a groupDao read on the UI thread and crash with the main-thread DB check removed. Compute the title before entering the main block, or construct the instance on a background dispatcher.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt` around lines 551
- 560, `ProxyInstance` is still being constructed inside the `onMainDispatcher`
block, so `ServiceNotification.genTitle(profile)` can trigger a `groupDao` read
on the UI thread when `DataStore.showGroupInNotification` is enabled. Move the
`ProxyInstance(profile, this)` construction, or at least the title computation
used by `displayProfileName`, to a background dispatcher before
`onMainDispatcher`, and keep the main block limited to UI-only work like
`createNotification(...)`, `Executable.killAll()`, and `preInit()`.
🧹 Nitpick comments (1)
app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt (1)

74-81: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Effective for StrictMode, but note the main thread is still blocked.

close() runs on the main thread (via killProcesses() inside stopRunner's runOnMainDispatcher). Passing Dispatchers.Default correctly executes looper.stop()'s synchronous DAO writes on a background thread (satisfying StrictMode), but runBlocking still parks the main thread until the flush completes. If looper.stop() can be slow, this remains an ANR risk on teardown. Consider bounding it (e.g. withTimeoutOrNull) or making teardown fully async as in persistStats.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt` around
lines 74 - 81, `ProxyInstance.close()` still blocks the main thread because it
wraps `looper?.stop()` in `runBlocking(Dispatchers.Default)`, so update this
teardown path to avoid an unbounded wait while keeping the DAO flush off the UI
thread. Use the existing `looper`/`looper.stop()` flow in `ProxyInstance` and
either bound the blocking work with a timeout or convert the shutdown to a fully
async pattern like `persistStats`, then clear `looper` only after the stop
completes safely.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt`:
- Around line 551-560: `ProxyInstance` is still being constructed inside the
`onMainDispatcher` block, so `ServiceNotification.genTitle(profile)` can trigger
a `groupDao` read on the UI thread when `DataStore.showGroupInNotification` is
enabled. Move the `ProxyInstance(profile, this)` construction, or at least the
title computation used by `displayProfileName`, to a background dispatcher
before `onMainDispatcher`, and keep the main block limited to UI-only work like
`createNotification(...)`, `Executable.killAll()`, and `preInit()`.

---

Nitpick comments:
In `@app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt`:
- Around line 74-81: `ProxyInstance.close()` still blocks the main thread
because it wraps `looper?.stop()` in `runBlocking(Dispatchers.Default)`, so
update this teardown path to avoid an unbounded wait while keeping the DAO flush
off the UI thread. Use the existing `looper`/`looper.stop()` flow in
`ProxyInstance` and either bound the blocking work with a timeout or convert the
shutdown to a fully async pattern like `persistStats`, then clear `looper` only
after the stop completes safely.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c065241d-fc5c-4074-b707-0b7a39b76e11

📥 Commits

Reviewing files that changed from the base of the PR and between 6ca9cb9 and c9514df.

📒 Files selected for processing (2)
  • app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
  • app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt

Greptile: Formats.kt also parses hysteria2/hy2/vless/anytls/snell but they were missing
from the profile-import intent-filter, so sharing those links from another app didn't
offer NekoBox. Add them alongside the tuic/juicity entries for full parser<->manifest
parity.
@hawkff hawkff merged commit 4f459d9 into main Jul 3, 2026
8 checks passed
@hawkff hawkff deleted the fix/mainthread-buildconfig-and-quic-schemes branch July 3, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant