diff --git a/pack/native/CMakeLists.txt b/pack/native/CMakeLists.txt index d9de6f9ab..b666fc271 100644 --- a/pack/native/CMakeLists.txt +++ b/pack/native/CMakeLists.txt @@ -18,6 +18,10 @@ set(XZ_MICROLZMA_ENCODER OFF CACHE BOOL "" FORCE) set(XZ_MICROLZMA_DECODER OFF CACHE BOOL "" FORCE) set(XZ_LZIP_DECODER OFF CACHE BOOL "" FORCE) set(XZ_THREADS no CACHE STRING "" FORCE) +set(XZ_MATCH_FINDERS "" CACHE STRING "" FORCE) + +# Section-per-function so the loader link can drop xz encoders and unused checks. +add_compile_options(-ffunction-sections -fdata-sections) FetchContent_Declare( xz @@ -26,9 +30,33 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(xz) +if(TARGET liblzma) + # -O2 keeps the decompress loop fast. Sections, hidden visibility and no unwind + # tables let --gc-sections and --icf drop the unused xz encoder without slowing it. + target_compile_options(liblzma PRIVATE + -O2 + -ffunction-sections + -fdata-sections + -fvisibility=hidden + -fno-unwind-tables + -fno-asynchronous-unwind-tables) +endif() + add_library(loader SHARED loader.c) target_compile_features(loader PRIVATE c_std_17) -target_compile_options(loader PRIVATE -O3 -fvisibility=hidden -Wall -Wextra -Werror) +target_compile_options(loader PRIVATE + -Os + -fvisibility=hidden + -fno-unwind-tables + -fno-asynchronous-unwind-tables + -Wall + -Wextra + -Werror) target_include_directories(loader PRIVATE "${xz_SOURCE_DIR}/src/liblzma/api") -target_link_options(loader PRIVATE -Wl,--gc-sections -Wl,--exclude-libs,ALL -Wl,-soname,libloader.so) +target_link_options(loader PRIVATE + -Wl,--gc-sections + -Wl,--icf=safe + -Wl,--exclude-libs,ALL + -Wl,-s + -Wl,-soname,libloader.so) target_link_libraries(loader PRIVATE liblzma) diff --git a/pack/native/loader.c b/pack/native/loader.c index a5dc5245a..4336280e4 100644 --- a/pack/native/loader.c +++ b/pack/native/loader.c @@ -221,6 +221,7 @@ static NativeArchive *open_native_archive( free(archive); return NULL; } + (void)posix_fadvise(archive->fd, 0, 0, POSIX_FADV_SEQUENTIAL); struct stat status; if (fstat(archive->fd, &status) != 0 || status.st_size < 22) { set_error(error, error_capacity, "invalid APK: %s", strerror(errno)); @@ -393,7 +394,8 @@ static bool extract_payload( set_error(error, error_capacity, "allocate decompression buffers"); goto cleanup; } - lzma_ret result = lzma_stream_decoder(&stream, UINT64_MAX, 0); + // The uncompressed bytes are hashed below, so the xz container check is redundant work. + lzma_ret result = lzma_stream_decoder(&stream, UINT64_MAX, LZMA_IGNORE_CHECK); if (result != LZMA_OK) { set_error(error, error_capacity, "initialize liblzma decoder: %d", result); goto cleanup; @@ -449,7 +451,7 @@ static bool extract_payload( (unsigned long long)output_size, (unsigned long long)expected_size); goto cleanup; } - if (fsync(output) != 0) { + if (fdatasync(output) != 0) { set_error(error, error_capacity, "sync payload: %s", strerror(errno)); goto cleanup; } diff --git a/runtime/client/src/runtime/client/session/RuntimeOwnership.kt b/runtime/client/src/runtime/client/session/RuntimeOwnership.kt index a468209fe..d415f0b85 100644 --- a/runtime/client/src/runtime/client/session/RuntimeOwnership.kt +++ b/runtime/client/src/runtime/client/session/RuntimeOwnership.kt @@ -139,6 +139,24 @@ internal class RuntimeOwnership( }, ) + /** The root daemon is still the runtime. Used when a replacement start fails before the swap. */ + fun liveRootSnapshot(profile: Profile, generation: Long, lastError: String?): RuntimeSnapshot? { + if (!isRootDaemonActive()) return null + val mode = localModeForOwner(RuntimeOwner.RootDaemon) ?: return null + return activeSnapshot( + owner = RuntimeOwner.RootDaemon, + runMode = mode, + localPhase = localRuntimePhaseForOwner(RuntimeOwner.RootDaemon), + localStartedAt = localRuntimeStartedAtForOwner(RuntimeOwner.RootDaemon), + ).copy( + profileReady = true, + profileUuid = profile.uuid.toString(), + profileName = profile.name, + generation = generation, + lastError = lastError, + ) + } + fun startedSnapshot( current: RuntimeSnapshot, owner: RuntimeOwner, diff --git a/runtime/client/src/runtime/client/session/RuntimeSession.kt b/runtime/client/src/runtime/client/session/RuntimeSession.kt index 9ad44fe20..2af5ae40c 100644 --- a/runtime/client/src/runtime/client/session/RuntimeSession.kt +++ b/runtime/client/src/runtime/client/session/RuntimeSession.kt @@ -353,7 +353,12 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { val currentOwner = ownership.detectActiveOwner().takeIf { it != RuntimeOwner.None } ?: _runtimeSnapshot.value.owner - if (currentOwner != RuntimeOwner.None) { + // Root reload keeps the live daemon through config compile. The launcher swaps it + // under the core lifecycle lock. VPN and cross-mode starts still stop first: the + // service owns the tun and will not apply a second start while it is running. + val replacingSameRoot = + currentOwner == RuntimeOwner.RootDaemon && targetOwner == RuntimeOwner.RootDaemon + if (currentOwner != RuntimeOwner.None && !replacingSameRoot) { stopInternal( RuntimeStopRequest( owner = currentOwner, @@ -377,16 +382,26 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { runCatching { launcher.start(targetOwner, mode) } .onFailure { error -> - clearRuntimePayload(resetGroups = false) - publishSnapshot( - RuntimeStateMapper.idleSnapshot( - configuredMode = mode, - generation = generation, - lastError = error.message, + val liveRoot = + if (replacingSameRoot) { + ownership.liveRootSnapshot(activeProfile, generation, error.message) + } else { + null + } + if (liveRoot != null) { + publishSnapshot(liveRoot) + } else { + clearRuntimePayload(resetGroups = false) + publishSnapshot( + RuntimeStateMapper.idleSnapshot( + configuredMode = mode, + generation = generation, + lastError = error.message, + ) ) - ) - stopTrafficPolling() - scope.launch { onAfterIdle() } + stopTrafficPolling() + scope.launch { onAfterIdle() } + } throw error } } diff --git a/runtime/service/src/runtime/service/core/AndroidProcessController.kt b/runtime/service/src/runtime/service/core/AndroidProcessController.kt index ebd4b2447..b5055389b 100644 --- a/runtime/service/src/runtime/service/core/AndroidProcessController.kt +++ b/runtime/service/src/runtime/service/core/AndroidProcessController.kt @@ -41,7 +41,7 @@ class AndroidProcessController(context: Context) : ProcessController { } override fun stopRoot() { - CoreProcess.stopRoot(appContext) + CoreProcess.stopRoot() } override fun isRootDaemonAlive(): Boolean = CoreProcess.isRootDaemonAlive() diff --git a/runtime/service/src/runtime/service/core/CoreProcess.kt b/runtime/service/src/runtime/service/core/CoreProcess.kt index 4bd32b8c1..9e41efc4e 100644 --- a/runtime/service/src/runtime/service/core/CoreProcess.kt +++ b/runtime/service/src/runtime/service/core/CoreProcess.kt @@ -18,7 +18,7 @@ * */ -@file:Suppress("SimplifiableCallChain", "CanConvertToMultiDollarString", "CanUnescapeDollarLiteral") +@file:Suppress("SimplifiableCallChain") package com.github.yumeyucca.yumebox.runtime.service.core @@ -37,7 +37,6 @@ import com.github.yumeyucca.yumebox.runtime.api.CoreApi import com.github.yumeyucca.yumebox.runtime.service.controller.CoreController import com.github.yumeyucca.yumebox.runtime.service.util.SocketOwnerResolver import com.topjohnwu.superuser.Shell -import kotlinx.coroutines.* import timber.log.Timber import java.io.File import java.io.FileInputStream @@ -45,10 +44,14 @@ import java.io.FileOutputStream import java.net.InetAddress import java.net.InetSocketAddress import java.util.* +import java.util.concurrent.locks.ReentrantLock /** The running core's UNIX REST controller: socket path + bearer secret. */ data class CoreEndpoint(val sock: String, val secret: String) +/** Tun fd created only after the previous core has exited. */ +data class VpnTunnel(val fd: Int, val gateway: String, val dns: String) + /** * Launches and owns the out-of-process mihomo core. A tiny `libmihomo.so` PIE shell is fork+exec'd * from nativeLibraryDir and dlopens the compressed `libmihomocore.so` payload, then the existing @@ -63,8 +66,21 @@ class CoreProcess(private val context: Context) { private var ownerChannel: Channel? = null private var ownerQueryThread: Thread? = null - /** Fork the core for VpnService mode, deliver [config] and [tunFd], and publish [current]. */ + /** + * Reap any live core, then open the tun, then fork. The tun is created inside the lifecycle + * lock so a root daemon and this VPN cannot hold a tunnel at the same time. + */ fun startVpn( + config: String, + stack: String, + openTunnel: () -> VpnTunnel, + ): CoreEndpoint = withLifecycleLock { + reapBeforeLaunch() + val tunnel = openTunnel() + launchVpn(tunnel.fd, tunnel.gateway, tunnel.dns, config, stack) + } + + private fun launchVpn( tunFd: Int, gateway: String, dns: String, @@ -231,8 +247,12 @@ class CoreProcess(private val context: Context) { * child core, outlives the app process — reattached over the REST socket ([reconnectRoot]). * [mode] = "tun" or "ebpf". */ - fun startRoot(mode: String, config: String): CoreEndpoint { - awaitRootStopGrace() + fun startRoot(mode: String, config: String): CoreEndpoint = withLifecycleLock { + reapBeforeLaunch() + launchRoot(mode, config) + } + + private fun launchRoot(mode: String, config: String): CoreEndpoint { val home = context.runtimeHomeDir.apply { mkdirs() } prepareSelectorCache(home) File(home, SOCK).delete() @@ -294,14 +314,14 @@ class CoreProcess(private val context: Context) { } fifo.delete() - RootDaemonState.save( + val record = RootDaemonState.Record( pid = pid, secret = secret, mode = mode, - startTimeTicks = rootProcessStartTimeTicks(pid) ?: 0L, + startTimeTicks = RootDaemonProbe.startTimeTicks(pid) ?: 0L, ) - ) + RootDaemonProbe.commit(record) Timber.tag(TAG).i("root core launched, pid=%d mode=%s", pid, mode) return CoreEndpoint(sock, secret).also { current = it } } @@ -343,13 +363,36 @@ class CoreProcess(private val context: Context) { check(cache.canRead() && cache.canWrite()) { "Selector cache is not accessible to app" } } - fun stop() { - val stoppedProcess = process - stoppedProcess?.let(::stopVpnProcess) + fun stop() = withLifecycleLock { + val stoppedProcess = process ?: running + if (stoppedProcess != null) { + stopVpnProcess(stoppedProcess) + if (!isVpnProcessAlive(stoppedProcess.pid)) { + if (running === stoppedProcess) running = null + process = null + current = null + } + } stopOwnerQueryLoop() - if (running === stoppedProcess) running = null + } + + /** + * The previous core must be gone before a new one is forked. VPN and root share tun, + * routes and the controller socket, so a successor that starts during teardown races the + * predecessor and leaves two mihomo processes up. + */ + private fun reapBeforeLaunch() { + PreviewCoreProcess.stopActive() + val child = running + if (child != null) { + stopVpnProcess(child) + if (isVpnProcessAlive(child.pid)) { + error("VPN core ${child.pid} is still running") + } + if (running === child) running = null + } process = null - current = null + reapRootDaemon() } /** @@ -358,23 +401,14 @@ class CoreProcess(private val context: Context) { */ private fun stopVpnProcess(process: NativeProcess) { runCatching { process.terminate() } - val deadline = SystemClock.elapsedRealtime() + VPN_STOP_GRACE_MS - while (isVpnProcessAlive(process.pid) && SystemClock.elapsedRealtime() < deadline) { - Thread.sleep(VPN_STOP_POLL_MS) - } - if (isVpnProcessAlive(process.pid)) { - Timber.tag(TAG).w("VPN core did not exit after SIGTERM; sending SIGKILL") - runCatching { process.kill() } + if (waitVpnExit(process.pid, VPN_STOP_GRACE_MS)) return + Timber.tag(TAG).w("VPN core did not exit after SIGTERM; sending SIGKILL") + runCatching { process.kill() } + if (!waitVpnExit(process.pid, VPN_STOP_KILL_WAIT_MS)) { + Timber.tag(TAG).w("VPN core %d still alive after SIGKILL", process.pid) } } - private fun isVpnProcessAlive(pid: Int): Boolean = - runCatching { - Os.kill(pid, 0) - true - } - .getOrDefault(false) - private fun stopOwnerQueryLoop() { val channel = ownerChannel ownerChannel = null @@ -457,54 +491,40 @@ class CoreProcess(private val context: Context) { */ @Volatile private var running: NativeProcess? = null - /** Last-resort SIGKILL of the VPN child after its normal SIGTERM shutdown timed out. */ - fun killRunning() { - running?.let { runCatching { it.kill() } } - running = null - } - - /** True if the persisted mihomo root process still has the recorded process identity. */ - fun isRootCoreAlive(): Boolean { - val record = RootDaemonState.load() ?: return false - return isRootRecordAlive(record) + private fun waitVpnExit(pid: Int, timeoutMs: Long): Boolean { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (isVpnProcessAlive(pid) && SystemClock.elapsedRealtime() < deadline) { + Thread.sleep(VPN_STOP_POLL_MS) + } + return !isVpnProcessAlive(pid) } - /** True if the persisted root daemon still has the recorded process identity. */ - fun isRootDaemonAlive(): Boolean = isRootCoreAlive() + private fun isVpnProcessAlive(pid: Int): Boolean = + runCatching { + Os.kill(pid, 0) + true + } + .getOrDefault(false) - private fun isRootRecordAlive(record: RootDaemonState.Record): Boolean { - val alive = - runCatching { Shell.cmd("kill -0 ${record.pid}").exec().isSuccess } - .getOrDefault(false) - if (!alive) return false + /** Last-resort SIGKILL of the VPN child after its normal SIGTERM shutdown timed out. */ + fun killRunning() = withLifecycleLock { + val child = running ?: return@withLifecycleLock + runCatching { child.kill() } + if (waitVpnExit(child.pid, VPN_STOP_KILL_WAIT_MS)) { + if (running === child) running = null + } else { + Timber.tag(TAG).w("VPN core %d still alive after SIGKILL", child.pid) + } + } - val executable = - runCatching { - Shell.cmd("readlink /proc/${record.pid}/exe") - .exec() - .out - .firstOrNull() - ?.substringBefore(" (deleted)") - ?.let(::File) - ?.name - } - .getOrNull() - if (executable !in ROOT_CORE_EXECUTABLE_NAMES) return false + /** One `kill -0`. Identity stays on reattach, where a recycled pid matters. */ + fun isTrackedRootProcessAlive(): Boolean = RootDaemonProbe.trackedAlive() - val recordedStartTime = record.startTimeTicks - return recordedStartTime <= 0L || - rootProcessStartTimeTicks(record.pid) == recordedStartTime - } + /** Status and tile polling. The probe caches the combined pid, exe and start-time check. */ + fun isRootDaemonAlive(): Boolean = RootDaemonProbe.isAlive() - private fun rootProcessStartTimeTicks(pid: Int): Long? = - runCatching { - val stat = Shell.cmd("cat /proc/$pid/stat").exec().out.joinToString(" ") - stat.substringAfterLast(") ", missingDelimiterValue = "") - .split(Regex("\\s+")) - .getOrNull(PROC_STAT_START_TIME_INDEX_AFTER_COMM) - ?.toLongOrNull() - } - .getOrNull() + /** True when a VPN child or a live root record already owns tun, routes and the socket. */ + internal fun realCoreReserved(): Boolean = isLocalCoreAlive() || RootDaemonProbe.trackedAlive() /** * True if the non-root VPN child core is still alive. Used by LOCAL_TUN startup verify so a @@ -550,76 +570,50 @@ class CoreProcess(private val context: Context) { * [current] from the persisted secret without relaunching. Returns the mode, or null * (clearing stale state). */ - fun reconnectRoot(context: Context): String? { - val record = RootDaemonState.load() ?: return null - if (!isRootRecordAlive(record)) { - RootDaemonState.clear() - return null + fun reconnectRoot(context: Context): String? = withLifecycleLock { + val record = RootDaemonState.load() ?: return@withLifecycleLock null + when (RootDaemonProbe.identity(record)) { + null -> null + false -> { + RootDaemonProbe.discard() + null + } + true -> { + RootDaemonProbe.commit(record) + current = + CoreEndpoint(context.runtimeHomeDir.resolve(SOCK).absolutePath, record.secret) + record.mode + } } - current = CoreEndpoint(context.runtimeHomeDir.resolve(SOCK).absolutePath, record.secret) - return record.mode } - /** Stops the detached root runtime. */ - fun stopRoot(context: Context) { - stopRoot() + /** Stop the root daemon and return only after that pid is gone. */ + fun stopRoot() = withLifecycleLock { + reapRootDaemon() } - // The su kill returns fast, but libsu's shell round-trip + mihomo's SIGTERM teardown - // (Tun route/rule cleanup) adds latency the stop path must not - // block on. - private val stopScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - /** Pid of a daemon whose SIGTERM teardown is still running; see [awaitRootStopGrace]. */ - @Volatile private var dyingRootPid: Int? = null + private val lifecycleLock = ReentrantLock() - /** - * Explicitly stop the root daemon: SIGTERM so mihomo tears down its tun/iptables state, a - * bounded grace for that teardown, then SIGKILL to close the window for good. - */ - fun stopRoot() { - val record = RootDaemonState.load() - // Clear state FIRST so isRootDaemonAlive() reports "stopped" immediately; the UI - // must never wait on the kill. - RootDaemonState.clear() - current = null - record ?: return - if (!isRootRecordAlive(record)) return - dyingRootPid = record.pid - stopScope.launch { - try { - runCatching { Shell.cmd("kill -TERM ${record.pid}").exec() } - val deadline = SystemClock.elapsedRealtime() + ROOT_STOP_GRACE_MS - while ( - SystemClock.elapsedRealtime() < deadline && - isRootRecordAlive(record) - ) { - delay(ROOT_STOP_POLL_MS) - } - if (isRootRecordAlive(record)) { - runCatching { Shell.cmd("kill -KILL ${record.pid}").exec() } - } - } finally { - dyingRootPid = null - } + internal inline fun withLifecycleLock(body: () -> T): T { + lifecycleLock.lock() + try { + return body() + } finally { + lifecycleLock.unlock() } } - /** - * Block (bounded) until a dying predecessor has finished tearing down. The daemon's ip - * rules, nftables table and iptables chains all carry fixed names, so a teardown that - * outlives the stop can dismantle what a freshly launched successor just set up. - */ - fun awaitRootStopGrace() { - val deadline = SystemClock.elapsedRealtime() + ROOT_STOP_GRACE_MS + ROOT_STOP_POLL_MS - while (dyingRootPid != null && SystemClock.elapsedRealtime() < deadline) { - Thread.sleep(ROOT_STOP_POLL_MS) + private fun reapRootDaemon() { + val record = RootDaemonState.load() ?: return + if (!RootDaemonProbe.reap(record)) { + error("root core ${record.pid} is still running") } + RootDaemonProbe.discard() + current = null } - private const val ROOT_STOP_GRACE_MS = 2_000L - private const val ROOT_STOP_POLL_MS = 100L private const val VPN_STOP_GRACE_MS = 2_000L + private const val VPN_STOP_KILL_WAIT_MS = 500L private const val VPN_STOP_POLL_MS = 25L // Config is delivered over this named pipe (never persisted); LEGACY_ROOT_CONFIG is the old @@ -660,9 +654,6 @@ class CoreProcess(private val context: Context) { .also { controller = it } private const val TAG = "CoreProcess" - private val ROOT_CORE_EXECUTABLE_NAMES = setOf(CoreArtifacts.SHELL_NAME, "mihomo") - // After stripping "pid (comm) ", index 0 is field 3 (state), so field 22 is index 19. - private const val PROC_STAT_START_TIME_INDEX_AFTER_COMM = 19 private const val CHUNK = 32 * 1024 private const val OWNER_QUERY_BUFFER_SIZE = 4096 private const val UNKNOWN_SOCKET_OWNER = "-1\t" diff --git a/runtime/service/src/runtime/service/core/PreviewCoreProcess.kt b/runtime/service/src/runtime/service/core/PreviewCoreProcess.kt index 650be6649..f13a9e06c 100644 --- a/runtime/service/src/runtime/service/core/PreviewCoreProcess.kt +++ b/runtime/service/src/runtime/service/core/PreviewCoreProcess.kt @@ -7,6 +7,7 @@ package com.github.yumeyucca.yumebox.runtime.service.core import android.content.Context +import android.os.SystemClock import com.github.yumeyucca.yumebox.core.bridge.Channel import com.github.yumeyucca.yumebox.core.bridge.NativeProcess import com.github.yumeyucca.yumebox.core.util.runtimeHomeDir @@ -16,83 +17,93 @@ import java.util.UUID import timber.log.Timber /** - * Owns the inspect-only core child. It intentionally has no relationship with [CoreProcess]: no - * Root record, no shared endpoint and no VPN socket-owner channel can leak across this boundary. + * Owns the inspect-only core child. It does not share the root record, the VPN endpoint, or the + * socket-owner channel. A real core launch reaps this child, and this child does not fork while + * that core is alive. */ class PreviewCoreProcess(private val context: Context) { private var process: NativeProcess? = null private var endpoint: CoreEndpoint? = null private var controller: CoreController? = null - @Synchronized - fun start(config: String): CoreEndpoint { - stop() - // Compiled provider paths and the geo/MMDB assets are rooted at runtimeHomeDir. Keep that - // as the core home, but use a nested process workdir so preview diagnostics cannot overwrite - // the real core's core.log. - val home = context.runtimeHomeDir.apply { mkdirs() } - val workdir = File(home, PREVIEW_WORKDIR).apply { mkdirs() } - File(home, SOCK).delete() - val (runtimeConfig, secret) = ensureControllerSecret(config) - val nextEndpoint = CoreEndpoint(File(home, SOCK).absolutePath, secret) - val args = - arrayOf( - "--home", - home.absolutePath, - "--controller", - nextEndpoint.sock, - "--mode", - "preview", - ) - val proc = spawn(home, workdir, args) - try { - Channel(proc.channelFd).use { channel -> - val bytes = runtimeConfig.toByteArray(Charsets.UTF_8) - var offset = 0 - while (offset < bytes.size) { - val length = minOf(CHUNK, bytes.size - offset) - channel.writeMessage(bytes, offset, length) - offset += length + fun start(config: String): CoreEndpoint = CoreProcess.withLifecycleLock { + synchronized(gate) { + if (CoreProcess.realCoreReserved()) { + error("preview refused while a runtime core is active") + } + stopLocked() + // Compiled provider paths and the geo/MMDB assets are rooted at runtimeHomeDir. Keep that + // as the core home, but use a nested process workdir so preview diagnostics cannot overwrite + // the real core's core.log. + val home = context.runtimeHomeDir.apply { mkdirs() } + val workdir = File(home, PREVIEW_WORKDIR).apply { mkdirs() } + File(home, SOCK).delete() + val (runtimeConfig, secret) = ensureControllerSecret(config) + val nextEndpoint = CoreEndpoint(File(home, SOCK).absolutePath, secret) + val args = + arrayOf( + "--home", + home.absolutePath, + "--controller", + nextEndpoint.sock, + "--mode", + "preview", + ) + val proc = spawn(home, workdir, args) + try { + Channel(proc.channelFd).use { channel -> + val bytes = runtimeConfig.toByteArray(Charsets.UTF_8) + var offset = 0 + while (offset < bytes.size) { + val length = minOf(CHUNK, bytes.size - offset) + channel.writeMessage(bytes, offset, length) + offset += length + } + // Closing the parent socket is the complete preview handoff. No descriptor and no + // post-start RPC are ever sent to this process. } - // Closing the parent socket is the complete preview handoff. No descriptor and no - // post-start RPC are ever sent to this process. + } catch (error: Throwable) { + runCatching { proc.kill() } + waitUntilExited(proc.pid) + throw IllegalStateException("preview config handoff failed", error) } - } catch (error: Throwable) { - runCatching { proc.kill() } - throw IllegalStateException("preview config handoff failed", error) + process = proc + active = this + endpoint = nextEndpoint + controller = CoreController(local = CoreController.Local(nextEndpoint.sock) { nextEndpoint.secret }) + Timber.tag(TAG).i("preview core launched, pid=%d", proc.pid) + nextEndpoint } - process = proc - endpoint = nextEndpoint - controller = CoreController(local = CoreController.Local(nextEndpoint.sock) { nextEndpoint.secret }) - Timber.tag(TAG).i("preview core launched, pid=%d", proc.pid) - return nextEndpoint } - @Synchronized fun stop() { + synchronized(gate) { stopLocked() } + } + + private fun stopLocked() { + if (active === this) active = null val previous = process process = null endpoint = null controller = null if (previous != null) { - runCatching { previous.terminate() } + reap(previous) } } - @Synchronized - fun isAlive(): Boolean { - val pid = process?.pid ?: return false + fun isAlive(): Boolean = synchronized(gate) { + val pid = process?.pid ?: return@synchronized false // On some Android builds `kill(pid, 0)` from the app process is intermittently denied // while a just-forked child is still completing exec. The preview PID is app-owned and // short-lived, so its proc entry is the reliable liveness signal for this handle. - return File("/proc/$pid").exists() + File("/proc/$pid").exists() } - @Synchronized - fun controller(): CoreController = checkNotNull(controller) { "Preview core is not running" } + fun controller(): CoreController = synchronized(gate) { + checkNotNull(controller) { "Preview core is not running" } + } - @Synchronized - fun endpoint(): CoreEndpoint? = endpoint + fun endpoint(): CoreEndpoint? = synchronized(gate) { endpoint } private fun spawn(home: File, workdir: File, args: Array): NativeProcess { val launchArgs = CoreArtifacts.previewArguments(context, args) @@ -129,10 +140,35 @@ class PreviewCoreProcess(private val context: Context) { return lines.joinToString("\n") to secret } - private companion object { + companion object { const val TAG = "PreviewCoreProcess" const val SOCK = "preview.sock" const val PREVIEW_WORKDIR = "preview" const val CHUNK = 32 * 1024 + const val PREVIEW_STOP_GRACE_MS = 300L + const val PREVIEW_STOP_KILL_WAIT_MS = 200L + const val PREVIEW_STOP_POLL_MS = 25L + + private val gate = Any() + private var active: PreviewCoreProcess? = null + + internal fun stopActive() { + synchronized(gate) { active?.stopLocked() } + } + + private fun reap(process: NativeProcess) { + runCatching { process.terminate() } + if (waitUntilExited(process.pid, PREVIEW_STOP_GRACE_MS)) return + runCatching { process.kill() } + waitUntilExited(process.pid, PREVIEW_STOP_KILL_WAIT_MS) + } + + private fun waitUntilExited(pid: Int, timeoutMs: Long = PREVIEW_STOP_KILL_WAIT_MS): Boolean { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (File("/proc/$pid").exists() && SystemClock.elapsedRealtime() < deadline) { + Thread.sleep(PREVIEW_STOP_POLL_MS) + } + return !File("/proc/$pid").exists() + } } } diff --git a/runtime/service/src/runtime/service/core/RootDaemonProbe.kt b/runtime/service/src/runtime/service/core/RootDaemonProbe.kt new file mode 100644 index 000000000..7504610d7 --- /dev/null +++ b/runtime/service/src/runtime/service/core/RootDaemonProbe.kt @@ -0,0 +1,169 @@ +/* + * This file is part of YumeBox. + * + * YumeBox is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Copyright (c) YumeYucca 2025 - Present + * + */ + +package com.github.yumeyucca.yumebox.runtime.service.core + +import android.os.SystemClock +import com.topjohnwu.superuser.Shell +import java.io.File + +/** + * Root pid checks go through libsu's persistent shell. [CoreProcess] still owns the lifecycle + * lock and the controller endpoint. + */ +internal object RootDaemonProbe { + fun trackedAlive(): Boolean { + val pid = RootDaemonState.load()?.pid ?: return false + if (pid <= 0) return false + return exec("kill -0 $pid")?.isSuccess == true + } + + fun isAlive(): Boolean { + val record = RootDaemonState.load() ?: return false + val now = SystemClock.elapsedRealtime() + val cached = liveness + if ( + cached != null && + cached.pid == record.pid && + cached.startTimeTicks == record.startTimeTicks && + now - cached.checkedAt <= CACHE_MS + ) { + return cached.alive + } + val alive = + identity(record) + ?: return cached != null && cached.pid == record.pid && cached.alive + liveness = Liveness(record.pid, record.startTimeTicks, alive, now) + return alive + } + + /** + * Null when the root shell itself failed. False is a dead or reused pid. Only a definite + * answer is cached, so a failed shell does not mark the daemon stopped. + */ + fun identity(record: RootDaemonState.Record): Boolean? = + when (val state = pidState(record.pid)) { + PidState.Unreachable -> null + PidState.Gone -> false + is PidState.Present -> + if (state.executable in EXECUTABLE_NAMES) startTimeMatches(record) else false + } + + fun reap(record: RootDaemonState.Record): Boolean = + when (val state = pidState(record.pid)) { + PidState.Unreachable -> false + PidState.Gone -> true + is PidState.Present -> + when (state.executable) { + null -> false + in EXECUTABLE_NAMES -> signal(record.pid) + else -> true + } + } + + /** Persists [record] and the liveness cache together. */ + fun commit(record: RootDaemonState.Record) { + RootDaemonState.save(record) + liveness = + Liveness( + pid = record.pid, + startTimeTicks = record.startTimeTicks, + alive = true, + checkedAt = SystemClock.elapsedRealtime(), + ) + } + + /** Drops the persisted record and the liveness cache together. */ + fun discard() { + RootDaemonState.clear() + liveness = null + } + + fun startTimeTicks(pid: Int): Long? { + val stat = exec("cat /proc/$pid/stat") ?: return null + if (!stat.isSuccess) return null + return startTimeTicks(stat.out.joinToString(" ")) + } + + private fun signal(pid: Int): Boolean { + val terminated = exec("kill -TERM $pid") ?: return false + if (!terminated.isSuccess || waitUntilDead(pid, TERM_ATTEMPTS)) return true + exec("kill -KILL $pid") + return waitUntilDead(pid, KILL_ATTEMPTS) + } + + private fun waitUntilDead(pid: Int, attempts: Int): Boolean { + repeat(attempts) { attempt -> + val result = exec("kill -0 $pid") ?: return false + if (!result.isSuccess) return true + if (attempt < attempts - 1) Thread.sleep(POLL_MS) + } + return false + } + + private fun pidState(pid: Int): PidState { + val alive = exec("kill -0 $pid") ?: return PidState.Unreachable + if (!alive.isSuccess) return PidState.Gone + val link = exec("readlink /proc/$pid/exe") ?: return PidState.Unreachable + if (!link.isSuccess) return PidState.Present(executable = null) + val name = link.out.firstOrNull()?.substringBefore(" (deleted)")?.let(::File)?.name + return PidState.Present(name) + } + + private fun startTimeMatches(record: RootDaemonState.Record): Boolean? { + val stat = exec("cat /proc/${record.pid}/stat") ?: return null + if (!stat.isSuccess) return false + val ticks = startTimeTicks(stat.out.joinToString(" ")) ?: return false + return record.startTimeTicks <= 0L || ticks == record.startTimeTicks + } + + private fun exec(command: String) = runCatching { Shell.cmd(command).exec() }.getOrNull() + + private fun startTimeTicks(stat: String): Long? = + stat.substringAfterLast(") ", missingDelimiterValue = "") + .split(Regex("\\s+")) + .getOrNull(START_TIME_INDEX_AFTER_COMM) + ?.toLongOrNull() + + private sealed interface PidState { + data object Unreachable : PidState + + data object Gone : PidState + + data class Present(val executable: String?) : PidState + } + + private data class Liveness( + val pid: Int, + val startTimeTicks: Long, + val alive: Boolean, + val checkedAt: Long, + ) + + @Volatile private var liveness: Liveness? = null + + private val EXECUTABLE_NAMES = setOf(CoreArtifacts.SHELL_NAME, "mihomo") + // After stripping "pid (comm) ", index 0 is field 3 (state), so field 22 is index 19. + private const val START_TIME_INDEX_AFTER_COMM = 19 + private const val CACHE_MS = 400L + private const val POLL_MS = 50L + private const val TERM_ATTEMPTS = 40 + private const val KILL_ATTEMPTS = 10 +} diff --git a/runtime/service/src/runtime/service/session/RootSessionLauncher.kt b/runtime/service/src/runtime/service/session/RootSessionLauncher.kt index 319f0f29e..1b17632b5 100644 --- a/runtime/service/src/runtime/service/session/RootSessionLauncher.kt +++ b/runtime/service/src/runtime/service/session/RootSessionLauncher.kt @@ -72,11 +72,14 @@ object RootSessionLauncher { } } appContext.requireBuiltinGeoAssets() - stopLocked(appContext, broadcastStopped = false) val log = RuntimeLog.writer(appContext, mode) log.beginSession(RuntimeLog.Type.Launcher, "root start mode=${mode.name}") + // Compile against the daemon that is still serving. startRoot reaps it under the + // lifecycle lock immediately before the new exec, so the gap is the swap, not the compile. + val servingMode = CoreProcess.rootDaemonMode()?.takeIf { CoreProcess.isTrackedRootProcessAlive() } RootForegroundService.start(appContext) + var launched = false try { StatusProvider.markRuntimeStarting(mode) StartupTaskCoordinator.awaitWarmup() @@ -89,6 +92,7 @@ object RootSessionLauncher { val compiled = CompiledConfigPipeline(appContext).compileDetailed(spec) log.i(RuntimeLog.Type.Launcher, "compiled groups=${compiled.proxyGroupNames.size}") CoreProcess(appContext).startRoot(mode.coreArg, compiled.finalYaml) + launched = true awaitControllerReady(appContext) log.i(RuntimeLog.Type.Launcher, "controller ready") @@ -96,15 +100,30 @@ object RootSessionLauncher { broadcast(appContext, Intents.actionRuntimeStarted(appContext.packageName)) log.i(RuntimeLog.Type.Launcher, "success: root daemon running mode=${mode.name}") } catch (error: Throwable) { - runCatching { CoreProcess.stopRoot(appContext) } - CoreProcess.awaitRootStopGrace() - StatusProvider.markRuntimeFailed(mode, error.message) - RootForegroundService.stop(appContext) + val stillServing = + servingMode != null && !launched && CoreProcess.isTrackedRootProcessAlive() + if (stillServing) { + StatusProvider.markRuntimeRunning(servingMode) + } else { + runCatching { CoreProcess.stopRoot() } + StatusProvider.markRuntimeFailed(mode, error.message) + RootForegroundService.stop(appContext) + } log.e(RuntimeLog.Type.Launcher, "root start failed", error) throw error } } + /** + * VPN launch reaps a root daemon inside [CoreProcess]. The notification host follows the + * record: once that record is gone, this is the same idle transition as an explicit stop. + */ + fun releaseReapedHost(context: Context, modeBeforeLaunch: RunMode?) { + if (modeBeforeLaunch == null || CoreProcess.rootDaemonMode() != null) return + StatusProvider.markRuntimeIdle(modeBeforeLaunch) + RootForegroundService.stop(context.appContextOrSelf) + } + /** Explicitly stop the daemon and release its status slot. */ suspend fun stop(context: Context) { lifecycleMutex.withLock { @@ -114,8 +133,7 @@ object RootSessionLauncher { private fun stopLocked(context: Context, broadcastStopped: Boolean) { val mode = CoreProcess.rootDaemonMode() - runCatching { CoreProcess.stopRoot(context) } - CoreProcess.awaitRootStopGrace() + runCatching { CoreProcess.stopRoot() } mode?.let { StatusProvider.markRuntimeIdle(it) } RootForegroundService.stop(context) if (broadcastStopped) { @@ -132,13 +150,8 @@ object RootSessionLauncher { private suspend fun awaitControllerReady(context: Context) { val deadline = SystemClock.elapsedRealtime() + STARTUP_PROBE_TIMEOUT_MS var lastError: Throwable? = null + var nextLivenessCheckAt = 0L while (true) { - // The native eBPF listener is part of the mihomo process; core liveness is enough. - if (!CoreProcess.isRootCoreAlive()) { - val reason = CoreProcess.coreLogTail(context) ?: "root core exited during startup" - error(reason) - } - val remainingMillis = deadline - SystemClock.elapsedRealtime() if (remainingMillis <= 0L) break val result = @@ -150,6 +163,17 @@ object RootSessionLauncher { if (result.isSuccess) return lastError = result.exceptionOrNull() + val now = SystemClock.elapsedRealtime() + if (now >= nextLivenessCheckAt) { + nextLivenessCheckAt = now + STARTUP_LIVENESS_INTERVAL_MS + // The socket probe is the ready signal. A shell liveness check is only there to + // fail fast once the process has actually exited. + if (!CoreProcess.isTrackedRootProcessAlive()) { + val reason = CoreProcess.coreLogTail(context) ?: "root core exited during startup" + error(reason) + } + } + val retryDelay = minOf( STARTUP_PROBE_INTERVAL_MS, @@ -175,5 +199,6 @@ object RootSessionLauncher { } private const val STARTUP_PROBE_INTERVAL_MS = 75L + private const val STARTUP_LIVENESS_INTERVAL_MS = 300L private const val STARTUP_PROBE_TIMEOUT_MS = 2_000L } diff --git a/runtime/service/src/runtime/service/session/VpnTunTransport.kt b/runtime/service/src/runtime/service/session/VpnTunTransport.kt index 2c58befe6..8533e3aa8 100644 --- a/runtime/service/src/runtime/service/session/VpnTunTransport.kt +++ b/runtime/service/src/runtime/service/session/VpnTunTransport.kt @@ -33,6 +33,7 @@ import com.github.yumeyucca.yumebox.runtime.service.R import com.github.yumeyucca.yumebox.runtime.service.config.AccessControlMode import com.github.yumeyucca.yumebox.runtime.service.config.ServiceStore import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess +import com.github.yumeyucca.yumebox.runtime.service.core.VpnTunnel import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog import com.github.yumeyucca.yumebox.runtime.service.util.buildIncludedRoutesFromExcludedCidrs import com.github.yumeyucca.yumebox.runtime.service.util.parseCIDR @@ -57,6 +58,20 @@ class VpnTunTransport( } else { runBlocking { pipeline.compile(spec) } } + val rootMode = CoreProcess.rootDaemonMode() + try { + core.startVpn( + config = config, + stack = vpnTunStack(store.tunStackMode), + openTunnel = { openTunnel(config) }, + ) + } finally { + RootSessionLauncher.releaseReapedHost(vpnService, rootMode) + } + log.i(RuntimeLog.Type.Transport, "success: tun attached and core launched") + } + + private fun openTunnel(config: String): VpnTunnel { val device = with(vpnService.Builder()) { val explicitRouteExcludes = @@ -112,7 +127,7 @@ class VpnTunTransport( } } - TunDevice( + VpnTunnel( fd = establish()?.detachFd() ?: error("Establish VPN rejected by system"), gateway = "$TUN_GATEWAY/$TUN_SUBNET_PREFIX" + @@ -125,15 +140,7 @@ class VpnTunTransport( }, ) } - - core.startVpn( - tunFd = device.fd, - gateway = device.gateway, - dns = device.dns, - config = config, - stack = vpnTunStack(store.tunStackMode), - ) - log.i(RuntimeLog.Type.Transport, "success: tun attached and core launched") + return device } /** @@ -246,12 +253,6 @@ class VpnTunTransport( } } - private data class TunDevice( - val fd: Int, - val gateway: String, - val dns: String, - ) - private companion object { fun vpnTunStack(mode: String): String = if (mode.equals("mips", ignoreCase = true)) "mips" else "gvisor"