From eaf0250f2062224b073b5dceb0959c083e29e9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:40:31 +0200 Subject: [PATCH 1/4] feat(android): run local server with microinit Replace the supervisord integration with loco-server's microinit runtime and fetch its Android artifact from GHCR. Co-authored-by: Cursor --- .github/workflows/ci.yml | 11 ++- Makefile | 28 +++---- app/build.gradle.kts | 39 ++++++--- .../android/server/LocalServerPaths.kt | 4 +- .../android/server/LocoServerService.kt | 79 +++---------------- .../android/server/NativeBinaries.kt | 3 +- .../ui/localserver/LocalServerStatusScreen.kt | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- gradle.properties | 3 +- scripts/fetch-ghcr-oras.sh | 30 ++++--- 12 files changed, 88 insertions(+), 117 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 404c256..80ff5d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,11 +87,14 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BIGFRED_OCI_TAG: ${{ vars.BIGFRED_OCI_TAG }} + MICROINIT_OCI_TAG: ${{ vars.MICROINIT_OCI_TAG }} run: | set -euo pipefail - TAG="${BIGFRED_OCI_TAG:-main}" - echo "Using loco-server OCI tag: ${TAG}" - make native-prebuilt BIGFRED_OCI_TAG="${TAG}" + LOCO_TAG="${BIGFRED_OCI_TAG:-main}" + MICROINIT_TAG="${MICROINIT_OCI_TAG:-main}" + echo "Using loco-server OCI tag: ${LOCO_TAG}" + echo "Using microinit OCI tag: ${MICROINIT_TAG}" + make native-prebuilt BIGFRED_OCI_TAG="${LOCO_TAG}" MICROINIT_OCI_TAG="${MICROINIT_TAG}" ls -lh native-prebuilt/arm64-v8a/ - name: Resolve app version @@ -126,7 +129,7 @@ jobs: APK="app/build/outputs/apk/release/app-release.apk" unzip -l "$APK" | grep -E 'lib/arm64-v8a/libloco-server\.so' unzip -l "$APK" | grep -E 'lib/arm64-v8a/libvalkey-server\.so' - unzip -l "$APK" | grep -E 'lib/arm64-v8a/libsupervisord\.so' + unzip -l "$APK" | grep -E 'lib/arm64-v8a/libmicroinit\.so' - name: Verify APK version metadata run: | diff --git a/Makefile b/Makefile index 1e7b89b..ae79734 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ # make import-models — fetch hydrus.pl catalog into assets/models/ # make loco-android — download libloco-server.so from GHCR (ORAS) # make valkey-android — download libvalkey-server.so from deps-android-valkey latest release -# make supervisord-android — download supervisord libs from deps-android-supervisord latest release +# make microinit-android — download libmicroinit.so from GHCR (ORAS) GRADLE ?= ./gradlew GRADLE_FLAGS ?= --quiet @@ -24,17 +24,17 @@ NATIVE_PREBUILT := native-prebuilt/arm64-v8a LOCO_SO := $(NATIVE_PREBUILT)/libloco-server.so LOCAL_LOCO_BIN := ../bigfred/bin/loco-server-android-arm64 VALKEY_SO := $(NATIVE_PREBUILT)/libvalkey-server.so -SUPERVISORD_SO := $(NATIVE_PREBUILT)/libsupervisord.so -SUPERVISORCTL_SO := $(NATIVE_PREBUILT)/libsupervisorctl.so +MICROINIT_SO := $(NATIVE_PREBUILT)/libmicroinit.so BIGFRED_OCI_IMAGE ?= ghcr.io/dcc-bigfred/loco-server-android-arm64 BIGFRED_OCI_TAG ?= main +MICROINIT_OCI_IMAGE ?= ghcr.io/dcc-bigfred/microinit-android-arm64 +MICROINIT_OCI_TAG ?= main VALKEY_REPO ?= dcc-bigfred/deps-android-valkey -SUPERVISORD_REPO ?= dcc-bigfred/deps-android-supervisord .PHONY: help apk release test test-android debug clean import-models \ - loco-android valkey-android supervisord-android native-prebuilt + loco-android valkey-android microinit-android native-prebuilt help: @echo "Targets:" @@ -46,7 +46,7 @@ help: @echo " make import-models Import hydrus models DB + thumbs → $(ASSETS_MODELS)" @echo " make loco-android Fetch $(LOCO_SO) (local $(LOCAL_LOCO_BIN) if present, else $(BIGFRED_OCI_IMAGE):$(BIGFRED_OCI_TAG); FORCE=1)" @echo " make valkey-android Fetch $(VALKEY_SO) from $(VALKEY_REPO) latest release (skip if exists; FORCE=1)" - @echo " make supervisord-android Fetch supervisord libs from $(SUPERVISORD_REPO) latest release (skip if exists; FORCE=1)" + @echo " make microinit-android Fetch $(MICROINIT_SO) from $(MICROINIT_OCI_IMAGE):$(MICROINIT_OCI_TAG) (skip if exists; FORCE=1)" @echo " make clean Clean Gradle build outputs" @echo "" @echo "Release signing (optional; falls back to debug keystore):" @@ -71,10 +71,10 @@ import-models: ifdef FORCE .PHONY: force-clean-native force-clean-native: - rm -f "$(LOCO_SO)" "$(VALKEY_SO)" "$(SUPERVISORD_SO)" "$(SUPERVISORCTL_SO)" + rm -f "$(LOCO_SO)" "$(VALKEY_SO)" "$(MICROINIT_SO)" loco-android: force-clean-native valkey-android: force-clean-native -supervisord-android: force-clean-native +microinit-android: force-clean-native endif loco-android: $(LOCO_SO) @@ -95,15 +95,13 @@ valkey-android: $(VALKEY_SO) $(VALKEY_SO): ./scripts/fetch-github-release-asset.sh "$(VALKEY_REPO)" libvalkey-server.so "$@" -supervisord-android: $(SUPERVISORD_SO) $(SUPERVISORCTL_SO) +microinit-android: $(MICROINIT_SO) -$(SUPERVISORD_SO): - ./scripts/fetch-github-release-asset.sh "$(SUPERVISORD_REPO)" libsupervisord.so "$@" - -$(SUPERVISORCTL_SO): - ./scripts/fetch-github-release-asset.sh "$(SUPERVISORD_REPO)" libsupervisorctl.so "$@" +$(MICROINIT_SO): + @mkdir -p "$(NATIVE_PREBUILT)" + ./scripts/fetch-ghcr-oras.sh "$(MICROINIT_OCI_IMAGE)" "$(MICROINIT_OCI_TAG)" "$@" main -native-prebuilt: loco-android valkey-android supervisord-android +native-prebuilt: loco-android valkey-android microinit-android apk release: native-prebuilt $(GRADLE) $(GRADLE_FLAGS) :app:assembleRelease diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bd0158b..6b4d96f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -156,6 +156,8 @@ val fetchNativeBinaries by tasks.registering { doLast { val outDir = jniOut.asFile outDir.mkdirs() + File(outDir, "libsupervisord.so").delete() + File(outDir, "libsupervisorctl.so").delete() fun githubToken(): String? = System.getenv("BIGFRED_NATIVE_TOKEN") @@ -214,22 +216,12 @@ val fetchNativeBinaries by tasks.registering { dest.copyTo(cache, overwrite = true) } - // Valkey / supervisord: prefer make-fetched native-prebuilt, else latest GitHub release. + // Valkey: prefer make-fetched native-prebuilt, else latest GitHub release. stageOrFetch( "libvalkey-server.so", "depsAndroidValkeyRepo", "dcc-bigfred/deps-android-valkey", ) - stageOrFetch( - "libsupervisord.so", - "depsAndroidSupervisordRepo", - "dcc-bigfred/deps-android-supervisord", - ) - stageOrFetch( - "libsupervisorctl.so", - "depsAndroidSupervisordRepo", - "dcc-bigfred/deps-android-supervisord", - ) // loco-server: local ../bigfred/bin → native-prebuilt → GHCR (ORAS). val locoDest = File(outDir, "libloco-server.so") @@ -257,6 +249,31 @@ val fetchNativeBinaries by tasks.registering { "or pull from ghcr.io/dcc-bigfred/loco-server-android-arm64 (ORAS).", ) } + + // microinit: native-prebuilt → GHCR (ORAS). + val microinitDest = File(outDir, "libmicroinit.so") + val microinitCached = prebuilt.file("libmicroinit.so").asFile + when { + microinitCached.isFile -> { + microinitCached.copyTo(microinitDest, overwrite = true) + println("Staged libmicroinit.so from ${microinitCached.absolutePath}") + } + else -> { + val image = System.getenv("MICROINIT_OCI_IMAGE") + ?: "ghcr.io/dcc-bigfred/microinit-android-arm64" + val tag = System.getenv("MICROINIT_OCI_TAG") ?: "main" + runScript(ghcrScript.asFile, image, tag, microinitDest.absolutePath, "main") + microinitCached.parentFile.mkdirs() + microinitDest.copyTo(microinitCached, overwrite = true) + } + } + if (!microinitDest.isFile || microinitDest.length() < 1024) { + throw GradleException( + "libmicroinit.so missing after fetch. " + + "Place it in native-prebuilt/arm64-v8a/ or pull from " + + "ghcr.io/dcc-bigfred/microinit-android-arm64 (ORAS).", + ) + } } } diff --git a/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt b/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt index 561479c..549ea3a 100644 --- a/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt +++ b/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt @@ -13,8 +13,8 @@ data class LocalServerPaths( val redisDir: File get() = File(dataDir, "redis") val locoServerPid: File get() = File(runDir, "loco-server.pid") - val valkeyPid: File get() = File(runDir, "valkey.pid") - val supervisordPid: File get() = File(runDir, "supervisord.pid") + val microinitSocket: File get() = File(runDir, "microinit.sock") + val microinitPid: File get() = File(runDir, "microinit.pid") val dbFile: File get() = File(dataDir, "bigfred.db") fun ensureDirs() { diff --git a/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt b/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt index 2ad03f5..bde912a 100644 --- a/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt +++ b/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt @@ -27,13 +27,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.runBlocking /** - * Foreground service hosting Valkey + loco-server for "BigFred on phone". - * Stage 1 uses `--no-supervisor`; stage 2 passes absolute jniLibs paths for supervisord. + * Foreground service hosting loco-server and its microinit-managed children for "BigFred on phone". */ class LocoServerService : Service() { private var wakeLock: PowerManager.WakeLock? = null - private var valkeyProcess: Process? = null private var locoProcess: Process? = null private var watchdogThread: Thread? = null private var bootThread: Thread? = null @@ -147,8 +145,7 @@ class LocoServerService : Service() { paths.ensureDirs() ProcessOrphanReaper.reap(paths.locoServerPid, NativeBinaries.LOCO_SERVER) - ProcessOrphanReaper.reap(paths.valkeyPid, NativeBinaries.VALKEY) - ProcessOrphanReaper.reap(paths.supervisordPid, NativeBinaries.SUPERVISORD) + ProcessOrphanReaper.reap(paths.microinitPid, NativeBinaries.MICROINIT) ensureBootActive() if (ProcessOrphanReaper.isPortOpen("127.0.0.1", HTTP_PORT) && @@ -161,9 +158,7 @@ class LocoServerService : Service() { val locoBin = NativeBinaries.require(this, NativeBinaries.LOCO_SERVER) val valkeyBin = NativeBinaries.require(this, NativeBinaries.VALKEY) - val supervisordBin = NativeBinaries.file(this, NativeBinaries.SUPERVISORD) - val supervisorctlBin = NativeBinaries.file(this, NativeBinaries.SUPERVISORCTL) - val supervised = supervisordBin.isFile && supervisorctlBin.isFile + val microinitBin = NativeBinaries.require(this, NativeBinaries.MICROINIT) val prefs = (application as BigFredApplication).serverPreferences val jwt = runBlocking { prefs.getOrCreateLocalJwtSecret() } @@ -175,15 +170,6 @@ class LocoServerService : Service() { env["BIGFRED_LAN_PREFIX"] = prefix Log.i(TAG, "BIGFRED_LAN_PREFIX=$prefix (for dcc-bus scan --lan-prefix)") } - if (supervised) { - // Shim resolves sibling libsupervisord.so; also set env for clarity. - env["SUPERVISORD_BIN"] = supervisordBin.absolutePath - } - - if (!supervised) { - // Stage 1: FGS owns Valkey; loco-server runs without supervisord. - bootUnmanagedValkey(paths, valkeyBin) - } ensureBootActive() val locoLog = File(paths.logsDir, "loco-server.log") @@ -193,20 +179,10 @@ class LocoServerService : Service() { "--db", paths.dbFile.absolutePath, "--redis-bin", valkeyBin.absolutePath, "--redis-addr", "127.0.0.1:$REDIS_PORT", + "--microinit-bin", microinitBin.absolutePath, + "--microinit-socket", paths.microinitSocket.absolutePath, "--mdns=false", ) - if (supervised) { - // Stage 2: loco-server owns supervisord → Valkey + dcc-bus. - // Execute from nativeLibraryDir (executable); never copy to code_cache (noexec). - locoArgs += listOf( - "--supervisord-bin", supervisordBin.absolutePath, - "--supervisorctl-bin", supervisorctlBin.absolutePath, - ) - Log.i(TAG, "starting supervised local mode (jniLibs supervisord)") - } else { - locoArgs += listOf("--redis-external", "--no-supervisor") - Log.i(TAG, "starting unmanaged local mode (--no-supervisor)") - } locoProcess = ProcessBuilder(locoArgs) .directory(paths.dataDir) @@ -226,27 +202,7 @@ class LocoServerService : Service() { } running.set(true) _state.value = LocalServerState.Running(LOCAL_BASE_URL) - startWatchdog(supervised) - } - - private fun bootUnmanagedValkey(paths: LocalServerPaths, valkeyBin: File) { - ensureBootActive() - val valkeyLog = File(paths.logsDir, "valkey.log") - valkeyProcess = ProcessBuilder( - valkeyBin.absolutePath, - "--bind", "127.0.0.1", - "--port", REDIS_PORT.toString(), - "--dir", paths.redisDir.absolutePath, - "--save", "", - "--appendonly", "no", - "--protected-mode", "no", - "--daemonize", "no", - ).redirectErrorStream(true) - .redirectOutput(ProcessBuilder.Redirect.appendTo(valkeyLog)) - .start() - ensureBootActive() - ProcessOrphanReaper.writePid(paths.valkeyPid, processPid(valkeyProcess!!)) - waitForPort("127.0.0.1", REDIS_PORT, 15_000) + startWatchdog() } private fun ensureBootActive() { @@ -255,15 +211,6 @@ class LocoServerService : Service() { } } - private fun waitForPort(host: String, port: Int, timeoutMs: Long) { - val deadline = System.currentTimeMillis() + timeoutMs - while (System.currentTimeMillis() < deadline) { - if (ProcessOrphanReaper.isPortOpen(host, port)) return - Thread.sleep(200) - } - throw IllegalStateException("Timeout waiting for $host:$port") - } - private fun waitForHttpReady(timeoutMs: Long) { val probe = ServerProbe() val deadline = System.currentTimeMillis() + timeoutMs @@ -275,16 +222,15 @@ class LocoServerService : Service() { throw IllegalStateException("Timeout waiting for $LOCAL_BASE_URL") } - private fun startWatchdog(supervised: Boolean) { + private fun startWatchdog() { watchdogThread?.interrupt() watchdogThread = thread(name = "loco-server-watchdog", isDaemon = true) { while (running.get() && !Thread.currentThread().isInterrupted) { try { Thread.sleep(3_000) val locoAlive = locoProcess?.isAlive == true - val valkeyOk = supervised || valkeyProcess?.isAlive == true - if (!locoAlive || !valkeyOk) { - Log.w(TAG, "child died loco=$locoAlive valkeyOk=$valkeyOk — restarting") + if (!locoAlive) { + Log.w(TAG, "loco-server died — restarting") cleanupChildren() running.set(false) if (!booting.compareAndSet(false, true)) { @@ -328,8 +274,7 @@ class LocoServerService : Service() { @Synchronized private fun cleanupChildren() { - listOf(locoProcess, valkeyProcess).forEach { proc -> - proc ?: return@forEach + locoProcess?.let { proc -> try { proc.destroy() if (!proc.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) { @@ -339,12 +284,10 @@ class LocoServerService : Service() { } } locoProcess = null - valkeyProcess = null try { val paths = LocalServerPaths.from(this) paths.locoServerPid.delete() - paths.valkeyPid.delete() - paths.supervisordPid.delete() + paths.microinitPid.delete() } catch (_: Exception) { } } diff --git a/app/src/main/java/com/dccbigfred/android/server/NativeBinaries.kt b/app/src/main/java/com/dccbigfred/android/server/NativeBinaries.kt index f04c25f..fe5b570 100644 --- a/app/src/main/java/com/dccbigfred/android/server/NativeBinaries.kt +++ b/app/src/main/java/com/dccbigfred/android/server/NativeBinaries.kt @@ -8,8 +8,7 @@ import java.io.File object NativeBinaries { const val LOCO_SERVER = "libloco-server.so" const val VALKEY = "libvalkey-server.so" - const val SUPERVISORD = "libsupervisord.so" - const val SUPERVISORCTL = "libsupervisorctl.so" + const val MICROINIT = "libmicroinit.so" fun dir(context: Context): File = File(context.applicationInfo.nativeLibraryDir) diff --git a/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerStatusScreen.kt b/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerStatusScreen.kt index ecb23c0..267301c 100644 --- a/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerStatusScreen.kt +++ b/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerStatusScreen.kt @@ -68,7 +68,7 @@ fun LocalServerStatusScreen( } val logs = remember(state, logTick) { val dir = LocalServerPaths.from(context).logsDir - listOf("loco-server.log", "valkey.log", "supervisord.log") + listOf("loco-server.log") .map { File(dir, it) } .filter { it.isFile } .joinToString("\n\n") { f -> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1cc6d32..900ec19 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -160,7 +160,7 @@ Lokaler BigFred-Server BigFred auf dem Telefon Tippen, um BigFred zu öffnen - Native Binärdateien fehlen. make native-prebuilt ausführen (loco-server aus GHCR + Valkey/supervisord-Releases) oder loco lokal in ../bigfred bauen. + Native Binärdateien fehlen. make native-prebuilt ausführen (loco-server/microinit aus GHCR + Valkey-Release) oder loco lokal in ../bigfred bauen. Einstellungen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c17890e..1b4308a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -160,7 +160,7 @@ Lokalny serwer BigFred BigFred na telefonie Kliknij aby przejść do BigFred - Brak binarek natywnych. Uruchom make native-prebuilt (loco-server z GHCR + release Valkey/supervisord) lub zbuduj loco lokalnie w ../bigfred. + Brak binarek natywnych. Uruchom make native-prebuilt (loco-server/microinit z GHCR + release Valkey) lub zbuduj loco lokalnie w ../bigfred. Ustawienia diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20c3b27..de2aeaa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -160,7 +160,7 @@ Local BigFred server BigFred on phone Tap to open BigFred - Native binaries missing. Run make native-prebuilt (GHCR loco-server + Valkey/supervisord releases) or build loco locally in ../bigfred. + Native binaries missing. Run make native-prebuilt (GHCR loco-server/microinit + Valkey release) or build loco locally in ../bigfred. Settings diff --git a/gradle.properties b/gradle.properties index 56b77c9..ce97ec0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,6 +9,5 @@ android.nonTransitiveRClass=true bigfredOciImage=ghcr.io/dcc-bigfred/loco-server-android-arm64 bigfredOciTag=main -# Valkey / supervisord Android prebuilts (latest GitHub release of each deps repo). +# Valkey Android prebuilt (latest GitHub release). depsAndroidValkeyRepo=dcc-bigfred/deps-android-valkey -depsAndroidSupervisordRepo=dcc-bigfred/deps-android-supervisord diff --git a/scripts/fetch-ghcr-oras.sh b/scripts/fetch-ghcr-oras.sh index 289b8d2..17874d5 100755 --- a/scripts/fetch-ghcr-oras.sh +++ b/scripts/fetch-ghcr-oras.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Pull loco-server-android-arm64 from GHCR (ORAS) and write libloco-server.so. +# Pull a single-file native binary artifact from GHCR (ORAS). # Usage: fetch-ghcr-oras.sh [fallback-tag ...] # # Auth (optional; needed for private packages): @@ -44,15 +44,27 @@ if ! pull_tag "${TAG}"; then fi fi -src="${tmpdir}/loco-server-android-arm64" +# Prefer a file matching the requested lib*.so output (for example, +# libmicroinit.so → microinit-android-arm64), then retain the historical +# loco-server name and finally accept a single-file artifact. +output_name="$(basename "${OUT}")" +binary_name="${output_name#lib}" +binary_name="${binary_name%.so}" +src="${tmpdir}/${output_name}" if [[ ! -f "${src}" ]]; then - mapfile -t files < <(find "${tmpdir}" -type f ! -name 'manifest.json' ! -name 'config.json') - if [[ ${#files[@]} -eq 1 ]]; then - src="${files[0]}" - else - echo "error: expected loco-server-android-arm64 in OCI artifact, found:" >&2 - find "${tmpdir}" -type f >&2 - exit 1 + src="${tmpdir}/${binary_name}-android-arm64" +fi +if [[ ! -f "${src}" ]]; then + src="${tmpdir}/loco-server-android-arm64" + if [[ ! -f "${src}" ]]; then + mapfile -t files < <(find "${tmpdir}" -type f ! -name 'manifest.json' ! -name 'config.json') + if [[ ${#files[@]} -eq 1 ]]; then + src="${files[0]}" + else + echo "error: could not identify executable for ${output_name}; found:" >&2 + find "${tmpdir}" -type f >&2 + exit 1 + fi fi fi From 7dc40bacc3d1ef0bc9d71dd5f471e6278dcf8a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:21:28 +0200 Subject: [PATCH 2/4] fix: align local redis data path with var/lib/redis Match bigfred's default $DATA_DIR/var/lib/redis layout when ensuring local server directories. Co-authored-by: Cursor --- .../main/java/com/dccbigfred/android/server/LocalServerPaths.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt b/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt index 549ea3a..c655f79 100644 --- a/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt +++ b/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt @@ -10,7 +10,7 @@ data class LocalServerPaths( val etcDir: File get() = File(dataDir, "etc") val runDir: File get() = File(dataDir, "run") val logsDir: File get() = File(dataDir, "logs") - val redisDir: File get() = File(dataDir, "redis") + val redisDir: File get() = File(dataDir, "var/lib/redis") val locoServerPid: File get() = File(runDir, "loco-server.pid") val microinitSocket: File get() = File(runDir, "microinit.sock") From 4af581fad6dcfc606090fe34cc9263a645d9d7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:59:53 +0200 Subject: [PATCH 3/4] feat(android): graceful microinit shutdown on service stop When the foreground service stops, loco-server now gets a 10s graceful window (was 3s) so its own shutdown defer can forward a SIGTERM soft-kill to microinit via the SDK. As a fallback for the case where loco-server is hard-killed before that completes, cleanupChildren also sends SIGTERM directly to the microinit PID (10s grace then SIGKILL) so its managed services (redis/alloy/ dcc-bus) still get a chance to stop instead of being orphaned. ProcessOrphanReaper gains stopGracefully(pidFile, needle, graceMs); reap() now delegates to it with the original 3s grace. Co-authored-by: Cursor --- .../android/server/LocoServerService.kt | 18 +++++++++++++++--- .../android/server/ProcessOrphanReaper.kt | 13 ++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt b/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt index bde912a..217e585 100644 --- a/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt +++ b/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt @@ -274,20 +274,32 @@ class LocoServerService : Service() { @Synchronized private fun cleanupChildren() { + val paths = LocalServerPaths.from(this) locoProcess?.let { proc -> try { + // SIGTERM loco-server first; its own shutdown defer forwards a + // soft-kill (SIGTERM) to microinit via the SDK. Give it a + // generous window so the managed services (redis/alloy/dcc-bus) + // can flush before loco-server is force-killed. proc.destroy() - if (!proc.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) { + if (!proc.waitFor(10, java.util.concurrent.TimeUnit.SECONDS)) { proc.destroyForcibly() } } catch (_: Exception) { } } locoProcess = null + // Belt-and-suspenders: if loco-server was hard-killed before its + // graceful shutdown completed, microinit could be orphaned. Send it + // a SIGTERM directly (10s grace then SIGKILL) so its managed services + // still get a chance to stop cleanly. + try { + ProcessOrphanReaper.stopGracefully(paths.microinitPid, NativeBinaries.MICROINIT, 10_000) + } catch (e: Exception) { + Log.w(TAG, "microinit graceful stop failed", e) + } try { - val paths = LocalServerPaths.from(this) paths.locoServerPid.delete() - paths.microinitPid.delete() } catch (_: Exception) { } } diff --git a/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt b/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt index 394ffa7..95cd536 100644 --- a/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt +++ b/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt @@ -13,13 +13,24 @@ import java.net.Socket object ProcessOrphanReaper { fun reap(pidFile: File, cmdlineNeedle: String) { + stopGracefully(pidFile, cmdlineNeedle, graceMs = 3_000) + } + + /** + * SIGTERM the process backing [pidFile] (after verifying /proc//cmdline + * contains [cmdlineNeedle] to guard against PID reuse), wait up to [graceMs] + * for it to exit, then SIGKILL. The pidfile is deleted once the process is + * gone. Used so microinit receives a graceful SIGTERM and can stop its + * managed services before being force-killed. + */ + fun stopGracefully(pidFile: File, cmdlineNeedle: String, graceMs: Long) { val pid = readPid(pidFile) ?: return if (!cmdlineMatches(pid, cmdlineNeedle)) { pidFile.delete() return } signal(pid, OsConstants.SIGTERM) - val deadline = System.currentTimeMillis() + 3_000 + val deadline = System.currentTimeMillis() + graceMs while (System.currentTimeMillis() < deadline) { if (!isAlive(pid)) break Thread.sleep(100) From 6f99106f45308a50a38a004180568168c9859c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:58:46 +0200 Subject: [PATCH 4/4] fix(android): harden microinit orphan cleanup on shutdown Validate pidfile identity with starttime to avoid PID reuse kills, align graceful shutdown with the 15s microinit budget, and extend tests. Co-authored-by: Cursor --- .../android/server/LocoServerService.kt | 12 ++-- .../android/server/ProcessOrphanReaper.kt | 66 ++++++++++++++----- .../android/server/ProcessOrphanReaperTest.kt | 12 +++- 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt b/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt index 217e585..7f129b2 100644 --- a/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt +++ b/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt @@ -280,9 +280,10 @@ class LocoServerService : Service() { // SIGTERM loco-server first; its own shutdown defer forwards a // soft-kill (SIGTERM) to microinit via the SDK. Give it a // generous window so the managed services (redis/alloy/dcc-bus) - // can flush before loco-server is force-killed. + // can flush before loco-server is force-killed. Match the 15s + // microinit/SDK shutdown budget. proc.destroy() - if (!proc.waitFor(10, java.util.concurrent.TimeUnit.SECONDS)) { + if (!proc.waitFor(15, java.util.concurrent.TimeUnit.SECONDS)) { proc.destroyForcibly() } } catch (_: Exception) { @@ -291,10 +292,11 @@ class LocoServerService : Service() { locoProcess = null // Belt-and-suspenders: if loco-server was hard-killed before its // graceful shutdown completed, microinit could be orphaned. Send it - // a SIGTERM directly (10s grace then SIGKILL) so its managed services - // still get a chance to stop cleanly. + // a SIGTERM directly (15s grace then SIGKILL) so its managed services + // still get a chance to stop cleanly. Relies on microinit writing + // run/microinit.pid (pid + starttime) via the Go supervise Host. try { - ProcessOrphanReaper.stopGracefully(paths.microinitPid, NativeBinaries.MICROINIT, 10_000) + ProcessOrphanReaper.stopGracefully(paths.microinitPid, NativeBinaries.MICROINIT, 15_000) } catch (e: Exception) { Log.w(TAG, "microinit graceful stop failed", e) } diff --git a/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt b/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt index 95cd536..aedf60b 100644 --- a/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt +++ b/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt @@ -7,36 +7,37 @@ import java.net.InetSocketAddress import java.net.Socket /** - * Cleans up orphaned loco-server / valkey processes left after force-stop. - * Verifies /proc//cmdline before signaling to avoid PID reuse kills. + * Cleans up orphaned loco-server / microinit processes left after force-stop. + * Verifies /proc//cmdline and starttime before signaling to avoid PID reuse kills. */ object ProcessOrphanReaper { + data class PidIdentity(val pid: Int, val starttime: Long?) + fun reap(pidFile: File, cmdlineNeedle: String) { stopGracefully(pidFile, cmdlineNeedle, graceMs = 3_000) } /** * SIGTERM the process backing [pidFile] (after verifying /proc//cmdline - * contains [cmdlineNeedle] to guard against PID reuse), wait up to [graceMs] - * for it to exit, then SIGKILL. The pidfile is deleted once the process is - * gone. Used so microinit receives a graceful SIGTERM and can stop its - * managed services before being force-killed. + * contains [cmdlineNeedle] and optional starttime to guard against PID reuse), + * wait up to [graceMs] for it to exit, then SIGKILL. The pidfile is deleted + * once the process is gone. */ fun stopGracefully(pidFile: File, cmdlineNeedle: String, graceMs: Long) { - val pid = readPid(pidFile) ?: return - if (!cmdlineMatches(pid, cmdlineNeedle)) { + val identity = readPidIdentity(pidFile) ?: return + if (!matchesIdentity(identity, cmdlineNeedle)) { pidFile.delete() return } - signal(pid, OsConstants.SIGTERM) + signal(identity.pid, OsConstants.SIGTERM) val deadline = System.currentTimeMillis() + graceMs while (System.currentTimeMillis() < deadline) { - if (!isAlive(pid)) break + if (!matchesIdentity(identity, cmdlineNeedle)) break Thread.sleep(100) } - if (isAlive(pid)) { - signal(pid, OsConstants.SIGKILL) + if (matchesIdentity(identity, cmdlineNeedle)) { + signal(identity.pid, OsConstants.SIGKILL) } pidFile.delete() } @@ -51,14 +52,30 @@ object ProcessOrphanReaper { false } - fun readPid(pidFile: File): Int? { + fun readPid(pidFile: File): Int? = readPidIdentity(pidFile)?.pid + + /** + * Pidfile format: + * - line 1: pid + * - line 2 (optional): /proc//stat starttime (field 22) + */ + fun readPidIdentity(pidFile: File): PidIdentity? { if (!pidFile.isFile) return null - return pidFile.readText().trim().toIntOrNull()?.takeIf { it > 0 } + val lines = pidFile.readText().trim().lines().map { it.trim() }.filter { it.isNotEmpty() } + if (lines.isEmpty()) return null + val pid = lines[0].toIntOrNull()?.takeIf { it > 0 } ?: return null + val starttime = lines.getOrNull(1)?.toLongOrNull() + return PidIdentity(pid, starttime) } fun writePid(pidFile: File, pid: Long) { + writePidIdentity(pidFile, pid.toInt(), readStarttime(pid.toInt())) + } + + fun writePidIdentity(pidFile: File, pid: Int, starttime: Long?) { pidFile.parentFile?.mkdirs() - pidFile.writeText(pid.toString()) + val body = if (starttime != null) "$pid\n$starttime\n" else "$pid\n" + pidFile.writeText(body) } fun cmdlineMatches(pid: Int, needle: String): Boolean { @@ -71,6 +88,25 @@ object ProcessOrphanReaper { fun isAlive(pid: Int): Boolean = File("/proc/$pid").isDirectory + fun readStarttime(pid: Int): Long? { + val stat = File("/proc/$pid/stat") + if (!stat.isFile) return null + val text = stat.readText() + val close = text.lastIndexOf(") ") + if (close < 0) return null + val fields = text.substring(close + 2).trim().split(Regex("\\s+")) + // After ") ": index 0 = state (stat field 3). starttime is field 22 → index 19. + return fields.getOrNull(19)?.toLongOrNull() + } + + fun matchesIdentity(identity: PidIdentity, cmdlineNeedle: String): Boolean { + if (!isAlive(identity.pid)) return false + if (!cmdlineMatches(identity.pid, cmdlineNeedle)) return false + val expected = identity.starttime ?: return true + val actual = readStarttime(identity.pid) ?: return false + return actual == expected + } + private fun signal(pid: Int, sig: Int) { try { Os.kill(pid, sig) diff --git a/app/src/test/java/com/dccbigfred/android/server/ProcessOrphanReaperTest.kt b/app/src/test/java/com/dccbigfred/android/server/ProcessOrphanReaperTest.kt index 4e021ae..d99a157 100644 --- a/app/src/test/java/com/dccbigfred/android/server/ProcessOrphanReaperTest.kt +++ b/app/src/test/java/com/dccbigfred/android/server/ProcessOrphanReaperTest.kt @@ -17,8 +17,18 @@ class ProcessOrphanReaperTest { fun writeAndReadPid_roundTrip() { val f = File.createTempFile("pid", ".txt") f.deleteOnExit() - ProcessOrphanReaper.writePid(f, 4242) + ProcessOrphanReaper.writePidIdentity(f, 4242, 99L) assertTrue(ProcessOrphanReaper.readPid(f) == 4242) + val id = ProcessOrphanReaper.readPidIdentity(f)!! + assertTrue(id.pid == 4242) + assertTrue(id.starttime == 99L) + } + + @Test + fun matchesIdentity_rejectsStarttimeMismatch() { + val fake = ProcessOrphanReaper.PidIdentity(pid = 1, starttime = 1L) + // PID 1 is alive on Linux/Android hosts used for unit tests, but starttime won't match 1. + assertFalse(ProcessOrphanReaper.matchesIdentity(fake, "init")) } @Test