From 7cfce94f6286fb3ee668c49ef733299751a1f332 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 14:13:58 -0700 Subject: [PATCH 01/40] =?UTF-8?q?ADFA-4128:=20qb=2004/12=20runtime=20?= =?UTF-8?q?=E2=80=94=20Inside=20the=20proxy=20app:=20swaps=20code,=20resou?= =?UTF-8?q?rces=20and=20assets=20into=20the=20running=20process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- quickbuild/runtime/build.gradle.kts | 109 +++ .../runtime/src/main/AndroidManifest.xml | 35 + .../quickbuild/IQuickBuildHost.aidl | 27 + .../quickbuild/IQuickBuildTarget.aidl | 46 ++ .../quickbuild/runtime/ActivityTracker.java | 195 +++++ .../quickbuild/runtime/AssetExtractor.java | 214 ++++++ .../runtime/BaselineGeneration.java | 56 ++ .../quickbuild/runtime/BootProbation.java | 57 ++ .../quickbuild/runtime/BuildStatus.java | 136 ++++ .../quickbuild/runtime/DeployMetadata.java | 59 ++ .../runtime/DirectoryAssetsProvider.java | 85 +++ .../quickbuild/runtime/Generations.java | 39 + .../runtime/LegacyResourceSwap.java | 133 ++++ .../quickbuild/runtime/LoaderRouter.java | 36 + .../quickbuild/runtime/MiniJson.java | 411 ++++++++++ .../quickbuild/runtime/OverlayState.java | 156 ++++ .../runtime/PayloadPersistence.java | 708 ++++++++++++++++++ .../quickbuild/runtime/PayloadStore.java | 328 ++++++++ .../runtime/PersistedSelection.java | 31 + .../QuickBuildAppComponentFactory.java | 254 +++++++ .../runtime/QuickBuildClassLoaders.java | 37 + .../quickbuild/runtime/QuickBuildClient.java | 308 ++++++++ .../runtime/QuickBuildKeepAliveService.java | 45 ++ .../quickbuild/runtime/QuickBuildRuntime.java | 698 +++++++++++++++++ .../quickbuild/runtime/ResourceStore.java | 290 +++++++ .../runtime/ResourceSwapStrategy.java | 39 + .../quickbuild/runtime/RestartHandoff.java | 123 +++ .../quickbuild/runtime/RuntimeLog.java | 90 +++ .../quickbuild/runtime/StatusOverlay.java | 136 ++++ .../quickbuild/runtime/Streams.java | 77 ++ .../AssetExtractorFailurePathTest.java | 104 +++ .../runtime/AssetExtractorTest.java | 235 ++++++ .../runtime/BaselineGenerationTest.java | 68 ++ .../quickbuild/runtime/BootProbationTest.java | 114 +++ .../quickbuild/runtime/BuildStatusTest.java | 98 +++ .../runtime/DeployMetadataTest.java | 52 ++ .../runtime/DirectoryAssetsProviderTest.java | 121 +++ .../quickbuild/runtime/GenerationsTest.java | 54 ++ .../LegacyResourceSwapAddAssetPathTest.java | 24 + .../runtime/LegacyResourceSwapSweepTest.java | 108 +++ .../runtime/LegacyResourceSwapTest.java | 73 ++ .../quickbuild/runtime/LoaderRouterTest.java | 86 +++ .../ManifestAppComponentFactoryTest.java | 47 ++ .../runtime/MiniJsonHardeningTest.java | 142 ++++ .../MiniJsonSeparatorAndLiteralTest.java | 57 ++ .../quickbuild/runtime/MiniJsonTest.java | 77 ++ .../runtime/OfflineNetworkGuardTest.java | 61 ++ .../quickbuild/runtime/OverlayStateTest.java | 91 +++ .../runtime/OverlayStateTextEdgeTest.java | 53 ++ .../PayloadPersistenceAtomicSetTest.java | 248 ++++++ .../PayloadPersistenceAtomicWriteTest.java | 110 +++ .../PayloadPersistenceCorruptMetaTest.java | 182 +++++ .../PayloadPersistenceQuarantineTest.java | 281 +++++++ .../runtime/PayloadPersistenceTest.java | 191 +++++ .../runtime/PersistedSelectionTest.java | 86 +++ ...ckBuildAppComponentFactoryRethrowTest.java | 71 ++ ...QuickBuildClassLoadersForActivityTest.java | 45 ++ .../runtime/QuickBuildClassLoadersTest.java | 26 + .../runtime/ResourceSwapStrategyTest.java | 30 + .../runtime/RestartHandoffTest.java | 263 +++++++ .../quickbuild/runtime/RuntimeLogTest.java | 46 ++ .../runtime/StreamsCloseQuietlyTest.java | 29 + .../quickbuild/runtime/StreamsTest.java | 100 +++ settings.gradle.kts | 1 + 64 files changed, 8332 insertions(+) create mode 100644 quickbuild/runtime/build.gradle.kts create mode 100644 quickbuild/runtime/src/main/AndroidManifest.xml create mode 100644 quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl create mode 100644 quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildTarget.aidl create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BuildStatus.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadata.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouter.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/MiniJson.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoaders.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildKeepAliveService.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategy.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoff.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BaselineGenerationTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadataTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapAddAssetPathTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouterTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ManifestAppComponentFactoryTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonHardeningTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonSeparatorAndLiteralTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OfflineNetworkGuardTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersForActivityTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategyTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoffTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsCloseQuietlyTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java diff --git a/quickbuild/runtime/build.gradle.kts b/quickbuild/runtime/build.gradle.kts new file mode 100644 index 0000000000..801e89e83a --- /dev/null +++ b/quickbuild/runtime/build.gradle.kts @@ -0,0 +1,109 @@ +import com.itsaky.androidide.build.config.BuildConfig + +plugins { + id("com.android.library") +} + +description = + "Quick Build runtime embedded in generated proxy apps: binds to CoGo, receives payload fds, hot-reloads (ADFA-4128)" + +// CoGo stages this AAR into its assets and the device reads it by name, so pin the archive +// name instead of inheriting the module name. +base.archivesName.set("quickbuild-runtime") + +android { + namespace = "${BuildConfig.PACKAGE_NAME}.quickbuild.runtime" + + defaultConfig { + // Runs inside apps BUILT WITH CoGo, not inside the IDE. + minSdk = BuildConfig.MIN_SDK_FOR_APPS_BUILT_WITH_COGO + } + + compileOptions { + // Java-only and Java 8, like :logsender - the AAR is injected into user + // projects and must not drag kotlin-stdlib or any other dependency in. + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildFeatures.apply { + aidl = true + viewBinding = false + buildConfig = false + } +} + +// JVM unit tests for the plain-Java payload logic (generation gate, metadata/component +// map parsing, asset extraction). Mirrors :quick-build's jupiter setup. +tasks.withType { + useJUnitPlatform() + // StreamsTest exercises the 256 MB payload cap through the default readFully + // overload; a capped reader legitimately buffers up to the cap before throwing, + // which overflows Gradle's default 512 MB test-worker heap. + maxHeapSize = "1g" +} + +// DoD coverage gate: >=90% line+branch on non-UI (domain/data) code. +// Same shape as :quick-build's report: the root build attaches the jacoco agent to +// every Test task, and for Android modules the exec lands at +// build/outputs/unit_test_code_coverage/UnitTest/, NOT build/jacoco/ -- a +// JacocoReport pointed at build/jacoco/ silently SKIPs and the gate is never +// measured (ADFA-3834 learnings). +tasks.register("jacocoTestReport") { + group = "verification" + description = "JaCoCo line+branch coverage for the v8Debug unit tests." + dependsOn("testV8DebugUnitTest") + + reports { + xml.required.set(true) + html.required.set(true) + } + + // Java-only module: the hand-written surface is the javac output. The AIDL stubs + // (IQuickBuildHost/IQuickBuildTarget + nested Stub/Proxy/Default) are generated + // code, so they are excluded from the measured set. + // + // Device-only Android/binder glue is EXEMPT from the JVM coverage bar (DoD: >=90% + // line+branch on non-UI code; these classes only execute meaningfully on a device + // and are covered by the android-qa device walks instead). Anything JVM-testable + // stays in the measured set - notably LegacyResourceSwap's file half and all + // parsing/persistence code. + classDirectories.setFrom( + fileTree( + layout.buildDirectory.dir("intermediates/javac/v8Debug/compileV8DebugJavaWithJavac/classes"), + ) { + exclude("com/itsaky/androidide/quickbuild/IQuickBuild*") + // Binder host service: payload fds, Handler/Looper, activity relaunch orchestration. + exclude("com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime*") + // ServiceConnection bind/reconnect to CoGo; binder death + rebind only happen on-device. + exclude("com/itsaky/androidide/quickbuild/runtime/QuickBuildClient*") + // Framework-instantiated AppComponentFactory (Activity/Service/Provider hooks). + exclude("com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory*") + // InMemoryDexClassLoader (ART-only) + /proc + android.os.Process boot path; not + // splittable without moving prod code around - the generation-gate logic it defers + // to (Generations, PayloadPersistence, PersistedSelection) is JVM-tested. + exclude("com/itsaky/androidide/quickbuild/runtime/PayloadStore*") + // API 30+ ResourcesLoader/ResourcesProvider attach; framework Resources objects only. + exclude("com/itsaky/androidide/quickbuild/runtime/ResourceStore*") + // Overlay banner View/TextView UI (UI is DoD-exempt; OverlayState text model is JVM-tested). + exclude("com/itsaky/androidide/quickbuild/runtime/StatusOverlay*") + // Application.ActivityLifecycleCallbacks census over real Activity instances. + exclude("com/itsaky/androidide/quickbuild/runtime/ActivityTracker*") + }, + ) + sourceDirectories.setFrom(files("src/main/java")) + executionData.setFrom( + layout.buildDirectory.file( + "outputs/unit_test_code_coverage/v8DebugUnitTest/testV8DebugUnitTest.exec", + ), + ) +} + +dependencies { + testImplementation(libs.tests.junit.jupiter) + testImplementation(libs.tests.google.truth) + // Shared offline-guard scanner (OfflineNetworkGuardTest). Test-only: this never + // reaches the AAR, so the module's no-kotlin-stdlib rule still holds. + testImplementation(testFixtures(projects.quickbuild.protocol)) + testRuntimeOnly(libs.tests.junit.platformLauncher) +} diff --git a/quickbuild/runtime/src/main/AndroidManifest.xml b/quickbuild/runtime/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..16e18325ee --- /dev/null +++ b/quickbuild/runtime/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + diff --git a/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl new file mode 100644 index 0000000000..b004d43fbe --- /dev/null +++ b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl @@ -0,0 +1,27 @@ +package com.itsaky.androidide.quickbuild; + +import com.itsaky.androidide.quickbuild.IQuickBuildTarget; + +/** + * CoGo side of the deploy channel (bound service, LogSender bind pattern). The proxy app + * binds on launch and registers its callback. CoGo verifies Binder.getCallingUid() + * against the proxy app's installed uid on every call. + */ +interface IQuickBuildHost { + + /** + * Register the proxy app. CoGo replies (possibly immediately) with an + * {@link IQuickBuildTarget#onPayload} carrying the current generation when the + * app's running generation is stale. + */ + void connect(IQuickBuildTarget target, String packageName, long runningGeneration); + + /** The payload for {@code generation} was loaded and rendered in {@code reloadMillis}. */ + oneway void reportReloaded(long generation, long reloadMillis); + + /** The payload for {@code generation} crashed in render/lifecycle. */ + oneway void reportCrash(long generation, String stackSummary); + + /** Drop the registration for {@code packageName}, so CoGo stops sending it payloads. */ + void disconnect(String packageName); +} diff --git a/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildTarget.aidl b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildTarget.aidl new file mode 100644 index 0000000000..6f469c7405 --- /dev/null +++ b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildTarget.aidl @@ -0,0 +1,46 @@ +package com.itsaky.androidide.quickbuild; + +/** + * Proxy app side of the deploy channel. CoGo calls this after a successful + * quick build. Payloads travel as ParcelFileDescriptors; nothing touches shared storage. + * The target accepts a payload only when {@code generation} is strictly newer than the + * generation it currently runs. + * + * Versioning: CoGo and an installed proxy app can run DIFFERENT revisions of this + * interface (the runtime AAR is baked into the proxy app at proxy app build time). Only ever + * APPEND methods at the end - never reorder or remove. An older proxy app's stub answers + * an unknown transaction code with "not handled", and because the interface is oneway + * the caller never notices; the message is simply ignored. + */ +oneway interface IQuickBuildTarget { + + /** + * Deliver generation {@code generation}. + * + * @param dexPayload classes.dex containing ALL user classes + generated proxies, + * or null for a resources/assets-only deploy. + * @param resourcesPayload fd to the full relinked resource apk (resources.arsc plus + * every compiled resource file, not a bare table - see + * Aapt2Link's KDoc) for + * ResourcesProvider.loadFromApk, or null when resources did + * not change. + * @param assetsPayload a zip of changed asset files, or null. + * @param metadataJson JSON: entry activity class, changed-asset paths, flags. + * Schema in quickbuild/protocol/README.md. + */ + void onPayload(long generation, in @nullable ParcelFileDescriptor dexPayload, + in @nullable ParcelFileDescriptor resourcesPayload, + in @nullable ParcelFileDescriptor assetsPayload, String metadataJson); + + /** + * Build-status message: tells the running proxy app that a quick build + * FAILED CoGo-side (a compile error never produces a payload, so without this the + * app would silently keep running old code with no user-visible signal), or that a + * build succeeded (clears a previously shown failure). + * + * @param statusJson JSON with string-only values; schema in quickbuild/protocol/README.md. + * Unknown kinds and unknown fields are ignored by the runtime, so + * the schema can grow without breaking installed proxy apps. + */ + void onBuildStatus(String statusJson); +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java new file mode 100644 index 0000000000..4b077deb0e --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java @@ -0,0 +1,195 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Tracks the process's live activities so a reload knows which one to recreate. + * + * Registered via {@link Application#registerActivityLifecycleCallbacks}. Activities are held weakly so the tracker never keeps a destroyed one alive, and access is synchronized because the binder thread reads {@link #hasResumedActivity} while lifecycle callbacks mutate the lists on the main thread. + */ +final class ActivityTracker implements Application.ActivityLifecycleCallbacks { + + private final QuickBuildRuntime runtime; + + /** Every live activity, oldest first, so the newest is the last live entry. */ + private final List> created = new ArrayList>(); + + /** The most recently resumed activity, or null once it is destroyed. */ + private WeakReference resumed; + + /** + * Whether {@link #resumed} is still in the resumed state. Cleared on its pause, so this distinguishes "on screen now" from "was on screen last"; {@link #resumed} alone cannot, because it survives a home press until the activity is destroyed. + */ + private boolean resumedActive; + + /** + * @param runtime + * the runtime to notify of activity creation and resume; held strongly, which is safe because the runtime outlives every activity + */ + ActivityTracker(QuickBuildRuntime runtime) { + this.runtime = runtime; + } + + /** + * Records the activity, lets the runtime do its first-activity Context work, then attaches swapped resources. + * + * The runtime call comes first because it is what creates the resource loader when a cold start adopts a persisted generation; attaching before it would be a no-op, leaving this activity resolving against the baseline table for its whole lifetime. + * + * @param activity + * the activity being created + * @param savedInstanceState + * the framework's saved state; unused here + */ + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + synchronized (this) { + created.add(new WeakReference(activity)); + } + runtime.onActivityCreated(activity); + ResourceStore.INSTANCE.attachTo(activity.getResources()); + } + + /** + * Drops the activity, and any reference whose activity has already been collected. + * + * @param activity + * the activity being destroyed + */ + @Override + public void onActivityDestroyed(Activity activity) { + synchronized (this) { + Iterator> it = created.iterator(); + while (it.hasNext()) { + Activity tracked = it.next().get(); + if (tracked == null || tracked == activity) { + it.remove(); + } + } + if (resumed != null && resumed.get() == activity) { + resumed = null; + resumedActive = false; + } + } + } + + /** + * Marks the app as off screen when its resumed activity pauses. + * + * In an in-app A-to-B transition, A's pause runs before B's resume, so the flag dips and recovers within the same handoff; only a real background (home, app switch) leaves it cleared. + * + * @param activity + * the activity leaving the resumed state + */ + @Override + public void onActivityPaused(Activity activity) { + synchronized (this) { + if (resumed != null && resumed.get() == activity) { + resumedActive = false; + } + } + } + + /** + * Attaches swapped resources early enough that the activity's own inflation sees them. + * + * Only fires on API 29+; on older devices {@link #onActivityCreated} is the later backstop. + * + * The runtime's Context work runs here too, because this is the only hook that precedes the activity's own inflation: on a cold start that adopts a persisted generation the resources do not exist until it runs, so deferring it to {@link #onActivityCreated} would let the first activity inflate against the baseline table. Every step of it is idempotent. + * + * @param activity + * the activity about to be created, used for its Resources and as the runtime's first Context + * @param savedInstanceState + * the framework's saved state; unused here + */ + @Override + public void onActivityPreCreated(Activity activity, Bundle savedInstanceState) { + runtime.onActivityCreated(activity); + ResourceStore.INSTANCE.attachTo(activity.getResources()); + } + + /** + * Marks the activity as the reload target and lets the runtime bind its overlay to it. + * + * @param activity + * the activity now in the foreground + */ + @Override + public void onActivityResumed(Activity activity) { + synchronized (this) { + resumed = new WeakReference(activity); + resumedActive = true; + } + runtime.onActivityResumed(activity); + } + + /** + * @param activity + * the activity being saved; unused, since what a restart waits for is the stop that follows, not this callback - the framework reports the state to the server from the stop, and killing between the two is what force-removes the record + * @param outState + * the framework's bundle; untouched + */ + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} + + /** + * Counts the activity into the set a restart deploy waits to empty before killing the process. + * + * @param activity + * the activity being started; unused, since the wait is on the census rather than on any one of them + */ + @Override + public void onActivityStarted(Activity activity) { + runtime.onActivityStarted(); + } + + /** + * Counts the activity back out of that set, which is where ActivityThread captures its state. + * + * @param activity + * the activity being stopped; unused, as above + */ + @Override + public void onActivityStopped(Activity activity) { + runtime.onActivityStopped(); + } + + /** + * Whether the app is on screen: some live, non-finishing activity is currently resumed. + * + * @return true when the most recently resumed activity is still resumed and alive + */ + synchronized boolean hasResumedActivity() { + if (!resumedActive || resumed == null) { + return false; + } + Activity top = resumed.get(); + return top != null && !top.isFinishing(); + } + + /** + * Picks the activity a reload should recreate: the resumed one, else the newest live one. + * + * @return the resumed activity, else the newest live one, else null; a finishing activity is skipped, since recreating one would just have it finish again + */ + synchronized Activity topActivity() { + if (resumed != null) { + Activity top = resumed.get(); + if (top != null && !top.isFinishing()) { + return top; + } + } + for (int i = created.size() - 1; i >= 0; i--) { + Activity candidate = created.get(i).get(); + if (candidate != null && !candidate.isFinishing()) { + return candidate; + } + } + return null; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java new file mode 100644 index 0000000000..d32ec141c7 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java @@ -0,0 +1,214 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Extracts changed-assets zip payloads into one cumulative app-private override directory. + * + * Each payload carries only the assets that changed since the previous build, so extraction merges into one directory rather than per-payload dirs. It outlives the process on purpose: after a relaunch only the newest zip is re-applied from persistence, and the merged dir is what still holds the older ones. A fingerprint marker keys it to the baseline and clears it on mismatch, so assets never outlive the baseline they were deployed onto. + * + * Entry names arrive over binder and are checked for path traversal before any byte is written. Plain Java, no Android imports, so it stays JVM-unit-testable. + */ +final class AssetExtractor { + + /** + * Directory under the assets root that the merged extraction accumulates into. Its layout is a DirectoryAssetsProvider root: assets sit under an {@code assets/} subdirectory, because that provider treats its directory as the root of an APK. + */ + static final String CURRENT_DIR = "current"; + + /** APK-layout subdirectory of {@link #CURRENT_DIR} the asset entries land in. */ + static final String ASSETS_SUBDIR = "assets"; + + /** Marker file beside {@link #CURRENT_DIR} naming the baseline the merged assets belong to. */ + static final String BASELINE_MARKER = "baseline.fp"; + + private static final int BUFFER_SIZE = 16 * 1024; + + /** + * The merged override directory under {@code assetsRoot} - the DirectoryAssetsProvider root. + * + * @param assetsRoot + * the per-app assets cache root the cumulative state lives under + * @return the directory {@link #extractCumulative} merges into; may not exist yet + */ + static File currentDir(File assetsRoot) { + return new File(assetsRoot, CURRENT_DIR); + } + + /** + * Extracts every file entry of {@code zipStream} under {@code destDir}, overwriting existing files. Does not close the stream; the caller owns it. + * + * @param zipStream + * the changed-assets zip as it arrived over binder; read but never closed + * @param destDir + * the app-private override directory, created when missing + * @return the number of files extracted, directory entries excluded + * @throws IOException + * on I/O failure or when an entry would escape {@code destDir}, at which point extraction stops and the directory can hold a partial set + */ + static int extract(InputStream zipStream, File destDir) throws IOException { + if (!destDir.isDirectory() && !destDir.mkdirs()) { + throw new IOException("cannot create asset dir " + destDir); + } + String destPrefix = destDir.getCanonicalPath() + File.separator; + ZipInputStream zip = new ZipInputStream(zipStream); + int count = 0; + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + try { + if (entry.isDirectory()) { + continue; + } + File target = new File(destDir, entry.getName()); + if (!target.getCanonicalPath().startsWith(destPrefix)) { + throw new IOException("zip entry escapes destination: " + entry.getName()); + } + writeFile(zip, target); + count++; + } finally { + zip.closeEntry(); + } + } + return count; + } + + /** + * Merges a changed-assets zip into the cumulative override dir, clearing it first when it was built against another baseline. + * + * The clear-then-mark order is the safe crash window: a death between the two leaves a mismatched marker, so the next call clears an already-empty dir instead of serving another baseline's assets. + * + * @param zipStream + * the changed-assets zip as it arrived over binder; read but never closed + * @param assetsRoot + * the per-app assets cache root holding the merged dir and its marker + * @param baselineFingerprint + * the running baseline's fingerprint; a marker mismatch clears the merged dir + * @return the number of files extracted from this zip, directory entries excluded + * @throws IOException + * on I/O failure, a path-traversal entry, or a stale dir that cannot be cleared - serving it anyway would violate the never-stale invariant + */ + static int extractCumulative(InputStream zipStream, File assetsRoot, + String baselineFingerprint) throws IOException { + if (baselineFingerprint == null) { + throw new IOException("no baseline fingerprint; cannot key the asset override dir"); + } + File providerRoot = currentDir(assetsRoot); + File marker = new File(assetsRoot, BASELINE_MARKER); + if (!baselineFingerprint.equals(readMarker(marker))) { + deleteRecursively(providerRoot); + writeMarker(marker, baselineFingerprint); + } + return extract(zipStream, new File(providerRoot, ASSETS_SUBDIR)); + } + + /** + * Deletes {@code file} and everything under it; a no-op when it does not exist. + * + * @param file + * the file or directory to remove + * @throws IOException + * when anything cannot be deleted; the caller must not proceed, since leftover files would be served as live assets + */ + private static void deleteRecursively(File file) throws IOException { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + if (file.exists() && !file.delete()) { + throw new IOException("cannot delete stale asset override " + file); + } + } + + /** + * Reads the baseline marker. + * + * @param marker + * the marker file; may be absent + * @return its contents, or null when absent or unreadable - both count as a mismatch, which errs toward clearing rather than serving assets of unknown provenance + */ + private static String readMarker(File marker) { + if (!marker.isFile()) { + return null; + } + InputStream in = null; + try { + in = new FileInputStream(marker); + return new String(Streams.readFully(in), StandardCharsets.UTF_8); + } catch (IOException error) { + return null; + } finally { + Streams.closeQuietly(in); + } + } + + /** + * Writes to a temp file and renames, so a failure mid-copy never leaves a half-written asset. + * + * @param in + * the current zip entry's bytes; read to the end of the entry, never closed + * @param target + * the final path, already checked to sit inside the destination directory + * @throws IOException + * when a parent directory cannot be created, the copy fails, or the rename into place fails twice + */ + private static void writeFile(InputStream in, File target) throws IOException { + File parent = target.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("cannot create dir " + parent); + } + File temp = new File(parent, target.getName() + ".qb-tmp"); + FileOutputStream out = new FileOutputStream(temp); + try { + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } finally { + out.close(); + } + if (!temp.renameTo(target)) { + // Rename over an existing file can fail on some filesystems; retry once + // after an explicit delete, then give up loudly. + target.delete(); + if (!temp.renameTo(target)) { + temp.delete(); + throw new IOException("cannot move extracted asset into place: " + target); + } + } + } + + /** + * Writes the baseline marker. A plain write, not temp-then-rename: a torn marker reads as a mismatch, which clears and rewrites - the safe direction. + * + * @param marker + * the marker file; its parent is created when missing + * @param fingerprint + * the baseline fingerprint to record + * @throws IOException + * when the parent cannot be created or the write fails + */ + private static void writeMarker(File marker, String fingerprint) throws IOException { + File parent = marker.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("cannot create dir " + parent); + } + FileOutputStream out = new FileOutputStream(marker); + try { + out.write(fingerprint.getBytes(StandardCharsets.UTF_8)); + } finally { + out.close(); + } + } + + private AssetExtractor() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java new file mode 100644 index 0000000000..a03d586bc1 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java @@ -0,0 +1,56 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.io.InputStream; + +/** + * Parses the baseline-generation stamp asset the Gradle plugin writes next to the baseline payload dex. + * + * The proxy app build stamps the generation the host allocated for the baseline, drawn from the same persistent counter that numbers hot deploys. Booting the baseline at that number makes a post-rebaseline reconnect read in-sync by construction, and keeps every later hot deploy strictly newer. A missing or malformed stamp parses as {@link #UNSTAMPED}, so an APK built by an older plugin behaves exactly as before stamping existed. + */ +final class BaselineGeneration { + + /** The fallback: an unstamped baseline is generation 0, as before stamping existed. */ + static final long UNSTAMPED = 0L; + + /** + * Parses stamp text into a generation. + * + * @param text + * the asset's content; surrounding whitespace is tolerated + * @return the parsed generation, or {@link #UNSTAMPED} for null, non-numeric or negative input - the host's counter only hands out positive numbers, so a negative stamp is corruption, and adopting it would let payloads at or below generation 0 replace the baseline + */ + static long parse(String text) { + if (text == null) { + return UNSTAMPED; + } + try { + long value = Long.parseLong(text.trim()); + return value < 0 ? UNSTAMPED : value; + } catch (NumberFormatException error) { + return UNSTAMPED; + } + } + + /** + * Reads and parses the stamp from an asset stream, closing it. + * + * @param in + * the stamp asset's stream, or null when the APK carries none + * @return the stamped generation, or {@link #UNSTAMPED} when the stream is null or unreadable + */ + static long read(InputStream in) { + if (in == null) { + return UNSTAMPED; + } + try { + return parse(new String(Streams.readFully(in), "UTF-8")); + } catch (Throwable error) { + RuntimeLog.w("unreadable baseline-generation stamp: " + error); + return UNSTAMPED; + } finally { + Streams.closeQuietly(in); + } + } + + private BaselineGeneration() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java new file mode 100644 index 0000000000..0248480744 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java @@ -0,0 +1,57 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Which generation a crash right now should be quarantined against, so a fresh process stops booting it. + * + * A hot swap answers that on its own: the generation whose reload is still awaiting its first frame is the one that just took the screen. A restart deploy answers nothing - it persists the generation and kills the process, so the fresh process boots that generation from the store with no reload pending, and every crash on its way to the screen is invisible to the guard. Measured on an A56: the app crash-looped on the bad generation on every launch with no way out, where the same crash before the always-restart rule at least quarantined and came back on older code. + * + * So a generation this process took from the store is on probation until it proves itself, and the proof is the one a fallback already needs: {@link PayloadPersistence#markGood} recorded it, which only happens once an activity of it was resumed. + * + * Blaming too widely is the safe direction. {@link PayloadPersistence#quarantine} refuses to name a generation already recorded good, so an over-eager blame costs a log line rather than the user's last working code - which is also what keeps a fallback boot from quarantining the very generation it fell back to. + */ +final class BootProbation { + + /** The generation this process took from the store, until it proves itself, else -1. Guarded by {@code this}. */ + private long unprovenGeneration = -1; + + /** + * Puts the generation this process booted from the store on probation. + * + * @param generation + * the persisted generation adopted at boot, or -1 when the process booted the code the installed APK carries - which is the floor a quarantine falls back to anyway, so there is nothing there to refuse + */ + synchronized void bootedFromStore(long generation) { + unprovenGeneration = generation > 0 ? generation : -1; + } + + /** + * The generation a crash happening right now should be quarantined against. + * + * @param pendingReloadGeneration + * the hot-swapped generation awaiting its first frame, or -1; it outranks the booted one, being the newer claim on the screen that just died + * @param liveGeneration + * the generation the store currently serves, which is how a booted generation superseded by a later deploy stops being blamed for that deploy's crash + * @return the generation to quarantine, or -1 when nothing this process adopted is to blame + */ + synchronized long generationToBlame(long pendingReloadGeneration, long liveGeneration) { + if (pendingReloadGeneration >= 0) { + return pendingReloadGeneration; + } + if (unprovenGeneration >= 0 && unprovenGeneration == liveGeneration) { + return unprovenGeneration; + } + return -1; + } + + /** + * Ends the probation: the generation is now recorded as the one a later quarantine falls back to. + * + * @param generation + * the generation just recorded good; anything else is a confirmation for a superseded generation and leaves the probation where it is + */ + synchronized void proved(long generation) { + if (generation == unprovenGeneration) { + unprovenGeneration = -1; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BuildStatus.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BuildStatus.java new file mode 100644 index 0000000000..d4530573fa --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BuildStatus.java @@ -0,0 +1,136 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.util.Map; + +/** + * Parsed form of the {@code statusJson} argument of {@code IQuickBuildTarget.onBuildStatus}. + * + * Schema is in quickbuild/protocol/README.md. Every value is a string on the wire because {@link MiniJson} reads only strings. Unknown kinds parse to null and unknown fields are ignored, so CoGo can extend the schema without breaking installed proxy apps. + */ +final class BuildStatus { + + /** A compile failed; {@link #message} carries the first error's first line. */ + static final String KIND_BUILD_FAILED = "build_failed"; + + /** A build succeeded, so any error banner can come down; carries no further fields. */ + static final String KIND_BUILD_OK = "build_ok"; + + /** A build started; only {@link #runningGeneration} is meaningful. */ + static final String KIND_BUILDING = "building"; + + /** An update is built but its reinstall awaits a confirm only CoGo can show; no further fields. */ + static final String KIND_REINSTALL_PENDING = "reinstall_pending"; + + /** + * Parses one build status message. + * + * @param json + * the {@code statusJson} argument of {@code onBuildStatus}; must be a JSON object + * @return the parsed status, or null for a kind this runtime does not know; unknown kinds are ignored, not errors + * @throws IllegalArgumentException + * on malformed JSON, for the caller to log and drop + */ + static BuildStatus parse(String json) { + Map obj = MiniJson.parseObject(json); + String kind = asString(obj.get("kind")); + if (KIND_BUILD_OK.equals(kind)) { + return new BuildStatus(KIND_BUILD_OK, null, 0, -1); + } + if (KIND_BUILD_FAILED.equals(kind)) { + return new BuildStatus( + KIND_BUILD_FAILED, + asString(obj.get("message")), + Math.max(0, asInt(obj.get("moreErrors"), 0)), + -1); + } + if (KIND_BUILDING.equals(kind)) { + return new BuildStatus(KIND_BUILDING, null, 0, asLong(obj.get("runningGeneration"), -1)); + } + if (KIND_REINSTALL_PENDING.equals(kind)) { + return new BuildStatus(KIND_REINSTALL_PENDING, null, 0, -1); + } + return null; + } + + /** + * Reads a wire value as an int, since every JSON value here is a string. + * + * @param value + * the raw value {@link MiniJson} produced, possibly null + * @param fallback + * returned when the value is absent, not a string, or not a number + * @return the parsed int, or {@code fallback} + */ + private static int asInt(Object value, int fallback) { + if (!(value instanceof String)) { + return fallback; + } + try { + return Integer.parseInt((String) value); + } catch (NumberFormatException e) { + return fallback; + } + } + + /** + * Reads a wire value as a long, for the generation counter. + * + * @param value + * the raw value {@link MiniJson} produced, possibly null + * @param fallback + * returned when the value is absent, not a string, or not a number + * @return the parsed long, or {@code fallback} + */ + private static long asLong(Object value, long fallback) { + if (!(value instanceof String)) { + return fallback; + } + try { + return Long.parseLong((String) value); + } catch (NumberFormatException e) { + return fallback; + } + } + + /** + * Narrows a parsed JSON value to a string, so an unexpected type defaults instead of throwing. + * + * @param value + * the raw value {@link MiniJson} produced, possibly null + * @return {@code value} as a string, or null when absent or of another type + */ + private static String asString(Object value) { + return value instanceof String ? (String) value : null; + } + + /** One of the KIND_ constants; never anything else, since an unknown kind parses to null. */ + final String kind; + + /** First line of the first error message, or null. */ + final String message; + + /** How many further errors the build reported beyond the first, >= 0. */ + final int moreErrors; + + /** For {@link #KIND_BUILDING}: the generation the app still runs, or -1 if unknown. */ + final long runningGeneration; + + /** + * Stores one already-defaulted status; only {@link #parse} constructs these. + * + * @param kind + * one of the KIND_ constants + * @param message + * first line of the first error message, or null + * @param moreErrors + * further error count beyond the first, already clamped to >= 0 + * @param runningGeneration + * generation still running, for {@link #KIND_BUILDING}, else -1 + */ + private BuildStatus(String kind, String message, int moreErrors, long runningGeneration) { + this.kind = kind; + this.message = message; + this.moreErrors = moreErrors; + this.runningGeneration = runningGeneration; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadata.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadata.java new file mode 100644 index 0000000000..bf9d5933da --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadata.java @@ -0,0 +1,59 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.util.Map; + +/** + * Parsed form of the {@code metadataJson} argument of {@code IQuickBuildTarget.onPayload}. + * + * The fields below are the schema this class reads: the host writes it from {@code PayloadDeployer.metadata}, and writes more than this. Unknown fields are ignored, so the host can extend the schema without breaking installed proxy apps. + */ +final class DeployMetadata { + + /** + * Parses the deploy metadata, defaulting every absent or wrongly-typed field. + * + * @param json + * the {@code metadataJson} argument of {@code onPayload}; must be a JSON object + * @return the parsed metadata, with a null entry activity and no restart when the fields are absent + * @throws IllegalArgumentException + * on malformed JSON, which the caller treats as a bad payload + */ + static DeployMetadata parse(String json) { + Map obj = MiniJson.parseObject(json); + return new DeployMetadata( + asString(obj.get("entryActivity")), + "true".equals(obj.get("restart"))); + } + + /** + * Narrows a parsed JSON value to a string, so an unexpected type defaults instead of throwing. + * + * @param value + * the raw value {@link MiniJson} produced, possibly null + * @return {@code value} as a string, or null when absent or of another type + */ + private static String asString(Object value) { + return value instanceof String ? (String) value : null; + } + + /** + * Fully-qualified USER entry activity class; may be null. Not launched by the runtime (a deploy with no live activity applies silently, so a save never takes the screen); kept on the wire for compatibility. + */ + final String entryActivity; + + /** + * True when the recompiled set touched a service, provider, or custom Application class, so the runtime must persist the payload, ack, and exit instead of hot-swapping. On the wire this is the string {@code "restart": "true"}, per the MiniJson strings-only convention. + */ + final boolean restart; + + /** + * @param entryActivity + * user entry activity class, per {@link #entryActivity}; null when unknown + * @param restart + * true to persist-and-exit instead of hot-swapping, per {@link #restart} + */ + DeployMetadata(String entryActivity, boolean restart) { + this.entryActivity = entryActivity; + this.restart = restart; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java new file mode 100644 index 0000000000..60a0dd95a5 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java @@ -0,0 +1,85 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.annotation.TargetApi; +import android.content.res.AssetFileDescriptor; +import android.content.res.loader.AssetsProvider; +import android.os.ParcelFileDescriptor; +import java.io.Closeable; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** + * Serves a directory laid out as an APK root through the API 30+ {@link AssetsProvider} hook. + * + * The framework has such a provider but never made it public API - only the interface is - so this is the minimal open-coded equivalent. Lookups arrive with full APK-relative paths ({@code assets/...}), which is why {@link AssetExtractor} extracts under an {@code assets/} subdirectory. + * + * A missing file returns null, which falls the lookup through to the next provider and finally the baked-in APK - that fall-through is what makes the override additive: it can add and replace assets but never hide one. + */ +@TargetApi(30) +final class DirectoryAssetsProvider implements AssetsProvider, Closeable { + + /** + * Whether {@code candidate} resolves strictly inside {@code root}. + * + * Both sides are canonicalized, so {@code ..} segments and symlinks resolve before the comparison rather than being compared as text. The trailing separator is what stops a sibling whose name merely starts with the root's - {@code /a/rootEvil} against root {@code /a/root} - and it also excludes {@code root} itself. + * + * @param root + * the override directory being served + * @param candidate + * a path resolved against it + * @return true when the candidate may be served; false when it escapes, or when either path cannot be canonicalized - unresolvable counts as outside, since a path this process cannot resolve is one it must not serve + */ + static boolean isWithinRoot(File root, File candidate) { + try { + return candidate.getCanonicalPath().startsWith(root.getCanonicalPath() + File.separator); + } catch (IOException error) { + return false; + } + } + + private final File root; + + /** + * @param root + * the directory to serve, laid out as an APK root (asset files under {@code assets/}) + */ + DirectoryAssetsProvider(File root) { + this.root = root; + } + + /** Nothing held open between lookups; here so {@link ResourceStore} can treat providers uniformly. */ + @Override + public void close() {} + + /** + * Opens one asset for the framework. + * + * @param path + * the APK-relative path the framework resolved, such as {@code assets/data/levels.json} + * @param accessMode + * ignored; the descriptor is read-only regardless + * @return a read-only descriptor over the file, or null when this override does not carry it or the path would escape {@link #root} + */ + @Override + public AssetFileDescriptor loadAssetFd(String path, int accessMode) { + File candidate = new File(root, path); + // Same containment rule as AssetExtractor: the path arrives from outside + // this process's control and must not resolve outside the override dir. + if (!isWithinRoot(root, candidate)) { + return null; + } + if (!candidate.isFile()) { + return null; + } + try { + ParcelFileDescriptor fd = ParcelFileDescriptor.open( + candidate, ParcelFileDescriptor.MODE_READ_ONLY); + return new AssetFileDescriptor(fd, 0, candidate.length()); + } catch (FileNotFoundException error) { + // Raced by a concurrent clear; absent and unreadable look the same to the + // framework, which falls through to the baked-in copy. + return null; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java new file mode 100644 index 0000000000..889a8817e5 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java @@ -0,0 +1,39 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Holds the generation acceptance rule, so it is stated once and JVM-testable. + */ +final class Generations { + + /** + * Decides whether an incoming payload may replace the running one. + * + * Only a strictly newer generation is accepted (IQuickBuildTarget contract); an equal or older one is a replay from a deploy racing a reconnect and must be dropped. + * + * @param runningGeneration + * generation of the payload already live in this process; 0 when nothing has been applied yet + * @param incomingGeneration + * generation stamped on the arriving payload by the deploying host + * @return true when the incoming payload is strictly newer and the caller should apply it + */ + static boolean accepts(long runningGeneration, long incomingGeneration) { + return incomingGeneration > runningGeneration; + } + + /** + * Decides whether a failed reload's rollback still applies. + * + * A reload's rollback snapshot is taken before its apply, but the failure can surface much later - the recreate runs on a posted main-thread runnable, and a newer payload can land on a binder thread in the meantime. Restoring then would drop the store to a snapshot two generations old, undoing a deploy that succeeded. The rollback is only ever the right answer while the store still holds the generation that failed. + * + * @param runningGeneration + * generation the store holds right now + * @param failedGeneration + * generation whose reload failed and wants to roll back + * @return true when the failure still owns the store and the caller should restore + */ + static boolean rollbackApplies(long runningGeneration, long failedGeneration) { + return runningGeneration == failedGeneration; + } + + private Generations() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java new file mode 100644 index 0000000000..48a5539af3 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java @@ -0,0 +1,133 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.content.res.AssetManager; +import android.content.res.Resources; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; + +/** + * Applies a resource payload on API 28/29, where ResourcesLoader does not exist. + * + * Persist the relinked apk, append it to the live AssetManager through the hidden addAssetPath, then flush the Resources caches so the deploy's activity recreate resolves against the new table. The new package shares the old package id and resource ids, and the last-added package wins the lookup. + * + * Degraded by design relative to the API 30+ loader path: an added path can never be removed, so each generation appends one more package until the process restarts, and a Resources with its own AssetManager only picks the table up when {@link ResourceStore#attachTo} reaches it. {@link #deleteStaleApks} sweeps the directory at startup instead, since nothing a previous process mounted survives its death. + */ +final class LegacyResourceSwap { + + /** + * Cache subdirectory the relinked apks live in. + * + * Must match {@code ResourceStore.LEGACY_TABLE_DIR}, which is what actually writes them; {@code LegacyResourceSwapCacheDirTest} pins the two together. + */ + static final String TABLE_DIR = "quickbuild-res"; + + /** Prefix of a generation-stamped relinked apk, as written by {@link #writeResourceApk}. */ + private static final String APK_PREFIX = "gen-"; + + /** Suffix of a generation-stamped relinked apk. */ + private static final String APK_SUFFIX = ".zip"; + + /** + * Mounts the resource apk at {@code path} on the live AssetManager, via the hidden addAssetPath. + * + * Idempotent: the framework returns the existing cookie for an already-added path. Throws on any failure so the deploy path can roll the payload back, because a resource payload must never be silently dropped. + * + * @param assets + * the process's live AssetManager, normally {@code Resources#getAssets()} + * @param path + * absolute path of the apk written by {@link #writeResourceApk}; it must stay on disk for the life of the process, since a mounted path can never be removed + * @throws IOException + * when the hidden method is missing, throws, or returns cookie 0, which is the framework's way of rejecting the path + */ + static void addAssetPath(AssetManager assets, String path) throws IOException { + try { + Method method = AssetManager.class.getDeclaredMethod("addAssetPath", String.class); + method.setAccessible(true); + Object cookie = method.invoke(assets, path); + if (!(cookie instanceof Integer) || (Integer) cookie == 0) { + throw new IOException("addAssetPath rejected " + path + " (cookie=" + cookie + ")"); + } + } catch (IOException error) { + throw error; + } catch (Throwable error) { + throw new IOException("addAssetPath failed for " + path, error); + } + } + + /** + * Deletes every relinked apk in {@code dir}, for a process that has mounted none of them yet. + * + * Safe only before the first swap of this process: a mounted path can never be unmounted, so deleting one this process is serving would leave the AssetManager pointing at nothing. Best-effort - a file it cannot delete costs cache space, never correctness. + * + * @param dir + * the cache subdirectory named by {@link #TABLE_DIR}; a missing one is a no-op + * @return how many files were deleted, for the log line and the tests + */ + static int deleteStaleApks(File dir) { + File[] entries = dir.listFiles(); + if (entries == null) { + return 0; + } + int deleted = 0; + for (File entry : entries) { + String name = entry.getName(); + if (!entry.isFile() || !name.startsWith(APK_PREFIX) || !name.endsWith(APK_SUFFIX)) { + continue; + } + if (entry.delete()) { + deleted++; + } else { + RuntimeLog.w("could not delete stale resource apk " + entry); + } + } + return deleted; + } + + /** + * Drops the cached drawables, color state lists and typed values so lookups cannot serve values from the old table. + * + * updateConfiguration with the current config is the only public way to force that. + * + * @param resources + * the Resources whose caches to drop; its configuration is re-applied unchanged, so this is a flush and not a configuration change + */ + @SuppressWarnings("deprecation") + static void flushCaches(Resources resources) { + resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics()); + } + + /** + * Copies the relinked resource apk stream to a gen-numbered zip under {@code dir}. + * + * The stream from {@code Aapt2Link} is already a valid apk/zip, so this is a plain byte copy. Do not re-wrap it: a bare arsc in a synthetic single-entry zip leaves file-backed resources such as layouts and drawable XMLs with no zip entry to resolve against, and they crash on first access. + * + * @param apk + * the relinked apk bytes; read to exhaustion but never closed, since the caller owns the stream + * @param dir + * app-private directory to write into, created when missing + * @param generation + * the payload generation, which names the file and so keeps every mounted path distinct + * @return the written file, whose path is what {@link #addAssetPath} mounts + * @throws IOException + * when {@code dir} cannot be created, the stream exceeds the payload cap, or the write fails + */ + static File writeResourceApk(InputStream apk, File dir, long generation) throws IOException { + byte[] bytes = Streams.readFully(apk); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("cannot create " + dir); + } + File zip = new File(dir, APK_PREFIX + generation + APK_SUFFIX); + FileOutputStream out = new FileOutputStream(zip); + try { + out.write(bytes); + } finally { + out.close(); + } + return zip; + } + + private LegacyResourceSwap() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouter.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouter.java new file mode 100644 index 0000000000..87ab1fdbe3 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouter.java @@ -0,0 +1,36 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Holds the classloader routing decision every {@link QuickBuildAppComponentFactory} override makes. + * + * Extracted from the factory so it is JVM-unit-testable without the Android framework. + */ +final class LoaderRouter { + + /** + * Picks the loader that should instantiate {@code className}: the payload loader when it can serve the class, else the default. + * + * The payload loader's parent chain covers the APK, so framework and androidx classes resolve the same either way. Only ClassNotFoundException is caught - a LinkageError must propagate so the factory's own catch re-instantiates through the default loader, a stronger fallback. + * + * @param defaultLoader + * the loader the framework handed the factory; returned whenever the payload cannot serve the class + * @param payloadLoader + * the live payload loader, or null when no payload is live, which always yields {@code defaultLoader} + * @param className + * binary name of the component the framework is about to instantiate + * @return the loader to instantiate {@code className} with, never null unless {@code defaultLoader} was + */ + static ClassLoader pick(ClassLoader defaultLoader, ClassLoader payloadLoader, String className) { + if (payloadLoader == null) { + return defaultLoader; + } + try { + payloadLoader.loadClass(className); + return payloadLoader; + } catch (ClassNotFoundException notInPayloadChain) { + return defaultLoader; + } + } + + private LoaderRouter() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/MiniJson.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/MiniJson.java new file mode 100644 index 0000000000..bd4e7b8179 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/MiniJson.java @@ -0,0 +1,411 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Reads the runtime's small JSON schemas: deploy metadata, build status and the persisted-payload metadata. + * + * Hand-rolled because this AAR carries zero dependencies and android.jar's org.json is a stub in JVM unit tests. It keeps only strings and arrays of strings, but consumes nested objects, numbers, booleans and nulls so a document with extra fields still parses. Malformed input throws {@link IllegalArgumentException}, which callers treat as a bad payload. + * + * Every rejection must be that exception and nothing else: the input crosses binder from CoGo, so a document deep enough to exhaust the stack would raise an Error no caller catches, and a literal skipped without checking its shape would leave a key silently missing from the result. + */ +final class MiniJson { + + /** + * Nesting the parser will descend, since each level costs a Java frame. + * + * Well above the two shallow schemas this reads, and far below any stack the runtime has. + */ + private static final int MAX_DEPTH = 64; + + /** + * Parses {@code json} as a top-level object. + * + * String values map to {@link String} and arrays keep only their string elements as {@code List}; every other value is consumed and dropped. + * + * @param json + * the whole document, which must be one object with nothing after it + * @return a mutable insertion-ordered map holding the kept values; keys whose value was dropped are absent entirely + * @throws IllegalArgumentException + * when {@code json} is null, is not a well-formed object, or carries trailing content + */ + static Map parseObject(String json) { + if (json == null) { + throw new IllegalArgumentException("json is null"); + } + MiniJson parser = new MiniJson(json); + parser.skipWhitespace(); + parser.enter(); + Map result = parser.readObject(); + parser.depth--; + parser.skipWhitespace(); + if (parser.pos != json.length()) { + throw parser.fail("trailing content"); + } + return result; + } + + /** The document being read; a parser instance is single-use. */ + private final String src; + + /** Read cursor into {@link #src}, in chars. */ + private int pos; + + /** Object and array levels currently open, capped by {@link #MAX_DEPTH}. */ + private int depth; + + /** + * @param src + * the document to read; never null, since {@link #parseObject} checks first + */ + private MiniJson(String src) { + this.src = src; + } + + /** + * Opens one nesting level, refusing to descend past {@link #MAX_DEPTH}. + * + * The recursion is one Java frame per level, so an unbounded descent raises StackOverflowError - an Error, not the IllegalArgumentException this class contracts to throw and callers catch. + * + * @throws IllegalArgumentException + * when the document nests deeper than the cap + */ + private void enter() { + if (++depth > MAX_DEPTH) { + throw fail("nesting deeper than " + MAX_DEPTH + " levels"); + } + } + + /** + * Consumes the next char, requiring it to be {@code expected}. + * + * @param expected + * the char the grammar demands here + * @throws IllegalArgumentException + * when the next char differs, with the cursor left on it so the message points at the right offset + */ + private void expect(char expected) { + if (read() != expected) { + pos--; + throw fail("expected '" + expected + "'"); + } + } + + /** + * Builds the parse failure, stamped with the current offset. + * + * @param message + * what the grammar expected at this point + * @return the exception to throw; this method never throws it itself + */ + private IllegalArgumentException fail(String message) { + return new IllegalArgumentException("malformed json at offset " + pos + ": " + message); + } + + /** + * @param c + * the char to test + * @return true for ASCII 0-9 only; Character.isDigit would also accept other scripts' digits, which JSON does not + */ + private boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + /** + * Whether {@code token} is a JSON number. + * + * Hand-rolled rather than delegating to Double.parseDouble, which also accepts hex, {@code NaN}, {@code Infinity}, a trailing {@code d}/{@code f} and surrounding whitespace - none of which is JSON. + * + * @param token + * the candidate token, never empty + * @return true when it matches {@code -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?} + */ + private boolean isNumber(String token) { + int i = 0; + int length = token.length(); + if (token.charAt(i) == '-') { + i++; + } + if (i >= length) { + return false; + } + if (token.charAt(i) == '0') { + i++; + } else { + int digits = i; + while (i < length && isDigit(token.charAt(i))) { + i++; + } + if (i == digits) { + return false; + } + } + if (i < length && token.charAt(i) == '.') { + i++; + int digits = i; + while (i < length && isDigit(token.charAt(i))) { + i++; + } + if (i == digits) { + return false; + } + } + if (i < length && (token.charAt(i) == 'e' || token.charAt(i) == 'E')) { + i++; + if (i < length && (token.charAt(i) == '+' || token.charAt(i) == '-')) { + i++; + } + int digits = i; + while (i < length && isDigit(token.charAt(i))) { + i++; + } + if (i == digits) { + return false; + } + } + return i == length; + } + + /** + * The char at the cursor, without consuming it. + * + * @return the current char + * @throws IllegalArgumentException + * at end of input, so no caller has to bounds-check + */ + private char peek() { + if (pos >= src.length()) { + throw fail("unexpected end of input"); + } + return src.charAt(pos); + } + + /** + * The char at the cursor, consuming it. + * + * @return the char just consumed + * @throws IllegalArgumentException + * at end of input + */ + private char read() { + char c = peek(); + pos++; + return c; + } + + /** + * Reads an array, keeping only its string elements. + * + * No schema reads the elements, but an array must still survive as a non-null value: {@code PayloadPersistence.namedFile} tells a corrupt store from an absent kind by the value's type, and a dropped array would read as "this kind was never persisted". + * + * @return the string elements in document order, empty when the array held none + * @throws IllegalArgumentException + * on a malformed array or at end of input + */ + private List readArray() { + expect('['); + List out = new ArrayList(); + skipWhitespace(); + if (peek() == ']') { + pos++; + return out; + } + while (true) { + skipWhitespace(); + Object value = readValue(); + if (value instanceof String) { + out.add((String) value); + } + skipWhitespace(); + char c = read(); + if (c == ']') { + return out; + } + if (c != ',') { + throw fail("expected ',' or ']'"); + } + } + } + + /** + * Reads an object, keeping only the entries whose value survived {@link #readValue}. + * + * @return the kept entries in document order; a duplicate key keeps the last value + * @throws IllegalArgumentException + * on a malformed object or at end of input + */ + private Map readObject() { + expect('{'); + Map out = new LinkedHashMap(); + skipWhitespace(); + if (peek() == '}') { + pos++; + return out; + } + while (true) { + skipWhitespace(); + String key = readString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + Object value = readValue(); + if (value != null) { + out.put(key, value); + } + skipWhitespace(); + char c = read(); + if (c == '}') { + return out; + } + if (c != ',') { + throw fail("expected ',' or '}'"); + } + } + } + + /** + * Reads a quoted string, decoding the standard JSON escapes. + * + * @return the decoded string, without its quotes + * @throws IllegalArgumentException + * on an unknown escape, an unterminated string, or end of input + */ + private String readString() { + expect('"'); + StringBuilder sb = new StringBuilder(); + while (true) { + char c = read(); + if (c == '"') { + return sb.toString(); + } + if (c != '\\') { + sb.append(c); + continue; + } + char escape = read(); + switch (escape) { + case '"': + sb.append('"'); + break; + case '\\': + sb.append('\\'); + break; + case '/': + sb.append('/'); + break; + case 'b': + sb.append('\b'); + break; + case 'f': + sb.append('\f'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case 'u': + sb.append(readUnicodeEscape()); + break; + default: + throw fail("bad escape '\\" + escape + "'"); + } + } + } + + /** + * Decodes the four hex digits of a {@code \\u} escape, the cursor being just past the u. + * + * @return the decoded char; a surrogate is returned as-is, so a pair decodes across two calls + * @throws IllegalArgumentException + * when fewer than four chars remain or they are not four hex digits + */ + private char readUnicodeEscape() { + if (pos + 4 > src.length()) { + throw fail("truncated unicode escape"); + } + String hex = src.substring(pos, pos + 4); + int decoded = 0; + for (int i = 0; i < 4; i++) { + // Integer.parseInt(hex, 16) accepts a leading sign, so an escape whose four + // chars start with + or - would decode instead of being rejected. Digits only. + int digit = Character.digit(hex.charAt(i), 16); + if (digit < 0) { + throw fail("bad unicode escape '\\u" + hex + "'"); + } + decoded = (decoded << 4) | digit; + } + pos += 4; + return (char) decoded; + } + + /** + * Reads any value, keeping the two types this parser supports. + * + * @return a String, a List of strings, or null for value types we drop; null therefore means "consumed and dropped", never "the JSON literal null" + * @throws IllegalArgumentException + * on malformed input or at end of input + */ + private Object readValue() { + char c = peek(); + if (c == '"') { + return readString(); + } + if (c == '[') { + enter(); + List array = readArray(); + depth--; + return array; + } + if (c == '{') { + enter(); + readObject(); + depth--; + return null; + } + skipLiteral(); + return null; + } + + /** + * Consumes a number / true / false / null token, dropping its value but checking its shape. + * + * Stops at the first structural char or whitespace. The shape check is what keeps a dropped value distinguishable from a rejected document: without it {@code {"b":qqq}} parses cleanly with {@code b} simply absent, which a caller reads as "the host did not send b". + * + * @throws IllegalArgumentException + * when the cursor sits on a structural char, i.e. there is no token here at all, or when the token is not one of the four JSON literal forms + */ + private void skipLiteral() { + int start = pos; + while (pos < src.length()) { + char c = src.charAt(pos); + if (c == ',' || c == '}' || c == ']' || Character.isWhitespace(c)) { + break; + } + pos++; + } + if (pos == start) { + throw fail("unexpected character '" + src.charAt(pos) + "'"); + } + String token = src.substring(start, pos); + if (!"true".equals(token) && !"false".equals(token) && !"null".equals(token) + && !isNumber(token)) { + pos = start; + throw fail("not a json literal: '" + token + "'"); + } + } + + /** Advances the cursor past any whitespace; safe at end of input, where it does nothing. */ + private void skipWhitespace() { + while (pos < src.length() && Character.isWhitespace(src.charAt(pos))) { + pos++; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java new file mode 100644 index 0000000000..eb419dc1be --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java @@ -0,0 +1,156 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Immutable description of what the status overlay currently shows. + * + * The overlay is error-only: it tells the user when a build fails or a payload crashes. {@link #building} is the one narrow exception, a neutral in-flight line so a slow compile does not read as silence. Success renders nothing. Every terminal event installs a new state and the overlay always renders the latest, so a transient state cannot get stuck on screen. + */ +final class OverlayState { + + /** + * State for a compile error, carrying the message summary the banner names. The banner is position-free by design: the error location is CoGo's to show, so it never crosses the deploy channel. + * + * @param status + * a parsed {@link BuildStatus#KIND_BUILD_FAILED} message; must be non-null, and its already-defaulted fields are copied as they are + * @return the state to render + */ + static OverlayState buildFailed(BuildStatus status) { + return new OverlayState(Kind.BUILD_FAILED, status.message, status.moreErrors, -1); + } + + /** + * State for a build in flight, with the app on screen still running {@code runningGeneration}. + * + * @param runningGeneration + * the generation the screen still shows, named in the banner text; -1 when unknown, which drops that clause + * @return the neutral in-flight state + */ + static OverlayState building(long runningGeneration) { + return new OverlayState(Kind.BUILDING, null, 0, runningGeneration); + } + + /** + * State for a payload that crashed and was rolled back, with a stack summary as {@code detail}. + * + * @param detail + * one-line summary of the crash, appended to the banner; null renders the headline alone + * @return the crash state + */ + static OverlayState crashed(String detail) { + return new OverlayState(Kind.CRASHED, detail, 0, -1); + } + + /** + * State that renders nothing, the resting state. + * + * @return the state that makes {@link StatusOverlay#render} remove the banner + */ + static OverlayState hidden() { + return new OverlayState(Kind.HIDDEN, null, 0, -1); + } + + /** + * State for an update whose reinstall is waiting on a confirm dialog only CoGo can show. The user watching this app is the one person the CoGo-side signals cannot reach, so this banner is the recovery instruction. + * + * @return the state to render + */ + static OverlayState reinstallPending() { + return new OverlayState(Kind.REINSTALL_PENDING, null, 0, -1); + } + + /** Which overlay state this is; decides the color and the text. */ + final Kind kind; + + /** First diagnostic line / crash stack summary, or null. */ + final String detail; + + /** Further error count beyond the first, >= 0. */ + final int moreErrors; + + /** For {@link Kind#BUILDING}: the generation still on screen, or -1 otherwise. */ + final long runningGeneration; + + /** + * Stores one state; only the factory methods above construct these. + * + * @param kind + * which state this is + * @param detail + * first diagnostic line or crash summary, or null + * @param moreErrors + * further error count beyond the first, >= 0 + * @param runningGeneration + * generation still on screen for BUILDING, else -1 + */ + private OverlayState(Kind kind, String detail, int moreErrors, long runningGeneration) { + this.kind = kind; + this.detail = detail; + this.moreErrors = moreErrors; + this.runningGeneration = runningGeneration; + } + + /** + * True while a build compiles; a terminal build_ok/build_failed must clear this too. + * + * @return whether this is the BUILDING state + */ + boolean isBuilding() { + return kind == Kind.BUILDING; + } + + /** + * True for the states a successful reload / build must clear. + * + * @return whether this state is BUILD_FAILED, CRASHED or REINSTALL_PENDING + */ + boolean isError() { + return kind == Kind.BUILD_FAILED || kind == Kind.CRASHED || kind == Kind.REINSTALL_PENDING; + } + + /** + * Builds the banner text for this state; failure copy always says the app still runs the last working code. + * + * @return the multi-line banner text, empty for {@link Kind#HIDDEN} + */ + String text() { + switch (kind) { + case BUILD_FAILED: + StringBuilder sb = new StringBuilder( + "Build failed - app is running the last working version"); + if (detail != null) { + sb.append('\n').append(detail); + if (moreErrors > 0) { + sb.append(" (+").append(moreErrors).append(" more)"); + } + } + return sb.toString(); + case CRASHED: + return "New code crashed - app is running the last working version" + + (detail == null ? "" : "\n" + detail); + case REINSTALL_PENDING: + return "Update needs your OK in Code on the Go - switch back to approve it\n" + + "This app is running the last working version"; + case BUILDING: + return runningGeneration >= 0 + ? "Quick Build is compiling - this screen is running gen " + runningGeneration + + " (one reload behind)" + : "Quick Build is compiling - this screen is one reload behind"; + default: + return ""; + } + } + + /** The states the banner can be in; each one fixes its color and its copy. */ + enum Kind { + /** Nothing to say, so the banner is removed. */ + HIDDEN, + /** CoGo reported a compile error; the app keeps running the last-good code. */ + BUILD_FAILED, + /** A delivered payload crashed in render/lifecycle; rolled back to last-good. */ + CRASHED, + /** A build is compiling; the app keeps running its last-deployed generation. */ + BUILDING, + /** An update's reinstall awaits a confirm only CoGo can show; the user must switch back. */ + REINSTALL_PENDING + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java new file mode 100644 index 0000000000..f5b4c76d1a --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java @@ -0,0 +1,708 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Keeps the newest payload generation on disk so a fresh process boots it rather than the baked gen-0 baseline: providers and a custom Application instantiate before the binder connects and are never re-instantiated, so otherwise they stay pinned to baseline code after any process death. + * + * A deploy writes only the payload kinds it carries, under generation-stamped names nothing references yet; one atomic rename of {@code meta.json} then publishes the set, so a torn write leaves unreferenced files rather than a generation mixing dex and resources from different builds. A generation that failed to apply is recorded in {@code quarantine.json} and refused by {@link #load}, so a bad payload cannot crash-loop the app where nothing can report it. + */ +final class PayloadPersistence { + + /** Layout tag {@code meta.json} must carry; any other value is a store this build cannot read. */ + static final String LAYOUT = "2"; + + /** Names the generation and its payload files; its atomic rename is the publish. */ + static final String META_FILE = "meta.json"; + + /** Names a generation that failed to apply, which {@link #load} then refuses. */ + static final String QUARANTINE_FILE = "quarantine.json"; + + /** + * A copy of {@link #META_FILE} for the newest generation that got an activity on screen, which {@link #load} falls back to when the published one is quarantined. + * + * Without it a quarantine drops the app all the way to the installed baseline, discarding every save since - and CoGo, seeing the app reconnect far behind the session, re-sends its retained payload onto that baseline, which fails the same way and gets quarantined too. Measured on an A56: one bad generation cost a crash, a silent revert to install-time code, a second crash, and the system's "app keeps stopping" dialog, with the good generations swept up along with the bad one. + */ + static final String GOOD_FILE = "good.json"; + + /** Payload kind: the dex carrying all user classes; absent when no code deploy landed. */ + static final String KIND_DEX = "dex"; + + /** Payload kind: the relinked resource apk, despite the name; absent when no resources changed. */ + static final String KIND_ARSC = "arsc"; + + /** Payload kind: the changed-assets zip; absent when no assets changed. */ + static final String KIND_ASSETS = "assets"; + + /** Suffix of every generation-stamped payload file. */ + private static final String PAYLOAD_SUFFIX = ".bin"; + + /** Suffix of an in-flight {@link #writeAtomic} temp file. */ + private static final String TEMP_SUFFIX = ".tmp"; + + /** + * Computes the key that ties a persisted payload to the baseline APK it was deployed onto: hex SHA-256 of the baseline dex bytes. + * + * @param baselineDex + * the whole gen-0 dex as baked into the proxy app APK + * @return the lowercase hex digest, which a reinstall or rebaseline changes and so invalidates the store + * @throws IllegalStateException + * when SHA-256 is unavailable, which no supported runtime does + */ + static String fingerprint(byte[] baselineDex) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(baselineDex); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException error) { + // SHA-256 is mandatory on every Android/JVM release; treat absence as fatal + // for persistence only (callers degrade to gen-0 boots). + throw new IllegalStateException("SHA-256 unavailable", error); + } + } + + /** + * The on-disk name for one kind of one generation's payload. + * + * @param kind + * one of {@link #KIND_DEX}, {@link #KIND_ARSC}, {@link #KIND_ASSETS} + * @param generation + * the generation that produced these bytes, which makes the name unique so a write can never touch a file an older generation still needs + * @return the file name, relative to the store directory + */ + static String payloadFileName(String kind, long generation) { + return kind + "-" + generation + PAYLOAD_SUFFIX; + } + + /** + * The generation stamped into a payload file name. + * + * @param name + * a store directory entry name + * @return the generation, or -1 when {@code name} is not a generation-stamped payload file + */ + private static long generationOf(String name) { + if (!name.endsWith(PAYLOAD_SUFFIX)) { + return -1; + } + int dash = name.indexOf('-'); + if (dash <= 0) { + return -1; + } + try { + return Long.parseLong(name.substring(dash + 1, name.length() - PAYLOAD_SUFFIX.length())); + } catch (NumberFormatException notAPayloadFile) { + return -1; + } + } + + /** + * Reads a whole store file. + * + * @param file + * an existing store file; opened and closed here, never created + * @return the whole file in memory, since every store file is payload-sized by construction + * @throws IOException + * when the file is unreadable or exceeds the payload cap + */ + private static byte[] readBytes(File file) throws IOException { + InputStream in = new FileInputStream(file); + try { + return Streams.readFully(in); + } finally { + Streams.closeQuietly(in); + } + } + + /** + * Reads a whole store file as UTF-8 text. + * + * @param file + * the file to read, in practice {@link #META_FILE} or {@link #QUARANTINE_FILE} + * @return its contents decoded as UTF-8 + * @throws IOException + * when the file is unreadable + */ + private static String readText(File file) throws IOException { + return new String(readBytes(file), StandardCharsets.UTF_8); + } + + /** + * Writes temp-then-rename with an fsync, so a reader never sees a half-written file. + * + * @param target + * the final path; its parent must already exist + * @param bytes + * the whole contents to write + * @throws IOException + * when the write, the sync, or both rename attempts fail + */ + private static void writeAtomic(File target, byte[] bytes) throws IOException { + File temp = new File(target.getParentFile(), target.getName() + TEMP_SUFFIX); + FileOutputStream out = new FileOutputStream(temp); + try { + out.write(bytes); + out.getFD().sync(); + } finally { + Streams.closeQuietly(out); + } + if (!temp.renameTo(target)) { + // rename over an existing file is atomic on POSIX; a failure here is a + // filesystem oddity - fall back to delete+rename before giving up. + if (!target.delete() || !temp.renameTo(target)) { + throw new IOException("cannot rename " + temp + " to " + target); + } + } + } + + private final File dir; + + /** + * @param dir + * the store directory, app-private and created lazily by {@link #persist}; it need not exist yet + */ + PayloadPersistence(File dir) { + this.dir = dir; + } + + /** + * Deletes the whole store; best-effort, used when the store is untrusted. + * + * Recursive, because an untrusted store may hold directories a filesystem oddity left where a payload file belonged; a non-recursive delete would leave those behind and the next load would keep tripping over them. + */ + synchronized void clear() { + deleteRecursively(dir); + } + + /** + * The store directory. + * + * @return the directory this store reads and writes, which may not exist yet + */ + File dir() { + return dir; + } + + /** + * Loads the persisted payload, or discards the store when it cannot be trusted. + * + * A fingerprint mismatch means a rebaseline or reinstall, so the payload must not outlive the baseline it was compiled against. Distrust - an unreadable layout, a mismatch, a missing file the meta names - deletes the store, and the caller then boots the gen-0 baseline the installed APK already carries. + * + * A quarantined generation is the one distrust that does NOT discard the store: it falls back to {@link #GOOD_FILE}, the newest generation that got an activity on screen, and republishes that as the current set. Every other distrust means the store itself cannot be read, and {@link #GOOD_FILE} lives in the same store. + * + * @param expectedFingerprint + * the running baseline's fingerprint from {@link #fingerprint(byte[])}; anything else discards the store + * @return the loaded payload, or null when the store is absent, mismatched, corrupt, or quarantined with nothing good behind it - never throws, since an unreadable store is a discard rather than an error the caller handles + */ + synchronized Loaded load(String expectedFingerprint) { + File meta = new File(dir, META_FILE); + if (!meta.isFile()) { + return null; + } + try { + Map obj = MiniJson.parseObject(readText(meta)); + if (!LAYOUT.equals(obj.get("layout"))) { + // An older runtime's flat store, or a layout from a newer one. Treating + // it as absent is safe; adopting a layout we cannot read is not. + RuntimeLog.i("persisted payload uses an unreadable layout; discarding"); + clear(); + return null; + } + Object fp = obj.get("fingerprint"); + Object gen = obj.get("generation"); + if (!(fp instanceof String) || !(gen instanceof String)) { + throw new IOException("meta.json missing fingerprint/generation"); + } + if (!fp.equals(expectedFingerprint)) { + RuntimeLog.i("persisted payload is for another baseline; discarding"); + clear(); + return null; + } + long generation = Long.parseLong((String) gen); + if (generation == quarantinedGeneration()) { + // This generation already failed to apply once. Adopting it again repeats + // that failure during startup, where no reload is pending and so nothing + // reports it to CoGo - a silent crash loop. + RuntimeLog.w("persisted generation " + generation + + " is quarantined; falling back to the last generation that ran"); + return loadLastGood(expectedFingerprint); + } + File dexFile = namedFile(obj, KIND_DEX); + return new Loaded(generation, + dexFile == null ? null : readBytes(dexFile), + namedFile(obj, KIND_ARSC), + namedFile(obj, KIND_ASSETS)); + } catch (Throwable error) { + RuntimeLog.e("unreadable persisted payload; discarding", error); + clear(); + return null; + } + } + + /** + * Records that {@code generation} is one a fresh process may boot when a newer one is quarantined. + * + * Call when the generation has demonstrably run - an activity of its was resumed - which is exactly the bar a fallback has to clear, since the failure this guards against is a payload that throws on the way to the screen. Never throws: the caller is a lifecycle callback. + * + * @param generation + * the generation now on screen; ignored unless the store currently publishes it, since a caller confirming a superseded generation has nothing here to record + * @return true when {@link #GOOD_FILE} names {@code generation} after this call, which is also the moment {@link #quarantine} starts refusing to name it; false when the store no longer publishes it or the write failed, and the caller must go on treating it as unproven + */ + synchronized boolean markGood(long generation) { + try { + File meta = new File(dir, META_FILE); + if (!meta.isFile() || generationIn(meta) != generation) { + return false; + } + if (generationIn(new File(dir, GOOD_FILE)) == generation) { + return true; + } + writeAtomic(new File(dir, GOOD_FILE), readBytes(meta)); + RuntimeLog.i("generation " + generation + " reached the screen; keeping it as the fallback"); + return true; + } catch (Throwable error) { + // Costs the fallback one generation of freshness, nothing else. + RuntimeLog.w("could not record generation " + generation + " as good", error); + return false; + } + } + + /** + * Writes {@code generation} as the newest payload, published as one atomic set. + * + * A null byte array keeps the previously persisted file of that kind, since deploys are per-kind deltas and the store is cumulative. The carried-forward file is referenced by name rather than copied, so a full disk cannot turn a delta deploy into a mixed store. + * + * @param generation + * the generation this store will claim once meta.json lands + * @param fingerprint + * the current baseline's fingerprint, which gates a later load + * @param dex + * the dex bytes, or null to keep the persisted ones + * @param arsc + * the relinked resource apk bytes, or null to keep the persisted ones + * @param assetsZip + * the changed-assets zip bytes, or null to keep the persisted ones + * @return the store's payload files after the write, for callers that apply resources from the persisted copies; each field is null when that kind was never persisted + * @throws IOException + * when the directory cannot be created or any write fails; meta.json lands last, so a failure leaves the store on the previous generation, whole + */ + synchronized Persisted persist(long generation, String fingerprint, byte[] dex, byte[] arsc, + byte[] assetsZip) throws IOException { + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("cannot create " + dir); + } + Map previous = readInheritableMeta(generation, fingerprint); + String dexName = writeOrInherit(KIND_DEX, generation, dex, previous); + String arscName = writeOrInherit(KIND_ARSC, generation, arsc, previous); + String assetsName = writeOrInherit(KIND_ASSETS, generation, assetsZip, previous); + StringBuilder meta = new StringBuilder("{\"layout\":\"").append(LAYOUT) + .append("\",\"generation\":\"").append(generation) + .append("\",\"fingerprint\":\"").append(fingerprint).append('"'); + appendName(meta, KIND_DEX, dexName); + appendName(meta, KIND_ARSC, arscName); + appendName(meta, KIND_ASSETS, assetsName); + meta.append('}'); + // The one publishing act: until this rename lands, nothing above is reachable. + writeAtomic(new File(dir, META_FILE), meta.toString().getBytes(StandardCharsets.UTF_8)); + // A complete published set supersedes any quarantine claim, including one naming + // this same number from an earlier install's generation sequence. + deleteQuietly(new File(dir, QUARANTINE_FILE)); + if (generationIn(new File(dir, GOOD_FILE)) >= generation) { + // The host's generation counter restarted (its project state was wiped while the + // app stayed installed), so the last-good set belongs to a sequence that no + // longer exists and falling back to it would boot a LATER-numbered older build. + deleteQuietly(new File(dir, GOOD_FILE)); + } + collectOrphans(dexName, arscName, assetsName); + return new Persisted(fileOrNull(arscName), fileOrNull(assetsName)); + } + + /** + * Records that {@code generation} failed to apply, so {@link #load} never adopts it. + * + * A marker rather than a rollback of the store, because it also survives a crash part-way through the rollback itself, and because the failing generation's files are what a later successful deploy carries forward from. Never throws: the callers are the reload failure path and the uncaught-exception guard, neither of which can handle one. + * + * @param generation + * the generation whose apply or render failed; a marker for a generation the store does not claim is inert and gets cleared by the next successful persist + */ + synchronized void quarantine(long generation) { + if (generation == generationIn(new File(dir, GOOD_FILE))) { + // This generation already got an activity on screen, so a fresh process booting + // it does not repeat whatever just failed - the startup crash loop the marker + // exists to break cannot happen here. Writing one anyway is what swept the + // user's last working saves away along with the broken generation: the app then + // dropped to install-time code, CoGo re-sent its retained payload onto it, and + // that failed too. + RuntimeLog.w("not quarantining generation " + generation + + "; it already ran, so it is the fallback rather than the fault"); + return; + } + try { + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("cannot create " + dir); + } + writeAtomic(new File(dir, QUARANTINE_FILE), + ("{\"generation\":\"" + generation + "\"}").getBytes(StandardCharsets.UTF_8)); + RuntimeLog.w("quarantined generation " + generation + "; a fresh process will boot the baseline"); + } catch (Throwable error) { + RuntimeLog.e("cannot quarantine generation " + generation, error); + } + } + + /** + * Appends one {@code "kind":"file"} member to a meta document under construction. + * + * @param meta + * the document so far, always already carrying at least one member + * @param kind + * the payload kind this name belongs to + * @param name + * the file name, or null to omit the member entirely + */ + private void appendName(StringBuilder meta, String kind, String name) { + if (name != null) { + meta.append(",\"").append(kind).append("\":\"").append(name).append('"'); + } + } + + /** + * Deletes payload files and temp leftovers no live meta references. + * + * "Live" is the just-published generation plus the last-good set, whose files a quarantine boots from and which the published meta therefore does not name. Everything else goes whatever generation stamps it: {@link #persist} holds the monitor from its first write to here, so no other deploy has a write in flight, and a stamp newer than the published generation can only be a torn write or a leftover from a generation sequence that restarted. Runs after the publish, so a failure here leaks a file rather than removing a live one. + * + * @param names + * the file names the published generation references; nulls are ignored + */ + private void collectOrphans(String... names) { + File[] entries = dir.listFiles(); + if (entries == null) { + return; + } + Set referenced = payloadNamesIn(new File(dir, GOOD_FILE)); + for (String name : names) { + if (name != null) { + referenced.add(name); + } + } + for (File entry : entries) { + String name = entry.getName(); + if (name.endsWith(TEMP_SUFFIX)) { + deleteQuietly(entry); + continue; + } + if (generationOf(name) >= 0 && !referenced.contains(name)) { + deleteQuietly(entry); + } + } + } + + /** + * Deletes one entry, logging rather than failing when it cannot be removed. + * + * @param file + * the entry to delete; a missing one is not a failure + */ + private void deleteQuietly(File file) { + if (file.exists() && !file.delete()) { + RuntimeLog.w("could not delete " + file); + } + } + + /** + * Removes {@code file} and, when it is a directory, everything under it; best-effort. + * + * @param file + * the entry to remove; a missing one is not a failure + */ + private void deleteRecursively(File file) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + deleteQuietly(file); + } + + /** + * @param name + * a store file name, or null + * @return the file in the store dir, or null when {@code name} was null + */ + private File fileOrNull(String name) { + return name == null ? null : new File(dir, name); + } + + /** + * The generation a meta-shaped document names. + * + * @param file + * {@link #META_FILE}, {@link #GOOD_FILE} or {@link #QUARANTINE_FILE} + * @return the generation, or -1 when the file is absent or unreadable; treating an unreadable side file as absent only costs the guard it feeds, where failing closed would strand the app on the baseline forever + */ + private long generationIn(File file) { + if (!file.isFile()) { + return -1; + } + try { + Object gen = MiniJson.parseObject(readText(file)).get("generation"); + return gen instanceof String ? Long.parseLong((String) gen) : -1; + } catch (Throwable error) { + RuntimeLog.w("unreadable " + file.getName() + "; ignoring it", error); + return -1; + } + } + + /** + * Boots the last generation that reached the screen, and republishes it as the current set. + * + * Republishing matters as much as loading: the store has to agree with what this process is running, or the next deploy inherits payload files from the quarantined generation and every later boot walks the same fallback again. + * + * @param expectedFingerprint + * the running baseline's fingerprint; a last-good set keyed to another baseline is as unusable as a published one + * @return the payload to boot, or null after discarding the store when there is no usable last-good set - which returns the caller to the installed baseline, the behaviour a quarantine had before + */ + private Loaded loadLastGood(String expectedFingerprint) { + File good = new File(dir, GOOD_FILE); + if (!good.isFile()) { + clear(); + return null; + } + try { + Map obj = MiniJson.parseObject(readText(good)); + Object fp = obj.get("fingerprint"); + Object gen = obj.get("generation"); + if (!LAYOUT.equals(obj.get("layout")) || !(fp instanceof String) || !(gen instanceof String) + || !fp.equals(expectedFingerprint)) { + clear(); + return null; + } + long generation = Long.parseLong((String) gen); + if (generation == quarantinedGeneration()) { + // Belt and braces: quarantine() refuses to name the good generation, so this + // can only be a hand-edited or torn store. + clear(); + return null; + } + File dexFile = namedFile(obj, KIND_DEX); + Loaded loaded = new Loaded(generation, + dexFile == null ? null : readBytes(dexFile), + namedFile(obj, KIND_ARSC), + namedFile(obj, KIND_ASSETS)); + // Only once every file it names has resolved, so a torn last-good set cannot + // replace a readable meta with an unservable one. + writeAtomic(new File(dir, META_FILE), readBytes(good)); + RuntimeLog.i("booting generation " + generation + ", the last one that ran"); + return loaded; + } catch (Throwable error) { + RuntimeLog.e("unusable last-good payload; discarding the store", error); + clear(); + return null; + } + } + + /** + * Resolves the file a meta document names for one kind, asserting it is really there. + * + * A named file that is missing means a torn or hand-edited store, so it is corruption rather than a plain absence: the meta claims a generation it cannot serve, and serving a subset would be the mixed store this layout exists to prevent. + * + * @param meta + * the parsed meta document + * @param kind + * the payload kind to resolve + * @return the file, or null when the meta names none for this kind + * @throws IOException + * when the meta names a file that does not exist + */ + private File namedFile(Map meta, String kind) throws IOException { + Object name = meta.get(kind); + if (name == null) { + return null; + } + if (!(name instanceof String)) { + throw new IOException("meta.json has a non-string " + kind + " name"); + } + File file = new File(dir, (String) name); + if (!file.isFile()) { + throw new IOException("meta.json names a missing " + kind + " file: " + name); + } + return file; + } + + /** + * The payload file names a meta-shaped document references. + * + * @param metaFile + * {@link #META_FILE} or {@link #GOOD_FILE}; a missing or unreadable one yields an empty set, which only costs the caller the files it names + * @return the referenced names, never null + */ + private Set payloadNamesIn(File metaFile) { + Set names = new HashSet(); + if (!metaFile.isFile()) { + return names; + } + try { + Map obj = MiniJson.parseObject(readText(metaFile)); + String[] kinds = {KIND_DEX, KIND_ARSC, KIND_ASSETS}; + for (String kind : kinds) { + Object name = obj.get(kind); + if (name instanceof String) { + names.add((String) name); + } + } + } catch (Throwable error) { + RuntimeLog.w("unreadable " + metaFile.getName() + "; the files it names may be collected", error); + } + return names; + } + + /** + * The generation the quarantine marker names. + * + * @return the quarantined generation, or -1 when there is no readable marker; an unreadable marker is treated as absent, which only costs the crash-loop guard for one generation + */ + private long quarantinedGeneration() { + return generationIn(new File(dir, QUARANTINE_FILE)); + } + + /** + * The published meta a new generation may carry files forward from. + * + * Only a strictly older generation is inheritable. A store already claiming this number or a newer one means the host's generation counter restarted (its project state was wiped while the app stayed installed), and carrying files forward from it would pair this dex with resources from a LATER build - the one mismatch direction the cumulative delta scheme does not make safe. + * + * @param generation + * the incoming generation + * @param fingerprint + * the baseline the incoming payload was built against; a store keyed to another baseline has nothing inheritable in it + * @return the parsed meta, or null when the store is absent, unreadable, on another layout, keyed to another baseline, or not strictly older + */ + private Map readInheritableMeta(long generation, String fingerprint) { + File meta = new File(dir, META_FILE); + if (!meta.isFile()) { + return null; + } + try { + Map obj = MiniJson.parseObject(readText(meta)); + Object stored = obj.get("fingerprint"); + if (!LAYOUT.equals(obj.get("layout")) || stored == null || !stored.equals(fingerprint)) { + return null; + } + Object gen = obj.get("generation"); + if (!(gen instanceof String) + || !Generations.accepts(Long.parseLong((String) gen), generation)) { + RuntimeLog.w("persisted generation " + gen + " is not older than " + generation + + "; persisting a fresh set"); + return null; + } + return obj; + } catch (Throwable error) { + RuntimeLog.w("previous meta.json unreadable; persisting a fresh set", error); + return null; + } + } + + /** + * Writes one kind's bytes under a generation-stamped name, or carries the previous name forward. + * + * @param kind + * the payload kind being written + * @param generation + * the incoming generation, which stamps the new file's name + * @param bytes + * the bytes to write, or null when this deploy carried nothing of this kind + * @param previous + * the inheritable published meta, or null when there is none + * @return the file name the new meta should reference, or null when this kind has never been persisted + * @throws IOException + * when the write fails + */ + private String writeOrInherit(String kind, long generation, byte[] bytes, + Map previous) throws IOException { + if (bytes != null) { + String name = payloadFileName(kind, generation); + writeAtomic(new File(dir, name), bytes); + return name; + } + if (previous == null) { + return null; + } + Object inherited = previous.get(kind); + // Only carry forward a name that still resolves; a meta naming a missing file + // would be published as corruption. + if (inherited instanceof String && new File(dir, (String) inherited).isFile()) { + return (String) inherited; + } + return null; + } + + /** + * A successfully loaded persisted payload; a null {@code dex} means no code deploy was persisted. + */ + static final class Loaded { + + /** The generation meta.json claimed, always strictly greater than 0 to be worth booting. */ + final long generation; + + /** Payload dex bytes, or null for a resources or assets-only generation. */ + final byte[] dex; + + /** The persisted resource apk, or null when none was ever persisted. */ + final File arscFile; + + /** The persisted assets zip, or null when none was ever persisted. */ + final File assetsFile; + + /** + * @param generation + * the generation meta.json claimed; must be greater than 0, since gen 0 is the APK baseline and never worth booting from the store + * @param dex + * payload dex bytes, or null to keep the baseline classes + * @param arscFile + * the persisted resource apk, or null + * @param assetsFile + * the persisted assets zip, or null + */ + Loaded(long generation, byte[] dex, File arscFile, File assetsFile) { + this.generation = generation; + this.dex = dex; + this.arscFile = arscFile; + this.assetsFile = assetsFile; + } + } + + /** The payload files currently in the store (post-persist view). */ + static final class Persisted { + + /** The store's resource apk after the write, or null when none was ever persisted. */ + final File arscFile; + + /** The store's assets zip after the write, or null when none was ever persisted. */ + final File assetsFile; + + /** + * @param arscFile + * the store's resource apk, or null + * @param assetsFile + * the store's assets zip, or null + */ + Persisted(File arscFile, File assetsFile) { + this.arscFile = arscFile; + this.assetsFile = assetsFile; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java new file mode 100644 index 0000000000..cbceff8780 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java @@ -0,0 +1,328 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.content.Context; +import android.os.Build; +import dalvik.system.InMemoryDexClassLoader; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * Owns the current payload generation and its classloader, process-wide. + * + * A singleton, since there is exactly one live generation per process. Generation and loader travel together in an immutable {@link Payload} swapped atomically, so a reader can never see generation N with generation N-1's classes. + * + * The dex loads through {@link InMemoryDexClassLoader} with the APK classloader as parent: framework and androidx classes resolve from the APK while user classes exist only in the payload, so parent-first delegation cannot serve a stale user class. + * + * At boot {@link #ensureBaseline} loads the baked baseline dex at its stamped generation ({@link BaselineGeneration}), then swaps in a newer persisted generation ({@link PayloadPersistence}); otherwise a relaunched process would pin its providers and custom Application to baseline code. + */ +final class PayloadStore { + + /** The process-wide store; the factory and the deploy path must see the same loader. */ + static final PayloadStore INSTANCE = new PayloadStore(); + + /** Where the proxy app build bakes the baseline payload into the proxy app APK. */ + static final String BASELINE_ASSET = "assets/quickbuild/gen-0.dex"; + + /** Sibling of {@link BASELINE_ASSET}: the baked baseline's stamped generation. */ + static final String BASELINE_GENERATION_ASSET = "assets/quickbuild/baseline-generation.txt"; + + /** Store dir for the persisted newest payload, relative to the app's filesDir. */ + static final String PERSIST_DIR = "quickbuild/payload"; + + /** Generation of an UNSTAMPED baked baseline; a stamped one boots at its stamp instead. */ + static final long BASELINE_GENERATION = BaselineGeneration.UNSTAMPED; + + /** + * Derives the persist dir without a Context, because none exists when the factory first runs. + * + * Takes the package name from /proc/self/cmdline - the default process name is the applicationId, and the manifest transformer rejects android:process - and the user id from the uid. + * + * @return the store directory, or null when the derivation fails; {@link #attachPersistence} heals that later + */ + private static File defaultPersistDir() { + InputStream in = null; + try { + in = new FileInputStream("/proc/self/cmdline"); + String cmdline = new String(Streams.readFully(in), "UTF-8"); + int nul = cmdline.indexOf('\0'); + String pkg = (nul >= 0 ? cmdline.substring(0, nul) : cmdline).trim(); + if (pkg.isEmpty()) { + return null; + } + int userId = android.os.Process.myUid() / 100000; + File dataDir = new File("/data/user/" + userId + "/" + pkg); + if (!dataDir.isDirectory()) { + return null; + } + return new File(dataDir, "files/" + PERSIST_DIR); + } catch (Throwable error) { + RuntimeLog.w("cmdline data-dir derivation failed: " + error); + return null; + } finally { + Streams.closeQuietly(in); + } + } + + /** The live generation and its loader; volatile so binder and main threads see swaps at once. */ + private volatile Payload current; + + /** The base APK's loader, the parent of every payload loader. */ + private ClassLoader apkClassLoader; + + /** Latches {@link #ensureBaseline} so the baseline loads once, even after a failure. */ + private boolean baselineAttempted; + + /** The persisted-payload store, resolved at boot or late-bound from a Context. */ + private volatile PayloadPersistence persistence; + + /** Fingerprint of the loaded baseline dex, the key a persisted payload must match. */ + private volatile String baselineFingerprint; + + /** Persisted resource payloads found at boot, pending application once a Context exists. */ + private volatile PayloadPersistence.Loaded pendingBootResources; + + /** The persisted generation this process adopted at boot, or -1 when it booted the baked baseline. */ + private volatile long bootedPersistedGeneration = -1; + + private PayloadStore() {} + + /** + * Swaps in a new payload atomically, if it is strictly newer than the running one. + * + * A null {@code dex}, meaning a resources or assets-only deploy, keeps the current classes and only advances the generation. + * + * @param generation + * the incoming generation; only a strictly newer one is accepted + * @param dex + * the payload dex, or null for a resources or assets-only deploy + * @return true when the payload was accepted and is now current; false for a stale generation or when no baseline was ever loaded, in which case nothing changed + */ + synchronized boolean apply(long generation, ByteBuffer dex) { + Payload previous = current; + if (previous == null) { + RuntimeLog.w("rejecting payload gen " + generation + ": no baseline loaded"); + return false; + } + if (!Generations.accepts(previous.generation, generation)) { + RuntimeLog.w("rejecting stale payload gen " + generation + + " (running gen " + previous.generation + ")"); + return false; + } + ClassLoader loader = dex == null + ? previous.classLoader + : new InMemoryDexClassLoader(dex, apkClassLoader); + current = new Payload(generation, loader); + return true; + } + + /** + * Late-binds the persistence dir from a real Context, at the first activity. + * + * Heals a boot whose pre-Context dir derivation failed; a no-op when boot already resolved it. + * + * @param context + * any context with a real filesDir, normally the first activity's; also a no-op before a baseline exists, since there would be no fingerprint to gate a load + */ + synchronized void attachPersistence(Context context) { + if (persistence != null || baselineFingerprint == null) { + return; + } + try { + persistence = new PayloadPersistence(new File(context.getFilesDir(), PERSIST_DIR)); + } catch (Throwable error) { + RuntimeLog.e("cannot attach payload persistence", error); + } + } + + /** + * The baseline's fingerprint, or null while no baseline is loaded. + * + * @return the key a persisted payload must match to be adopted + */ + String baselineFingerprint() { + return baselineFingerprint; + } + + /** + * The generation this process took from the store rather than from the APK. + * + * A restart deploy leaves no reload pending in the process that boots its work, so this is the only handle the crash guard has on what a startup crash is about. The baked baseline is excluded deliberately: it is the code the installed APK carries, so refusing it would leave the app nothing at all to boot. + * + * @return the adopted persisted generation, or -1 when the process booted the baked baseline + */ + long bootedPersistedGeneration() { + return bootedPersistedGeneration; + } + + /** + * The current payload classloader, or null when no payload is live (runtime inert). + * + * @return the loader every component should be instantiated through, or null to fall back to the framework default + */ + ClassLoader classLoader() { + Payload payload = current; + return payload == null ? null : payload.classLoader; + } + + /** + * Loads the baked baseline from the APK once, at its stamped generation, then swaps in a newer persisted generation if one matches it. + * + * Reads the asset through the classloader, not a Context, since the factory runs before any Context exists. A missing baseline leaves the store inert, so lookups fall back to the default classloader instead of crashing an app the AAR was wrongly injected into. + * + * @param apkLoader + * the base APK's classloader, retained as the parent of every payload loader; null is ignored, and only the first non-null call has any effect + */ + synchronized void ensureBaseline(ClassLoader apkLoader) { + if (baselineAttempted || apkLoader == null) { + return; + } + baselineAttempted = true; + this.apkClassLoader = apkLoader; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + // InMemoryDexClassLoader is API 26+. Quick Build is gated far above this, + // but the AAR must stay inert, not crash, wherever it lands. + RuntimeLog.w("quick build runtime inert below API 26"); + return; + } + InputStream in = null; + try { + in = apkLoader.getResourceAsStream(BASELINE_ASSET); + if (in == null) { + RuntimeLog.w("no baseline payload at " + BASELINE_ASSET + "; runtime inert"); + return; + } + byte[] dex = Streams.readFully(in); + // The sibling stamp asset carries the generation the host allocated for this + // baseline; without it (older plugin) the baseline is generation 0 as before. + long baselineGeneration = BaselineGeneration.read(apkLoader.getResourceAsStream(BASELINE_GENERATION_ASSET)); + current = new Payload(baselineGeneration, + new InMemoryDexClassLoader(ByteBuffer.wrap(dex), apkLoader)); + RuntimeLog.i("baseline payload loaded (" + dex.length + " bytes, gen " + + baselineGeneration + ")"); + baselineFingerprint = PayloadPersistence.fingerprint(dex); + loadPersisted(apkLoader, baselineGeneration); + } catch (Throwable error) { + RuntimeLog.e("failed to load baseline payload; runtime inert", error); + current = null; + } finally { + Streams.closeQuietly(in); + } + } + + /** + * The generation the app currently runs: the baked baseline's stamped generation until a newer payload lands, or 0 while the store is inert. + * + * @return the running generation, which the client reports to CoGo on every connect + */ + long generation() { + Payload payload = current; + return payload == null ? BASELINE_GENERATION : payload.generation; + } + + /** + * The persisted-payload store, or null when unavailable (deploys must then fail loudly on restart). + * + * @return the store to persist through, or null when neither boot nor {@link #attachPersistence} could resolve a directory + */ + PayloadPersistence persistence() { + return persistence; + } + + /** + * Rolls back to a {@link #snapshot} after a failed reload. + * + * The app then visibly runs the old generation, and the host hears about it via reportCrash, rather than claiming a generation whose classes never rendered. + * + * @param payload + * the value {@link #snapshot} returned before the failed apply; restored verbatim, null included + */ + synchronized void restore(Payload payload) { + current = payload; + } + + /** + * Snapshot for rollback: pair with {@link #restore} when a reload fails. + * + * @return the live payload, or null when none is; safe to hold because it is immutable + */ + synchronized Payload snapshot() { + return current; + } + + /** + * Persisted resource payloads found at boot; null after the first call (one consumer). + * + * @return the boot-time payload whose resources still need applying, or null when there was none or it has already been taken + */ + synchronized PayloadPersistence.Loaded takePendingBootResources() { + PayloadPersistence.Loaded pending = pendingBootResources; + pendingBootResources = null; + return pending; + } + + /** + * Adopts a matching persisted payload's generation and classes now, before any provider or Application instantiates. + * + * Resource payloads cannot apply without a Context, so they are stashed for {@link #takePendingBootResources}. Any failure keeps the baked baseline, which is always safe. + * + * @param apkLoader + * the base APK's classloader, the parent of the loader built from the persisted dex; must be the same one the baseline was read through + * @param baselineGeneration + * the baked baseline's stamped generation; only a strictly newer persisted payload is adopted + */ + private void loadPersisted(ClassLoader apkLoader, long baselineGeneration) { + try { + File dir = defaultPersistDir(); + if (dir == null) { + RuntimeLog.w("cannot derive data dir pre-Context; booting the baked baseline"); + return; + } + PayloadPersistence store = new PayloadPersistence(dir); + persistence = store; + // The stamped-generation gate lives in PersistedSelection so it stays JVM-tested. + PayloadPersistence.Loaded loaded = PersistedSelection.selectPersisted(baselineGeneration, + store, baselineFingerprint); + if (loaded == null) { + return; + } + ClassLoader loader = loaded.dex == null + // Resource-only generations persisted with no code deploy: the + // baseline classes ARE current, only the generation label advances. + ? current.classLoader + : new InMemoryDexClassLoader(ByteBuffer.wrap(loaded.dex), apkLoader); + current = new Payload(loaded.generation, loader); + pendingBootResources = loaded; + // The crash guard's only handle on a startup crash: this generation arrived from a + // restart deploy, so nothing in this process is pending to pin the blame on. + bootedPersistedGeneration = loaded.generation; + RuntimeLog.i("booting persisted generation " + loaded.generation); + } catch (Throwable error) { + RuntimeLog.e("persisted payload unusable; booting the baked baseline", error); + } + } + + /** Immutable generation snapshot; swapped as one unit. */ + static final class Payload { + + /** The generation these classes came from. */ + final long generation; + + /** + * The loader serving that generation's classes; shared with the previous payload when the deploy carried no dex. + */ + final ClassLoader classLoader; + + /** + * @param generation + * the generation these classes came from; the APK baseline boots at its stamped generation (0 when unstamped), and later deploys must only ever increase it + * @param classLoader + * the loader to instantiate components through; never null in practice, since an inert store holds no Payload at all + */ + Payload(long generation, ClassLoader classLoader) { + this.generation = generation; + this.classLoader = classLoader; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java new file mode 100644 index 0000000000..ec8cbc63a0 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java @@ -0,0 +1,31 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * The boot-time decision of whether a persisted payload supersedes the baked baseline. + * + * Extracted from {@link PayloadStore}'s boot path so it is JVM-testable: the classloader half of that path is dalvik-only, but this gate is not, and it is the S7 fix. A rebaseline can leave the baseline dex byte-identical (manifest/asset-only change), so the fingerprint alone would adopt the previous epoch's persisted payload over the fresh, strictly newer baseline and boot superseded code - only gating on the STAMPED generation, not a constant 0, prevents that. + */ +final class PersistedSelection { + + /** + * Loads the persisted payload and gates it against the stamped baseline generation. + * + * @param stampedBaselineGeneration + * the baked baseline's stamped generation ({@link BaselineGeneration}); only a strictly newer persisted payload may replace it + * @param store + * the persisted-payload store found at boot + * @param baselineFingerprint + * the running baseline's fingerprint, which {@link PayloadPersistence#load} keys the store on + * @return the persisted payload to boot, or null to boot the baked baseline + */ + static PayloadPersistence.Loaded selectPersisted(long stampedBaselineGeneration, + PayloadPersistence store, String baselineFingerprint) { + PayloadPersistence.Loaded loaded = store.load(baselineFingerprint); + if (loaded == null || !Generations.accepts(stampedBaselineGeneration, loaded.generation)) { + return null; + } + return loaded; + } + + private PersistedSelection() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java new file mode 100644 index 0000000000..11e14fd8ee --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java @@ -0,0 +1,254 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Activity; +import android.app.AppComponentFactory; +import android.app.Application; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.ContentProvider; +import android.content.Intent; + +/** + * Instantiates every app component through the current payload generation's classloader, which is what makes hot reload work. + * + * After a payload swap a recreated activity comes from the new loader, and since user classes exist only in the payload dex the parent-first chain cannot serve a stale copy. Receivers are re-instantiated per delivery so routing alone keeps them current; services, providers and the Application swap via a process restart CoGo drives. + * + * Declared as {@code android:appComponentFactory} in the runtime manifest, which the framework instantiates on API 28+. Androidx-free on purpose - the AAR is injected into arbitrary user apps and must not drag a dependency in. Every override falls back to the framework default on failure; when the fallback fails too, the PAYLOAD failure propagates - see {@link #rethrowPayloadFailure}. + */ +public class QuickBuildAppComponentFactory extends AppComponentFactory { + + /** + * Throws the failure that best explains a component we could not instantiate from either loader: the PAYLOAD one. + * + * The fallback exists for framework classes that really do live in the APK, so when it fails too the class was a user class and the default loader was never going to find it - its {@code ClassNotFoundException} is a consequence, not the cause, and reporting it would leave the real failure buried in logcat. + * + * @param payloadError + * what the payload loader threw; rethrown as-is when its type allows, so its stack survives + * @param fallbackError + * what the default loader then threw; attached as suppressed so it is not lost either + * @return never returns - declared so callers can write {@code throw rethrowPayloadFailure(...)} and the compiler sees the path end + * @throws InstantiationException + * when that is what the payload loader threw + * @throws IllegalAccessException + * when that is what the payload loader threw + * @throws ClassNotFoundException + * when that is what the payload loader threw + */ + static RuntimeException rethrowPayloadFailure(Throwable payloadError, Throwable fallbackError) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + if (fallbackError != payloadError) { + payloadError.addSuppressed(fallbackError); + } + if (payloadError instanceof InstantiationException) { + throw (InstantiationException) payloadError; + } + if (payloadError instanceof IllegalAccessException) { + throw (IllegalAccessException) payloadError; + } + if (payloadError instanceof ClassNotFoundException) { + throw (ClassNotFoundException) payloadError; + } + if (payloadError instanceof Error) { + throw (Error) payloadError; + } + if (payloadError instanceof RuntimeException) { + throw (RuntimeException) payloadError; + } + // A checked throwable none of these signatures allow. Wrapping keeps it as the cause, + // which is the whole point of this method. + return new RuntimeException(payloadError); + } + + /** + * Picks the loader for {@code className}; the decision itself lives in {@link LoaderRouter}, where it is unit-tested. + * + * @param defaultLoader + * the loader the framework passed this factory + * @param className + * binary name of the component about to be instantiated + * @return the payload loader when it can serve the class, else {@code defaultLoader} + */ + private static ClassLoader pickLoader(ClassLoader defaultLoader, String className) { + return LoaderRouter.pick(defaultLoader, PayloadStore.INSTANCE.classLoader(), className); + } + + /** + * Instantiates an activity from the payload loader, so a recreate after a deploy runs new code. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the activity, as the manifest declares it + * @param intent + * the launch intent, passed to the framework untouched + * @return the activity instance the framework will attach + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public Activity instantiateActivity(ClassLoader cl, String className, Intent intent) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + PayloadStore.INSTANCE.ensureBaseline(cl); + try { + return super.instantiateActivity(pickLoader(cl, className), className, intent); + } catch (Throwable payloadError) { + RuntimeLog.e("payload activity instantiation failed for " + className + + "; using default loader", payloadError); + try { + return super.instantiateActivity(cl, className, intent); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + } + + /** + * Routes the Application through the payload loader and installs the runtime, the earliest per-process hook. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the app's Application class + * @return the Application instance, with {@link QuickBuildRuntime} already installed on it + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public Application instantiateApplication(ClassLoader cl, String className) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + PayloadStore.INSTANCE.ensureBaseline(cl); + Application application; + try { + application = super.instantiateApplication(pickLoader(cl, className), className); + } catch (Throwable payloadError) { + RuntimeLog.e("payload application instantiation failed; using default loader", payloadError); + try { + application = super.instantiateApplication(cl, className); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + // The runtime defers Context work to the first activity: the Application has + // no base context yet. + QuickBuildRuntime.install(application); + return application; + } + + /** + * Instantiates a content provider from the payload loader. + * + * Providers cannot hot-swap: one already created keeps its class until CoGo restarts the process, so this only keeps a provider created after a deploy on current code. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the provider + * @return the provider instance the framework will attach + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public ContentProvider instantiateProvider(ClassLoader cl, String className) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + // Providers instantiate after instantiateApplication but BEFORE + // Application.onCreate, so the baseline already exists on the normal path; + // this ensureBaseline is defense-in-depth for exotic entry orders. Nothing + // here may touch QuickBuildRuntime or any Context - too early. + PayloadStore.INSTANCE.ensureBaseline(cl); + try { + return super.instantiateProvider(pickLoader(cl, className), className); + } catch (Throwable payloadError) { + RuntimeLog.e("payload provider instantiation failed for " + className + + "; using default loader", payloadError); + try { + return super.instantiateProvider(cl, className); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + } + + /** + * Instantiates a broadcast receiver from the payload loader, which is all a receiver needs to stay on current code. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the receiver + * @param intent + * the broadcast being delivered, passed to the framework untouched + * @return the receiver instance for this one delivery + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public BroadcastReceiver instantiateReceiver(ClassLoader cl, String className, Intent intent) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + // Manifest receivers are created fresh per delivery, so routing through the + // current loader alone keeps them on current code - no restart needed. + PayloadStore.INSTANCE.ensureBaseline(cl); + try { + return super.instantiateReceiver(pickLoader(cl, className), className, intent); + } catch (Throwable payloadError) { + RuntimeLog.e("payload receiver instantiation failed for " + className + + "; using default loader", payloadError); + try { + return super.instantiateReceiver(cl, className, intent); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + } + + /** + * Instantiates a service from the payload loader. + * + * A service already running keeps its class, which is why a deploy touching service code restarts the process instead of hot-swapping. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the service + * @param intent + * the intent that started the service, passed to the framework untouched + * @return the service instance the framework will attach + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public Service instantiateService(ClassLoader cl, String className, Intent intent) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + PayloadStore.INSTANCE.ensureBaseline(cl); + try { + return super.instantiateService(pickLoader(cl, className), className, intent); + } catch (Throwable payloadError) { + RuntimeLog.e("payload service instantiation failed for " + className + + "; using default loader", payloadError); + try { + return super.instantiateService(cl, className, intent); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoaders.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoaders.java new file mode 100644 index 0000000000..9ec030c219 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoaders.java @@ -0,0 +1,37 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Supplies the classloader that generated proxy activities return from their {@code getClassLoader()} override. + * + * Public for that reason (see ProxySourceGenerator in :gradle-plugin); everything else in this AAR is package-private. The override is needed because {@link QuickBuildAppComponentFactory} only chooses the loader that instantiates the Activity object - the framework still pins {@code Context#getClassLoader()} to the base APK's loader at attach time. Anything resolving a class by name through the Context, such as LayoutInflater on a custom view tag or an androidx FragmentFactory on a {@code } tag, would otherwise miss every payload-only class. + */ +public final class QuickBuildClassLoaders { + + /** + * Returns the loader a proxy activity should report: the payload loader whenever one is live. + * + * The payload loader's parent is the APK classloader (see {@link PayloadStore}), so it resolves everything {@code fallback} would plus the payload-only classes, never less. The fallback should be unreachable from a proxy activity, whose own bytecode is payload-only, and exists only so a misinjected app cannot crash. + * + * @param fallback + * the loader to report when no payload is live, normally the activity's {@code super.getClassLoader()}; may be null, in which case null is returned + * @return the loader the caller must report from {@code getClassLoader()} + */ + public static ClassLoader forActivity(ClassLoader fallback) { + return choose(PayloadStore.INSTANCE.classLoader(), fallback); + } + + /** + * Prefers the payload loader over the fallback; extracted so the choice is testable without the PayloadStore singleton. + * + * @param payloadLoader + * the live payload loader, or null when no payload has been applied + * @param fallback + * the loader to fall back to; returned verbatim, null included + * @return {@code payloadLoader} when non-null, otherwise {@code fallback} + */ + static ClassLoader choose(ClassLoader payloadLoader, ClassLoader fallback) { + return payloadLoader != null ? payloadLoader : fallback; + } + + private QuickBuildClassLoaders() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java new file mode 100644 index 0000000000..15afca98ab --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -0,0 +1,308 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import com.itsaky.androidide.quickbuild.IQuickBuildHost; +import com.itsaky.androidide.quickbuild.IQuickBuildTarget; + +/** + * The proxy app's end of the deploy channel to CoGo. + * + * Binds to CoGo's Quick Build service with an explicit action and package plus BIND_AUTO_CREATE and BIND_IMPORTANT, registers the {@link IQuickBuildTarget} callback, and carries reload and crash reports back. Every remote call is guarded, so losing CoGo degrades the proxy app rather than crashing it. + * + * BIND_AUTO_CREATE keeps the binding alive across a CoGo service restart: the framework reconnects and {@link #onServiceConnected} re-runs connect with the running generation, which is how a relaunched proxy app catches up. Manual rebinds with backoff cover what the framework does not retry - a failed bind call, a dead or null binding. + */ +final class QuickBuildClient implements ServiceConnection { + + /** Intent action of CoGo's deploy service. */ + static final String SERVICE_ACTION = "com.itsaky.androidide.QUICK_BUILD_ACTION"; + + /** CoGo's package name (same constant the LogSender uses). */ + static final String IDE_PACKAGE = "com.itsaky.androidide"; + + /** First rebind delay, doubled per failed attempt. */ + private static final int REBIND_MIN_DELAY_MS = 1000; + + /** Ceiling for the doubling, so a CoGo that never comes back costs one attempt per 30s. */ + private static final int REBIND_MAX_DELAY_MS = 30000; + + private final QuickBuildRuntime runtime; + + /** Rebinds are posted here, so bindService is always called from the main thread. */ + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + /** Application context, volatile because binder threads read it. */ + private volatile Context appContext; + + /** The live host proxy, or null while disconnected; volatile for the same reason. */ + private volatile IQuickBuildHost host; + + /** True once {@link #bind} has run, which is what makes that call idempotent. */ + private boolean bindRequested; + + /** True while a rebind is queued, so failures cannot pile up attempts. */ + private boolean rebindScheduled; + + /** Delay for the next rebind; reset to the minimum on every successful connect. */ + private int rebindDelayMs = REBIND_MIN_DELAY_MS; + + /** The callback CoGo drives; every method hands straight to the runtime's guarded handlers. */ + private final IQuickBuildTarget.Stub target = new IQuickBuildTarget.Stub() { + + /** + * @param statusJson + * the build status document, forwarded verbatim for parsing + */ + @Override + public void onBuildStatus(String statusJson) { + // Oneway call, arrives on a binder thread. handleBuildStatus guards all + // throwables itself; nothing may escape into the binder. + runtime.handleBuildStatus(statusJson); + } + + /** + * @param generation + * the payload's generation, which must be strictly newer to be applied + * @param dexPayload + * the dex bytes, or null when this deploy changed no code + * @param resourcesPayload + * the relinked resource apk, or null when no resources changed + * @param assetsPayload + * the changed-assets zip, or null when no assets changed + * @param metadataJson + * the deploy metadata document + */ + @Override + public void onPayload(long generation, ParcelFileDescriptor dexPayload, + ParcelFileDescriptor resourcesPayload, ParcelFileDescriptor assetsPayload, + String metadataJson) { + // Oneway call, arrives on a binder thread. handlePayload guards all + // throwables itself; nothing may escape into the binder. + runtime.handlePayload(generation, dexPayload, resourcesPayload, assetsPayload, + metadataJson); + } + }; + + /** + * @param runtime + * the runtime this client reports to and reads the running generation from + */ + QuickBuildClient(QuickBuildRuntime runtime) { + this.runtime = runtime; + } + + /** + * Drops the dead binding and queues a fresh one, since the framework will not revive it. + * + * @param name + * CoGo's service component; unused, there is only one binding + */ + @Override + public void onBindingDied(ComponentName name) { + RuntimeLog.w("binding to CoGo died; rebinding"); + host = null; + unbindQuietly(); + scheduleRebind(); + } + + /** + * Treats a null binding as a not-ready CoGo and retries with backoff. + * + * @param name + * CoGo's service component; unused, there is only one binding + */ + @Override + public void onNullBinding(ComponentName name) { + RuntimeLog.w("CoGo returned a null binding; retrying later"); + host = null; + unbindQuietly(); + scheduleRebind(); + } + + /** + * Registers this app with CoGo, naming the generation it currently runs so CoGo can send the catch-up payload. + * + * connect() is the one synchronous call on the host interface, so host-thrown exceptions cross the binder into this method, on the main thread. CoGo deliberately rejects connect with a SecurityException when no session is live, and the app must keep running standalone, so a rejection drops the channel and falls back to the backoff loop. + * + * The backoff reset happens only after a successful connect: resetting on mere service connection would make a rejecting host retry at the minimum delay forever. + * + * @param name + * CoGo's service component; unused, there is only one binding + * @param service + * the host binder, which may still be null in practice, hence the check + */ + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + IQuickBuildHost connected = IQuickBuildHost.Stub.asInterface(service); + if (connected == null) { + RuntimeLog.w("null host proxy from onServiceConnected"); + scheduleRebind(); + return; + } + host = connected; + try { + Context context = appContext; + String packageName = context == null ? "" : context.getPackageName(); + connected.connect(target, packageName, runtime.runningGeneration()); + synchronized (this) { + rebindDelayMs = REBIND_MIN_DELAY_MS; + } + RuntimeLog.i("connected to CoGo (running gen " + runtime.runningGeneration() + ")"); + } catch (RemoteException error) { + RuntimeLog.e("connect() to CoGo failed", error); + host = null; + scheduleRebind(); + } catch (RuntimeException error) { + // SecurityException (and any other binder-propagatable runtime exception) from + // the host: expected when CoGo has no live session. Continue standalone. + RuntimeLog.w("CoGo rejected connect(); continuing standalone: " + error); + host = null; + unbindQuietly(); + scheduleRebind(); + } + } + + /** + * Forgets the host and waits, because the framework reconnects this binding itself. + * + * @param name + * CoGo's service component; unused, there is only one binding + */ + @Override + public void onServiceDisconnected(ComponentName name) { + // The binding stays valid; the framework restarts the service (BIND_AUTO_CREATE) + // and calls onServiceConnected again. Do NOT rebind manually here - a second + // bindService with the same connection would stack bindings. + RuntimeLog.w("CoGo deploy service disconnected; awaiting reconnect"); + host = null; + } + + /** + * Starts the binding to CoGo. Idempotent, so it is safe to call once per activity. + * + * @param context + * any context; only its application context is retained, so no activity leaks + */ + synchronized void bind(Context context) { + if (bindRequested) { + return; + } + bindRequested = true; + appContext = context.getApplicationContext(); + if (!bindNow()) { + scheduleRebind(); + } + } + + /** + * Tells CoGo a generation crashed and was rolled back. Best-effort: a lost host is logged, never fatal. + * + * @param generation + * the generation that crashed, which CoGo marks bad so it is not re-sent + * @param stackSummary + * one-line summary of the crash, for CoGo to show the developer + */ + void reportCrash(long generation, String stackSummary) { + IQuickBuildHost current = host; + if (current == null) { + RuntimeLog.w("cannot report crash for gen " + generation + ": not connected"); + return; + } + try { + current.reportCrash(generation, stackSummary); + } catch (RemoteException error) { + RuntimeLog.e("reportCrash failed", error); + } + } + + /** + * Tells CoGo a generation reloaded and how long it took. Best-effort: a lost host is logged, never fatal. + * + * @param generation + * the generation now running, which becomes CoGo's new baseline + * @param reloadMillis + * wall-clock time from payload arrival to the screen being back, the number the IDE reports to the developer + */ + void reportReloaded(long generation, long reloadMillis) { + IQuickBuildHost current = host; + if (current == null) { + RuntimeLog.w("cannot report reloaded gen " + generation + ": not connected"); + return; + } + try { + current.reportReloaded(generation, reloadMillis); + } catch (RemoteException error) { + RuntimeLog.e("reportReloaded failed", error); + } + } + + /** + * Issues one bindService against CoGo's explicit service intent. + * + * @return true when the framework accepted the bind request - only {@link #onServiceConnected} confirms the channel - and false when there is no context yet, CoGo is not installed, or bindService threw + */ + private boolean bindNow() { + Context context = appContext; + if (context == null) { + return false; + } + Intent intent = new Intent(SERVICE_ACTION); + intent.setPackage(IDE_PACKAGE); + try { + boolean binding = context.bindService(intent, this, + Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT); + if (!binding) { + RuntimeLog.w("bindService returned false; is CoGo installed?"); + } + return binding; + } catch (Throwable error) { + RuntimeLog.e("bindService failed", error); + return false; + } + } + + /** Queues one rebind attempt, doubling the delay up to {@link #REBIND_MAX_DELAY_MS}. */ + private synchronized void scheduleRebind() { + if (rebindScheduled) { + return; + } + rebindScheduled = true; + int delay = rebindDelayMs; + rebindDelayMs = Math.min(rebindDelayMs * 2, REBIND_MAX_DELAY_MS); + mainHandler.postDelayed(new Runnable() { + + @Override + public void run() { + synchronized (QuickBuildClient.this) { + rebindScheduled = false; + } + if (host != null) { + return; + } + if (!bindNow()) { + scheduleRebind(); + } + } + }, delay); + } + + /** Unbinds, ignoring the not-registered case that a dead binding can produce. */ + private void unbindQuietly() { + Context context = appContext; + if (context == null) { + return; + } + try { + context.unbindService(this); + } catch (Throwable error) { + RuntimeLog.d("unbindService: " + error); + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildKeepAliveService.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildKeepAliveService.java new file mode 100644 index 0000000000..94096edf7c --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildKeepAliveService.java @@ -0,0 +1,45 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; + +/** + * A featureless bound service CoGo binds into, keeping this process out of Android's cached-app freezer while a Quick Build session is open: the proxy app has no foreground activity during the edit loop, and a frozen process runs no binder threads, so every save would fail the deploy timeout. + * + * It has to run this way round because a binding raises the priority of the process hosting the SERVICE, not the client's, so {@link QuickBuildClient}'s outward bind to CoGo confers nothing here. Named in the Gradle plugin's {@code ComponentProxiabilityResolver.UNPROXIABLE_BY_NAME} so the proxy-app manifest transform keeps the exact name CoGo binds by. + * + * Final on purpose: it is not a hot-swap target, and the final flag is a second, independent reason for the manifest transform to skip it. + */ +public final class QuickBuildKeepAliveService extends Service { + + /** Handed to every binder; carries no operations because the binding is the whole point. */ + private final IBinder binder = new Binder(); + + /** + * Accepts the bind that keeps this process unfrozen. + * + * @param intent + * CoGo's explicit bind intent; nothing is read from it + * @return a featureless binder, never null - a null binding would leave the caller retrying and this process cached + */ + @Override + public IBinder onBind(Intent intent) { + RuntimeLog.i("keep-alive bound; this process is no longer freezer-eligible"); + return binder; + } + + /** + * Notes that the process is cacheable again, which is the correct state once no session can deploy to it. + * + * @param intent + * the intent originally used to bind; nothing is read from it + * @return false, so a later rebind gets {@link #onBind} again rather than onRebind + */ + @Override + public boolean onUnbind(Intent intent) { + RuntimeLog.i("keep-alive unbound; this process is freezer-eligible again"); + return false; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java new file mode 100644 index 0000000000..6bb99d87d0 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -0,0 +1,698 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Activity; +import android.app.Application; +import android.os.Handler; +import android.os.Looper; +import android.os.MessageQueue; +import android.os.ParcelFileDescriptor; +import android.os.SystemClock; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * Coordinates the proxy app runtime: takes payloads from {@link QuickBuildClient}, applies them to {@link PayloadStore} and {@link ResourceStore}, drives the reload, and keeps the {@link StatusOverlay} and the reports to CoGo honest. + * + * Installed once per process by {@link QuickBuildAppComponentFactory} at application instantiation; Context work - binding to CoGo, cache dirs - waits for the first activity, since the Application has no base context yet. + * + * Failure policy throughout: a reload failure reports the crash and rolls back, so the app keeps running the last working code rather than crash-looping or silently claiming the new generation. + */ +final class QuickBuildRuntime { + + /** Stack frames kept in a crash summary; enough to place the fault, short enough to read. */ + private static final int MAX_CRASH_SUMMARY_FRAMES = 5; + + /** + * How long a restart deploy waits for the framework to take the app's state, across both phases of the handoff. + * + * Only spent when the app is actually in front, which in the normal loop it is not - the user is typing in CoGo, so every activity is already stopped and both phases pass at once. Bounded well under the host's 5 s disconnect wait, since the kill is owed either way. + */ + private static final long RESTART_HANDOFF_TIMEOUT_MILLIS = 1500; + + /** Hard cap on a crash summary, since it crosses binder and lands in a banner. */ + private static final int MAX_CRASH_SUMMARY_LENGTH = 2000; + + /** The one runtime per process, or null before {@link #install}. */ + private static volatile QuickBuildRuntime instance; + + /** + * Creates and starts the one runtime for this process. Idempotent, and never throws. + * + * @param application + * the app's Application, already instantiated but without a base context yet, so only non-Context setup runs here; null is ignored + */ + static void install(Application application) { + if (instance != null || application == null) { + return; + } + synchronized (QuickBuildRuntime.class) { + if (instance != null) { + return; + } + try { + QuickBuildRuntime runtime = new QuickBuildRuntime(application); + runtime.start(); + instance = runtime; + } catch (Throwable error) { + RuntimeLog.e("failed to install quick build runtime", error); + } + } + } + + /** + * Opens a persisted store file as a read-only fd, the form the resource paths take. + * + * @param file + * the store file to open; must exist + * @return the fd, which the callee closes + * @throws IOException + * when the file cannot be opened + */ + private static ParcelFileDescriptor openReadOnly(File file) throws IOException { + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + + /** + * Parses deploy metadata, falling back to defaults so a bad blob cannot block a code reload. + * + * @param metadataJson + * the metadata document from the host + * @return the parsed metadata, or a recreate-only default with no entry activity when the document is malformed + */ + private static DeployMetadata parseMetadata(String metadataJson) { + try { + return DeployMetadata.parse(metadataJson); + } catch (IllegalArgumentException error) { + // Defaults are recreate-only, with no entry launch. + RuntimeLog.e("unparseable deploy metadata; using defaults", error); + return new DeployMetadata(null, false); + } + } + + /** + * Drains one payload fd into memory and closes it. + * + * @param fd + * the payload fd, or null when this deploy carried nothing of that kind + * @return the bytes, or null when {@code fd} was null + * @throws IOException + * on a read failure or when the payload exceeds the size cap; the fd is still closed + */ + private static byte[] readBytesAndClose(ParcelFileDescriptor fd) throws IOException { + if (fd == null) { + return null; + } + InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(fd); + try { + return Streams.readFully(in); + } finally { + in.close(); + } + } + + /** + * Compact single-string stack summary for reportCrash / the overlay. + * + * @param error + * the failure to summarize; must be non-null + * @return the exception, up to {@link #MAX_CRASH_SUMMARY_FRAMES} frames and its immediate cause, truncated to {@link #MAX_CRASH_SUMMARY_LENGTH} chars + */ + private static String summarize(Throwable error) { + StringBuilder sb = new StringBuilder(); + sb.append(error.toString()); + StackTraceElement[] frames = error.getStackTrace(); + int limit = Math.min(frames.length, MAX_CRASH_SUMMARY_FRAMES); + for (int i = 0; i < limit; i++) { + sb.append("\n at ").append(frames[i]); + } + Throwable cause = error.getCause(); + if (cause != null && cause != error) { + sb.append("\nCaused by: ").append(cause.toString()); + } + if (sb.length() > MAX_CRASH_SUMMARY_LENGTH) { + sb.setLength(MAX_CRASH_SUMMARY_LENGTH); + } + return sb.toString(); + } + + private final Application application; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ActivityTracker tracker = new ActivityTracker(this); + + private final QuickBuildClient client = new QuickBuildClient(this); + private final StatusOverlay overlay = new StatusOverlay(); + + /** Whether the generation this process booted from the store has proved itself yet. */ + private final BootProbation bootProbation = new BootProbation(); + + /** The restart path's wait for the framework to be told the app's state before the process dies. */ + private final RestartHandoff restartHandoff = new RestartHandoff(); + + /** What the overlay should show; written from any thread, rendered on the main one. */ + private volatile OverlayState overlayState = OverlayState.hidden(); + + /** Generation whose reload is awaiting its first resumed frame, or -1. */ + private volatile long pendingReloadGeneration = -1; + + /** Uptime at which the pending reload's payload arrived, the start of the reported duration. */ + private volatile long pendingReloadStartUptime; + + /** Latches the legacy resource-apk cache sweep, which is only safe before the first swap. */ + private boolean sweptLegacyResourceCache; + + /** Newest generation already recorded as good, so the write happens once rather than per resume. */ + private volatile long lastMarkedGoodGeneration = -1; + + /** + * @param application + * the app's Application; retained for its package name, cache dir and lifecycle callbacks, and safe to hold because the runtime is process-scoped + */ + private QuickBuildRuntime(Application application) { + this.application = application; + } + + /** + * Turns a build-status message from CoGo into overlay state. + * + * This is the only way the running app learns about a compile error, which never produces a payload. Runs on a binder thread and swallows every throwable, so nothing escapes into the binder. + * + * @param statusJson + * the status document from the host; an unknown kind or malformed document is dropped, leaving the overlay as it was + */ + void handleBuildStatus(String statusJson) { + try { + BuildStatus status = BuildStatus.parse(statusJson); + if (status == null) { + // Unknown kind from a newer CoGo: the versioning contract says ignore. + return; + } + if (BuildStatus.KIND_BUILD_FAILED.equals(status.kind)) { + setOverlayState(OverlayState.buildFailed(status)); + } else if (BuildStatus.KIND_BUILDING.equals(status.kind)) { + // Replaces whatever was showing (a stale failure or nothing) - a new + // attempt starting is real news either way. + setOverlayState(OverlayState.building(status.runningGeneration)); + } else if (BuildStatus.KIND_REINSTALL_PENDING.equals(status.kind)) { + // The update is built but its install confirm can only be shown from + // CoGo; this banner is the one signal that reaches the user watching + // the stale app. + setOverlayState(OverlayState.reinstallPending()); + } else if (overlayState.isError() || overlayState.isBuilding()) { + // build_ok clears a stale failure or in-flight banner; it never renders + // anything itself. + setOverlayState(OverlayState.hidden()); + } + } catch (Throwable error) { + RuntimeLog.w("unusable build status; dropped", error); + } + } + + /** + * Applies one deploy: reads the payload fds, persists them, then swaps in the new generation. + * + * Runs on a binder thread; only the reload is posted to the main thread. Persisting before applying is what lets a relaunched process boot the newest generation. A restart deploy persists, acks and exits instead, since services, providers and the Application only swap across a process restart; a recreate deploy acks on its next resumed frame, or at apply time when backgrounded, because a deferred recreate renders no frame to prove. + * + * @param generation + * the incoming generation; a stale one is dropped without a report, since acking a refused payload would mislead the host + * @param dexPayload + * the dex fd, or null for a resources or assets-only deploy; always closed + * @param resourcesPayload + * the relinked resource apk fd, or null; always closed + * @param assetsPayload + * the changed-assets zip fd, or null; always closed + * @param metadataJson + * the deploy metadata; a malformed document defaults rather than failing + */ + void handlePayload(long generation, ParcelFileDescriptor dexPayload, + ParcelFileDescriptor resourcesPayload, ParcelFileDescriptor assetsPayload, + String metadataJson) { + long startUptime = SystemClock.uptimeMillis(); + PayloadStore.Payload previous = PayloadStore.INSTANCE.snapshot(); + try { + DeployMetadata metadata = parseMetadata(metadataJson); + byte[] dexBytes = readBytesAndClose(dexPayload); + byte[] arscBytes = readBytesAndClose(resourcesPayload); + byte[] assetsBytes = readBytesAndClose(assetsPayload); + if (previous == null + || !Generations.accepts(previous.generation, generation)) { + // Deliberately unreported: claiming a reload for a payload we refused + // would mislead the host. + RuntimeLog.w("dropping payload gen " + generation + " (running " + + (previous == null ? "no baseline" : "gen " + previous.generation) + ")"); + return; + } + if (metadata.restart && dexBytes == null) { + // Without a dex, the relaunch would boot old classes under a new + // generation label. A CoGo bug if it ever happens. + throw new IllegalStateException("restart deploy without a dex payload"); + } + PayloadPersistence.Persisted persisted = persistPayload(generation, dexBytes, arscBytes, assetsBytes); + if (metadata.restart) { + // Never applied in-memory: this process is already condemned, and the + // fresh one boots the persisted generation. + RuntimeLog.i("restart deploy gen " + generation + " persisted; exiting"); + client.reportReloaded(generation, SystemClock.uptimeMillis() - startUptime); + exitForRestart(); + return; + } + if (!PayloadStore.INSTANCE.apply(generation, + dexBytes == null ? null : ByteBuffer.wrap(dexBytes))) { + // Raced by a newer payload between the acceptance check and here. + return; + } + if (arscBytes != null) { + ResourceStore.INSTANCE.applyTable( + openReadOnly(persisted.arscFile), generation, application); + } + if (assetsBytes != null) { + ResourceStore.INSTANCE.applyAssets( + openReadOnly(persisted.assetsFile), + PayloadStore.INSTANCE.baselineFingerprint(), + application.getCacheDir()); + } + if (tracker.hasResumedActivity()) { + pendingReloadStartUptime = startUptime; + pendingReloadGeneration = generation; + } else { + // Backgrounded: no resumed activity to hang a frame callback on, so + // waiting for render-proof would time out a deploy that worked. Ack at + // apply+persist, like the restart path. + // Do NOT read this as "the recreate is deferred until the user returns." + // Measured on an A56 (Android 16), a stopped-but-not-destroyed activity + // relaunches immediately - the tracker still holds it, so the relaunch is + // scheduled before this ack is even written. That timing is not + // guaranteed across versions or states, which is exactly why the ack does + // not depend on it. + // Tradeoffs: the metric is apply-time, not render-time, and a crash in + // the relaunch goes unreported (gap #91's shape). A background race after + // this check falls back to the deploy timeout. + client.reportReloaded(generation, SystemClock.uptimeMillis() - startUptime); + } + final long reloadGeneration = generation; + final PayloadStore.Payload rollback = previous; + mainHandler.post(new Runnable() { + + @Override + public void run() { + reloadOnMain(reloadGeneration, rollback); + } + }); + } catch (Throwable error) { + RuntimeLog.e("payload gen " + generation + " failed to apply", error); + Streams.closeQuietly(dexPayload); + Streams.closeQuietly(resourcesPayload); + Streams.closeQuietly(assetsPayload); + failReload(generation, previous, error); + } + } + + /** + * Does the Context-dependent setup deferred from install: bind to CoGo, attach persistence, restore boot resources. + * + * @param activity + * the activity being created, used only for its application context; every step is idempotent, so this runs safely on each activity + */ + void onActivityCreated(Activity activity) { + // First moment a usable Context exists; bind() is idempotent. + // The sweep runs before bind and before the boot resources apply, because it is + // only safe while this process has mounted no relinked apk of its own. + sweepLegacyResourceCache(activity.getApplicationContext()); + client.bind(activity.getApplicationContext()); + PayloadStore.INSTANCE.attachPersistence(activity.getApplicationContext()); + applyPendingBootResources(activity.getApplicationContext()); + } + + /** + * Completes a pending reload on its first rendered frame, and renders the overlay and return button. + * + * This is where reportReloaded fires for a foreground deploy: the first callback after the swap at which the new generation is committed to being drawn. Note that onResume is NOT itself a rendered frame - it precedes the first draw, so the reported time understates true time-to-pixels by that margin (measured at ~4 ms on an A56, foreground path). A backgrounded deploy was already acked at apply time and left no pending generation, so it cannot double-report here. + * + * @param activity + * the activity now in the foreground, which hosts the overlay + */ + void onActivityResumed(Activity activity) { + long pending = pendingReloadGeneration; + if (pending >= 0 && PayloadStore.INSTANCE.generation() == pending) { + pendingReloadGeneration = -1; + long reloadMillis = SystemClock.uptimeMillis() - pendingReloadStartUptime; + client.reportReloaded(pending, reloadMillis); + // Success renders nothing; it only clears a shown error or in-flight + // banner, since a landed reload means the build finished even if the + // build_ok message is still in flight behind it. + if (overlayState.isError() || overlayState.isBuilding()) { + setOverlayState(OverlayState.hidden()); + } else { + overlay.render(activity, overlayState); + } + } else { + overlay.render(activity, overlayState); + } + // Unconditional, because the point is that an activity of this generation is on + // screen - which is true whether it arrived by hot swap or by a fresh process + // booting it, and only the first of those leaves a pending generation behind. + markLiveGenerationGood(); + } + + /** Counts an activity into the set a restart deploy waits to empty before killing the process. */ + void onActivityStarted() { + restartHandoff.onActivityStarted(); + } + + /** Counts an activity out of that set; the last one out is what lets a waiting restart move on. */ + void onActivityStopped() { + restartHandoff.onActivityStopped(); + } + + /** + * The generation the app is running, as reported to CoGo on connect. + * + * @return the live generation, 0 when only the baseline has ever run + */ + long runningGeneration() { + return PayloadStore.INSTANCE.generation(); + } + + /** + * Applies the resource payloads a persisted boot left pending, once a Context exists. + * + * The code half already loaded pre-Context in {@link PayloadStore#ensureBaseline}. Components that read resources before the first activity, such as providers, see baseline resources until this runs. A failure keeps baseline resources and the next deploy re-applies current ones. + * + * @param context + * application context, for the Resources to swap and the cache dir to extract assets into + */ + private void applyPendingBootResources(android.content.Context context) { + PayloadPersistence.Loaded pending = PayloadStore.INSTANCE.takePendingBootResources(); + if (pending == null) { + return; + } + try { + if (pending.arscFile != null) { + ResourceStore.INSTANCE.applyTable( + openReadOnly(pending.arscFile), pending.generation, context); + } + if (pending.assetsFile != null) { + ResourceStore.INSTANCE.applyAssets( + openReadOnly(pending.assetsFile), + PayloadStore.INSTANCE.baselineFingerprint(), + context.getCacheDir()); + } + RuntimeLog.i("restored persisted resources for gen " + pending.generation); + } catch (Throwable error) { + RuntimeLog.e("could not restore persisted resources", error); + } + } + + /** + * Asks Android to background the app and waits until the framework has been told the app's state, so the relaunch can put the user back where they were. + * + * Killing a process the server still believes has no saved state for its top activity gets that record force-removed; when it was the task's only entry the task goes too, and the relaunch has nothing to resume. Waiting for the in-process onSaveInstanceState callback, as this did before, ends about one main-thread message too early - the app has written its bundle and the server has not been told, which measured on an A56 as a force-removal 102 ms later and a task collapsed to a single launcher entry. + * + * The gate is any STARTED activity rather than a resumed one, because the record at risk is any the server holds no state for, split screen and a dialog from another app included. + * + * A no-op in the normal loop: the user saves by typing in CoGo, so every activity is already stopped and the framework has what it needs. Never fails the restart - a handoff that does not complete costs the user their place, not their app. + */ + private void backgroundForRestart() { + final Activity top = tracker.topActivity(); + if (top == null || !restartHandoff.anyActivityStarted()) { + return; + } + // Arm before asking, so nothing from an earlier handoff can answer this one. + restartHandoff.arm(); + mainHandler.post(new Runnable() { + + @Override + public void run() { + try { + // nonRoot, so this works from any activity in the task rather than only + // the one that started it. + top.moveTaskToBack(true); + } catch (Throwable error) { + RuntimeLog.w("could not background the task before restarting", error); + } + } + }); + boolean handedOff = restartHandoff.awaitHandoff(RESTART_HANDOFF_TIMEOUT_MILLIS, new Runnable() { + + @Override + public void run() { + drainMainLooper(); + } + }); + if (!handedOff) { + RuntimeLog.w("the framework was not told the app's state within " + + RESTART_HANDOFF_TIMEOUT_MILLIS + + " ms; restarting anyway, so the screen and back stack may not come back"); + } + } + + /** + * Ends the handoff once the main looper has run everything the last activity's stop queued behind it. + * + * ActivityThread posts its {@code activityStopped} report - the message carrying the saved state to the server - to the main looper from inside the stop it has just dispatched. A message queued from here can land either side of that post, so it proves nothing; an idle callback cannot, because the looper only looks for one when no message is ready, which is necessarily after the report has run. The empty post is the nudge that makes it look, since adding an idle handler does not wake a looper that is already parked. + * + * A failure here ends the wait rather than stranding it: the kill is owed either way, and a full timeout would cost the user the same place this is protecting. + */ + private void drainMainLooper() { + try { + Looper.getMainLooper().getQueue().addIdleHandler(new MessageQueue.IdleHandler() { + + @Override + public boolean queueIdle() { + restartHandoff.onDrained(); + return false; + } + }); + mainHandler.post(new Runnable() { + + @Override + public void run() {} + }); + } catch (Throwable error) { + RuntimeLog.w("could not wait for the main looper before restarting", error); + restartHandoff.onDrained(); + } + } + + /** + * Backgrounds the app so Android saves its state, then kills the process, because a restart deploy's ack promises a fresh boot. + * + * The kill has to come from inside the app: CoGo binds this app's keep-alive service to keep it out of the cached-app freezer, which also holds it out of the killable bucket, so {@code am kill} reports success and leaves the process running (measured on an A56, 3 of 3). + */ + private void exitForRestart() { + backgroundForRestart(); + android.os.Process.killProcess(android.os.Process.myPid()); + } + + /** + * Rolls back to {@code rollback}, reports the crash to CoGo, and shows the banner; the app stays on the old generation. + * + * @param generation + * the generation that failed, which CoGo marks bad + * @param rollback + * the snapshot taken before the apply; may be null, which restores the inert state the store was already in + * @param error + * the failure, summarized into both the report and the banner + */ + private void failReload(long generation, PayloadStore.Payload rollback, Throwable error) { + if (!Generations.rollbackApplies(PayloadStore.INSTANCE.generation(), generation)) { + // A newer payload landed while this one was failing, so it owns the store, the + // pending ack and the screen. Rolling back here would undo a deploy that worked. + RuntimeLog.w("gen " + generation + " failed but gen " + + PayloadStore.INSTANCE.generation() + " is live; leaving it alone", error); + return; + } + PayloadStore.INSTANCE.restore(rollback); + quarantine(generation); + pendingReloadGeneration = -1; + String summary = summarize(error); + setOverlayState(OverlayState.crashed(summary)); + client.reportCrash(generation, summary); + } + + /** + * Chains a handler that quarantines and reports the generation a crash belongs to, before the app dies. + * + * A payload crash during render happens outside our call stack - the recreated activity throws in its own lifecycle - so the default uncaught handler is the only interception point. It delegates afterwards, so the process still dies; on relaunch the app reconnects with whatever the store then serves and CoGo decides what to redeploy. + * + * Which generation a crash belongs to is {@link BootProbation}'s question, not this handler's, because a restart deploy's crash lands in the process AFTER the one that deployed it, where no reload is pending. + */ + private void installCrashGuard() { + final Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + + /** + * @param thread + * the thread that died; forwarded untouched to the previous handler + * @param error + * the uncaught failure, reported to CoGo only when a generation this process adopted is to blame + */ + @Override + public void uncaughtException(Thread thread, Throwable error) { + try { + long doomed = bootProbation.generationToBlame(pendingReloadGeneration, + PayloadStore.INSTANCE.generation()); + if (doomed >= 0) { + // The store already claims this generation, so a relaunch would + // adopt it and die the same way again - and the marker is what + // sends that relaunch to the last generation that ran instead. + quarantine(doomed); + client.reportCrash(doomed, summarize(error)); + } + } catch (Throwable ignored) { + // The crash guard itself must never throw. + } + if (previous != null) { + previous.uncaughtException(thread, error); + } + } + }); + } + + /** + * Records the running generation as the one a later quarantine should fall back to, and ends its probation. + * + * Called from a resumed activity, which is the bar that matters: the failure a fallback has to survive is a payload that throws on the way to the screen, so a generation that got there is one a fresh process can boot. Without this a quarantine drops the app to install-time code and discards every save since. + * + * The probation ends on the recorded write rather than on the resume that prompted it, so the two facts stay simultaneous: the moment this generation stops being blamed for a crash is the moment there is something to fall back to instead. A write that fails leaves it on probation, which is the safe direction - {@link PayloadPersistence#quarantine} refuses to name a recorded generation, so the cost of blaming one wrongly is a log line. + * + * Written off the main thread, because the write is fsynced and this runs on the frame path; latched per generation, so it costs one short-lived thread per generation rather than one per resume. Losing the write to a process death only makes the fallback one generation older. + */ + private void markLiveGenerationGood() { + final long generation = PayloadStore.INSTANCE.generation(); + final PayloadPersistence store = PayloadStore.INSTANCE.persistence(); + if (generation <= 0 || generation == lastMarkedGoodGeneration || store == null) { + return; + } + lastMarkedGoodGeneration = generation; + new Thread(new Runnable() { + + @Override + public void run() { + if (store.markGood(generation)) { + bootProbation.proved(generation); + } + } + }, "qb-mark-good").start(); + } + + /** + * Writes the payload to the persisted store before anything applies it. + * + * @param generation + * the generation the store will claim after this write + * @param dex + * the dex bytes, or null to keep whatever is persisted + * @param arsc + * the relinked resource apk bytes, or null to keep whatever is persisted + * @param assetsZip + * the changed-assets zip bytes, or null to keep whatever is persisted + * @return the store's payload files, which the resource paths then open read-only + * @throws IOException + * when the store is unavailable or the write fails, so the deploy fails loudly instead of leaving the boot path behind the running generation + */ + private PayloadPersistence.Persisted persistPayload(long generation, byte[] dex, + byte[] arsc, byte[] assetsZip) throws IOException { + PayloadPersistence store = PayloadStore.INSTANCE.persistence(); + String fingerprint = PayloadStore.INSTANCE.baselineFingerprint(); + if (store == null || fingerprint == null) { + throw new IOException("payload persistence unavailable"); + } + return store.persist(generation, fingerprint, dex, arsc, assetsZip); + } + + /** + * Marks {@code generation} as one a fresh process must not boot. + * + * The payload was persisted before it was applied, so without this the generation that just failed is what the next cold start adopts - and it fails again during startup, where no reload is pending and so nothing reports it. Refusing it boots the baseline instead, which is the code the installed APK carries. + * + * @param generation + * the generation that failed to apply or render; nothing happens when persistence never came up, which already means no cold start can adopt it + */ + private void quarantine(long generation) { + PayloadPersistence store = PayloadStore.INSTANCE.persistence(); + if (store != null) { + store.quarantine(generation); + } + } + + /** + * Recreates the top activity so it re-instantiates from the new generation's classloader. + * + * With no live activity there is nothing to recreate and deliberately nothing to launch: the payload is applied, persisted and acked, so the next launch boots this generation. Launching here would take the screen on a plain save, which a save must never do; the user-asked-for launch paths live in CoGo. + * + * @param generation + * the generation being reloaded, used only for logging and the failure path + * @param rollback + * the pre-apply snapshot to restore if the recreate throws + */ + private void reloadOnMain(long generation, PayloadStore.Payload rollback) { + try { + Activity top = tracker.topActivity(); + if (top != null) { + top.recreate(); + } else { + RuntimeLog.i("no live activity; gen " + generation + " applies on next launch"); + } + // A foreground deploy's reportReloaded fires from onActivityResumed, after + // the reload rendered; a backgrounded one was acked at apply time. + } catch (Throwable error) { + RuntimeLog.e("reload for gen " + generation + " failed", error); + failReload(generation, rollback, error); + } + } + + /** + * Installs the new overlay state and re-renders it on the main thread; callable from any thread. + * + * @param state + * the state to become current; the render reads the field rather than this argument, so a state superseded before the post lands is never drawn + */ + private void setOverlayState(OverlayState state) { + overlayState = state; + mainHandler.post(new Runnable() { + + @Override + public void run() { + overlay.render(tracker.topActivity(), overlayState); + } + }); + } + + /** + * Wires up the pieces that need no Context: activity tracking, the boot probation and the crash guard. + * + * The store has already run - {@link QuickBuildAppComponentFactory} calls {@link PayloadStore#ensureBaseline} before it instantiates the Application - so the generation this process booted is known here, which is early enough for the guard to cover the Application's own onCreate. + */ + private void start() { + application.registerActivityLifecycleCallbacks(tracker); + bootProbation.bootedFromStore(PayloadStore.INSTANCE.bootedPersistedGeneration()); + installCrashGuard(); + } + + /** + * Deletes the relinked apks a previous process left in the API 28/29 resource cache, once. + * + * Those files can only be unmounted by the process dying, so the process that wrote them cannot clean them up and the cache would otherwise grow by one apk per deploy. Latched and run before this process mounts any of its own, since a mounted path deleted underneath the AssetManager cannot be recovered. + * + * @param context + * application context, for the cache directory + */ + private void sweepLegacyResourceCache(android.content.Context context) { + if (sweptLegacyResourceCache) { + return; + } + sweptLegacyResourceCache = true; + try { + int deleted = LegacyResourceSwap.deleteStaleApks( + new File(context.getCacheDir(), LegacyResourceSwap.TABLE_DIR)); + if (deleted > 0) { + RuntimeLog.i("swept " + deleted + " stale relinked apk(s) from a previous process"); + } + } catch (Throwable error) { + RuntimeLog.w("could not sweep the legacy resource cache", error); + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java new file mode 100644 index 0000000000..9264b33782 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -0,0 +1,290 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.loader.ResourcesLoader; +import android.content.res.loader.ResourcesProvider; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Owns the payload's resource and asset overrides. + * + * The payload fd is always the whole relinked resource apk from {@code Aapt2Link}, never a bare table: a bare table cannot back a file-typed resource such as a layout or a drawable XML. + * + * The swap mechanism follows {@link ResourceSwapStrategy}. On API 30+ one long-lived {@link ResourcesLoader} has its providers swapped per payload; one loader suffices because an attached loader propagates provider changes, so an activity attaches once and follows every later generation. On API 28/29 {@link LegacyResourceSwap} addAssetPath's the apk instead, and nothing there can serve assets, so CoGo's classifier routes asset-bearing edits to a full Gradle build. + * + * The asset overlay can add and replace but not hide: a deleted asset stays readable until the next proxy app build. + */ +final class ResourceStore { + + /** The process-wide store; the strategy is fixed from the device's SDK level at class init. */ + static final ResourceStore INSTANCE = new ResourceStore(); + + /** Cache subdirectory holding the API 28/29 relinked apks, one per generation. */ + private static final String LEGACY_TABLE_DIR = "quickbuild-res"; + + /** Cache subdirectory holding the cumulative extracted assets and their baseline marker. */ + private static final String ASSETS_ROOT_DIR = "quickbuild-assets"; + + private final ResourceSwapStrategy strategy; + + /** The API 30+ loader, created on the first resource or assets payload and never replaced. */ + private volatile ResourcesLoader loader; + + /** The table provider inside {@link #loader}; the previous one is closed after each swap. */ + private volatile ResourcesProvider provider; + + /** The assets-only provider inside {@link #loader}; the previous one is closed after each swap. */ + private volatile ResourcesProvider assetsProvider; + + /** The directory provider backing {@link #assetsProvider}; closed alongside it. */ + private volatile DirectoryAssetsProvider assetsDirProvider; + + /** The newest API 28/29 apk, mounted onto each new Resources by {@link #attachTo}. */ + private volatile File legacyTableZip; + + /** Latches the unsupported-SDK warning so it is logged once, not once per deploy. */ + private boolean warnedNoResourceReload; + + /** + * @param strategy + * the swap mechanism to use; injected so tests can drive each branch without an SDK level + */ + ResourceStore(ResourceSwapStrategy strategy) { + this.strategy = strategy; + } + + /** Builds {@link #INSTANCE}, picking the strategy from this device's SDK level. */ + private ResourceStore() { + this(ResourceSwapStrategy.forSdk(Build.VERSION.SDK_INT)); + } + + /** + * Merges a changed-assets zip into the cumulative override dir under {@code cacheRoot} and serves it through the loader. + * + * The merge clears the dir first when it belongs to another baseline, so assets never outlive the baseline they were deployed onto. + * + * A failed merge is not undone: there is no asset rollback, so whatever it already wrote stays live until the next successful deploy onto the same baseline overwrites it. + * + * @param assetsFd + * the changed-assets zip; always closed, success or failure + * @param baselineFingerprint + * the running baseline's fingerprint, which keys the cumulative dir + * @param cacheRoot + * the app's cache directory, the parent of the cumulative dir + * @throws IOException + * on a read, extraction, path-traversal or provider failure; the previous override stays live + */ + void applyAssets(ParcelFileDescriptor assetsFd, String baselineFingerprint, File cacheRoot) + throws IOException { + File assetsRoot = new File(cacheRoot, ASSETS_ROOT_DIR); + InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(assetsFd); + try { + int extracted = AssetExtractor.extractCumulative(in, assetsRoot, baselineFingerprint); + if (strategy == ResourceSwapStrategy.RESOURCES_LOADER) { + refreshAssetsProvider(AssetExtractor.currentDir(assetsRoot)); + } + RuntimeLog.i("merged " + extracted + " changed asset(s) into the override"); + } finally { + try { + in.close(); + } catch (IOException ignored) { + // Nothing useful to do with a failed close. + } + } + } + + /** + * Swaps in a new resource table, using whichever strategy this API level supports. + * + * A swap that already took is not undone: the deploy path's rollback covers the dex payload only, so a table applied before a later step threw stays mounted until the next successful deploy. + * + * @param tableFd + * the relinked resource apk; always closed, success or failure + * @param generation + * the payload generation, used only by the API 28/29 path to name its file + * @param appContext + * application context, used only by the API 28/29 path for its cache dir and Resources + * @throws IOException + * when the swap fails; an unsupported SDK is not a failure, it warns once and drops the payload + */ + void applyTable(ParcelFileDescriptor tableFd, long generation, Context appContext) + throws IOException { + switch (strategy) { + case RESOURCES_LOADER: + applyTableWithLoader(tableFd); + return; + case LEGACY_ASSET_PATH: + applyTableLegacy(tableFd, generation, appContext); + return; + default: + Streams.closeQuietly(tableFd); + synchronized (this) { + if (!warnedNoResourceReload) { + warnedNoResourceReload = true; + RuntimeLog.w("resource payloads need API 28+; ignoring"); + } + } + } + } + + /** + * Attaches the current resource override to a newly created {@code resources}. + * + * Uses the loader on API 30+ and the current table zip on 28/29; both are idempotent, and it is a no-op until the first resource payload arrives. A failed attach is logged, never fatal. + * + * @param resources + * the newly created activity or context Resources, attached to before it inflates anything or it resolves against the old table; null is ignored + */ + void attachTo(Resources resources) { + if (resources == null) { + return; + } + if (strategy == ResourceSwapStrategy.RESOURCES_LOADER) { + attachLoaderTo(resources); + } else if (strategy == ResourceSwapStrategy.LEGACY_ASSET_PATH) { + File zip = legacyTableZip; + if (zip == null) { + return; + } + try { + LegacyResourceSwap.addAssetPath(resources.getAssets(), zip.getAbsolutePath()); + LegacyResourceSwap.flushCaches(resources); + } catch (Throwable error) { + RuntimeLog.d("legacy attachTo skipped: " + error); + } + } + } + + /** + * API 28/29 swap: write the apk to disk, addAssetPath it into the application AssetManager, flush caches. + * + * The deploy's activity recreate then re-resolves from the new table. A throw rolls back the dex payload only, not this path's own on-disk apk or an addAssetPath that already succeeded. + * + * @param tableFd + * the relinked resource apk; always closed, success or failure + * @param generation + * the payload generation, which names the file on disk + * @param appContext + * application context, for the cache dir and the Resources to mount onto + * @throws IOException + * when the write or the mount fails; the previous table stays live + */ + private void applyTableLegacy(ParcelFileDescriptor tableFd, long generation, + Context appContext) throws IOException { + InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(tableFd); + try { + File dir = new File(appContext.getCacheDir(), LEGACY_TABLE_DIR); + File zip = LegacyResourceSwap.writeResourceApk(in, dir, generation); + Resources appResources = appContext.getResources(); + LegacyResourceSwap.addAssetPath(appResources.getAssets(), zip.getAbsolutePath()); + legacyTableZip = zip; + LegacyResourceSwap.flushCaches(appResources); + } finally { + try { + in.close(); + } catch (IOException ignored) { + // Nothing useful to do with a failed close. + } + } + } + + /** + * API 30+ swap: replace the table provider inside the process-wide loader, creating the loader on first use. + * + * TargetApi because lint cannot see the SDK guard: the strategy is RESOURCES_LOADER only when SDK >= 30. + * + * @param tableFd + * the relinked resource apk; loadFromApk dups it, so this method closes ours either way + * @throws IOException + * when the apk cannot be loaded as a provider; the previous provider stays live and attached + */ + @TargetApi(30) + private void applyTableWithLoader(ParcelFileDescriptor tableFd) throws IOException { + try { + ResourcesProvider next = ResourcesProvider.loadFromApk(tableFd, null); + synchronized (this) { + ResourcesProvider previous = provider; + provider = next; + installProviders(); + Streams.closeQuietly(previous); + } + } finally { + // loadFromApk dups the fd internally; ours must be closed either way. + Streams.closeQuietly(tableFd); + } + } + + /** + * Adds the process-wide loader to one Resources object. TargetApi: reached only on SDK >= 30. + * + * @param resources + * the Resources to attach to; attaching again, or an unusual implementation, is logged and ignored + */ + @TargetApi(30) + private void attachLoaderTo(Resources resources) { + ResourcesLoader target = loader; + if (target == null) { + return; + } + try { + resources.addLoaders(target); + } catch (Throwable error) { + // Already attached, or an unusual Resources implementation. Not worth + // crashing over. + RuntimeLog.d("attachTo skipped: " + error); + } + } + + /** + * Installs the current provider set into the loader, creating the loader on first use. Callers hold the monitor and close any provider they replaced. + */ + @TargetApi(30) + private void installProviders() { + ResourcesLoader target = loader; + if (target == null) { + target = new ResourcesLoader(); + loader = target; + } + List providers = new ArrayList(2); + if (provider != null) { + providers.add(provider); + } + if (assetsProvider != null) { + providers.add(assetsProvider); + } + target.setProviders(providers); + } + + /** + * API 30+: rebuild the assets half of the loader over the merged override dir. + * + * A fresh provider pair per deploy, rather than one long-lived one, so the loader's setProviders notifies every attached Resources that the underlying assets changed; the recreate then reads the new content. The table provider is untouched - the two halves change independently, since a deploy carries only what changed. + * + * @param dir + * the merged override dir laid out as an APK root (assets under {@code assets/}) + * @throws IOException + * when the provider cannot be created; the previous one stays live and attached + */ + @TargetApi(30) + private void refreshAssetsProvider(File dir) throws IOException { + DirectoryAssetsProvider nextDir = new DirectoryAssetsProvider(dir); + ResourcesProvider next = ResourcesProvider.empty(nextDir); + synchronized (this) { + ResourcesProvider previous = assetsProvider; + DirectoryAssetsProvider previousDir = assetsDirProvider; + assetsProvider = next; + assetsDirProvider = nextDir; + installProviders(); + Streams.closeQuietly(previous); + Streams.closeQuietly(previousDir); + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategy.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategy.java new file mode 100644 index 0000000000..e48cf69a4b --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategy.java @@ -0,0 +1,39 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * How a resource-table payload is applied on this device, chosen once per process from the SDK level. + * + * Free of android.* imports so the version routing is JVM-unit-testable. + */ +enum ResourceSwapStrategy { + + /** API 30+: ResourcesLoader/ResourcesProvider hot swap, the full-fidelity path. */ + RESOURCES_LOADER, + + /** + * API 28/29: no ResourcesLoader, so {@link LegacyResourceSwap} appends the relinked apk to the live AssetManager and the per-deploy activity recreate re-reads from it. + */ + LEGACY_ASSET_PATH, + + /** + * Below API 28: no mechanism this runtime supports, so resource payloads are ignored. Unreachable in practice, since the deploying CoGo host needs API 28+ on the same device. + */ + UNSUPPORTED; + + /** + * Maps an SDK level to its strategy; the levels are inlined (R and P) to keep this class android-free. + * + * @param sdkInt + * the device's {@code Build.VERSION.SDK_INT}, passed in by the caller so no android.* symbol is referenced here + * @return the strategy this process must use for every resource payload it receives + */ + static ResourceSwapStrategy forSdk(int sdkInt) { + if (sdkInt >= 30) { + return RESOURCES_LOADER; + } + if (sdkInt >= 28) { + return LEGACY_ASSET_PATH; + } + return UNSUPPORTED; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoff.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoff.java new file mode 100644 index 0000000000..517b7ce271 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoff.java @@ -0,0 +1,123 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * The wait a restart deploy makes between asking Android to background the app and killing the process. + * + * A process killed while the server still believes its top activity has no saved state gets that record force-removed ("app died, no saved state") - taking the task with it when it was the only entry, so the relaunch has nothing to resume and the user lands on the launcher screen with no Back to their work. Measured on an A56: force-removed on 8 of 8 restarts made with the app in front against 0 of 5 made with it backgrounded, and on a clean two-entry stack the task collapsed to one entry. Twice in six the relaunch then found nothing to start at all and the save cost 17 s instead of 2. + * + * The state reaches the server in two steps, and only the second is the one it reads. Every activity stops, which is when ActivityThread captures the state; then ActivityThread posts its {@code activityStopped} report - carrying that state - to the main looper, and the looper runs it. So this waits for both: {@link #onActivityStopped} for the first, and a drain of the main looper for the second, since a message queued behind the stop cannot run before the report the stop queued. + * + * Extracted from {@link ActivityTracker} and {@link QuickBuildRuntime} so the wait is JVM-testable: the halves either side of it are Activity lifecycle, {@code MessageQueue.IdleHandler} and {@code Process.killProcess}, none of which runs off device. + * + * The started count is kept for the process rather than armed per restart, because it is a census of what is on screen right now - unlike the capture flag this replaced, which said only that a capture had happened at some point and so answered this restart's wait with an hour-old backgrounding. + */ +final class RestartHandoff { + + /** True once the main looper has drained past the framework's report. Guarded by {@code this}. */ + private boolean drained; + + /** Activities of this process between onStart and onStop. Guarded by {@code this}. */ + private int startedActivities; + + /** + * Whether any activity of this process is started, which is the case a restart has to hand off for. + * + * @return true when at least one activity is between onStart and onStop; false is the normal loop, where the user saves by typing in CoGo and the framework already holds everything it needs + */ + synchronized boolean anyActivityStarted() { + return startedActivities > 0; + } + + /** Discards a drain from an earlier handoff, so only one requested from here on can end this wait. */ + synchronized void arm() { + drained = false; + } + + /** + * Waits for every activity to stop and then for the main looper to run what stopping queued. + * + * @param timeoutMillis + * upper bound across BOTH phases, not per phase; the caller kills the process either way, so this bounds how long a restart is delayed by an app that will not stop + * @param requestDrain + * invoked once, on the calling thread, the moment the last activity has stopped; the caller uses it to schedule the main-looper drain that {@link #onDrained} ends. Not invoked at all when the stop wait times out, since there would be nothing behind the report to drain + * @return true when every activity stopped and the drain landed inside the bound; false on timeout or interruption, which the caller reports rather than treating as a handoff + */ + boolean awaitHandoff(long timeoutMillis, Runnable requestDrain) { + long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; + if (!awaitAllStopped(deadlineNanos)) { + return false; + } + // Deliberately outside the monitor: the drain it schedules calls back in. + requestDrain.run(); + return awaitDrained(deadlineNanos); + } + + /** Counts an activity into the set a restart waits to empty. */ + synchronized void onActivityStarted() { + startedActivities++; + } + + /** + * Counts an activity out of that set, releasing a waiting restart once it is empty. + * + * Balanced against {@link #onActivityStarted} by the framework, which stops an activity before destroying it; clamped at zero anyway, since a count stuck above it would make every later restart pay the full timeout. + */ + synchronized void onActivityStopped() { + if (startedActivities > 0 && --startedActivities == 0) { + notifyAll(); + } + } + + /** Records that the main looper has run everything queued behind the last stop, ending any wait. */ + synchronized void onDrained() { + drained = true; + notifyAll(); + } + + /** + * @param deadlineNanos + * when to give up, on the {@link System#nanoTime} clock + * @return true once no activity is started, including when none was to begin with + */ + private synchronized boolean awaitAllStopped(long deadlineNanos) { + while (startedActivities > 0) { + if (!waitUntil(deadlineNanos)) { + return false; + } + } + return true; + } + + /** + * @param deadlineNanos + * when to give up, on the {@link System#nanoTime} clock + * @return true once a drain has landed, including one that landed before this call + */ + private synchronized boolean awaitDrained(long deadlineNanos) { + while (!drained) { + if (!waitUntil(deadlineNanos)) { + return false; + } + } + return true; + } + + /** + * @param deadlineNanos + * when to give up, on the {@link System#nanoTime} clock + * @return false when the deadline has passed or the wait was interrupted; the interrupt is re-flagged rather than propagated, since the restart is still owed a kill + */ + private synchronized boolean waitUntil(long deadlineNanos) { + long remainingMillis = (deadlineNanos - System.nanoTime()) / 1_000_000L; + if (remainingMillis <= 0) { + return false; + } + try { + wait(remainingMillis); + return true; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java new file mode 100644 index 0000000000..d75af414ef --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java @@ -0,0 +1,90 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.util.Log; + +/** + * The runtime's only logging entry point, under one tag so a device walk can follow a whole reload with a single logcat filter. The tag carries the same QB- prefix every Quick Build tag does, so one grep spans this process and CoGo's. + * + * Calls android.util.Log directly, because this AAR ships into arbitrary user apps and must not carry a logging dependency. Every call is guarded because android.util.Log is an unmocked stub in JVM unit tests and throws there; on device it never throws, so the guard costs nothing. + */ +final class RuntimeLog { + + /** The single logcat tag every runtime message carries. */ + static final String TAG = "QB-Runtime"; + + /** + * Logs at debug level, for the step-by-step detail of a reload. + * + * @param message + * the line to log; passed through unformatted + */ + static void d(String message) { + try { + Log.d(TAG, message); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + /** + * Logs at error level, for a failure that cost the user a reload. + * + * @param message + * the line to log; passed through unformatted + * @param error + * the cause to attach, printed with its stack trace; may be null + */ + static void e(String message, Throwable error) { + try { + Log.e(TAG, message, error); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + /** + * Logs at info level, for the milestones of a reload a device walk follows. + * + * @param message + * the line to log; passed through unformatted + */ + static void i(String message) { + try { + Log.i(TAG, message); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + /** + * Logs at warning level, for a degraded path the runtime recovered from. + * + * @param message + * the line to log; passed through unformatted + */ + static void w(String message) { + try { + Log.w(TAG, message); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + /** + * Logs at warning level with the swallowed cause attached. + * + * @param message + * the line to log; passed through unformatted + * @param error + * the cause to attach, printed with its stack trace; may be null + */ + static void w(String message, Throwable error) { + try { + Log.w(TAG, message, error); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + private RuntimeLog() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java new file mode 100644 index 0000000000..6c5d1c02f1 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java @@ -0,0 +1,136 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Activity; +import android.graphics.Color; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +/** + * Draws the translucent status banner just below the system status bar. + * + * It attaches to the window decor with a status-bar top margin, so the system bar stays untouched while the app's own chrome may be overlapped; this is an error surface. Rendering is stateless: {@link #render} makes the banner match the given {@link OverlayState} exactly, creating, updating or removing it. There is no separate clear call to forget, which is what makes a stuck banner impossible. + */ +final class StatusOverlay { + + /** View tag identifying the banner so re-renders update instead of stacking views. */ + private static final String VIEW_TAG = "com.itsaky.androidide.quickbuild.runtime.banner"; + + private static final int COLOR_BUILD_FAILED = 0xCCBF360C; + private static final int COLOR_CRASHED = 0xCCB71C1C; + private static final int COLOR_NEUTRAL = 0xCC37474F; + + /** + * Banner background color for a state kind. + * + * @param kind + * the state being rendered; anything but BUILD_FAILED and CRASHED, HIDDEN included, takes the neutral color + * @return an ARGB color, deliberately translucent so the app stays readable behind it + */ + private static int colorFor(OverlayState.Kind kind) { + switch (kind) { + case BUILD_FAILED: + case REINSTALL_PENDING: + return COLOR_BUILD_FAILED; + case CRASHED: + return COLOR_CRASHED; + default: + return COLOR_NEUTRAL; + } + } + + /** + * Makes the banner on {@code activity} match {@code state}, adding, updating or removing it. + * + * Must run on the main thread. Never throws: overlay failures are logged, not fatal. + * + * @param activity + * the activity whose decor view hosts the banner; null, or one without a window, is ignored + * @param state + * the state to render; a HIDDEN state removes the banner, while null is ignored rather than treated as HIDDEN + */ + void render(Activity activity, OverlayState state) { + if (activity == null || state == null) { + return; + } + try { + // The decor, not android.R.id.content: under edge-to-edge the content root + // consumes the insets, and the decor's action-bar container is a sibling + // that out-draws anything inside content, since elevation does not reorder + // across subtrees. + View decorView = activity.getWindow() != null ? activity.getWindow().getDecorView() : null; + if (!(decorView instanceof ViewGroup)) { + return; + } + ViewGroup decor = (ViewGroup) decorView; + TextView banner = decor.findViewWithTag(VIEW_TAG); + if (state.kind == OverlayState.Kind.HIDDEN) { + if (banner != null) { + decor.removeView(banner); + } + return; + } + if (banner == null) { + banner = createBanner(activity); + decor.addView(banner); + } + applyStatusBarInset(decor, banner); + banner.setBackgroundColor(colorFor(state.kind)); + banner.setText(state.text()); + banner.bringToFront(); + } catch (Throwable error) { + RuntimeLog.w("status overlay render failed", error); + } + } + + /** + * Sets the banner's top margin to the status-bar inset, so it starts just below the bar. + * + * Reads the inset directly because listener dispatch is consumed by the app's root and never reaches us. The deprecated accessor is the only one available at minSdk 28. + * + * @param decor + * the decor view the banner is attached to, the source of the insets + * @param banner + * the banner view whose layout params are updated in place, and only when the margin actually changed, to avoid a needless relayout on every render + */ + @SuppressWarnings("deprecation") + private void applyStatusBarInset(ViewGroup decor, TextView banner) { + android.view.WindowInsets insets = decor.getRootWindowInsets(); + int top = insets != null ? insets.getSystemWindowInsetTop() : 0; + ViewGroup.LayoutParams lp = banner.getLayoutParams(); + if (lp instanceof FrameLayout.LayoutParams + && ((FrameLayout.LayoutParams) lp).topMargin != top) { + ((FrameLayout.LayoutParams) lp).topMargin = top; + banner.setLayoutParams(lp); + } + } + + /** + * Builds the banner view; the caller sets its color and text. + * + * @param activity + * supplies the display density for the padding and elevation + * @return the tagged, full-width, top-anchored banner, not yet attached to anything + */ + private TextView createBanner(Activity activity) { + TextView banner = new TextView(activity); + banner.setTag(VIEW_TAG); + banner.setTextColor(Color.WHITE); + banner.setTextSize(12f); + banner.setMaxLines(6); + float density = activity.getResources().getDisplayMetrics().density; + final int padding = (int) (8 * density); + banner.setPadding(padding, padding, padding, padding); + // Sibling order is not enough: app bars carry elevation and draw above a plain + // later-added sibling, so out-elevate them. + banner.setElevation(16 * density); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.TOP); + banner.setLayoutParams(params); + return banner; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java new file mode 100644 index 0000000000..b960ee6439 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java @@ -0,0 +1,77 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Reads payload fds fully into memory, with a size cap. + * + * Dex bytes go straight into an InMemoryDexClassLoader; nothing lands in shared storage. The only disk the payload path touches is the app-private {@link PayloadPersistence} store and the extracted-assets cache. + */ +final class Streams { + + private static final int BUFFER_SIZE = 16 * 1024; + + /** + * Ceiling for {@link #readFully(InputStream)}, guarding against an OOM from a runaway payload. + * + * Binder does not size-limit a ParcelFileDescriptor, and payloads are read fully into memory. A legitimate payload is one app's dex, resources and assets, tens of MB even for a whole cold deploy, so hitting 256 MB always means something is wrong. + */ + static final int MAX_PAYLOAD_BYTES = 256 * 1024 * 1024; + + /** + * Closes {@code closeable} if non-null, swallowing any close failure. + * + * @param closeable + * the stream or fd to close; null is a no-op, so callers need not pre-check + */ + static void closeQuietly(AutoCloseable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (Exception ignored) { + // Nothing useful to do with a failed close. + } + } + } + + /** + * Reads {@code in} to exhaustion, capped at {@link #MAX_PAYLOAD_BYTES}. Does not close the stream; the caller owns it. + * + * @param in + * the payload stream, normally a fd handed over binder; read but never closed + * @return the whole stream as a fresh array, empty when the stream was already at its end + * @throws IOException + * on a read failure, or when the stream exceeds {@link #MAX_PAYLOAD_BYTES} + */ + static byte[] readFully(InputStream in) throws IOException { + return readFully(in, MAX_PAYLOAD_BYTES); + } + + /** + * Reads {@code in} to exhaustion. Does not close the stream; the caller owns it. + * + * @param in + * the stream to drain; read but never closed + * @param maxBytes + * inclusive ceiling on the total read; exactly {@code maxBytes} is fine + * @return the whole stream as a fresh array, empty when the stream was already at its end + * @throws IOException + * on a read failure, or at the first chunk that would carry the total past {@code maxBytes}, so it never buffers without bound + */ + static byte[] readFully(InputStream in, int maxBytes) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + if (out.size() + read > maxBytes) { + throw new IOException("stream exceeds the " + maxBytes + "-byte payload limit; rejecting rather than buffering it in memory"); + } + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + + private Streams() {} +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java new file mode 100644 index 0000000000..698b8c0548 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java @@ -0,0 +1,104 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the extractor's failure paths: unmountable destination dirs and the rename fallback. + * + * A POSIX rename cannot replace a directory with a file, so pre-planting a directory where a file entry must land drives the delete-and-retry fallback both ways: an empty dir lets the fallback succeed, a non-empty one makes extraction fail loudly and leave no temp file behind. + */ +class AssetExtractorFailurePathTest { + + private static String readFile(File file) throws IOException { + FileInputStream in = new FileInputStream(file); + try { + return new String(Streams.readFully(in), "UTF-8"); + } finally { + in.close(); + } + } + + private static InputStream zipWithEntry(String name, byte[] content) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bytes); + zip.putNextEntry(new ZipEntry(name)); + zip.write(content); + zip.closeEntry(); + zip.close(); + return new ByteArrayInputStream(bytes.toByteArray()); + } + + @TempDir + Path tempDir; + + @Test + void aDestDirBlockedByAFileThrows() throws IOException { + File blocked = tempDir.resolve("dest").toFile(); + Files.write(blocked.toPath(), "not a dir".getBytes("UTF-8")); + + IOException error = assertThrows(IOException.class, + () -> AssetExtractor.extract(zipWithEntry("a.txt", "x".getBytes("UTF-8")), + blocked)); + + assertThat(error).hasMessageThat().contains("cannot create asset dir"); + } + + @Test + void anEntryParentBlockedByAFileThrows() throws IOException { + File dest = tempDir.resolve("dest").toFile(); + assertThat(dest.mkdirs()).isTrue(); + Files.write(dest.toPath().resolve("sub"), "not a dir".getBytes("UTF-8")); + + IOException error = assertThrows(IOException.class, + () -> AssetExtractor.extract( + zipWithEntry("sub/a.txt", "x".getBytes("UTF-8")), dest)); + + assertThat(error).hasMessageThat().contains("cannot create dir"); + } + + @Test + void anUndeletableTargetFailsLoudlyAndLeavesNoTempFile() throws IOException { + File dest = tempDir.resolve("dest").toFile(); + File inTheWay = new File(dest, "a.txt"); + // A NON-empty directory: rename over it fails, delete fails, retry fails. + assertThat(new File(inTheWay, "child").mkdirs()).isTrue(); + + IOException error = assertThrows(IOException.class, + () -> AssetExtractor.extract( + zipWithEntry("a.txt", "x".getBytes("UTF-8")), dest)); + + assertThat(error).hasMessageThat() + .contains("cannot move extracted asset into place"); + assertThat(new File(dest, "a.txt.qb-tmp").exists()).isFalse(); + // The pre-existing content is untouched. + assertThat(new File(inTheWay, "child").isDirectory()).isTrue(); + } + + @Test + void renameFallbackReplacesAnEmptyDirectoryInTheWay() throws IOException { + File dest = tempDir.resolve("dest").toFile(); + File inTheWay = new File(dest, "a.txt"); + assertThat(inTheWay.mkdirs()).isTrue(); + + int count = AssetExtractor.extract( + zipWithEntry("a.txt", "fresh".getBytes("UTF-8")), dest); + + assertThat(count).isEqualTo(1); + assertThat(inTheWay.isFile()).isTrue(); + assertThat(readFile(inTheWay)).isEqualTo("fresh"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java new file mode 100644 index 0000000000..e2b4bb2587 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java @@ -0,0 +1,235 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AssetExtractorTest { + + private static String readFile(File file) throws IOException { + FileInputStream in = new FileInputStream(file); + try { + return new String(Streams.readFully(in), "UTF-8"); + } finally { + in.close(); + } + } + + private static InputStream zipOf(Map entries) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bytes); + for (Map.Entry entry : entries.entrySet()) { + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue()); + zip.closeEntry(); + } + zip.close(); + return new ByteArrayInputStream(bytes.toByteArray()); + } + + @TempDir + Path tempDir; + + @Test + void cumulativeMergeKeepsEarlierPayloadsFiles() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map first = new LinkedHashMap(); + first.put("message.txt", "hello".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-1"); + + Map second = new LinkedHashMap(); + second.put("data/levels.json", "{}".getBytes("UTF-8")); + int count = AssetExtractor.extractCumulative(zipOf(second), root, "fp-1"); + + // The second payload carries only its own delta; the first one's file must survive. + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + assertThat(count).isEqualTo(1); + assertThat(readFile(new File(assetsDir, "message.txt"))).isEqualTo("hello"); + assertThat(readFile(new File(assetsDir, "data/levels.json"))).isEqualTo("{}"); + } + + @Test + void cumulativeMergeOverwritesChangedFile() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map first = new LinkedHashMap(); + first.put("message.txt", "old".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-1"); + + Map second = new LinkedHashMap(); + second.put("message.txt", "new".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(second), root, "fp-1"); + + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + assertThat(readFile(new File(assetsDir, "message.txt"))).isEqualTo("new"); + } + + @Test + void cumulativeRejectsZipSlipEntry() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map entries = new LinkedHashMap(); + entries.put("../../evil.txt", "escaped".getBytes("UTF-8")); + + assertThrows(IOException.class, + () -> AssetExtractor.extractCumulative(zipOf(entries), root, "fp-1")); + + assertThat(new File(tempDir.toFile(), "evil.txt").exists()).isFalse(); + } + + @Test + void emptyZipExtractsNothing() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bytes); + // A zip must contain at least one entry to be written; use a directory-only zip. + zip.putNextEntry(new ZipEntry("emptydir/")); + zip.closeEntry(); + zip.close(); + File dest = tempDir.resolve("out").toFile(); + + int count = AssetExtractor.extract(new ByteArrayInputStream(bytes.toByteArray()), dest); + + assertThat(count).isEqualTo(0); + } + + @Test + void extractsFilesWithNestedDirectories() throws IOException { + Map entries = new LinkedHashMap(); + entries.put("data/levels.json", "{\"level\": 1}".getBytes("UTF-8")); + entries.put("img/icons/star.png", new byte[]{1, 2, 3}); + entries.put("top.txt", "hello".getBytes("UTF-8")); + File dest = tempDir.resolve("out").toFile(); + + int count = AssetExtractor.extract(zipOf(entries), dest); + + assertThat(count).isEqualTo(3); + assertThat(readFile(new File(dest, "data/levels.json"))).isEqualTo("{\"level\": 1}"); + assertThat(new File(dest, "img/icons/star.png").length()).isEqualTo(3); + assertThat(readFile(new File(dest, "top.txt"))).isEqualTo("hello"); + } + + @Test + void leavesNoTempFilesBehind() throws IOException { + Map entries = new LinkedHashMap(); + entries.put("a/b.txt", "x".getBytes("UTF-8")); + File dest = tempDir.resolve("out").toFile(); + + AssetExtractor.extract(zipOf(entries), dest); + + assertThat(new File(dest, "a").list()).asList().containsExactly("b.txt"); + } + + @Test + void markerRecordsTheBaselineFingerprint() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map entries = new LinkedHashMap(); + entries.put("a.txt", "x".getBytes("UTF-8")); + + AssetExtractor.extractCumulative(zipOf(entries), root, "fp-abc"); + + // The marker is what a later process compares against; it must hold the exact key. + assertThat(readFile(new File(root, AssetExtractor.BASELINE_MARKER))).isEqualTo("fp-abc"); + } + + @Test + void missingMarkerClearsPreexistingDir() throws IOException { + // A dir with no marker has unknown provenance - never serve it. + File root = tempDir.resolve("assets-root").toFile(); + File strayDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + strayDir.mkdirs(); + java.io.FileOutputStream out = new java.io.FileOutputStream(new File(strayDir, "stray.txt")); + out.write("unowned".getBytes("UTF-8")); + out.close(); + + Map entries = new LinkedHashMap(); + entries.put("fresh.txt", "owned".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(entries), root, "fp-1"); + + assertThat(new File(strayDir, "stray.txt").exists()).isFalse(); + assertThat(readFile(new File(strayDir, "fresh.txt"))).isEqualTo("owned"); + } + + @Test + void nullFingerprintIsRefused() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map entries = new LinkedHashMap(); + entries.put("a.txt", "x".getBytes("UTF-8")); + InputStream zip = zipOf(entries); + + assertThrows(IOException.class, + () -> AssetExtractor.extractCumulative(zip, root, null)); + + assertThat(AssetExtractor.currentDir(root).exists()).isFalse(); + } + + @Test + void overwritesExistingFiles() throws IOException { + File dest = tempDir.resolve("out").toFile(); + Map first = new LinkedHashMap(); + first.put("data/config.txt", "old".getBytes("UTF-8")); + AssetExtractor.extract(zipOf(first), dest); + + Map second = new LinkedHashMap(); + second.put("data/config.txt", "new".getBytes("UTF-8")); + AssetExtractor.extract(zipOf(second), dest); + + assertThat(readFile(new File(dest, "data/config.txt"))).isEqualTo("new"); + } + + @Test + void rebaselineClearsMergedAssets() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map first = new LinkedHashMap(); + first.put("stale.txt", "from the old baseline".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-old"); + + Map second = new LinkedHashMap(); + second.put("fresh.txt", "from the new baseline".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(second), root, "fp-new"); + + // The proxy rebuild changed the baseline: the old baseline's assets must not survive it. + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + assertThat(new File(assetsDir, "stale.txt").exists()).isFalse(); + assertThat(readFile(new File(assetsDir, "fresh.txt"))).isEqualTo("from the new baseline"); + } + + @Test + void rejectsZipSlipEntry() throws IOException { + Map entries = new LinkedHashMap(); + entries.put("../evil.txt", "escaped".getBytes("UTF-8")); + File dest = tempDir.resolve("out").toFile(); + + assertThrows(IOException.class, () -> AssetExtractor.extract(zipOf(entries), dest)); + + assertThat(new File(tempDir.toFile(), "evil.txt").exists()).isFalse(); + } + + @Test + void skipsDirectoryEntries() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bytes); + zip.putNextEntry(new ZipEntry("data/")); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("data/file.txt")); + zip.write("content".getBytes("UTF-8")); + zip.closeEntry(); + zip.close(); + File dest = tempDir.resolve("out").toFile(); + + int count = AssetExtractor.extract(new ByteArrayInputStream(bytes.toByteArray()), dest); + + assertThat(count).isEqualTo(1); + assertThat(readFile(new File(dest, "data/file.txt"))).isEqualTo("content"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BaselineGenerationTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BaselineGenerationTest.java new file mode 100644 index 0000000000..c941b88c94 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BaselineGenerationTest.java @@ -0,0 +1,68 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +/** + * The baseline-generation stamp parser: a stamped baseline boots at its stamp, and every malformed or missing stamp must fall back to 0 - the pre-stamp constant - or an APK from an older plugin would change behavior. + */ +class BaselineGenerationTest { + + @Test + void malformedStampIsGenerationZero() { + assertThat(BaselineGeneration.parse("")).isEqualTo(0L); + assertThat(BaselineGeneration.parse("garbage")).isEqualTo(0L); + assertThat(BaselineGeneration.parse("1.5")).isEqualTo(0L); + // Overflows a long. + assertThat(BaselineGeneration.parse("99999999999999999999")).isEqualTo(0L); + } + + @Test + void missingStampIsGenerationZero() { + assertThat(BaselineGeneration.parse(null)).isEqualTo(0L); + assertThat(BaselineGeneration.read(null)).isEqualTo(0L); + } + + @Test + void negativeStampIsGenerationZero() { + // The host's counter only hands out positive numbers; a negative stamp is + // corruption, and adopting it would accept payloads at or below generation 0. + assertThat(BaselineGeneration.parse("-3")).isEqualTo(0L); + } + + @Test + void parsesADecimalStamp() { + assertThat(BaselineGeneration.parse("7")).isEqualTo(7L); + assertThat(BaselineGeneration.parse("42")).isEqualTo(42L); + assertThat(BaselineGeneration.parse(String.valueOf(Long.MAX_VALUE))).isEqualTo(Long.MAX_VALUE); + } + + @Test + void readsTheStampFromAStream() { + InputStream in = new ByteArrayInputStream("9\n".getBytes(StandardCharsets.UTF_8)); + assertThat(BaselineGeneration.read(in)).isEqualTo(9L); + } + + @Test + void toleratesSurroundingWhitespace() { + // The asset is written by a Gradle task; a trailing newline from a future edit + // must not silently reset every baseline to 0. + assertThat(BaselineGeneration.parse(" 12\n")).isEqualTo(12L); + } + + @Test + void unreadableStreamIsGenerationZero() { + InputStream failing = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("boom"); + } + }; + assertThat(BaselineGeneration.read(failing)).isEqualTo(0L); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java new file mode 100644 index 0000000000..835ac763b8 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java @@ -0,0 +1,114 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Which generation the crash guard blames, now that a restart deploy's crash lands in a different process from the deploy. + * + * What each test would catch: blaming only the pending reload - the rule before this - makes every crash on a restart-booted generation invisible, which measured on an A56 as an app that crash-looped on the bad generation with no way out; blaming a generation that already reached the screen would poison the fallback the app just landed on; and blaming one a later deploy has superseded would write a marker for a generation nothing is running. + */ +class BootProbationTest { + + /** Stands for "no reload is awaiting its first frame", the state a fresh process boots in. */ + private static final long NO_PENDING_RELOAD = -1; + + @Test + void aBootedGenerationSupersededByALaterDeployIsNotBlamed() { + // A deploy landed on top of the booted generation without a resumed activity to hang a + // frame callback on, so nothing is pending - but 9 is no longer what is running, and a + // marker naming it would refuse a generation the app is not booting anyway. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 10)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aBootedGenerationThatReachedTheScreenIsNotBlamedForALaterCrash() { + // It got an activity up, so a fresh process booting it does not repeat whatever failed + // afterwards. This is also what stops a fallback boot from quarantining the very + // generation it fell back to. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + probation.proved(9); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 9)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aConfirmationForASupersededGenerationLeavesTheProbationStanding() { + // A late mark-good for 8 says nothing about whether 9 can reach the screen. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + probation.proved(8); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 9)).isEqualTo(9); + } + + @Test + void aCrashOnTheGenerationThisProcessBootedIsBlamedOnIt() { + // The defect this class exists for. A restart deploy persists and exits, so the process + // that runs its work has no reload pending and the guard used to see nothing at all - + // leaving the app to boot the same crashing generation on every launch, forever. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 9)).isEqualTo(9); + } + + @Test + void anUnstampedBaselineIsNotAGenerationWorthRefusing() { + // An older host plugin stamps no generation, so the baseline boots as 0 and the store + // reports it as the adopted one; generation 0 is the APK's own code either way. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(0); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 0)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aPendingReloadOutranksTheGenerationThisProcessBooted() { + // Both are live claims on the screen; the hot swap is the newer one, and it is the one + // whose classes the activity that just died was built from. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + assertThat(probation.generationToBlame(11, 11)).isEqualTo(11); + } + + @Test + void aProcessThatBootedTheInstalledCodeBlamesNothing() { + // The baked baseline is the floor a quarantine falls back to. Refusing it would leave + // the app nothing at all to boot. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(-1); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 0)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aProcessWithNothingAdoptedAndNothingPendingBlamesNothing() { + // The steady state: the app has been up for an hour and the user's own code throws. + // Quarantining a generation over that would cost them working code. + BootProbation probation = new BootProbation(); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 12)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aSecondBootFromTheStoreReplacesTheGenerationOnProbation() { + // One runtime per process, so this is defensive rather than a path - but a probation + // that accumulated would blame a generation two boots stale. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + probation.bootedFromStore(8); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 9)).isEqualTo(NO_PENDING_RELOAD); + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 8)).isEqualTo(8); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java new file mode 100644 index 0000000000..70129c3bae --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java @@ -0,0 +1,98 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class BuildStatusTest { + + @Test + void buildingWithAMissingOrUnparseableGenerationFallsBackToUnknown() { + assertThat(BuildStatus.parse("{\"kind\": \"building\"}").runningGeneration).isEqualTo(-1L); + assertThat(BuildStatus.parse("{\"kind\": \"building\", \"runningGeneration\": \"nope\"}").runningGeneration).isEqualTo(-1L); + } + + @Test + void malformedJsonThrows() { + assertThrows(IllegalArgumentException.class, () -> BuildStatus.parse("not json")); + assertThrows(IllegalArgumentException.class, () -> BuildStatus.parse(null)); + } + + @Test + void parsesBuildFailed() { + BuildStatus status = BuildStatus.parse( + "{\"kind\": \"build_failed\", \"message\": \"Unresolved reference: foo\"," + + " \"moreErrors\": \"2\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILD_FAILED); + assertThat(status.message).isEqualTo("Unresolved reference: foo"); + assertThat(status.moreErrors).isEqualTo(2); + } + + @Test + void parsesBuildFailedWithMissingMessageFields() { + BuildStatus status = BuildStatus.parse("{\"kind\": \"build_failed\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILD_FAILED); + assertThat(status.message).isNull(); + assertThat(status.moreErrors).isEqualTo(0); + } + + @Test + void parsesBuilding() { + BuildStatus status = BuildStatus.parse( + "{\"kind\": \"building\", \"runningGeneration\": \"5\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILDING); + assertThat(status.runningGeneration).isEqualTo(5L); + } + + @Test + void parsesBuildOk() { + BuildStatus status = BuildStatus.parse("{\"kind\": \"build_ok\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILD_OK); + } + + @Test + void parsesReinstallPendingAsKindOnly() { + BuildStatus status = BuildStatus.parse("{\"kind\": \"reinstall_pending\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_REINSTALL_PENDING); + assertThat(status.message).isNull(); + assertThat(status.moreErrors).isEqualTo(0); + } + + @Test + void positionFieldsFromAnOlderCoGoAreIgnored() { + // A CoGo predating the position-free build-status still sends file/line/column; the + // runtime has no use for them and must parse the rest of the message unchanged. + BuildStatus status = BuildStatus.parse( + "{\"kind\": \"build_failed\", \"file\": \"/project/Foo.kt\", \"line\": \"12\"," + + " \"column\": \"5\", \"message\": \"boom\", \"moreErrors\": \"1\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILD_FAILED); + assertThat(status.message).isEqualTo("boom"); + assertThat(status.moreErrors).isEqualTo(1); + assertThat(OverlayState.buildFailed(status).text()) + .isEqualTo("Build failed - app is running the last working version\nboom (+1 more)"); + } + + @Test + void unknownFieldsAreIgnored() { + BuildStatus status = BuildStatus.parse( + "{\"kind\": \"build_failed\", \"message\": \"x\", \"futureField\": {\"y\": 1}}"); + assertThat(status.message).isEqualTo("x"); + } + + @Test + void unknownKindParsesToNull() { + // The versioning contract: a newer CoGo may send kinds this runtime predates. + assertThat(BuildStatus.parse("{\"kind\": \"build_started\"}")).isNull(); + assertThat(BuildStatus.parse("{}")).isNull(); + } + + @Test + void unparseableNumbersFallBack() { + assertThat(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"moreErrors\": \"three\"}").moreErrors).isEqualTo(0); + // A negative extra-error count would render as nonsense; clamped to zero. + assertThat(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"moreErrors\": \"-3\"}").moreErrors).isEqualTo(0); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadataTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadataTest.java new file mode 100644 index 0000000000..c82bbb0227 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadataTest.java @@ -0,0 +1,52 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class DeployMetadataTest { + + @Test + void ignoresUnknownFields() { + // The host must be able to extend the schema without breaking installed apps - it + // writes changedAssets and reason, which this class does not read. + DeployMetadata meta = DeployMetadata.parse( + "{\"entryActivity\": \"com.example.app.MainActivity\"," + + " \"changedAssets\": [\"data/levels.json\", \"img/logo.png\"]," + + " \"reason\": \"mixed\", \"futureField\": {\"x\": 1}, \"count\": 3}"); + assertThat(meta.entryActivity).isEqualTo("com.example.app.MainActivity"); + assertThat(meta.restart).isFalse(); + } + + @Test + void malformedJsonThrows() { + assertThrows(IllegalArgumentException.class, () -> DeployMetadata.parse("not json")); + assertThrows(IllegalArgumentException.class, () -> DeployMetadata.parse(null)); + } + + @Test + void missingFieldsFallBackToSafeDefaults() { + DeployMetadata meta = DeployMetadata.parse("{}"); + assertThat(meta.entryActivity).isNull(); + assertThat(meta.restart).isFalse(); + } + + @Test + void parsesRestartFlag() { + // The CoGo side marks restart deploys with the STRING "true" (MiniJson + // strings-only convention); anything else must read as a plain hot-swap. + assertThat(DeployMetadata.parse("{\"restart\": \"true\"}").restart).isTrue(); + assertThat(DeployMetadata.parse("{\"restart\": \"false\"}").restart).isFalse(); + assertThat(DeployMetadata.parse("{\"restart\": true}").restart).isFalse(); + assertThat(DeployMetadata.parse("{\"reason\": \"code\"}").restart).isFalse(); + } + + @Test + void wrongFieldTypesFallBackToDefaults() { + DeployMetadata meta = DeployMetadata.parse( + "{\"entryActivity\": 42, \"restart\": [\"true\"]}"); + assertThat(meta.entryActivity).isNull(); + assertThat(meta.restart).isFalse(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java new file mode 100644 index 0000000000..64e35be36a --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java @@ -0,0 +1,121 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The containment rule of the API 30+ assets override. The framework resolves the paths passed to {@link DirectoryAssetsProvider#loadAssetFd} out of resource tables this process does not control, so refusing one that escapes the override directory is a security boundary, not a tidiness check. + */ +class DirectoryAssetsProviderTest { + + @TempDir + Path tempDir; + + @Test + void aDotDotEscapeIsRefused() { + File root = root(); + + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/../../secret.json"))) + .isFalse(); + } + + @Test + void aDotDotThatResolvesBackInsideIsServable() { + File root = root(); + + // Textually suspicious, canonically fine: the rule is about where the path lands, + // not about whether it spells "..". + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/../assets/levels.json"))) + .isTrue(); + } + + @Test + void aPathOutsideTheRootIsRefused() { + File root = root(); + + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(tempDir.toFile(), "secret.json"))) + .isFalse(); + } + + @Test + void aPathThatCannotBeCanonicalizedIsRefused() { + File root = root(); + + // An embedded NUL makes getCanonicalPath throw rather than answer. A path this + // process cannot resolve is one it cannot prove is contained, so it must not serve + // it - refusing is the safe direction, and the framework falls through. + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/le\0vels.json"))) + .isFalse(); + } + + @Test + void aPathUnderTheRootIsServable() { + File root = root(); + + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/data/levels.json"))) + .isTrue(); + } + + @Test + void aSiblingSharingTheRootsNamePrefixIsRefused() { + File root = root(); + + // Why the rule appends a separator before comparing: "/tmp/x/overrideEvil" starts + // with "/tmp/x/override" as text while being an unrelated directory. + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(tempDir.toFile(), "overrideEvil/secret.json"))) + .isFalse(); + } + + @Test + void aSymlinkOutOfTheRootIsRefused() throws IOException { + File root = root(); + File outside = new File(tempDir.toFile(), "outside"); + assertThat(outside.mkdirs()).isTrue(); + Files.createSymbolicLink(new File(root, "link").toPath(), outside.toPath()); + + // Canonicalization is what catches this: the path is textually under the root and + // resolves outside it. + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "link/secret.json"))) + .isFalse(); + } + + @Test + void loadAssetFdFallsThroughForAFileThisOverrideDoesNotCarry() { + DirectoryAssetsProvider provider = new DirectoryAssetsProvider(root()); + + // Null is the fall-through to the next provider and finally the baked-in APK, which + // is what makes the override additive. + assertThat(provider.loadAssetFd("assets/data/absent.json", 0)).isNull(); + } + + @Test + void loadAssetFdRefusesAnEscapingPathEvenWhenItResolvesToARealFile() throws IOException { + File root = root(); + File secret = new File(tempDir.toFile(), "secret.json"); + Files.write(secret.toPath(), new byte[]{'x'}); + + // The file exists and is readable, so null can only come from the containment + // check - which is the point: this is the guard wired into the framework hook. + DirectoryAssetsProvider provider = new DirectoryAssetsProvider(root); + assertThat(provider.loadAssetFd("assets/../../secret.json", 0)).isNull(); + } + + @Test + void theRootItselfIsNotInsideItself() { + File root = root(); + + assertThat(DirectoryAssetsProvider.isWithinRoot(root, root)).isFalse(); + } + + private File root() { + File root = new File(tempDir.toFile(), "override"); + assertThat(root.mkdirs()).isTrue(); + return root; + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java new file mode 100644 index 0000000000..f185a0a34d --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java @@ -0,0 +1,54 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class GenerationsTest { + + @Test + void acceptsStrictlyNewerGeneration() { + assertThat(Generations.accepts(0, 1)).isTrue(); + assertThat(Generations.accepts(41, 42)).isTrue(); + assertThat(Generations.accepts(41, 100)).isTrue(); + } + + // The stamped-baseline boot gate is pinned in PersistedSelectionTest, against the real + // PayloadStore seam - re-numbering accepts() cases here could not fail on that caller. + + @Test + void rejectsEqualGeneration() { + // A replayed deploy of the running generation must be dropped, not re-rendered. + assertThat(Generations.accepts(7, 7)).isFalse(); + assertThat(Generations.accepts(0, 0)).isFalse(); + } + + @Test + void rejectsOlderGeneration() { + assertThat(Generations.accepts(7, 6)).isFalse(); + assertThat(Generations.accepts(7, 0)).isFalse(); + assertThat(Generations.accepts(0, -1)).isFalse(); + } + + @Test + void rollbackAppliesWhileTheFailedGenerationIsStillTheLiveOne() { + assertThat(Generations.rollbackApplies(6, 6)).isTrue(); + assertThat(Generations.rollbackApplies(0, 0)).isTrue(); + } + + @Test + void rollbackDoesNotApplyOnceANewerPayloadHasLanded() { + // The case that motivated the rule: gen 6's recreate is posted to the main thread, + // gen 7 lands on a binder thread, then the posted recreate throws. Restoring gen 6's + // pre-apply snapshot would take the store back to gen 5 and undo gen 7. + assertThat(Generations.rollbackApplies(7, 6)).isFalse(); + assertThat(Generations.rollbackApplies(100, 41)).isFalse(); + } + + @Test + void rollbackDoesNotApplyToAGenerationTheStoreNeverReached() { + // Defensive rather than reachable, but the rule is an equality and should say so: + // a failure naming a generation ahead of the store owns nothing either. + assertThat(Generations.rollbackApplies(6, 7)).isFalse(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapAddAssetPathTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapAddAssetPathTest.java new file mode 100644 index 0000000000..8ac1637a73 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapAddAssetPathTest.java @@ -0,0 +1,24 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import org.junit.jupiter.api.Test; + +/** + * Pins that any addAssetPath reflective failure becomes an IOException naming the path. + * + * The deploy path needs that to roll the resource payload back instead of silently dropping it (the never-stale invariant). On the JVM the hidden AssetManager.addAssetPath cannot be invoked at all - the SDK stub omits it - which is a representative reflective failure; the success path exists only on a real API 28/29 device. + */ +class LegacyResourceSwapAddAssetPathTest { + + @Test + void aReflectiveFailureIsWrappedInAnIOExceptionNamingThePath() { + IOException error = assertThrows(IOException.class, + () -> LegacyResourceSwap.addAssetPath(null, "/data/x/gen-3.zip")); + + assertThat(error).hasMessageThat().contains("/data/x/gen-3.zip"); + assertThat(error.getCause()).isNotNull(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java new file mode 100644 index 0000000000..4b5d1837b4 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java @@ -0,0 +1,108 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the startup sweep of the API 28/29 relinked-apk cache. + * + * A mounted asset path can never be unmounted, so a relinked apk has to stay on disk for the life of the process that mounted it - which is why nothing deletes one during a session. Nothing survives that process's death either, so at the next startup every apk in the directory is garbage; without the sweep the cache grows by one relinked apk per deploy, forever, on the low-storage devices this whole path exists to serve. + */ +class LegacyResourceSwapSweepTest { + + @TempDir + File tempDir; + + @Test + void deletesEveryGenerationApk() throws IOException { + write("gen-1.zip"); + write("gen-2.zip"); + write("gen-17.zip"); + + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(3); + + assertThat(tempDir.listFiles()).isEmpty(); + } + + @Test + void ignoresDirectoriesThatLookLikeApks() throws IOException { + File lookalike = new File(tempDir, "gen-3.zip"); + assertThat(lookalike.mkdirs()).isTrue(); + + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(0); + + assertThat(lookalike.isDirectory()).isTrue(); + } + + @Test + void isBestEffortOverAnApkItCannotDelete() throws IOException { + // Cache space is the only thing at stake, so an undeletable file must not stop the + // sweep or the swap that follows it. + write("gen-1.zip"); + assertThat(tempDir.setWritable(false)).isTrue(); + try { + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(0); + + assertThat(new File(tempDir, "gen-1.zip").isFile()).isTrue(); + } finally { + // Or the temp-dir teardown inherits the problem. + tempDir.setWritable(true); + } + } + + @Test + void leavesEveryOtherFileAlone() throws IOException { + // The sweep runs over a shared cache subdirectory, so an over-broad delete would + // take out whatever else ends up beside the apks. + write("gen-1.zip"); + write("something-else.zip"); + write("gen-1.zip.partial"); + write("notes.txt"); + + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(1); + + assertThat(new File(tempDir, "something-else.zip").isFile()).isTrue(); + assertThat(new File(tempDir, "gen-1.zip.partial").isFile()).isTrue(); + assertThat(new File(tempDir, "notes.txt").isFile()).isTrue(); + } + + @Test + void onAMissingDirectoryItIsANoOp() { + // API 30+ never creates the directory, and neither does a first run. + assertThat(LegacyResourceSwap.deleteStaleApks(new File(tempDir, "never-created"))) + .isEqualTo(0); + } + + @Test + void sweepsExactlyWhatWriteResourceApkProduces() throws IOException { + // Pins the two halves together: a rename of the written file that the sweep's + // prefix/suffix did not follow would leak every apk silently. + File written = LegacyResourceSwap.writeResourceApk( + new java.io.ByteArrayInputStream("apk".getBytes(StandardCharsets.UTF_8)), tempDir, 9); + + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(1); + + assertThat(written.exists()).isFalse(); + } + + @Test + void theCacheDirNameMatchesTheOneResourceStoreWritesTo() throws Exception { + // The sweep is driven from the runtime, which cannot see ResourceStore's private + // constant; a drift between the two would silently sweep nothing. + Field field = ResourceStore.class.getDeclaredField("LEGACY_TABLE_DIR"); + field.setAccessible(true); + + assertThat(LegacyResourceSwap.TABLE_DIR).isEqualTo(field.get(null)); + } + + private void write(String name) throws IOException { + Files.write(new File(tempDir, name).toPath(), "x".getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java new file mode 100644 index 0000000000..6d2b61dd99 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java @@ -0,0 +1,73 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Random; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the JVM-testable half of the API 28/29 shim: writing the relinked resource apk to disk. + * + * The payload is already the full relinked apk, so persisting it is a plain byte copy and must not be re-wrapped into a synthetic zip. The reflective addAssetPath and the resource cache flush are device-only and are not exercised here. + */ +class LegacyResourceSwapTest { + + @TempDir + File tempDir; + + @Test + void createsMissingDirectories() throws IOException { + File nested = new File(new File(tempDir, "a"), "b"); + byte[] apk = "apk-bytes".getBytes("UTF-8"); + + File zip = LegacyResourceSwap.writeResourceApk(new ByteArrayInputStream(apk), nested, 0); + + assertThat(zip.isFile()).isTrue(); + assertThat(Files.readAllBytes(zip.toPath())).isEqualTo(apk); + } + + @Test + void distinctFilePerGeneration() throws IOException { + byte[] first = "gen one apk".getBytes("UTF-8"); + byte[] second = "gen two apk - different".getBytes("UTF-8"); + + File zipOne = LegacyResourceSwap.writeResourceApk(new ByteArrayInputStream(first), tempDir, 1); + File zipTwo = LegacyResourceSwap.writeResourceApk(new ByteArrayInputStream(second), tempDir, 2); + + assertThat(zipOne.getAbsolutePath()).isNotEqualTo(zipTwo.getAbsolutePath()); + assertThat(Files.readAllBytes(zipOne.toPath())).isEqualTo(first); + assertThat(Files.readAllBytes(zipTwo.toPath())).isEqualTo(second); + } + + @Test + void uncreatableDirectoryThrowsInsteadOfSilentlyDropping() throws IOException { + // A dir path shadowed by an existing FILE cannot be created; the shim must throw + // (deploy rolls back) rather than lose the resource payload (never-stale). + File shadow = new File(tempDir, "shadow"); + assertThat(shadow.createNewFile()).isTrue(); + + assertThrows(IOException.class, () -> LegacyResourceSwap + .writeResourceApk(new ByteArrayInputStream(new byte[]{1}), shadow, 1)); + } + + @Test + void writesTheApkBytesUnmodified() throws IOException { + // A wrapping path would re-encode the input into a + // synthetic zip entry, so a naive "it produced *a* zip" assertion would not have + // caught the content being wrong. This asserts byte-for-byte identity with what + // aapt2 link actually produced. + byte[] apk = new byte[64 * 1024]; + new Random(7).nextBytes(apk); + + File zip = LegacyResourceSwap.writeResourceApk(new ByteArrayInputStream(apk), tempDir, 3); + + assertThat(zip.getName()).isEqualTo("gen-3.zip"); + assertThat(Files.readAllBytes(zip.toPath())).isEqualTo(apk); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouterTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouterTest.java new file mode 100644 index 0000000000..40602e4013 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouterTest.java @@ -0,0 +1,86 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class LoaderRouterTest { + + private final ClassLoader defaultLoader = getClass().getClassLoader(); + + @Test + void fallsBackWhenClassNotInPayloadChain() { + ClassLoader payload = new ServingLoader("com.example.Other"); + assertThat(LoaderRouter.pick(defaultLoader, payload, "com.example.UserActivity")) + .isSameInstanceAs(defaultLoader); + } + + @Test + void fallsBackWhenNoPayloadIsLive() { + // Inert runtime (no baseline loaded): the app must behave like a normal app. + assertThat(LoaderRouter.pick(defaultLoader, null, "com.example.UserActivity")) + .isSameInstanceAs(defaultLoader); + } + + @Test + void nonCnfeProbeFailuresPropagate() { + // The factory's own catch handles these by RE-INSTANTIATING through the + // default loader; swallowing here would weaken that fallback. Pin it. + ClassLoader payload = new BrokenLoader(); + assertThrows(NoClassDefFoundError.class, + () -> LoaderRouter.pick(defaultLoader, payload, "com.example.UserActivity")); + } + + @Test + void payloadWinsWhenBothLoadersServeTheClass() { + // Never-stale: whenever the payload chain can serve the class, it must be + // the one that does - even for names the default loader also knows. + ClassLoader payload = new ServingLoader("java.lang.Runnable"); + assertThat(LoaderRouter.pick(defaultLoader, payload, "java.lang.Runnable")) + .isSameInstanceAs(payload); + } + + @Test + void picksPayloadWhenItServesTheClass() { + ClassLoader payload = new ServingLoader("com.example.UserActivity"); + assertThat(LoaderRouter.pick(defaultLoader, payload, "com.example.UserActivity")) + .isSameInstanceAs(payload); + } + + private static final class BrokenLoader extends ClassLoader { + + BrokenLoader() { + super(null); + } + + @Override + protected Class findClass(String name) { + throw new NoClassDefFoundError("corrupt payload entry: " + name); + } + } + + /** + * Serves exactly one class name, backed by a stand-in Class object. + * + * Anything else its parent chain misses comes back as a ClassNotFoundException. + */ + private static final class ServingLoader extends ClassLoader { + + private final String served; + + ServingLoader(String served) { + // Bootstrap parent: framework-style names still resolve parent-first. + super(null); + this.served = served; + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + if (name.equals(served)) { + return Runnable.class; + } + throw new ClassNotFoundException(name); + } + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ManifestAppComponentFactoryTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ManifestAppComponentFactoryTest.java new file mode 100644 index 0000000000..21742ef956 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ManifestAppComponentFactoryTest.java @@ -0,0 +1,47 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import javax.xml.parsers.DocumentBuilderFactory; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Attr; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.NodeList; + +/** + * Pins that the runtime AAR manifest declares no {@code android:appComponentFactory}. + * + * If it did, a debuggable app also pulling androidx.core would fail manifest merge at {@code processDebugMainManifest} before the proxy build's merged-manifest transform runs - the failure that once killed Quick Build provisioning for every app template. The proxy app build owns the factory; the XML is parsed so this comment's mention cannot trip the check. + */ +class ManifestAppComponentFactoryTest { + + /** + * Resolves this module's src/main/AndroidManifest.xml from the test working directory. + * + * Gradle runs unit tests with the working directory at the module root, so no search is needed, and no module path is hardcoded that a module move would silently invalidate. + * + * @return the manifest file; a miss throws rather than letting the test pass vacuously + */ + private static File locateManifest() { + File manifest = new File(System.getProperty("user.dir"), "src/main/AndroidManifest.xml"); + if (!manifest.isFile()) { + throw new IllegalStateException("no src/main/AndroidManifest.xml under " + manifest.getParent()); + } + return manifest; + } + + @Test + void aarManifestDoesNotDeclareAppComponentFactory() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + NodeList elements = factory.newDocumentBuilder().parse(locateManifest()).getElementsByTagName("*"); + for (int i = 0; i < elements.getLength(); i++) { + NamedNodeMap attrs = elements.item(i).getAttributes(); + for (int j = 0; j < attrs.getLength(); j++) { + Attr attr = (Attr) attrs.item(j); + assertThat(attr.getLocalName()).isNotEqualTo("appComponentFactory"); + } + } + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonHardeningTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonHardeningTest.java new file mode 100644 index 0000000000..a53e7a9666 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonHardeningTest.java @@ -0,0 +1,142 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Covers the parser's behaviour on hostile and near-miss input. + * + * The documents come over binder from CoGo, so every rejection has to be the IllegalArgumentException the class contracts to throw: an Error escapes the callers' catch clauses, and a value dropped without being checked is indistinguishable from a key the host never sent. + */ +class MiniJsonHardeningTest { + + /** + * Builds a document nesting {@code levels} objects inside the top-level one. + * + * @param levels + * how many nested objects to wrap around the innermost value + * @return the document text + */ + private static String nested(int levels) { + StringBuilder json = new StringBuilder("{\"a\":"); + for (int i = 0; i < levels; i++) { + json.append("{\"a\":"); + } + json.append("\"deep\""); + for (int i = 0; i < levels; i++) { + json.append('}'); + } + return json.append('}').toString(); + } + + @Test + void aDuplicateKeyKeepsTheLastValue() { + // The documented contract, and the one a map insertion helper can silently + // invert: with putIfAbsent this reads "first" and every other test stays green. + Map obj = MiniJson.parseObject("{\"a\":\"first\",\"a\":\"second\"}"); + + assertThat(obj.get("a")).isEqualTo("second"); + } + + @Test + void anUncheckedLiteralIsRejectedRatherThanDroppedSilently() { + // Every one of these parses cleanly without the shape check, leaving the key + // absent - which a caller reads as "the host did not send it". + assertRejects("{\"b\":qqq}"); + assertRejects("{\"b\":tru}"); + assertRejects("{\"b\":TRUE}"); + assertRejects("{\"b\":nul}"); + assertRejects("{\"b\":01}"); + assertRejects("{\"b\":1.}"); + assertRejects("{\"b\":.5}"); + assertRejects("{\"b\":+1}"); + assertRejects("{\"b\":-}"); + assertRejects("{\"b\":1e}"); + assertRejects("{\"b\":1e+}"); + assertRejects("{\"b\":1e5x}"); + assertRejects("{\"b\":0x1f}"); + assertRejects("{\"b\":NaN}"); + assertRejects("{\"b\":Infinity}"); + assertRejects("{\"b\":1d}"); + assertRejects("{\"b\":\u0661}"); + } + + @Test + void aSignedUnicodeEscapeIsRejected() { + // Integer.parseInt(hex, 16) accepts a sign, so these decode to 0x41 and -0x41 + // unless the four chars are checked as digits first. + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\":\"\\u+041\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\":\"\\u-041\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\":\"\\u 041\"}")); + } + + @Test + void deepNestingInsideArraysIsCappedToo() { + StringBuilder json = new StringBuilder("{\"a\":"); + for (int i = 0; i < 20000; i++) { + json.append('['); + } + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject(json.toString())); + } + + @Test + void deepNestingIsRejectedAsBadInputNotAsAnError() { + // Without a depth cap this raises StackOverflowError, which is an Error and so + // escapes assertThrows(IllegalArgumentException) and every caller's catch. + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject(nested(20000))); + + assertThat(error).hasMessageThat().contains("nesting deeper than"); + } + + @Test + void manySiblingsAreNotMistakenForDepth() { + // The cap counts open levels, not levels ever opened; a flat document with far + // more than the cap's worth of nested-but-closed values must still parse. + StringBuilder json = new StringBuilder("{\"keep\":\"v\""); + for (int i = 0; i < 500; i++) { + json.append(",\"n").append(i).append("\":{\"x\":[\"y\"]}"); + } + json.append('}'); + + assertThat(MiniJson.parseObject(json.toString()).get("keep")).isEqualTo("v"); + } + + @Test + void nestingUpToTheCapStillParses() { + // 63 nested objects plus the top-level one is exactly the cap. + assertThat(MiniJson.parseObject(nested(63))).isEmpty(); + } + + @Test + void unicodeEscapesStillDecodeAcrossTheHexRange() { + Map obj = MiniJson.parseObject( + "{\"a\":\"\\u0041\\u00ff\\u00FF\\uabcd\\uABCD\\u0061\"}"); + + assertThat(obj.get("a")).isEqualTo("A\u00ff\u00FF\uabcd\uABCD\u0061"); + } + + @Test + void wellFormedLiteralsAreStillConsumedAndDropped() { + // The shape check must not start rejecting the numbers a real document carries. + Map obj = MiniJson.parseObject("{\"a\":0,\"b\":-0,\"c\":12,\"d\":-1.5," + + "\"e\":1e3,\"f\":1E+3,\"g\":-2.5e-4,\"h\":true,\"i\":false,\"j\":null," + + "\"keep\":\"v\"}"); + + assertThat(obj.keySet()).containsExactly("keep"); + } + + /** + * Asserts {@code json} is refused as malformed rather than parsed with a key dropped. + * + * @param json + * the document to reject + */ + private void assertRejects(String json) { + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject(json), + "expected " + json + " to be rejected"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonSeparatorAndLiteralTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonSeparatorAndLiteralTest.java new file mode 100644 index 0000000000..40ed10cc19 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonSeparatorAndLiteralTest.java @@ -0,0 +1,57 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Covers the hand-rolled parser's separator and literal-token edges. + * + * A missing comma, a value that starts on a separator, and literals terminated by whitespace, ']' or end-of-input. Every malformed input must throw IllegalArgumentException - the bad-payload contract - rather than parse into something plausible. + */ +class MiniJsonSeparatorAndLiteralTest { + + @Test + void aBracketTerminatedLiteralInsideAnArrayIsDropped() { + Map obj = MiniJson.parseObject("{\"a\":[1]}"); + assertThat(obj).containsKey("a"); + assertThat((Iterable) obj.get("a")).isEmpty(); + } + + @Test + void aLiteralRunningToEndOfInputThrows() { + // skipLiteral consumes "true" to the end; the object is then unterminated. + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject("{\"a\":true")); + assertThat(error).hasMessageThat().contains("unexpected end of input"); + } + + @Test + void arrayElementsWithoutACommaThrow() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject("{\"a\":[\"x\" \"y\"]}")); + assertThat(error).hasMessageThat().contains("expected ',' or ']'"); + } + + @Test + void aValueStartingOnASeparatorThrows() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject("{\"a\":,}")); + assertThat(error).hasMessageThat().contains("unexpected character"); + } + + @Test + void aWhitespaceTerminatedLiteralIsDropped() { + Map obj = MiniJson.parseObject("{\"a\":true }"); + assertThat(obj).isEmpty(); + } + + @Test + void objectEntriesWithoutACommaThrow() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject("{\"a\":\"b\" \"c\":\"d\"}")); + assertThat(error).hasMessageThat().contains("expected ',' or '}'"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonTest.java new file mode 100644 index 0000000000..e26d2a5427 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonTest.java @@ -0,0 +1,77 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MiniJsonTest { + + @Test + void decodesEscapes() { + Map obj = MiniJson.parseObject( + "{\"e\": \"q\\\"b\\\\s\\/f\\nn\\tt\\rr\\bb\\ff\\u0041u\"}"); + assertThat(obj.get("e")).isEqualTo("q\"b\\s/f\nn\tt\rr\bb\ffAu"); + } + + @Test + void dropsNestedObjectsButKeepsFollowingFields() { + Map obj = MiniJson.parseObject( + "{\"nested\": {\"deep\": {\"x\": [1, \"s\"]}}, \"after\": \"v\"}"); + assertThat(obj.keySet()).containsExactly("after"); + } + + @Test + void dropsNumbersBooleansAndNulls() { + Map obj = MiniJson.parseObject( + "{\"n\": 42, \"f\": -1.5e3, \"t\": true, \"z\": null, \"keep\": \"v\"}"); + assertThat(obj.keySet()).containsExactly("keep"); + assertThat(obj.get("keep")).isEqualTo("v"); + } + + @Test + void keepsOnlyStringElementsInsideArrays() { + Map obj = MiniJson.parseObject( + "{\"a\": [\"keep\", 1, true, null, {\"o\": 1}, [\"inner\"], \"also\"]}"); + assertThat(obj.get("a")).isEqualTo(Arrays.asList("keep", "also")); + } + + @Test + void parsesEmptyObjectAndEmptyArray() { + assertThat(MiniJson.parseObject("{}")).isEmpty(); + assertThat(MiniJson.parseObject("{\"a\": []}").get("a")) + .isEqualTo(Arrays.asList()); + } + + @Test + void parsesStringsAndStringArrays() { + Map obj = MiniJson.parseObject( + "{\"a\": \"hello\", \"b\": [\"x\", \"y\"]}"); + assertThat(obj.get("a")).isEqualTo("hello"); + assertThat(obj.get("b")).isEqualTo(Arrays.asList("x", "y")); + } + + @Test + void throwsOnMalformedInput() { + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject(null)); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("[]")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\"")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\" \"b\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"unterminated}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"v\"} trailing")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"bad\\q\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"\\u00ZZ\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"\\u0\"}")); + } + + @Test + void toleratesWhitespaceEverywhere() { + Map obj = MiniJson.parseObject( + " \n\t{ \"a\" :\n\"v\" ,\r\n \"b\" : [ \"x\" , \"y\" ] } \n"); + assertThat(obj.get("a")).isEqualTo("v"); + assertThat(obj.get("b")).isEqualTo(Arrays.asList("x", "y")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OfflineNetworkGuardTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OfflineNetworkGuardTest.java new file mode 100644 index 0000000000..1240c764db --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OfflineNetworkGuardTest.java @@ -0,0 +1,61 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import java.io.File; +import java.util.List; +import org.appdevforall.cotg.quickbuild.testfixtures.OfflineGuard; +import org.junit.jupiter.api.Test; + +/** + * Fails if any production class in the `:quickbuild:runtime` AAR references a network API. + * + * Covers ADFA-4128 offline-test-plan touchpoints 7-10. The AAR is what generated proxy apps embed to bind to CoGo and hot-reload payloads over binder IPC, so it must be provably network-free. The scan reads compiled constant pools and names the offending class plus constant; it runs in the normal `test` task, so a regression is caught in CI, not on a device. + */ +class OfflineNetworkGuardTest { + + private static List bannedHits(byte[] bytes) { + List hits = new java.util.ArrayList<>(); + for (String banned : OfflineGuard.INSTANCE.getBANNED()) { + if (OfflineGuard.INSTANCE.containsAscii(bytes, banned)) { + hits.add(banned); + } + } + return hits; + } + + /** + * Proves the detector fires on banned bytes and stays quiet on local-URL APIs. + * + * Without it, a green scan could be a scanner that can never fire. `java/net/URL`, `URI` and `URLClassLoader` are absent from this module today and absent from {@link OfflineGuard#BANNED}, so adding one for a local `file:` URI would not trip the test. + */ + @Test + void detectorFiresOnBannedBytesAndNotOnAllowedBytes() { + byte[] banned = "prefix Lokhttp3/OkHttpClient; and java/net/Socket suffix".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + assertThat(bannedHits(banned)).containsExactly("okhttp3/", "java/net/Socket"); + + byte[] allowed = "Ljava/net/URL; Ljava/net/URLClassLoader; Ljava/net/URI;".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + assertThat(bannedHits(allowed)).isEmpty(); + } + + @Test + void productionClassesReferenceNoNetworkApis() { + File buildDir = OfflineGuard.INSTANCE.moduleBuildDir(getClass()); + List classFiles = OfflineGuard.INSTANCE.productionClassFiles(buildDir); + + // Anti-vacuous: a mis-location must fail loudly, never pass by scanning nothing. + assertWithMessage("no production .class files found under " + buildDir + " -- guard self-location is broken") + .that(classFiles) + .isNotEmpty(); + + List violations = OfflineGuard.INSTANCE.scanForBannedReferences(buildDir, classFiles); + assertWithMessage( + "Quick Build must be network-free offline, but production classes reference banned" + + " network APIs:\n - " + + String.join("\n - ", violations) + + "\n(scanned " + classFiles.size() + " classes under " + buildDir + ")") + .that(violations) + .isEmpty(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java new file mode 100644 index 0000000000..34336897dc --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java @@ -0,0 +1,91 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class OverlayStateTest { + + @Test + void buildFailedNeverNamesAnErrorLocation() { + // Locating an error is CoGo's job (Build Output); the overlay is a stale-app warning, + // so no file path reaches the device even when CoGo knows one. + OverlayState state = OverlayState.buildFailed(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"file\": \"/p/src/Foo.kt\", \"line\": \"12\"," + + " \"message\": \"boom\"}")); + assertThat(state.text()).contains("boom"); + assertThat(state.text()).doesNotContain("Foo.kt"); + assertThat(state.text()).doesNotContain("12"); + } + + @Test + void buildFailedSaysTheAppRunsTheLastWorkingVersion() { + OverlayState state = OverlayState.buildFailed(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"message\": \"Unresolved reference: foo\"}")); + // The honesty line is the point of the overlay: never stale. + assertThat(state.text()).contains("running the last working version"); + assertThat(state.text()).contains("Unresolved reference: foo"); + assertThat(state.isError()).isTrue(); + } + + @Test + void buildFailedShowsTheExtraErrorCount() { + OverlayState state = OverlayState.buildFailed(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"message\": \"first\", \"moreErrors\": \"2\"}")); + assertThat(state.text()).contains("(+2 more)"); + } + + @Test + void buildingSaysWhichGenerationIsStillOnScreen() { + OverlayState state = OverlayState.building(4L); + assertThat(state.text()).contains("gen 4"); + assertThat(state.isBuilding()).isTrue(); + // Not an error - there is no failure yet. + assertThat(state.isError()).isFalse(); + } + + @Test + void buildingWithAnUnknownGenerationStillRendersHonestly() { + OverlayState state = OverlayState.building(-1L); + assertThat(state.text()).doesNotContain("gen -1"); + assertThat(state.text()).contains("one reload behind"); + } + + @Test + void crashedSaysTheAppRunsTheLastWorkingVersionAndCarriesTheSummary() { + OverlayState state = OverlayState.crashed("java.lang.NullPointerException\n at Foo.bar"); + assertThat(state.text()).contains("running the last working version"); + assertThat(state.text()).contains("NullPointerException"); + assertThat(state.isError()).isTrue(); + } + + @Test + void hiddenRendersNothing() { + OverlayState state = OverlayState.hidden(); + assertThat(state.kind).isEqualTo(OverlayState.Kind.HIDDEN); + assertThat(state.text()).isEmpty(); + assertThat(state.isError()).isFalse(); + } + + @Test + void onlyBuildingIsBuilding() { + assertThat(OverlayState.hidden().isBuilding()).isFalse(); + assertThat(OverlayState.crashed("x").isBuilding()).isFalse(); + } + + @Test + void reinstallPendingIsClearedBySuccessLikeAnyError() { + // isError() is what makes a later build_ok take the banner down; without it the + // banner would outlive the recovery it asks for. + assertThat(OverlayState.reinstallPending().isError()).isTrue(); + } + + @Test + void reinstallPendingSendsTheUserBackToCoGo() { + // The user watching this app is the one person CoGo's own signals cannot reach; + // this banner is the recovery instruction, plus the standard honesty line. + OverlayState state = OverlayState.reinstallPending(); + assertThat(state.text()).contains("Code on the Go"); + assertThat(state.text()).contains("running the last working version"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java new file mode 100644 index 0000000000..396aaf76c4 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java @@ -0,0 +1,53 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Covers the banner-text edges when a build_failed status arrives only partly filled in. + * + * The wire schema makes message and moreErrors both optional, so the rendered text must degrade cleanly on any subset instead of printing "null" or leaving dangling separators. + */ +class OverlayStateTextEdgeTest { + + private static BuildStatus failed(String json) { + return BuildStatus.parse(json); + } + + @Test + void buildFailedWithADetailAppendsItUnderTheHeadline() { + OverlayState state = OverlayState + .buildFailed(failed("{\"kind\":\"build_failed\",\"message\":\"boom\"}")); + + assertThat(state.text()).isEqualTo( + "Build failed - app is running the last working version\nboom"); + } + + @Test + void buildFailedWithMoreErrorsAppendsTheCount() { + OverlayState state = OverlayState.buildFailed( + failed("{\"kind\":\"build_failed\",\"message\":\"boom\",\"moreErrors\":\"3\"}")); + + assertThat(state.text()).isEqualTo( + "Build failed - app is running the last working version\nboom (+3 more)"); + } + + @Test + void buildFailedWithNoDetailRendersOnlyTheHeadline() { + // Nothing to name, so no dangling separator and no orphan "(+N more)" either. + OverlayState state = OverlayState + .buildFailed(failed("{\"kind\":\"build_failed\",\"moreErrors\":\"3\"}")); + + assertThat(state.text()) + .isEqualTo("Build failed - app is running the last working version"); + } + + @Test + void crashedWithoutDetailRendersOnlyTheHeadline() { + OverlayState state = OverlayState.crashed(null); + + assertThat(state.text()) + .isEqualTo("New code crashed - app is running the last working version"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java new file mode 100644 index 0000000000..3df43f2c0f --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java @@ -0,0 +1,248 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the property that makes the store safe to boot from: one generation's dex, resources and assets are published together or not at all. + * + * The failure this guards is not a lost deploy but a mixed one - generation N's dex paired with generation N-1's resources - which installs code against a table that never matched it. The app then throws Resources$NotFoundException during startup, before the runtime can bind, so it cannot be reported and CoGo cannot correct it. Every test here forces an IO failure part-way through a persist and asserts the store still serves exactly one whole generation. + */ +class PayloadPersistenceAtomicSetTest { + + private static final String FP = "baseline-fp"; + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + @TempDir + File temp; + + @Test + void aLaterPersistCollectsWhatATornWriteLeftBehind() throws IOException { + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + blockWrite(store, PayloadPersistence.KIND_ARSC, 2); + assertThrows(IOException.class, () -> store.persist(2, FP, bytes("dex2"), bytes("arsc2"), null)); + + store.persist(3, FP, bytes("dex3"), null, null); + + // gen 2's orphan dex is unreferenced and older than the published generation. + assertThat(payload(store, PayloadPersistence.KIND_DEX, 2).exists()).isFalse(); + assertThat(store.load(FP).dex).isEqualTo(bytes("dex3")); + } + + @Test + void aMetaNamingAMissingFileIsCorruptionNotAnAbsentKind() throws IOException { + // Serving the subset that happens to be present is exactly the mixed store this + // layout exists to prevent, so a dangling reference must discard the store. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), bytes("arsc1"), null); + assertThat(payload(store, PayloadPersistence.KIND_ARSC, 1).delete()).isTrue(); + + assertThat(store.load(FP)).isNull(); + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void anOldFlatLayoutStoreIsDiscardedRatherThanAdopted() throws IOException { + // What an already-installed proxy app has on disk: fixed filenames and a + // meta.json with no layout tag. Its cross-kind consistency was never + // guaranteed, so it cannot be trusted; absent is safe, since the app then runs + // the code its installed APK carries. + File dir = new File(temp, "payload"); + assertThat(dir.mkdirs()).isTrue(); + Files.write(new File(dir, "payload.dex").toPath(), bytes("old dex")); + Files.write(new File(dir, "resources.arsc").toPath(), bytes("old arsc")); + Files.write(new File(dir, PayloadPersistence.META_FILE).toPath(), + bytes("{\"generation\":\"7\",\"fingerprint\":\"" + FP + "\"}")); + PayloadPersistence store = new PayloadPersistence(dir); + + assertThat(store.load(FP)).isNull(); + assertThat(dir.exists()).isFalse(); + } + + @Test + void aStoreClaimingANewerGenerationIsNotInheritedFrom() throws IOException { + // The host's counter restarts if the project's state dir is wiped while the app + // stays installed, so a low generation can arrive at a store claiming a high one. + // Carrying gen 40's resources forward would pair this dex with a LATER build's + // table - the one direction cumulative deltas do not make safe. + PayloadPersistence store = store(); + store.persist(40, FP, bytes("dex40"), bytes("arsc40"), null); + + store.persist(1, FP, bytes("dex1"), null, null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(1); + assertThat(loaded.dex).isEqualTo(bytes("dex1")); + assertThat(loaded.arscFile).isNull(); + assertThat(payload(store, PayloadPersistence.KIND_ARSC, 40).exists()).isFalse(); + } + + @Test + void aTornPersistNeverPairsOneGenerationsDexWithAnothersResources() throws IOException { + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), bytes("arsc1"), null); + blockWrite(store, PayloadPersistence.KIND_ARSC, 2); + + assertThrows(IOException.class, () -> store.persist(2, FP, bytes("dex2"), bytes("arsc2"), null)); + + // The whole of generation 1, or nothing. Not gen 2's dex against gen 1's table, + // and not a discarded store either - gen 1 is still complete and bootable. + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(1); + assertThat(loaded.dex).isEqualTo(bytes("dex1")); + assertThat(Files.readAllBytes(loaded.arscFile.toPath())).isEqualTo(bytes("arsc1")); + } + + @Test + void aTornPersistOfTheFirstEverGenerationLeavesNoStoreAtAll() throws IOException { + PayloadPersistence store = store(); + blockWrite(store, PayloadPersistence.KIND_ASSETS, 1); + + assertThrows(IOException.class, + () -> store.persist(1, FP, bytes("dex1"), bytes("arsc1"), bytes("assets1"))); + + // No meta was ever published, so there is nothing to adopt - the boot falls back + // to the baseline rather than to a dex with no matching resources. + assertThat(store.load(FP)).isNull(); + } + + @Test + void concurrentDeploysAlwaysLeaveOneWholeLoadableGeneration() throws Exception { + // onPayload arrives on a oneway binder callback, whose thread pool can dispatch + // two calls at once. Interleaved inheritance reads and orphan collection would + // publish a meta naming a file the other thread had just collected. + final PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), bytes("arsc1"), bytes("assets1")); + final AtomicReference failure = new AtomicReference(); + Thread dexDeploys = new Thread(persister(store, failure, 2, 40, true)); + Thread resourceDeploys = new Thread(persister(store, failure, 3, 41, false)); + + dexDeploys.start(); + resourceDeploys.start(); + dexDeploys.join(TimeUnit.SECONDS.toMillis(30)); + resourceDeploys.join(TimeUnit.SECONDS.toMillis(30)); + + assertThat(failure.get()).isNull(); + // load() discards the store and answers null the moment the published meta names + // a file that is not there - which is exactly what an inheritance read + // interleaved with the other thread's orphan collection produces. So a non-null + // load here IS the consistency assertion. + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isAtLeast(2L); + } + + @Test + void persistSerialisesOnTheStoreMonitor() throws Exception { + // The behavioural test above can only catch an interleaving it happens to hit; + // this one is deterministic. Holding the store's monitor must be enough to stop + // a persist, which is only true while persist takes that same monitor. + final PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + final CountDownLatch started = new CountDownLatch(1); + final CountDownLatch finished = new CountDownLatch(1); + final AtomicReference failure = new AtomicReference(); + Thread other = new Thread(new Runnable() { + + @Override + public void run() { + started.countDown(); + try { + store.persist(2, FP, bytes("dex2"), null, null); + } catch (Throwable error) { + failure.set(error); + } + finished.countDown(); + } + }); + + synchronized (store) { + other.start(); + assertThat(started.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(finished.await(500, TimeUnit.MILLISECONDS)).isFalse(); + } + + assertThat(finished.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(failure.get()).isNull(); + assertThat(store.load(FP).generation).isEqualTo(2); + } + + /** + * Makes the write of one kind of one generation fail the way a full disk does. + * + * A non-empty directory at the target path cannot be renamed over, deleted, or retried, so writeAtomic exhausts its fallback and throws. + * + * @param store + * the store whose write to block + * @param kind + * the payload kind to block + * @param generation + * the generation whose write to block + */ + private void blockWrite(PayloadPersistence store, String kind, long generation) + throws IOException { + File target = payload(store, kind, generation); + assertThat(new File(target, "child").mkdirs()).isTrue(); + } + + private File payload(PayloadPersistence store, String kind, long generation) { + return new File(store.dir(), PayloadPersistence.payloadFileName(kind, generation)); + } + + /** + * A thread body that hammers the store with one kind of delta deploy. The two threads take opposite {@code dex} values, so inheritance is exercised in both directions. + * + * @param store + * the store under test + * @param failure + * where an unexpected throwable is recorded for the main thread to assert on + * @param from + * first generation this thread publishes + * @param to + * last generation this thread publishes, exclusive + * @param dex + * true to deploy dex only, false to deploy resources and assets only + * @return the runnable to hand to a Thread + */ + private Runnable persister(final PayloadPersistence store, + final AtomicReference failure, final int from, final int to, + final boolean dex) { + return new Runnable() { + + @Override + public void run() { + try { + for (int generation = from; generation < to; generation += 2) { + if (dex) { + store.persist(generation, FP, bytes("dex" + generation), null, null); + } else { + store.persist(generation, FP, null, bytes("arsc" + generation), + bytes("assets" + generation)); + } + } + } catch (Throwable error) { + failure.set(error); + } + } + }; + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(temp, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java new file mode 100644 index 0000000000..650420dfe8 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java @@ -0,0 +1,110 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the atomic-write and best-effort-clear edges of the payload store. + * + * Persist must fail loudly when the store cannot be written, since a swallowed write would let a later boot silently serve older code. The writeAtomic rename fallback must recover when only the first rename fails, and clear() must stay best-effort over entries it cannot delete. + */ +class PayloadPersistenceAtomicWriteTest { + + private static final String FP = "fp"; + + @TempDir + Path tempDir; + + @Test + void anUndeletableRenameTargetFailsThePersistLoudly() throws IOException { + File dir = tempDir.resolve("store").toFile(); + File inTheWay = new File(dir, PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 1)); + // A NON-empty directory: rename over it fails, delete fails, retry impossible. + assertThat(new File(inTheWay, "child").mkdirs()).isTrue(); + PayloadPersistence store = new PayloadPersistence(dir); + + IOException error = assertThrows(IOException.class, + () -> store.persist(1, FP, new byte[]{1}, null, null)); + + assertThat(error).hasMessageThat().contains("cannot rename"); + } + + @Test + void aStorePathBlockedByAFileFailsThePersistLoudly() throws IOException { + File blocked = tempDir.resolve("store").toFile(); + Files.write(blocked.toPath(), "not a dir".getBytes("UTF-8")); + PayloadPersistence store = new PayloadPersistence(blocked); + + IOException error = assertThrows(IOException.class, + () -> store.persist(1, FP, new byte[]{1}, null, null)); + + assertThat(error).hasMessageThat().contains("cannot create"); + } + + @Test + void clearIsBestEffortWhenAnEntryCannotBeDeleted() throws IOException { + File dir = tempDir.resolve("store").toFile(); + File stubborn = new File(dir, "stubborn"); + File child = new File(stubborn, "child"); + assertThat(child.mkdirs()).isTrue(); + // A read-only parent is the portable way to make a child undeletable. + assertThat(stubborn.setWritable(false)).isTrue(); + PayloadPersistence store = new PayloadPersistence(dir); + try { + assertDoesNotThrow(store::clear); + + // Undeletable entries survive; clear reported and moved on instead of throwing. + assertThat(child.exists()).isTrue(); + assertThat(dir.isDirectory()).isTrue(); + } finally { + // Or the temp-dir teardown inherits the problem. + stubborn.setWritable(true); + } + } + + @Test + void fingerprintsAreLowercaseHexOfTheExpectedLength() { + // Pins the on-disk key format: 64 hex chars for SHA-256, stable across runs. + String fingerprint = PayloadPersistence.fingerprint(new byte[]{0, 1, 2}); + + assertThat(fingerprint).hasLength(64); + assertThat(fingerprint).matches("[0-9a-f]{64}"); + } + + @Test + void metaWithAFingerprintButNoGenerationDeletesTheStore() throws IOException { + File dir = tempDir.resolve("store").toFile(); + assertThat(dir.mkdirs()).isTrue(); + File meta = new File(dir, PayloadPersistence.META_FILE); + Files.write(meta.toPath(), + ("{\"layout\":\"" + PayloadPersistence.LAYOUT + "\",\"fingerprint\":\"" + FP + "\"}") + .getBytes("UTF-8")); + PayloadPersistence store = new PayloadPersistence(dir); + + assertThat(store.load(FP)).isNull(); + assertThat(meta.exists()).isFalse(); + } + + @Test + void renameFallbackReplacesAnEmptyDirectoryInTheWay() throws IOException { + File dir = tempDir.resolve("store").toFile(); + File inTheWay = new File(dir, PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 3)); + assertThat(inTheWay.mkdirs()).isTrue(); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(3, FP, new byte[]{9, 9}, null, null); + + assertThat(inTheWay.isFile()).isTrue(); + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(3); + assertThat(loaded.dex).isEqualTo(new byte[]{9, 9}); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java new file mode 100644 index 0000000000..803762bff0 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java @@ -0,0 +1,182 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers what the store does with a meta.json it did not write. + * + * Every case here is reachable on a real device: an interrupted write, a downgrade that wrote an older layout, or a store rolled forward by a build that is gone. The contract is that anything the store cannot fully understand counts as absent - the boot then serves the installed APK's baseline, which is always self-consistent - never as a partially readable set worth serving. + */ +class PayloadPersistenceCorruptMetaTest { + + private static final String FP = "baseline-fp"; + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + @TempDir + File temp; + + @Test + void aMetaWithANonStringKindNameDiscardsTheStore() throws IOException { + // An array where a filename belongs is corruption, not "this kind was absent": + // treating it as absent would serve the remaining kinds as if they were whole. + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":\"1\",\"fingerprint\":\"" + FP + "\",\"dex\":[\"dex-1.bin\"]}"); + PayloadPersistence store = new PayloadPersistence(dir); + + assertThat(store.load(FP)).isNull(); + assertThat(dir.exists()).isFalse(); + } + + @Test + void aQuarantineMarkerWithANonStringGenerationIsIgnored() throws IOException { + // The marker refuses a boot, so an unreadable one must fail open. Failing closed + // would strand the app on its baseline with no way back. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + Files.write(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).toPath(), + bytes("{\"generation\":5}")); + + assertThat(store.load(FP).generation).isEqualTo(1); + } + + @Test + void filesThatAreNotGenerationStampedPayloadsAreLeftAlone() throws IOException { + // Orphan collection may only claim names it can prove it owns. Anything else in + // the directory belongs to some other part of the runtime. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + File noDash = new File(store.dir(), "payload.bin"); + File noNumber = new File(store.dir(), "dex-x.bin"); + Files.write(noDash.toPath(), bytes("not ours")); + Files.write(noNumber.toPath(), bytes("not ours either")); + + store.persist(2, FP, bytes("dex2"), null, null); + + assertThat(noDash.isFile()).isTrue(); + assertThat(noNumber.isFile()).isTrue(); + } + + @Test + void persistDoesNotCarryForwardANameWhoseFileIsGone() throws IOException { + // Carrying the name forward regardless would publish a meta pointing at nothing, + // which load() has to treat as corruption - turning a survivable delta deploy + // into a discarded store. + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":\"1\",\"fingerprint\":\"" + FP + + "\",\"arsc\":\"arsc-1.bin\"}"); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAMetaWithANonStringGeneration() throws IOException { + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":1,\"fingerprint\":\"" + FP + "\",\"arsc\":\"arsc-1.bin\"}"); + Files.write(new File(dir, "arsc-1.bin").toPath(), bytes("arsc1")); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + // Unreadable generation means the ordering check cannot run, and inheriting from + // a set that might be NEWER is the one direction cumulative deltas do not survive. + assertThat(store.load(FP).arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAMetaWithANonStringKindName() throws IOException { + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":\"1\",\"fingerprint\":\"" + FP + "\",\"arsc\":[\"arsc-1.bin\"]}"); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + // Nothing to carry forward, so the new set is dex-only rather than a dex paired + // with a resource file the meta could not name. + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAMetaWithNoFingerprint() throws IOException { + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":\"1\",\"arsc\":\"arsc-1.bin\"}"); + Files.write(new File(dir, "arsc-1.bin").toPath(), bytes("arsc1")); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + // Without a fingerprint there is no evidence those resources were linked against + // this baseline, and a table from another APK is what crashes startup. + assertThat(store.load(FP).arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAnOldLayoutStore() throws IOException { + // The upgrade path: an already-installed proxy app with the flat layout on disk. + File dir = seed("{\"generation\":\"1\",\"fingerprint\":\"" + FP + "\"}"); + Files.write(new File(dir, "resources.arsc").toPath(), bytes("old arsc")); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAnUnparseableMeta() throws IOException { + File dir = seed("{not json at all"); + + new PayloadPersistence(dir).persist(2, FP, bytes("dex2"), null, null); + + // Publishing over the garbage is the recovery: the next boot gets a whole, + // readable generation instead of a store nothing can ever adopt. + PayloadPersistence.Loaded loaded = new PayloadPersistence(dir).load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAStoreLeftByAnotherBaseline() throws IOException { + // A Standard Run reinstall changes the baseline dex and so the fingerprint. Its + // old resource table was linked against code that is no longer installed, and + // pairing it with the new dex is precisely the startup crash the set-atomicity + // work exists to prevent. + PayloadPersistence store = store(); + store.persist(1, "an-older-baseline", bytes("dex1"), bytes("arsc1"), null); + + store.persist(2, FP, bytes("dex2"), null, null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + private File seed(String metaJson) throws IOException { + File dir = new File(temp, "payload"); + assertThat(dir.mkdirs()).isTrue(); + Files.write(new File(dir, PayloadPersistence.META_FILE).toPath(), bytes(metaJson)); + return dir; + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(temp, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java new file mode 100644 index 0000000000..019f33c554 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java @@ -0,0 +1,281 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the guard against a failed generation becoming the sticky boot generation. + * + * The payload is persisted before it is applied, so when the apply or the render throws, the store already claims the generation that just failed. A fresh process would adopt it, fail the same way during startup, and report nothing - no reload is pending in a new process, so the crash guard stays silent and the app crash-loops with CoGo none the wiser. A marker naming the failed generation is what breaks that loop, and it is a marker rather than a rollback of the store because it also survives a crash part-way through the rollback itself. + */ +class PayloadPersistenceQuarantineTest { + + private static final String FP = "baseline-fp"; + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + @TempDir + File temp; + + @Test + void aGenerationThatAlreadyRanIsNotQuarantined() throws IOException { + // It reached the screen once, so a fresh process booting it does not repeat whatever + // failed later - and quarantining it would throw away the fallback along with the + // fault, which is how the good generations got swept up on device. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + + store.quarantine(7); + + assertThat(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).exists()).isFalse(); + assertThat(store.load(FP).generation).isEqualTo(7); + } + + @Test + void aLastGoodSetForAnotherBaselineIsNotBooted() throws IOException { + // A reinstall or rebaseline changes the fingerprint; the fallback must not outlive + // the baseline its classes were compiled against any more than the published set does. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + store.quarantine(8); + + assertThat(store.load("a-different-baseline")).isNull(); + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void aLastGoodSetNamingAMissingFileDiscardsTheStore() throws IOException { + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + store.quarantine(8); + assertThat(new File(store.dir(), + PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 7)).delete()).isTrue(); + + // A meta claiming a generation it cannot serve is corruption, not a plain absence. + assertThat(store.load(FP)).isNull(); + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void anUnreadableMarkerIsIgnoredRatherThanBlockingEveryBoot() throws IOException { + PayloadPersistence store = store(); + store.persist(4, FP, bytes("dex4"), null, null); + Files.write(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).toPath(), + bytes("not json")); + + // Failing closed here would strand the app on the baseline forever over a + // corrupt side file; failing open only costs the guard for one generation. + assertThat(store.load(FP).generation).isEqualTo(4); + } + + @Test + void aQuarantinedGenerationWithNothingGoodBehindItDiscardsTheStore() throws IOException { + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), bytes("arsc7"), null); + + store.quarantine(7); + + // Nothing ever reached the screen, so there is nothing to fall back to: the process + // boots the gen-0 baseline - the code the installed APK already carries - and reports + // generation 0, which is what makes CoGo redeploy instead of leaving the app dead. + assertThat(store.load(FP)).isNull(); + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void aQuarantineFallsBackToTheLastGenerationThatRan() throws IOException { + // The measured cascade: generation 14 crashed, the app rebooted on install-time code + // six saves behind, CoGo re-sent its retained payload onto that baseline, and the + // second crash ended at the system's "app keeps stopping" dialog. Landing on 7 + // instead means the app comes back where the session already is, so nothing is + // re-sent and nothing crashes twice. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), bytes("arsc7"), null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + + store.quarantine(8); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(7); + assertThat(loaded.dex).isEqualTo(bytes("dex7")); + // Its resources come back with it, not generation 8's. + assertThat(loaded.arscFile.getName()) + .isEqualTo(PayloadPersistence.payloadFileName(PayloadPersistence.KIND_ARSC, 7)); + } + + @Test + void aRestartedGenerationCounterDropsTheFallbackFromTheOldSequence() throws IOException { + // The project's state dir was wiped while the app stayed installed, so numbering + // restarts. Falling back to 13 from the old sequence would boot an older build under + // a higher number - the one mismatch direction the store cannot make safe. + PayloadPersistence store = store(); + store.persist(13, FP, bytes("dex13"), null, null); + store.markGood(13); + + store.persist(3, FP, bytes("dex3"), null, null); + + assertThat(new File(store.dir(), PayloadPersistence.GOOD_FILE).exists()).isFalse(); + store.quarantine(3); + assertThat(store.load(FP)).isNull(); + } + + @Test + void aSuccessfulPersistClearsTheMarker() throws IOException { + // The generation counter restarts if the project's state dir is wiped, so a + // marker naming 7 must not be able to refuse a later, different generation 7. + // Publishing a complete set is the event that supersedes the claim. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.quarantine(7); + + store.persist(8, FP, bytes("dex8"), null, null); + + assertThat(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).exists()).isFalse(); + assertThat(store.load(FP).generation).isEqualTo(8); + } + + @Test + void markGoodIgnoresAGenerationTheStoreNoLongerPublishes() throws IOException { + // A late confirmation for a superseded generation must not record 7's files as the + // fallback while the store publishes 8 - the two would disagree about what is live. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.persist(8, FP, bytes("dex8"), null, null); + + assertThat(store.markGood(7)).isFalse(); + + assertThat(new File(store.dir(), PayloadPersistence.GOOD_FILE).exists()).isFalse(); + } + + @Test + void markGoodNeverThrowsWhenTheStoreCannotBeWritten() throws IOException { + File blocked = new File(temp, "payload"); + Files.write(blocked.toPath(), bytes("not a dir")); + PayloadPersistence store = new PayloadPersistence(blocked); + + assertDoesNotThrow(new org.junit.jupiter.api.function.Executable() { + + @Override + public void execute() { + // Reported as a failure rather than swallowed: the caller keeps treating the + // generation as unproven, since nothing was written for a quarantine to reach. + assertThat(store.markGood(3)).isFalse(); + } + }); + } + + @Test + void markGoodReportsWhetherTheFallbackNowNamesTheGeneration() throws IOException { + // The crash guard stops blaming a generation exactly when this says yes, so "recorded" + // and "already recorded" have to answer the same way - a second confirmation writes + // nothing and must still mean the fallback is in place. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + + assertThat(store.markGood(7)).isTrue(); + assertThat(store.markGood(7)).isTrue(); + + assertThat(new File(store.dir(), PayloadPersistence.GOOD_FILE).isFile()).isTrue(); + } + + @Test + void quarantineNeverThrowsWhenTheStoreCannotBeWritten() throws IOException { + // Called from the reload failure path and from the uncaught-exception guard, + // neither of which can handle a throw. + File blocked = new File(temp, "payload"); + Files.write(blocked.toPath(), bytes("not a dir")); + PayloadPersistence store = new PayloadPersistence(blocked); + + assertDoesNotThrow(new org.junit.jupiter.api.function.Executable() { + + @Override + public void execute() { + store.quarantine(3); + } + }); + } + + @Test + void quarantineOnlyBlocksTheGenerationItNames() throws IOException { + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.quarantine(6); + + // Generation 6 failed; 7 was never tried and must still boot. + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(7); + } + + @Test + void quarantineSurvivesAStoreThatWasNeverWritten() { + // persist() threw before publishing anything, so the marker names a generation + // the store does not claim. It must be inert, not a blanket refusal. + PayloadPersistence store = store(); + + store.quarantine(9); + + assertThat(store.load(FP)).isNull(); + assertThat(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).isFile()).isTrue(); + } + + @Test + void theFallbackIsRepublishedSoLaterBootsAgreeWithThisOne() throws IOException { + // Loading 7 while the store still claims 8 would make every later boot walk the + // fallback again, and the next deploy inherit files from the quarantined set. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + store.quarantine(8); + + assertThat(store.load(FP).generation).isEqualTo(7); + + PayloadPersistence reopened = store(); + PayloadPersistence.Loaded loaded = reopened.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(7); + assertThat(loaded.dex).isEqualTo(bytes("dex7")); + } + + @Test + void theLastGoodSetSurvivesTheOrphanSweepOfLaterGenerations() throws IOException { + // persist() collects every payload file the published meta does not name. The + // fallback's files are named only by good.json, so without that being consulted the + // fallback would resolve to a meta pointing at files that are gone. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + store.persist(9, FP, bytes("dex9"), null, null); + + assertThat(new File(store.dir(), + PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 7)).isFile()).isTrue(); + // Generation 8's is not the fallback and not published, so it still goes. + assertThat(new File(store.dir(), + PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 8)).isFile()).isFalse(); + + store.quarantine(9); + assertThat(store.load(FP).generation).isEqualTo(7); + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(temp, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java new file mode 100644 index 0000000000..aabc8e55d9 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java @@ -0,0 +1,191 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PayloadPersistenceTest { + + private static final String FP = PayloadPersistence.fingerprint(bytes("baseline")); + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private static File payload(PayloadPersistence store, String kind, long generation) { + return new File(store.dir(), PayloadPersistence.payloadFileName(kind, generation)); + } + + @TempDir + File temp; + + @Test + void anOrphanPayloadFileNoMetaNamesIsIgnored() throws IOException { + // What a crash mid-persist leaves behind: a newer generation's payload file with + // no meta referencing it. The store must keep serving the older complete + // generation (host catch-up redeploys), and must not pick the orphan up. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + try (FileOutputStream out = new FileOutputStream(payload(store, PayloadPersistence.KIND_DEX, 2))) { + out.write(bytes("dex2")); + } + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(1); + assertThat(loaded.dex).isEqualTo(bytes("dex1")); + } + + @Test + void clearOnEmptyDirIsHarmless() throws IOException { + PayloadPersistence store = store(); + + store.clear(); + + // Teardown runs clear() whether or not anything was ever persisted, so the + // no-payload case must delete nothing, create nothing, and leave a store that + // still works. + assertThat(store.dir().exists()).isFalse(); + assertThat(store.load(FP)).isNull(); + store.persist(1, FP, bytes("dex1"), null, null); + assertThat(store.load(FP).generation).isEqualTo(1); + } + + @Test + void clearRemovesNestedEntriesToo() throws IOException { + // An untrusted store can hold a directory where a payload file belonged (the + // rename-fallback path proves the filesystem allows it). A non-recursive clear + // would leave it there and the next load would keep tripping over it. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + assertThat(new File(new File(store.dir(), "nested"), "deep").mkdirs()).isTrue(); + + store.clear(); + + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void corruptMetaDeletesTheStore() throws IOException { + PayloadPersistence store = store(); + store.persist(5, FP, bytes("dex5"), null, null); + try (FileOutputStream out = new FileOutputStream(new File(store.dir(), PayloadPersistence.META_FILE))) { + out.write(bytes("not json")); + } + + assertThat(store.load(FP)).isNull(); + assertThat(payload(store, PayloadPersistence.KIND_DEX, 5).exists()).isFalse(); + } + + @Test + void emptyStoreLoadsNull() { + assertThat(store().load(FP)).isNull(); + } + + @Test + void fingerprintIsStableAndContentSensitive() { + assertThat(PayloadPersistence.fingerprint(bytes("a"))) + .isEqualTo(PayloadPersistence.fingerprint(bytes("a"))); + assertThat(PayloadPersistence.fingerprint(bytes("a"))) + .isNotEqualTo(PayloadPersistence.fingerprint(bytes("b"))); + } + + @Test + void fingerprintMismatchDeletesTheStore() throws IOException { + // A rebaseline/reinstall changed the baseline: the persisted payload was + // compiled against the OLD baseline and must never boot on the new one. + PayloadPersistence store = store(); + store.persist(5, FP, bytes("dex5"), null, null); + + assertThat(store.load(PayloadPersistence.fingerprint(bytes("new-baseline")))).isNull(); + assertThat(new File(store.dir(), PayloadPersistence.META_FILE).exists()).isFalse(); + assertThat(payload(store, PayloadPersistence.KIND_DEX, 5).exists()).isFalse(); + // And the original fingerprint finds nothing either - the store is gone. + assertThat(store.load(FP)).isNull(); + } + + @Test + void keepsNewestFilePerKindAcrossDeltaDeploys() throws IOException { + // Deploys carry only what changed; the store must stay cumulative so a boot + // gets the newest dex AND the newest resources even when they shipped apart. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + store.persist(2, FP, null, bytes("arsc2"), null); + store.persist(3, FP, bytes("dex3"), null, bytes("assets3")); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(3); + assertThat(loaded.dex).isEqualTo(bytes("dex3")); + assertThat(Files.readAllBytes(loaded.arscFile.toPath())).isEqualTo(bytes("arsc2")); + assertThat(Files.readAllBytes(loaded.assetsFile.toPath())).isEqualTo(bytes("assets3")); + } + + @Test + void metaWithoutFingerprintDeletesTheStore() throws IOException { + PayloadPersistence store = store(); + store.persist(5, FP, bytes("dex5"), null, null); + try (FileOutputStream out = new FileOutputStream(new File(store.dir(), PayloadPersistence.META_FILE))) { + out.write(bytes("{\"layout\":\"" + PayloadPersistence.LAYOUT + "\",\"generation\":\"5\"}")); + } + + assertThat(store.load(FP)).isNull(); + } + + @Test + void persistReturnsTheCumulativeResourceFiles() throws IOException { + PayloadPersistence store = store(); + store.persist(1, FP, null, bytes("arsc1"), null); + PayloadPersistence.Persisted persisted = store.persist(2, FP, bytes("dex2"), null, null); + + // The dex-only deploy still sees the previously persisted arsc. + assertThat(persisted.arscFile).isNotNull(); + assertThat(Files.readAllBytes(persisted.arscFile.toPath())).isEqualTo(bytes("arsc1")); + assertThat(persisted.assetsFile).isNull(); + } + + @Test + void resourceOnlyHistoryLoadsWithNullDex() throws IOException { + PayloadPersistence store = store(); + store.persist(1, FP, null, bytes("arsc1"), null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(1); + assertThat(loaded.dex).isNull(); + assertThat(loaded.arscFile).isNotNull(); + } + + @Test + void roundTripsAFullPayload() throws IOException { + PayloadPersistence store = store(); + store.persist(3, FP, bytes("dex3"), bytes("arsc3"), bytes("assets3")); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(3); + assertThat(loaded.dex).isEqualTo(bytes("dex3")); + assertThat(Files.readAllBytes(loaded.arscFile.toPath())).isEqualTo(bytes("arsc3")); + assertThat(Files.readAllBytes(loaded.assetsFile.toPath())).isEqualTo(bytes("assets3")); + } + + @Test + void supersededPayloadFilesAreCollected() throws IOException { + // Generation-stamped names would otherwise accumulate one dex per deploy in an + // app-private dir on a device with 1.8 GB of storage. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + store.persist(2, FP, bytes("dex2"), null, null); + + assertThat(payload(store, PayloadPersistence.KIND_DEX, 1).exists()).isFalse(); + assertThat(payload(store, PayloadPersistence.KIND_DEX, 2).isFile()).isTrue(); + assertThat(store.load(FP).dex).isEqualTo(bytes("dex2")); + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(temp, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java new file mode 100644 index 0000000000..7e03f5dbde --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java @@ -0,0 +1,86 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The S7 fix's decision, against a real on-disk store: which persisted payload, if any, a boot at a given STAMPED baseline generation adopts. Reverting the gate to a constant 0 makes the first test go green on the previous epoch's payload, which is exactly the on-device S7 hole. + */ +class PersistedSelectionTest { + + private static final byte[] BASELINE_DEX = "baseline-dex".getBytes(StandardCharsets.UTF_8); + + @TempDir + File dir; + + @Test + void anEmptyStoreBootsTheBakedBaseline() { + assertThat(PersistedSelection.selectPersisted(8, store(), fingerprint())).isNull(); + } + + @Test + void anUnstampedBaselineKeepsItsOldBehaviorAndAnyPersistedDeployWins() throws Exception { + PayloadPersistence store = store(); + persist(store, 1); + + PayloadPersistence.Loaded loaded = PersistedSelection.selectPersisted(BaselineGeneration.UNSTAMPED, store, fingerprint()); + + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(1); + } + + @Test + void aPayloadDeployedOnTopOfTheStampedBaselineIsAdoptedAtBoot() throws Exception { + PayloadPersistence store = store(); + persist(store, 9); + + PayloadPersistence.Loaded loaded = PersistedSelection.selectPersisted(8, store, fingerprint()); + + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(9); + } + + @Test + void aPersistedPayloadEqualToTheStampIsARejectedReplay() throws Exception { + PayloadPersistence store = store(); + persist(store, 8); + + assertThat(PersistedSelection.selectPersisted(8, store, fingerprint())).isNull(); + } + + @Test + void aStampedRebaselineRejectsThePreviousEpochsPersistedPayload() throws Exception { + // A manifest-only rebaseline leaves the baseline dex byte-identical, so the + // fingerprint matches; only the stamp says gen 7 is from the superseded epoch. + PayloadPersistence store = store(); + persist(store, 7); + + assertThat(PersistedSelection.selectPersisted(8, store, fingerprint())).isNull(); + } + + @Test + void aStoreKeyedToAnotherBaselineIsNotAdopted() throws Exception { + PayloadPersistence store = store(); + persist(store, 9); + + String otherFingerprint = PayloadPersistence.fingerprint("other-dex".getBytes(StandardCharsets.UTF_8)); + + assertThat(PersistedSelection.selectPersisted(8, store, otherFingerprint)).isNull(); + } + + private String fingerprint() { + return PayloadPersistence.fingerprint(BASELINE_DEX); + } + + private void persist(PayloadPersistence store, long generation) throws Exception { + store.persist(generation, fingerprint(), "dex".getBytes(StandardCharsets.UTF_8), null, null); + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(dir, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java new file mode 100644 index 0000000000..a730541b1e --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java @@ -0,0 +1,71 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * What reaches the user when a component cannot be instantiated from either loader. + * + * User classes exist only in the payload dex, so the default-loader fallback almost always ends in a {@code ClassNotFoundException} - surfacing that one would put it in the crash dialog and the crash reporter while the real cause (a payload class whose static init threw, a stale-payload {@code NoSuchFieldError}) stayed only in logcat. + */ +class QuickBuildAppComponentFactoryRethrowTest { + + @Test + void aCheckedPayloadFailureKeepsItsOwnType() { + InstantiationException payloadError = new InstantiationException("no public no-arg constructor"); + + InstantiationException thrown = assertThrows( + InstantiationException.class, + () -> QuickBuildAppComponentFactory.rethrowPayloadFailure( + payloadError, new ClassNotFoundException("com.example.UserService"))); + + assertThat(thrown).isSameInstanceAs(payloadError); + } + + @Test + void aRuntimePayloadFailurePropagatesUnwrapped() { + NoSuchFieldError payloadError = new NoSuchFieldError("field removed by a stale payload"); + + assertThat(assertThrows( + NoSuchFieldError.class, + () -> QuickBuildAppComponentFactory.rethrowPayloadFailure( + payloadError, new ClassNotFoundException("com.example.UserProvider")))) + .isSameInstanceAs(payloadError); + } + + @Test + void aThrowableNoSignatureAllowsIsWrappedWithTheCauseIntact() throws Exception { + Throwable payloadError = new Throwable("some other checked throwable"); + + RuntimeException wrapper = QuickBuildAppComponentFactory.rethrowPayloadFailure( + payloadError, new ClassNotFoundException("com.example.UserReceiver")); + + assertThat(wrapper.getCause()).isSameInstanceAs(payloadError); + } + + @Test + void oneThrowableAsBothFailuresDoesNotBlowUpOnSelfSuppression() { + RuntimeException error = new RuntimeException("the same instance twice"); + + assertThat(assertThrows( + RuntimeException.class, + () -> QuickBuildAppComponentFactory.rethrowPayloadFailure(error, error))) + .isSameInstanceAs(error); + } + + @Test + void theOriginalPayloadFailurePropagatesRatherThanTheFallbacksClassNotFound() { + ExceptionInInitializerError payloadError = new ExceptionInInitializerError("payload static init threw"); + ClassNotFoundException fallbackError = new ClassNotFoundException("com.example.UserActivity"); + + ExceptionInInitializerError thrown = assertThrows( + ExceptionInInitializerError.class, + () -> QuickBuildAppComponentFactory.rethrowPayloadFailure(payloadError, fallbackError)); + + assertThat(thrown).isSameInstanceAs(payloadError); + // The fallback failure is still reachable - kept, just not promoted over the cause. + assertThat(thrown.getSuppressed()).asList().containsExactly(fallbackError); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersForActivityTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersForActivityTest.java new file mode 100644 index 0000000000..9ec6fcddd0 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersForActivityTest.java @@ -0,0 +1,45 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Pins forActivity's contract on both sides of the choice: the live payload loader wins, and with none live the fallback comes back unchanged. + * + * The payload side is what a proxy activity's getClassLoader() exists for - reporting the fallback while a payload is live means LayoutInflater and FragmentFactory resolve names against the base APK and silently miss every payload-only class. The fallback side is the defense-in-depth half: the loader must never be null. + */ +class QuickBuildClassLoadersForActivityTest { + + /** The store is process-wide, so each test takes it over and hands it back. */ + private PayloadStore.Payload previous; + + @Test + void prefersTheLivePayloadLoaderOverTheFallback() { + ClassLoader fallback = new ClassLoader() {}; + ClassLoader payload = new ClassLoader(null) {}; + PayloadStore.INSTANCE.restore(new PayloadStore.Payload(1L, payload)); + + assertThat(QuickBuildClassLoaders.forActivity(fallback)).isSameInstanceAs(payload); + } + + @AfterEach + void restoreThePayloadStore() { + PayloadStore.INSTANCE.restore(previous); + } + + @Test + void returnsTheFallbackWhenNoPayloadIsLive() { + ClassLoader fallback = new ClassLoader() {}; + + assertThat(QuickBuildClassLoaders.forActivity(fallback)).isSameInstanceAs(fallback); + } + + @BeforeEach + void takeOverThePayloadStore() { + previous = PayloadStore.INSTANCE.snapshot(); + PayloadStore.INSTANCE.restore(null); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersTest.java new file mode 100644 index 0000000000..039c592efd --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersTest.java @@ -0,0 +1,26 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class QuickBuildClassLoadersTest { + + private final ClassLoader fallback = getClass().getClassLoader(); + + @Test + void fallsBackWhenNoPayloadIsLive() { + // Inert runtime (no baseline loaded, or this Activity type reached before + // ensureBaseline ran): must behave like a normal app, never NPE. + assertThat(QuickBuildClassLoaders.choose(null, fallback)).isSameInstanceAs(fallback); + } + + @Test + void picksThePayloadLoaderWhenOneIsLive() { + ClassLoader payload = new ClassLoader(null) {}; + // The payload loader wins even though fallback also "works" - a stale-code lie + // (Fragment/custom-view resolution silently missing every payload-only class) + // is exactly the regression this guards. + assertThat(QuickBuildClassLoaders.choose(payload, fallback)).isSameInstanceAs(payload); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategyTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategyTest.java new file mode 100644 index 0000000000..21801b9572 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategyTest.java @@ -0,0 +1,30 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pins the version routing: 30+ takes ResourcesLoader, 28/29 the degraded addAssetPath shim, below 28 is unsupported. + */ +class ResourceSwapStrategyTest { + + @Test + void api28And29UseLegacyAssetPath() { + assertThat(ResourceSwapStrategy.forSdk(28)).isEqualTo(ResourceSwapStrategy.LEGACY_ASSET_PATH); + assertThat(ResourceSwapStrategy.forSdk(29)).isEqualTo(ResourceSwapStrategy.LEGACY_ASSET_PATH); + } + + @Test + void api30AndAboveUseResourcesLoader() { + assertThat(ResourceSwapStrategy.forSdk(30)).isEqualTo(ResourceSwapStrategy.RESOURCES_LOADER); + assertThat(ResourceSwapStrategy.forSdk(31)).isEqualTo(ResourceSwapStrategy.RESOURCES_LOADER); + assertThat(ResourceSwapStrategy.forSdk(36)).isEqualTo(ResourceSwapStrategy.RESOURCES_LOADER); + } + + @Test + void below28IsUnsupported() { + assertThat(ResourceSwapStrategy.forSdk(27)).isEqualTo(ResourceSwapStrategy.UNSUPPORTED); + assertThat(ResourceSwapStrategy.forSdk(16)).isEqualTo(ResourceSwapStrategy.UNSUPPORTED); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoffTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoffTest.java new file mode 100644 index 0000000000..e1e072da23 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoffTest.java @@ -0,0 +1,263 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** + * The restart path's wait for the framework to be told the app's state before the process dies. + * + * What each test would catch: ending the wait at the last stop - the shape this replaced, one callback later - kills the process while ActivityThread's report to the server is still sitting on the main looper, which measured on an A56 as a force-removed record and a collapsed task; asking for the drain before every activity has stopped drains the wrong queue; giving each phase its own timeout doubles how long a restart is delayed by an app that will not stop; and returning true on timeout would report a handoff that never happened, which is the failure mode the caller logs about. + */ +class RestartHandoffTest { + + /** Long enough that a slow machine cannot fail it, short enough that a hang is obvious. */ + private static final long GENEROUS_TIMEOUT_MILLIS = 5000; + + /** A budget for the two-phase test, big enough that its two halves are separable on a loaded machine. */ + private static final long SHARED_BUDGET_MILLIS = 300; + + /** Long enough to be measurable, short enough to keep the suite fast. */ + private static final long SHORT_TIMEOUT_MILLIS = 60; + + /** Most of {@link #SHARED_BUDGET_MILLIS}, so a per-phase bound would visibly overrun it. */ + private static final long SLOW_STOP_MILLIS = 250; + + /** A drain request that never produces a drain, standing in for a main looper that never idles. */ + private static Runnable neverDrains() { + return new Runnable() { + + @Override + public void run() {} + }; + } + + @Test + void aDrainFromAnEarlierHandoffDoesNotAnswerThisOne() { + // Nothing arms the drain except arm(), so a stale one would let a restart kill the + // process the instant the last activity stopped - one message too early, which is the + // whole defect. + RestartHandoff handoff = new RestartHandoff(); + handoff.onDrained(); + handoff.arm(); + + assertThat(handoff.awaitHandoff(SHORT_TIMEOUT_MILLIS, neverDrains())).isFalse(); + } + + @Test + void aDrainThatLandsInsideTheRequestStillCounts() { + // The real ordering has the drain arrive on the main thread, but it can land before the + // waiter gets back to wait() and must still be seen. + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + + boolean handedOff = handoff.awaitHandoff(GENEROUS_TIMEOUT_MILLIS, new Runnable() { + + @Override + public void run() { + handoff.onDrained(); + } + }); + + assertThat(handedOff).isTrue(); + } + + @Test + void anAppAlreadyOffScreenNeedsNoBackgrounding() { + // The normal loop: the user saves by typing in CoGo, so every activity stopped long ago + // and the framework already holds what the relaunch needs. Measured clean on an A56, 0 + // of 5 backgrounded saves force-removed, and this is the check that keeps it that way. + RestartHandoff handoff = new RestartHandoff(); + + assertThat(handoff.anyActivityStarted()).isFalse(); + + handoff.onActivityStarted(); + assertThat(handoff.anyActivityStarted()).isTrue(); + + handoff.onActivityStopped(); + assertThat(handoff.anyActivityStarted()).isFalse(); + } + + @Test + void anAppThatNeverStopsTimesOutWithoutAskingForADrain() { + RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + handoff.onActivityStarted(); + final AtomicInteger drainRequests = new AtomicInteger(); + + long startedAt = System.nanoTime(); + boolean handedOff = handoff.awaitHandoff(SHORT_TIMEOUT_MILLIS, new Runnable() { + + @Override + public void run() { + drainRequests.incrementAndGet(); + } + }); + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L; + + assertThat(handedOff).isFalse(); + assertThat(elapsedMillis).isAtLeast(SHORT_TIMEOUT_MILLIS - 5); + // Nothing has been queued behind a stop that never happened, so there is nothing to + // drain and asking would only wait on an unrelated idle. + assertThat(drainRequests.get()).isEqualTo(0); + } + + @Test + void anInterruptEndsTheWaitAndKeepsTheFlagSet() throws Exception { + // The caller still owes a kill, so an interrupt must end the wait rather than propagate - + // but swallowing it outright would hide it from anything else on the thread. + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + handoff.onActivityStarted(); + final AtomicBoolean handedOff = new AtomicBoolean(true); + final AtomicBoolean interruptFlagKept = new AtomicBoolean(); + final CountDownLatch waiting = new CountDownLatch(1); + Thread waiter = new Thread(() -> { + waiting.countDown(); + handedOff.set(handoff.awaitHandoff(GENEROUS_TIMEOUT_MILLIS, neverDrains())); + interruptFlagKept.set(Thread.currentThread().isInterrupted()); + }); + waiter.start(); + assertThat(waiting.await(GENEROUS_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue(); + // The waiter has to reach wait() before the interrupt lands, or it never blocks. + Thread.sleep(50); + + waiter.interrupt(); + waiter.join(GENEROUS_TIMEOUT_MILLIS); + + assertThat(waiter.isAlive()).isFalse(); + assertThat(handedOff.get()).isFalse(); + assertThat(interruptFlagKept.get()).isTrue(); + } + + @Test + void anUnbalancedStopCannotDriveTheCountBelowZero() { + // A count stuck below zero would swallow the next real start, and the restart after that + // would kill an app that is still on screen. + RestartHandoff handoff = new RestartHandoff(); + + handoff.onActivityStopped(); + handoff.onActivityStarted(); + + assertThat(handoff.anyActivityStarted()).isTrue(); + } + + @Test + void aWaitWithNoTimeLeftReportsNoHandoffImmediately() { + RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + + assertThat(handoff.awaitHandoff(0, neverDrains())).isFalse(); + assertThat(handoff.awaitHandoff(-1, neverDrains())).isFalse(); + } + + @Test + void theDrainIsAskedForOnlyOnceEveryActivityHasStopped() throws Exception { + // Two activities in the task; the drain has to wait for both, because ActivityThread + // posts a report per activity and the last one is the one that can still be in flight. + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + handoff.onActivityStarted(); + handoff.onActivityStarted(); + final AtomicInteger drainRequests = new AtomicInteger(); + final AtomicBoolean handedOff = new AtomicBoolean(); + final CountDownLatch waiting = new CountDownLatch(1); + Thread waiter = new Thread(() -> { + waiting.countDown(); + handedOff.set(handoff.awaitHandoff(GENEROUS_TIMEOUT_MILLIS, new Runnable() { + + @Override + public void run() { + drainRequests.incrementAndGet(); + handoff.onDrained(); + } + })); + }); + waiter.start(); + assertThat(waiting.await(GENEROUS_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue(); + Thread.sleep(50); + + handoff.onActivityStopped(); + Thread.sleep(50); + assertThat(drainRequests.get()).isEqualTo(0); + + handoff.onActivityStopped(); + waiter.join(GENEROUS_TIMEOUT_MILLIS); + + assertThat(waiter.isAlive()).isFalse(); + assertThat(drainRequests.get()).isEqualTo(1); + assertThat(handedOff.get()).isTrue(); + } + + @Test + void theHandoffIsNotCompleteUntilTheDrainLands() { + // The defect this commit exists for. Every activity has stopped, so the app has written + // its bundle - and the server has not been told, because ActivityThread's report to it + // is still queued on the main looper. Killing here is what force-removed the record on 8 + // of 8 foreground saves measured on an A56. + RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + + long startedAt = System.nanoTime(); + boolean handedOff = handoff.awaitHandoff(SHORT_TIMEOUT_MILLIS, neverDrains()); + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L; + + assertThat(handedOff).isFalse(); + assertThat(elapsedMillis).isAtLeast(SHORT_TIMEOUT_MILLIS - 5); + } + + @Test + void theTimeoutBoundsBothPhasesTogetherRatherThanEach() throws Exception { + // The bound is what keeps a restart under the host's disconnect wait. Two phases each + // given the full timeout would spend twice as long on an app that will not stop, and the + // justification for the number would no longer hold. + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + handoff.onActivityStarted(); + Thread stopper = new Thread(() -> { + try { + Thread.sleep(SLOW_STOP_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + handoff.onActivityStopped(); + }); + stopper.start(); + + long startedAt = System.nanoTime(); + boolean handedOff = handoff.awaitHandoff(SHARED_BUDGET_MILLIS, neverDrains()); + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L; + stopper.join(GENEROUS_TIMEOUT_MILLIS); + + assertThat(handedOff).isFalse(); + // Most of the budget went on the stop, so the drain can only have had what was left of + // it. A per-phase bound would run to SLOW_STOP + SHARED_BUDGET instead. + assertThat(elapsedMillis).isAtLeast(SHARED_BUDGET_MILLIS - 15); + assertThat(elapsedMillis).isLessThan(SLOW_STOP_MILLIS + SHARED_BUDGET_MILLIS - 100); + } + + @Test + void theWaitEndsAsSoonAsTheDrainArrives() throws Exception { + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + final AtomicBoolean handedOff = new AtomicBoolean(); + final CountDownLatch waiting = new CountDownLatch(1); + Thread waiter = new Thread(() -> { + waiting.countDown(); + handedOff.set(handoff.awaitHandoff(GENEROUS_TIMEOUT_MILLIS, neverDrains())); + }); + waiter.start(); + assertThat(waiting.await(GENEROUS_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue(); + + handoff.onDrained(); + waiter.join(GENEROUS_TIMEOUT_MILLIS); + + assertThat(waiter.isAlive()).isFalse(); + assertThat(handedOff.get()).isTrue(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java new file mode 100644 index 0000000000..c446d02ea3 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java @@ -0,0 +1,46 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Pins the logging guard's contract that logging never alters behavior. + * + * In JVM unit tests android.util.Log is the unmocked stub and throws on every call, so each assertion below proves the guard swallowed a real throw: remove any try/catch in RuntimeLog and its test here goes red. + */ +class RuntimeLogTest { + + @Test + void debugSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.d("debug message")); + } + + @Test + void environmentSanityTheLogStubActuallyThrows() { + // Self-validation: if Log stopped throwing here (e.g. returnDefaultValues flipped + // on), the no-throw assertions below would pass vacuously. Keep this canary. + assertThrows(Throwable.class, () -> android.util.Log.d(RuntimeLog.TAG, "canary")); + } + + @Test + void errorSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.e("error message", new RuntimeException("cause"))); + } + + @Test + void infoSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.i("info message")); + } + + @Test + void warnSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.w("warn message")); + } + + @Test + void warnWithThrowableSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.w("warn message", new RuntimeException("cause"))); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsCloseQuietlyTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsCloseQuietlyTest.java new file mode 100644 index 0000000000..0d2b2fdcdf --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsCloseQuietlyTest.java @@ -0,0 +1,29 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.Test; + +/** closeQuietly's contract: null-safe, and a failing close never propagates. */ +class StreamsCloseQuietlyTest { + + @Test + void closesTheCloseable() { + final boolean[] closed = {false}; + Streams.closeQuietly(() -> closed[0] = true); + assertThat(closed[0]).isTrue(); + } + + @Test + void nullIsANoOp() { + assertDoesNotThrow(() -> Streams.closeQuietly(null)); + } + + @Test + void swallowsACloseFailure() { + assertDoesNotThrow(() -> Streams.closeQuietly(() -> { + throw new java.io.IOException("close failed"); + })); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java new file mode 100644 index 0000000000..9e018517b8 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java @@ -0,0 +1,100 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Random; +import org.junit.jupiter.api.Test; + +class StreamsTest { + + @Test + void defaultOverloadAppliesThePayloadCap() { + // The 1-arg overload every production call site uses must carry the cap itself - a + // capped 2-arg variant nobody calls would leave all six call sites unbounded. + IOException thrown = assertThrows(IOException.class, + () -> Streams.readFully(new OversizedStream(Streams.MAX_PAYLOAD_BYTES + 1L))); + assertThat(thrown).hasMessageThat().contains(String.valueOf(Streams.MAX_PAYLOAD_BYTES)); + } + + @Test + void emptyStreamYieldsEmptyArray() throws IOException { + assertThat(Streams.readFully(new ByteArrayInputStream(new byte[0]))).isEmpty(); + } + + @Test + void exactCapSizedStreamReadsFully() throws IOException { + // The cap is inclusive: exactly maxBytes is a legal payload, one byte more is not. + byte[] data = new byte[64 * 1024]; + new Random(11).nextBytes(data); + assertThat(Streams.readFully(new ByteArrayInputStream(data), 64 * 1024)).isEqualTo(data); + } + + @Test + void overCapStreamThrowsNamingTheLimit() { + // Payload fds are Binder-unbounded and read fully on a binder thread; without the cap + // an oversized payload is an OOM, not an IOException the deploy path can reject. + byte[] data = new byte[64 * 1024 + 1]; + IOException thrown = assertThrows(IOException.class, + () -> Streams.readFully(new ByteArrayInputStream(data), 64 * 1024)); + assertThat(thrown).hasMessageThat().contains(String.valueOf(64 * 1024)); + } + + @Test + void readsContentLargerThanInternalBuffer() throws IOException { + // 100 KB > the 16 KB read buffer, so the loop must run several times. + byte[] data = new byte[100 * 1024]; + new Random(42).nextBytes(data); + assertThat(Streams.readFully(new ByteArrayInputStream(data))).isEqualTo(data); + } + + @Test + void readsSmallStreamFully() throws IOException { + byte[] data = "payload-bytes".getBytes("UTF-8"); + assertThat(Streams.readFully(new ByteArrayInputStream(data))).isEqualTo(data); + } + + @Test + void readsUnderCapContentIntact() throws IOException { + // A cap spanning several internal buffers: content under it must arrive byte-identical. + byte[] data = new byte[40 * 1024]; + new Random(7).nextBytes(data); + assertThat(Streams.readFully(new ByteArrayInputStream(data), 64 * 1024)).isEqualTo(data); + } + + /** + * Claims {@code size} zero bytes without ever allocating them. + * + * Makes the 256 MB default cap testable in-heap: the capped reader must throw before it buffers anywhere near that much. + */ + private static final class OversizedStream extends java.io.InputStream { + + private long remaining; + + OversizedStream(long size) { + this.remaining = size; + } + + @Override + public int read() { + if (remaining <= 0) { + return -1; + } + remaining--; + return 0; + } + + @Override + public int read(byte[] buffer, int offset, int length) { + if (remaining <= 0) { + return -1; + } + int count = (int) Math.min(length, remaining); + java.util.Arrays.fill(buffer, offset, offset + count, (byte) 0); + remaining -= count; + return count; + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9cea6dad8a..78cea6d5ca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -142,6 +142,7 @@ include( ":lsp:xml", ":profiler", ":quickbuild:protocol", + ":quickbuild:runtime", ":subprojects:aapt2-proto", ":subprojects:aaptcompiler", ":subprojects:builder-model-impl", From 728a00c03ae9713bdfacd36c8723110eba7687d0 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:48:22 -0700 Subject: [PATCH 02/40] =?UTF-8?q?ADFA-4128:=20qb=2004=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20reload=20failure=20attribution=20+=20surfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stale pendingReloadGeneration mis-blaming later crashes: the backgrounded apply now assigns the pending slot too (Generations.pendingAfterApply), and BootProbation.generationToBlame refuses a pending value the store has moved past. Covered by BootProbationTest.aPendingReloadTheStoreMovedPastIsNotBlamed (fails without the fix) and GenerationsTest.aBackgroundedApplyClearsThePendingSlotItAlreadyAcked. - failReload swallowing every pre-apply failure: the newer-generation guard is now a three-way Generations.onReloadFailure — never-applied failures skip the rollback/quarantine but still reportCrash + banner; only a failure superseded by a newer live generation stays silent. Covered by GenerationsTest.aFailureTheStoreNeverAdoptedStillReports. - Binder-thread setProviders + immediate provider close racing main-thread inflation: ResourceStore now performs the field swap, setProviders and the close of the replaced provider on the main thread (inline when already there, so the boot restore path still lands before first inflation; Looper FIFO keeps a posted swap ahead of the posted recreate). Pure threading with no JVM seam — justified in swapProvidersOnMain's doc; device-covered. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- .../quickbuild/runtime/BootProbation.java | 4 +- .../quickbuild/runtime/Generations.java | 45 +++++++++++ .../quickbuild/runtime/QuickBuildRuntime.java | 29 ++++--- .../quickbuild/runtime/ResourceStore.java | 80 +++++++++++++++---- .../quickbuild/runtime/BootProbationTest.java | 20 +++++ .../quickbuild/runtime/GenerationsTest.java | 44 +++++++++- 6 files changed, 191 insertions(+), 31 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java index 0248480744..a330b4fef1 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java @@ -28,13 +28,13 @@ synchronized void bootedFromStore(long generation) { * The generation a crash happening right now should be quarantined against. * * @param pendingReloadGeneration - * the hot-swapped generation awaiting its first frame, or -1; it outranks the booted one, being the newer claim on the screen that just died + * the hot-swapped generation awaiting its first frame, or -1; it outranks the booted one, being the newer claim on the screen that just died - unless the store has already moved past it, which means the value is stale (a later deploy acked while backgrounded) and the crash belongs to whatever runs now, not to it * @param liveGeneration * the generation the store currently serves, which is how a booted generation superseded by a later deploy stops being blamed for that deploy's crash * @return the generation to quarantine, or -1 when nothing this process adopted is to blame */ synchronized long generationToBlame(long pendingReloadGeneration, long liveGeneration) { - if (pendingReloadGeneration >= 0) { + if (pendingReloadGeneration >= 0 && pendingReloadGeneration >= liveGeneration) { return pendingReloadGeneration; } if (unprovenGeneration >= 0 && unprovenGeneration == liveGeneration) { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java index 889a8817e5..41d7154e68 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java @@ -20,6 +20,41 @@ static boolean accepts(long runningGeneration, long incomingGeneration) { return incomingGeneration > runningGeneration; } + /** + * What a failed reload owes, decided from where the store stands relative to the failure. + * + * The three cases matter because {@link #rollbackApplies} alone conflates two of them: a failure superseded by a newer deploy must stay silent, but a failure the store never adopted - an oversize payload, a full disk, a restart deploy missing its dex - still has to reach the host and the banner, or its only trace is the host's deploy timeout. + * + * @param runningGeneration + * generation the store holds right now + * @param failedGeneration + * generation whose reload failed + * @return the action the failure path must take + */ + static FailureAction onReloadFailure(long runningGeneration, long failedGeneration) { + if (rollbackApplies(runningGeneration, failedGeneration)) { + return FailureAction.ROLLBACK_AND_REPORT; + } + return runningGeneration > failedGeneration + ? FailureAction.LEAVE_ALONE + : FailureAction.REPORT_ONLY; + } + + /** + * The pending-reload generation the runtime should hold after a payload applies. + * + * A foreground apply leaves the generation pending until its first resumed frame acks it. A backgrounded apply acks at apply time, and the pending slot must still be assigned - not skipped: leaving an older generation's pending value behind is what let the crash guard blame it for a later generation's crash, and let the crashing generation escape quarantine. + * + * @param resumedActivity + * whether an activity is resumed, i.e. whether there is a frame to prove the reload on + * @param generation + * the generation just applied + * @return the value the pending slot must take: the generation while its ack waits for a frame, or -1 when the apply was already acked + */ + static long pendingAfterApply(boolean resumedActivity, long generation) { + return resumedActivity ? generation : -1; + } + /** * Decides whether a failed reload's rollback still applies. * @@ -36,4 +71,14 @@ static boolean rollbackApplies(long runningGeneration, long failedGeneration) { } private Generations() {} + + /** What {@link #onReloadFailure} tells the failure path to do. */ + enum FailureAction { + /** The store still holds the failed generation: roll back, quarantine, report, banner. */ + ROLLBACK_AND_REPORT, + /** The store never adopted the failed generation: nothing to roll back or quarantine, but report and banner still fire. */ + REPORT_ONLY, + /** A newer generation owns the store, the pending ack and the screen: touch nothing, say nothing. */ + LEAVE_ALONE + } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 6bb99d87d0..134264c180 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -17,7 +17,7 @@ * * Installed once per process by {@link QuickBuildAppComponentFactory} at application instantiation; Context work - binding to CoGo, cache dirs - waits for the first activity, since the Application has no base context yet. * - * Failure policy throughout: a reload failure reports the crash and rolls back, so the app keeps running the last working code rather than crash-looping or silently claiming the new generation. + * Failure policy throughout: a reload failure reports the crash, and rolls back when the store adopted the failed generation, so the app keeps running the last working code rather than crash-looping or silently claiming the new generation. Only a failure superseded by a newer live generation stays silent. */ final class QuickBuildRuntime { @@ -273,10 +273,13 @@ void handlePayload(long generation, ParcelFileDescriptor dexPayload, PayloadStore.INSTANCE.baselineFingerprint(), application.getCacheDir()); } - if (tracker.hasResumedActivity()) { - pendingReloadStartUptime = startUptime; - pendingReloadGeneration = generation; - } else { + boolean resumed = tracker.hasResumedActivity(); + pendingReloadStartUptime = startUptime; + // Assigned on BOTH branches: the backgrounded ack must also clear any older + // generation still pending, or the crash guard keeps blaming it for this + // generation's crashes - and this generation escapes quarantine. + pendingReloadGeneration = Generations.pendingAfterApply(resumed, generation); + if (!resumed) { // Backgrounded: no resumed activity to hang a frame callback on, so // waiting for render-proof would time out a deploy that worked. Ack at // apply+persist, like the restart path. @@ -487,7 +490,9 @@ private void exitForRestart() { } /** - * Rolls back to {@code rollback}, reports the crash to CoGo, and shows the banner; the app stays on the old generation. + * Reports the failure to CoGo and shows the banner; rolls back only when the store adopted the failed generation, so the app stays on the old one either way. + * + * A failure before the apply took - an oversize payload, a persist failure, a restart deploy missing its dex - leaves the store on the previous generation, so there is nothing to restore or quarantine; the report and banner still fire, or the host's only signal would be its deploy timeout. Only a failure superseded by a newer live generation stays silent, since that generation owns the store, the pending ack and the screen. * * @param generation * the generation that failed, which CoGo marks bad @@ -497,16 +502,20 @@ private void exitForRestart() { * the failure, summarized into both the report and the banner */ private void failReload(long generation, PayloadStore.Payload rollback, Throwable error) { - if (!Generations.rollbackApplies(PayloadStore.INSTANCE.generation(), generation)) { + Generations.FailureAction action = Generations.onReloadFailure( + PayloadStore.INSTANCE.generation(), generation); + if (action == Generations.FailureAction.LEAVE_ALONE) { // A newer payload landed while this one was failing, so it owns the store, the // pending ack and the screen. Rolling back here would undo a deploy that worked. RuntimeLog.w("gen " + generation + " failed but gen " + PayloadStore.INSTANCE.generation() + " is live; leaving it alone", error); return; } - PayloadStore.INSTANCE.restore(rollback); - quarantine(generation); - pendingReloadGeneration = -1; + if (action == Generations.FailureAction.ROLLBACK_AND_REPORT) { + PayloadStore.INSTANCE.restore(rollback); + quarantine(generation); + pendingReloadGeneration = -1; + } String summary = summarize(error); setOverlayState(OverlayState.crashed(summary)); client.reportCrash(generation, summary); diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 9264b33782..0f8e492466 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -6,6 +6,8 @@ import android.content.res.loader.ResourcesLoader; import android.content.res.loader.ResourcesProvider; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import android.os.ParcelFileDescriptor; import java.io.File; import java.io.IOException; @@ -209,13 +211,19 @@ private void applyTableLegacy(ParcelFileDescriptor tableFd, long generation, @TargetApi(30) private void applyTableWithLoader(ParcelFileDescriptor tableFd) throws IOException { try { - ResourcesProvider next = ResourcesProvider.loadFromApk(tableFd, null); - synchronized (this) { - ResourcesProvider previous = provider; - provider = next; - installProviders(); - Streams.closeQuietly(previous); - } + final ResourcesProvider next = ResourcesProvider.loadFromApk(tableFd, null); + swapProvidersOnMain(new Runnable() { + + @Override + public void run() { + synchronized (ResourceStore.this) { + ResourcesProvider previous = provider; + provider = next; + installProviders(); + Streams.closeQuietly(previous); + } + } + }); } finally { // loadFromApk dups the fd internally; ours must be closed either way. Streams.closeQuietly(tableFd); @@ -275,16 +283,54 @@ private void installProviders() { */ @TargetApi(30) private void refreshAssetsProvider(File dir) throws IOException { - DirectoryAssetsProvider nextDir = new DirectoryAssetsProvider(dir); - ResourcesProvider next = ResourcesProvider.empty(nextDir); - synchronized (this) { - ResourcesProvider previous = assetsProvider; - DirectoryAssetsProvider previousDir = assetsDirProvider; - assetsProvider = next; - assetsDirProvider = nextDir; - installProviders(); - Streams.closeQuietly(previous); - Streams.closeQuietly(previousDir); + final DirectoryAssetsProvider nextDir = new DirectoryAssetsProvider(dir); + final ResourcesProvider next = ResourcesProvider.empty(nextDir); + swapProvidersOnMain(new Runnable() { + + @Override + public void run() { + synchronized (ResourceStore.this) { + ResourcesProvider previous = assetsProvider; + DirectoryAssetsProvider previousDir = assetsDirProvider; + assetsProvider = next; + assetsDirProvider = nextDir; + installProviders(); + Streams.closeQuietly(previous); + Streams.closeQuietly(previousDir); + } + } + }); + } + + /** + * Runs a provider swap on the main thread, inline when already there. + * + * The swap must not run on the binder thread the deploy arrives on: setProviders rebuilds every attached Resources in place and the swap then closes the replaced provider's ApkAssets, either of which can race an inflation already in progress on the main thread - a lookup straddling the swap mixes old and new values, or touches a just-closed provider. Serializing with the main thread removes both races, and Looper FIFO keeps a posted swap ahead of the recreate the deploy posts right after it. + * + * Inline on the main thread, not posted, because the boot restore path runs during the first activity's creation and its swap must land before anything inflates. + * + * A swap failure is logged rather than thrown: on the posted path no caller is left to catch it, and the previous provider set stays live either way, which the next deploy replaces. + * + * @param swap + * the field swap + setProviders + close of the replaced provider, taking the store's monitor itself + */ + private void swapProvidersOnMain(final Runnable swap) { + Runnable guarded = new Runnable() { + + @Override + public void run() { + try { + swap.run(); + } catch (Throwable error) { + RuntimeLog.e("resource provider swap failed; previous set stays live", error); + } + } + }; + Looper main = Looper.getMainLooper(); + if (Looper.myLooper() == main) { + guarded.run(); + } else { + new Handler(main).post(guarded); } } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java index 835ac763b8..6d6c057897 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java @@ -70,6 +70,15 @@ void anUnstampedBaselineIsNotAGenerationWorthRefusing() { assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 0)).isEqualTo(NO_PENDING_RELOAD); } + @Test + void aPendingReloadAheadOfTheStoreIsStillBlamed() { + // The failure path's window: gen 11 failed, the store was just restored to 10, and the + // pending slot has not been cleared yet. A crash here is still gen 11's doing. + BootProbation probation = new BootProbation(); + + assertThat(probation.generationToBlame(11, 10)).isEqualTo(11); + } + @Test void aPendingReloadOutranksTheGenerationThisProcessBooted() { // Both are live claims on the screen; the hot swap is the newer one, and it is the one @@ -80,6 +89,17 @@ void aPendingReloadOutranksTheGenerationThisProcessBooted() { assertThat(probation.generationToBlame(11, 11)).isEqualTo(11); } + @Test + void aPendingReloadTheStoreMovedPastIsNotBlamed() { + // Deploy 9 lands foreground and is left pending its first frame; the user backgrounds, + // deploy 10 applies and acks in the background. A crash now is gen 10's: blaming stale + // 9 would quarantine working code, mislead CoGo, AND let 10 boot again on relaunch - + // the exact startup crash-loop the quarantine machinery exists to break. + BootProbation probation = new BootProbation(); + + assertThat(probation.generationToBlame(9, 10)).isEqualTo(NO_PENDING_RELOAD); + } + @Test void aProcessThatBootedTheInstalledCodeBlamesNothing() { // The baked baseline is the floor a quarantine falls back to. Refusing it would leave diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java index f185a0a34d..bc2b5af26d 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java @@ -6,6 +6,18 @@ class GenerationsTest { + @Test + void aBackgroundedApplyClearsThePendingSlotItAlreadyAcked() { + // The regression: deploy 9 lands foreground and is left pending, the user backgrounds + // before its first resumed frame, deploy 10 applies and acks in the background. The + // backgrounded apply must still assign the slot - skipping it left stale 9 behind, so + // the crash guard blamed 9 for gen 10's crashes and 10 escaped quarantine. + assertThat(Generations.pendingAfterApply(false, 10)).isEqualTo(-1); + } + + // The stamped-baseline boot gate is pinned in PersistedSelectionTest, against the real + // PayloadStore seam - re-numbering accepts() cases here could not fail on that caller. + @Test void acceptsStrictlyNewerGeneration() { assertThat(Generations.accepts(0, 1)).isTrue(); @@ -13,8 +25,36 @@ void acceptsStrictlyNewerGeneration() { assertThat(Generations.accepts(41, 100)).isTrue(); } - // The stamped-baseline boot gate is pinned in PersistedSelectionTest, against the real - // PayloadStore seam - re-numbering accepts() cases here could not fail on that caller. + @Test + void aFailureSupersededByANewerLiveGenerationStaysSilent() { + // Gen 6's posted recreate throws after gen 7 already applied: gen 7 owns the store, + // the pending ack and the screen, so gen 6's failure must touch and say nothing. + assertThat(Generations.onReloadFailure(7, 6)) + .isEqualTo(Generations.FailureAction.LEAVE_ALONE); + } + + @Test + void aFailureTheStoreNeverAdoptedStillReports() { + // An oversize payload, a persist failure, a restart deploy missing its dex: the store + // still runs the previous generation, so there is nothing to roll back or quarantine - + // but the report and banner must fire, or the failure's only trace is the host's + // deploy timeout and the developer sees nothing on device. + assertThat(Generations.onReloadFailure(5, 6)) + .isEqualTo(Generations.FailureAction.REPORT_ONLY); + assertThat(Generations.onReloadFailure(0, 1)) + .isEqualTo(Generations.FailureAction.REPORT_ONLY); + } + + @Test + void aFailureWhileTheFailedGenerationOwnsTheStoreRollsBackAndReports() { + assertThat(Generations.onReloadFailure(6, 6)) + .isEqualTo(Generations.FailureAction.ROLLBACK_AND_REPORT); + } + + @Test + void aForegroundApplyLeavesItsGenerationPendingItsFirstFrame() { + assertThat(Generations.pendingAfterApply(true, 10)).isEqualTo(10); + } @Test void rejectsEqualGeneration() { From 987ca37aef9657e63af65a1d020bb4deb0f9da5e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 26 Aug 2026 23:43:44 -0700 Subject: [PATCH 03/40] ADFA-4128 (4/11): address CodeRabbit review - F1716-2 heal a half-finished asset merge on the next run - F1716-5 stop answering a VirtualMachineError with another allocation - F1716-7 take the asset length from the descriptor already open - F1716-8 un-commit a resource provider swap that failed to install Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- .../quickbuild/runtime/AssetExtractor.java | 21 +++++++- .../runtime/DirectoryAssetsProvider.java | 5 +- .../QuickBuildAppComponentFactory.java | 19 +++++++ .../quickbuild/runtime/ResourceStore.java | 26 ++++++++-- .../runtime/AssetExtractorTest.java | 49 +++++++++++++++++++ ...ckBuildAppComponentFactoryRethrowTest.java | 18 +++++++ 6 files changed, 132 insertions(+), 6 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java index d32ec141c7..7ffc861b80 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java @@ -29,6 +29,13 @@ final class AssetExtractor { /** Marker file beside {@link #CURRENT_DIR} naming the baseline the merged assets belong to. */ static final String BASELINE_MARKER = "baseline.fp"; + /** + * Marker file beside {@link #CURRENT_DIR} that exists only while a merge is in flight. + * + * Finding it at the start of the next merge means the previous one died part-way, so the merged dir holds two generations. The baseline marker still matches and later payloads carry only newly-changed files, so nothing else would ever heal it - a wrongly-written file would stay wrong until a forced rebuild. + */ + static final String MERGE_PENDING_MARKER = "merge.pending"; + private static final int BUFFER_SIZE = 16 * 1024; /** @@ -84,6 +91,8 @@ static int extract(InputStream zipStream, File destDir) throws IOException { * * The clear-then-mark order is the safe crash window: a death between the two leaves a mismatched marker, so the next call clears an already-empty dir instead of serving another baseline's assets. * + * A merge that dies part-way is recovered at the START of the next call, not on the failure path: {@link #MERGE_PENDING_MARKER} is written before the first byte and cleared only after the last, and finding it still there clears the dir. A cleared dir is safe - the provider falls through to the APK's baked-in assets - whereas a half-merged one serves a file from the wrong generation. + * * @param zipStream * the changed-assets zip as it arrived over binder; read but never closed * @param assetsRoot @@ -101,11 +110,19 @@ static int extractCumulative(InputStream zipStream, File assetsRoot, } File providerRoot = currentDir(assetsRoot); File marker = new File(assetsRoot, BASELINE_MARKER); - if (!baselineFingerprint.equals(readMarker(marker))) { + File pending = new File(assetsRoot, MERGE_PENDING_MARKER); + if (!baselineFingerprint.equals(readMarker(marker)) || pending.isFile()) { deleteRecursively(providerRoot); writeMarker(marker, baselineFingerprint); } - return extract(zipStream, new File(providerRoot, ASSETS_SUBDIR)); + writeMarker(pending, baselineFingerprint); + int count = extract(zipStream, new File(providerRoot, ASSETS_SUBDIR)); + if (!pending.delete()) { + // The merge itself is complete and correct, but a marker we cannot clear makes the + // next call clear a dir that did not need it. Say so rather than leave it silent. + throw new IOException("cannot clear merge marker " + pending); + } + return count; } /** diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java index 60a0dd95a5..a67a5a719a 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java @@ -75,7 +75,10 @@ public AssetFileDescriptor loadAssetFd(String path, int accessMode) { try { ParcelFileDescriptor fd = ParcelFileDescriptor.open( candidate, ParcelFileDescriptor.MODE_READ_ONLY); - return new AssetFileDescriptor(fd, 0, candidate.length()); + // Size from the descriptor, not a second stat of the path: open() pinned an inode, + // and an extraction renaming the file in between would otherwise pair the old + // inode with the new file's length - a short read, or a read past EOF. + return new AssetFileDescriptor(fd, 0, fd.getStatSize()); } catch (FileNotFoundException error) { // Raced by a concurrent clear; absent and unreadable look the same to the // framework, which falls through to the baked-in copy. diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java index 11e14fd8ee..4aa59e40f4 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java @@ -17,6 +17,20 @@ */ public class QuickBuildAppComponentFactory extends AppComponentFactory { + /** + * Rethrows a throwable the fallback cannot help with, before anything else runs. + * + * A {@link VirtualMachineError} says the VM is out of a resource the retry needs, so logging it (formatting a message, walking a stack trace) and then re-running the same construction allocates again in exactly the state that cannot afford it - and when the default-loader retry happens to succeed, the error is swallowed outright. {@link LinkageError} is deliberately NOT in this set: a stale-payload {@code NoSuchFieldError} is the case the fallback exists for. + * + * @param error + * what the payload loader threw + */ + static void rethrowIfFatal(Throwable error) { + if (error instanceof VirtualMachineError) { + throw (VirtualMachineError) error; + } + } + /** * Throws the failure that best explains a component we could not instantiate from either loader: the PAYLOAD one. * @@ -96,6 +110,7 @@ public Activity instantiateActivity(ClassLoader cl, String className, Intent int try { return super.instantiateActivity(pickLoader(cl, className), className, intent); } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); RuntimeLog.e("payload activity instantiation failed for " + className + "; using default loader", payloadError); try { @@ -129,6 +144,7 @@ public Application instantiateApplication(ClassLoader cl, String className) try { application = super.instantiateApplication(pickLoader(cl, className), className); } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); RuntimeLog.e("payload application instantiation failed; using default loader", payloadError); try { application = super.instantiateApplication(cl, className); @@ -170,6 +186,7 @@ public ContentProvider instantiateProvider(ClassLoader cl, String className) try { return super.instantiateProvider(pickLoader(cl, className), className); } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); RuntimeLog.e("payload provider instantiation failed for " + className + "; using default loader", payloadError); try { @@ -206,6 +223,7 @@ public BroadcastReceiver instantiateReceiver(ClassLoader cl, String className, I try { return super.instantiateReceiver(pickLoader(cl, className), className, intent); } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); RuntimeLog.e("payload receiver instantiation failed for " + className + "; using default loader", payloadError); try { @@ -242,6 +260,7 @@ public Service instantiateService(ClassLoader cl, String className, Intent inten try { return super.instantiateService(pickLoader(cl, className), className, intent); } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); RuntimeLog.e("payload service instantiation failed for " + className + "; using default loader", payloadError); try { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 0f8e492466..1c98dc4d93 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -219,7 +219,17 @@ public void run() { synchronized (ResourceStore.this) { ResourcesProvider previous = provider; provider = next; - installProviders(); + try { + installProviders(); + } catch (RuntimeException | Error error) { + // Un-commit. The loader still holds the previous set, so the field has to as + // well - leaving the rejected provider there makes the next deploy offer it + // again, and dropping `previous` on the floor leaks a provider that is still + // installed. Closing `next` is safe: it was never installed. + provider = previous; + Streams.closeQuietly(next); + throw error; + } Streams.closeQuietly(previous); } } @@ -294,7 +304,17 @@ public void run() { DirectoryAssetsProvider previousDir = assetsDirProvider; assetsProvider = next; assetsDirProvider = nextDir; - installProviders(); + try { + installProviders(); + } catch (RuntimeException | Error error) { + // Same un-commit as applyTableWithLoader: restore the fields the loader still + // reflects, close the pair that never got installed, and let the failure out. + assetsProvider = previous; + assetsDirProvider = previousDir; + Streams.closeQuietly(next); + Streams.closeQuietly(nextDir); + throw error; + } Streams.closeQuietly(previous); Streams.closeQuietly(previousDir); } @@ -309,7 +329,7 @@ public void run() { * * Inline on the main thread, not posted, because the boot restore path runs during the first activity's creation and its swap must land before anything inflates. * - * A swap failure is logged rather than thrown: on the posted path no caller is left to catch it, and the previous provider set stays live either way, which the next deploy replaces. + * A swap failure is logged rather than thrown: on the posted path no caller is left to catch it, and the previous provider set stays live either way, which the next deploy replaces. The result is deliberately NOT returned to the deploy chain - that would make a deploy arriving on a binder thread block on a main-thread round trip in the hot reload path, which is the very thing posting the swap exists to avoid. Each swap un-commits its own fields on failure, so what stays live is a consistent previous generation. * * @param swap * the field swap + setProviders + close of the replaced provider, taking the store's monitor itself diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java index e2b4bb2587..120c90e3bb 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java @@ -43,6 +43,55 @@ private static InputStream zipOf(Map entries) throws IOException @TempDir Path tempDir; + @Test + void aCompletedMergeClearsThePendingMarker() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map first = new LinkedHashMap(); + first.put("kept.txt", "from the first merge".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-1"); + + assertThat(new File(root, AssetExtractor.MERGE_PENDING_MARKER).exists()).isFalse(); + + // And because it is clear, the next merge accumulates instead of starting over - + // a marker left behind would silently throw the first payload's files away. + Map second = new LinkedHashMap(); + second.put("added.txt", "from the second merge".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(second), root, "fp-1"); + + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + assertThat(readFile(new File(assetsDir, "kept.txt"))).isEqualTo("from the first merge"); + assertThat(readFile(new File(assetsDir, "added.txt"))).isEqualTo("from the second merge"); + } + + @Test + void aMergeThatDiedPartWayIsClearedAtTheStartOfTheNextOne() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + Map first = new LinkedHashMap(); + first.put("kept.txt", "from the completed merge".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-1"); + + // One entry lands, the next one escapes the destination and aborts the merge. The + // dir now holds two generations and the baseline marker still matches, so nothing + // downstream can tell. + Map partial = new LinkedHashMap(); + partial.put("half.txt", "half-written".getBytes("UTF-8")); + partial.put("../evil.txt", "escaped".getBytes("UTF-8")); + assertThrows(IOException.class, + () -> AssetExtractor.extractCumulative(zipOf(partial), root, "fp-1")); + assertThat(new File(assetsDir, "half.txt").exists()).isTrue(); + assertThat(new File(root, AssetExtractor.MERGE_PENDING_MARKER).isFile()).isTrue(); + + Map third = new LinkedHashMap(); + third.put("fresh.txt", "after recovery".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(third), root, "fp-1"); + + // Same baseline throughout, so only the pending marker could have forced this clear. + assertThat(new File(assetsDir, "kept.txt").exists()).isFalse(); + assertThat(new File(assetsDir, "half.txt").exists()).isFalse(); + assertThat(readFile(new File(assetsDir, "fresh.txt"))).isEqualTo("after recovery"); + } + @Test void cumulativeMergeKeepsEarlierPayloadsFiles() throws IOException { File root = tempDir.resolve("assets-root").toFile(); diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java index a730541b1e..926bd41ad0 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java @@ -24,6 +24,13 @@ void aCheckedPayloadFailureKeepsItsOwnType() { assertThat(thrown).isSameInstanceAs(payloadError); } + @Test + void aLinkageErrorIsNotFatal() { + // The stale-payload case the default-loader fallback exists for, so it must fall + // through to the retry rather than being rethrown here. + QuickBuildAppComponentFactory.rethrowIfFatal(new NoSuchFieldError("field removed by a stale payload")); + } + @Test void aRuntimePayloadFailurePropagatesUnwrapped() { NoSuchFieldError payloadError = new NoSuchFieldError("field removed by a stale payload"); @@ -45,6 +52,17 @@ void aThrowableNoSignatureAllowsIsWrappedWithTheCauseIntact() throws Exception { assertThat(wrapper.getCause()).isSameInstanceAs(payloadError); } + @Test + void aVirtualMachineErrorIsRethrownRatherThanRetried() { + OutOfMemoryError fatal = new OutOfMemoryError("payload dex would not fit"); + + // Retrying the same construction after this would allocate again in exactly the state + // that cannot afford it, and a retry that happened to succeed would swallow it entirely. + assertThat(assertThrows( + OutOfMemoryError.class, () -> QuickBuildAppComponentFactory.rethrowIfFatal(fatal))) + .isSameInstanceAs(fatal); + } + @Test void oneThrowableAsBothFailuresDoesNotBlowUpOnSelfSuppression() { RuntimeException error = new RuntimeException("the same instance twice"); From 2a46878d80d477bb1c4285f99bd6821fa21c1174 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Sat, 29 Aug 2026 10:46:09 -0700 Subject: [PATCH 04/40] ADFA-4128 (4/11): address Akash's review Fixes for every finding on the runtime module, plus two changes that came out of reviewing them. Crash banner. The copy said "New code crashed", which named the one event this banner cannot observe: the CRASHED state is set only from failReload, so it is always the reload machinery that failed, never the user's own code. It also carried a stack summary it had no room for. It now reads Live reload crashed. App is on the last working version. For more info, see Build Output in Code on the Go. and points at the pane where the full text already goes unchanged. At the narrowest width measured on an A56 at 2x font scale that is five rendered lines, four from 28 characters up; MAX_BANNER_LINES stays at 6, one line of slack, because the line a tighter cap drops is the tail of the pointer - which leaves the reader told to look somewhere without the name of the place. Crash report. It walks up to three causes and prints each one's frames, not just its toString. An Android lifecycle crash always arrives wrapped, so the top frames are ActivityThread's every time and the line naming the developer's bug sits in the cause; reporting the message alone named the exception without ever placing it. markGood retry. lastMarkedGoodGeneration was set before the write was attempted, so a failed markGood was never retried and its latch blocked every later one for the process lifetime, and the KDoc's justification was inverted. Clearing the latch on a bare false is not safe either - markGood answers several situations with one false, and persist runs before apply, so meta.json is briefly ahead of the live generation on every deploy. markGoodCanSucceed separates a failed write from a store that moved on, and only the failed write clears. Payload overtake. onPayload is oneway, so a slower older deploy can be overtaken while it reads its payload and then publish itself over the newer one, leaving disk a generation behind the running process until the next cold boot adopts it. PayloadStore.apply already refuses a generation that is not strictly newer, so this could never reach the screen - only disk. PayloadPersistence now keeps the highest generation this process has published and refuses anything older, throwing StalePayloadException so the deploy path can tell a lost race from a broken store and stay silent about it. The bar rises only after the publishing rename, so a persist that threw part-way does not block its own retry. That guard also separates the two cases a generation number alone conflates. A restarted host counter - the project's state dir wiped while the app stays installed - always arrives in a process that has published nothing, so the mark is zero and the low generation is adopted as before. Both counter-restart tests now build a fresh store object over the same directory, which is the only shape that case has on a device. Payload memory. The resource apk and the assets zip were read whole into memory and written straight back out to files that are reopened as files afterwards, so a cold deploy held two payload-sized arrays live for no benefit on the devices least able to spare them. persist now takes both as streams and copies them through a 16 KB buffer into the same temp-then-fsync-then-rename write. Only the dex stays a byte array, because InMemoryDexClassLoader needs one. The 64 MB cap is unchanged and now guards the streaming path; the parameter types are what keep it that way. Also from Akash: the manifest-merger comment, the KDoc corrections, and the test helper that divided length by width - it modelled a renderer that breaks mid-word, so it read the banner's six real lines as five and could not have caught the overflow it existed for. It wraps on words now, and was watched failing at the old cap before the cap moved. Both new gates were watched red first: the payload cap with its check stubbed out, the overtake refusal with its condition forced false. Only the intended test failed each time. 248 tests green. Banner inset. Photographing the new banner at 2x font scale showed it drawing over the status bar: getRootWindowInsets() comes back null on the first render after a config-change recreate, and the overlay took that as a 0 inset. A null read now means "not measured yet" - the margin is left alone and re-read after the next layout, once, by a listener that removes itself. The decision is a pure static so it can be unit-tested; the deferred re-read firing is checked on a device (A56: banner flush below the 101 px bar at 1.0 and 2.0). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC --- quickbuild/runtime/build.gradle.kts | 6 +- .../quickbuild/runtime/CrashSummary.java | 95 +++++++++ .../runtime/DirectoryAssetsProvider.java | 51 +++-- .../quickbuild/runtime/OverlayState.java | 24 ++- .../runtime/PayloadPersistence.java | 155 ++++++++++++-- .../quickbuild/runtime/QuickBuildClient.java | 9 +- .../quickbuild/runtime/QuickBuildRuntime.java | 134 +++++++----- .../quickbuild/runtime/ResourceStore.java | 140 +++++++++++-- .../quickbuild/runtime/RuntimeLog.java | 18 ++ .../quickbuild/runtime/StatusOverlay.java | 129 +++++++++++- .../quickbuild/runtime/Streams.java | 58 +++++- .../quickbuild/runtime/CrashSummaryTest.java | 195 ++++++++++++++++++ .../runtime/DirectoryAssetsProviderTest.java | 30 ++- .../quickbuild/runtime/OverlayStateTest.java | 10 +- .../runtime/OverlayStateTextEdgeTest.java | 10 +- .../PayloadPersistenceAtomicSetTest.java | 88 +++++++- .../PayloadPersistenceCorruptMetaTest.java | 8 +- .../PayloadPersistenceQuarantineTest.java | 60 +++++- .../runtime/PayloadPersistenceTest.java | 16 +- .../quickbuild/runtime/RuntimeLogTest.java | 5 + .../runtime/StatusOverlayInsetActionTest.java | 45 ++++ .../quickbuild/runtime/StreamsTest.java | 58 +++++- 22 files changed, 1172 insertions(+), 172 deletions(-) create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlayInsetActionTest.java diff --git a/quickbuild/runtime/build.gradle.kts b/quickbuild/runtime/build.gradle.kts index 801e89e83a..ba287659de 100644 --- a/quickbuild/runtime/build.gradle.kts +++ b/quickbuild/runtime/build.gradle.kts @@ -37,9 +37,9 @@ android { // map parsing, asset extraction). Mirrors :quick-build's jupiter setup. tasks.withType { useJUnitPlatform() - // StreamsTest exercises the 256 MB payload cap through the default readFully - // overload; a capped reader legitimately buffers up to the cap before throwing, - // which overflows Gradle's default 512 MB test-worker heap. + // StreamsTest exercises the payload cap through the default readFully overload; a + // capped reader legitimately buffers up to the cap and then copies it, so the peak is + // about twice the cap - more headroom than Gradle's default 512 MB test-worker heap. maxHeapSize = "1g" } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java new file mode 100644 index 0000000000..4ab911cc5c --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java @@ -0,0 +1,95 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Turns a payload crash into the text CoGo is told about it, and owns the height its banner may reach. + * + * One summary, one reader. {@link #forReport} crosses binder to CoGo, which has a screen, a scrollback and the developer's attention, so it carries enough frames to place the fault. The banner over the user's own app carries none of it - it names Build Output and stops - so what remains here for the banner is {@link #MAX_BANNER_LINES} alone. + */ +final class CrashSummary { + + /** + * Lines {@link StatusOverlay}'s banner shows before it ellipsizes, sized against the whole rendered banner. + * + * The crash banner is the tallest state whose text this class controls: a headline of 56 characters and {@code OverlayState.FULL_OUTPUT_POINTER} at 50. Wrapped at 25 characters per line - the narrowest width measured on an A56 at 2x font scale - the headline costs 3 rendered lines and the pointer 2, so the banner is 5 at its tallest. + * + * Five is needed across the 25-to-27 band; from 28 characters up the headline folds into two as well and the banner fits in 4. The cap is left one line above the worst measured case rather than tightened onto it, because the line it would drop is the tail of the pointer - the reader is left told to look somewhere, without the name of the place - and because a font or locale wider than anything measured here should cost a blank line, not a truncated instruction. + * + * An earlier version also put a stack summary on the banner and needed 14 lines to fit it. Dropping the summary is what buys this back, so putting any detail on the banner again means recomputing here rather than raising the cap. {@code StatusOverlay} reads this instead of carrying a number of its own that could drift from it. + * + * One state is outside this arithmetic: {@code BUILD_FAILED}'s detail is a diagnostic line CoGo sends and nothing here caps its length, so a long one still ellipsizes. That predates this budget and is not addressed by it. + */ + static final int MAX_BANNER_LINES = 6; + + /** Frames a report to CoGo names; enough to place the fault, short enough to read. */ + private static final int MAX_REPORT_FRAMES = 5; + + /** Hard cap on the report form, since it crosses binder. */ + private static final int MAX_REPORT_LENGTH = 2000; + + /** + * Causes the report walks past the top throwable. + * + * An Android lifecycle crash always arrives wrapped - the framework rethrows as "Unable to start activity" - so the top throwable's frames are ActivityThread's and the frame naming the developer's bug is one level down. Reporting the top alone spends the whole budget on frames no reader can act on. Three levels covers a wrapped cause and the two rewraps a build pipeline tends to add; {@link #truncate} is the backstop, and it cuts from the deepest cause, which is the end worth losing. + */ + private static final int MAX_REPORT_CAUSES = 3; + + /** + * The full form reported to CoGo. + * + * @param error + * the failure to summarize; must be non-null + * @return the exception and up to {@link #MAX_REPORT_CAUSES} causes, each with up to {@link #MAX_REPORT_FRAMES} frames, truncated to {@link #MAX_REPORT_LENGTH} chars + */ + static String forReport(Throwable error) { + StringBuilder sb = new StringBuilder(); + sb.append(error.toString()); + appendFrames(sb, error, MAX_REPORT_FRAMES); + // Each cause gets its frames too, not just its toString. The frame a developer needs is + // almost never in the top throwable: the framework wraps a lifecycle crash, so the top + // five frames are ActivityThread's every time and the one line naming the bug sits in + // the cause. Reporting the message alone named the exception without ever placing it. + Throwable cause = error.getCause(); + Throwable previous = error; + for (int depth = 0; cause != null && cause != previous && depth < MAX_REPORT_CAUSES; depth++) { + sb.append("\nCaused by: ").append(cause.toString()); + appendFrames(sb, cause, MAX_REPORT_FRAMES); + previous = cause; + cause = cause.getCause(); + } + return truncate(sb, MAX_REPORT_LENGTH); + } + + /** + * Appends at most {@code limit} of {@code error}'s frames, one per line. + * + * @param sb + * the summary under construction + * @param error + * the failure whose trace to read; a trace stripped by the VM is simply empty + * @param limit + * the most frames to append + */ + private static void appendFrames(StringBuilder sb, Throwable error, int limit) { + StackTraceElement[] frames = error.getStackTrace(); + int count = Math.min(frames.length, limit); + for (int i = 0; i < count; i++) { + sb.append("\n at ").append(frames[i]); + } + } + + /** + * @param sb + * the summary under construction + * @param limit + * the most characters to keep + * @return the summary, cut to {@code limit} characters + */ + private static String truncate(StringBuilder sb, int limit) { + if (sb.length() > limit) { + sb.setLength(limit); + } + return sb.toString(); + } + + private CrashSummary() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java index a67a5a719a..5e914cb280 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java @@ -19,26 +19,12 @@ @TargetApi(30) final class DirectoryAssetsProvider implements AssetsProvider, Closeable { + private final File root; + /** - * Whether {@code candidate} resolves strictly inside {@code root}. - * - * Both sides are canonicalized, so {@code ..} segments and symlinks resolve before the comparison rather than being compared as text. The trailing separator is what stops a sibling whose name merely starts with the root's - {@code /a/rootEvil} against root {@code /a/root} - and it also excludes {@code root} itself. - * - * @param root - * the override directory being served - * @param candidate - * a path resolved against it - * @return true when the candidate may be served; false when it escapes, or when either path cannot be canonicalized - unresolvable counts as outside, since a path this process cannot resolve is one it must not serve + * The root's canonical path with a trailing separator, resolved once: {@code root} is final, so its canonical form cannot change over the provider's lifetime. Null when the root could not be canonicalized, which refuses every lookup. */ - static boolean isWithinRoot(File root, File candidate) { - try { - return candidate.getCanonicalPath().startsWith(root.getCanonicalPath() + File.separator); - } catch (IOException error) { - return false; - } - } - - private final File root; + private final String rootPrefix; /** * @param root @@ -46,6 +32,13 @@ static boolean isWithinRoot(File root, File candidate) { */ DirectoryAssetsProvider(File root) { this.root = root; + String prefix; + try { + prefix = root.getCanonicalPath() + File.separator; + } catch (IOException unresolvableRoot) { + prefix = null; + } + this.rootPrefix = prefix; } /** Nothing held open between lookups; here so {@link ResourceStore} can treat providers uniformly. */ @@ -66,7 +59,7 @@ public AssetFileDescriptor loadAssetFd(String path, int accessMode) { File candidate = new File(root, path); // Same containment rule as AssetExtractor: the path arrives from outside // this process's control and must not resolve outside the override dir. - if (!isWithinRoot(root, candidate)) { + if (!isWithinRoot(candidate)) { return null; } if (!candidate.isFile()) { @@ -85,4 +78,24 @@ public AssetFileDescriptor loadAssetFd(String path, int accessMode) { return null; } } + + /** + * Whether {@code candidate} resolves strictly inside the served root. + * + * Both sides are canonicalized, so {@code ..} segments and symlinks resolve before the comparison rather than being compared as text. The root's half is resolved in the constructor instead of here, because this runs for every asset the app opens and this provider sits ahead of the baked-in APK in the loader's list. The trailing separator is what stops a sibling whose name merely starts with the root's - {@code /a/rootEvil} against root {@code /a/root} - and it also excludes the root itself. + * + * @param candidate + * a path resolved against the root + * @return true when the candidate may be served; false when it escapes, or when either path could not be canonicalized - unresolvable counts as outside, since a path this process cannot resolve is one it must not serve + */ + boolean isWithinRoot(File candidate) { + if (rootPrefix == null) { + return false; + } + try { + return candidate.getCanonicalPath().startsWith(rootPrefix); + } catch (IOException error) { + return false; + } + } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java index eb419dc1be..50fe8f1b28 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java @@ -7,6 +7,13 @@ */ final class OverlayState { + /** + * Where a reader goes for the whole failure, named on the crash banner. + * + * The banner is a strip over the user's own app and deliberately cannot scroll, so it names the surface that holds the failure rather than carrying any of it. Build Output is the pane CoGo already writes the reported summary to, so this points at something that exists rather than something we would have to build. Its length is half of {@link CrashSummary#MAX_BANNER_LINES}' arithmetic - lengthening it without revisiting that clips the pointer itself, which is the one line the banner cannot afford to lose. + */ + static final String FULL_OUTPUT_POINTER = "For more info, see Build Output in Code on the Go."; + /** * State for a compile error, carrying the message summary the banner names. The banner is position-free by design: the error location is CoGo's to show, so it never crosses the deploy channel. * @@ -30,14 +37,14 @@ static OverlayState building(long runningGeneration) { } /** - * State for a payload that crashed and was rolled back, with a stack summary as {@code detail}. + * State for a payload that crashed and was rolled back. + * + * The banner takes no stack summary. It covers the user's own app, so it says what happened and names where the detail is, and stops; {@link CrashSummary#forReport} still ships the whole thing to CoGo. A summary here would be text nobody can scroll, on a surface that ellipsizes, in front of an app the reader did not ask us to cover. * - * @param detail - * one-line summary of the crash, appended to the banner; null renders the headline alone * @return the crash state */ - static OverlayState crashed(String detail) { - return new OverlayState(Kind.CRASHED, detail, 0, -1); + static OverlayState crashed() { + return new OverlayState(Kind.CRASHED, null, 0, -1); } /** @@ -125,8 +132,11 @@ String text() { } return sb.toString(); case CRASHED: - return "New code crashed - app is running the last working version" - + (detail == null ? "" : "\n" + detail); + // "Live reload crashed", not "new code crashed": this state is set only from + // failReload, so it is the reload machinery that failed, never the user's own + // code. The old wording named the one event this banner cannot observe. + return "Live reload crashed. App is on the last working version." + "\n" + + FULL_OUTPUT_POINTER; case REINSTALL_PENDING: return "Update needs your OK in Code on the Go - switch back to approve it\n" + "This app is running the last working version"; diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java index f5b4c76d1a..a076018195 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java @@ -1,5 +1,6 @@ package com.itsaky.androidide.quickbuild.runtime; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -123,7 +124,7 @@ private static long generationOf(String name) { private static byte[] readBytes(File file) throws IOException { InputStream in = new FileInputStream(file); try { - return Streams.readFully(in); + return Streams.readFully(in, Streams.MAX_PAYLOAD_BYTES, file.length()); } finally { Streams.closeQuietly(in); } @@ -153,10 +154,24 @@ private static String readText(File file) throws IOException { * when the write, the sync, or both rename attempts fail */ private static void writeAtomic(File target, byte[] bytes) throws IOException { + writeAtomic(target, new ByteArrayInputStream(bytes)); + } + + /** + * Writes temp-then-rename with an fsync, streaming so the contents are never held whole in memory. + * + * @param target + * the final path; its parent must already exist + * @param in + * the contents, read to exhaustion; not closed, since the caller owns it + * @throws IOException + * when the read, the write, the sync, or both rename attempts fail, or when the stream exceeds {@link Streams#MAX_PAYLOAD_BYTES} + */ + private static void writeAtomic(File target, InputStream in) throws IOException { File temp = new File(target.getParentFile(), target.getName() + TEMP_SUFFIX); FileOutputStream out = new FileOutputStream(temp); try { - out.write(bytes); + Streams.copy(in, out, Streams.MAX_PAYLOAD_BYTES); out.getFD().sync(); } finally { Streams.closeQuietly(out); @@ -172,6 +187,13 @@ private static void writeAtomic(File target, byte[] bytes) throws IOException { private final File dir; + /** + * Highest generation this process has published, or 0 before the first publish. + * + * Deliberately per-process rather than read from the store, and that is what separates the two cases a generation number alone conflates. An overtaken deploy is always two binder threads inside one live process, so it is caught here. A restarted host counter always arrives in a process that has published nothing yet - the store it finds was written by an earlier session or install - so this is 0 and the low generation is adopted, which is what the app needs for the deploy to work at all. + */ + private long highestPersistedGeneration; + /** * @param dir * the store directory, app-private and created lazily by {@link #persist}; it need not exist yet @@ -261,7 +283,7 @@ synchronized Loaded load(String expectedFingerprint) { * * @param generation * the generation now on screen; ignored unless the store currently publishes it, since a caller confirming a superseded generation has nothing here to record - * @return true when {@link #GOOD_FILE} names {@code generation} after this call, which is also the moment {@link #quarantine} starts refusing to name it; false when the store no longer publishes it or the write failed, and the caller must go on treating it as unproven + * @return true when {@link #GOOD_FILE} names {@code generation} after this call, which is also the moment {@link #quarantine} starts refusing to name it; false when the store no longer publishes it, when it is already quarantined, or when the write failed, and the caller must go on treating it as unproven. {@link #markGoodCanSucceed} separates the retryable false from the other two. */ synchronized boolean markGood(long generation) { try { @@ -269,6 +291,17 @@ synchronized boolean markGood(long generation) { if (!meta.isFile() || generationIn(meta) != generation) { return false; } + if (generationIn(new File(dir, QUARANTINE_FILE)) == generation) { + // The crash guard got here first. Recording it good anyway leaves both + // markers naming the same generation, and the next boot then finds the + // published set quarantined AND the fallback set quarantined, which load() + // reads as corruption and answers by clearing the whole store - the app + // drops to install-time code with every save since discarded. quarantine() + // holds this same monitor and carries the mirror-image guard, so whichever + // of the two runs first, the second refuses. + RuntimeLog.w("not recording generation " + generation + " as good; it is quarantined"); + return false; + } if (generationIn(new File(dir, GOOD_FILE)) == generation) { return true; } @@ -282,6 +315,21 @@ synchronized boolean markGood(long generation) { } } + /** + * Whether a {@link #markGood} that returned false is worth calling again. + * + * markGood returns the same false for three different situations and only one of them is transient. The store having moved on to a newer generation, or having quarantined this one, are both permanent for this generation - a caller that retried on those would spawn a write per resume for the rest of the process. A failed write is the one the caller should come back to. + * + * @param generation + * the generation whose markGood returned false + * @return true when the store still publishes {@code generation} and has not quarantined it, so the false came from the write rather than from the store's state + */ + synchronized boolean markGoodCanSucceed(long generation) { + File meta = new File(dir, META_FILE); + return meta.isFile() && generationIn(meta) == generation + && generationIn(new File(dir, QUARANTINE_FILE)) != generation; + } + /** * Writes {@code generation} as the newest payload, published as one atomic set. * @@ -294,15 +342,25 @@ synchronized boolean markGood(long generation) { * @param dex * the dex bytes, or null to keep the persisted ones * @param arsc - * the relinked resource apk bytes, or null to keep the persisted ones + * the relinked resource apk, streamed straight to its store file, or null to keep the persisted one * @param assetsZip - * the changed-assets zip bytes, or null to keep the persisted ones + * the changed-assets zip, streamed straight to its store file, or null to keep the persisted one * @return the store's payload files after the write, for callers that apply resources from the persisted copies; each field is null when that kind was never persisted + * @throws StalePayloadException + * when a generation this process already published is newer than this one, which means this deploy was overtaken while it read its payload * @throws IOException * when the directory cannot be created or any write fails; meta.json lands last, so a failure leaves the store on the previous generation, whole */ - synchronized Persisted persist(long generation, String fingerprint, byte[] dex, byte[] arsc, - byte[] assetsZip) throws IOException { + synchronized Persisted persist(long generation, String fingerprint, byte[] dex, InputStream arsc, + InputStream assetsZip) throws IOException { + if (generation < highestPersistedGeneration) { + // onPayload is oneway, so two deploys genuinely arrive on two binder threads. + // The older one can be overtaken while it reads its payload and then publish + // itself over the newer one, leaving disk a generation behind the running + // process - invisible until the next cold boot adopts it. + throw new StalePayloadException("refusing to persist generation " + generation + + " over " + highestPersistedGeneration + " already published by this process"); + } if (!dir.isDirectory() && !dir.mkdirs()) { throw new IOException("cannot create " + dir); } @@ -328,6 +386,9 @@ synchronized Persisted persist(long generation, String fingerprint, byte[] dex, // longer exists and falling back to it would boot a LATER-numbered older build. deleteQuietly(new File(dir, GOOD_FILE)); } + // Only after the publishing rename: a persist that threw part-way published + // nothing, and must not raise the bar against the retry that follows it. + highestPersistedGeneration = generation; collectOrphans(dexName, arscName, assetsName); return new Persisted(fileOrNull(arscName), fileOrNull(assetsName)); } @@ -468,6 +529,28 @@ private long generationIn(File file) { } } + /** + * The previous generation's file name for {@code kind}, when it is still there to carry forward. + * + * @param kind + * the payload kind this deploy did not carry + * @param previous + * the inheritable published meta, or null when there is none + * @return the name to reference, or null when there is nothing inheritable + */ + private String inherit(String kind, Map previous) { + if (previous == null) { + return null; + } + Object inherited = previous.get(kind); + // Only carry forward a name that still resolves; a meta naming a missing file + // would be published as corruption. + if (inherited instanceof String && new File(dir, (String) inherited).isFile()) { + return (String) inherited; + } + return null; + } + /** * Boots the last generation that reached the screen, and republishes it as the current set. * @@ -633,21 +716,33 @@ private Map readInheritableMeta(long generation, String fingerpr */ private String writeOrInherit(String kind, long generation, byte[] bytes, Map previous) throws IOException { - if (bytes != null) { - String name = payloadFileName(kind, generation); - writeAtomic(new File(dir, name), bytes); - return name; - } - if (previous == null) { - return null; - } - Object inherited = previous.get(kind); - // Only carry forward a name that still resolves; a meta naming a missing file - // would be published as corruption. - if (inherited instanceof String && new File(dir, (String) inherited).isFile()) { - return (String) inherited; + return writeOrInherit(kind, generation, + bytes == null ? null : new ByteArrayInputStream(bytes), previous); + } + + /** + * Streams one kind's contents to a generation-stamped name, or carries the previous name forward. + * + * @param kind + * the payload kind being written + * @param generation + * the incoming generation, which stamps the new file's name + * @param in + * the contents, or null when this deploy carried nothing of this kind; read to exhaustion, not closed + * @param previous + * the inheritable published meta, or null when there is none + * @return the file name the new meta should reference, or null when this kind has never been persisted + * @throws IOException + * when the write fails + */ + private String writeOrInherit(String kind, long generation, InputStream in, + Map previous) throws IOException { + if (in == null) { + return inherit(kind, previous); } - return null; + String name = payloadFileName(kind, generation); + writeAtomic(new File(dir, name), in); + return name; } /** @@ -705,4 +800,22 @@ static final class Persisted { this.assetsFile = assetsFile; } } + + /** + * Refusal of a payload an already-persisted one has overtaken. + * + * Separate from a plain {@link IOException} so the deploy path can tell "this payload lost a race" from "the store is broken": the first is ordinary and must stay silent, and reporting it would put a crash banner on a screen that is running exactly what it should be. + */ + static final class StalePayloadException extends IOException { + + private static final long serialVersionUID = 1L; + + /** + * @param message + * names both generations, since which one won is the whole content of the event + */ + StalePayloadException(String message) { + super(message); + } + } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index 15afca98ab..6dc21581b2 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -143,6 +143,12 @@ public void onServiceConnected(ComponentName name, IBinder service) { IQuickBuildHost connected = IQuickBuildHost.Stub.asInterface(service); if (connected == null) { RuntimeLog.w("null host proxy from onServiceConnected"); + // Drop the binding before queueing a fresh one, like every other failure path + // here. bindService against a connection the framework still holds a live + // binding for is answered from the existing record rather than by a fresh + // connect, so a rebind stacked on top of one we never released can be dropped + // on the floor - and this branch has no other way back. + unbindQuietly(); scheduleRebind(); return; } @@ -158,6 +164,7 @@ public void onServiceConnected(ComponentName name, IBinder service) { } catch (RemoteException error) { RuntimeLog.e("connect() to CoGo failed", error); host = null; + unbindQuietly(); scheduleRebind(); } catch (RuntimeException error) { // SecurityException (and any other binder-propagatable runtime exception) from @@ -302,7 +309,7 @@ private void unbindQuietly() { try { context.unbindService(this); } catch (Throwable error) { - RuntimeLog.d("unbindService: " + error); + RuntimeLog.d("unbindService", error); } } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 134264c180..4d335d63f4 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -21,9 +21,6 @@ */ final class QuickBuildRuntime { - /** Stack frames kept in a crash summary; enough to place the fault, short enough to read. */ - private static final int MAX_CRASH_SUMMARY_FRAMES = 5; - /** * How long a restart deploy waits for the framework to take the app's state, across both phases of the handoff. * @@ -31,9 +28,6 @@ final class QuickBuildRuntime { */ private static final long RESTART_HANDOFF_TIMEOUT_MILLIS = 1500; - /** Hard cap on a crash summary, since it crosses binder and lands in a banner. */ - private static final int MAX_CRASH_SUMMARY_LENGTH = 2000; - /** The one runtime per process, or null before {@link #install}. */ private static volatile QuickBuildRuntime instance; @@ -104,37 +98,29 @@ private static byte[] readBytesAndClose(ParcelFileDescriptor fd) throws IOExcept if (fd == null) { return null; } + // A regular-file fd knows its size, so the buffer is allocated once instead of + // doubling and copying its way up to a payload-sized array; a pipe reports -1, + // which readFully takes as unknown. + long size = fd.getStatSize(); InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(fd); try { - return Streams.readFully(in); + return Streams.readFully(in, Streams.MAX_PAYLOAD_BYTES, size); } finally { in.close(); } } /** - * Compact single-string stack summary for reportCrash / the overlay. + * Wraps one payload fd as a stream, without reading any of it. * - * @param error - * the failure to summarize; must be non-null - * @return the exception, up to {@link #MAX_CRASH_SUMMARY_FRAMES} frames and its immediate cause, truncated to {@link #MAX_CRASH_SUMMARY_LENGTH} chars + * Only the dex has to become a byte array, because {@code InMemoryDexClassLoader} takes one. Resources and assets are written straight to their store files and then reopened as files, so nothing is gained by holding a whole resource apk in the heap on the way past - and on a low-end device it is what an oversize deploy would die on. + * + * @param fd + * the payload fd, or null when this deploy carried nothing of that kind + * @return a stream that owns {@code fd} and closes it, or null when {@code fd} was null */ - private static String summarize(Throwable error) { - StringBuilder sb = new StringBuilder(); - sb.append(error.toString()); - StackTraceElement[] frames = error.getStackTrace(); - int limit = Math.min(frames.length, MAX_CRASH_SUMMARY_FRAMES); - for (int i = 0; i < limit; i++) { - sb.append("\n at ").append(frames[i]); - } - Throwable cause = error.getCause(); - if (cause != null && cause != error) { - sb.append("\nCaused by: ").append(cause.toString()); - } - if (sb.length() > MAX_CRASH_SUMMARY_LENGTH) { - sb.setLength(MAX_CRASH_SUMMARY_LENGTH); - } - return sb.toString(); + private static InputStream streamOf(ParcelFileDescriptor fd) { + return fd == null ? null : new ParcelFileDescriptor.AutoCloseInputStream(fd); } private final Application application; @@ -230,12 +216,22 @@ void handlePayload(long generation, ParcelFileDescriptor dexPayload, ParcelFileDescriptor resourcesPayload, ParcelFileDescriptor assetsPayload, String metadataJson) { long startUptime = SystemClock.uptimeMillis(); - PayloadStore.Payload previous = PayloadStore.INSTANCE.snapshot(); + PayloadStore.Payload previous = null; + InputStream arscIn = null; + InputStream assetsIn = null; try { DeployMetadata metadata = parseMetadata(metadataJson); byte[] dexBytes = readBytesAndClose(dexPayload); - byte[] arscBytes = readBytesAndClose(resourcesPayload); - byte[] assetsBytes = readBytesAndClose(assetsPayload); + arscIn = streamOf(resourcesPayload); + assetsIn = streamOf(assetsPayload); + PayloadPersistence.Persisted persisted; + // Read here rather than on the way in: draining the dex takes long enough for + // a newer deploy to land and finish, and a check against a generation read + // before that would pass. This is the fast path, not the guard - it can still + // be overtaken between here and the persist below, and what stops the older + // payload reaching disk is the store refusing a generation it has already + // published past. + previous = PayloadStore.INSTANCE.snapshot(); if (previous == null || !Generations.accepts(previous.generation, generation)) { // Deliberately unreported: claiming a reload for a payload we refused @@ -249,7 +245,7 @@ void handlePayload(long generation, ParcelFileDescriptor dexPayload, // generation label. A CoGo bug if it ever happens. throw new IllegalStateException("restart deploy without a dex payload"); } - PayloadPersistence.Persisted persisted = persistPayload(generation, dexBytes, arscBytes, assetsBytes); + persisted = persistPayload(generation, dexBytes, arscIn, assetsIn); if (metadata.restart) { // Never applied in-memory: this process is already condemned, and the // fresh one boots the persisted generation. @@ -263,15 +259,26 @@ void handlePayload(long generation, ParcelFileDescriptor dexPayload, // Raced by a newer payload between the acceptance check and here. return; } - if (arscBytes != null) { + final PayloadStore.Payload rollback = previous; + ResourceStore.SwapFailure onSwapFailure = new ResourceStore.SwapFailure() { + + @Override + public void onSwapFailed(Throwable error) { + // The swap lands after this method returns, so without this the deploy + // acks a reload the app is not showing: CoGo reports success while the + // screen still renders the previous table, and no banner fires. + failReload(generation, rollback, error); + } + }; + if (resourcesPayload != null) { ResourceStore.INSTANCE.applyTable( - openReadOnly(persisted.arscFile), generation, application); + openReadOnly(persisted.arscFile), generation, application, onSwapFailure); } - if (assetsBytes != null) { + if (assetsPayload != null) { ResourceStore.INSTANCE.applyAssets( openReadOnly(persisted.assetsFile), PayloadStore.INSTANCE.baselineFingerprint(), - application.getCacheDir()); + application, onSwapFailure); } boolean resumed = tracker.hasResumedActivity(); pendingReloadStartUptime = startUptime; @@ -295,7 +302,6 @@ void handlePayload(long generation, ParcelFileDescriptor dexPayload, client.reportReloaded(generation, SystemClock.uptimeMillis() - startUptime); } final long reloadGeneration = generation; - final PayloadStore.Payload rollback = previous; mainHandler.post(new Runnable() { @Override @@ -303,12 +309,22 @@ public void run() { reloadOnMain(reloadGeneration, rollback); } }); + } catch (PayloadPersistence.StalePayloadException overtaken) { + // Deliberately unreported, like the acceptance check above: the screen is + // running the newer payload that overtook this one, so there is nothing wrong + // to tell the user about and nothing for the host to roll back. + RuntimeLog.w("dropping payload gen " + generation + ": " + overtaken.getMessage()); } catch (Throwable error) { RuntimeLog.e("payload gen " + generation + " failed to apply", error); Streams.closeQuietly(dexPayload); Streams.closeQuietly(resourcesPayload); Streams.closeQuietly(assetsPayload); failReload(generation, previous, error); + } finally { + // Also covers the early returns: an overtaken or restart deploy leaves here + // without having read these, and an unclosed fd leaks for the process life. + Streams.closeQuietly(arscIn); + Streams.closeQuietly(assetsIn); } } @@ -392,15 +408,18 @@ private void applyPendingBootResources(android.content.Context context) { return; } try { + // No failure listener: there is no deploy in flight to fail here, and the store + // already logs a failed swap. Baseline resources stay live and the next deploy + // re-applies the current ones, which is what this method's contract promises. if (pending.arscFile != null) { ResourceStore.INSTANCE.applyTable( - openReadOnly(pending.arscFile), pending.generation, context); + openReadOnly(pending.arscFile), pending.generation, context, null); } if (pending.assetsFile != null) { ResourceStore.INSTANCE.applyAssets( openReadOnly(pending.assetsFile), PayloadStore.INSTANCE.baselineFingerprint(), - context.getCacheDir()); + context, null); } RuntimeLog.i("restored persisted resources for gen " + pending.generation); } catch (Throwable error) { @@ -516,9 +535,12 @@ private void failReload(long generation, PayloadStore.Payload rollback, Throwabl quarantine(generation); pendingReloadGeneration = -1; } - String summary = summarize(error); - setOverlayState(OverlayState.crashed(summary)); - client.reportCrash(generation, summary); + // The banner gets no summary at all: it is a few unscrollable lines over the user's + // own app, so a stack put there is clipped mid-frame and the frames naming the fault + // are the half nobody sees. It names Build Output instead, and the report below is + // what actually puts the text there. + setOverlayState(OverlayState.crashed()); + client.reportCrash(generation, CrashSummary.forReport(error)); } /** @@ -548,7 +570,7 @@ public void uncaughtException(Thread thread, Throwable error) { // adopt it and die the same way again - and the marker is what // sends that relaunch to the last generation that ran instead. quarantine(doomed); - client.reportCrash(doomed, summarize(error)); + client.reportCrash(doomed, CrashSummary.forReport(error)); } } catch (Throwable ignored) { // The crash guard itself must never throw. @@ -565,7 +587,7 @@ public void uncaughtException(Thread thread, Throwable error) { * * Called from a resumed activity, which is the bar that matters: the failure a fallback has to survive is a payload that throws on the way to the screen, so a generation that got there is one a fresh process can boot. Without this a quarantine drops the app to install-time code and discards every save since. * - * The probation ends on the recorded write rather than on the resume that prompted it, so the two facts stay simultaneous: the moment this generation stops being blamed for a crash is the moment there is something to fall back to instead. A write that fails leaves it on probation, which is the safe direction - {@link PayloadPersistence#quarantine} refuses to name a recorded generation, so the cost of blaming one wrongly is a log line. + * The probation ends on the recorded write rather than on the resume that prompted it, so the two facts stay simultaneous: the moment this generation stops being blamed for a crash is the moment there is something to fall back to instead. A write that fails leaves it on probation, which is the expensive direction, not a safe one: nothing recorded it, so a later crash anywhere in the app blames a generation that demonstrably reached the screen and quarantines it. That is why a failed write releases the latch and the next resume tries again. * * Written off the main thread, because the write is fsynced and this runs on the frame path; latched per generation, so it costs one short-lived thread per generation rather than one per resume. Losing the write to a process death only makes the fallback one generation older. */ @@ -582,6 +604,16 @@ private void markLiveGenerationGood() { public void run() { if (store.markGood(generation)) { bootProbation.proved(generation); + return; + } + // markGood answers three situations with one false, and only a failed + // write is worth coming back to. The other two - the store has moved on, + // or has quarantined this generation - can never succeed, and releasing + // the latch for them would start a write thread on every resume. The + // store-moved-on case is ordinary, not exotic: persist runs before apply, + // so meta.json is briefly ahead of the live generation on every deploy. + if (lastMarkedGoodGeneration == generation && store.markGoodCanSucceed(generation)) { + lastMarkedGoodGeneration = -1; } } }, "qb-mark-good").start(); @@ -595,15 +627,15 @@ public void run() { * @param dex * the dex bytes, or null to keep whatever is persisted * @param arsc - * the relinked resource apk bytes, or null to keep whatever is persisted + * the relinked resource apk, streamed to the store, or null to keep whatever is persisted * @param assetsZip - * the changed-assets zip bytes, or null to keep whatever is persisted + * the changed-assets zip, streamed to the store, or null to keep whatever is persisted * @return the store's payload files, which the resource paths then open read-only * @throws IOException * when the store is unavailable or the write fails, so the deploy fails loudly instead of leaving the boot path behind the running generation */ private PayloadPersistence.Persisted persistPayload(long generation, byte[] dex, - byte[] arsc, byte[] assetsZip) throws IOException { + InputStream arsc, InputStream assetsZip) throws IOException { PayloadPersistence store = PayloadStore.INSTANCE.persistence(); String fingerprint = PayloadStore.INSTANCE.baselineFingerprint(); if (store == null || fingerprint == null) { @@ -644,6 +676,16 @@ private void reloadOnMain(long generation, PayloadStore.Payload rollback) { top.recreate(); } else { RuntimeLog.i("no live activity; gen " + generation + " applies on next launch"); + if (pendingReloadGeneration == generation) { + // The activity that was resumed when this deploy was accepted is gone, + // so there is no frame left to ack on - the same situation the + // backgrounded branch already acks at apply time. Without this the + // host learns only from its deploy timeout, and the crash guard goes + // on blaming this generation for anything the app throws later. + pendingReloadGeneration = -1; + client.reportReloaded(generation, + SystemClock.uptimeMillis() - pendingReloadStartUptime); + } } // A foreground deploy's reportReloaded fires from onActivityResumed, after // the reload rendered; a backgrounded one was acked at apply time. diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 1c98dc4d93..5343a60836 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -35,6 +35,27 @@ final class ResourceStore { /** Cache subdirectory holding the cumulative extracted assets and their baseline marker. */ private static final String ASSETS_ROOT_DIR = "quickbuild-assets"; + /** + * Hands a swap failure to the caller's listener without letting the listener's own failure escape. + * + * This runs on the main thread inside the swap's guard, so a throw here would replace a resource failure with an unrelated crash. + * + * @param onFailure + * the caller's listener; null is a no-op + * @param error + * the failure to report, already logged + */ + private static void reportSwapFailure(SwapFailure onFailure, Throwable error) { + if (onFailure == null) { + return; + } + try { + onFailure.onSwapFailed(error); + } catch (Throwable reportFailure) { + RuntimeLog.e("resource swap failure listener threw", reportFailure); + } + } + private final ResourceSwapStrategy strategy; /** The API 30+ loader, created on the first resource or assets payload and never replaced. */ @@ -55,6 +76,9 @@ final class ResourceStore { /** Latches the unsupported-SDK warning so it is logged once, not once per deploy. */ private boolean warnedNoResourceReload; + /** Whether the application Resources has the loader; written under the monitor on the main thread. */ + private boolean attachedAppResources; + /** * @param strategy * the swap mechanism to use; injected so tests can drive each branch without an SDK level @@ -79,19 +103,21 @@ private ResourceStore() { * the changed-assets zip; always closed, success or failure * @param baselineFingerprint * the running baseline's fingerprint, which keys the cumulative dir - * @param cacheRoot - * the app's cache directory, the parent of the cumulative dir + * @param appContext + * application context, for the cache dir the cumulative override lives under and the Resources the loader attaches to + * @param onFailure + * told when the posted provider swap fails, since that lands after this method returns; null when the caller has nothing to do about it * @throws IOException * on a read, extraction, path-traversal or provider failure; the previous override stays live */ - void applyAssets(ParcelFileDescriptor assetsFd, String baselineFingerprint, File cacheRoot) - throws IOException { - File assetsRoot = new File(cacheRoot, ASSETS_ROOT_DIR); + void applyAssets(ParcelFileDescriptor assetsFd, String baselineFingerprint, Context appContext, + SwapFailure onFailure) throws IOException { + File assetsRoot = new File(appContext.getCacheDir(), ASSETS_ROOT_DIR); InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(assetsFd); try { int extracted = AssetExtractor.extractCumulative(in, assetsRoot, baselineFingerprint); if (strategy == ResourceSwapStrategy.RESOURCES_LOADER) { - refreshAssetsProvider(AssetExtractor.currentDir(assetsRoot)); + refreshAssetsProvider(AssetExtractor.currentDir(assetsRoot), appContext, onFailure); } RuntimeLog.i("merged " + extracted + " changed asset(s) into the override"); } finally { @@ -113,15 +139,17 @@ void applyAssets(ParcelFileDescriptor assetsFd, String baselineFingerprint, File * @param generation * the payload generation, used only by the API 28/29 path to name its file * @param appContext - * application context, used only by the API 28/29 path for its cache dir and Resources + * application context, for the Resources the loader attaches to and, on API 28/29, the cache dir and the Resources to mount onto + * @param onFailure + * told when the posted provider swap fails, since that lands after this method returns; null when the caller has nothing to do about it * @throws IOException * when the swap fails; an unsupported SDK is not a failure, it warns once and drops the payload */ - void applyTable(ParcelFileDescriptor tableFd, long generation, Context appContext) - throws IOException { + void applyTable(ParcelFileDescriptor tableFd, long generation, Context appContext, + SwapFailure onFailure) throws IOException { switch (strategy) { case RESOURCES_LOADER: - applyTableWithLoader(tableFd); + applyTableWithLoader(tableFd, appContext, onFailure); return; case LEGACY_ASSET_PATH: applyTableLegacy(tableFd, generation, appContext); @@ -160,7 +188,7 @@ void attachTo(Resources resources) { LegacyResourceSwap.addAssetPath(resources.getAssets(), zip.getAbsolutePath()); LegacyResourceSwap.flushCaches(resources); } catch (Throwable error) { - RuntimeLog.d("legacy attachTo skipped: " + error); + RuntimeLog.d("legacy attachTo skipped", error); } } } @@ -205,14 +233,19 @@ private void applyTableLegacy(ParcelFileDescriptor tableFd, long generation, * * @param tableFd * the relinked resource apk; loadFromApk dups it, so this method closes ours either way + * @param appContext + * application context, whose Resources the loader is attached to on first use + * @param onFailure + * told when the posted swap fails or is refused; null when the caller has nothing to do about it * @throws IOException * when the apk cannot be loaded as a provider; the previous provider stays live and attached */ @TargetApi(30) - private void applyTableWithLoader(ParcelFileDescriptor tableFd) throws IOException { + private void applyTableWithLoader(ParcelFileDescriptor tableFd, final Context appContext, + SwapFailure onFailure) throws IOException { try { final ResourcesProvider next = ResourcesProvider.loadFromApk(tableFd, null); - swapProvidersOnMain(new Runnable() { + boolean willRun = swapProvidersOnMain(new Runnable() { @Override public void run() { @@ -230,16 +263,40 @@ public void run() { Streams.closeQuietly(next); throw error; } + attachAppResources(appContext); Streams.closeQuietly(previous); } } - }); + }, onFailure); + if (!willRun) { + // The swap will never run, so nothing else will ever close this provider. + Streams.closeQuietly(next); + } } finally { // loadFromApk dups the fd internally; ours must be closed either way. Streams.closeQuietly(tableFd); } } + /** + * Adds the loader to the application Resources, once. + * + * Activity Resources get the loader through {@link #attachTo}, but nothing ever creates the application's, so without this a Service, a ContentProvider or a notification builder keeps resolving the baseline table while the activity on screen resolves the new one - the two disagree about the same resource id. The API 28/29 path already mounts onto the application Resources explicitly; this makes the API 30+ path symmetric. + * + * Once is enough: the loader is long-lived and every later provider swap propagates to each Resources already attached to it. Callers hold the monitor and have just installed the providers, so the loader exists. + * + * @param appContext + * application context; null is ignored, which only costs the symmetry this restores + */ + @TargetApi(30) + private void attachAppResources(Context appContext) { + if (attachedAppResources || appContext == null) { + return; + } + attachedAppResources = true; + attachLoaderTo(appContext.getResources()); + } + /** * Adds the process-wide loader to one Resources object. TargetApi: reached only on SDK >= 30. * @@ -257,7 +314,7 @@ private void attachLoaderTo(Resources resources) { } catch (Throwable error) { // Already attached, or an unusual Resources implementation. Not worth // crashing over. - RuntimeLog.d("attachTo skipped: " + error); + RuntimeLog.d("attachTo skipped", error); } } @@ -288,14 +345,19 @@ private void installProviders() { * * @param dir * the merged override dir laid out as an APK root (assets under {@code assets/}) + * @param appContext + * application context, whose Resources the loader is attached to on first use + * @param onFailure + * told when the posted swap fails or is refused; null when the caller has nothing to do about it * @throws IOException * when the provider cannot be created; the previous one stays live and attached */ @TargetApi(30) - private void refreshAssetsProvider(File dir) throws IOException { + private void refreshAssetsProvider(File dir, final Context appContext, SwapFailure onFailure) + throws IOException { final DirectoryAssetsProvider nextDir = new DirectoryAssetsProvider(dir); final ResourcesProvider next = ResourcesProvider.empty(nextDir); - swapProvidersOnMain(new Runnable() { + boolean willRun = swapProvidersOnMain(new Runnable() { @Override public void run() { @@ -315,11 +377,17 @@ public void run() { Streams.closeQuietly(nextDir); throw error; } + attachAppResources(appContext); Streams.closeQuietly(previous); Streams.closeQuietly(previousDir); } } - }); + }, onFailure); + if (!willRun) { + // The swap will never run, so nothing else will ever close this pair. + Streams.closeQuietly(next); + Streams.closeQuietly(nextDir); + } } /** @@ -329,12 +397,15 @@ public void run() { * * Inline on the main thread, not posted, because the boot restore path runs during the first activity's creation and its swap must land before anything inflates. * - * A swap failure is logged rather than thrown: on the posted path no caller is left to catch it, and the previous provider set stays live either way, which the next deploy replaces. The result is deliberately NOT returned to the deploy chain - that would make a deploy arriving on a binder thread block on a main-thread round trip in the hot reload path, which is the very thing posting the swap exists to avoid. Each swap un-commits its own fields on failure, so what stays live is a consistent previous generation. + * A swap failure is never thrown from here: on the posted path no caller is left to catch it, and the previous provider set stays live either way. The result is still deliberately NOT returned synchronously to the deploy chain - that would make a deploy arriving on a binder thread block on a main-thread round trip in the hot reload path, which is the very thing posting the swap exists to avoid. It travels back through {@code onFailure} instead, so the deploy that queued the swap can fail rather than ack a swap that did not land. Each swap un-commits its own fields on failure, so what stays live is a consistent previous generation. * * @param swap * the field swap + setProviders + close of the replaced provider, taking the store's monitor itself + * @param onFailure + * told when the swap threw or was never accepted; may be null + * @return true when the swap has run or is queued to run, false when the main looper refused it and the caller still owns the providers it created */ - private void swapProvidersOnMain(final Runnable swap) { + private boolean swapProvidersOnMain(final Runnable swap, final SwapFailure onFailure) { Runnable guarded = new Runnable() { @Override @@ -343,14 +414,39 @@ public void run() { swap.run(); } catch (Throwable error) { RuntimeLog.e("resource provider swap failed; previous set stays live", error); + reportSwapFailure(onFailure, error); } } }; Looper main = Looper.getMainLooper(); if (Looper.myLooper() == main) { guarded.run(); - } else { - new Handler(main).post(guarded); + return true; + } + if (new Handler(main).post(guarded)) { + return true; } + // The main looper is quitting, so the runnable will never run. Without this the + // deploy would ack a swap that never happened and the app would render the old + // table under the new generation's label. + IllegalStateException error = new IllegalStateException( + "main looper refused the resource provider swap"); + RuntimeLog.e("resource provider swap was not queued; previous set stays live", error); + reportSwapFailure(onFailure, error); + return false; + } + + /** + * Told when a posted provider swap did not land, so the deploy that queued it can fail rather than ack. + * + * The swap runs after the method that queued it has returned, so this is the only way the failure reaches the deploy chain. + */ + interface SwapFailure { + + /** + * @param error + * the swap failure, already logged by the store + */ + void onSwapFailed(Throwable error); } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java index d75af414ef..c479948440 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java @@ -26,6 +26,24 @@ static void d(String message) { } } + /** + * Logs at debug level with the swallowed cause attached, mirroring {@link #e} and {@link #w}. + * + * Prefer this over concatenating the exception into the message: {@code toString()} names the exception and nothing else, while the attached trace names where it came from - which is the whole question on the skipped-step paths that log at this level. + * + * @param message + * the line to log; passed through unformatted + * @param error + * the cause to attach, printed with its stack trace; may be null + */ + static void d(String message, Throwable error) { + try { + Log.d(TAG, message, error); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + /** * Logs at error level, for a failure that cost the user a reload. * diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java index 6c5d1c02f1..ed12f9f421 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java @@ -2,9 +2,11 @@ import android.app.Activity; import android.graphics.Color; +import android.text.TextUtils; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; import android.widget.FrameLayout; import android.widget.TextView; @@ -22,6 +24,24 @@ final class StatusOverlay { private static final int COLOR_CRASHED = 0xCCB71C1C; private static final int COLOR_NEUTRAL = 0xCC37474F; + /** + * Decides what a status-bar inset read means. + * + * The null case is the one that matters. {@code getRootWindowInsets} returns null on the first render after a config-change recreate - a font-scale change, say - and a null read is not a zero inset: treating it as one leaves the top margin at 0 and draws the banner over the clock and the status icons. Wait for a real read instead. + * + * @param insetTop + * the status-bar inset the window reported, or null when it has none yet + * @param bannerAttached + * whether the banner is still in the view hierarchy; a detached banner ends the wait whatever the inset says + * @return what the caller should do with this read + */ + static InsetAction insetAction(Integer insetTop, boolean bannerAttached) { + if (!bannerAttached) { + return InsetAction.GIVE_UP; + } + return insetTop == null ? InsetAction.WAIT : InsetAction.APPLY; + } + /** * Banner background color for a state kind. * @@ -41,6 +61,36 @@ private static int colorFor(OverlayState.Kind kind) { } } + /** + * Sets the banner's top margin, only when it changed, to avoid a needless relayout on every render. + * + * @param banner + * the banner view whose layout params are updated in place + * @param top + * the margin to set + */ + private static void setTopMargin(TextView banner, int top) { + ViewGroup.LayoutParams lp = banner.getLayoutParams(); + if (lp instanceof FrameLayout.LayoutParams + && ((FrameLayout.LayoutParams) lp).topMargin != top) { + ((FrameLayout.LayoutParams) lp).topMargin = top; + banner.setLayoutParams(lp); + } + } + + /** + * The window's status-bar inset. + * + * @param decor + * the decor view to read the insets from + * @return the inset, or null when the window has no insets yet - explicitly not 0, which is a real inset value + */ + @SuppressWarnings("deprecation") + private static Integer statusBarInsetTop(ViewGroup decor) { + android.view.WindowInsets insets = decor.getRootWindowInsets(); + return insets == null ? null : Integer.valueOf(insets.getSystemWindowInsetTop()); + } + /** * Makes the banner on {@code activity} match {@code state}, adding, updating or removing it. * @@ -88,22 +138,24 @@ void render(Activity activity, OverlayState state) { /** * Sets the banner's top margin to the status-bar inset, so it starts just below the bar. * - * Reads the inset directly because listener dispatch is consumed by the app's root and never reaches us. The deprecated accessor is the only one available at minSdk 28. + * Reads the inset directly because listener dispatch is consumed by the app's root and never reaches us. The deprecated accessor is the only one available at minSdk 28. When the read comes back null the margin is left alone and re-read after the next layout; see {@link #insetAction}. * * @param decor * the decor view the banner is attached to, the source of the insets * @param banner - * the banner view whose layout params are updated in place, and only when the margin actually changed, to avoid a needless relayout on every render + * the banner view whose layout params are updated in place */ - @SuppressWarnings("deprecation") private void applyStatusBarInset(ViewGroup decor, TextView banner) { - android.view.WindowInsets insets = decor.getRootWindowInsets(); - int top = insets != null ? insets.getSystemWindowInsetTop() : 0; - ViewGroup.LayoutParams lp = banner.getLayoutParams(); - if (lp instanceof FrameLayout.LayoutParams - && ((FrameLayout.LayoutParams) lp).topMargin != top) { - ((FrameLayout.LayoutParams) lp).topMargin = top; - banner.setLayoutParams(lp); + Integer top = statusBarInsetTop(decor); + switch (insetAction(top, banner.getParent() != null)) { + case APPLY: + setTopMargin(banner, top); + break; + case WAIT: + reapplyInsetAfterLayout(decor, banner); + break; + default: + break; } } @@ -119,7 +171,23 @@ private TextView createBanner(Activity activity) { banner.setTag(VIEW_TAG); banner.setTextColor(Color.WHITE); banner.setTextSize(12f); - banner.setMaxLines(6); + // The text is sp, so it grows with the system font scale: measured on an A56, the + // banner wraps at about 53-58 characters per line at 1.0 and 25-32 at 2.0. The + // budget belongs to CrashSummary, which sizes the message against this same + // number - hold it in two places and the two drift. + banner.setMaxLines(CrashSummary.MAX_BANNER_LINES); + // Ellipsize so an overflow reads as one. Without this the text is cut mid-word and + // looks like the whole message, which is how a truncated stack frame passes for a + // complete one. + banner.setEllipsize(TextUtils.TruncateAt.END); + // Do NOT give this a movement method to make it scroll. The banner is a strip + // sitting directly over the app's own toolbar - measured [0,0][1080,285] against + // an appbar of [0,0][1080,180] - and setMovementMethod runs the framework's + // focusable/clickable fixup, so the strip would start consuming touches meant for + // the toolbar underneath: a truncation bug traded for a dead toolbar. Keeping the + // message short enough not to need scrolling is CrashSummary's job instead. + banner.setClickable(false); + banner.setFocusable(false); float density = activity.getResources().getDisplayMetrics().density; final int padding = (int) (8 * density); banner.setPadding(padding, padding, padding, padding); @@ -133,4 +201,43 @@ private TextView createBanner(Activity activity) { banner.setLayoutParams(params); return banner; } + + /** + * Re-reads the inset after each layout until it is available, then applies it once. + * + * The listener removes itself on the first usable read, so two renders before one layout cost one extra no-op listener rather than a leak. + * + * @param decor + * the decor view to re-read the insets from + * @param banner + * the banner whose margin the deferred read updates + */ + private void reapplyInsetAfterLayout(final ViewGroup decor, final TextView banner) { + banner.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + Integer top = statusBarInsetTop(decor); + InsetAction action = insetAction(top, banner.getParent() != null); + if (action == InsetAction.WAIT) { + return; + } + // Fetched again rather than captured: this runs while the banner is + // attached, so it is the live observer the listener sits on. + banner.getViewTreeObserver().removeOnGlobalLayoutListener(this); + if (action == InsetAction.APPLY) { + setTopMargin(banner, top); + } + } + }); + } + + /** What an inset read means: use it, wait for a real one, or stop waiting. */ + enum InsetAction { + /** The inset is known: make it the banner's top margin and stop waiting. */ + APPLY, + /** The window has no insets yet: leave the margin alone and read again after the next layout. */ + WAIT, + /** The banner is no longer attached: stop waiting, so no listener outlives it. */ + GIVE_UP + } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java index b960ee6439..477d296a57 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java @@ -3,6 +3,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; /** * Reads payload fds fully into memory, with a size cap. @@ -16,9 +17,11 @@ final class Streams { /** * Ceiling for {@link #readFully(InputStream)}, guarding against an OOM from a runaway payload. * - * Binder does not size-limit a ParcelFileDescriptor, and payloads are read fully into memory. A legitimate payload is one app's dex, resources and assets, tens of MB even for a whole cold deploy, so hitting 256 MB always means something is wrong. + * Binder does not size-limit a ParcelFileDescriptor, and payloads are read fully into memory. A legitimate payload is one app's dex, resources and assets, tens of MB even for a whole cold deploy, so anything near this always means something is wrong. + * + * The number has to be one a device heap can actually hold, or the guard cannot prevent the OOM it exists for: the buffer doubles as it grows and {@link ByteArrayOutputStream#toByteArray} copies it, so a payload at the cap needs roughly twice the cap live at the copy. At the former 256 MB that was half a gigabyte on a phone whose whole heap is a few hundred MB - the app died before the cap ever fired. 64 MB keeps the peak inside a low-end heap and still leaves generous headroom over a real cold deploy. */ - static final int MAX_PAYLOAD_BYTES = 256 * 1024 * 1024; + static final int MAX_PAYLOAD_BYTES = 64 * 1024 * 1024; /** * Closes {@code closeable} if non-null, swallowing any close failure. @@ -36,6 +39,35 @@ static void closeQuietly(AutoCloseable closeable) { } } + /** + * Copies {@code in} to {@code out} to exhaustion, capped at {@code maxBytes}. Closes neither; the caller owns both. + * + * The point of copying rather than reading into an array is that nothing payload-sized is ever live in the heap: a chunk at a time crosses, so a large resource apk costs the buffer rather than twice its own size. + * + * @param in + * the stream to drain; read but never closed + * @param out + * where the bytes go; written but never closed or flushed + * @param maxBytes + * inclusive ceiling on the total copied; exactly {@code maxBytes} is fine + * @return the number of bytes copied + * @throws IOException + * on a read or write failure, or at the first chunk that would carry the total past {@code maxBytes}, so an oversize stream is refused part-written rather than absorbed + */ + static long copy(InputStream in, OutputStream out, int maxBytes) throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + long total = 0; + int read; + while ((read = in.read(buffer)) != -1) { + if (total + read > maxBytes) { + throw new IOException("stream exceeds the " + maxBytes + "-byte payload limit; rejecting rather than writing it out"); + } + out.write(buffer, 0, read); + total += read; + } + return total; + } + /** * Reads {@code in} to exhaustion, capped at {@link #MAX_PAYLOAD_BYTES}. Does not close the stream; the caller owns it. * @@ -61,7 +93,27 @@ static byte[] readFully(InputStream in) throws IOException { * on a read failure, or at the first chunk that would carry the total past {@code maxBytes}, so it never buffers without bound */ static byte[] readFully(InputStream in, int maxBytes) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); + return readFully(in, maxBytes, 0); + } + + /** + * Reads {@code in} to exhaustion, sizing the buffer up front when the caller knows how big the stream is. Does not close the stream; the caller owns it. + * + * The hint only avoids the doubling-and-copying a growing buffer does; it is invisible in the bytes returned, and a wrong hint costs at most the growth it would have cost anyway. Callers holding a file or a descriptor have an exact size for free and pass it straight through - a length of 0, the negative a pipe reports, or one past {@code maxBytes} is taken as "unknown" here rather than checked at every call site. + * + * @param in + * the stream to drain; read but never closed + * @param maxBytes + * inclusive ceiling on the total read; exactly {@code maxBytes} is fine + * @param sizeHint + * expected byte count, straight from {@code File.length()} or {@code ParcelFileDescriptor.getStatSize()}; anything outside {@code 1..maxBytes} means unknown and starts at the read-buffer size + * @return the whole stream as a fresh array, empty when the stream was already at its end + * @throws IOException + * on a read failure, or at the first chunk that would carry the total past {@code maxBytes}, so it never buffers without bound + */ + static byte[] readFully(InputStream in, int maxBytes, long sizeHint) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream( + sizeHint > 0 && sizeHint <= maxBytes ? (int) sizeHint : BUFFER_SIZE); byte[] buffer = new byte[BUFFER_SIZE]; int read; while ((read = in.read(buffer)) != -1) { diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java new file mode 100644 index 0000000000..b3d8c58500 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java @@ -0,0 +1,195 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pins the split between what a crash tells CoGo and what it tells the user's own screen. + * + * The banner is a few lines of sp text pinned over someone else's app, and it grows with the system font scale, so it carries no stack at all - it names the pane that has one. The report has no such limit and must go on carrying the detail; these tests fail if either half drifts toward the other. + */ +class CrashSummaryTest { + + /** + * Characters the banner fits on a rendered line at its worst measured width. + * + * Measured on an A56 at font scale 2.0, where the banner wrapped at 25-32 characters. The low end is the one worth testing against: it is the width at which the message needs the most lines. + */ + private static final int NARROWEST_MEASURED_LINE_CHARS = 25; + + /** A crash of the shape the deploy path actually reports: deep stack, real cause. */ + private static Throwable deepError() { + RuntimeException error = new RuntimeException("outer failure", + new IllegalStateException("the underlying cause")); + StackTraceElement[] frames = new StackTraceElement[60]; + for (int i = 0; i < frames.length; i++) { + frames[i] = new StackTraceElement("com.example.app.ProxyAppScreen" + i, "onCreate", + "ProxyAppScreen.java", 100 + i); + } + error.setStackTrace(frames); + return error; + } + + /** + * A crash of the shape the device actually produced: framework wrapper, developer's frame in the cause. + * + * Captured from an A56 on 2026-08-27, where a proxy-app reload crash reported five ActivityThread frames and the cause's message, and not one frame of the app under edit. + * + * @return the wrapped failure + */ + private static Throwable lifecycleCrash() { + RuntimeException bug = new RuntimeException("QB-CRASH-PROBE"); + bug.setStackTrace(new StackTraceElement[]{ + new StackTraceElement("com.example.mybasic.MainActivity", "onCreate", "MainActivity.kt", + 22), + new StackTraceElement("android.app.Activity", "performCreate", "Activity.java", 8305)}); + RuntimeException wrapper = new RuntimeException( + "Unable to start activity ComponentInfo{com.example.mybasic/...Proxy0Activity}", bug); + StackTraceElement[] framework = new StackTraceElement[8]; + for (int i = 0; i < framework.length; i++) { + framework[i] = new StackTraceElement("android.app.ActivityThread", "performLaunchActivity", + "ActivityThread.java", 5040 + i); + } + wrapper.setStackTrace(framework); + return wrapper; + } + + private static String veryLongMessage() { + StringBuilder sb = new StringBuilder(); + while (sb.length() < 5000) { + sb.append("a message long enough to run past both caps. "); + } + return sb.toString(); + } + + /** + * Rendered lines {@code text} takes on a view that wraps at {@code width} characters. + * + * Every logical line costs at least one rendered line and a partial one is not shared with the next, which is what makes the worst case worse than dividing the total length. + */ + /** + * Rendered lines {@code text} occupies at {@code width} characters, wrapping at word boundaries. + * + * Dividing the length by the width instead would model a renderer that breaks mid-word, which under-counts every time a word straddles the boundary: the copy this guards needs 6 lines at width 25 and character division claims 5. That is the overflow the test exists to catch, so the arithmetic has to be the one TextView actually performs. + * + * @param text + * the banner text, newlines separating its logical lines + * @param width + * characters per rendered line + * @return the rendered line count + */ + private static int wrappedLineCount(String text, int width) { + int lines = 0; + for (String logical : text.split("\n", -1)) { + int used = 0; + boolean open = false; + for (String word : logical.split(" +")) { + if (word.isEmpty()) { + continue; + } + if (!open) { + lines += 1; + used = 0; + open = true; + } else if (used + 1 + word.length() <= width) { + used += 1 + word.length(); + continue; + } else { + lines += 1; + used = 0; + } + // A word wider than the line wraps mid-word wherever it lands; the remainder + // after the last full line is what the next word has to fit beside. + if (word.length() > width) { + lines += (word.length() - 1) / width; + used = word.length() - ((word.length() - 1) / width) * width; + } else { + used = word.length(); + } + } + if (!open) { + lines += 1; + } + } + return lines; + } + + @Test + void aFramelessThrowableStillReports() { + // A VM that stripped the trace, or a throwable built with writableStackTrace off, + // must not turn a crash report into a second crash. + RuntimeException error = new RuntimeException("no frames"); + error.setStackTrace(new StackTraceElement[0]); + + assertThat(CrashSummary.forReport(error)).contains("no frames"); + } + + @Test + void aReportIsCappedForBinder() { + RuntimeException error = new RuntimeException(veryLongMessage()); + + assertThat(CrashSummary.forReport(error).length()).isAtMost(2000); + } + + @Test + void aReportKeepsTheDetailTheBannerNeverShows() { + String report = CrashSummary.forReport(deepError()); + + // Why the banner can afford to name a pane instead of a stack: CoGo still gets the + // frames, and so does logcat. + assertThat(report).contains("ProxyAppScreen4"); + assertThat(report).contains("the underlying cause"); + } + + @Test + void aReportPlacesTheFaultInTheCauseNotJustNamesIt() { + // The whole argument for taking the stack off the banner is that CoGo still gets one + // worth reading. A wrapped lifecycle crash is the common case and its top frames are + // all framework, so a report that stops at the cause's message spends its budget + // naming an exception it never locates. This is the line the developer needs. + String report = CrashSummary.forReport(lifecycleCrash()); + + assertThat(report).contains("Caused by"); + assertThat(report).contains("MainActivity.kt:22"); + } + + @Test + void aSelfCausedThrowableDoesNotRepeatItself() { + // getCause() returning the throwable itself is legal, and appending it would print + // the same line twice. + RuntimeException error = new RuntimeException("self caused") { + + @Override + public synchronized Throwable getCause() { + return this; + } + }; + + assertThat(CrashSummary.forReport(error)).doesNotContain("Caused by"); + } + + @Test + void theCrashBannerCarriesNoStackAtAll() { + // The property the banner's whole size argument rests on. If a summary is ever put + // back on it, MAX_BANNER_LINES stops describing what renders and this goes red + // before the arithmetic silently becomes wrong. + String rendered = OverlayState.crashed().text(); + + assertThat(rendered).contains("Build Output"); + assertThat(rendered).doesNotContain("Exception"); + assertThat(rendered).doesNotContain(" at "); + assertThat(rendered).doesNotContain("Caused by"); + } + + @Test + void theCrashBannerFitsTheOverlayLineBudgetAtTwoTimesFontScale() { + // Measured over what the overlay actually renders, not over any one part of it: + // measuring one piece against a budget several pieces spend is how the headline's + // own wrap was once free to push text below the ellipsis unnoticed. + String rendered = OverlayState.crashed().text(); + + assertThat(wrappedLineCount(rendered, NARROWEST_MEASURED_LINE_CHARS)) + .isAtMost(CrashSummary.MAX_BANNER_LINES); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java index 64e35be36a..08683234b6 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java @@ -21,7 +21,7 @@ class DirectoryAssetsProviderTest { void aDotDotEscapeIsRefused() { File root = root(); - assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/../../secret.json"))) + assertThat(new DirectoryAssetsProvider(root).isWithinRoot(new File(root, "assets/../../secret.json"))) .isFalse(); } @@ -31,7 +31,7 @@ void aDotDotThatResolvesBackInsideIsServable() { // Textually suspicious, canonically fine: the rule is about where the path lands, // not about whether it spells "..". - assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/../assets/levels.json"))) + assertThat(new DirectoryAssetsProvider(root).isWithinRoot(new File(root, "assets/../assets/levels.json"))) .isTrue(); } @@ -39,7 +39,7 @@ void aDotDotThatResolvesBackInsideIsServable() { void aPathOutsideTheRootIsRefused() { File root = root(); - assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(tempDir.toFile(), "secret.json"))) + assertThat(new DirectoryAssetsProvider(root).isWithinRoot(new File(tempDir.toFile(), "secret.json"))) .isFalse(); } @@ -50,7 +50,7 @@ void aPathThatCannotBeCanonicalizedIsRefused() { // An embedded NUL makes getCanonicalPath throw rather than answer. A path this // process cannot resolve is one it cannot prove is contained, so it must not serve // it - refusing is the safe direction, and the framework falls through. - assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/le\0vels.json"))) + assertThat(new DirectoryAssetsProvider(root).isWithinRoot(new File(root, "assets/le\0vels.json"))) .isFalse(); } @@ -58,17 +58,31 @@ void aPathThatCannotBeCanonicalizedIsRefused() { void aPathUnderTheRootIsServable() { File root = root(); - assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/data/levels.json"))) + assertThat(new DirectoryAssetsProvider(root).isWithinRoot(new File(root, "assets/data/levels.json"))) .isTrue(); } + @Test + void aRootThatCannotBeCanonicalizedRefusesEveryLookup() { + // The root is resolved once now, in the constructor, so its failure has to be + // carried rather than rediscovered per lookup - and it must refuse, not admit. + // Same direction as the per-path rule: a root this process cannot resolve is one + // it cannot prove anything is inside of. + DirectoryAssetsProvider provider = new DirectoryAssetsProvider( + new File(tempDir.toFile(), "over\0ride")); + + assertThat(provider.isWithinRoot(new File(tempDir.toFile(), "override/assets/levels.json"))) + .isFalse(); + assertThat(provider.loadAssetFd("assets/data/levels.json", 0)).isNull(); + } + @Test void aSiblingSharingTheRootsNamePrefixIsRefused() { File root = root(); // Why the rule appends a separator before comparing: "/tmp/x/overrideEvil" starts // with "/tmp/x/override" as text while being an unrelated directory. - assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(tempDir.toFile(), "overrideEvil/secret.json"))) + assertThat(new DirectoryAssetsProvider(root).isWithinRoot(new File(tempDir.toFile(), "overrideEvil/secret.json"))) .isFalse(); } @@ -81,7 +95,7 @@ void aSymlinkOutOfTheRootIsRefused() throws IOException { // Canonicalization is what catches this: the path is textually under the root and // resolves outside it. - assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "link/secret.json"))) + assertThat(new DirectoryAssetsProvider(root).isWithinRoot(new File(root, "link/secret.json"))) .isFalse(); } @@ -110,7 +124,7 @@ void loadAssetFdRefusesAnEscapingPathEvenWhenItResolvesToARealFile() throws IOEx void theRootItselfIsNotInsideItself() { File root = root(); - assertThat(DirectoryAssetsProvider.isWithinRoot(root, root)).isFalse(); + assertThat(new DirectoryAssetsProvider(root).isWithinRoot(root)).isFalse(); } private File root() { diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java index 34336897dc..e2a8cc926d 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java @@ -52,10 +52,10 @@ void buildingWithAnUnknownGenerationStillRendersHonestly() { } @Test - void crashedSaysTheAppRunsTheLastWorkingVersionAndCarriesTheSummary() { - OverlayState state = OverlayState.crashed("java.lang.NullPointerException\n at Foo.bar"); - assertThat(state.text()).contains("running the last working version"); - assertThat(state.text()).contains("NullPointerException"); + void crashedSaysTheAppRunsTheLastWorkingVersionAndNamesTheOutputPane() { + OverlayState state = OverlayState.crashed(); + assertThat(state.text()).contains("on the last working version"); + assertThat(state.text()).contains("Build Output"); assertThat(state.isError()).isTrue(); } @@ -70,7 +70,7 @@ void hiddenRendersNothing() { @Test void onlyBuildingIsBuilding() { assertThat(OverlayState.hidden().isBuilding()).isFalse(); - assertThat(OverlayState.crashed("x").isBuilding()).isFalse(); + assertThat(OverlayState.crashed().isBuilding()).isFalse(); } @Test diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java index 396aaf76c4..c7baeda741 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java @@ -44,10 +44,14 @@ void buildFailedWithNoDetailRendersOnlyTheHeadline() { } @Test - void crashedWithoutDetailRendersOnlyTheHeadline() { - OverlayState state = OverlayState.crashed(null); + void crashedIsExactlyTheHeadlineAndThePointer() { + // Pinned as an equality rather than a contains: the banner's height argument is + // that these two strings are the whole of it, so anything appended has to fail + // here rather than quietly cost lines the budget does not have. + OverlayState state = OverlayState.crashed(); assertThat(state.text()) - .isEqualTo("New code crashed - app is running the last working version"); + .isEqualTo("Live reload crashed. App is on the last working version.\n" + + "For more info, see Build Output in Code on the Go."); } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java index 3df43f2c0f..49a5c9e85c 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java @@ -3,8 +3,10 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.concurrent.CountDownLatch; @@ -26,15 +28,36 @@ private static byte[] bytes(String s) { return s.getBytes(StandardCharsets.UTF_8); } + private static InputStream stream(String text) { + return new ByteArrayInputStream(bytes(text)); + } + @TempDir File temp; + @Test + void aFailedPersistDoesNotBlockItsOwnRetry() throws IOException { + // The bar rises only on a published generation. A persist that threw part-way + // published nothing, so refusing the retry would strand the app on the older + // generation for the life of the process. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), stream("arsc1"), null); + blockWrite(store, PayloadPersistence.KIND_ARSC, 2); + assertThrows(IOException.class, + () -> store.persist(2, FP, bytes("dex2"), stream("arsc2"), null)); + unblockWrite(store, PayloadPersistence.KIND_ARSC, 2); + + store.persist(2, FP, bytes("dex2"), stream("arsc2"), null); + + assertThat(store.load(FP).generation).isEqualTo(2); + } + @Test void aLaterPersistCollectsWhatATornWriteLeftBehind() throws IOException { PayloadPersistence store = store(); store.persist(1, FP, bytes("dex1"), null, null); blockWrite(store, PayloadPersistence.KIND_ARSC, 2); - assertThrows(IOException.class, () -> store.persist(2, FP, bytes("dex2"), bytes("arsc2"), null)); + assertThrows(IOException.class, () -> store.persist(2, FP, bytes("dex2"), stream("arsc2"), null)); store.persist(3, FP, bytes("dex3"), null, null); @@ -48,7 +71,7 @@ void aMetaNamingAMissingFileIsCorruptionNotAnAbsentKind() throws IOException { // Serving the subset that happens to be present is exactly the mixed store this // layout exists to prevent, so a dangling reference must discard the store. PayloadPersistence store = store(); - store.persist(1, FP, bytes("dex1"), bytes("arsc1"), null); + store.persist(1, FP, bytes("dex1"), stream("arsc1"), null); assertThat(payload(store, PayloadPersistence.KIND_ARSC, 1).delete()).isTrue(); assertThat(store.load(FP)).isNull(); @@ -73,15 +96,37 @@ void anOldFlatLayoutStoreIsDiscardedRatherThanAdopted() throws IOException { assertThat(dir.exists()).isFalse(); } + @Test + void anOvertakenDeployIsRefusedRatherThanPublishedOverTheNewerOne() throws IOException { + // onPayload is oneway, so a slow older deploy can still be inside persist when a + // newer one finishes. Publishing it would leave disk a generation behind the + // running process, which shows up only on the next cold boot. + PayloadPersistence store = store(); + store.persist(41, FP, bytes("dex41"), stream("arsc41"), null); + + assertThrows(PayloadPersistence.StalePayloadException.class, + () -> store.persist(40, FP, bytes("dex40"), stream("arsc40"), null)); + + // Not merely refused - the store is untouched, still whole, still on 41. + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(41); + assertThat(loaded.dex).isEqualTo(bytes("dex41")); + } + @Test void aStoreClaimingANewerGenerationIsNotInheritedFrom() throws IOException { // The host's counter restarts if the project's state dir is wiped while the app // stays installed, so a low generation can arrive at a store claiming a high one. // Carrying gen 40's resources forward would pair this dex with a LATER build's // table - the one direction cumulative deltas do not make safe. - PayloadPersistence store = store(); - store.persist(40, FP, bytes("dex40"), bytes("arsc40"), null); - + PayloadPersistence earlier = store(); + earlier.persist(40, FP, bytes("dex40"), stream("arsc40"), null); + + // A fresh store over the same directory, because that is the only shape this case + // has: the wipe happens between app processes, so gen 1 always arrives at a store + // object that has published nothing. A store that had published 40 in THIS process + // would be watching an overtaken deploy instead, and refuses it. + PayloadPersistence store = new PayloadPersistence(earlier.dir()); store.persist(1, FP, bytes("dex1"), null, null); PayloadPersistence.Loaded loaded = store.load(FP); @@ -94,10 +139,10 @@ void aStoreClaimingANewerGenerationIsNotInheritedFrom() throws IOException { @Test void aTornPersistNeverPairsOneGenerationsDexWithAnothersResources() throws IOException { PayloadPersistence store = store(); - store.persist(1, FP, bytes("dex1"), bytes("arsc1"), null); + store.persist(1, FP, bytes("dex1"), stream("arsc1"), null); blockWrite(store, PayloadPersistence.KIND_ARSC, 2); - assertThrows(IOException.class, () -> store.persist(2, FP, bytes("dex2"), bytes("arsc2"), null)); + assertThrows(IOException.class, () -> store.persist(2, FP, bytes("dex2"), stream("arsc2"), null)); // The whole of generation 1, or nothing. Not gen 2's dex against gen 1's table, // and not a discarded store either - gen 1 is still complete and bootable. @@ -114,7 +159,7 @@ void aTornPersistOfTheFirstEverGenerationLeavesNoStoreAtAll() throws IOException blockWrite(store, PayloadPersistence.KIND_ASSETS, 1); assertThrows(IOException.class, - () -> store.persist(1, FP, bytes("dex1"), bytes("arsc1"), bytes("assets1"))); + () -> store.persist(1, FP, bytes("dex1"), stream("arsc1"), stream("assets1"))); // No meta was ever published, so there is nothing to adopt - the boot falls back // to the baseline rather than to a dex with no matching resources. @@ -127,7 +172,7 @@ void concurrentDeploysAlwaysLeaveOneWholeLoadableGeneration() throws Exception { // two calls at once. Interleaved inheritance reads and orphan collection would // publish a meta naming a file the other thread had just collected. final PayloadPersistence store = store(); - store.persist(1, FP, bytes("dex1"), bytes("arsc1"), bytes("assets1")); + store.persist(1, FP, bytes("dex1"), stream("arsc1"), stream("assets1")); final AtomicReference failure = new AtomicReference(); Thread dexDeploys = new Thread(persister(store, failure, 2, 40, true)); Thread resourceDeploys = new Thread(persister(store, failure, 3, 41, false)); @@ -231,10 +276,15 @@ public void run() { if (dex) { store.persist(generation, FP, bytes("dex" + generation), null, null); } else { - store.persist(generation, FP, null, bytes("arsc" + generation), - bytes("assets" + generation)); + store.persist(generation, FP, null, stream("arsc" + generation), + stream("assets" + generation)); } } + } catch (PayloadPersistence.StalePayloadException overtaken) { + // Expected here, and the reason this test still means something: the two + // threads interleave, so whichever falls behind is refused exactly as an + // overtaken deploy is on a device. What must still hold is the invariant + // asserted after the join - the store is never left half-published. } catch (Throwable error) { failure.set(error); } @@ -245,4 +295,20 @@ public void run() { private PayloadPersistence store() { return new PayloadPersistence(new File(temp, "payload")); } + + /** + * Undoes {@link #blockWrite}, so the same generation can be persisted successfully on a retry. + * + * @param store + * the store whose write to unblock + * @param kind + * the payload kind to unblock + * @param generation + * the generation whose write to unblock + */ + private void unblockWrite(PayloadPersistence store, String kind, long generation) { + File target = payload(store, kind, generation); + assertThat(new File(target, "child").delete()).isTrue(); + assertThat(target.delete()).isTrue(); + } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java index 803762bff0..e5668e2579 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java @@ -2,8 +2,10 @@ import static com.google.common.truth.Truth.assertThat; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import org.junit.jupiter.api.Test; @@ -22,6 +24,10 @@ private static byte[] bytes(String s) { return s.getBytes(StandardCharsets.UTF_8); } + private static InputStream stream(String text) { + return new ByteArrayInputStream(bytes(text)); + } + @TempDir File temp; @@ -160,7 +166,7 @@ void persistInheritsNothingFromAStoreLeftByAnotherBaseline() throws IOException // pairing it with the new dex is precisely the startup crash the set-atomicity // work exists to prevent. PayloadPersistence store = store(); - store.persist(1, "an-older-baseline", bytes("dex1"), bytes("arsc1"), null); + store.persist(1, "an-older-baseline", bytes("dex1"), stream("arsc1"), null); store.persist(2, FP, bytes("dex2"), null, null); diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java index 019f33c554..ff26e3317e 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java @@ -3,8 +3,10 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import org.junit.jupiter.api.Test; @@ -23,6 +25,10 @@ private static byte[] bytes(String s) { return s.getBytes(StandardCharsets.UTF_8); } + private static InputStream stream(String text) { + return new ByteArrayInputStream(bytes(text)); + } + @TempDir File temp; @@ -85,7 +91,7 @@ void anUnreadableMarkerIsIgnoredRatherThanBlockingEveryBoot() throws IOException @Test void aQuarantinedGenerationWithNothingGoodBehindItDiscardsTheStore() throws IOException { PayloadPersistence store = store(); - store.persist(7, FP, bytes("dex7"), bytes("arsc7"), null); + store.persist(7, FP, bytes("dex7"), stream("arsc7"), null); store.quarantine(7); @@ -104,7 +110,7 @@ void aQuarantineFallsBackToTheLastGenerationThatRan() throws IOException { // instead means the app comes back where the session already is, so nothing is // re-sent and nothing crashes twice. PayloadPersistence store = store(); - store.persist(7, FP, bytes("dex7"), bytes("arsc7"), null); + store.persist(7, FP, bytes("dex7"), stream("arsc7"), null); store.markGood(7); store.persist(8, FP, bytes("dex8"), null, null); @@ -119,15 +125,41 @@ void aQuarantineFallsBackToTheLastGenerationThatRan() throws IOException { .isEqualTo(PayloadPersistence.payloadFileName(PayloadPersistence.KIND_ARSC, 7)); } + @Test + void aQuarantineThatBeatMarkGoodLeavesTheEarlierFallbackIntact() throws IOException { + // The race the guard exists for, in the order that used to lose. Booting from a + // persisted generation leaves it unproven, so the first resume starts the good + // write while the crash guard may already be quarantining that same generation. + // With both markers naming 8, the next boot finds the published set quarantined + // AND the fallback quarantined, reads that as corruption and clears the whole + // store - the app drops to install-time code and every save since goes with it. + // Landing on 7 is the entire point of the fallback file. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + + store.quarantine(8); + assertThat(store.markGood(8)).isFalse(); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(7); + assertThat(store.dir().exists()).isTrue(); + } + @Test void aRestartedGenerationCounterDropsTheFallbackFromTheOldSequence() throws IOException { // The project's state dir was wiped while the app stayed installed, so numbering // restarts. Falling back to 13 from the old sequence would boot an older build under // a higher number - the one mismatch direction the store cannot make safe. - PayloadPersistence store = store(); - store.persist(13, FP, bytes("dex13"), null, null); - store.markGood(13); + PayloadPersistence earlier = store(); + earlier.persist(13, FP, bytes("dex13"), null, null); + earlier.markGood(13); + // A fresh store object over the same directory, because the wipe happens between + // app processes: gen 3 always arrives at a store that has published nothing yet. + PayloadPersistence store = new PayloadPersistence(earlier.dir()); store.persist(3, FP, bytes("dex3"), null, null); assertThat(new File(store.dir(), PayloadPersistence.GOOD_FILE).exists()).isFalse(); @@ -150,6 +182,24 @@ void aSuccessfulPersistClearsTheMarker() throws IOException { assertThat(store.load(FP).generation).isEqualTo(8); } + @Test + void markGoodCanSucceedSeparatesAFailedWriteFromAStoreThatMovedOn() throws IOException { + // markGood answers three situations with one false, and the caller may retry only + // the transient one. Reading a bare false as retryable starts a write thread on + // every resume; reading it as permanent strands the generation unproven for the + // process lifetime, which leaves the crash guard blaming it. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + + assertThat(store.markGoodCanSucceed(7)).isTrue(); + + store.persist(8, FP, bytes("dex8"), null, null); + assertThat(store.markGoodCanSucceed(7)).isFalse(); + + store.quarantine(8); + assertThat(store.markGoodCanSucceed(8)).isFalse(); + } + @Test void markGoodIgnoresAGenerationTheStoreNoLongerPublishes() throws IOException { // A late confirmation for a superseded generation must not record 7's files as the diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java index aabc8e55d9..4287c74a9f 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java @@ -2,9 +2,11 @@ import static com.google.common.truth.Truth.assertThat; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import org.junit.jupiter.api.Test; @@ -22,6 +24,10 @@ private static File payload(PayloadPersistence store, String kind, long generati return new File(store.dir(), PayloadPersistence.payloadFileName(kind, generation)); } + private static InputStream stream(String text) { + return new ByteArrayInputStream(bytes(text)); + } + @TempDir File temp; @@ -115,8 +121,8 @@ void keepsNewestFilePerKindAcrossDeltaDeploys() throws IOException { // gets the newest dex AND the newest resources even when they shipped apart. PayloadPersistence store = store(); store.persist(1, FP, bytes("dex1"), null, null); - store.persist(2, FP, null, bytes("arsc2"), null); - store.persist(3, FP, bytes("dex3"), null, bytes("assets3")); + store.persist(2, FP, null, stream("arsc2"), null); + store.persist(3, FP, bytes("dex3"), null, stream("assets3")); PayloadPersistence.Loaded loaded = store.load(FP); assertThat(loaded.generation).isEqualTo(3); @@ -139,7 +145,7 @@ void metaWithoutFingerprintDeletesTheStore() throws IOException { @Test void persistReturnsTheCumulativeResourceFiles() throws IOException { PayloadPersistence store = store(); - store.persist(1, FP, null, bytes("arsc1"), null); + store.persist(1, FP, null, stream("arsc1"), null); PayloadPersistence.Persisted persisted = store.persist(2, FP, bytes("dex2"), null, null); // The dex-only deploy still sees the previously persisted arsc. @@ -151,7 +157,7 @@ void persistReturnsTheCumulativeResourceFiles() throws IOException { @Test void resourceOnlyHistoryLoadsWithNullDex() throws IOException { PayloadPersistence store = store(); - store.persist(1, FP, null, bytes("arsc1"), null); + store.persist(1, FP, null, stream("arsc1"), null); PayloadPersistence.Loaded loaded = store.load(FP); assertThat(loaded.generation).isEqualTo(1); @@ -162,7 +168,7 @@ void resourceOnlyHistoryLoadsWithNullDex() throws IOException { @Test void roundTripsAFullPayload() throws IOException { PayloadPersistence store = store(); - store.persist(3, FP, bytes("dex3"), bytes("arsc3"), bytes("assets3")); + store.persist(3, FP, bytes("dex3"), stream("arsc3"), stream("assets3")); PayloadPersistence.Loaded loaded = store.load(FP); assertThat(loaded).isNotNull(); diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java index c446d02ea3..09ece2fc95 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java @@ -17,6 +17,11 @@ void debugSwallowsTheLogThrow() { assertDoesNotThrow(() -> RuntimeLog.d("debug message")); } + @Test + void debugWithThrowableSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.d("debug message", new RuntimeException("cause"))); + } + @Test void environmentSanityTheLogStubActuallyThrows() { // Self-validation: if Log stopped throwing here (e.g. returnDefaultValues flipped diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlayInsetActionTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlayInsetActionTest.java new file mode 100644 index 0000000000..3630c3fb02 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlayInsetActionTest.java @@ -0,0 +1,45 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Covers what the banner does with a status-bar inset read. + * + * The banner drew over the clock at font scale 2.0 because the read comes back null on the first render after the config-change recreate, and a null read was taken as a 0 inset. The decision is pulled out of the View code so it can be checked here; that the deferred re-read actually fires is a device check, not a JVM one. + */ +class StatusOverlayInsetActionTest { + + @Test + void aDetachedBannerIsNotMovedEvenWhenTheInsetArrives() { + assertThat(StatusOverlay.insetAction(Integer.valueOf(83), false)) + .isEqualTo(StatusOverlay.InsetAction.GIVE_UP); + } + + @Test + void aDetachedBannerStopsTheWaitEvenWithNoInsetYet() { + // Otherwise the deferred re-read outlives the banner it was going to move. + assertThat(StatusOverlay.insetAction(null, false)) + .isEqualTo(StatusOverlay.InsetAction.GIVE_UP); + } + + @Test + void anAvailableInsetIsApplied() { + assertThat(StatusOverlay.insetAction(Integer.valueOf(83), true)) + .isEqualTo(StatusOverlay.InsetAction.APPLY); + } + + @Test + void anUnavailableInsetWaitsInsteadOfBeingTreatedAsZero() { + // The regression: null means "not measured yet", not "no status bar". + assertThat(StatusOverlay.insetAction(null, true)).isEqualTo(StatusOverlay.InsetAction.WAIT); + } + + @Test + void aZeroInsetIsStillARealReadAndIsApplied() { + // A window with no status bar reports 0. That ends the wait, unlike null. + assertThat(StatusOverlay.insetAction(Integer.valueOf(0), true)) + .isEqualTo(StatusOverlay.InsetAction.APPLY); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java index 9e018517b8..b212349122 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java @@ -4,12 +4,68 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Random; import org.junit.jupiter.api.Test; class StreamsTest { + @Test + void aCopyRefusesAtTheCapRatherThanWritingPastIt() { + // The cap has to hold on the streaming path too, or moving resources off the heap + // would have quietly removed the only guard against a runaway payload. + byte[] data = new byte[64 * 1024 + 1]; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + IOException thrown = assertThrows(IOException.class, + () -> Streams.copy(new ByteArrayInputStream(data), out, 64 * 1024)); + assertThat(thrown).hasMessageThat().contains(String.valueOf(64 * 1024)); + assertThat(out.size()).isAtMost(64 * 1024); + } + + @Test + void aCopyReproducesTheStreamExactlyAcrossBufferBoundaries() throws IOException { + // Sized past the internal buffer on purpose: a copy that mishandled a partial + // final chunk would still pass on anything that fits in one read. + byte[] data = new byte[40 * 1024 + 7]; + new Random(11).nextBytes(data); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + long copied = Streams.copy(new ByteArrayInputStream(data), out, 64 * 1024); + + assertThat(copied).isEqualTo(data.length); + assertThat(out.toByteArray()).isEqualTo(data); + } + + @Test + void aSizeHintDoesNotRaiseTheCap() { + // The hint arrives from the same descriptor the stream does, so an oversized one + // must never be read as permission to buffer that much. + byte[] data = new byte[64 * 1024 + 1]; + + IOException thrown = assertThrows(IOException.class, + () -> Streams.readFully(new ByteArrayInputStream(data), 64 * 1024, data.length)); + assertThat(thrown).hasMessageThat().contains(String.valueOf(64 * 1024)); + } + + @Test + void aSizeHintNeverChangesTheBytesReturned() throws IOException { + // The hint only picks the starting buffer size. Each value here is one a real + // caller passes - an exact File.length(), the -1 a pipe reports, a stale length + // from a file that changed under the read, and one past the cap - and all four + // must return the same array, or presizing has become a correctness surface. + byte[] data = new byte[40 * 1024]; + new Random(23).nextBytes(data); + + assertThat(Streams.readFully(new ByteArrayInputStream(data), 64 * 1024, data.length)) + .isEqualTo(data); + assertThat(Streams.readFully(new ByteArrayInputStream(data), 64 * 1024, -1L)).isEqualTo(data); + assertThat(Streams.readFully(new ByteArrayInputStream(data), 64 * 1024, 8L)).isEqualTo(data); + assertThat(Streams.readFully(new ByteArrayInputStream(data), 64 * 1024, Long.MAX_VALUE)) + .isEqualTo(data); + } + @Test void defaultOverloadAppliesThePayloadCap() { // The 1-arg overload every production call site uses must carry the cap itself - a @@ -67,7 +123,7 @@ void readsUnderCapContentIntact() throws IOException { /** * Claims {@code size} zero bytes without ever allocating them. * - * Makes the 256 MB default cap testable in-heap: the capped reader must throw before it buffers anywhere near that much. + * Makes the default cap testable in-heap: the capped reader must throw before it buffers anywhere near that much. */ private static final class OversizedStream extends java.io.InputStream { From 9df3d71c9f2b4f4059e300d801884315d12b42b7 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Sat, 29 Aug 2026 16:10:00 -0700 Subject: [PATCH 05/40] ADFA-4128 (4/11): drop the unused IQuickBuildHost.disconnect AIDL method The runtime only ever disconnected by process death, which ProxyAppConnections.onDisconnected already handles. Asked for in review on #1718. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC --- .../aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl | 3 --- 1 file changed, 3 deletions(-) diff --git a/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl index b004d43fbe..e8d1bc08af 100644 --- a/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl +++ b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl @@ -21,7 +21,4 @@ interface IQuickBuildHost { /** The payload for {@code generation} crashed in render/lifecycle. */ oneway void reportCrash(long generation, String stackSummary); - - /** Drop the registration for {@code packageName}, so CoGo stops sending it payloads. */ - void disconnect(String packageName); } From 3998b9895041707c9e2f0cba22d447535b716f62 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 31 Aug 2026 18:48:06 -0700 Subject: [PATCH 06/40] ADFA-4128: qb-04 review fixes - overlay observer capture, BUILD_FAILED clamp, streamed apk write, resource-attach latch, log overload Akash's 08-31 review of #1716, items 1/2/3/5/6 (4 and 7 deferred to followup tickets per the triage): - StatusOverlay: capture the ViewTreeObserver at add time; on GIVE_UP the banner is detached and a re-fetch returns a floating observer, making the removal a silent no-op. isAlive() guard with a decor-observer fallback. - OverlayState/CrashSummary: BUILD_FAILED detail clamped to a one-line budget (BUILD_FAILED_DETAIL_CHARS) and the Build Output pointer appended, same shape as CRASHED; budget arithmetic documented from both ends. - LegacyResourceSwap: stream the resource apk to disk instead of buffering it in heap (small-heap API 28/29 devices); partial file deleted on a failed copy so the throw-means-nothing-written contract holds. - ResourceStore: attachedAppResources latched only when the attach succeeded, so a swallowed failure is retried on the next deploy. - QuickBuildClient: two-arg RuntimeLog.w keeps the stack trace. Tests: clamp test verified red before the fix; partial-file-delete test added; three edge tests updated for the pointer line. 255 green. Also: plain-language pass over the comments added by these fixes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01STCsdMzx9daNBcqMN424Ci --- .../quickbuild/runtime/CrashSummary.java | 11 ++++-- .../runtime/LegacyResourceSwap.java | 17 +++++++-- .../quickbuild/runtime/OverlayState.java | 14 ++++++-- .../quickbuild/runtime/QuickBuildClient.java | 2 +- .../quickbuild/runtime/QuickBuildRuntime.java | 34 ++++++++++++++++-- .../quickbuild/runtime/ResourceStore.java | 13 ++++--- .../quickbuild/runtime/StatusOverlay.java | 17 ++++++--- .../quickbuild/runtime/BuildStatusTest.java | 3 +- .../runtime/LegacyResourceSwapTest.java | 21 +++++++++++ .../quickbuild/runtime/OverlayStateTest.java | 18 ++++++++++ .../runtime/OverlayStateTextEdgeTest.java | 9 +++-- ...ickBuildRuntimeFailReloadDispatchTest.java | 35 +++++++++++++++++++ 12 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFailReloadDispatchTest.java diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java index 4ab911cc5c..b8021ccbee 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java @@ -10,16 +10,23 @@ final class CrashSummary { /** * Lines {@link StatusOverlay}'s banner shows before it ellipsizes, sized against the whole rendered banner. * - * The crash banner is the tallest state whose text this class controls: a headline of 56 characters and {@code OverlayState.FULL_OUTPUT_POINTER} at 50. Wrapped at 25 characters per line - the narrowest width measured on an A56 at 2x font scale - the headline costs 3 rendered lines and the pointer 2, so the banner is 5 at its tallest. + * The crash banner: a headline of 56 characters and {@code OverlayState.FULL_OUTPUT_POINTER} at 50. Wrapped at 25 characters per line - the narrowest width measured on an A56 at 2x font scale - the headline costs 3 rendered lines and the pointer 2, so the crash banner is 5 at its tallest. * * Five is needed across the 25-to-27 band; from 28 characters up the headline folds into two as well and the banner fits in 4. The cap is left one line above the worst measured case rather than tightened onto it, because the line it would drop is the tail of the pointer - the reader is left told to look somewhere, without the name of the place - and because a font or locale wider than anything measured here should cost a blank line, not a truncated instruction. * * An earlier version also put a stack summary on the banner and needed 14 lines to fit it. Dropping the summary is what buys this back, so putting any detail on the banner again means recomputing here rather than raising the cap. {@code StatusOverlay} reads this instead of carrying a number of its own that could drift from it. * - * One state is outside this arithmetic: {@code BUILD_FAILED}'s detail is a diagnostic line CoGo sends and nothing here caps its length, so a long one still ellipsizes. That predates this budget and is not addressed by it. + * {@code BUILD_FAILED} is the tallest state, at exactly this cap: a 54-character headline (3 lines at the narrowest measure), one detail line clamped to {@link #BUILD_FAILED_DETAIL_CHARS}, and the pointer's 2. */ static final int MAX_BANNER_LINES = 6; + /** + * Characters of {@code BUILD_FAILED} diagnostic detail the banner shows: one wrapped line. + * + * Of the {@link #MAX_BANNER_LINES} cap, the build-failed headline takes 3 lines (54 characters at the narrowest measured 25 per line) and the pointer takes 2, which leaves one line - 25 characters - for the diagnostic. An unclamped detail would push the pointer off the banner, and the pointer is the line that tells the user where to look. + */ + static final int BUILD_FAILED_DETAIL_CHARS = 25; + /** Frames a report to CoGo names; enough to place the fault, short enough to read. */ private static final int MAX_REPORT_FRAMES = 5; diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java index 48a5539af3..e9fa0dab7c 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java @@ -115,16 +115,27 @@ static void flushCaches(Resources resources) { * when {@code dir} cannot be created, the stream exceeds the payload cap, or the write fails */ static File writeResourceApk(InputStream apk, File dir, long generation) throws IOException { - byte[] bytes = Streams.readFully(apk); if (!dir.isDirectory() && !dir.mkdirs()) { throw new IOException("cannot create " + dir); } File zip = new File(dir, APK_PREFIX + generation + APK_SUFFIX); FileOutputStream out = new FileOutputStream(zip); + boolean written = false; try { - out.write(bytes); + // Streamed rather than read into a byte[] first: buffering held the whole apk + // in heap (plus the growth doubling and a final copy) on the small-heap + // API 28/29 devices this path serves. The copy enforces the same payload cap. + Streams.copy(apk, out, Streams.MAX_PAYLOAD_BYTES); + written = true; } finally { - out.close(); + if (written) { + out.close(); + } else { + // A failed copy leaves a partial file on disk; delete it so a throw still + // means nothing was written. + Streams.closeQuietly(out); + zip.delete(); + } } return zip; } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java index 50fe8f1b28..00e731c17f 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java @@ -125,11 +125,21 @@ String text() { StringBuilder sb = new StringBuilder( "Build failed - app is running the last working version"); if (detail != null) { - sb.append('\n').append(detail); + StringBuilder line = new StringBuilder(detail); if (moreErrors > 0) { - sb.append(" (+").append(moreErrors).append(" more)"); + line.append(" (+").append(moreErrors).append(" more)"); } + // The detail is a diagnostic line CoGo sends and its length is unbounded, + // so clamp it to the one line CrashSummary budgets for it; anything longer + // would push FULL_OUTPUT_POINTER off the banner. The explicit "..." matters: + // a hard cut mid-word reads as the whole message. + if (line.length() > CrashSummary.BUILD_FAILED_DETAIL_CHARS) { + line.setLength(CrashSummary.BUILD_FAILED_DETAIL_CHARS - 3); + line.append("..."); + } + sb.append('\n').append(line); } + sb.append('\n').append(FULL_OUTPUT_POINTER); return sb.toString(); case CRASHED: // "Live reload crashed", not "new code crashed": this state is set only from diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index 6dc21581b2..de6f260243 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -169,7 +169,7 @@ public void onServiceConnected(ComponentName name, IBinder service) { } catch (RuntimeException error) { // SecurityException (and any other binder-propagatable runtime exception) from // the host: expected when CoGo has no live session. Continue standalone. - RuntimeLog.w("CoGo rejected connect(); continuing standalone: " + error); + RuntimeLog.w("CoGo rejected connect(); continuing standalone", error); host = null; unbindQuietly(); scheduleRebind(); diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 4d335d63f4..1d575b2b68 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -55,6 +55,19 @@ static void install(Application application) { } } + /** + * Runs a reload-failure body on its own thread. Package-private so the JVM test can pin the dispatch off the caller's thread, which is what keeps the quarantine fsync off the frame path. + * + * @param body + * the failure handling to run + * @return the started thread, so a test can join it + */ + static Thread startFailReloadThread(Runnable body) { + Thread thread = new Thread(body, "qb-fail-reload"); + thread.start(); + return thread; + } + /** * Opens a persisted store file as a read-only fd, the form the resource paths take. * @@ -124,11 +137,11 @@ private static InputStream streamOf(ParcelFileDescriptor fd) { } private final Application application; - private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private final ActivityTracker tracker = new ActivityTracker(this); + private final ActivityTracker tracker = new ActivityTracker(this); private final QuickBuildClient client = new QuickBuildClient(this); + private final StatusOverlay overlay = new StatusOverlay(); /** Whether the generation this process booted from the store has proved itself yet. */ @@ -511,7 +524,7 @@ private void exitForRestart() { /** * Reports the failure to CoGo and shows the banner; rolls back only when the store adopted the failed generation, so the app stays on the old one either way. * - * A failure before the apply took - an oversize payload, a persist failure, a restart deploy missing its dex - leaves the store on the previous generation, so there is nothing to restore or quarantine; the report and banner still fire, or the host's only signal would be its deploy timeout. Only a failure superseded by a newer live generation stays silent, since that generation owns the store, the pending ack and the screen. + * The body runs off the caller's thread, like {@link #markLiveGenerationGood}'s write: the rollback path fsyncs the quarantine marker to disk, and two of the three entry points - a rejected resource swap and a recreate that throws - land on main. Everything in the body is already safe off-main: the pending-reload field is volatile, the store calls are synchronized, the crash report is oneway, and the banner re-posts to main itself. * * @param generation * the generation that failed, which CoGo marks bad @@ -521,6 +534,21 @@ private void exitForRestart() { * the failure, summarized into both the report and the banner */ private void failReload(long generation, PayloadStore.Payload rollback, Throwable error) { + startFailReloadThread(new Runnable() { + + @Override + public void run() { + failReloadNow(generation, rollback, error); + } + }); + } + + /** + * The {@link #failReload} body: decides the failure action against the store's live generation, then reports and renders. + * + * A failure before the apply took - an oversize payload, a persist failure, a restart deploy missing its dex - leaves the store on the previous generation, so there is nothing to restore or quarantine; the report and banner still fire, or the host's only signal would be its deploy timeout. Only a failure superseded by a newer live generation stays silent, since that generation owns the store, the pending ack and the screen. + */ + private void failReloadNow(long generation, PayloadStore.Payload rollback, Throwable error) { Generations.FailureAction action = Generations.onReloadFailure( PayloadStore.INSTANCE.generation(), generation); if (action == Generations.FailureAction.LEAVE_ALONE) { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 5343a60836..a8174a908e 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -293,8 +293,10 @@ private void attachAppResources(Context appContext) { if (attachedAppResources || appContext == null) { return; } - attachedAppResources = true; - attachLoaderTo(appContext.getResources()); + // Latched only on success: attachLoaderTo swallows its failures, so setting the + // flag first would record an attach that never happened and no later deploy + // would retry it. Callers hold the monitor, so the retry is bounded by deploys. + attachedAppResources = attachLoaderTo(appContext.getResources()); } /** @@ -302,19 +304,22 @@ private void attachAppResources(Context appContext) { * * @param resources * the Resources to attach to; attaching again, or an unusual implementation, is logged and ignored + * @return whether the loader was attached; false when there is no loader yet or addLoaders threw (logged, not rethrown), so the caller can retry on a later deploy instead of recording a failed attach as done */ @TargetApi(30) - private void attachLoaderTo(Resources resources) { + private boolean attachLoaderTo(Resources resources) { ResourcesLoader target = loader; if (target == null) { - return; + return false; } try { resources.addLoaders(target); + return true; } catch (Throwable error) { // Already attached, or an unusual Resources implementation. Not worth // crashing over. RuntimeLog.d("attachTo skipped", error); + return false; } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java index ed12f9f421..67c93efc70 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java @@ -213,7 +213,8 @@ private TextView createBanner(Activity activity) { * the banner whose margin the deferred read updates */ private void reapplyInsetAfterLayout(final ViewGroup decor, final TextView banner) { - banner.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + final ViewTreeObserver observer = banner.getViewTreeObserver(); + observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { Integer top = statusBarInsetTop(decor); @@ -221,9 +222,17 @@ public void onGlobalLayout() { if (action == InsetAction.WAIT) { return; } - // Fetched again rather than captured: this runs while the banner is - // attached, so it is the live observer the listener sits on. - banner.getViewTreeObserver().removeOnGlobalLayoutListener(this); + // Captured at add time, not re-fetched: on the GIVE_UP branch the banner + // is detached, and a detached view's getViewTreeObserver() returns a + // fresh floating observer, so removing from it is a silent no-op and the + // listener leaks with the decor. When the framework has already merged + // the captured observer away (isAlive() false), the listener now lives + // on the decor's observer, so remove there. + if (observer.isAlive()) { + observer.removeOnGlobalLayoutListener(this); + } else { + decor.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } if (action == InsetAction.APPLY) { setTopMargin(banner, top); } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java index 70129c3bae..a15fe7b1f1 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java @@ -70,7 +70,8 @@ void positionFieldsFromAnOlderCoGoAreIgnored() { assertThat(status.message).isEqualTo("boom"); assertThat(status.moreErrors).isEqualTo(1); assertThat(OverlayState.buildFailed(status).text()) - .isEqualTo("Build failed - app is running the last working version\nboom (+1 more)"); + .isEqualTo("Build failed - app is running the last working version\nboom (+1 more)\n" + + "For more info, see Build Output in Code on the Go."); } @Test diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java index 6d2b61dd99..8c8dff5602 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java @@ -6,6 +6,7 @@ import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.util.Random; import org.junit.jupiter.api.Test; @@ -32,6 +33,26 @@ void createsMissingDirectories() throws IOException { assertThat(Files.readAllBytes(zip.toPath())).isEqualTo(apk); } + @Test + void deletesThePartialFileWhenTheStreamFails() { + // The write streams straight into the file, so a stream that dies mid-copy has + // already left bytes on disk; the contract stays "a throw means nothing written". + InputStream failing = new InputStream() { + private int sent; + + @Override + public int read() throws IOException { + if (sent++ < 10_000) { + return 7; + } + throw new IOException("stream died mid-copy"); + } + }; + assertThrows(IOException.class, + () -> LegacyResourceSwap.writeResourceApk(failing, tempDir, 9)); + assertThat(tempDir.listFiles()).isEmpty(); + } + @Test void distinctFilePerGeneration() throws IOException { byte[] first = "gen one apk".getBytes("UTF-8"); diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java index e2a8cc926d..baf7aff510 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java @@ -6,6 +6,24 @@ class OverlayStateTest { + @Test + void buildFailedClampsUnboundedDetailAndStillNamesBuildOutput() { + // The detail is a diagnostic line CoGo sends and its length is unbounded; the + // banner ellipsizes at CrashSummary.MAX_BANNER_LINES, so an unclamped detail + // would push the Build Output pointer off the bottom. The clamp keeps the + // detail to its one-line budget. + StringBuilder longDetail = new StringBuilder(); + for (int i = 0; i < 300; i++) { + longDetail.append('x'); + } + OverlayState state = OverlayState.buildFailed(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"message\": \"" + longDetail + + "\", \"moreErrors\": \"2\"}")); + String[] lines = state.text().split("\n"); + assertThat(state.text()).contains(OverlayState.FULL_OUTPUT_POINTER); + assertThat(lines[1].length()).isAtMost(CrashSummary.BUILD_FAILED_DETAIL_CHARS); + } + @Test void buildFailedNeverNamesAnErrorLocation() { // Locating an error is CoGo's job (Build Output); the overlay is a stale-app warning, diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java index c7baeda741..90f8e6dc96 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java @@ -21,7 +21,8 @@ void buildFailedWithADetailAppendsItUnderTheHeadline() { .buildFailed(failed("{\"kind\":\"build_failed\",\"message\":\"boom\"}")); assertThat(state.text()).isEqualTo( - "Build failed - app is running the last working version\nboom"); + "Build failed - app is running the last working version\nboom\n" + + "For more info, see Build Output in Code on the Go."); } @Test @@ -30,7 +31,8 @@ void buildFailedWithMoreErrorsAppendsTheCount() { failed("{\"kind\":\"build_failed\",\"message\":\"boom\",\"moreErrors\":\"3\"}")); assertThat(state.text()).isEqualTo( - "Build failed - app is running the last working version\nboom (+3 more)"); + "Build failed - app is running the last working version\nboom (+3 more)\n" + + "For more info, see Build Output in Code on the Go."); } @Test @@ -40,7 +42,8 @@ void buildFailedWithNoDetailRendersOnlyTheHeadline() { .buildFailed(failed("{\"kind\":\"build_failed\",\"moreErrors\":\"3\"}")); assertThat(state.text()) - .isEqualTo("Build failed - app is running the last working version"); + .isEqualTo("Build failed - app is running the last working version\n" + + "For more info, see Build Output in Code on the Go."); } @Test diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFailReloadDispatchTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFailReloadDispatchTest.java new file mode 100644 index 0000000000..a6e3a08c27 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFailReloadDispatchTest.java @@ -0,0 +1,35 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Pins the reload-failure dispatch off the caller's thread. + * + * The failure body fsyncs the quarantine marker to disk ({@code PayloadPersistence.writeAtomic}), and two of its three entry points - a rejected resource swap and a recreate that throws - run on the main thread. Collapsing the dispatch back to the caller's thread would put a blocking disk sync on the frame path; this test goes red if that happens. + */ +class QuickBuildRuntimeFailReloadDispatchTest { + + @Test + void bodyRunsOffTheCallersThread() throws Exception { + final AtomicReference ranOn = new AtomicReference<>(); + final CountDownLatch done = new CountDownLatch(1); + Thread started = QuickBuildRuntime.startFailReloadThread(new Runnable() { + + @Override + public void run() { + ranOn.set(Thread.currentThread()); + done.countDown(); + } + }); + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + started.join(TimeUnit.SECONDS.toMillis(5)); + assertThat(ranOn.get()).isNotSameInstanceAs(Thread.currentThread()); + assertThat(ranOn.get()).isSameInstanceAs(started); + assertThat(started.getName()).isEqualTo("qb-fail-reload"); + } +} From 02da2f4cdb26f197abb20e8a307834cb5d561b72 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 2 Sep 2026 17:03:32 -0700 Subject: [PATCH 07/40] ADFA-4128: 0902 review round on quickbuild:runtime Ordering and lifetime fixes from Akash's 2 September round, plus the nitpick sweep. - The failed reload's decision and its rollback now happen under one lock (PayloadStore.restoreIfCurrent), so a deploy that applies between the two is no longer rolled back by a decision taken before it existed. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939550 - A generation whose resource swap failed is marked before the failure is dispatched off-thread, and the posted recreate skips it. Moving failReload off the looper had left the recreate free to render the generation the rollback was undoing, and the mark also covers the inline swap, which fails before the recreate is posted at all. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939543 https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913952461 - Provider swaps carry their generation and drop an overtaken one instead of installing it, so a slower deploy's swap landing last cannot put the older table back under the newer generation's label. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939562 - The connect() handshake - the one non-oneway host call - runs off the binding callback's thread, so a cold CoGo no longer blocks the proxy app's main thread. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939574 - Asset extraction is capped cumulatively at MAX_PAYLOAD_BYTES, matching every other payload path. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939586 - The five fallback catches in the component factory rethrow fatal errors, so an OOM is grouped as itself rather than under the payload's throwable. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939594 - writeAtomic no longer short-circuits on the delete: a first write, where there is nothing to delete, skipped the retry and leaked the temp file. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939632 - PayloadStore's last throwable-concatenation site takes the two-arg log. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939624 - LegacyResourceSwap's KDoc names the test that exists. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939640 - The fail-reload dispatch test's KDoc claims what the test pins - the helper's contract - and says what it does not. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3913939603 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/runtime/AssetExtractor.java | 17 +++++- .../runtime/LegacyResourceSwap.java | 2 +- .../runtime/PayloadPersistence.java | 9 ++- .../quickbuild/runtime/PayloadStore.java | 21 ++++++- .../QuickBuildAppComponentFactory.java | 15 +++++ .../quickbuild/runtime/QuickBuildClient.java | 25 ++++++++- .../quickbuild/runtime/QuickBuildRuntime.java | 34 ++++++++++-- .../quickbuild/runtime/ResourceStore.java | 55 ++++++++++++++++--- ...ickBuildRuntimeFailReloadDispatchTest.java | 6 +- 9 files changed, 158 insertions(+), 26 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java index 7ffc861b80..832b1eab24 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java @@ -58,7 +58,7 @@ static File currentDir(File assetsRoot) { * the app-private override directory, created when missing * @return the number of files extracted, directory entries excluded * @throws IOException - * on I/O failure or when an entry would escape {@code destDir}, at which point extraction stops and the directory can hold a partial set + * on I/O failure, when an entry would escape {@code destDir}, or when the entries together exceed {@link Streams#MAX_PAYLOAD_BYTES}; extraction stops and the directory can hold a partial set */ static int extract(InputStream zipStream, File destDir) throws IOException { if (!destDir.isDirectory() && !destDir.mkdirs()) { @@ -67,6 +67,10 @@ static int extract(InputStream zipStream, File destDir) throws IOException { String destPrefix = destDir.getCanonicalPath() + File.separator; ZipInputStream zip = new ZipInputStream(zipStream); int count = 0; + // Every other payload path is capped at MAX_PAYLOAD_BYTES; the per-entry writes + // here were not, so a zip's entries could together exceed it. The cap is + // cumulative because no single entry has to be large to get there. + long written = 0; ZipEntry entry; while ((entry = zip.getNextEntry()) != null) { try { @@ -77,7 +81,7 @@ static int extract(InputStream zipStream, File destDir) throws IOException { if (!target.getCanonicalPath().startsWith(destPrefix)) { throw new IOException("zip entry escapes destination: " + entry.getName()); } - writeFile(zip, target); + written += writeFile(zip, target, Streams.MAX_PAYLOAD_BYTES - written); count++; } finally { zip.closeEntry(); @@ -177,17 +181,23 @@ private static String readMarker(File marker) { * @throws IOException * when a parent directory cannot be created, the copy fails, or the rename into place fails twice */ - private static void writeFile(InputStream in, File target) throws IOException { + private static long writeFile(InputStream in, File target, long remaining) throws IOException { File parent = target.getParentFile(); if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { throw new IOException("cannot create dir " + parent); } File temp = new File(parent, target.getName() + ".qb-tmp"); FileOutputStream out = new FileOutputStream(temp); + long written = 0; try { byte[] buffer = new byte[BUFFER_SIZE]; int read; while ((read = in.read(buffer)) != -1) { + written += read; + if (written > remaining) { + throw new IOException("asset payload exceeds " + Streams.MAX_PAYLOAD_BYTES + + " bytes at " + target.getName()); + } out.write(buffer, 0, read); } } finally { @@ -202,6 +212,7 @@ private static void writeFile(InputStream in, File target) throws IOException { throw new IOException("cannot move extracted asset into place: " + target); } } + return written; } /** diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java index e9fa0dab7c..75ef8f739e 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java @@ -20,7 +20,7 @@ final class LegacyResourceSwap { /** * Cache subdirectory the relinked apks live in. * - * Must match {@code ResourceStore.LEGACY_TABLE_DIR}, which is what actually writes them; {@code LegacyResourceSwapCacheDirTest} pins the two together. + * Must match {@code ResourceStore.LEGACY_TABLE_DIR}, which is what actually writes them; {@code LegacyResourceSwapSweepTest} pins the two together. */ static final String TABLE_DIR = "quickbuild-res"; diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java index a076018195..2b7b7c5c29 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java @@ -178,8 +178,13 @@ private static void writeAtomic(File target, InputStream in) throws IOException } if (!temp.renameTo(target)) { // rename over an existing file is atomic on POSIX; a failure here is a - // filesystem oddity - fall back to delete+rename before giving up. - if (!target.delete() || !temp.renameTo(target)) { + // filesystem oddity - fall back to delete+rename before giving up. The + // delete's own result is not the test: it returns false on a first write, + // where there was nothing to delete, which short-circuited the retry that + // would have worked and left the temp file behind for good. + target.delete(); + if (!temp.renameTo(target)) { + temp.delete(); throw new IOException("cannot rename " + temp + " to " + target); } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java index cbceff8780..bfb722238c 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java @@ -58,7 +58,7 @@ private static File defaultPersistDir() { } return new File(dataDir, "files/" + PERSIST_DIR); } catch (Throwable error) { - RuntimeLog.w("cmdline data-dir derivation failed: " + error); + RuntimeLog.w("cmdline data-dir derivation failed", error); return null; } finally { Streams.closeQuietly(in); @@ -242,6 +242,25 @@ synchronized void restore(Payload payload) { current = payload; } + /** + * Decides what a failed reload owes and rolls back in the same lock. + * + * Reading {@link #generation()} and then calling {@link #restore} took the monitor twice, so a deploy that applied in the gap was rolled back by a decision taken before it existed - the app then ran two generations behind the host's view of it, silently. + * + * @param failedGeneration + * the generation whose reload failed + * @param rollback + * the {@link #snapshot} taken before the failed apply; restored verbatim, null included + * @return the action decided against the generation live at the moment of the restore; the rollback has already happened when it is ROLLBACK_AND_REPORT + */ + synchronized Generations.FailureAction restoreIfCurrent(long failedGeneration, Payload rollback) { + Generations.FailureAction action = Generations.onReloadFailure(generation(), failedGeneration); + if (action == Generations.FailureAction.ROLLBACK_AND_REPORT) { + current = rollback; + } + return action; + } + /** * Snapshot for rollback: pair with {@link #restore} when a reload fails. * diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java index 4aa59e40f4..46faecda21 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java @@ -116,6 +116,9 @@ public Activity instantiateActivity(ClassLoader cl, String className, Intent int try { return super.instantiateActivity(cl, className, intent); } catch (Throwable fallbackError) { + // Without this an OOM or a linkage error from the fallback is reported under + // the payload's throwable, which groups the crash as the wrong fault. + rethrowIfFatal(fallbackError); throw rethrowPayloadFailure(payloadError, fallbackError); } } @@ -149,6 +152,9 @@ public Application instantiateApplication(ClassLoader cl, String className) try { application = super.instantiateApplication(cl, className); } catch (Throwable fallbackError) { + // Without this an OOM or a linkage error from the fallback is reported under + // the payload's throwable, which groups the crash as the wrong fault. + rethrowIfFatal(fallbackError); throw rethrowPayloadFailure(payloadError, fallbackError); } } @@ -192,6 +198,9 @@ public ContentProvider instantiateProvider(ClassLoader cl, String className) try { return super.instantiateProvider(cl, className); } catch (Throwable fallbackError) { + // Without this an OOM or a linkage error from the fallback is reported under + // the payload's throwable, which groups the crash as the wrong fault. + rethrowIfFatal(fallbackError); throw rethrowPayloadFailure(payloadError, fallbackError); } } @@ -229,6 +238,9 @@ public BroadcastReceiver instantiateReceiver(ClassLoader cl, String className, I try { return super.instantiateReceiver(cl, className, intent); } catch (Throwable fallbackError) { + // Without this an OOM or a linkage error from the fallback is reported under + // the payload's throwable, which groups the crash as the wrong fault. + rethrowIfFatal(fallbackError); throw rethrowPayloadFailure(payloadError, fallbackError); } } @@ -266,6 +278,9 @@ public Service instantiateService(ClassLoader cl, String className, Intent inten try { return super.instantiateService(cl, className, intent); } catch (Throwable fallbackError) { + // Without this an OOM or a linkage error from the fallback is reported under + // the payload's throwable, which groups the crash as the wrong fault. + rethrowIfFatal(fallbackError); throw rethrowPayloadFailure(payloadError, fallbackError); } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index de6f260243..805ee3e430 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -129,9 +129,9 @@ public void onNullBinding(ComponentName name) { /** * Registers this app with CoGo, naming the generation it currently runs so CoGo can send the catch-up payload. * - * connect() is the one synchronous call on the host interface, so host-thrown exceptions cross the binder into this method, on the main thread. CoGo deliberately rejects connect with a SecurityException when no session is live, and the app must keep running standalone, so a rejection drops the channel and falls back to the backoff loop. + * connect() is the one synchronous call on the host interface, so host-thrown exceptions cross the binder into the handshake. CoGo deliberately rejects connect with a SecurityException when no session is live, and the app must keep running standalone, so a rejection drops the channel and falls back to the backoff loop. * - * The backoff reset happens only after a successful connect: resetting on mere service connection would make a rejecting host retry at the minimum delay forever. + * The handshake itself runs off this callback's thread, because the framework delivers the callback on the app's main thread and a synchronous binder call there blocks the user's UI for as long as CoGo takes to answer - a cold CoGo answers slowly, and the app it is blocking is not CoGo. * * @param name * CoGo's service component; unused, there is only one binding @@ -153,6 +153,27 @@ public void onServiceConnected(ComponentName name, IBinder service) { return; } host = connected; + Thread handshake = new Thread(new Runnable() { + + @Override + public void run() { + connectToHost(connected); + } + }, "qb-connect"); + handshake.start(); + } + + /** + * Runs the connect handshake and settles the backoff, off the main thread. + * + * The backoff reset happens only after a successful connect: resetting on mere service connection would make a rejecting host retry at the minimum delay forever. + * + * Safe off-thread: {@code host} and {@code appContext} are volatile, the backoff fields are taken under this object's monitor, and both unbind and rebind are callable from anywhere - the rebind itself posts to the main thread before touching bindService. + * + * @param connected + * the host proxy this handshake registers against + */ + private void connectToHost(IQuickBuildHost connected) { try { Context context = appContext; String packageName = context == null ? "" : context.getPackageName(); diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 1d575b2b68..a3094be4bc 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -156,6 +156,13 @@ private static InputStream streamOf(ParcelFileDescriptor fd) { /** Generation whose reload is awaiting its first resumed frame, or -1. */ private volatile long pendingReloadGeneration = -1; + /** + * Newest generation whose resource swap failed, or -1 before the first. + * + * The swap failure arrives on the main thread after the deploy thread has queued - or is about to queue - the recreate for the same generation, and the rollback now runs off-thread, so the recreate can beat it and render the generation the rollback is undoing. Written on main inside the swap guard, read by the posted recreate. + */ + private volatile long abandonedReloadGeneration = -1; + /** Uptime at which the pending reload's payload arrived, the start of the reported duration. */ private volatile long pendingReloadStartUptime; @@ -280,6 +287,14 @@ public void onSwapFailed(Throwable error) { // The swap lands after this method returns, so without this the deploy // acks a reload the app is not showing: CoGo reports success while the // screen still renders the previous table, and no banner fires. + // + // The recreate is abandoned BEFORE the failure is dispatched, because + // the dispatch is off-thread now: a recreate still queued here would + // otherwise run against the failed generation while the rollback that + // undoes it is still on another thread. Marking rather than removing + // the callback also covers the inline swap, which fails before the + // recreate has been posted at all. + abandonedReloadGeneration = generation; failReload(generation, rollback, error); } }; @@ -289,7 +304,7 @@ public void onSwapFailed(Throwable error) { } if (assetsPayload != null) { ResourceStore.INSTANCE.applyAssets( - openReadOnly(persisted.assetsFile), + openReadOnly(persisted.assetsFile), generation, PayloadStore.INSTANCE.baselineFingerprint(), application, onSwapFailure); } @@ -430,7 +445,7 @@ private void applyPendingBootResources(android.content.Context context) { } if (pending.assetsFile != null) { ResourceStore.INSTANCE.applyAssets( - openReadOnly(pending.assetsFile), + openReadOnly(pending.assetsFile), pending.generation, PayloadStore.INSTANCE.baselineFingerprint(), context, null); } @@ -544,13 +559,15 @@ public void run() { } /** - * The {@link #failReload} body: decides the failure action against the store's live generation, then reports and renders. + * The {@link #failReload} body: decides the failure action against the store's live generation and rolls back in the same lock, then reports and renders. * * A failure before the apply took - an oversize payload, a persist failure, a restart deploy missing its dex - leaves the store on the previous generation, so there is nothing to restore or quarantine; the report and banner still fire, or the host's only signal would be its deploy timeout. Only a failure superseded by a newer live generation stays silent, since that generation owns the store, the pending ack and the screen. */ private void failReloadNow(long generation, PayloadStore.Payload rollback, Throwable error) { - Generations.FailureAction action = Generations.onReloadFailure( - PayloadStore.INSTANCE.generation(), generation); + // Decided and restored under one lock: reading the live generation and then + // restoring took the monitor twice, and a deploy applying in the gap was rolled + // back by a decision taken before it existed. + Generations.FailureAction action = PayloadStore.INSTANCE.restoreIfCurrent(generation, rollback); if (action == Generations.FailureAction.LEAVE_ALONE) { // A newer payload landed while this one was failing, so it owns the store, the // pending ack and the screen. Rolling back here would undo a deploy that worked. @@ -559,7 +576,6 @@ private void failReloadNow(long generation, PayloadStore.Payload rollback, Throw return; } if (action == Generations.FailureAction.ROLLBACK_AND_REPORT) { - PayloadStore.INSTANCE.restore(rollback); quarantine(generation); pendingReloadGeneration = -1; } @@ -698,6 +714,12 @@ private void quarantine(long generation) { * the pre-apply snapshot to restore if the recreate throws */ private void reloadOnMain(long generation, PayloadStore.Payload rollback) { + if (generation <= abandonedReloadGeneration) { + // This generation's resource swap failed, so failReload owns it: recreating + // here would render a generation whose rollback is already in flight. + RuntimeLog.w("skipping recreate for gen " + generation + ": its resource swap failed"); + return; + } try { Activity top = tracker.topActivity(); if (top != null) { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index a8174a908e..a974220dd4 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -79,6 +79,13 @@ private static void reportSwapFailure(SwapFailure onFailure, Throwable error) { /** Whether the application Resources has the loader; written under the monitor on the main thread. */ private boolean attachedAppResources; + /** + * Newest generation whose provider swap has committed, or -1 before the first. + * + * Two deploys arrive on two binder threads, so the swaps they post are not ordered by generation. Without this an overtaken deploy's swap can land last and put its older table back under the newer generation's label. Written and read under the monitor, inside the swap itself. + */ + private long swappedGeneration = -1; + /** * @param strategy * the swap mechanism to use; injected so tests can drive each branch without an SDK level @@ -101,6 +108,8 @@ private ResourceStore() { * * @param assetsFd * the changed-assets zip; always closed, success or failure + * @param generation + * the payload generation, which orders this swap against the others; an overtaken one is dropped rather than installed * @param baselineFingerprint * the running baseline's fingerprint, which keys the cumulative dir * @param appContext @@ -110,14 +119,15 @@ private ResourceStore() { * @throws IOException * on a read, extraction, path-traversal or provider failure; the previous override stays live */ - void applyAssets(ParcelFileDescriptor assetsFd, String baselineFingerprint, Context appContext, - SwapFailure onFailure) throws IOException { + void applyAssets(ParcelFileDescriptor assetsFd, long generation, String baselineFingerprint, + Context appContext, SwapFailure onFailure) throws IOException { File assetsRoot = new File(appContext.getCacheDir(), ASSETS_ROOT_DIR); InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(assetsFd); try { int extracted = AssetExtractor.extractCumulative(in, assetsRoot, baselineFingerprint); if (strategy == ResourceSwapStrategy.RESOURCES_LOADER) { - refreshAssetsProvider(AssetExtractor.currentDir(assetsRoot), appContext, onFailure); + refreshAssetsProvider( + AssetExtractor.currentDir(assetsRoot), generation, appContext, onFailure); } RuntimeLog.i("merged " + extracted + " changed asset(s) into the override"); } finally { @@ -137,7 +147,7 @@ void applyAssets(ParcelFileDescriptor assetsFd, String baselineFingerprint, Cont * @param tableFd * the relinked resource apk; always closed, success or failure * @param generation - * the payload generation, used only by the API 28/29 path to name its file + * the payload generation: the API 28/29 path names its file after it, and the API 30+ path orders this swap against the others * @param appContext * application context, for the Resources the loader attaches to and, on API 28/29, the cache dir and the Resources to mount onto * @param onFailure @@ -149,7 +159,7 @@ void applyTable(ParcelFileDescriptor tableFd, long generation, Context appContex SwapFailure onFailure) throws IOException { switch (strategy) { case RESOURCES_LOADER: - applyTableWithLoader(tableFd, appContext, onFailure); + applyTableWithLoader(tableFd, generation, appContext, onFailure); return; case LEGACY_ASSET_PATH: applyTableLegacy(tableFd, generation, appContext); @@ -233,6 +243,8 @@ private void applyTableLegacy(ParcelFileDescriptor tableFd, long generation, * * @param tableFd * the relinked resource apk; loadFromApk dups it, so this method closes ours either way + * @param generation + * the payload generation, which orders this swap against the others; an overtaken one is dropped rather than installed * @param appContext * application context, whose Resources the loader is attached to on first use * @param onFailure @@ -241,8 +253,8 @@ private void applyTableLegacy(ParcelFileDescriptor tableFd, long generation, * when the apk cannot be loaded as a provider; the previous provider stays live and attached */ @TargetApi(30) - private void applyTableWithLoader(ParcelFileDescriptor tableFd, final Context appContext, - SwapFailure onFailure) throws IOException { + private void applyTableWithLoader(ParcelFileDescriptor tableFd, final long generation, + final Context appContext, SwapFailure onFailure) throws IOException { try { final ResourcesProvider next = ResourcesProvider.loadFromApk(tableFd, null); boolean willRun = swapProvidersOnMain(new Runnable() { @@ -250,6 +262,15 @@ private void applyTableWithLoader(ParcelFileDescriptor tableFd, final Context ap @Override public void run() { synchronized (ResourceStore.this) { + if (generation < swappedGeneration) { + // Overtaken. Two deploys arrive on two binder threads, so the posts are not + // ordered by generation, and installing this one would put the older table + // back under the newer generation's label. + RuntimeLog.w("dropping overtaken table swap for gen " + generation + "; gen " + + swappedGeneration + " already committed"); + Streams.closeQuietly(next); + return; + } ResourcesProvider previous = provider; provider = next; try { @@ -263,6 +284,9 @@ public void run() { Streams.closeQuietly(next); throw error; } + // Recorded only after the install took: a rejected swap leaves the previous set + // live, so it must not block the next deploy from installing over it. + swappedGeneration = generation; attachAppResources(appContext); Streams.closeQuietly(previous); } @@ -350,6 +374,8 @@ private void installProviders() { * * @param dir * the merged override dir laid out as an APK root (assets under {@code assets/}) + * @param generation + * the payload generation, which orders this swap against the others; an overtaken one is dropped rather than installed * @param appContext * application context, whose Resources the loader is attached to on first use * @param onFailure @@ -358,8 +384,8 @@ private void installProviders() { * when the provider cannot be created; the previous one stays live and attached */ @TargetApi(30) - private void refreshAssetsProvider(File dir, final Context appContext, SwapFailure onFailure) - throws IOException { + private void refreshAssetsProvider(File dir, final long generation, final Context appContext, + SwapFailure onFailure) throws IOException { final DirectoryAssetsProvider nextDir = new DirectoryAssetsProvider(dir); final ResourcesProvider next = ResourcesProvider.empty(nextDir); boolean willRun = swapProvidersOnMain(new Runnable() { @@ -367,6 +393,15 @@ private void refreshAssetsProvider(File dir, final Context appContext, SwapFailu @Override public void run() { synchronized (ResourceStore.this) { + if (generation < swappedGeneration) { + // Overtaken, same as the table swap: a newer generation's providers are already + // installed and this pair would replace them with the older override dir. + RuntimeLog.w("dropping overtaken assets swap for gen " + generation + "; gen " + + swappedGeneration + " already committed"); + Streams.closeQuietly(next); + Streams.closeQuietly(nextDir); + return; + } ResourcesProvider previous = assetsProvider; DirectoryAssetsProvider previousDir = assetsDirProvider; assetsProvider = next; @@ -382,6 +417,8 @@ public void run() { Streams.closeQuietly(nextDir); throw error; } + // Recorded only after the install took, as in the table swap. + swappedGeneration = generation; attachAppResources(appContext); Streams.closeQuietly(previous); Streams.closeQuietly(previousDir); diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFailReloadDispatchTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFailReloadDispatchTest.java index a6e3a08c27..4621883105 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFailReloadDispatchTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFailReloadDispatchTest.java @@ -8,9 +8,11 @@ import org.junit.jupiter.api.Test; /** - * Pins the reload-failure dispatch off the caller's thread. + * Pins {@code QuickBuildRuntime.startFailReloadThread} to running its body on a new thread named qb-fail-reload. * - * The failure body fsyncs the quarantine marker to disk ({@code PayloadPersistence.writeAtomic}), and two of its three entry points - a rejected resource swap and a recreate that throws - run on the main thread. Collapsing the dispatch back to the caller's thread would put a blocking disk sync on the frame path; this test goes red if that happens. + * The failure body fsyncs the quarantine marker to disk ({@code PayloadPersistence.writeAtomic}), and two of its three entry points - a rejected resource swap and a recreate that throws - run on the main thread, so a body that ran inline would put a blocking disk sync on the frame path. This goes red if the helper is collapsed to an inline call. + * + * What it deliberately does NOT pin is the call site: {@code failReload} needs an Application, a main Looper and a bound client, so whether it still routes through this helper is checked on device, not here. */ class QuickBuildRuntimeFailReloadDispatchTest { From 102342c4c5bc026ee786640ad7de715df62d6d9b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 2 Sep 2026 18:35:29 -0700 Subject: [PATCH 08/40] style: spotless reformat of QuickBuildClient, no functional change The Eclipse formatter's member sorting moves connectToHost below its caller. Standalone so it does not read as a behavioural change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/runtime/QuickBuildClient.java | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index 805ee3e430..18801367cf 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -163,40 +163,6 @@ public void run() { handshake.start(); } - /** - * Runs the connect handshake and settles the backoff, off the main thread. - * - * The backoff reset happens only after a successful connect: resetting on mere service connection would make a rejecting host retry at the minimum delay forever. - * - * Safe off-thread: {@code host} and {@code appContext} are volatile, the backoff fields are taken under this object's monitor, and both unbind and rebind are callable from anywhere - the rebind itself posts to the main thread before touching bindService. - * - * @param connected - * the host proxy this handshake registers against - */ - private void connectToHost(IQuickBuildHost connected) { - try { - Context context = appContext; - String packageName = context == null ? "" : context.getPackageName(); - connected.connect(target, packageName, runtime.runningGeneration()); - synchronized (this) { - rebindDelayMs = REBIND_MIN_DELAY_MS; - } - RuntimeLog.i("connected to CoGo (running gen " + runtime.runningGeneration() + ")"); - } catch (RemoteException error) { - RuntimeLog.e("connect() to CoGo failed", error); - host = null; - unbindQuietly(); - scheduleRebind(); - } catch (RuntimeException error) { - // SecurityException (and any other binder-propagatable runtime exception) from - // the host: expected when CoGo has no live session. Continue standalone. - RuntimeLog.w("CoGo rejected connect(); continuing standalone", error); - host = null; - unbindQuietly(); - scheduleRebind(); - } - } - /** * Forgets the host and waits, because the framework reconnects this binding itself. * @@ -296,6 +262,40 @@ private boolean bindNow() { } } + /** + * Runs the connect handshake and settles the backoff, off the main thread. + * + * The backoff reset happens only after a successful connect: resetting on mere service connection would make a rejecting host retry at the minimum delay forever. + * + * Safe off-thread: {@code host} and {@code appContext} are volatile, the backoff fields are taken under this object's monitor, and both unbind and rebind are callable from anywhere - the rebind itself posts to the main thread before touching bindService. + * + * @param connected + * the host proxy this handshake registers against + */ + private void connectToHost(IQuickBuildHost connected) { + try { + Context context = appContext; + String packageName = context == null ? "" : context.getPackageName(); + connected.connect(target, packageName, runtime.runningGeneration()); + synchronized (this) { + rebindDelayMs = REBIND_MIN_DELAY_MS; + } + RuntimeLog.i("connected to CoGo (running gen " + runtime.runningGeneration() + ")"); + } catch (RemoteException error) { + RuntimeLog.e("connect() to CoGo failed", error); + host = null; + unbindQuietly(); + scheduleRebind(); + } catch (RuntimeException error) { + // SecurityException (and any other binder-propagatable runtime exception) from + // the host: expected when CoGo has no live session. Continue standalone. + RuntimeLog.w("CoGo rejected connect(); continuing standalone", error); + host = null; + unbindQuietly(); + scheduleRebind(); + } + } + /** Queues one rebind attempt, doubling the delay up to {@link #REBIND_MAX_DELAY_MS}. */ private synchronized void scheduleRebind() { if (rebindScheduled) { From 7fa1d5ee23c43a9e552064f24b4afc66b0c71041 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 13:40:22 -0700 Subject: [PATCH 09/40] ADFA-4128: hold a generation blamable until its first frame has drawn Answers review thread 3926539435 (CRITICAL) on PR #1716. onResume precedes the first traversal, so nothing that vouches for a payload could legitimately happen there. The runtime acked the generation, cleared the pending slot and wrote good.json from onActivityResumed, so a payload whose activity resumed and then threw in measure, layout or draw reached the crash guard with nothing pending and nothing unproven. generationToBlame returned -1, quarantine then refused to name a generation already recorded good, and every relaunch adopted it and died the same way with no in-app escape. The ack, the pending-clear and the good-marking now move together into onFirstFrameDrawn, released by a ViewTreeObserver.OnDrawListener whose completion is posted rather than run inline: the listener fires at the start of a draw pass, so only the posted message runs after the traversal that drew the frame returns. An activity with no live view tree completes inline as before, since waiting for a frame that will never arrive would strand the deploy unacked. The pending slot moves out of QuickBuildRuntime into FirstFrameGate, which is plain Java and JVM-testable; FirstFrameGateTest pins that the generation stays blamable across the undrawn window and is released only by a drawn frame. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- quickbuild/docs/reliability-gaps.md | 2 +- .../quickbuild/runtime/BootProbation.java | 2 +- .../quickbuild/runtime/FirstFrameGate.java | 58 ++++++++++ .../quickbuild/runtime/QuickBuildRuntime.java | 107 ++++++++++++++---- .../runtime/FirstFrameGateTest.java | 89 +++++++++++++++ 5 files changed, 237 insertions(+), 21 deletions(-) create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGateTest.java diff --git a/quickbuild/docs/reliability-gaps.md b/quickbuild/docs/reliability-gaps.md index 0fed3a536a..3d94d2c66f 100644 --- a/quickbuild/docs/reliability-gaps.md +++ b/quickbuild/docs/reliability-gaps.md @@ -52,7 +52,7 @@ flowchart LR ## #91 - an organic proxy-app crash never reaches the crash surface - **Root cause:** the runtime only reports a crash while a reload is in flight - (`QuickBuildRuntime.java:306` gates on `pendingReloadGeneration >= 0`, which is -1 between + (the crash guard gates on `FirstFrameGate.pending() >= 0`, which is -1 between builds). The disconnect *is* detected - `ProxyAppConnections.onDisconnected()` emits `TargetReport.Disconnected` - but the session manager's collector only tests for `Crashed` (`QuickBuildSessionManager.kt:281`). Today's entire user-visible consequence of a crash is an diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java index a330b4fef1..662a40b714 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java @@ -5,7 +5,7 @@ * * A hot swap answers that on its own: the generation whose reload is still awaiting its first frame is the one that just took the screen. A restart deploy answers nothing - it persists the generation and kills the process, so the fresh process boots that generation from the store with no reload pending, and every crash on its way to the screen is invisible to the guard. Measured on an A56: the app crash-looped on the bad generation on every launch with no way out, where the same crash before the always-restart rule at least quarantined and came back on older code. * - * So a generation this process took from the store is on probation until it proves itself, and the proof is the one a fallback already needs: {@link PayloadPersistence#markGood} recorded it, which only happens once an activity of it was resumed. + * So a generation this process took from the store is on probation until it proves itself, and the proof is the one a fallback already needs: {@link PayloadPersistence#markGood} recorded it, which only happens once an activity of it drew a frame. * * Blaming too widely is the safe direction. {@link PayloadPersistence#quarantine} refuses to name a generation already recorded good, so an over-eager blame costs a log line rather than the user's last working code - which is also what keeps a fallback boot from quarantining the very generation it fell back to. */ diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java new file mode 100644 index 0000000000..8661d0314d --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java @@ -0,0 +1,58 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Holds a hot swap's pending generation until the activity that took the screen has actually drawn it. + * + * A resume is not proof that a payload works. onResume runs before the first traversal, so a broken layout or a custom view that throws in measure, layout or draw dies after the resume and before any pixels exist. Clearing the pending generation at resume made that crash unattributable: {@link BootProbation#generationToBlame} saw nothing pending and nothing unproven, quarantined nothing, and the next launch adopted the same generation and died the same way, with no in-app escape. + * + * So the generation stays pending across the resume and is only released by {@link #drawn}, which the runtime calls once the frame that rendered it has completed. Everything a released generation owes - the ack to CoGo, the good-marking that ends its probation - hangs off that one release, so the moment the crash guard stops blaming a generation is the same moment there is drawn evidence it works. + * + * A generation that never draws is released by the runtime's own fallbacks instead: an activity with no live view tree, and the branch where the resumed activity is gone by the time the recreate runs, both complete without a frame. That is the deliberate looser case - waiting for a frame that will never arrive would strand the deploy unacked. + */ +final class FirstFrameGate { + + /** Generation awaiting its first drawn frame, or -1 when nothing is pending. Guarded by {@code this}. */ + private long pendingGeneration = -1; + + /** + * Arms the gate for a payload that just applied. + * + * @param generation + * the generation to hold until it draws, or -1 when the apply was already acked and nothing is pending - which must still be assigned rather than skipped, or an older generation left in the slot keeps taking the blame for this one's crashes + */ + synchronized void arm(long generation) { + pendingGeneration = generation; + } + + /** Releases the slot without acking, for a generation whose reload failed or was rolled back. */ + synchronized void disarm() { + pendingGeneration = -1; + } + + /** + * The generation a crash right now should be blamed on. + * + * Deliberately unchanged by a resume: between the resume and the drawn frame this still names the generation that just took the screen, which is the whole point of the gate. + * + * @return the pending generation, or -1 when none is + */ + synchronized long pending() { + return pendingGeneration; + } + + /** + * Records that a frame carrying the live generation finished drawing, releasing its ack. + * + * @param liveGeneration + * the generation the store serves now; a pending generation the store has already moved past is stale and is not acked here + * @return the generation to ack, or -1 when nothing was pending for this live generation, including every frame after the first + */ + synchronized long drawn(long liveGeneration) { + if (pendingGeneration < 0 || pendingGeneration != liveGeneration) { + return -1; + } + long acked = pendingGeneration; + pendingGeneration = -1; + return acked; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index a3094be4bc..6e6823c972 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -7,6 +7,9 @@ import android.os.MessageQueue; import android.os.ParcelFileDescriptor; import android.os.SystemClock; +import android.view.View; +import android.view.ViewTreeObserver; +import android.view.Window; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -153,8 +156,8 @@ private static InputStream streamOf(ParcelFileDescriptor fd) { /** What the overlay should show; written from any thread, rendered on the main one. */ private volatile OverlayState overlayState = OverlayState.hidden(); - /** Generation whose reload is awaiting its first resumed frame, or -1. */ - private volatile long pendingReloadGeneration = -1; + /** Holds a generation's ack and its probation until the frame that rendered it has drawn. */ + private final FirstFrameGate firstFrame = new FirstFrameGate(); /** * Newest generation whose resource swap failed, or -1 before the first. @@ -313,7 +316,7 @@ public void onSwapFailed(Throwable error) { // Assigned on BOTH branches: the backgrounded ack must also clear any older // generation still pending, or the crash guard keeps blaming it for this // generation's crashes - and this generation escapes quarantine. - pendingReloadGeneration = Generations.pendingAfterApply(resumed, generation); + firstFrame.arm(Generations.pendingAfterApply(resumed, generation)); if (!resumed) { // Backgrounded: no resumed activity to hang a frame callback on, so // waiting for render-proof would time out a deploy that worked. Ack at @@ -373,19 +376,84 @@ void onActivityCreated(Activity activity) { } /** - * Completes a pending reload on its first rendered frame, and renders the overlay and return button. + * Schedules the reload's completion for the resumed activity's first DRAWN frame. * - * This is where reportReloaded fires for a foreground deploy: the first callback after the swap at which the new generation is committed to being drawn. Note that onResume is NOT itself a rendered frame - it precedes the first draw, so the reported time understates true time-to-pixels by that margin (measured at ~4 ms on an A56, foreground path). A backgrounded deploy was already acked at apply time and left no pending generation, so it cannot double-report here. + * onResume is not a rendered frame - it precedes the first traversal - so nothing that vouches for a generation may happen here. A payload whose activity resumes and then throws in measure, layout or draw would otherwise have already been acked, cleared from the pending slot and written to good.json, leaving {@link BootProbation} nothing to blame and {@link PayloadPersistence#quarantine} refusing to name it: every relaunch adopted the same generation and died the same way, with no in-app escape. + * + * So the ack, the pending-clear and the good-marking all move together to {@link #onFirstFrameDrawn}, released only once a frame carrying the generation has been drawn. * * @param activity * the activity now in the foreground, which hosts the overlay */ - void onActivityResumed(Activity activity) { - long pending = pendingReloadGeneration; - if (pending >= 0 && PayloadStore.INSTANCE.generation() == pending) { - pendingReloadGeneration = -1; + void onActivityResumed(final Activity activity) { + if (!completeOnFirstFrame(activity)) { + // No live view tree to hang a draw callback on, so this activity may never + // draw at all. Completing inline is the pre-existing looser bar, and the + // right one here: waiting for a frame that will never arrive would strand + // the deploy unacked and leave the generation on probation forever. + onFirstFrameDrawn(activity); + } + } + + /** + * Runs the completion once the resumed activity's first frame has finished drawing. + * + * The listener fires at the START of each draw pass, so the completion is posted rather than run inline: the posted message runs after the traversal that drew the frame returns, which is what makes measure, layout and draw failures land BEFORE the generation is vouched for rather than after. Posting is also what makes removing the listener legal, since a {@link ViewTreeObserver} rejects a removal made from inside its own dispatch. + * + * @param activity + * the resumed activity whose first frame gates the completion + * @return true when a draw callback was installed, false when the activity has no live view tree and the caller must complete without one + */ + private boolean completeOnFirstFrame(final Activity activity) { + Window window = activity.getWindow(); + final View decor = window == null ? null : window.peekDecorView(); + if (decor == null) { + return false; + } + final ViewTreeObserver observer = decor.getViewTreeObserver(); + if (observer == null || !observer.isAlive()) { + return false; + } + final ViewTreeObserver.OnDrawListener[] listener = new ViewTreeObserver.OnDrawListener[1]; + final boolean[] scheduled = new boolean[1]; + listener[0] = new ViewTreeObserver.OnDrawListener() { + + @Override + public void onDraw() { + if (scheduled[0]) { + return; + } + scheduled[0] = true; + mainHandler.post(new Runnable() { + + @Override + public void run() { + ViewTreeObserver live = decor.getViewTreeObserver(); + if (live != null && live.isAlive()) { + live.removeOnDrawListener(listener[0]); + } + onFirstFrameDrawn(activity); + } + }); + } + }; + observer.addOnDrawListener(listener[0]); + return true; + } + + /** + * Completes a pending reload now that its generation has been drawn, and renders the overlay and return button. + * + * This is where reportReloaded fires for a foreground deploy, and the reported time is now true time-to-pixels rather than time-to-resume. A backgrounded deploy was already acked at apply time and left no pending generation, so it cannot double-report here. + * + * @param activity + * the activity that drew the frame, which hosts the overlay + */ + private void onFirstFrameDrawn(Activity activity) { + long acked = firstFrame.drawn(PayloadStore.INSTANCE.generation()); + if (acked >= 0) { long reloadMillis = SystemClock.uptimeMillis() - pendingReloadStartUptime; - client.reportReloaded(pending, reloadMillis); + client.reportReloaded(acked, reloadMillis); // Success renders nothing; it only clears a shown error or in-flight // banner, since a landed reload means the build finished even if the // build_ok message is still in flight behind it. @@ -397,9 +465,10 @@ void onActivityResumed(Activity activity) { } else { overlay.render(activity, overlayState); } - // Unconditional, because the point is that an activity of this generation is on - // screen - which is true whether it arrived by hot swap or by a fresh process - // booting it, and only the first of those leaves a pending generation behind. + // Unconditional, because the point is that a drawn frame of this generation + // reached the screen - which is true whether it arrived by hot swap or by a + // fresh process booting it, and only the first of those leaves a pending + // generation behind. markLiveGenerationGood(); } @@ -577,7 +646,7 @@ private void failReloadNow(long generation, PayloadStore.Payload rollback, Throw } if (action == Generations.FailureAction.ROLLBACK_AND_REPORT) { quarantine(generation); - pendingReloadGeneration = -1; + firstFrame.disarm(); } // The banner gets no summary at all: it is a few unscrollable lines over the user's // own app, so a stack put there is clipped mid-frame and the frames naming the fault @@ -607,7 +676,7 @@ private void installCrashGuard() { @Override public void uncaughtException(Thread thread, Throwable error) { try { - long doomed = bootProbation.generationToBlame(pendingReloadGeneration, + long doomed = bootProbation.generationToBlame(firstFrame.pending(), PayloadStore.INSTANCE.generation()); if (doomed >= 0) { // The store already claims this generation, so a relaunch would @@ -629,9 +698,9 @@ public void uncaughtException(Thread thread, Throwable error) { /** * Records the running generation as the one a later quarantine should fall back to, and ends its probation. * - * Called from a resumed activity, which is the bar that matters: the failure a fallback has to survive is a payload that throws on the way to the screen, so a generation that got there is one a fresh process can boot. Without this a quarantine drops the app to install-time code and discards every save since. + * Called from a DRAWN frame, which is the bar that matters: the failure a fallback has to survive is a payload that throws on the way to the screen, so a generation whose frame reached it is one a fresh process can boot. A resume is not that bar - it precedes the first traversal, so a payload that resumes and then throws in measure, layout or draw has proved nothing. Without this a quarantine drops the app to install-time code and discards every save since. * - * The probation ends on the recorded write rather than on the resume that prompted it, so the two facts stay simultaneous: the moment this generation stops being blamed for a crash is the moment there is something to fall back to instead. A write that fails leaves it on probation, which is the expensive direction, not a safe one: nothing recorded it, so a later crash anywhere in the app blames a generation that demonstrably reached the screen and quarantines it. That is why a failed write releases the latch and the next resume tries again. + * The probation ends on the recorded write rather than on the frame that prompted it, so the two facts stay simultaneous: the moment this generation stops being blamed for a crash is the moment there is something to fall back to instead. A write that fails leaves it on probation, which is the expensive direction, not a safe one: nothing recorded it, so a later crash anywhere in the app blames a generation that demonstrably reached the screen and quarantines it. That is why a failed write releases the latch and the next resume tries again. * * Written off the main thread, because the write is fsynced and this runs on the frame path; latched per generation, so it costs one short-lived thread per generation rather than one per resume. Losing the write to a process death only makes the fallback one generation older. */ @@ -726,13 +795,13 @@ private void reloadOnMain(long generation, PayloadStore.Payload rollback) { top.recreate(); } else { RuntimeLog.i("no live activity; gen " + generation + " applies on next launch"); - if (pendingReloadGeneration == generation) { + if (firstFrame.pending() == generation) { // The activity that was resumed when this deploy was accepted is gone, // so there is no frame left to ack on - the same situation the // backgrounded branch already acks at apply time. Without this the // host learns only from its deploy timeout, and the crash guard goes // on blaming this generation for anything the app throws later. - pendingReloadGeneration = -1; + firstFrame.disarm(); client.reportReloaded(generation, SystemClock.uptimeMillis() - pendingReloadStartUptime); } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGateTest.java new file mode 100644 index 0000000000..e7a3b6881d --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGateTest.java @@ -0,0 +1,89 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pins the rule that a hot-swapped generation stays blamable until a frame of it has been drawn. + * + * The defect this covers: the runtime used to ack the generation, clear the pending slot and write good.json from onResume, which precedes the first traversal. A payload that resumed and then threw in measure, layout or draw therefore reached the crash guard with nothing pending and nothing unproven, so {@link BootProbation#generationToBlame} returned -1, {@link PayloadPersistence#quarantine} then refused to name a generation already recorded good, and every relaunch adopted it and died the same way. + * + * What these tests deliberately do NOT pin is the call site - that {@code onActivityResumed} installs a draw listener rather than completing inline needs an Activity, a Window and a live ViewTreeObserver, so it is checked on device. What they do pin is that the class now owning the pending slot holds it across a resume and releases it only on a drawn frame. + */ +class FirstFrameGateTest { + + /** + * The regression: a crash after the resume but before the first drawn frame still names a generation to quarantine. + * + * Goes red if the gate stops holding the generation across the undrawn window - which is the pre-fix behaviour, expressed in the class that now owns it. + */ + @Test + void crashBetweenResumeAndFirstFrameStillQuarantines() { + FirstFrameGate gate = new FirstFrameGate(); + BootProbation probation = new BootProbation(); + gate.arm(7); + + // The activity has resumed. Nothing releases the gate here, which is the fix: + // the crash guard runs with the generation still pending. + assertThat(gate.pending()).isEqualTo(7); + assertThat(probation.generationToBlame(gate.pending(), 7)).isEqualTo(7); + } + + /** Once the frame is drawn the generation is vouched for, so a later crash is no longer blamed on it. */ + @Test + void aDrawnFrameReleasesTheGenerationAndStopsTheBlame() { + FirstFrameGate gate = new FirstFrameGate(); + BootProbation probation = new BootProbation(); + gate.arm(7); + + assertThat(gate.drawn(7)).isEqualTo(7); + assertThat(gate.pending()).isEqualTo(-1); + assertThat(probation.generationToBlame(gate.pending(), 7)).isEqualTo(-1); + } + + /** The ack fires once: every frame after the first finds the slot already released. */ + @Test + void onlyTheFirstDrawnFrameAcks() { + FirstFrameGate gate = new FirstFrameGate(); + gate.arm(7); + + assertThat(gate.drawn(7)).isEqualTo(7); + assertThat(gate.drawn(7)).isEqualTo(-1); + assertThat(gate.drawn(7)).isEqualTo(-1); + } + + /** A pending generation the store has already moved past is stale, so its frame acks nothing. */ + @Test + void aFrameForASupersededGenerationAcksNothing() { + FirstFrameGate gate = new FirstFrameGate(); + gate.arm(7); + + assertThat(gate.drawn(8)).isEqualTo(-1); + // Still pending, so gen 7 keeps taking the blame until its own frame or a disarm. + assertThat(gate.pending()).isEqualTo(7); + } + + /** A backgrounded apply arms with -1, which must clear an older generation rather than leave it to be blamed. */ + @Test + void armingWithNoPendingGenerationClearsTheSlot() { + FirstFrameGate gate = new FirstFrameGate(); + gate.arm(7); + + gate.arm(Generations.pendingAfterApply(false, 8)); + + assertThat(gate.pending()).isEqualTo(-1); + } + + /** A rolled-back reload releases the slot without acking, since there is no frame to report. */ + @Test + void disarmReleasesWithoutAcking() { + FirstFrameGate gate = new FirstFrameGate(); + gate.arm(7); + + gate.disarm(); + + assertThat(gate.pending()).isEqualTo(-1); + assertThat(gate.drawn(7)).isEqualTo(-1); + } +} From eeaa8a4e18129240a9b766d602a041c96fb85258 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 13:50:47 -0700 Subject: [PATCH 10/40] ADFA-4128: serialize and order the API 28/29 resource swap Answers review threads 3926539450 and 3926539457 (both IMPORTANT) on PR #1716. applyTableLegacy mutated the live AssetManager and flushed the Resources caches straight off the binder thread the deploy arrived on, which is exactly what swapProvidersOnMain's own KDoc says must not happen: addAssetPath re-tables the AssetManager and flushCaches drops the drawable and typed-value caches, and either can race an inflation already in progress. The path is live rather than dead - CoGo's classifier routes resource edits with no SDK gate, so a res/ edit on a 28/29 device lands here. It also compared nothing before mounting, while both loader swaps drop an overtaken generation against swappedGeneration. Two binder threads could interleave so that the older table was the last one added, which wins the lookup: the screen resolved gen N-1 while the store reported gen N, and legacyTableZip then handed that apk to every activity created afterwards. The write stays on the calling thread; only the mount is posted, under the store's monitor, behind the same generation comparison its two siblings use. A mount failure now travels back through the swap-failure listener instead of being thrown synchronously, so the deploy still fails rather than acking a table that never mounted. Not JVM-tested: ResourceStore needs a Context, a Resources and a live main Looper, and is one of the classes this module's coverage gate excludes as device-only glue. Both siblings' identical guards are untested for the same reason. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/runtime/ResourceStore.java | 61 +++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index a974220dd4..0eea079d2a 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -162,7 +162,7 @@ void applyTable(ParcelFileDescriptor tableFd, long generation, Context appContex applyTableWithLoader(tableFd, generation, appContext, onFailure); return; case LEGACY_ASSET_PATH: - applyTableLegacy(tableFd, generation, appContext); + applyTableLegacy(tableFd, generation, appContext, onFailure); return; default: Streams.closeQuietly(tableFd); @@ -204,29 +204,32 @@ void attachTo(Resources resources) { } /** - * API 28/29 swap: write the apk to disk, addAssetPath it into the application AssetManager, flush caches. + * API 28/29 swap: write the apk to disk, then addAssetPath it into the application AssetManager and flush caches on the main thread. * - * The deploy's activity recreate then re-resolves from the new table. A throw rolls back the dex payload only, not this path's own on-disk apk or an addAssetPath that already succeeded. + * The mount is posted through {@link #swapProvidersOnMain} for the reason its own KDoc gives for the loader path: addAssetPath re-tables the live AssetManager and flushCaches drops the drawable and typed-value caches, and either can race an inflation already in progress - a lookup straddling the swap mixes old and new values. Running it on the arriving binder thread left that race open on exactly the devices this path exists for, since CoGo's classifier routes resource edits with no SDK gate. + * + * The mount is also ordered against the other swaps by {@code swappedGeneration}, like both siblings: two deploys arrive on two binder threads, so an overtaken one could otherwise addAssetPath last and leave the screen resolving an older table than {@code PayloadStore.generation()} reports. + * + * The write stays off the main thread; only the mount is posted. A throw rolls back the dex payload only, not this path's own on-disk apk or a mount that already committed. * * @param tableFd * the relinked resource apk; always closed, success or failure * @param generation - * the payload generation, which names the file on disk + * the payload generation, which names the file on disk and orders this swap against the others; an overtaken one is dropped rather than mounted * @param appContext * application context, for the cache dir and the Resources to mount onto + * @param onFailure + * told when the posted mount fails or is refused, since that lands after this method returns; null when the caller has nothing to do about it * @throws IOException - * when the write or the mount fails; the previous table stays live + * when the write fails; the previous table stays live. A mount failure arrives through {@code onFailure} instead. */ - private void applyTableLegacy(ParcelFileDescriptor tableFd, long generation, - Context appContext) throws IOException { + private void applyTableLegacy(ParcelFileDescriptor tableFd, final long generation, + final Context appContext, SwapFailure onFailure) throws IOException { InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(tableFd); + final File zip; try { File dir = new File(appContext.getCacheDir(), LEGACY_TABLE_DIR); - File zip = LegacyResourceSwap.writeResourceApk(in, dir, generation); - Resources appResources = appContext.getResources(); - LegacyResourceSwap.addAssetPath(appResources.getAssets(), zip.getAbsolutePath()); - legacyTableZip = zip; - LegacyResourceSwap.flushCaches(appResources); + zip = LegacyResourceSwap.writeResourceApk(in, dir, generation); } finally { try { in.close(); @@ -234,6 +237,40 @@ private void applyTableLegacy(ParcelFileDescriptor tableFd, long generation, // Nothing useful to do with a failed close. } } + // The return is not tested: a refused post is already reported through onFailure, + // and unlike the loader paths there is no provider left in this method's hands to + // close - the apk on disk is swept by deleteStaleApks on the next process start. + swapProvidersOnMain(new Runnable() { + + @Override + public void run() { + synchronized (ResourceStore.this) { + if (generation < swappedGeneration) { + // Overtaken, same as both loader swaps. Mounting now would make the last + // addAssetPath win the lookup with the older table, under the newer + // generation's label, and hand that apk to every later activity. + RuntimeLog.w("dropping overtaken legacy table swap for gen " + generation + + "; gen " + swappedGeneration + " already committed"); + return; + } + Resources appResources = appContext.getResources(); + try { + LegacyResourceSwap.addAssetPath(appResources.getAssets(), + zip.getAbsolutePath()); + } catch (IOException error) { + // A Runnable cannot carry a checked exception out, and swallowing it would + // ack a table that never mounted. swapProvidersOnMain catches Throwable and + // routes it to onFailure, so wrap rather than drop. + throw new IllegalStateException( + "legacy table swap failed for gen " + generation, error); + } + // Recorded only after the mount took, as in both loader swaps. + legacyTableZip = zip; + swappedGeneration = generation; + LegacyResourceSwap.flushCaches(appResources); + } + } + }, onFailure); } /** From e5b05202b2248e9ff67b9af3e269734684446df6 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 13:50:58 -0700 Subject: [PATCH 11/40] ADFA-4128: a stale handshake failure must not tear down a live binding Answers review thread 3926539468 (IMPORTANT) on PR #1716. Moving the connect handshake onto its own thread left both failure branches writing the shared host field unconditionally. The handshake can outlive its binding: CoGo's service dies, onServiceDisconnected nulls the host, the framework reconnects and a second handshake succeeds against a new proxy. The first thread's connect() then fails and nulls that live host, unbinds a healthy channel and schedules a rebind - and until the rebind lands, reportReloaded and reportCrash only log "not connected", so every deploy in the window can end only in the host's own timeout. Both branches now go through abandonHandshake, which takes the monitor and returns early unless the proxy that failed is still the live one. The test and the teardown have to be one step because host is also written from the framework's callback thread. Not JVM-tested: QuickBuildClient is binder and ServiceConnection glue over a Context, an IBinder and a main-thread Handler, and is one of the classes this module's coverage gate excludes as device-only. The interleaving the reviewer describes is a plausible reading of the code rather than one reproduced here; what is verified is that the guard was absent and is now present. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/runtime/QuickBuildClient.java | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index 18801367cf..b9bcab0e45 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -283,19 +283,35 @@ private void connectToHost(IQuickBuildHost connected) { RuntimeLog.i("connected to CoGo (running gen " + runtime.runningGeneration() + ")"); } catch (RemoteException error) { RuntimeLog.e("connect() to CoGo failed", error); - host = null; - unbindQuietly(); - scheduleRebind(); + abandonHandshake(connected); } catch (RuntimeException error) { // SecurityException (and any other binder-propagatable runtime exception) from // the host: expected when CoGo has no live session. Continue standalone. RuntimeLog.w("CoGo rejected connect(); continuing standalone", error); - host = null; - unbindQuietly(); - scheduleRebind(); + abandonHandshake(connected); } } + /** + * Drops the binding after a failed handshake, but only while the proxy that failed is still the live one. + * + * The handshake runs on its own thread, so a slow one outlives its binding: CoGo's service dies, {@link #onServiceDisconnected} nulls the host, the framework reconnects, and a second handshake succeeds against a new proxy. Unguarded, the first thread's failure then nulls that live host, unbinds a healthy channel and schedules a rebind - and until the rebind lands every {@code reportReloaded} and {@code reportCrash} only logs "not connected", so each deploy in the window can end only in the host's own timeout. + * + * Under the monitor, because the test and the teardown have to be one step: {@code host} is written from the framework's callback thread as well as from here. + * + * @param connected + * the proxy whose handshake failed + */ + private synchronized void abandonHandshake(IQuickBuildHost connected) { + if (host != connected) { + RuntimeLog.w("stale handshake failure; a newer binding is live, leaving it alone"); + return; + } + host = null; + unbindQuietly(); + scheduleRebind(); + } + /** Queues one rebind attempt, doubling the delay up to {@link #REBIND_MAX_DELAY_MS}. */ private synchronized void scheduleRebind() { if (rebindScheduled) { From e035c410d2811560c0b78e3eb0934d1a0508602b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 13:51:10 -0700 Subject: [PATCH 12/40] ADFA-4128: delete a temp file on every failure, not just a failed rename Answers review thread 3926539487 (MINOR) on PR #1716. AssetExtractor.writeFile deleted its .qb-tmp only from the rename fallback, so a throw out of the copy - the payload cap, a write failure, a truncated entry - left the partial file under current/assets/, which is the tree DirectoryAssetsProvider resolves names against, and the app could open it by name. Both write paths now delete the temp from one finally that covers the copy, the close and the rename alike. The reviewer cites two siblings as already correct. Only one is: LegacyResourceSwap.writeResourceApk does delete its partial file when the copy fails. PayloadPersistence.writeAtomic has the same gap being fixed here - its finally only closed the stream, and its temp.delete() sat in the rename fallback - so an oversize payload or a full disk left a .tmp in the store directory that nothing sweeps. Both are fixed here. Two tests, each verified to fail without the fix and for its own reason - a leftover temp file, not an unexpected throw: AssetExtractorFailurePathTest.aCopyThatFailsMidEntryLeavesNoTempFile drives a truncated zip entry, and PayloadPersistenceAtomicWriteTest.aStreamThatFailsMidCopyLeavesNoTempFile drives a payload stream that dies mid-copy. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/runtime/AssetExtractor.java | 43 +++++++++++-------- .../runtime/PayloadPersistence.java | 38 ++++++++++------ .../AssetExtractorFailurePathTest.java | 33 +++++++++++++- .../PayloadPersistenceAtomicWriteTest.java | 35 +++++++++++++++ 4 files changed, 116 insertions(+), 33 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java index 832b1eab24..72d69f3837 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java @@ -178,6 +178,8 @@ private static String readMarker(File marker) { * the current zip entry's bytes; read to the end of the entry, never closed * @param target * the final path, already checked to sit inside the destination directory + * The temp is deleted on every failure, not only a failed rename: it sits in the tree {@link DirectoryAssetsProvider} serves, so a partial file left there is one the app can open by name. + * * @throws IOException * when a parent directory cannot be created, the copy fails, or the rename into place fails twice */ @@ -187,29 +189,36 @@ private static long writeFile(InputStream in, File target, long remaining) throw throw new IOException("cannot create dir " + parent); } File temp = new File(parent, target.getName() + ".qb-tmp"); - FileOutputStream out = new FileOutputStream(temp); long written = 0; + boolean placed = false; try { - byte[] buffer = new byte[BUFFER_SIZE]; - int read; - while ((read = in.read(buffer)) != -1) { - written += read; - if (written > remaining) { - throw new IOException("asset payload exceeds " + Streams.MAX_PAYLOAD_BYTES - + " bytes at " + target.getName()); + FileOutputStream out = new FileOutputStream(temp); + try { + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + written += read; + if (written > remaining) { + throw new IOException("asset payload exceeds " + Streams.MAX_PAYLOAD_BYTES + + " bytes at " + target.getName()); + } + out.write(buffer, 0, read); } - out.write(buffer, 0, read); + } finally { + out.close(); } - } finally { - out.close(); - } - if (!temp.renameTo(target)) { - // Rename over an existing file can fail on some filesystems; retry once - // after an explicit delete, then give up loudly. - target.delete(); if (!temp.renameTo(target)) { + // Rename over an existing file can fail on some filesystems; retry once + // after an explicit delete, then give up loudly. + target.delete(); + if (!temp.renameTo(target)) { + throw new IOException("cannot move extracted asset into place: " + target); + } + } + placed = true; + } finally { + if (!placed) { temp.delete(); - throw new IOException("cannot move extracted asset into place: " + target); } } return written; diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java index 2b7b7c5c29..7695ab4572 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java @@ -169,23 +169,33 @@ private static void writeAtomic(File target, byte[] bytes) throws IOException { */ private static void writeAtomic(File target, InputStream in) throws IOException { File temp = new File(target.getParentFile(), target.getName() + TEMP_SUFFIX); - FileOutputStream out = new FileOutputStream(temp); + boolean placed = false; try { - Streams.copy(in, out, Streams.MAX_PAYLOAD_BYTES); - out.getFD().sync(); - } finally { - Streams.closeQuietly(out); - } - if (!temp.renameTo(target)) { - // rename over an existing file is atomic on POSIX; a failure here is a - // filesystem oddity - fall back to delete+rename before giving up. The - // delete's own result is not the test: it returns false on a first write, - // where there was nothing to delete, which short-circuited the retry that - // would have worked and left the temp file behind for good. - target.delete(); + FileOutputStream out = new FileOutputStream(temp); + try { + Streams.copy(in, out, Streams.MAX_PAYLOAD_BYTES); + out.getFD().sync(); + } finally { + Streams.closeQuietly(out); + } if (!temp.renameTo(target)) { + // rename over an existing file is atomic on POSIX; a failure here is a + // filesystem oddity - fall back to delete+rename before giving up. The + // delete's own result is not the test: it returns false on a first write, + // where there was nothing to delete, which short-circuited the retry that + // would have worked and left the temp file behind for good. + target.delete(); + if (!temp.renameTo(target)) { + throw new IOException("cannot rename " + temp + " to " + target); + } + } + placed = true; + } finally { + // Every failure, not just a failed rename: an oversize payload or a full disk + // threw out of the copy above and left the partial temp in the store dir, where + // nothing sweeps it and the next write of the same generation finds it there. + if (!placed) { temp.delete(); - throw new IOException("cannot rename " + temp + " to " + target); } } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java index 698b8c0548..8b9311ec5d 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java @@ -32,14 +32,18 @@ private static String readFile(File file) throws IOException { } } - private static InputStream zipWithEntry(String name, byte[] content) throws IOException { + private static byte[] zipBytes(String name, byte[] content) throws IOException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); ZipOutputStream zip = new ZipOutputStream(bytes); zip.putNextEntry(new ZipEntry(name)); zip.write(content); zip.closeEntry(); zip.close(); - return new ByteArrayInputStream(bytes.toByteArray()); + return bytes.toByteArray(); + } + + private static InputStream zipWithEntry(String name, byte[] content) throws IOException { + return new ByteArrayInputStream(zipBytes(name, content)); } @TempDir @@ -88,6 +92,31 @@ void anUndeletableTargetFailsLoudlyAndLeavesNoTempFile() throws IOException { assertThat(new File(inTheWay, "child").isDirectory()).isTrue(); } + /** + * A copy that dies mid-entry leaves no readable partial behind, not just a failed rename. + * + * Goes red without the fix: the temp was deleted only from the rename fallback, so a throw out of the copy left {@code a.txt.qb-tmp} sitting under the very directory {@link DirectoryAssetsProvider} resolves asset names against, where the app could open it. + */ + @Test + void aCopyThatFailsMidEntryLeavesNoTempFile() throws IOException { + File dest = tempDir.resolve("dest").toFile(); + byte[] content = new byte[256 * 1024]; + for (int i = 0; i < content.length; i++) { + // Poorly compressible, so the truncation below really does cut the entry + // short rather than land past its whole deflated form. + content[i] = (byte) (i * 31 + (i >> 3)); + } + byte[] whole = zipBytes("a.txt", content); + byte[] truncated = new byte[whole.length / 2]; + System.arraycopy(whole, 0, truncated, 0, truncated.length); + + assertThrows(IOException.class, + () -> AssetExtractor.extract(new ByteArrayInputStream(truncated), dest)); + + assertThat(new File(dest, "a.txt.qb-tmp").exists()).isFalse(); + assertThat(new File(dest, "a.txt").exists()).isFalse(); + } + @Test void renameFallbackReplacesAnEmptyDirectoryInTheWay() throws IOException { File dest = tempDir.resolve("dest").toFile(); diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java index 650420dfe8..4514c15cd1 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java @@ -6,6 +6,7 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.Test; @@ -20,6 +21,22 @@ class PayloadPersistenceAtomicWriteTest { private static final String FP = "fp"; + /** A payload stream that yields a few bytes and then fails, like a read off a dying fd. */ + private static InputStream failingStream() { + return new InputStream() { + + private int served; + + @Override + public int read() throws IOException { + if (served++ < 8) { + return 0x41; + } + throw new IOException("payload stream died mid-copy"); + } + }; + } + @TempDir Path tempDir; @@ -37,6 +54,24 @@ void anUndeletableRenameTargetFailsThePersistLoudly() throws IOException { assertThat(error).hasMessageThat().contains("cannot rename"); } + /** + * A stream payload that dies mid-copy leaves no {@code .tmp} behind in the store dir. + * + * Goes red without the fix: writeAtomic deleted its temp only from the rename fallback, so a throw out of the copy - an oversize payload, a full disk - left the partial temp in the store directory, which nothing sweeps. + */ + @Test + void aStreamThatFailsMidCopyLeavesNoTempFile() throws IOException { + File dir = tempDir.resolve("store").toFile(); + PayloadPersistence store = new PayloadPersistence(dir); + + assertThrows(IOException.class, + () -> store.persist(1, FP, new byte[]{1}, failingStream(), null)); + + String[] leftovers = dir.list((unusedDir, name) -> name.endsWith(".tmp")); + assertThat(leftovers).isNotNull(); + assertThat(leftovers).isEmpty(); + } + @Test void aStorePathBlockedByAFileFailsThePersistLoudly() throws IOException { File blocked = tempDir.resolve("store").toFile(); From ec7558811c136e0bd6fbbace3ff6a4e25f34d1f7 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 13:51:20 -0700 Subject: [PATCH 13/40] ADFA-4128: drop the no-op test-heap override Answers review thread 3926539494 (NITPICK) on PR #1716. The root build already sets maxHeapSize = "1g" on every Test task in every subproject (build.gradle.kts, the subprojects tasks.withType block), so this module never saw Gradle's 512 MB default and the override changed nothing. Its comment also compared against neither the real cap - Streams.MAX_PAYLOAD_BYTES is 64 MB - nor the heap actually in force, so a reader trimming test memory later would have trusted it twice over. Verified against the root build before removing: the subprojects block does set it, so dropping this leaves the same 1g in effect. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- quickbuild/runtime/build.gradle.kts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/quickbuild/runtime/build.gradle.kts b/quickbuild/runtime/build.gradle.kts index ba287659de..c952973813 100644 --- a/quickbuild/runtime/build.gradle.kts +++ b/quickbuild/runtime/build.gradle.kts @@ -37,10 +37,6 @@ android { // map parsing, asset extraction). Mirrors :quick-build's jupiter setup. tasks.withType { useJUnitPlatform() - // StreamsTest exercises the payload cap through the default readFully overload; a - // capped reader legitimately buffers up to the cap and then copies it, so the peak is - // about twice the cap - more headroom than Gradle's default 512 MB test-worker heap. - maxHeapSize = "1g" } // DoD coverage gate: >=90% line+branch on non-UI (domain/data) code. From 75cdaff832f591d063e74590d8e9f330cf93a90c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 13:58:03 -0700 Subject: [PATCH 14/40] ADFA-4128: style: spotless reformat, no functional change The Eclipse Java formatter sorts members, so files this round touched came under the ratchet and had their declarations reordered. Kept standalone so the behavioural commits around it stay readable. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/runtime/AssetExtractor.java | 3 +- .../quickbuild/runtime/FirstFrameGate.java | 22 +-- .../quickbuild/runtime/QuickBuildClient.java | 40 ++--- .../quickbuild/runtime/QuickBuildRuntime.java | 154 +++++++++--------- .../AssetExtractorFailurePathTest.java | 50 +++--- .../runtime/FirstFrameGateTest.java | 56 +++---- .../PayloadPersistenceAtomicWriteTest.java | 24 +-- 7 files changed, 174 insertions(+), 175 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java index 72d69f3837..9d43ac0f1e 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java @@ -177,8 +177,7 @@ private static String readMarker(File marker) { * @param in * the current zip entry's bytes; read to the end of the entry, never closed * @param target - * the final path, already checked to sit inside the destination directory - * The temp is deleted on every failure, not only a failed rename: it sits in the tree {@link DirectoryAssetsProvider} serves, so a partial file left there is one the app can open by name. + * the final path, already checked to sit inside the destination directory The temp is deleted on every failure, not only a failed rename: it sits in the tree {@link DirectoryAssetsProvider} serves, so a partial file left there is one the app can open by name. * * @throws IOException * when a parent directory cannot be created, the copy fails, or the rename into place fails twice diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java index 8661d0314d..5bc847ead9 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java @@ -29,17 +29,6 @@ synchronized void disarm() { pendingGeneration = -1; } - /** - * The generation a crash right now should be blamed on. - * - * Deliberately unchanged by a resume: between the resume and the drawn frame this still names the generation that just took the screen, which is the whole point of the gate. - * - * @return the pending generation, or -1 when none is - */ - synchronized long pending() { - return pendingGeneration; - } - /** * Records that a frame carrying the live generation finished drawing, releasing its ack. * @@ -55,4 +44,15 @@ synchronized long drawn(long liveGeneration) { pendingGeneration = -1; return acked; } + + /** + * The generation a crash right now should be blamed on. + * + * Deliberately unchanged by a resume: between the resume and the drawn frame this still names the generation that just took the screen, which is the whole point of the gate. + * + * @return the pending generation, or -1 when none is + */ + synchronized long pending() { + return pendingGeneration; + } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index b9bcab0e45..b103ef0d9c 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -237,6 +237,26 @@ void reportReloaded(long generation, long reloadMillis) { } } + /** + * Drops the binding after a failed handshake, but only while the proxy that failed is still the live one. + * + * The handshake runs on its own thread, so a slow one outlives its binding: CoGo's service dies, {@link #onServiceDisconnected} nulls the host, the framework reconnects, and a second handshake succeeds against a new proxy. Unguarded, the first thread's failure then nulls that live host, unbinds a healthy channel and schedules a rebind - and until the rebind lands every {@code reportReloaded} and {@code reportCrash} only logs "not connected", so each deploy in the window can end only in the host's own timeout. + * + * Under the monitor, because the test and the teardown have to be one step: {@code host} is written from the framework's callback thread as well as from here. + * + * @param connected + * the proxy whose handshake failed + */ + private synchronized void abandonHandshake(IQuickBuildHost connected) { + if (host != connected) { + RuntimeLog.w("stale handshake failure; a newer binding is live, leaving it alone"); + return; + } + host = null; + unbindQuietly(); + scheduleRebind(); + } + /** * Issues one bindService against CoGo's explicit service intent. * @@ -292,26 +312,6 @@ private void connectToHost(IQuickBuildHost connected) { } } - /** - * Drops the binding after a failed handshake, but only while the proxy that failed is still the live one. - * - * The handshake runs on its own thread, so a slow one outlives its binding: CoGo's service dies, {@link #onServiceDisconnected} nulls the host, the framework reconnects, and a second handshake succeeds against a new proxy. Unguarded, the first thread's failure then nulls that live host, unbinds a healthy channel and schedules a rebind - and until the rebind lands every {@code reportReloaded} and {@code reportCrash} only logs "not connected", so each deploy in the window can end only in the host's own timeout. - * - * Under the monitor, because the test and the teardown have to be one step: {@code host} is written from the framework's callback thread as well as from here. - * - * @param connected - * the proxy whose handshake failed - */ - private synchronized void abandonHandshake(IQuickBuildHost connected) { - if (host != connected) { - RuntimeLog.w("stale handshake failure; a newer binding is live, leaving it alone"); - return; - } - host = null; - unbindQuietly(); - scheduleRebind(); - } - /** Queues one rebind attempt, doubling the delay up to {@link #REBIND_MAX_DELAY_MS}. */ private synchronized void scheduleRebind() { if (rebindScheduled) { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 6e6823c972..402e8b4795 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -395,83 +395,6 @@ void onActivityResumed(final Activity activity) { } } - /** - * Runs the completion once the resumed activity's first frame has finished drawing. - * - * The listener fires at the START of each draw pass, so the completion is posted rather than run inline: the posted message runs after the traversal that drew the frame returns, which is what makes measure, layout and draw failures land BEFORE the generation is vouched for rather than after. Posting is also what makes removing the listener legal, since a {@link ViewTreeObserver} rejects a removal made from inside its own dispatch. - * - * @param activity - * the resumed activity whose first frame gates the completion - * @return true when a draw callback was installed, false when the activity has no live view tree and the caller must complete without one - */ - private boolean completeOnFirstFrame(final Activity activity) { - Window window = activity.getWindow(); - final View decor = window == null ? null : window.peekDecorView(); - if (decor == null) { - return false; - } - final ViewTreeObserver observer = decor.getViewTreeObserver(); - if (observer == null || !observer.isAlive()) { - return false; - } - final ViewTreeObserver.OnDrawListener[] listener = new ViewTreeObserver.OnDrawListener[1]; - final boolean[] scheduled = new boolean[1]; - listener[0] = new ViewTreeObserver.OnDrawListener() { - - @Override - public void onDraw() { - if (scheduled[0]) { - return; - } - scheduled[0] = true; - mainHandler.post(new Runnable() { - - @Override - public void run() { - ViewTreeObserver live = decor.getViewTreeObserver(); - if (live != null && live.isAlive()) { - live.removeOnDrawListener(listener[0]); - } - onFirstFrameDrawn(activity); - } - }); - } - }; - observer.addOnDrawListener(listener[0]); - return true; - } - - /** - * Completes a pending reload now that its generation has been drawn, and renders the overlay and return button. - * - * This is where reportReloaded fires for a foreground deploy, and the reported time is now true time-to-pixels rather than time-to-resume. A backgrounded deploy was already acked at apply time and left no pending generation, so it cannot double-report here. - * - * @param activity - * the activity that drew the frame, which hosts the overlay - */ - private void onFirstFrameDrawn(Activity activity) { - long acked = firstFrame.drawn(PayloadStore.INSTANCE.generation()); - if (acked >= 0) { - long reloadMillis = SystemClock.uptimeMillis() - pendingReloadStartUptime; - client.reportReloaded(acked, reloadMillis); - // Success renders nothing; it only clears a shown error or in-flight - // banner, since a landed reload means the build finished even if the - // build_ok message is still in flight behind it. - if (overlayState.isError() || overlayState.isBuilding()) { - setOverlayState(OverlayState.hidden()); - } else { - overlay.render(activity, overlayState); - } - } else { - overlay.render(activity, overlayState); - } - // Unconditional, because the point is that a drawn frame of this generation - // reached the screen - which is true whether it arrived by hot swap or by a - // fresh process booting it, and only the first of those leaves a pending - // generation behind. - markLiveGenerationGood(); - } - /** Counts an activity into the set a restart deploy waits to empty before killing the process. */ void onActivityStarted() { restartHandoff.onActivityStarted(); @@ -567,6 +490,52 @@ public void run() { } } + /** + * Runs the completion once the resumed activity's first frame has finished drawing. + * + * The listener fires at the START of each draw pass, so the completion is posted rather than run inline: the posted message runs after the traversal that drew the frame returns, which is what makes measure, layout and draw failures land BEFORE the generation is vouched for rather than after. Posting is also what makes removing the listener legal, since a {@link ViewTreeObserver} rejects a removal made from inside its own dispatch. + * + * @param activity + * the resumed activity whose first frame gates the completion + * @return true when a draw callback was installed, false when the activity has no live view tree and the caller must complete without one + */ + private boolean completeOnFirstFrame(final Activity activity) { + Window window = activity.getWindow(); + final View decor = window == null ? null : window.peekDecorView(); + if (decor == null) { + return false; + } + final ViewTreeObserver observer = decor.getViewTreeObserver(); + if (observer == null || !observer.isAlive()) { + return false; + } + final ViewTreeObserver.OnDrawListener[] listener = new ViewTreeObserver.OnDrawListener[1]; + final boolean[] scheduled = new boolean[1]; + listener[0] = new ViewTreeObserver.OnDrawListener() { + + @Override + public void onDraw() { + if (scheduled[0]) { + return; + } + scheduled[0] = true; + mainHandler.post(new Runnable() { + + @Override + public void run() { + ViewTreeObserver live = decor.getViewTreeObserver(); + if (live != null && live.isAlive()) { + live.removeOnDrawListener(listener[0]); + } + onFirstFrameDrawn(activity); + } + }); + } + }; + observer.addOnDrawListener(listener[0]); + return true; + } + /** * Ends the handoff once the main looper has run everything the last activity's stop queued behind it. * @@ -732,6 +701,37 @@ public void run() { }, "qb-mark-good").start(); } + /** + * Completes a pending reload now that its generation has been drawn, and renders the overlay and return button. + * + * This is where reportReloaded fires for a foreground deploy, and the reported time is now true time-to-pixels rather than time-to-resume. A backgrounded deploy was already acked at apply time and left no pending generation, so it cannot double-report here. + * + * @param activity + * the activity that drew the frame, which hosts the overlay + */ + private void onFirstFrameDrawn(Activity activity) { + long acked = firstFrame.drawn(PayloadStore.INSTANCE.generation()); + if (acked >= 0) { + long reloadMillis = SystemClock.uptimeMillis() - pendingReloadStartUptime; + client.reportReloaded(acked, reloadMillis); + // Success renders nothing; it only clears a shown error or in-flight + // banner, since a landed reload means the build finished even if the + // build_ok message is still in flight behind it. + if (overlayState.isError() || overlayState.isBuilding()) { + setOverlayState(OverlayState.hidden()); + } else { + overlay.render(activity, overlayState); + } + } else { + overlay.render(activity, overlayState); + } + // Unconditional, because the point is that a drawn frame of this generation + // reached the screen - which is true whether it arrived by hot swap or by a + // fresh process booting it, and only the first of those leaves a pending + // generation behind. + markLiveGenerationGood(); + } + /** * Writes the payload to the persisted store before anything applies it. * diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java index 8b9311ec5d..7532b47005 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java @@ -49,6 +49,31 @@ private static InputStream zipWithEntry(String name, byte[] content) throws IOEx @TempDir Path tempDir; + /** + * A copy that dies mid-entry leaves no readable partial behind, not just a failed rename. + * + * Goes red without the fix: the temp was deleted only from the rename fallback, so a throw out of the copy left {@code a.txt.qb-tmp} sitting under the very directory {@link DirectoryAssetsProvider} resolves asset names against, where the app could open it. + */ + @Test + void aCopyThatFailsMidEntryLeavesNoTempFile() throws IOException { + File dest = tempDir.resolve("dest").toFile(); + byte[] content = new byte[256 * 1024]; + for (int i = 0; i < content.length; i++) { + // Poorly compressible, so the truncation below really does cut the entry + // short rather than land past its whole deflated form. + content[i] = (byte) (i * 31 + (i >> 3)); + } + byte[] whole = zipBytes("a.txt", content); + byte[] truncated = new byte[whole.length / 2]; + System.arraycopy(whole, 0, truncated, 0, truncated.length); + + assertThrows(IOException.class, + () -> AssetExtractor.extract(new ByteArrayInputStream(truncated), dest)); + + assertThat(new File(dest, "a.txt.qb-tmp").exists()).isFalse(); + assertThat(new File(dest, "a.txt").exists()).isFalse(); + } + @Test void aDestDirBlockedByAFileThrows() throws IOException { File blocked = tempDir.resolve("dest").toFile(); @@ -92,31 +117,6 @@ void anUndeletableTargetFailsLoudlyAndLeavesNoTempFile() throws IOException { assertThat(new File(inTheWay, "child").isDirectory()).isTrue(); } - /** - * A copy that dies mid-entry leaves no readable partial behind, not just a failed rename. - * - * Goes red without the fix: the temp was deleted only from the rename fallback, so a throw out of the copy left {@code a.txt.qb-tmp} sitting under the very directory {@link DirectoryAssetsProvider} resolves asset names against, where the app could open it. - */ - @Test - void aCopyThatFailsMidEntryLeavesNoTempFile() throws IOException { - File dest = tempDir.resolve("dest").toFile(); - byte[] content = new byte[256 * 1024]; - for (int i = 0; i < content.length; i++) { - // Poorly compressible, so the truncation below really does cut the entry - // short rather than land past its whole deflated form. - content[i] = (byte) (i * 31 + (i >> 3)); - } - byte[] whole = zipBytes("a.txt", content); - byte[] truncated = new byte[whole.length / 2]; - System.arraycopy(whole, 0, truncated, 0, truncated.length); - - assertThrows(IOException.class, - () -> AssetExtractor.extract(new ByteArrayInputStream(truncated), dest)); - - assertThat(new File(dest, "a.txt.qb-tmp").exists()).isFalse(); - assertThat(new File(dest, "a.txt").exists()).isFalse(); - } - @Test void renameFallbackReplacesAnEmptyDirectoryInTheWay() throws IOException { File dest = tempDir.resolve("dest").toFile(); diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGateTest.java index e7a3b6881d..2a00e66663 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGateTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGateTest.java @@ -13,23 +13,6 @@ */ class FirstFrameGateTest { - /** - * The regression: a crash after the resume but before the first drawn frame still names a generation to quarantine. - * - * Goes red if the gate stops holding the generation across the undrawn window - which is the pre-fix behaviour, expressed in the class that now owns it. - */ - @Test - void crashBetweenResumeAndFirstFrameStillQuarantines() { - FirstFrameGate gate = new FirstFrameGate(); - BootProbation probation = new BootProbation(); - gate.arm(7); - - // The activity has resumed. Nothing releases the gate here, which is the fix: - // the crash guard runs with the generation still pending. - assertThat(gate.pending()).isEqualTo(7); - assertThat(probation.generationToBlame(gate.pending(), 7)).isEqualTo(7); - } - /** Once the frame is drawn the generation is vouched for, so a later crash is no longer blamed on it. */ @Test void aDrawnFrameReleasesTheGenerationAndStopsTheBlame() { @@ -42,17 +25,6 @@ void aDrawnFrameReleasesTheGenerationAndStopsTheBlame() { assertThat(probation.generationToBlame(gate.pending(), 7)).isEqualTo(-1); } - /** The ack fires once: every frame after the first finds the slot already released. */ - @Test - void onlyTheFirstDrawnFrameAcks() { - FirstFrameGate gate = new FirstFrameGate(); - gate.arm(7); - - assertThat(gate.drawn(7)).isEqualTo(7); - assertThat(gate.drawn(7)).isEqualTo(-1); - assertThat(gate.drawn(7)).isEqualTo(-1); - } - /** A pending generation the store has already moved past is stale, so its frame acks nothing. */ @Test void aFrameForASupersededGenerationAcksNothing() { @@ -75,6 +47,23 @@ void armingWithNoPendingGenerationClearsTheSlot() { assertThat(gate.pending()).isEqualTo(-1); } + /** + * The regression: a crash after the resume but before the first drawn frame still names a generation to quarantine. + * + * Goes red if the gate stops holding the generation across the undrawn window - which is the pre-fix behaviour, expressed in the class that now owns it. + */ + @Test + void crashBetweenResumeAndFirstFrameStillQuarantines() { + FirstFrameGate gate = new FirstFrameGate(); + BootProbation probation = new BootProbation(); + gate.arm(7); + + // The activity has resumed. Nothing releases the gate here, which is the fix: + // the crash guard runs with the generation still pending. + assertThat(gate.pending()).isEqualTo(7); + assertThat(probation.generationToBlame(gate.pending(), 7)).isEqualTo(7); + } + /** A rolled-back reload releases the slot without acking, since there is no frame to report. */ @Test void disarmReleasesWithoutAcking() { @@ -86,4 +75,15 @@ void disarmReleasesWithoutAcking() { assertThat(gate.pending()).isEqualTo(-1); assertThat(gate.drawn(7)).isEqualTo(-1); } + + /** The ack fires once: every frame after the first finds the slot already released. */ + @Test + void onlyTheFirstDrawnFrameAcks() { + FirstFrameGate gate = new FirstFrameGate(); + gate.arm(7); + + assertThat(gate.drawn(7)).isEqualTo(7); + assertThat(gate.drawn(7)).isEqualTo(-1); + assertThat(gate.drawn(7)).isEqualTo(-1); + } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java index 4514c15cd1..daec4f9b5b 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java @@ -54,6 +54,18 @@ void anUndeletableRenameTargetFailsThePersistLoudly() throws IOException { assertThat(error).hasMessageThat().contains("cannot rename"); } + @Test + void aStorePathBlockedByAFileFailsThePersistLoudly() throws IOException { + File blocked = tempDir.resolve("store").toFile(); + Files.write(blocked.toPath(), "not a dir".getBytes("UTF-8")); + PayloadPersistence store = new PayloadPersistence(blocked); + + IOException error = assertThrows(IOException.class, + () -> store.persist(1, FP, new byte[]{1}, null, null)); + + assertThat(error).hasMessageThat().contains("cannot create"); + } + /** * A stream payload that dies mid-copy leaves no {@code .tmp} behind in the store dir. * @@ -72,18 +84,6 @@ void aStreamThatFailsMidCopyLeavesNoTempFile() throws IOException { assertThat(leftovers).isEmpty(); } - @Test - void aStorePathBlockedByAFileFailsThePersistLoudly() throws IOException { - File blocked = tempDir.resolve("store").toFile(); - Files.write(blocked.toPath(), "not a dir".getBytes("UTF-8")); - PayloadPersistence store = new PayloadPersistence(blocked); - - IOException error = assertThrows(IOException.class, - () -> store.persist(1, FP, new byte[]{1}, null, null)); - - assertThat(error).hasMessageThat().contains("cannot create"); - } - @Test void clearIsBestEffortWhenAnEntryCannotBeDeleted() throws IOException { File dir = tempDir.resolve("store").toFile(); From 30408427bb1324ef0bdb19c5d1f73ce1e305097c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 13:59:17 -0700 Subject: [PATCH 15/40] ADFA-4128: hold a backgrounded deploy's ack until its resource swaps commit Answers review threads 3926539472 (IMPORTANT) and, in part, 3926539480 (IMPORTANT) on PR #1716. The earlier swap-failure fix reached only the foreground branch. applyTable posts its swap to the main looper and returns, and the backgrounded branch then acked on the binder thread while that swap was still queued. When the swap failed, onSwapFailed ran later, rolled the store back to gen N-1, quarantined N and reported the crash - after CoGo had already been told N reloaded. DeployChannel resolves a deploy on the first report naming the generation, so the ack won: the build was recorded as a successful reload with a timing number, the Crashed branch never ran, and the session manager's separate collector still raised RELOAD_CRASHED, so one save produced both signals. The comment on that branch says the backgrounded case is the normal edit loop, so it is the branch a failing resource swap usually takes. SwapFailure becomes SwapOutcome and gains the success counterpart the reviewer points at. Its contract is that exactly one of the two fires per applyTable or applyAssets call that returns normally - including the calls that queue nothing because this SDK level has no swap to make, since a deploy waiting on one of those would wait forever. A swap dropped as overtaken reports committed rather than failed: it returned normally, and the generation that overtook it owns the screen and its own ack. SwapAckGate counts a deploy's posted swaps down to the one ack it owes, and a failure cancels it for good so a second swap landing afterwards cannot turn a rolled-back deploy back into a success. A dex-only deploy - the commonest one - posts nothing and still acks immediately, through noSwapPosted rather than through committed, so a deploy with one swap in flight cannot mistake that call for its swap's own commit. The resumed check moves above the applies, because the commit callback is free to fire before handlePayload returns and has to know which branch it is completing. Arming the first-frame gate moves with it, which also closes a smaller hole: an apply that threw used to leave an older generation's value in the pending slot. The abandoned-generation half of 3926539480 comes with it: an applyAssets failure left the table swap applyTable had already queued live under the rolled-back dex, and nothing marked the generation abandoned, since only onSwapFailed did that. The recreate then rendered gen N's table over gen N-1's classes while the banner said the app was on the last working version. handlePayload's catch now marks it. DEFERRED, deliberately: the other half of 3926539480 - having failReload restore the provider set alongside the payload. A resource rollback is a new capability, not a guard: ResourceStore keeps no per-generation provider history, the API 28/29 path cannot unmount an added asset path at all, and the store's own KDoc already documents "a swap that already took is not undone" as the contract. Adding one belongs in its own change with its own device verification, not folded into a review fix. Marking the generation abandoned already stops the recreate, which is what makes the banner honest. SwapAckGateTest pins the rule and was verified to fail without it: with the gate mutated to ack regardless of queued swaps, and with failed() made a no-op, three of its seven tests go red on exactly the assertions they are named for. The call site itself - handlePayload counting its swaps - needs a binder thread, a main looper and a Context, so it is checked on device; QuickBuildRuntime and ResourceStore are both in this module's device-only coverage exclusion list. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/runtime/QuickBuildRuntime.java | 90 ++++++++++++----- .../quickbuild/runtime/ResourceStore.java | 99 +++++++++++++------ .../quickbuild/runtime/SwapAckGate.java | 66 +++++++++++++ .../quickbuild/runtime/SwapAckGateTest.java | 88 +++++++++++++++++ 4 files changed, 288 insertions(+), 55 deletions(-) create mode 100644 quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGateTest.java diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 402e8b4795..707fbd8fb6 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -222,7 +222,7 @@ void handleBuildStatus(String statusJson) { /** * Applies one deploy: reads the payload fds, persists them, then swaps in the new generation. * - * Runs on a binder thread; only the reload is posted to the main thread. Persisting before applying is what lets a relaunched process boot the newest generation. A restart deploy persists, acks and exits instead, since services, providers and the Application only swap across a process restart; a recreate deploy acks on its next resumed frame, or at apply time when backgrounded, because a deferred recreate renders no frame to prove. + * Runs on a binder thread; only the reload is posted to the main thread. Persisting before applying is what lets a relaunched process boot the newest generation. A restart deploy persists, acks and exits instead, since services, providers and the Application only swap across a process restart; a recreate deploy acks on the first frame it draws, or, when backgrounded, once its resource swaps have committed, because a deferred recreate renders no frame to prove. * * @param generation * the incoming generation; a stale one is dropped without a report, since acking a refused payload would mislead the host @@ -283,7 +283,30 @@ void handlePayload(long generation, ParcelFileDescriptor dexPayload, return; } final PayloadStore.Payload rollback = previous; - ResourceStore.SwapFailure onSwapFailure = new ResourceStore.SwapFailure() { + // Read BEFORE the swaps, not after: the backgrounded ack now rides on the + // swap's commit callback, which is free to fire before this method returns, + // and the callback has to know which branch it is completing. + final boolean resumed = tracker.hasResumedActivity(); + pendingReloadStartUptime = startUptime; + // Armed on BOTH branches: the backgrounded ack must also clear any older + // generation still pending, or the crash guard keeps blaming it for this + // generation's crashes - and this generation escapes quarantine. + firstFrame.arm(Generations.pendingAfterApply(resumed, generation)); + // One outcome per apply call, so a backgrounded ack waits for the last of them. + final SwapAckGate ackGate = new SwapAckGate( + (resourcesPayload == null ? 0 : 1) + (assetsPayload == null ? 0 : 1)); + final long arrivedUptime = startUptime; + ResourceStore.SwapOutcome onSwapOutcome = new ResourceStore.SwapOutcome() { + + @Override + public void onSwapCommitted() { + if (resumed || !ackGate.committed()) { + // A foreground deploy acks from its drawn frame instead, and a + // backgrounded one whose other swap is still in flight is not done. + return; + } + ackBackgroundedReload(generation, arrivedUptime); + } @Override public void onSwapFailed(Throwable error) { @@ -297,40 +320,26 @@ public void onSwapFailed(Throwable error) { // undoes it is still on another thread. Marking rather than removing // the callback also covers the inline swap, which fails before the // recreate has been posted at all. + ackGate.failed(); abandonedReloadGeneration = generation; failReload(generation, rollback, error); } }; if (resourcesPayload != null) { ResourceStore.INSTANCE.applyTable( - openReadOnly(persisted.arscFile), generation, application, onSwapFailure); + openReadOnly(persisted.arscFile), generation, application, onSwapOutcome); } if (assetsPayload != null) { ResourceStore.INSTANCE.applyAssets( openReadOnly(persisted.assetsFile), generation, PayloadStore.INSTANCE.baselineFingerprint(), - application, onSwapFailure); + application, onSwapOutcome); } - boolean resumed = tracker.hasResumedActivity(); - pendingReloadStartUptime = startUptime; - // Assigned on BOTH branches: the backgrounded ack must also clear any older - // generation still pending, or the crash guard keeps blaming it for this - // generation's crashes - and this generation escapes quarantine. - firstFrame.arm(Generations.pendingAfterApply(resumed, generation)); - if (!resumed) { - // Backgrounded: no resumed activity to hang a frame callback on, so - // waiting for render-proof would time out a deploy that worked. Ack at - // apply+persist, like the restart path. - // Do NOT read this as "the recreate is deferred until the user returns." - // Measured on an A56 (Android 16), a stopped-but-not-destroyed activity - // relaunches immediately - the tracker still holds it, so the relaunch is - // scheduled before this ack is even written. That timing is not - // guaranteed across versions or states, which is exactly why the ack does - // not depend on it. - // Tradeoffs: the metric is apply-time, not render-time, and a crash in - // the relaunch goes unreported (gap #91's shape). A background race after - // this check falls back to the deploy timeout. - client.reportReloaded(generation, SystemClock.uptimeMillis() - startUptime); + if (!resumed && ackGate.noSwapPosted()) { + // A dex-only deploy queues no swap, so there is nothing to wait for and + // nothing that could still fail the reload. Every deploy that does carry a + // resource or asset payload leaves this false and acks from the callback. + ackBackgroundedReload(generation, startUptime); } final long reloadGeneration = generation; mainHandler.post(new Runnable() { @@ -347,6 +356,13 @@ public void run() { RuntimeLog.w("dropping payload gen " + generation + ": " + overtaken.getMessage()); } catch (Throwable error) { RuntimeLog.e("payload gen " + generation + " failed to apply", error); + // A step that already ran may have queued a swap that will still commit on main: + // applyTable posts before applyAssets can throw. Nothing cancels that swap, so + // the recreate must not run - it would render this generation's table over the + // dex failReload is rolling back, while the banner says the app is on the last + // working version. Only onSwapFailed used to set this, which is the branch where + // the swap is the thing that failed. + abandonedReloadGeneration = generation; Streams.closeQuietly(dexPayload); Streams.closeQuietly(resourcesPayload); Streams.closeQuietly(assetsPayload); @@ -414,6 +430,32 @@ long runningGeneration() { return PayloadStore.INSTANCE.generation(); } + /** + * Acks a backgrounded deploy, once everything that could still fail its reload has landed. + * + * Deliberately not at apply time. A resource swap is queued to the main looper and commits after the deploy method has returned, so an ack written there claimed a reload that could still fail - and when it did, the rollback, the quarantine and the crash report all ran after CoGo had been told the generation reloaded. CoGo's deploy resolves on the first report naming the generation, so the ack won: the build was recorded as a successful reload with a timing number, while the session manager's separate collector still raised a reload crash, and one save produced both signals. The comment on the branch that calls this says the backgrounded case is the normal edit loop, so that was the branch a failing resource swap usually took. + * + * The foreground branch never had this problem: it acks from a drawn frame, which cannot precede the swap that frame renders. + * + * @param generation + * the generation that applied, which becomes CoGo's new baseline + * @param arrivedUptime + * uptime at which the payload arrived, so the reported duration still spans the whole deploy rather than just the swap + */ + private void ackBackgroundedReload(long generation, long arrivedUptime) { + // Do NOT read the backgrounded branch as "the recreate is deferred until the user + // returns." Measured on an A56 (Android 16), a stopped-but-not-destroyed activity + // relaunches immediately - the tracker still holds it, so the relaunch is scheduled + // before this ack is written. That timing is not guaranteed across versions or + // states, which is exactly why the ack does not depend on it. + // + // Remaining tradeoffs: the metric is swap-time, not render-time, since no frame is + // coming to measure; a crash in the relaunch still goes unreported (gap #91's + // shape); and a background race after the resumed check falls back to the deploy + // timeout. + client.reportReloaded(generation, SystemClock.uptimeMillis() - arrivedUptime); + } + /** * Applies the resource payloads a persisted boot left pending, once a Context exists. * diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 0eea079d2a..77153013a6 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -35,22 +35,41 @@ final class ResourceStore { /** Cache subdirectory holding the cumulative extracted assets and their baseline marker. */ private static final String ASSETS_ROOT_DIR = "quickbuild-assets"; + /** + * Tells the caller's listener the swap is live, without letting the listener's own failure escape. + * + * Runs on the main thread inside the swap's guard, like {@link #reportSwapFailure}, so a throw here would turn a landed swap into an unrelated crash. + * + * @param onOutcome + * the caller's listener; null is a no-op + */ + private static void reportSwapCommitted(SwapOutcome onOutcome) { + if (onOutcome == null) { + return; + } + try { + onOutcome.onSwapCommitted(); + } catch (Throwable reportFailure) { + RuntimeLog.e("resource swap commit listener threw", reportFailure); + } + } + /** * Hands a swap failure to the caller's listener without letting the listener's own failure escape. * * This runs on the main thread inside the swap's guard, so a throw here would replace a resource failure with an unrelated crash. * - * @param onFailure + * @param onOutcome * the caller's listener; null is a no-op * @param error * the failure to report, already logged */ - private static void reportSwapFailure(SwapFailure onFailure, Throwable error) { - if (onFailure == null) { + private static void reportSwapFailure(SwapOutcome onOutcome, Throwable error) { + if (onOutcome == null) { return; } try { - onFailure.onSwapFailed(error); + onOutcome.onSwapFailed(error); } catch (Throwable reportFailure) { RuntimeLog.e("resource swap failure listener threw", reportFailure); } @@ -114,20 +133,25 @@ private ResourceStore() { * the running baseline's fingerprint, which keys the cumulative dir * @param appContext * application context, for the cache dir the cumulative override lives under and the Resources the loader attaches to - * @param onFailure + * @param onOutcome * told when the posted provider swap fails, since that lands after this method returns; null when the caller has nothing to do about it * @throws IOException * on a read, extraction, path-traversal or provider failure; the previous override stays live */ void applyAssets(ParcelFileDescriptor assetsFd, long generation, String baselineFingerprint, - Context appContext, SwapFailure onFailure) throws IOException { + Context appContext, SwapOutcome onOutcome) throws IOException { File assetsRoot = new File(appContext.getCacheDir(), ASSETS_ROOT_DIR); InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(assetsFd); try { int extracted = AssetExtractor.extractCumulative(in, assetsRoot, baselineFingerprint); if (strategy == ResourceSwapStrategy.RESOURCES_LOADER) { refreshAssetsProvider( - AssetExtractor.currentDir(assetsRoot), generation, appContext, onFailure); + AssetExtractor.currentDir(assetsRoot), generation, appContext, onOutcome); + } else { + // The merge on disk is the whole swap below API 30; there is no provider to + // queue, so the outcome is settled here rather than by a callback that never + // comes. + reportSwapCommitted(onOutcome); } RuntimeLog.i("merged " + extracted + " changed asset(s) into the override"); } finally { @@ -150,19 +174,19 @@ void applyAssets(ParcelFileDescriptor assetsFd, long generation, String baseline * the payload generation: the API 28/29 path names its file after it, and the API 30+ path orders this swap against the others * @param appContext * application context, for the Resources the loader attaches to and, on API 28/29, the cache dir and the Resources to mount onto - * @param onFailure + * @param onOutcome * told when the posted provider swap fails, since that lands after this method returns; null when the caller has nothing to do about it * @throws IOException * when the swap fails; an unsupported SDK is not a failure, it warns once and drops the payload */ void applyTable(ParcelFileDescriptor tableFd, long generation, Context appContext, - SwapFailure onFailure) throws IOException { + SwapOutcome onOutcome) throws IOException { switch (strategy) { case RESOURCES_LOADER: - applyTableWithLoader(tableFd, generation, appContext, onFailure); + applyTableWithLoader(tableFd, generation, appContext, onOutcome); return; case LEGACY_ASSET_PATH: - applyTableLegacy(tableFd, generation, appContext, onFailure); + applyTableLegacy(tableFd, generation, appContext, onOutcome); return; default: Streams.closeQuietly(tableFd); @@ -172,6 +196,9 @@ void applyTable(ParcelFileDescriptor tableFd, long generation, Context appContex RuntimeLog.w("resource payloads need API 28+; ignoring"); } } + // Nothing was queued, so nothing reports later; a deploy must not be left + // waiting on a swap this SDK level never makes. + reportSwapCommitted(onOutcome); } } @@ -218,13 +245,13 @@ void attachTo(Resources resources) { * the payload generation, which names the file on disk and orders this swap against the others; an overtaken one is dropped rather than mounted * @param appContext * application context, for the cache dir and the Resources to mount onto - * @param onFailure + * @param onOutcome * told when the posted mount fails or is refused, since that lands after this method returns; null when the caller has nothing to do about it * @throws IOException - * when the write fails; the previous table stays live. A mount failure arrives through {@code onFailure} instead. + * when the write fails; the previous table stays live. A mount failure arrives through {@code onOutcome} instead. */ private void applyTableLegacy(ParcelFileDescriptor tableFd, final long generation, - final Context appContext, SwapFailure onFailure) throws IOException { + final Context appContext, SwapOutcome onOutcome) throws IOException { InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(tableFd); final File zip; try { @@ -237,7 +264,7 @@ private void applyTableLegacy(ParcelFileDescriptor tableFd, final long generatio // Nothing useful to do with a failed close. } } - // The return is not tested: a refused post is already reported through onFailure, + // The return is not tested: a refused post is already reported through onOutcome, // and unlike the loader paths there is no provider left in this method's hands to // close - the apk on disk is swept by deleteStaleApks on the next process start. swapProvidersOnMain(new Runnable() { @@ -260,7 +287,7 @@ public void run() { } catch (IOException error) { // A Runnable cannot carry a checked exception out, and swallowing it would // ack a table that never mounted. swapProvidersOnMain catches Throwable and - // routes it to onFailure, so wrap rather than drop. + // routes it to onOutcome, so wrap rather than drop. throw new IllegalStateException( "legacy table swap failed for gen " + generation, error); } @@ -270,7 +297,7 @@ public void run() { LegacyResourceSwap.flushCaches(appResources); } } - }, onFailure); + }, onOutcome); } /** @@ -284,14 +311,14 @@ public void run() { * the payload generation, which orders this swap against the others; an overtaken one is dropped rather than installed * @param appContext * application context, whose Resources the loader is attached to on first use - * @param onFailure + * @param onOutcome * told when the posted swap fails or is refused; null when the caller has nothing to do about it * @throws IOException * when the apk cannot be loaded as a provider; the previous provider stays live and attached */ @TargetApi(30) private void applyTableWithLoader(ParcelFileDescriptor tableFd, final long generation, - final Context appContext, SwapFailure onFailure) throws IOException { + final Context appContext, SwapOutcome onOutcome) throws IOException { try { final ResourcesProvider next = ResourcesProvider.loadFromApk(tableFd, null); boolean willRun = swapProvidersOnMain(new Runnable() { @@ -328,7 +355,7 @@ public void run() { Streams.closeQuietly(previous); } } - }, onFailure); + }, onOutcome); if (!willRun) { // The swap will never run, so nothing else will ever close this provider. Streams.closeQuietly(next); @@ -415,14 +442,14 @@ private void installProviders() { * the payload generation, which orders this swap against the others; an overtaken one is dropped rather than installed * @param appContext * application context, whose Resources the loader is attached to on first use - * @param onFailure + * @param onOutcome * told when the posted swap fails or is refused; null when the caller has nothing to do about it * @throws IOException * when the provider cannot be created; the previous one stays live and attached */ @TargetApi(30) private void refreshAssetsProvider(File dir, final long generation, final Context appContext, - SwapFailure onFailure) throws IOException { + SwapOutcome onOutcome) throws IOException { final DirectoryAssetsProvider nextDir = new DirectoryAssetsProvider(dir); final ResourcesProvider next = ResourcesProvider.empty(nextDir); boolean willRun = swapProvidersOnMain(new Runnable() { @@ -461,7 +488,7 @@ public void run() { Streams.closeQuietly(previousDir); } } - }, onFailure); + }, onOutcome); if (!willRun) { // The swap will never run, so nothing else will ever close this pair. Streams.closeQuietly(next); @@ -476,15 +503,15 @@ public void run() { * * Inline on the main thread, not posted, because the boot restore path runs during the first activity's creation and its swap must land before anything inflates. * - * A swap failure is never thrown from here: on the posted path no caller is left to catch it, and the previous provider set stays live either way. The result is still deliberately NOT returned synchronously to the deploy chain - that would make a deploy arriving on a binder thread block on a main-thread round trip in the hot reload path, which is the very thing posting the swap exists to avoid. It travels back through {@code onFailure} instead, so the deploy that queued the swap can fail rather than ack a swap that did not land. Each swap un-commits its own fields on failure, so what stays live is a consistent previous generation. + * A swap failure is never thrown from here: on the posted path no caller is left to catch it, and the previous provider set stays live either way. The result is still deliberately NOT returned synchronously to the deploy chain - that would make a deploy arriving on a binder thread block on a main-thread round trip in the hot reload path, which is the very thing posting the swap exists to avoid. It travels back through {@code onOutcome} instead, so the deploy that queued the swap can fail rather than ack a swap that did not land. Each swap un-commits its own fields on failure, so what stays live is a consistent previous generation. * * @param swap * the field swap + setProviders + close of the replaced provider, taking the store's monitor itself - * @param onFailure + * @param onOutcome * told when the swap threw or was never accepted; may be null * @return true when the swap has run or is queued to run, false when the main looper refused it and the caller still owns the providers it created */ - private boolean swapProvidersOnMain(final Runnable swap, final SwapFailure onFailure) { + private boolean swapProvidersOnMain(final Runnable swap, final SwapOutcome onOutcome) { Runnable guarded = new Runnable() { @Override @@ -493,8 +520,13 @@ public void run() { swap.run(); } catch (Throwable error) { RuntimeLog.e("resource provider swap failed; previous set stays live", error); - reportSwapFailure(onFailure, error); + reportSwapFailure(onOutcome, error); + return; } + // A swap dropped as overtaken reports committed too: it returned normally, and + // the generation that overtook it owns the screen and its own ack, so calling + // this one failed would report a crash for a deploy nothing is wrong with. + reportSwapCommitted(onOutcome); } }; Looper main = Looper.getMainLooper(); @@ -511,16 +543,21 @@ public void run() { IllegalStateException error = new IllegalStateException( "main looper refused the resource provider swap"); RuntimeLog.e("resource provider swap was not queued; previous set stays live", error); - reportSwapFailure(onFailure, error); + reportSwapFailure(onOutcome, error); return false; } /** - * Told when a posted provider swap did not land, so the deploy that queued it can fail rather than ack. + * Told how a posted provider swap ended, so the deploy that queued it can ack or fail on the swap itself rather than on having queued it. + * + * The swap runs after the method that queued it has returned, so this is the only way either answer reaches the deploy chain. * - * The swap runs after the method that queued it has returned, so this is the only way the failure reaches the deploy chain. + * Exactly one of the two fires per {@code applyTable} or {@code applyAssets} call that returns normally - including a call that queues nothing because this SDK level has no swap to make, since a deploy waiting on it would otherwise wait forever. A call that throws reports neither: the caller has the exception instead. */ - interface SwapFailure { + interface SwapOutcome { + + /** The swap is live, or there was none to make. A backgrounded deploy's ack hangs off this. */ + void onSwapCommitted(); /** * @param error diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java new file mode 100644 index 0000000000..fe29c52349 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java @@ -0,0 +1,66 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Holds a backgrounded deploy's ack until every resource swap it posted has committed. + * + * A resource swap is queued to the main looper and lands after the deploy method has returned, so acking at apply time claims a reload that can still fail. When it did fail, the rollback, the quarantine and the crash report all ran after CoGo had already been told the generation reloaded, and CoGo's deploy resolves on the first report naming that generation: the build was recorded as a successful reload with a timing number while the session manager separately raised a reload crash, so one save produced both signals. The foreground branch does not have this problem - it acks from a drawn frame, which cannot precede the swap that the frame renders. + * + * So the ack is owed only once, and only when every posted swap has committed and none has failed. A deploy that posts no swap at all - a dex-only edit, the commonest one - still owes it immediately, which is what {@link #noSwapPosted} is for. + */ +final class SwapAckGate { + + /** Posted swaps not yet committed. Guarded by {@code this}. */ + private int outstanding; + + /** Whether the ack has been claimed or cancelled, so it can never be owed twice. Guarded by {@code this}. */ + private boolean settled; + + /** + * @param postedSwaps + * how many resource swaps this deploy queued; 0 when it carries no resource or asset payload + */ + SwapAckGate(int postedSwaps) { + this.outstanding = postedSwaps; + } + + /** + * Records that one posted swap committed. + * + * @return true when this was the last one outstanding, so the caller now owes the ack + */ + synchronized boolean committed() { + if (settled || outstanding <= 0) { + return false; + } + outstanding--; + if (outstanding > 0) { + return false; + } + settled = true; + return true; + } + + /** + * Cancels the ack for good. + * + * A swap that failed is reported by the failure path instead, which rolls the store back and names the generation to CoGo. A second swap of the same deploy committing afterwards must not turn that into a success. + */ + synchronized void failed() { + settled = true; + } + + /** + * Asks whether this deploy queued nothing to wait for, settling the gate when it did not. + * + * Deliberately not {@link #committed}: a deploy with one swap still in flight would otherwise take that call for the swap's own commit and ack before it landed - the very thing this gate exists to stop. + * + * @return true when no swap was posted, so the ack is owed right away + */ + synchronized boolean noSwapPosted() { + if (settled || outstanding > 0) { + return false; + } + settled = true; + return true; + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGateTest.java new file mode 100644 index 0000000000..494095cafb --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGateTest.java @@ -0,0 +1,88 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pins the rule that a backgrounded deploy's ack waits for its resource swaps. + * + * The defect this covers: the ack fired at apply time, while the swap it depended on was still queued to the main looper. A swap that then failed rolled the store back, quarantined the generation and reported a crash - all after CoGo had been told the generation reloaded. CoGo's deploy resolves on the first report naming the generation, so the ack won and the build was recorded as a successful reload with a timing number, while the session manager's separate collector still raised a reload crash from the same save. + * + * What these tests do NOT pin is the call site - that {@code handlePayload} counts its posted swaps and hangs the ack off the last commit needs a binder thread, a main looper and a Context, so it is checked on device. What they do pin is the rule the call site delegates to. + */ +class SwapAckGateTest { + + /** + * The regression: with a swap still queued, nothing owes the ack yet. + * + * Goes red if the gate ever lets the deploy ack before its swap has committed, which is the pre-fix behaviour expressed in the class that now owns the decision. + */ + @Test + void aDeployWithAPostedSwapDoesNotAckBeforeItCommits() { + SwapAckGate gate = new SwapAckGate(1); + + // The apply has returned and the swap is still on the main looper's queue. + assertThat(gate.noSwapPosted()).isFalse(); + + assertThat(gate.committed()).isTrue(); + } + + /** A dex-only deploy posts no swap, so the ack is owed as soon as the applies are done. */ + @Test + void aDeployWithNoSwapAcksImmediately() { + SwapAckGate gate = new SwapAckGate(0); + + assertThat(gate.noSwapPosted()).isTrue(); + } + + /** A swap that fails cancels the ack for good, so the failure path is the only thing that reports. */ + @Test + void aFailedSwapNeverAcks() { + SwapAckGate gate = new SwapAckGate(1); + + gate.failed(); + + assertThat(gate.committed()).isFalse(); + assertThat(gate.noSwapPosted()).isFalse(); + } + + /** One swap of a pair failing cancels the ack, even though the other one lands. */ + @Test + void aFailureCancelsTheAckWhenTheOtherSwapStillCommits() { + SwapAckGate gate = new SwapAckGate(2); + + assertThat(gate.committed()).isFalse(); + gate.failed(); + + assertThat(gate.committed()).isFalse(); + } + + /** And once claimed by the no-swap path, a stray commit cannot claim it again. */ + @Test + void aStrayCommitAfterTheNoSwapAckIsIgnored() { + SwapAckGate gate = new SwapAckGate(0); + + assertThat(gate.noSwapPosted()).isTrue(); + assertThat(gate.committed()).isFalse(); + } + + /** With a table and an assets swap, only the second commit owes the ack. */ + @Test + void bothSwapsMustCommitBeforeTheAckIsOwed() { + SwapAckGate gate = new SwapAckGate(2); + + assertThat(gate.committed()).isFalse(); + assertThat(gate.committed()).isTrue(); + } + + /** The ack is owed once: a late or duplicated commit finds the gate settled. */ + @Test + void theAckIsOwedOnlyOnce() { + SwapAckGate gate = new SwapAckGate(1); + + assertThat(gate.committed()).isTrue(); + assertThat(gate.committed()).isFalse(); + assertThat(gate.noSwapPosted()).isFalse(); + } +} From 34c3b9018ad3e8c053ab64775c566c6fa35c469d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 16:10:21 -0700 Subject: [PATCH 16/40] ADFA-4128: name the unreported-relaunch-crash gap by its ticket, not by a planning-doc number The backgrounded-deploy comment in QuickBuildRuntime deferred to "gap #91", a number from quickbuild/docs/reliability-gaps.md that a reader of the comment cannot follow. State the gap in words and cite ADFA-5466, filed for it today; the gaps table carries the same key. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- quickbuild/docs/reliability-gaps.md | 2 +- .../androidide/quickbuild/runtime/QuickBuildRuntime.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/quickbuild/docs/reliability-gaps.md b/quickbuild/docs/reliability-gaps.md index 3d94d2c66f..c98bf3b864 100644 --- a/quickbuild/docs/reliability-gaps.md +++ b/quickbuild/docs/reliability-gaps.md @@ -13,7 +13,7 @@ relink-crash recovery gap. | Gap | What the user sees | Frequency | Blocks v1? | | --- | --- | --- | --- | | #89 | Red-alert icon; tapping Quick Build does nothing until "Restart session" | No device repro `[inferred]` | TBD | -| #91 | Their own app crash is never surfaced; CoGo blames deploy infra | `[unmeasured]` | TBD | +| #91 | Their own app crash is never surfaced; CoGo blames deploy infra | `[unmeasured]` | ADFA-5466 | | #87 | A one-line edit in a Room/KSP project runs a full ~200s rebuild + reinstall | 3/3 when attempted | TBD | | Relink crash | A reload that crashes the app repeats the crash at every process boot | Trigger fixed; net still absent | TBD | | Relink stuck | A failed relink re-fails on every later save until a gradle-file touch | `[unmeasured]` | No - fixed below | diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 707fbd8fb6..2d03631f10 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -450,9 +450,9 @@ private void ackBackgroundedReload(long generation, long arrivedUptime) { // states, which is exactly why the ack does not depend on it. // // Remaining tradeoffs: the metric is swap-time, not render-time, since no frame is - // coming to measure; a crash in the relaunch still goes unreported (gap #91's - // shape); and a background race after the resumed check falls back to the deploy - // timeout. + // coming to measure; a crash in the relaunch still goes unreported, because the + // crash guard only watches while a reload is pending (ADFA-5466); and a background + // race after the resumed check falls back to the deploy timeout. client.reportReloaded(generation, SystemClock.uptimeMillis() - arrivedUptime); } From 16f2211b1d92cb44350185a015256340daac2d39 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 17:18:02 -0700 Subject: [PATCH 17/40] ADFA-4128: pin the first-frame call site, and refuse an abandoned generation's swap Two review gaps on the runtime, both about a generation that is no longer supposed to be believed. The first-frame gate had a test for the gate class but nothing for its caller, so reverting the routing - completing the reload at onResume again, which is the pre-fix behaviour - left every test green. onActivityResumed needs an Activity, a Window and a live ViewTreeObserver, so the routing moves into a package-private seam, completeOnResume, the same shape as startFailReloadThread. The new test drives that seam and asserts what the resume must NOT do: with a frame still coming it completes nothing, so the generation stays pending in the gate and BootProbation still names it. The second is a swap the store used to commit after the deploy that queued it had already been rolled back. A swap is posted to main and commits after applyPayload returns, so a deploy that throws in a later step - applyTable posts before applyAssets can throw - had its rollback run with its own table swap still queued. The store already drops an OVERTAKEN swap in all three swap bodies; this adds the sibling case, an ABANDONED one, through the same guard. Undoing a committed swap is not available: the store keeps single provider slots and closes the previous provider after each swap, and the API 28/29 path cannot unmount an added asset path at all, so refusing the commit is the whole remedy. The runtime calls abandon() from both places it already gives up on a generation. The three swap bodies run on the main looper, so their call to the guard is not pinned by a JVM test; what is pinned is the decision they take. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/runtime/QuickBuildRuntime.java | 61 +++++++++++++-- .../quickbuild/runtime/ResourceStore.java | 78 +++++++++++++++---- ...QuickBuildRuntimeResumeCompletionTest.java | 71 +++++++++++++++++ .../ResourceStoreAbandonedSwapTest.java | 67 ++++++++++++++++ 4 files changed, 254 insertions(+), 23 deletions(-) create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeResumeCompletionTest.java create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreAbandonedSwapTest.java diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 2d03631f10..2e6de474c7 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -34,6 +34,22 @@ final class QuickBuildRuntime { /** The one runtime per process, or null before {@link #install}. */ private static volatile QuickBuildRuntime instance; + /** + * Routes a resumed activity's completion, to its first drawn frame when one is coming and inline when none is. + * + * Package-private and free of Activity so a JVM test can pin the branch that matters: a resume with a frame still coming must complete NOTHING, which is what leaves the generation pending in {@link FirstFrameGate} and blamable by {@link BootProbation}. The Activity overload cannot be driven from a unit test - it needs a Window and a live ViewTreeObserver - so without this seam the rule lives only in the gate class and the call site is unpinned. + * + * @param installDrawCallback + * installs the first-frame callback; false when the activity has no live view tree to hang one on + * @param completeNow + * the completion, run only when no frame is coming + */ + static void completeOnResume(FrameCallbackInstaller installDrawCallback, Runnable completeNow) { + if (!installDrawCallback.install()) { + completeNow.run(); + } + } + /** * Creates and starts the one runtime for this process. Idempotent, and never throws. * @@ -322,6 +338,10 @@ public void onSwapFailed(Throwable error) { // recreate has been posted at all. ackGate.failed(); abandonedReloadGeneration = generation; + // A deploy carrying both payloads has a second swap that may still be + // queued behind this one; committing it would serve this generation's + // resources over the dex the rollback is about to restore. + ResourceStore.INSTANCE.abandon(generation); failReload(generation, rollback, error); } }; @@ -363,6 +383,9 @@ public void run() { // working version. Only onSwapFailed used to set this, which is the branch where // the swap is the thing that failed. abandonedReloadGeneration = generation; + // The posted swap is refused rather than committed: the store cannot undo a + // swap that took, so the only place to stop it is before it commits. + ResourceStore.INSTANCE.abandon(generation); Streams.closeQuietly(dexPayload); Streams.closeQuietly(resourcesPayload); Streams.closeQuietly(assetsPayload); @@ -402,13 +425,24 @@ void onActivityCreated(Activity activity) { * the activity now in the foreground, which hosts the overlay */ void onActivityResumed(final Activity activity) { - if (!completeOnFirstFrame(activity)) { - // No live view tree to hang a draw callback on, so this activity may never - // draw at all. Completing inline is the pre-existing looser bar, and the - // right one here: waiting for a frame that will never arrive would strand - // the deploy unacked and leave the generation on probation forever. - onFirstFrameDrawn(activity); - } + // Routed through the seam so the rule - a resume with a frame coming completes + // nothing - is pinned by a test rather than only by this method's shape. The + // inline fallback is for an activity with no live view tree, which may never + // draw at all: waiting for a frame that will never arrive would strand the + // deploy unacked and leave the generation on probation forever. + completeOnResume(new FrameCallbackInstaller() { + + @Override + public boolean install() { + return completeOnFirstFrame(activity); + } + }, new Runnable() { + + @Override + public void run() { + onFirstFrameDrawn(activity); + } + }); } /** Counts an activity into the set a restart deploy waits to empty before killing the process. */ @@ -907,4 +941,17 @@ private void sweepLegacyResourceCache(android.content.Context context) { RuntimeLog.w("could not sweep the legacy resource cache", error); } } + + /** + * Installs the callback that runs a reload's completion on the resumed activity's first drawn frame. + * + * Exists so {@link #completeOnResume} can be driven without an Activity; the production implementation is {@link #completeOnFirstFrame}. + */ + interface FrameCallbackInstaller { + + /** + * @return true when a first-frame callback was installed, false when the activity has no live view tree and the caller must complete without a frame + */ + boolean install(); + } } diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 77153013a6..943fbf9409 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -105,6 +105,17 @@ private static void reportSwapFailure(SwapOutcome onOutcome, Throwable error) { */ private long swappedGeneration = -1; + /** + * Newest generation whose deploy was abandoned, or -1 when none has been. + * + * A swap is queued on the main thread and commits after the deploy method that queued it has returned, so a deploy that fails a later step - applyTable posts before applyAssets can throw - has its rollback run while its own table swap is still queued. Without this the abandoned generation's table commits over the dex the rollback just restored, and the screen renders a generation nothing else in the process believes is live. + * + * Refusing the commit is the whole remedy. Undoing one is not available: the store keeps single provider slots and closes the previous provider after each swap, and the API 28/29 path cannot unmount an added asset path at all. + * + * Written and read under the monitor, like {@link #swappedGeneration}. + */ + private long abandonedGeneration = -1; + /** * @param strategy * the swap mechanism to use; injected so tests can drive each branch without an SDK level @@ -118,6 +129,20 @@ private ResourceStore() { this(ResourceSwapStrategy.forSdk(Build.VERSION.SDK_INT)); } + /** + * Records that a generation's deploy was abandoned, so any swap it has already queued is refused rather than committed. + * + * Called by the runtime from both places that give up on a generation: a swap that failed, and a deploy step that threw after an earlier swap was already posted. + * + * @param generation + * the abandoned generation; an older one than the newest already abandoned is ignored + */ + synchronized void abandon(long generation) { + if (generation > abandonedGeneration) { + abandonedGeneration = generation; + } + } + /** * Merges a changed-assets zip into the cumulative override dir under {@code cacheRoot} and serves it through the loader. * @@ -230,6 +255,19 @@ void attachTo(Resources resources) { } } + /** + * Whether a queued swap must be dropped instead of committed. + * + * Two reasons, and they are different failures. Overtaken: a newer generation's swap already committed, so installing this one would put the older table back under the newer generation's label. Abandoned: this generation's own deploy gave up, so committing would render a generation whose rollback has already run. + * + * @param generation + * the generation of the queued swap + * @return true when the swap must be dropped + */ + synchronized boolean refusesSwap(long generation) { + return generation < swappedGeneration || generation <= abandonedGeneration; + } + /** * API 28/29 swap: write the apk to disk, then addAssetPath it into the application AssetManager and flush caches on the main thread. * @@ -272,12 +310,15 @@ private void applyTableLegacy(ParcelFileDescriptor tableFd, final long generatio @Override public void run() { synchronized (ResourceStore.this) { - if (generation < swappedGeneration) { - // Overtaken, same as both loader swaps. Mounting now would make the last + if (refusesSwap(generation)) { + // Overtaken or abandoned. Mounting an overtaken one would make the last // addAssetPath win the lookup with the older table, under the newer - // generation's label, and hand that apk to every later activity. - RuntimeLog.w("dropping overtaken legacy table swap for gen " + generation - + "; gen " + swappedGeneration + " already committed"); + // generation's label, and hand that apk to every later activity; mounting + // an abandoned one would mount the table of a generation whose rollback + // has already run. + RuntimeLog.w("dropping legacy table swap for gen " + generation + + "; gen " + swappedGeneration + " committed, gen " + + abandonedGeneration + " abandoned"); return; } Resources appResources = appContext.getResources(); @@ -326,12 +367,15 @@ private void applyTableWithLoader(ParcelFileDescriptor tableFd, final long gener @Override public void run() { synchronized (ResourceStore.this) { - if (generation < swappedGeneration) { - // Overtaken. Two deploys arrive on two binder threads, so the posts are not - // ordered by generation, and installing this one would put the older table - // back under the newer generation's label. - RuntimeLog.w("dropping overtaken table swap for gen " + generation + "; gen " - + swappedGeneration + " already committed"); + if (refusesSwap(generation)) { + // Overtaken, or abandoned by its own deploy. Two deploys arrive on two binder + // threads, so the posts are not ordered by generation, and installing an + // overtaken one would put the older table back under the newer generation's + // label; installing an abandoned one would serve a table whose dex has + // already been rolled back. + RuntimeLog.w("dropping table swap for gen " + generation + "; gen " + + swappedGeneration + " committed, gen " + abandonedGeneration + + " abandoned"); Streams.closeQuietly(next); return; } @@ -457,11 +501,13 @@ private void refreshAssetsProvider(File dir, final long generation, final Contex @Override public void run() { synchronized (ResourceStore.this) { - if (generation < swappedGeneration) { - // Overtaken, same as the table swap: a newer generation's providers are already - // installed and this pair would replace them with the older override dir. - RuntimeLog.w("dropping overtaken assets swap for gen " + generation + "; gen " - + swappedGeneration + " already committed"); + if (refusesSwap(generation)) { + // Overtaken or abandoned, same as the table swap: a newer generation's providers + // are already installed and this pair would replace them with the older override + // dir, or this generation's own deploy has already been rolled back. + RuntimeLog.w("dropping assets swap for gen " + generation + "; gen " + + swappedGeneration + " committed, gen " + abandonedGeneration + + " abandoned"); Streams.closeQuietly(next); Streams.closeQuietly(nextDir); return; diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeResumeCompletionTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeResumeCompletionTest.java new file mode 100644 index 0000000000..1b1c565a26 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeResumeCompletionTest.java @@ -0,0 +1,71 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pins the CALL SITE of the first-frame gate: a resume with a frame still coming completes nothing, so a generation that crashes between the resume and its first drawn frame is still blamed and quarantined. + * + * {@link FirstFrameGateTest} pins the gate class, which holds under any caller. This pins the caller. {@link QuickBuildRuntime#onActivityResumed} needs an Activity, a Window and a live ViewTreeObserver, so the routing is exercised through {@link QuickBuildRuntime#completeOnResume}, the seam that method delegates to. Reverting the routing - completing inline at resume, which is the pre-fix behaviour - turns the first test here red. + */ +class QuickBuildRuntimeResumeCompletionTest { + + /** No live view tree means no frame is coming, so the completion runs inline rather than stranding the deploy unacked. */ + @Test + void anActivityWithNoLiveViewTreeCompletesInline() { + FirstFrameGate gate = new FirstFrameGate(); + gate.arm(7); + final boolean[] completed = new boolean[1]; + + QuickBuildRuntime.completeOnResume(new QuickBuildRuntime.FrameCallbackInstaller() { + + @Override + public boolean install() { + return false; + } + }, new Runnable() { + + @Override + public void run() { + gate.drawn(7); + completed[0] = true; + } + }); + + assertThat(completed[0]).isTrue(); + assertThat(gate.pending()).isEqualTo(-1); + } + + /** + * The regression: with a frame coming, the resume itself must not ack, clear the pending slot or mark the generation good. + * + * Goes red if the resume completes inline again, because the crash guard would then find nothing pending and {@link BootProbation#generationToBlame} would return -1 for a crash that happened during the very first traversal. + */ + @Test + void aResumeWithAFrameComingCompletesNothingAndKeepsTheGenerationBlamable() { + FirstFrameGate gate = new FirstFrameGate(); + BootProbation probation = new BootProbation(); + gate.arm(7); + final boolean[] completed = new boolean[1]; + + QuickBuildRuntime.completeOnResume(new QuickBuildRuntime.FrameCallbackInstaller() { + + @Override + public boolean install() { + return true; + } + }, new Runnable() { + + @Override + public void run() { + gate.drawn(7); + completed[0] = true; + } + }); + + assertThat(completed[0]).isFalse(); + assertThat(gate.pending()).isEqualTo(7); + assertThat(probation.generationToBlame(gate.pending(), 7)).isEqualTo(7); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreAbandonedSwapTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreAbandonedSwapTest.java new file mode 100644 index 0000000000..3d65f32d60 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreAbandonedSwapTest.java @@ -0,0 +1,67 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pins the rule that a swap belonging to an abandoned generation is refused rather than committed. + * + * The failure this covers: a resource swap is queued on the main thread and commits after the deploy method that queued it has returned. A deploy that then fails a later step - applyTable posts before applyAssets can throw - has its rollback run while its own table swap is still queued, so the abandoned generation's table used to commit over the dex the rollback had just restored. Undoing a committed swap is not available here: the store keeps single provider slots and closes the previous provider after each swap, and the API 28/29 path cannot unmount an added asset path at all. Refusing the commit is the remedy. + * + * The three swap bodies that consult {@link ResourceStore#refusesSwap} run on the main looper, so their wiring is not exercised here; what is pinned is the decision they all take. + */ +class ResourceStoreAbandonedSwapTest { + + /** An out-of-order abandon must not lower the mark and let a refused swap through. */ + @Test + void abandoningAnOlderGenerationDoesNotUndoANewerAbandon() { + ResourceStore store = new ResourceStore(ResourceSwapStrategy.RESOURCES_LOADER); + + store.abandon(9); + store.abandon(4); + + assertThat(store.refusesSwap(9)).isTrue(); + assertThat(store.refusesSwap(10)).isFalse(); + } + + /** Abandoning an older generation does not retroactively refuse a newer one's swap. */ + @Test + void aSwapForAGenerationNewerThanTheAbandonedOneStillCommits() { + ResourceStore store = new ResourceStore(ResourceSwapStrategy.RESOURCES_LOADER); + + store.abandon(7); + + assertThat(store.refusesSwap(8)).isFalse(); + } + + /** A generation older than the abandoned one is abandoned too: its deploy cannot have outlived the newer one's failure. */ + @Test + void aSwapForAGenerationOlderThanTheAbandonedOneIsRefused() { + ResourceStore store = new ResourceStore(ResourceSwapStrategy.RESOURCES_LOADER); + + store.abandon(7); + + assertThat(store.refusesSwap(6)).isTrue(); + } + + /** The regression: after the deploy is abandoned, its own queued swap is refused. */ + @Test + void aSwapForAnAbandonedGenerationIsRefused() { + ResourceStore store = new ResourceStore(ResourceSwapStrategy.RESOURCES_LOADER); + + store.abandon(7); + + assertThat(store.refusesSwap(7)).isTrue(); + } + + /** With nothing abandoned the guard refuses nothing, so the normal deploy path is untouched. */ + @Test + void withNothingAbandonedEverySwapStillCommits() { + ResourceStore store = new ResourceStore(ResourceSwapStrategy.RESOURCES_LOADER); + + assertThat(store.refusesSwap(0)).isFalse(); + assertThat(store.refusesSwap(1)).isFalse(); + assertThat(store.refusesSwap(99)).isFalse(); + } +} From 1ed66876a4294049f3c952bf3323c1a78def600e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 09:57:13 -0700 Subject: [PATCH 18/40] ADFA-4128: say why the banner copy is inline English The runtime AAR is injected into the user's app and carries no res/ of its own, so the banner cannot use a string resource; REVIEW.md asks for that opt-out to be stated, not inferred. MAX_BANNER_LINES is derived from the literals' character counts, so its KDoc now says the arithmetic assumes the English copy. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3934035311 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../com/itsaky/androidide/quickbuild/runtime/CrashSummary.java | 2 ++ .../com/itsaky/androidide/quickbuild/runtime/OverlayState.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java index b8021ccbee..f80c38a01c 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java @@ -17,6 +17,8 @@ final class CrashSummary { * An earlier version also put a stack summary on the banner and needed 14 lines to fit it. Dropping the summary is what buys this back, so putting any detail on the banner again means recomputing here rather than raising the cap. {@code StatusOverlay} reads this instead of carrying a number of its own that could drift from it. * * {@code BUILD_FAILED} is the tallest state, at exactly this cap: a 54-character headline (3 lines at the narrowest measure), one detail line clamped to {@link #BUILD_FAILED_DETAIL_CHARS}, and the pointer's 2. + * + * The arithmetic assumes the English literals in {@link OverlayState}, which cannot be string resources (its class KDoc says why); a translation changes every count above. */ static final int MAX_BANNER_LINES = 6; diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java index 00e731c17f..7880165f5c 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java @@ -4,6 +4,8 @@ * Immutable description of what the status overlay currently shows. * * The overlay is error-only: it tells the user when a build fails or a payload crashes. {@link #building} is the one narrow exception, a neutral in-flight line so a slow compile does not read as silence. Success renders nothing. Every terminal event installs a new state and the overlay always renders the latest, so a transient state cannot get stuck on screen. + * + * The banner copy is inline English rather than a string resource, against the repo rule, because it cannot be one: this AAR is injected into the user's own app and carries no {@code res/}, so any id it referenced would resolve against that app's table - or against the very payload table a crash banner is reporting on. {@link CrashSummary#MAX_BANNER_LINES} is computed from these literals' character counts, so translating or lengthening them means recomputing there. */ final class OverlayState { From 3faf94bfbaa3f612a6b970e3f741e10fcddce0fe Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:00:39 -0700 Subject: [PATCH 19/40] ADFA-4128: report a mixed state when the failed swap had already committed failReload rolls the dex back, but a resource swap that committed before the failure stays live: the store keeps single provider slots and the API 28/29 path cannot unmount an asset path. That is the common ordering, since applyTable posts and returns while applyAssets merges on the binder thread. The banner then said "App is on the last working version" while the screen served the failed generation's table under the previous generation's classes. Now the failure path reads ResourceStore.swappedGeneration() after the generation has been abandoned (so a still-queued swap is refused rather than committing later) and, when it equals the failed generation, shows OverlayState.mixed() - "Restart the app - it is running mixed versions" - and prefixes the report to CoGo so Build Output carries the restart instruction in full. The decision lives in Generations.leavesMixedState so it is JVM-tested; the wiring in failReloadNow is device-only. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3934036794 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/runtime/CrashSummary.java | 33 +++++++++++++++++++ .../quickbuild/runtime/Generations.java | 15 +++++++++ .../quickbuild/runtime/OverlayState.java | 25 ++++++++++++-- .../quickbuild/runtime/QuickBuildRuntime.java | 26 +++++++++++---- .../quickbuild/runtime/ResourceStore.java | 11 +++++++ .../quickbuild/runtime/StatusOverlay.java | 3 +- .../quickbuild/runtime/CrashSummaryTest.java | 31 +++++++++++++++++ .../quickbuild/runtime/GenerationsTest.java | 15 +++++++++ .../quickbuild/runtime/OverlayStateTest.java | 11 +++++++ 9 files changed, 159 insertions(+), 11 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java index f80c38a01c..cac890071d 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java @@ -29,6 +29,14 @@ final class CrashSummary { */ static final int BUILD_FAILED_DETAIL_CHARS = 25; + /** + * First line of the report for a reload that failed after its resource swap committed. + * + * Build Output is where the banner sends the reader, so the instruction the banner gives has to be there in full: the rollback restored the code only, and a restart is what puts the app back on one generation. + */ + static final String MIXED_STATE_PREFIX = "Rolled back the code, but the resources had already swapped in: " + + "the app is running mixed versions until it is restarted."; + /** Frames a report to CoGo names; enough to place the fault, short enough to read. */ private static final int MAX_REPORT_FRAMES = 5; @@ -50,7 +58,32 @@ final class CrashSummary { * @return the exception and up to {@link #MAX_REPORT_CAUSES} causes, each with up to {@link #MAX_REPORT_FRAMES} frames, truncated to {@link #MAX_REPORT_LENGTH} chars */ static String forReport(Throwable error) { + return report(null, error); + } + + /** + * The full form reported to CoGo when the failure left the app on mixed versions: {@link #MIXED_STATE_PREFIX}, then the same frames as {@link #forReport}, under the same length cap. + * + * @param error + * the failure to summarize; must be non-null + * @return the prefixed report, truncated to {@link #MAX_REPORT_LENGTH} chars as a whole + */ + static String forMixedReport(Throwable error) { + return report(MIXED_STATE_PREFIX, error); + } + + /** + * @param prefix + * a line to put before the exception, or null for none + * @param error + * the failure to summarize; must be non-null + * @return the report, truncated as a whole so the prefix cannot push it past the binder cap + */ + private static String report(String prefix, Throwable error) { StringBuilder sb = new StringBuilder(); + if (prefix != null) { + sb.append(prefix).append('\n'); + } sb.append(error.toString()); appendFrames(sb, error, MAX_REPORT_FRAMES); // Each cause gets its frames too, not just its toString. The frame a developer needs is diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java index 41d7154e68..a7c3707ed9 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java @@ -40,6 +40,21 @@ static FailureAction onReloadFailure(long runningGeneration, long failedGenerati : FailureAction.REPORT_ONLY; } + /** + * Whether a failed reload has left the process serving two generations at once. + * + * The rollback restores the dex, but a resource swap that already committed cannot be undone: the store keeps single provider slots and closes the replaced one, and the API 28/29 path cannot unmount an asset path at all. A deploy posts its table swap and then does the asset merge on the binder thread, so when the merge throws the swap has usually landed. The app then runs the previous generation's classes over the failed generation's resources until a resources-carrying deploy or a process restart, and a banner claiming the last working version would be false. + * + * @param swappedGeneration + * the newest generation whose resource swap committed, or -1 before any + * @param failedGeneration + * the generation whose reload failed + * @return true when the failed generation's resources are what the screen resolves against + */ + static boolean leavesMixedState(long swappedGeneration, long failedGeneration) { + return swappedGeneration == failedGeneration; + } + /** * The pending-reload generation the runtime should hold after a payload applies. * diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java index 7880165f5c..a55dea878b 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java @@ -49,6 +49,17 @@ static OverlayState crashed() { return new OverlayState(Kind.CRASHED, null, 0, -1); } + /** + * State for a reload that failed after its resource swap had already committed, so the rollback restored the code half only. + * + * Says restart rather than "last working version": the screen resolves the failed generation's table over the previous generation's classes, and only a resources-carrying deploy or a process restart clears that. Restart is the remedy the user has - the failed generation is quarantined, so the next boot adopts the last good one whole. + * + * @return the mixed-versions crash state + */ + static OverlayState mixed() { + return new OverlayState(Kind.MIXED, null, 0, -1); + } + /** * State that renders nothing, the resting state. * @@ -110,14 +121,15 @@ boolean isBuilding() { /** * True for the states a successful reload / build must clear. * - * @return whether this state is BUILD_FAILED, CRASHED or REINSTALL_PENDING + * @return whether this state is BUILD_FAILED, CRASHED, MIXED or REINSTALL_PENDING */ boolean isError() { - return kind == Kind.BUILD_FAILED || kind == Kind.CRASHED || kind == Kind.REINSTALL_PENDING; + return kind == Kind.BUILD_FAILED || kind == Kind.CRASHED || kind == Kind.MIXED + || kind == Kind.REINSTALL_PENDING; } /** - * Builds the banner text for this state; failure copy always says the app still runs the last working code. + * Builds the banner text for this state; failure copy says the app still runs the last working code, except {@link Kind#MIXED}, where that would be false. * * @return the multi-line banner text, empty for {@link Kind#HIDDEN} */ @@ -149,6 +161,11 @@ String text() { // code. The old wording named the one event this banner cannot observe. return "Live reload crashed. App is on the last working version." + "\n" + FULL_OUTPUT_POINTER; + case MIXED: + // Same length band as CRASHED, so MAX_BANNER_LINES still holds; a longer line + // here means recomputing it. + return "Live reload crashed. Restart the app - it is running mixed versions." + "\n" + + FULL_OUTPUT_POINTER; case REINSTALL_PENDING: return "Update needs your OK in Code on the Go - switch back to approve it\n" + "This app is running the last working version"; @@ -170,6 +187,8 @@ enum Kind { BUILD_FAILED, /** A delivered payload crashed in render/lifecycle; rolled back to last-good. */ CRASHED, + /** A payload failed after its resources swapped in; the code rolled back, the resources could not. */ + MIXED, /** A build is compiling; the app keeps running its last-deployed generation. */ BUILDING, /** An update's reinstall awaits a confirm only CoGo can show; the user must switch back. */ diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 2e6de474c7..64610e1b0b 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -20,7 +20,7 @@ * * Installed once per process by {@link QuickBuildAppComponentFactory} at application instantiation; Context work - binding to CoGo, cache dirs - waits for the first activity, since the Application has no base context yet. * - * Failure policy throughout: a reload failure reports the crash, and rolls back when the store adopted the failed generation, so the app keeps running the last working code rather than crash-looping or silently claiming the new generation. Only a failure superseded by a newer live generation stays silent. + * Failure policy throughout: a reload failure reports the crash, and rolls back when the store adopted the failed generation, so the app keeps running the last working code rather than crash-looping or silently claiming the new generation. Only a failure superseded by a newer live generation stays silent. The rollback is of the code: a resource swap that had already committed stays live, and that case is reported as a mixed state that a restart clears rather than as the last working version. */ final class QuickBuildRuntime { @@ -379,9 +379,10 @@ public void run() { // A step that already ran may have queued a swap that will still commit on main: // applyTable posts before applyAssets can throw. Nothing cancels that swap, so // the recreate must not run - it would render this generation's table over the - // dex failReload is rolling back, while the banner says the app is on the last - // working version. Only onSwapFailed used to set this, which is the branch where - // the swap is the thing that failed. + // dex failReload is rolling back. Only onSwapFailed used to set this, which is + // the branch where the swap is the thing that failed. A swap that has ALREADY + // committed is a different case: it cannot be refused, and failReload reports + // the mixed state it leaves instead of claiming the last working version. abandonedReloadGeneration = generation; // The posted swap is refused rather than committed: the store cannot undo a // swap that took, so the only place to stop it is before it commits. @@ -675,6 +676,8 @@ public void run() { /** * The {@link #failReload} body: decides the failure action against the store's live generation and rolls back in the same lock, then reports and renders. * + * The rollback covers the dex; a resource swap that already committed stays live. When that is the failed generation's own swap the app is on mixed versions, and the banner and the report say restart instead of claiming the last working version - which the process is no longer running. + * * A failure before the apply took - an oversize payload, a persist failure, a restart deploy missing its dex - leaves the store on the previous generation, so there is nothing to restore or quarantine; the report and banner still fire, or the host's only signal would be its deploy timeout. Only a failure superseded by a newer live generation stays silent, since that generation owns the store, the pending ack and the screen. */ private void failReloadNow(long generation, PayloadStore.Payload rollback, Throwable error) { @@ -693,12 +696,21 @@ private void failReloadNow(long generation, PayloadStore.Payload rollback, Throw quarantine(generation); firstFrame.disarm(); } + // The rollback above restored the dex only. A table swap that committed before the + // failure - the usual order, since applyTable posts and returns while applyAssets + // merges on the binder thread - stays live, and the store cannot take it down. Both + // callers abandon the generation before dispatching here, so a swap still queued is + // refused and this read cannot be overtaken by it. + boolean mixed = Generations.leavesMixedState( + ResourceStore.INSTANCE.swappedGeneration(), generation); // The banner gets no summary at all: it is a few unscrollable lines over the user's // own app, so a stack put there is clipped mid-frame and the frames naming the fault // are the half nobody sees. It names Build Output instead, and the report below is - // what actually puts the text there. - setOverlayState(OverlayState.crashed()); - client.reportCrash(generation, CrashSummary.forReport(error)); + // what actually puts the text there - including, for the mixed case, the restart + // instruction in full. + setOverlayState(mixed ? OverlayState.mixed() : OverlayState.crashed()); + client.reportCrash(generation, + mixed ? CrashSummary.forMixedReport(error) : CrashSummary.forReport(error)); } /** diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 943fbf9409..8362360bd6 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -143,6 +143,17 @@ synchronized void abandon(long generation) { } } + /** + * The newest generation whose swap has committed, or -1 before the first. + * + * For the failure path: a deploy that threw after its table swap had already committed has left that table live, and nothing can take it down again (see {@link #abandonedGeneration}). Read only after {@link #abandon} for the same generation, so a swap still queued at that point is refused rather than committing later and changing the answer. + * + * @return the committed generation, which the failure path compares with the one that failed + */ + synchronized long swappedGeneration() { + return swappedGeneration; + } + /** * Merges a changed-assets zip into the cumulative override dir under {@code cacheRoot} and serves it through the loader. * diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java index 67c93efc70..e1054a653b 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java @@ -46,7 +46,7 @@ static InsetAction insetAction(Integer insetTop, boolean bannerAttached) { * Banner background color for a state kind. * * @param kind - * the state being rendered; anything but BUILD_FAILED and CRASHED, HIDDEN included, takes the neutral color + * the state being rendered; anything but the four failure kinds, HIDDEN included, takes the neutral color * @return an ARGB color, deliberately translucent so the app stays readable behind it */ private static int colorFor(OverlayState.Kind kind) { @@ -55,6 +55,7 @@ private static int colorFor(OverlayState.Kind kind) { case REINSTALL_PENDING: return COLOR_BUILD_FAILED; case CRASHED: + case MIXED: return COLOR_CRASHED; default: return COLOR_NEUTRAL; diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java index b3d8c58500..c2187adaba 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java @@ -192,4 +192,35 @@ void theCrashBannerFitsTheOverlayLineBudgetAtTwoTimesFontScale() { assertThat(wrappedLineCount(rendered, NARROWEST_MEASURED_LINE_CHARS)) .isAtMost(CrashSummary.MAX_BANNER_LINES); } + @Test + void theMixedBannerFitsTheOverlayLineBudgetAtTwoTimesFontScale() { + // MIXED has its own headline, so it has to be measured against the same budget the + // crash headline was, not assumed to fit because that one does. + String rendered = OverlayState.mixed().text(); + + assertThat(wrappedLineCount(rendered, NARROWEST_MEASURED_LINE_CHARS)) + .isAtMost(CrashSummary.MAX_BANNER_LINES); + assertThat(rendered).doesNotContain("Exception"); + } + + @Test + void theMixedReportLeadsWithTheRestartInstructionAndKeepsTheFrames() { + // Build Output is where the banner sends the reader, so the instruction has to be + // the first thing there, ahead of the same frames a plain crash reports. + String report = CrashSummary.forMixedReport(lifecycleCrash()); + + assertThat(report).startsWith(CrashSummary.MIXED_STATE_PREFIX); + assertThat(report).contains("restarted"); + assertThat(report).contains("MainActivity.kt:22"); + assertThat(report).contains("Caused by"); + } + + @Test + void theMixedReportIsCappedAsAWholeNotAfterThePrefix() { + // The prefix must not push the report past the binder cap forReport holds to. + String plain = CrashSummary.forReport(new RuntimeException(veryLongMessage())); + String mixed = CrashSummary.forMixedReport(new RuntimeException(veryLongMessage())); + + assertThat(mixed.length()).isEqualTo(plain.length()); + } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java index bc2b5af26d..c3c1159c55 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java @@ -91,4 +91,19 @@ void rollbackDoesNotApplyToAGenerationTheStoreNeverReached() { // a failure naming a generation ahead of the store owns nothing either. assertThat(Generations.rollbackApplies(6, 7)).isFalse(); } + @Test + void aFailureAfterItsOwnSwapCommittedLeavesAMixedState() { + // The common ordering: applyTable posts and returns, applyAssets throws on the binder + // thread, and by then the table swap has committed. The dex rolls back, the table + // cannot, so the banner must not claim the last working version. + assertThat(Generations.leavesMixedState(7, 7)).isTrue(); + } + + @Test + void aFailureBeforeAnySwapCommittedIsNotMixed() { + // The swap was refused or never posted: the screen resolves the previous table under + // the previous dex, which is the last working version the banner names. + assertThat(Generations.leavesMixedState(-1, 7)).isFalse(); + assertThat(Generations.leavesMixedState(6, 7)).isFalse(); + } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java index baf7aff510..e4e466497c 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java @@ -106,4 +106,15 @@ void reinstallPendingSendsTheUserBackToCoGo() { assertThat(state.text()).contains("Code on the Go"); assertThat(state.text()).contains("running the last working version"); } + @Test + void mixedSaysRestartAndNeverClaimsTheLastWorkingVersion() { + // Set when the failed generation's resource swap had already committed before the + // rollback: the code is on the previous generation, the table is not, and "last + // working version" would be the one claim the banner cannot make. + OverlayState state = OverlayState.mixed(); + assertThat(state.text()).contains("Restart the app"); + assertThat(state.text()).contains("Build Output"); + assertThat(state.text()).doesNotContain("last working version"); + assertThat(state.isError()).isTrue(); + } } From b663be1d21f3dccac8115c74a4633bce3a27b62a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:04:29 -0700 Subject: [PATCH 20/40] ADFA-4128: run the boot resource restore off the main thread and report its failure applyPendingBootResources is dispatched from onActivityPreCreated, inside the first activity's creation on the main thread, and ran the whole restore inline: the asset merge (a recursive delete plus an unzip) and, on API 28/29, the relinked apk copy, both bounded only by the 64 MB payload cap. Every cold start that adopts a persisted generation with resources - every save after a restart deploy, and every process death - paid that as launch jank or an ANR on the low-end devices the legacy path exists for. The extraction now runs on a qb-boot-restore thread and only the swap is posted; the first activity inflates against the baseline table and is recreated once the last swap lands, counted by the same SwapAckGate a backgrounded deploy uses. markLiveGenerationGood waits for the restore, since a frame drawn against the baseline proves the code half only. The restore also had the one remaining null outcome listener, so a corrupt store file or a full disk inside the merge left the process on this generation's code over the installed resources with one log line and nothing else. It now shows the mixed banner and reports to CoGo with a boot-specific first line; the report is best-effort, since CoGo may not have connected yet. Review threads: https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3934035278 https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3934035285 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/runtime/ActivityTracker.java | 2 +- .../quickbuild/runtime/CrashSummary.java | 19 +++ .../quickbuild/runtime/OverlayState.java | 2 +- .../quickbuild/runtime/QuickBuildRuntime.java | 149 ++++++++++++++++-- .../quickbuild/runtime/ResourceStore.java | 2 +- .../quickbuild/runtime/SwapAckGate.java | 2 + .../quickbuild/runtime/CrashSummaryTest.java | 10 ++ ...ckBuildRuntimeBootRestoreDispatchTest.java | 37 +++++ 8 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeBootRestoreDispatchTest.java diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java index 4b077deb0e..f9b523f829 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java @@ -100,7 +100,7 @@ public void onActivityPaused(Activity activity) { * * Only fires on API 29+; on older devices {@link #onActivityCreated} is the later backstop. * - * The runtime's Context work runs here too, because this is the only hook that precedes the activity's own inflation: on a cold start that adopts a persisted generation the resources do not exist until it runs, so deferring it to {@link #onActivityCreated} would let the first activity inflate against the baseline table. Every step of it is idempotent. + * The runtime's Context work runs here too, the first moment a usable Context exists: the bind to CoGo, persistence, and the start of the boot resource restore. The restore itself runs off this thread and lands after this activity has inflated, so on a cold start that adopts a persisted generation the first activity inflates against the baseline table and is recreated when the restored one is live. Every step of it is idempotent. * * @param activity * the activity about to be created, used for its Resources and as the runtime's first Context diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java index cac890071d..24bde0742f 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java @@ -37,6 +37,14 @@ final class CrashSummary { static final String MIXED_STATE_PREFIX = "Rolled back the code, but the resources had already swapped in: " + "the app is running mixed versions until it is restarted."; + /** + * First line of the report for a persisted generation whose resources could not be restored at startup. + * + * Distinct from {@link #MIXED_STATE_PREFIX} because nothing was rolled back: the code is this generation's and the resources are the installed APK's, and a restart re-runs the restore rather than clearing anything. + */ + static final String BOOT_RESTORE_PREFIX = "Could not restore this generation's resources at startup: " + + "the app is running its code over the installed resources. Restart the app to retry."; + /** Frames a report to CoGo names; enough to place the fault, short enough to read. */ private static final int MAX_REPORT_FRAMES = 5; @@ -72,6 +80,17 @@ static String forMixedReport(Throwable error) { return report(MIXED_STATE_PREFIX, error); } + /** + * The full form reported to CoGo when a boot-time resource restore failed: {@link #BOOT_RESTORE_PREFIX}, then the same frames as {@link #forReport}, under the same length cap. + * + * @param error + * the extraction or swap failure; must be non-null + * @return the prefixed report, truncated to {@link #MAX_REPORT_LENGTH} chars as a whole + */ + static String forBootRestoreReport(Throwable error) { + return report(BOOT_RESTORE_PREFIX, error); + } + /** * @param prefix * a line to put before the exception, or null for none diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java index a55dea878b..cd3d412712 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java @@ -52,7 +52,7 @@ static OverlayState crashed() { /** * State for a reload that failed after its resource swap had already committed, so the rollback restored the code half only. * - * Says restart rather than "last working version": the screen resolves the failed generation's table over the previous generation's classes, and only a resources-carrying deploy or a process restart clears that. Restart is the remedy the user has - the failed generation is quarantined, so the next boot adopts the last good one whole. + * Says restart rather than "last working version": the screen resolves the failed generation's table over the previous generation's classes, and only a resources-carrying deploy or a process restart clears that. Restart is the remedy the user has: after a deploy-time failure the generation is quarantined, so the next boot adopts the last good one whole, and after a boot-time restore failure the restart re-runs the restore. * * @return the mixed-versions crash state */ diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 64610e1b0b..5825977c45 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -87,6 +87,19 @@ static Thread startFailReloadThread(Runnable body) { return thread; } + /** + * Runs the boot-time resource restore on its own thread. Package-private so the JVM test can pin the dispatch off the caller's thread, which is the main thread inside the first activity's creation. + * + * @param body + * the restore to run + * @return the started thread, so a test can join it + */ + static Thread startBootRestoreThread(Runnable body) { + Thread thread = new Thread(body, "qb-boot-restore"); + thread.start(); + return thread; + } + /** * Opens a persisted store file as a read-only fd, the form the resource paths take. * @@ -191,6 +204,13 @@ private static InputStream streamOf(ParcelFileDescriptor fd) { /** Newest generation already recorded as good, so the write happens once rather than per resume. */ private volatile long lastMarkedGoodGeneration = -1; + /** + * True from the moment a persisted generation's resources are taken for restore until their swap has landed or failed. + * + * The restore runs off the main thread and its swap is posted, so the first activity draws against the baseline table. That frame proves the code half only: {@link #markLiveGenerationGood} waits for this to clear, or a generation whose resources then fail to render would already be recorded as good and be unblamable. + */ + private volatile boolean bootRestoreInFlight; + /** * @param application * the app's Application; retained for its package name, cache dir and lifecycle callbacks, and safe to hold because the runtime is process-scoped @@ -492,38 +512,135 @@ private void ackBackgroundedReload(long generation, long arrivedUptime) { } /** - * Applies the resource payloads a persisted boot left pending, once a Context exists. + * Restores the resource payloads a persisted boot left pending, once a Context exists. + * + * The code half already loaded pre-Context in {@link PayloadStore#ensureBaseline}. The extraction runs on its own thread and only the swap is posted to the main thread: this is dispatched from inside the first activity's creation, and running the asset merge or the API 28/29 apk copy inline there - both bounded only by the payload cap - cost a launch stall or an ANR on exactly the low-end devices the legacy path exists for. The price is that the first activity inflates against the baseline table; it is recreated once the swap lands, so the screen shows the restored resources one frame late rather than never. * - * The code half already loaded pre-Context in {@link PayloadStore#ensureBaseline}. Components that read resources before the first activity, such as providers, see baseline resources until this runs. A failure keeps baseline resources and the next deploy re-applies current ones. + * A failure keeps baseline resources under this generation's code, which is the mixed state a deploy-time failure reports, so it is reported the same way: banner and crash report, rather than the one log line it used to leave. The report is best-effort, since CoGo may not have connected yet. * * @param context * application context, for the Resources to swap and the cache dir to extract assets into */ - private void applyPendingBootResources(android.content.Context context) { - PayloadPersistence.Loaded pending = PayloadStore.INSTANCE.takePendingBootResources(); + private void applyPendingBootResources(final android.content.Context context) { + final PayloadPersistence.Loaded pending = PayloadStore.INSTANCE.takePendingBootResources(); if (pending == null) { return; } + // Set before the thread starts, so the first frame - which is coming on this + // thread as soon as the activity finishes creating - finds it. + bootRestoreInFlight = true; + startBootRestoreThread(new Runnable() { + + @Override + public void run() { + restoreBootResources(pending, context); + } + }); + } + + /** + * The {@link #applyPendingBootResources} body: extracts, posts the swaps, and settles the restore on their outcome. + * + * @param pending + * the persisted generation's resource files, at least one of them non-null + * @param context + * application context + */ + private void restoreBootResources(PayloadPersistence.Loaded pending, + android.content.Context context) { + final long generation = pending.generation; + // One outcome per swap posted, like a backgrounded deploy: a payload carrying both + // a table and assets lands in two swaps, and the recreate has to wait for the last. + final SwapAckGate gate = new SwapAckGate( + (pending.arscFile == null ? 0 : 1) + (pending.assetsFile == null ? 0 : 1)); + ResourceStore.SwapOutcome onOutcome = new ResourceStore.SwapOutcome() { + + @Override + public void onSwapCommitted() { + if (gate.committed()) { + onBootRestoreLanded(generation); + } + } + + @Override + public void onSwapFailed(Throwable error) { + gate.failed(); + onBootRestoreFailed(generation, error); + } + }; try { - // No failure listener: there is no deploy in flight to fail here, and the store - // already logs a failed swap. Baseline resources stay live and the next deploy - // re-applies the current ones, which is what this method's contract promises. if (pending.arscFile != null) { ResourceStore.INSTANCE.applyTable( - openReadOnly(pending.arscFile), pending.generation, context, null); + openReadOnly(pending.arscFile), generation, context, onOutcome); } if (pending.assetsFile != null) { ResourceStore.INSTANCE.applyAssets( - openReadOnly(pending.assetsFile), pending.generation, + openReadOnly(pending.assetsFile), generation, PayloadStore.INSTANCE.baselineFingerprint(), - context, null); + context, onOutcome); + } + if (gate.noSwapPosted()) { + onBootRestoreLanded(generation); } - RuntimeLog.i("restored persisted resources for gen " + pending.generation); } catch (Throwable error) { - RuntimeLog.e("could not restore persisted resources", error); + // The same hazard as a deploy that throws after applyTable posted: the table + // swap is still queued and would commit over a merge that failed. Refuse it, so + // the process stays wholly on the baseline table rather than half on each. + ResourceStore.INSTANCE.abandon(generation); + gate.failed(); + onBootRestoreFailed(generation, error); } } + /** + * Ends the restore once its last swap has committed, and recreates the activity that inflated before it landed. + * + * Runs on the main thread inside the swap's guard, so the recreate is posted rather than run here; a live Resources now resolves the restored table, but the views the first activity already inflated keep their baseline values until it is recreated. + * + * @param generation + * the generation whose resources are now live + */ + private void onBootRestoreLanded(long generation) { + bootRestoreInFlight = false; + RuntimeLog.i("restored persisted resources for gen " + generation); + mainHandler.post(new Runnable() { + + @Override + public void run() { + Activity top = tracker.topActivity(); + if (top == null) { + // Nothing inflated against the baseline yet; the next activity created + // picks the restored table up through attachTo. + return; + } + try { + top.recreate(); + } catch (Throwable error) { + // The table is live for every later inflate; only this activity's views + // are stale, and a crash here would cost the user the app for it. + RuntimeLog.w("could not recreate the activity after the boot restore", error); + } + } + }); + } + + /** + * Ends the restore on a failure: baseline resources stay live under this generation's code, and the user and CoGo are told so. + * + * Mixed rather than "last working version", because that is what the process is: the persisted dex is this generation's and the table is the installed APK's. There is no rollback here - no snapshot precedes a boot - so the banner offers the one remedy the user has, a restart, which re-runs the restore. + * + * @param generation + * the generation whose resources could not be restored + * @param error + * the extraction or swap failure + */ + private void onBootRestoreFailed(long generation, Throwable error) { + bootRestoreInFlight = false; + RuntimeLog.e("could not restore persisted resources for gen " + generation, error); + setOverlayState(OverlayState.mixed()); + client.reportCrash(generation, CrashSummary.forBootRestoreReport(error)); + } + /** * Asks Android to background the app and waits until the framework has been told the app's state, so the relaunch can put the user back where they were. * @@ -760,8 +877,16 @@ public void uncaughtException(Thread thread, Throwable error) { * The probation ends on the recorded write rather than on the frame that prompted it, so the two facts stay simultaneous: the moment this generation stops being blamed for a crash is the moment there is something to fall back to instead. A write that fails leaves it on probation, which is the expensive direction, not a safe one: nothing recorded it, so a later crash anywhere in the app blames a generation that demonstrably reached the screen and quarantines it. That is why a failed write releases the latch and the next resume tries again. * * Written off the main thread, because the write is fsynced and this runs on the frame path; latched per generation, so it costs one short-lived thread per generation rather than one per resume. Losing the write to a process death only makes the fallback one generation older. + * + * Deferred while a boot restore is in flight: a frame drawn against the baseline table has not shown this generation's resources, and recording it good from that frame would leave a table that fails to render unblamable. */ private void markLiveGenerationGood() { + if (bootRestoreInFlight) { + // This frame drew against the baseline table, so it proves the code half only; + // the frame after the restore's recreate is the one that vouches for the + // generation. Not latched, so that frame comes back here. + return; + } final long generation = PayloadStore.INSTANCE.generation(); final PayloadPersistence store = PayloadStore.INSTANCE.persistence(); if (generation <= 0 || generation == lastMarkedGoodGeneration || store == null) { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 8362360bd6..0220f104e7 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -558,7 +558,7 @@ public void run() { * * The swap must not run on the binder thread the deploy arrives on: setProviders rebuilds every attached Resources in place and the swap then closes the replaced provider's ApkAssets, either of which can race an inflation already in progress on the main thread - a lookup straddling the swap mixes old and new values, or touches a just-closed provider. Serializing with the main thread removes both races, and Looper FIFO keeps a posted swap ahead of the recreate the deploy posts right after it. * - * Inline on the main thread, not posted, because the boot restore path runs during the first activity's creation and its swap must land before anything inflates. + * Inline when already on the main thread, since posting from there would only queue the swap behind whatever else is waiting. Every current caller arrives off it - a deploy on a binder thread, the boot restore on its own thread - so in practice the swap is posted; the boot restore accepts that the first activity inflates against the baseline table and recreates it once the swap lands. * * A swap failure is never thrown from here: on the posted path no caller is left to catch it, and the previous provider set stays live either way. The result is still deliberately NOT returned synchronously to the deploy chain - that would make a deploy arriving on a binder thread block on a main-thread round trip in the hot reload path, which is the very thing posting the swap exists to avoid. It travels back through {@code onOutcome} instead, so the deploy that queued the swap can fail rather than ack a swap that did not land. Each swap un-commits its own fields on failure, so what stays live is a consistent previous generation. * diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java index fe29c52349..ea60762e6a 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java @@ -6,6 +6,8 @@ * A resource swap is queued to the main looper and lands after the deploy method has returned, so acking at apply time claims a reload that can still fail. When it did fail, the rollback, the quarantine and the crash report all ran after CoGo had already been told the generation reloaded, and CoGo's deploy resolves on the first report naming that generation: the build was recorded as a successful reload with a timing number while the session manager separately raised a reload crash, so one save produced both signals. The foreground branch does not have this problem - it acks from a drawn frame, which cannot precede the swap that the frame renders. * * So the ack is owed only once, and only when every posted swap has committed and none has failed. A deploy that posts no swap at all - a dex-only edit, the commonest one - still owes it immediately, which is what {@link #noSwapPosted} is for. + * + * The boot-time resource restore counts its swaps the same way, to recreate the first activity once the last of them has landed. */ final class SwapAckGate { diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java index c2187adaba..adec50bc49 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java @@ -223,4 +223,14 @@ void theMixedReportIsCappedAsAWholeNotAfterThePrefix() { assertThat(mixed.length()).isEqualTo(plain.length()); } + @Test + void theBootRestoreReportSaysNothingWasRolledBack() { + // A boot restore has no snapshot to roll back to, so its report must not borrow the + // deploy-time wording that says the code was rolled back. + String report = CrashSummary.forBootRestoreReport(lifecycleCrash()); + + assertThat(report).startsWith(CrashSummary.BOOT_RESTORE_PREFIX); + assertThat(report).doesNotContain("Rolled back"); + assertThat(report).contains("MainActivity.kt:22"); + } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeBootRestoreDispatchTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeBootRestoreDispatchTest.java new file mode 100644 index 0000000000..c3084ba61d --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeBootRestoreDispatchTest.java @@ -0,0 +1,37 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Pins {@code QuickBuildRuntime.startBootRestoreThread} to running its body on a new thread named qb-boot-restore. + * + * The restore body extracts the persisted asset merge and, on API 28/29, copies the relinked apk - both bounded only by the payload cap - and it is dispatched from inside the first activity's creation on the main thread. Run inline it is a launch stall or an ANR on every cold start that adopts a persisted generation with resources. This goes red if the helper is collapsed to an inline call. + * + * Like the fail-reload dispatch test, this does not pin the call site: {@code applyPendingBootResources} needs a Context and a store, so whether it still routes through this helper is checked on device. + */ +class QuickBuildRuntimeBootRestoreDispatchTest { + + @Test + void bodyRunsOffTheCallersThread() throws Exception { + final AtomicReference ranOn = new AtomicReference<>(); + final CountDownLatch done = new CountDownLatch(1); + Thread started = QuickBuildRuntime.startBootRestoreThread(new Runnable() { + + @Override + public void run() { + ranOn.set(Thread.currentThread()); + done.countDown(); + } + }); + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + started.join(TimeUnit.SECONDS.toMillis(5)); + assertThat(ranOn.get()).isNotSameInstanceAs(Thread.currentThread()); + assertThat(ranOn.get()).isSameInstanceAs(started); + assertThat(started.getName()).isEqualTo("qb-boot-restore"); + } +} From edcbd4ad26d9af2cea5277a2510ded1b8e55b4f7 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:06:41 -0700 Subject: [PATCH 21/40] ADFA-4128: remove the first-frame draw listener from the captured observer The listener was added to the observer captured before the draw and removed from a re-fetched decor.getViewTreeObserver(). Once the decor is detached - the activity destroyed between the draw and the posted completion - that accessor returns a fresh floating observer that never held the listener, so the removal was a silent no-op. Remove from the captured observer while it is alive, falling back to the decor's when the framework has merged it away, as StatusOverlay.reapplyInsetAfterLayout already does. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3934035280 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/runtime/QuickBuildRuntime.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 5825977c45..c80ff0ba55 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -717,9 +717,20 @@ public void onDraw() { @Override public void run() { - ViewTreeObserver live = decor.getViewTreeObserver(); - if (live != null && live.isAlive()) { - live.removeOnDrawListener(listener[0]); + // Captured at add time, not re-fetched: once the decor is detached - + // the activity destroyed between the draw and this post - its + // getViewTreeObserver() is a fresh floating observer that never held + // this listener, so removing there is a silent no-op. When the + // framework has merged the captured observer away, the listener + // lives on the decor's observer, so remove there. Same rule as + // StatusOverlay.reapplyInsetAfterLayout. + if (observer.isAlive()) { + observer.removeOnDrawListener(listener[0]); + } else { + ViewTreeObserver merged = decor.getViewTreeObserver(); + if (merged != null && merged.isAlive()) { + merged.removeOnDrawListener(listener[0]); + } } onFirstFrameDrawn(activity); } From ea8da5c946f1c9090996c1bd6a9a668e9fba90f2 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:06:42 -0700 Subject: [PATCH 22/40] ADFA-4128: write the client's host proxy under the monitor everywhere abandonHandshake tests host and tears the binding down under the monitor, but the four framework callbacks wrote host as plain volatile writes, so the exclusion held only against other synchronized callers. A disconnect-then-reconnect on the main thread could still land between the handshake thread's read and its write and unbind a healthy binding. onServiceConnected now writes under the monitor and the three null writes go through a synchronized dropHost(); the callbacks themselves stay unsynchronized so the main thread does not wait on a handshake thread's binder round trip. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3934037093 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/runtime/QuickBuildClient.java | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index b103ef0d9c..8efed73877 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -41,7 +41,11 @@ final class QuickBuildClient implements ServiceConnection { /** Application context, volatile because binder threads read it. */ private volatile Context appContext; - /** The live host proxy, or null while disconnected; volatile for the same reason. */ + /** + * The live host proxy, or null while disconnected. + * + * Volatile for the binder-thread reads in the report methods; every WRITE takes the monitor, so that {@link #abandonHandshake}'s test-and-teardown is one step against the four framework callbacks as well as against the other synchronized methods. + */ private volatile IQuickBuildHost host; /** True once {@link #bind} has run, which is what makes that call idempotent. */ @@ -107,7 +111,7 @@ public void onPayload(long generation, ParcelFileDescriptor dexPayload, @Override public void onBindingDied(ComponentName name) { RuntimeLog.w("binding to CoGo died; rebinding"); - host = null; + dropHost(); unbindQuietly(); scheduleRebind(); } @@ -121,7 +125,7 @@ public void onBindingDied(ComponentName name) { @Override public void onNullBinding(ComponentName name) { RuntimeLog.w("CoGo returned a null binding; retrying later"); - host = null; + dropHost(); unbindQuietly(); scheduleRebind(); } @@ -152,7 +156,9 @@ public void onServiceConnected(ComponentName name, IBinder service) { scheduleRebind(); return; } - host = connected; + synchronized (this) { + host = connected; + } Thread handshake = new Thread(new Runnable() { @Override @@ -175,7 +181,7 @@ public void onServiceDisconnected(ComponentName name) { // and calls onServiceConnected again. Do NOT rebind manually here - a second // bindService with the same connection would stack bindings. RuntimeLog.w("CoGo deploy service disconnected; awaiting reconnect"); - host = null; + dropHost(); } /** @@ -242,7 +248,7 @@ void reportReloaded(long generation, long reloadMillis) { * * The handshake runs on its own thread, so a slow one outlives its binding: CoGo's service dies, {@link #onServiceDisconnected} nulls the host, the framework reconnects, and a second handshake succeeds against a new proxy. Unguarded, the first thread's failure then nulls that live host, unbinds a healthy channel and schedules a rebind - and until the rebind lands every {@code reportReloaded} and {@code reportCrash} only logs "not connected", so each deploy in the window can end only in the host's own timeout. * - * Under the monitor, because the test and the teardown have to be one step: {@code host} is written from the framework's callback thread as well as from here. + * Under the monitor, because the test and the teardown have to be one step: {@code host} is written from the framework's callback thread as well as from here, and those writes take the monitor too ({@link #dropHost}, {@link #onServiceConnected}). Without that the exclusion held only against other callers of the synchronized methods, and a disconnect-then-reconnect on the main thread could still slip between this method's read of {@code host} and its write. * * @param connected * the proxy whose handshake failed @@ -257,6 +263,15 @@ private synchronized void abandonHandshake(IQuickBuildHost connected) { scheduleRebind(); } + /** + * Forgets the host under the monitor, so the write cannot land between {@link #abandonHandshake}'s test and its teardown. + * + * Only the write is under the monitor, not the whole callback: the callbacks run on the main thread, and holding the monitor across unbindService and the rebind post would serialize the main thread against a handshake thread's binder round trip for no gain. + */ + private synchronized void dropHost() { + host = null; + } + /** * Issues one bindService against CoGo's explicit service intent. * From df98c3a41eb1464f1c57b79d6275e0c25b281e1d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:06:42 -0700 Subject: [PATCH 23/40] ADFA-4128: document the recreate-into-stopped case FirstFrameGate does not release A recreate that succeeds but relaunches into the stopped state, whose task is then swiped away, never resumes: no draw callback is installed and nothing else releases the slot, so the deploy ends in CoGo's timeout and the generation stays blamable until the next save. The KDoc listed two no-frame fallbacks and not this one. It is recorded rather than wired: recreate() destroys the armed activity on every normal reload, so an onActivityDestroyed release would also need to know a relaunch is still pending, which nothing tracks yet. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3934035288 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../itsaky/androidide/quickbuild/runtime/FirstFrameGate.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java index 5bc847ead9..64d0c7c5ed 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java @@ -8,6 +8,8 @@ * So the generation stays pending across the resume and is only released by {@link #drawn}, which the runtime calls once the frame that rendered it has completed. Everything a released generation owes - the ack to CoGo, the good-marking that ends its probation - hangs off that one release, so the moment the crash guard stops blaming a generation is the same moment there is drawn evidence it works. * * A generation that never draws is released by the runtime's own fallbacks instead: an activity with no live view tree, and the branch where the resumed activity is gone by the time the recreate runs, both complete without a frame. That is the deliberate looser case - waiting for a frame that will never arrive would strand the deploy unacked. + * + * One case is deliberately not released: a recreate that succeeds but never resumes, because the user backgrounded the app mid-relaunch and the task was then swiped away. No draw callback is ever installed, so the deploy ends only in CoGo's timeout, and until the next save {@link #pending} still names this generation, so an unrelated crash in the process would be reported against it. Releasing from onActivityDestroyed is not the fix, since recreate() itself destroys the armed activity on every normal reload; a destroy-based release would need to know a relaunch is still pending, which nothing here tracks yet. */ final class FirstFrameGate { From fa204fc9c3dcf2e9a6758a8fbc4da1a902bbe707 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:06:42 -0700 Subject: [PATCH 24/40] ADFA-4128: refuse asset payloads below API 30 instead of acking a merge nothing reads Nothing below API 30 reads the merged asset dir: DirectoryAssetsProvider needs a ResourcesLoader and LegacyResourceSwap mounts the resource apk only. The legacy arm still ran the merge and reported the swap committed, which is what settles a backgrounded deploy's ack, so a reload the app could not show was acked. It now reports failed with the reason before touching the fd or the Context, and the comment names the host gate (QuickBuildModule's assetsLiveReloadable, the classifier) that keeps it unreachable today. The applyAssets KDoc also said a partial merge stays live until the next deploy overwrites it; extractCumulative leaves MERGE_PENDING_MARKER and the next merge clears the whole dir. Reworded to say so. Review threads: https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3934035295 https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3934035305 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/runtime/ResourceStore.java | 28 +++++---- .../ResourceStoreLegacyAssetsTest.java | 60 +++++++++++++++++++ 2 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreLegacyAssetsTest.java diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 0220f104e7..c5edaf073a 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -159,7 +159,9 @@ synchronized long swappedGeneration() { * * The merge clears the dir first when it belongs to another baseline, so assets never outlive the baseline they were deployed onto. * - * A failed merge is not undone: there is no asset rollback, so whatever it already wrote stays live until the next successful deploy onto the same baseline overwrites it. + * A failed merge is not undone here: there is no asset rollback, and the merge leaves {@link AssetExtractor#MERGE_PENDING_MARKER} behind. The NEXT merge finds it and clears the whole dir rather than overwriting into it - a half-merged dir would serve a file from the wrong generation - so the older generations' assets do not survive a failed merge; the provider falls through to the APK's baked-in copies until a forced rebuild. + * + * Below API 30 there is no asset swap at all: {@link DirectoryAssetsProvider} needs a {@code ResourcesLoader}, and {@link LegacyResourceSwap} mounts the relinked resource apk only. The host keeps such payloads away from this class - {@code QuickBuildModule} sets {@code assetsLiveReloadable} only from API 30 and the change classifier sends an asset-bearing edit to Gradle otherwise - but this class asserts that itself rather than acking a merge nothing would read. * * @param assetsFd * the changed-assets zip; always closed, success or failure @@ -170,25 +172,29 @@ synchronized long swappedGeneration() { * @param appContext * application context, for the cache dir the cumulative override lives under and the Resources the loader attaches to * @param onOutcome - * told when the posted provider swap fails, since that lands after this method returns; null when the caller has nothing to do about it + * told when the posted provider swap fails, since that lands after this method returns, and told failed inline below API 30; null when the caller has nothing to do about it * @throws IOException * on a read, extraction, path-traversal or provider failure; the previous override stays live */ void applyAssets(ParcelFileDescriptor assetsFd, long generation, String baselineFingerprint, Context appContext, SwapOutcome onOutcome) throws IOException { + if (strategy != ResourceSwapStrategy.RESOURCES_LOADER) { + // Nothing below API 30 reads the merged dir, so a merge here would be a swap + // that never happens - and reporting it committed used to ack a backgrounded + // reload the app could not show. Failed, with the reason, so the deploy is + // refused the way any other unservable payload is; the host's API gate is what + // keeps this from being reached. + Streams.closeQuietly(assetsFd); + reportSwapFailure(onOutcome, new IllegalStateException( + "asset payloads need API 30+ (ResourcesLoader); gen " + generation + " not applied")); + return; + } File assetsRoot = new File(appContext.getCacheDir(), ASSETS_ROOT_DIR); InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(assetsFd); try { int extracted = AssetExtractor.extractCumulative(in, assetsRoot, baselineFingerprint); - if (strategy == ResourceSwapStrategy.RESOURCES_LOADER) { - refreshAssetsProvider( - AssetExtractor.currentDir(assetsRoot), generation, appContext, onOutcome); - } else { - // The merge on disk is the whole swap below API 30; there is no provider to - // queue, so the outcome is settled here rather than by a callback that never - // comes. - reportSwapCommitted(onOutcome); - } + refreshAssetsProvider( + AssetExtractor.currentDir(assetsRoot), generation, appContext, onOutcome); RuntimeLog.i("merged " + extracted + " changed asset(s) into the override"); } finally { try { diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreLegacyAssetsTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreLegacyAssetsTest.java new file mode 100644 index 0000000000..91ab0eb5c0 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreLegacyAssetsTest.java @@ -0,0 +1,60 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Pins that an asset payload below API 30 is refused, not acked. + * + * Nothing below API 30 reads the merged asset dir: {@code DirectoryAssetsProvider} needs a ResourcesLoader and the API 28/29 path mounts the resource apk only. The legacy arm used to run the merge and report the swap committed, which settled a backgrounded deploy's ack on a change the app could not show. The host gates asset payloads on API 30 in another module; this test is the runtime's own assertion of it. + * + * Driven with a null fd and a null Context on purpose: the refusal has to come before either is touched, or the JVM stubs would throw first. + */ +class ResourceStoreLegacyAssetsTest { + + @Test + void anAssetPayloadBelowApi30IsReportedFailedWithTheReason() throws Exception { + ResourceStore store = new ResourceStore(ResourceSwapStrategy.LEGACY_ASSET_PATH); + final AtomicReference failure = new AtomicReference<>(); + final boolean[] committed = new boolean[1]; + + store.applyAssets(null, 7, "fingerprint", null, new ResourceStore.SwapOutcome() { + + @Override + public void onSwapCommitted() { + committed[0] = true; + } + + @Override + public void onSwapFailed(Throwable error) { + failure.set(error); + } + }); + + assertThat(committed[0]).isFalse(); + assertThat(failure.get()).isNotNull(); + assertThat(failure.get()).hasMessageThat().contains("API 30"); + assertThat(failure.get()).hasMessageThat().contains("gen 7"); + } + + @Test + void anAssetPayloadOnAnUnsupportedSdkIsRefusedTheSameWay() throws Exception { + ResourceStore store = new ResourceStore(ResourceSwapStrategy.UNSUPPORTED); + final AtomicReference failure = new AtomicReference<>(); + + store.applyAssets(null, 3, "fingerprint", null, new ResourceStore.SwapOutcome() { + + @Override + public void onSwapCommitted() {} + + @Override + public void onSwapFailed(Throwable error) { + failure.set(error); + } + }); + + assertThat(failure.get()).hasMessageThat().contains("gen 3"); + } +} From 14238233487221bfafc31d99396caba0de62eb3e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 10:08:44 -0700 Subject: [PATCH 25/40] style: spotless reformat, no functional change Eclipse member sorting over the members the review-fix commits added; no line inside any member changed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/runtime/CrashSummary.java | 56 ++--- .../quickbuild/runtime/Generations.java | 30 +-- .../quickbuild/runtime/OverlayState.java | 18 +- .../quickbuild/runtime/QuickBuildClient.java | 18 +- .../quickbuild/runtime/QuickBuildRuntime.java | 222 +++++++++--------- .../quickbuild/runtime/ResourceStore.java | 22 +- .../quickbuild/runtime/CrashSummaryTest.java | 38 +-- .../quickbuild/runtime/GenerationsTest.java | 31 +-- .../quickbuild/runtime/OverlayStateTest.java | 23 +- 9 files changed, 231 insertions(+), 227 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java index 24bde0742f..9ffb598e85 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/CrashSummary.java @@ -59,14 +59,14 @@ final class CrashSummary { private static final int MAX_REPORT_CAUSES = 3; /** - * The full form reported to CoGo. + * The full form reported to CoGo when a boot-time resource restore failed: {@link #BOOT_RESTORE_PREFIX}, then the same frames as {@link #forReport}, under the same length cap. * * @param error - * the failure to summarize; must be non-null - * @return the exception and up to {@link #MAX_REPORT_CAUSES} causes, each with up to {@link #MAX_REPORT_FRAMES} frames, truncated to {@link #MAX_REPORT_LENGTH} chars + * the extraction or swap failure; must be non-null + * @return the prefixed report, truncated to {@link #MAX_REPORT_LENGTH} chars as a whole */ - static String forReport(Throwable error) { - return report(null, error); + static String forBootRestoreReport(Throwable error) { + return report(BOOT_RESTORE_PREFIX, error); } /** @@ -81,14 +81,32 @@ static String forMixedReport(Throwable error) { } /** - * The full form reported to CoGo when a boot-time resource restore failed: {@link #BOOT_RESTORE_PREFIX}, then the same frames as {@link #forReport}, under the same length cap. + * The full form reported to CoGo. * * @param error - * the extraction or swap failure; must be non-null - * @return the prefixed report, truncated to {@link #MAX_REPORT_LENGTH} chars as a whole + * the failure to summarize; must be non-null + * @return the exception and up to {@link #MAX_REPORT_CAUSES} causes, each with up to {@link #MAX_REPORT_FRAMES} frames, truncated to {@link #MAX_REPORT_LENGTH} chars */ - static String forBootRestoreReport(Throwable error) { - return report(BOOT_RESTORE_PREFIX, error); + static String forReport(Throwable error) { + return report(null, error); + } + + /** + * Appends at most {@code limit} of {@code error}'s frames, one per line. + * + * @param sb + * the summary under construction + * @param error + * the failure whose trace to read; a trace stripped by the VM is simply empty + * @param limit + * the most frames to append + */ + private static void appendFrames(StringBuilder sb, Throwable error, int limit) { + StackTraceElement[] frames = error.getStackTrace(); + int count = Math.min(frames.length, limit); + for (int i = 0; i < count; i++) { + sb.append("\n at ").append(frames[i]); + } } /** @@ -120,24 +138,6 @@ private static String report(String prefix, Throwable error) { return truncate(sb, MAX_REPORT_LENGTH); } - /** - * Appends at most {@code limit} of {@code error}'s frames, one per line. - * - * @param sb - * the summary under construction - * @param error - * the failure whose trace to read; a trace stripped by the VM is simply empty - * @param limit - * the most frames to append - */ - private static void appendFrames(StringBuilder sb, Throwable error, int limit) { - StackTraceElement[] frames = error.getStackTrace(); - int count = Math.min(frames.length, limit); - for (int i = 0; i < count; i++) { - sb.append("\n at ").append(frames[i]); - } - } - /** * @param sb * the summary under construction diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java index a7c3707ed9..7722e84db0 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java @@ -20,6 +20,21 @@ static boolean accepts(long runningGeneration, long incomingGeneration) { return incomingGeneration > runningGeneration; } + /** + * Whether a failed reload has left the process serving two generations at once. + * + * The rollback restores the dex, but a resource swap that already committed cannot be undone: the store keeps single provider slots and closes the replaced one, and the API 28/29 path cannot unmount an asset path at all. A deploy posts its table swap and then does the asset merge on the binder thread, so when the merge throws the swap has usually landed. The app then runs the previous generation's classes over the failed generation's resources until a resources-carrying deploy or a process restart, and a banner claiming the last working version would be false. + * + * @param swappedGeneration + * the newest generation whose resource swap committed, or -1 before any + * @param failedGeneration + * the generation whose reload failed + * @return true when the failed generation's resources are what the screen resolves against + */ + static boolean leavesMixedState(long swappedGeneration, long failedGeneration) { + return swappedGeneration == failedGeneration; + } + /** * What a failed reload owes, decided from where the store stands relative to the failure. * @@ -40,21 +55,6 @@ static FailureAction onReloadFailure(long runningGeneration, long failedGenerati : FailureAction.REPORT_ONLY; } - /** - * Whether a failed reload has left the process serving two generations at once. - * - * The rollback restores the dex, but a resource swap that already committed cannot be undone: the store keeps single provider slots and closes the replaced one, and the API 28/29 path cannot unmount an asset path at all. A deploy posts its table swap and then does the asset merge on the binder thread, so when the merge throws the swap has usually landed. The app then runs the previous generation's classes over the failed generation's resources until a resources-carrying deploy or a process restart, and a banner claiming the last working version would be false. - * - * @param swappedGeneration - * the newest generation whose resource swap committed, or -1 before any - * @param failedGeneration - * the generation whose reload failed - * @return true when the failed generation's resources are what the screen resolves against - */ - static boolean leavesMixedState(long swappedGeneration, long failedGeneration) { - return swappedGeneration == failedGeneration; - } - /** * The pending-reload generation the runtime should hold after a payload applies. * diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java index cd3d412712..9f341a447a 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java @@ -49,6 +49,15 @@ static OverlayState crashed() { return new OverlayState(Kind.CRASHED, null, 0, -1); } + /** + * State that renders nothing, the resting state. + * + * @return the state that makes {@link StatusOverlay#render} remove the banner + */ + static OverlayState hidden() { + return new OverlayState(Kind.HIDDEN, null, 0, -1); + } + /** * State for a reload that failed after its resource swap had already committed, so the rollback restored the code half only. * @@ -60,15 +69,6 @@ static OverlayState mixed() { return new OverlayState(Kind.MIXED, null, 0, -1); } - /** - * State that renders nothing, the resting state. - * - * @return the state that makes {@link StatusOverlay#render} remove the banner - */ - static OverlayState hidden() { - return new OverlayState(Kind.HIDDEN, null, 0, -1); - } - /** * State for an update whose reinstall is waiting on a confirm dialog only CoGo can show. The user watching this app is the one person the CoGo-side signals cannot reach, so this banner is the recovery instruction. * diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index 8efed73877..8610133031 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -263,15 +263,6 @@ private synchronized void abandonHandshake(IQuickBuildHost connected) { scheduleRebind(); } - /** - * Forgets the host under the monitor, so the write cannot land between {@link #abandonHandshake}'s test and its teardown. - * - * Only the write is under the monitor, not the whole callback: the callbacks run on the main thread, and holding the monitor across unbindService and the rebind post would serialize the main thread against a handshake thread's binder round trip for no gain. - */ - private synchronized void dropHost() { - host = null; - } - /** * Issues one bindService against CoGo's explicit service intent. * @@ -327,6 +318,15 @@ private void connectToHost(IQuickBuildHost connected) { } } + /** + * Forgets the host under the monitor, so the write cannot land between {@link #abandonHandshake}'s test and its teardown. + * + * Only the write is under the monitor, not the whole callback: the callbacks run on the main thread, and holding the monitor across unbindService and the rebind post would serialize the main thread against a handshake thread's binder round trip for no gain. + */ + private synchronized void dropHost() { + host = null; + } + /** Queues one rebind attempt, doubling the delay up to {@link #REBIND_MAX_DELAY_MS}. */ private synchronized void scheduleRebind() { if (rebindScheduled) { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index c80ff0ba55..121527748a 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -75,27 +75,27 @@ static void install(Application application) { } /** - * Runs a reload-failure body on its own thread. Package-private so the JVM test can pin the dispatch off the caller's thread, which is what keeps the quarantine fsync off the frame path. + * Runs the boot-time resource restore on its own thread. Package-private so the JVM test can pin the dispatch off the caller's thread, which is the main thread inside the first activity's creation. * * @param body - * the failure handling to run + * the restore to run * @return the started thread, so a test can join it */ - static Thread startFailReloadThread(Runnable body) { - Thread thread = new Thread(body, "qb-fail-reload"); + static Thread startBootRestoreThread(Runnable body) { + Thread thread = new Thread(body, "qb-boot-restore"); thread.start(); return thread; } /** - * Runs the boot-time resource restore on its own thread. Package-private so the JVM test can pin the dispatch off the caller's thread, which is the main thread inside the first activity's creation. + * Runs a reload-failure body on its own thread. Package-private so the JVM test can pin the dispatch off the caller's thread, which is what keeps the quarantine fsync off the frame path. * * @param body - * the restore to run + * the failure handling to run * @return the started thread, so a test can join it */ - static Thread startBootRestoreThread(Runnable body) { - Thread thread = new Thread(body, "qb-boot-restore"); + static Thread startFailReloadThread(Runnable body) { + Thread thread = new Thread(body, "qb-fail-reload"); thread.start(); return thread; } @@ -538,109 +538,6 @@ public void run() { }); } - /** - * The {@link #applyPendingBootResources} body: extracts, posts the swaps, and settles the restore on their outcome. - * - * @param pending - * the persisted generation's resource files, at least one of them non-null - * @param context - * application context - */ - private void restoreBootResources(PayloadPersistence.Loaded pending, - android.content.Context context) { - final long generation = pending.generation; - // One outcome per swap posted, like a backgrounded deploy: a payload carrying both - // a table and assets lands in two swaps, and the recreate has to wait for the last. - final SwapAckGate gate = new SwapAckGate( - (pending.arscFile == null ? 0 : 1) + (pending.assetsFile == null ? 0 : 1)); - ResourceStore.SwapOutcome onOutcome = new ResourceStore.SwapOutcome() { - - @Override - public void onSwapCommitted() { - if (gate.committed()) { - onBootRestoreLanded(generation); - } - } - - @Override - public void onSwapFailed(Throwable error) { - gate.failed(); - onBootRestoreFailed(generation, error); - } - }; - try { - if (pending.arscFile != null) { - ResourceStore.INSTANCE.applyTable( - openReadOnly(pending.arscFile), generation, context, onOutcome); - } - if (pending.assetsFile != null) { - ResourceStore.INSTANCE.applyAssets( - openReadOnly(pending.assetsFile), generation, - PayloadStore.INSTANCE.baselineFingerprint(), - context, onOutcome); - } - if (gate.noSwapPosted()) { - onBootRestoreLanded(generation); - } - } catch (Throwable error) { - // The same hazard as a deploy that throws after applyTable posted: the table - // swap is still queued and would commit over a merge that failed. Refuse it, so - // the process stays wholly on the baseline table rather than half on each. - ResourceStore.INSTANCE.abandon(generation); - gate.failed(); - onBootRestoreFailed(generation, error); - } - } - - /** - * Ends the restore once its last swap has committed, and recreates the activity that inflated before it landed. - * - * Runs on the main thread inside the swap's guard, so the recreate is posted rather than run here; a live Resources now resolves the restored table, but the views the first activity already inflated keep their baseline values until it is recreated. - * - * @param generation - * the generation whose resources are now live - */ - private void onBootRestoreLanded(long generation) { - bootRestoreInFlight = false; - RuntimeLog.i("restored persisted resources for gen " + generation); - mainHandler.post(new Runnable() { - - @Override - public void run() { - Activity top = tracker.topActivity(); - if (top == null) { - // Nothing inflated against the baseline yet; the next activity created - // picks the restored table up through attachTo. - return; - } - try { - top.recreate(); - } catch (Throwable error) { - // The table is live for every later inflate; only this activity's views - // are stale, and a crash here would cost the user the app for it. - RuntimeLog.w("could not recreate the activity after the boot restore", error); - } - } - }); - } - - /** - * Ends the restore on a failure: baseline resources stay live under this generation's code, and the user and CoGo are told so. - * - * Mixed rather than "last working version", because that is what the process is: the persisted dex is this generation's and the table is the installed APK's. There is no rollback here - no snapshot precedes a boot - so the banner offers the one remedy the user has, a restart, which re-runs the restore. - * - * @param generation - * the generation whose resources could not be restored - * @param error - * the extraction or swap failure - */ - private void onBootRestoreFailed(long generation, Throwable error) { - bootRestoreInFlight = false; - RuntimeLog.e("could not restore persisted resources for gen " + generation, error); - setOverlayState(OverlayState.mixed()); - client.reportCrash(generation, CrashSummary.forBootRestoreReport(error)); - } - /** * Asks Android to background the app and waits until the framework has been told the app's state, so the relaunch can put the user back where they were. * @@ -925,6 +822,55 @@ public void run() { }, "qb-mark-good").start(); } + /** + * Ends the restore on a failure: baseline resources stay live under this generation's code, and the user and CoGo are told so. + * + * Mixed rather than "last working version", because that is what the process is: the persisted dex is this generation's and the table is the installed APK's. There is no rollback here - no snapshot precedes a boot - so the banner offers the one remedy the user has, a restart, which re-runs the restore. + * + * @param generation + * the generation whose resources could not be restored + * @param error + * the extraction or swap failure + */ + private void onBootRestoreFailed(long generation, Throwable error) { + bootRestoreInFlight = false; + RuntimeLog.e("could not restore persisted resources for gen " + generation, error); + setOverlayState(OverlayState.mixed()); + client.reportCrash(generation, CrashSummary.forBootRestoreReport(error)); + } + + /** + * Ends the restore once its last swap has committed, and recreates the activity that inflated before it landed. + * + * Runs on the main thread inside the swap's guard, so the recreate is posted rather than run here; a live Resources now resolves the restored table, but the views the first activity already inflated keep their baseline values until it is recreated. + * + * @param generation + * the generation whose resources are now live + */ + private void onBootRestoreLanded(long generation) { + bootRestoreInFlight = false; + RuntimeLog.i("restored persisted resources for gen " + generation); + mainHandler.post(new Runnable() { + + @Override + public void run() { + Activity top = tracker.topActivity(); + if (top == null) { + // Nothing inflated against the baseline yet; the next activity created + // picks the restored table up through attachTo. + return; + } + try { + top.recreate(); + } catch (Throwable error) { + // The table is live for every later inflate; only this activity's views + // are stale, and a crash here would cost the user the app for it. + RuntimeLog.w("could not recreate the activity after the boot restore", error); + } + } + }); + } + /** * Completes a pending reload now that its generation has been drawn, and renders the overlay and return button. * @@ -1038,6 +984,60 @@ private void reloadOnMain(long generation, PayloadStore.Payload rollback) { } } + /** + * The {@link #applyPendingBootResources} body: extracts, posts the swaps, and settles the restore on their outcome. + * + * @param pending + * the persisted generation's resource files, at least one of them non-null + * @param context + * application context + */ + private void restoreBootResources(PayloadPersistence.Loaded pending, + android.content.Context context) { + final long generation = pending.generation; + // One outcome per swap posted, like a backgrounded deploy: a payload carrying both + // a table and assets lands in two swaps, and the recreate has to wait for the last. + final SwapAckGate gate = new SwapAckGate( + (pending.arscFile == null ? 0 : 1) + (pending.assetsFile == null ? 0 : 1)); + ResourceStore.SwapOutcome onOutcome = new ResourceStore.SwapOutcome() { + + @Override + public void onSwapCommitted() { + if (gate.committed()) { + onBootRestoreLanded(generation); + } + } + + @Override + public void onSwapFailed(Throwable error) { + gate.failed(); + onBootRestoreFailed(generation, error); + } + }; + try { + if (pending.arscFile != null) { + ResourceStore.INSTANCE.applyTable( + openReadOnly(pending.arscFile), generation, context, onOutcome); + } + if (pending.assetsFile != null) { + ResourceStore.INSTANCE.applyAssets( + openReadOnly(pending.assetsFile), generation, + PayloadStore.INSTANCE.baselineFingerprint(), + context, onOutcome); + } + if (gate.noSwapPosted()) { + onBootRestoreLanded(generation); + } + } catch (Throwable error) { + // The same hazard as a deploy that throws after applyTable posted: the table + // swap is still queued and would commit over a merge that failed. Refuse it, so + // the process stays wholly on the baseline table rather than half on each. + ResourceStore.INSTANCE.abandon(generation); + gate.failed(); + onBootRestoreFailed(generation, error); + } + } + /** * Installs the new overlay state and re-renders it on the main thread; callable from any thread. * diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index c5edaf073a..c4e7132e41 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -143,17 +143,6 @@ synchronized void abandon(long generation) { } } - /** - * The newest generation whose swap has committed, or -1 before the first. - * - * For the failure path: a deploy that threw after its table swap had already committed has left that table live, and nothing can take it down again (see {@link #abandonedGeneration}). Read only after {@link #abandon} for the same generation, so a swap still queued at that point is refused rather than committing later and changing the answer. - * - * @return the committed generation, which the failure path compares with the one that failed - */ - synchronized long swappedGeneration() { - return swappedGeneration; - } - /** * Merges a changed-assets zip into the cumulative override dir under {@code cacheRoot} and serves it through the loader. * @@ -285,6 +274,17 @@ synchronized boolean refusesSwap(long generation) { return generation < swappedGeneration || generation <= abandonedGeneration; } + /** + * The newest generation whose swap has committed, or -1 before the first. + * + * For the failure path: a deploy that threw after its table swap had already committed has left that table live, and nothing can take it down again (see {@link #abandonedGeneration}). Read only after {@link #abandon} for the same generation, so a swap still queued at that point is refused rather than committing later and changing the answer. + * + * @return the committed generation, which the failure path compares with the one that failed + */ + synchronized long swappedGeneration() { + return swappedGeneration; + } + /** * API 28/29 swap: write the apk to disk, then addAssetPath it into the application AssetManager and flush caches on the main thread. * diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java index adec50bc49..952945a8a8 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/CrashSummaryTest.java @@ -169,6 +169,17 @@ public synchronized Throwable getCause() { assertThat(CrashSummary.forReport(error)).doesNotContain("Caused by"); } + @Test + void theBootRestoreReportSaysNothingWasRolledBack() { + // A boot restore has no snapshot to roll back to, so its report must not borrow the + // deploy-time wording that says the code was rolled back. + String report = CrashSummary.forBootRestoreReport(lifecycleCrash()); + + assertThat(report).startsWith(CrashSummary.BOOT_RESTORE_PREFIX); + assertThat(report).doesNotContain("Rolled back"); + assertThat(report).contains("MainActivity.kt:22"); + } + @Test void theCrashBannerCarriesNoStackAtAll() { // The property the banner's whole size argument rests on. If a summary is ever put @@ -192,6 +203,7 @@ void theCrashBannerFitsTheOverlayLineBudgetAtTwoTimesFontScale() { assertThat(wrappedLineCount(rendered, NARROWEST_MEASURED_LINE_CHARS)) .isAtMost(CrashSummary.MAX_BANNER_LINES); } + @Test void theMixedBannerFitsTheOverlayLineBudgetAtTwoTimesFontScale() { // MIXED has its own headline, so it has to be measured against the same budget the @@ -203,18 +215,6 @@ void theMixedBannerFitsTheOverlayLineBudgetAtTwoTimesFontScale() { assertThat(rendered).doesNotContain("Exception"); } - @Test - void theMixedReportLeadsWithTheRestartInstructionAndKeepsTheFrames() { - // Build Output is where the banner sends the reader, so the instruction has to be - // the first thing there, ahead of the same frames a plain crash reports. - String report = CrashSummary.forMixedReport(lifecycleCrash()); - - assertThat(report).startsWith(CrashSummary.MIXED_STATE_PREFIX); - assertThat(report).contains("restarted"); - assertThat(report).contains("MainActivity.kt:22"); - assertThat(report).contains("Caused by"); - } - @Test void theMixedReportIsCappedAsAWholeNotAfterThePrefix() { // The prefix must not push the report past the binder cap forReport holds to. @@ -223,14 +223,16 @@ void theMixedReportIsCappedAsAWholeNotAfterThePrefix() { assertThat(mixed.length()).isEqualTo(plain.length()); } + @Test - void theBootRestoreReportSaysNothingWasRolledBack() { - // A boot restore has no snapshot to roll back to, so its report must not borrow the - // deploy-time wording that says the code was rolled back. - String report = CrashSummary.forBootRestoreReport(lifecycleCrash()); + void theMixedReportLeadsWithTheRestartInstructionAndKeepsTheFrames() { + // Build Output is where the banner sends the reader, so the instruction has to be + // the first thing there, ahead of the same frames a plain crash reports. + String report = CrashSummary.forMixedReport(lifecycleCrash()); - assertThat(report).startsWith(CrashSummary.BOOT_RESTORE_PREFIX); - assertThat(report).doesNotContain("Rolled back"); + assertThat(report).startsWith(CrashSummary.MIXED_STATE_PREFIX); + assertThat(report).contains("restarted"); assertThat(report).contains("MainActivity.kt:22"); + assertThat(report).contains("Caused by"); } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java index c3c1159c55..98c1f18038 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java @@ -25,6 +25,22 @@ void acceptsStrictlyNewerGeneration() { assertThat(Generations.accepts(41, 100)).isTrue(); } + @Test + void aFailureAfterItsOwnSwapCommittedLeavesAMixedState() { + // The common ordering: applyTable posts and returns, applyAssets throws on the binder + // thread, and by then the table swap has committed. The dex rolls back, the table + // cannot, so the banner must not claim the last working version. + assertThat(Generations.leavesMixedState(7, 7)).isTrue(); + } + + @Test + void aFailureBeforeAnySwapCommittedIsNotMixed() { + // The swap was refused or never posted: the screen resolves the previous table under + // the previous dex, which is the last working version the banner names. + assertThat(Generations.leavesMixedState(-1, 7)).isFalse(); + assertThat(Generations.leavesMixedState(6, 7)).isFalse(); + } + @Test void aFailureSupersededByANewerLiveGenerationStaysSilent() { // Gen 6's posted recreate throws after gen 7 already applied: gen 7 owns the store, @@ -91,19 +107,4 @@ void rollbackDoesNotApplyToAGenerationTheStoreNeverReached() { // a failure naming a generation ahead of the store owns nothing either. assertThat(Generations.rollbackApplies(6, 7)).isFalse(); } - @Test - void aFailureAfterItsOwnSwapCommittedLeavesAMixedState() { - // The common ordering: applyTable posts and returns, applyAssets throws on the binder - // thread, and by then the table swap has committed. The dex rolls back, the table - // cannot, so the banner must not claim the last working version. - assertThat(Generations.leavesMixedState(7, 7)).isTrue(); - } - - @Test - void aFailureBeforeAnySwapCommittedIsNotMixed() { - // The swap was refused or never posted: the screen resolves the previous table under - // the previous dex, which is the last working version the banner names. - assertThat(Generations.leavesMixedState(-1, 7)).isFalse(); - assertThat(Generations.leavesMixedState(6, 7)).isFalse(); - } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java index e4e466497c..8a192d97aa 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java @@ -85,6 +85,18 @@ void hiddenRendersNothing() { assertThat(state.isError()).isFalse(); } + @Test + void mixedSaysRestartAndNeverClaimsTheLastWorkingVersion() { + // Set when the failed generation's resource swap had already committed before the + // rollback: the code is on the previous generation, the table is not, and "last + // working version" would be the one claim the banner cannot make. + OverlayState state = OverlayState.mixed(); + assertThat(state.text()).contains("Restart the app"); + assertThat(state.text()).contains("Build Output"); + assertThat(state.text()).doesNotContain("last working version"); + assertThat(state.isError()).isTrue(); + } + @Test void onlyBuildingIsBuilding() { assertThat(OverlayState.hidden().isBuilding()).isFalse(); @@ -106,15 +118,4 @@ void reinstallPendingSendsTheUserBackToCoGo() { assertThat(state.text()).contains("Code on the Go"); assertThat(state.text()).contains("running the last working version"); } - @Test - void mixedSaysRestartAndNeverClaimsTheLastWorkingVersion() { - // Set when the failed generation's resource swap had already committed before the - // rollback: the code is on the previous generation, the table is not, and "last - // working version" would be the one claim the banner cannot make. - OverlayState state = OverlayState.mixed(); - assertThat(state.text()).contains("Restart the app"); - assertThat(state.text()).contains("Build Output"); - assertThat(state.text()).doesNotContain("last working version"); - assertThat(state.isError()).isTrue(); - } } From 41b45098aa545353217b9c298cc75a859d36df41 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 15:29:49 -0700 Subject: [PATCH 26/40] ADFA-4128: decide what a frame proves on its draw pass, not when its completion runs markLiveGenerationGood read bootRestoreInFlight when the posted first-frame completion ran. The draw listener fires inside the traversal, an async message ahead of the sync barrier, and the completion is posted behind it, so a boot restore's swap message could land in between: the frame drew the baseline table, the swap committed and cleared the flag, and the completion then recorded good a generation whose table never rendered - unblamable if that table fails on the next boot. frameCompletion samples the flag on the draw pass and hands the fixed verdict to onFirstFrameDrawn / markLiveGenerationGood, which no longer re-read it. The seam is static and Android-free so QuickBuildRuntimeFrameCompletionTest can pin the ordering. Adversarial review 2026-09-04, finding #3 on 540eb96dd. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/runtime/QuickBuildRuntime.java | 86 +++++++++++++++--- .../QuickBuildRuntimeFrameCompletionTest.java | 90 +++++++++++++++++++ 2 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFrameCompletionTest.java diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 121527748a..109cebc7cf 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -50,6 +50,30 @@ static void completeOnResume(FrameCallbackInstaller installDrawCallback, Runnabl } } + /** + * Builds a drawn frame's completion, deciding NOW - on the frame's own draw pass - whether it proves the live generation's resources. + * + * The draw listener fires during the traversal, which runs as an async message ahead of the sync barrier, and the completion is posted behind it. A boot restore's swap message, posted earlier by the restore thread, therefore runs between the two: the traversal drew the baseline table, the swap then commits and clears {@link #bootRestoreInFlight}, and a completion that read the flag when it ran would record good a generation whose table never rendered. Reading it here, before the post, ties the verdict to the frame that was drawn. + * + * Package-private and free of Activity so a JVM test can pin the ordering; the listener that calls this needs a live ViewTreeObserver. + * + * @param restore + * whether a boot restore is still in flight, read once, here + * @param completion + * what runs when the posted completion lands; told whether the frame proved the resources + * @return the runnable to post to the main thread + */ + static Runnable frameCompletion(BootRestoreProbe restore, final FrameCompletion completion) { + final boolean frameProvesResources = !restore.inFlight(); + return new Runnable() { + + @Override + public void run() { + completion.complete(frameProvesResources); + } + }; + } + /** * Creates and starts the one runtime for this process. Idempotent, and never throws. * @@ -169,9 +193,10 @@ private static InputStream streamOf(ParcelFileDescriptor fd) { } private final Application application; - private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final ActivityTracker tracker = new ActivityTracker(this); + private final QuickBuildClient client = new QuickBuildClient(this); private final StatusOverlay overlay = new StatusOverlay(); @@ -207,10 +232,19 @@ private static InputStream streamOf(ParcelFileDescriptor fd) { /** * True from the moment a persisted generation's resources are taken for restore until their swap has landed or failed. * - * The restore runs off the main thread and its swap is posted, so the first activity draws against the baseline table. That frame proves the code half only: {@link #markLiveGenerationGood} waits for this to clear, or a generation whose resources then fail to render would already be recorded as good and be unblamable. + * The restore runs off the main thread and its swap is posted, so the first activity draws against the baseline table. That frame proves the code half only, so it must not record the generation good, or a table that then fails to render would be unblamable. Sampled ON the draw pass ({@link #frameCompletion}), never when the posted completion runs: the swap message can land between the two, clear this, and make a baseline frame read as a restored one. */ private volatile boolean bootRestoreInFlight; + /** Reads {@link #bootRestoreInFlight} for {@link #frameCompletion}, which is static so a JVM test can drive it. */ + private final BootRestoreProbe bootRestore = new BootRestoreProbe() { + + @Override + public boolean inFlight() { + return bootRestoreInFlight; + } + }; + /** * @param application * the app's Application; retained for its package name, cache dir and lifecycle callbacks, and safe to hold because the runtime is process-scoped @@ -461,7 +495,8 @@ public boolean install() { @Override public void run() { - onFirstFrameDrawn(activity); + // No frame is coming, so the restore state now is the best available. + onFirstFrameDrawn(activity, !bootRestoreInFlight); } }); } @@ -610,6 +645,15 @@ public void onDraw() { return; } scheduled[0] = true; + // Built here, on the draw pass, so what this frame proves is fixed before + // the swap message behind the traversal's sync barrier can change it. + final Runnable completion = frameCompletion(bootRestore, new FrameCompletion() { + + @Override + public void complete(boolean frameProvesResources) { + onFirstFrameDrawn(activity, frameProvesResources); + } + }); mainHandler.post(new Runnable() { @Override @@ -629,7 +673,7 @@ public void run() { merged.removeOnDrawListener(listener[0]); } } - onFirstFrameDrawn(activity); + completion.run(); } }); } @@ -786,13 +830,13 @@ public void uncaughtException(Thread thread, Throwable error) { * * Written off the main thread, because the write is fsynced and this runs on the frame path; latched per generation, so it costs one short-lived thread per generation rather than one per resume. Losing the write to a process death only makes the fallback one generation older. * - * Deferred while a boot restore is in flight: a frame drawn against the baseline table has not shown this generation's resources, and recording it good from that frame would leave a table that fails to render unblamable. + * Skipped for a frame drawn while a boot restore was in flight: it drew the baseline table, so it has not shown this generation's resources, and recording it good would leave a table that fails to render unblamable. The frame after the restore's recreate is the one that vouches; nothing is latched here, so that frame comes back. + * + * @param frameProvesResources + * whether the frame drew this generation's resource table, decided on its draw pass ({@link #frameCompletion}) - not re-read here, because the restore may have landed since */ - private void markLiveGenerationGood() { - if (bootRestoreInFlight) { - // This frame drew against the baseline table, so it proves the code half only; - // the frame after the restore's recreate is the one that vouches for the - // generation. Not latched, so that frame comes back here. + private void markLiveGenerationGood(boolean frameProvesResources) { + if (!frameProvesResources) { return; } final long generation = PayloadStore.INSTANCE.generation(); @@ -878,8 +922,10 @@ public void run() { * * @param activity * the activity that drew the frame, which hosts the overlay + * @param frameProvesResources + * false when the frame drew the baseline table under a boot restore, so it may complete the reload but not vouch for the generation */ - private void onFirstFrameDrawn(Activity activity) { + private void onFirstFrameDrawn(Activity activity, boolean frameProvesResources) { long acked = firstFrame.drawn(PayloadStore.INSTANCE.generation()); if (acked >= 0) { long reloadMillis = SystemClock.uptimeMillis() - pendingReloadStartUptime; @@ -899,7 +945,7 @@ private void onFirstFrameDrawn(Activity activity) { // reached the screen - which is true whether it arrived by hot swap or by a // fresh process booting it, and only the first of those leaves a pending // generation behind. - markLiveGenerationGood(); + markLiveGenerationGood(frameProvesResources); } /** @@ -1090,6 +1136,12 @@ private void sweepLegacyResourceCache(android.content.Context context) { } } + /** Reads whether a boot resource restore is still in flight; the seam {@link #frameCompletion} samples on the draw pass. */ + interface BootRestoreProbe { + + boolean inFlight(); + } + /** * Installs the callback that runs a reload's completion on the resumed activity's first drawn frame. * @@ -1102,4 +1154,14 @@ interface FrameCallbackInstaller { */ boolean install(); } + + /** A drawn frame's completion, run from the posted message with the verdict its draw pass fixed. */ + interface FrameCompletion { + + /** + * @param frameProvesResources + * true when the frame drew the live generation's resource table, false when it drew the baseline under a boot restore + */ + void complete(boolean frameProvesResources); + } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFrameCompletionTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFrameCompletionTest.java new file mode 100644 index 0000000000..ad7d6c398b --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntimeFrameCompletionTest.java @@ -0,0 +1,90 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pins that a drawn frame's verdict - does it prove the live generation's resources - is fixed on the frame's own draw pass, not when its posted completion runs. + * + * The ordering under test is the main looper's: the draw listener fires inside the traversal, an async message that runs ahead of the sync barrier; the completion is posted behind it; and a boot restore's swap message, posted earlier by the restore thread, runs in between. So a frame drawn against the baseline table can have its completion run after the restore has landed and cleared {@code bootRestoreInFlight}. A completion that read the flag when it ran would then record good a generation whose table never rendered, leaving a table that fails to render unblamable on the next boot. + * + * {@link QuickBuildRuntime#frameCompletion} is the seam: it takes the probe and must read it before returning. Moving the read into the returned runnable - the pre-fix shape, where {@code markLiveGenerationGood} read the field at completion time - turns the first test red. + */ +class QuickBuildRuntimeFrameCompletionTest { + + /** The frame after the restore's recreate is the one that vouches: drawn with the flag clear, it proves the resources. */ + @Test + void aFrameDrawnAfterTheRestoreLandedVouches() { + final Boolean[] verdict = new Boolean[1]; + + Runnable completion = QuickBuildRuntime.frameCompletion(new QuickBuildRuntime.BootRestoreProbe() { + + @Override + public boolean inFlight() { + return false; + } + }, new QuickBuildRuntime.FrameCompletion() { + + @Override + public void complete(boolean frameProvesResources) { + verdict[0] = frameProvesResources; + } + }); + completion.run(); + + assertThat(verdict[0]).isTrue(); + } + + /** A restore that lands between the draw and the completion must not turn a baseline frame into a vouching one. */ + @Test + void aRestoreLandingBetweenTheDrawAndTheCompletionDoesNotMakeTheFrameVouch() { + final boolean[] inFlight = {true}; + final Boolean[] verdict = new Boolean[1]; + + // The draw pass: the swap has not committed, so this frame drew the baseline table. + Runnable completion = QuickBuildRuntime.frameCompletion(new QuickBuildRuntime.BootRestoreProbe() { + + @Override + public boolean inFlight() { + return inFlight[0]; + } + }, new QuickBuildRuntime.FrameCompletion() { + + @Override + public void complete(boolean frameProvesResources) { + verdict[0] = frameProvesResources; + } + }); + // The swap message lands and onBootRestoreLanded clears the flag. + inFlight[0] = false; + // The posted completion runs last. + completion.run(); + + assertThat(verdict[0]).isFalse(); + } + + /** Nothing runs until the post lands: the completion is deferred, not executed on the draw pass, so measure, layout and draw failures still precede it. */ + @Test + void theCompletionRunsOnlyWhenPosted() { + final int[] completions = {0}; + + Runnable completion = QuickBuildRuntime.frameCompletion(new QuickBuildRuntime.BootRestoreProbe() { + + @Override + public boolean inFlight() { + return false; + } + }, new QuickBuildRuntime.FrameCompletion() { + + @Override + public void complete(boolean frameProvesResources) { + completions[0]++; + } + }); + + assertThat(completions[0]).isEqualTo(0); + completion.run(); + assertThat(completions[0]).isEqualTo(1); + } +} From d43bbbf753eb894695b97264509eb5b5551e87e5 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Sat, 5 Sep 2026 06:23:52 -0700 Subject: [PATCH 27/40] ADFA-4128: say what a failed boot restore actually leaves behind The catch in restoreBootResources claimed a failed restore leaves the process wholly on the baseline table. abandon() only refuses a swap still queued; a table swap that committed before applyAssets threw stays live, the app runs mixed, and onBootRestoreFailed already reports it as mixed. The comment now says that. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../androidide/quickbuild/runtime/QuickBuildRuntime.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 109cebc7cf..0055a567ae 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -1075,9 +1075,9 @@ public void onSwapFailed(Throwable error) { onBootRestoreLanded(generation); } } catch (Throwable error) { - // The same hazard as a deploy that throws after applyTable posted: the table - // swap is still queued and would commit over a merge that failed. Refuse it, so - // the process stays wholly on the baseline table rather than half on each. + // Same hazard as a deploy that throws after applyTable posted: a table swap still + // queued would commit over a merge that failed, so refuse it. A swap that already + // committed cannot be undone; the process then runs mixed, and the banner says so. ResourceStore.INSTANCE.abandon(generation); gate.failed(); onBootRestoreFailed(generation, error); From a82b4c8b4cf8bf1438e45b135fdff5f2ca08e1cf Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Sun, 6 Sep 2026 08:46:13 -0700 Subject: [PATCH 28/40] ADFA-4128: serialize the cumulative asset merge against a second binder dispatch Payloads arrive on a oneway binder callback, whose thread pool can dispatch two at once - the interleaving PayloadPersistenceAtomicSetTest already pins for the persist. extractCumulative merged into the one shared override dir with no lock, so two merges could race entry-for-entry, and the pending marker cannot recover that: the second merge clears it on the way out, leaving the dir holding two generations with nothing left to notice. The new test holds the extractor's monitor and asserts a concurrent merge cannot finish, the same deterministic shape persistSerialisesOnTheStoreMonitor uses. Without the synchronized keyword it fails with "a merge ran to completion while the extractor monitor was held / expected to be false". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/runtime/AssetExtractor.java | 4 +- .../runtime/AssetExtractorTest.java | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java index 9d43ac0f1e..fd530b9caa 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java @@ -97,6 +97,8 @@ static int extract(InputStream zipStream, File destDir) throws IOException { * * A merge that dies part-way is recovered at the START of the next call, not on the failure path: {@link #MERGE_PENDING_MARKER} is written before the first byte and cleared only after the last, and finding it still there clears the dir. A cleared dir is safe - the provider falls through to the APK's baked-in assets - whereas a half-merged one serves a file from the wrong generation. * + * Serialized on the class monitor because payloads arrive on a oneway binder callback, whose thread pool dispatches two calls at once - the same interleaving {@code PayloadPersistenceAtomicSetTest} pins for the persist. Two merges into the one shared dir would race entry-for-entry, and the pending marker cannot recover that: the second merge clears it on the way out, so the merged dir would be left holding two generations with nothing to notice. + * * @param zipStream * the changed-assets zip as it arrived over binder; read but never closed * @param assetsRoot @@ -107,7 +109,7 @@ static int extract(InputStream zipStream, File destDir) throws IOException { * @throws IOException * on I/O failure, a path-traversal entry, or a stale dir that cannot be cleared - serving it anyway would violate the never-stale invariant */ - static int extractCumulative(InputStream zipStream, File assetsRoot, + static synchronized int extractCumulative(InputStream zipStream, File assetsRoot, String baselineFingerprint) throws IOException { if (baselineFingerprint == null) { throw new IOException("no baseline fingerprint; cannot key the asset override dir"); diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java index 120c90e3bb..732087f8a5 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java @@ -1,6 +1,7 @@ package com.itsaky.androidide.quickbuild.runtime; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.ByteArrayInputStream; @@ -12,6 +13,9 @@ import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import org.junit.jupiter.api.Test; @@ -63,6 +67,48 @@ void aCompletedMergeClearsThePendingMarker() throws IOException { assertThat(readFile(new File(assetsDir, "added.txt"))).isEqualTo("from the second merge"); } + @Test + void aMergeSerialisesOnTheExtractorMonitor() throws Exception { + // Payloads arrive on a oneway binder callback, whose thread pool can dispatch two + // at once, and both merges land in the one shared dir. The pending marker cannot + // recover an interleaved pair - the second merge clears it on the way out - so the + // merges have to be serialized, which is only true while each takes this monitor. + final File root = tempDir.resolve("assets-root").toFile(); + Map entries = new LinkedHashMap(); + entries.put("a.txt", "merged".getBytes("UTF-8")); + final InputStream zip = zipOf(entries); + final CountDownLatch started = new CountDownLatch(1); + final CountDownLatch finished = new CountDownLatch(1); + final AtomicReference failure = new AtomicReference(); + Thread other = new Thread(new Runnable() { + + @Override + public void run() { + started.countDown(); + try { + AssetExtractor.extractCumulative(zip, root, "fp-1"); + } catch (Throwable error) { + failure.set(error); + } + finished.countDown(); + } + }); + // Or a merge that never returns outlives the test and holds the Gradle worker up. + other.setDaemon(true); + + synchronized (AssetExtractor.class) { + other.start(); + assertThat(started.await(10, TimeUnit.SECONDS)).isTrue(); + assertWithMessage("a merge ran to completion while the extractor monitor was held") + .that(finished.await(500, TimeUnit.MILLISECONDS)).isFalse(); + } + + assertThat(finished.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(failure.get()).isNull(); + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + assertThat(readFile(new File(assetsDir, "a.txt"))).isEqualTo("merged"); + } + @Test void aMergeThatDiedPartWayIsClearedAtTheStartOfTheNextOne() throws IOException { File root = tempDir.resolve("assets-root").toFile(); From 8547678ebd52846c5b8c91bbed803dddbbd17a84 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Sun, 6 Sep 2026 08:46:32 -0700 Subject: [PATCH 29/40] ADFA-4128: stop two runtime tests passing for a reason they do not test LegacyResourceSwapSweepTest.isBestEffortOverAnApkItCannotDelete assumed setWritable(false) denies deletion. A root worker unlinks regardless, sweeps the apk away, and fails the test for a reason it is not about. It now probes the capability at stake - a sacrificial file in the same locked directory - and skips when that deletes, rather than inferring privilege from a uid or from user.name, which is not tied to the effective uid at all. PayloadPersistenceAtomicSetTest.concurrentDeploysAlwaysLeaveOneWholeLoadable- Generation read failure.get() straight after a timed-out join, so a worker still inside persist could record its failure afterwards and the test would have passed over it. Asserting the threads are not alive first closes that, and making them daemons stops a hung persist outliving the Gradle worker. Both proven by mutation: inverting the sweep test's guard reports it skipped with "this worker deletes despite the directory mode", so the probe reads the real filesystem capability; holding the store monitor across the joins leaves the pre-fix test green with both workers still running, and red on "dex deploy thread did not finish" with the assertions in place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../runtime/LegacyResourceSwapSweepTest.java | 11 +++++++++++ .../runtime/PayloadPersistenceAtomicSetTest.java | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java index 4b5d1837b4..f997c7cbbb 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java @@ -1,6 +1,7 @@ package com.itsaky.androidide.quickbuild.runtime; import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import java.io.File; import java.io.IOException; @@ -46,14 +47,24 @@ void isBestEffortOverAnApkItCannotDelete() throws IOException { // Cache space is the only thing at stake, so an undeletable file must not stop the // sweep or the swap that follows it. write("gen-1.zip"); + write("probe.txt"); + File probe = new File(tempDir, "probe.txt"); assertThat(tempDir.setWritable(false)).isTrue(); try { + // A root worker unlinks regardless of the directory mode, which would sweep + // gen-1.zip away and fail this test for a reason it is not about. The effective + // uid is the wrong question and user.name is not even tied to it, so ask the + // filesystem for the capability at stake: a probe that deletes means this + // worker cannot be denied one, and there is no undeletable apk to test with. + assumeFalse(probe.delete(), "this worker deletes despite the directory mode"); + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(0); assertThat(new File(tempDir, "gen-1.zip").isFile()).isTrue(); } finally { // Or the temp-dir teardown inherits the problem. tempDir.setWritable(true); + probe.delete(); } } diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java index 49a5c9e85c..574326d960 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java @@ -1,6 +1,7 @@ package com.itsaky.androidide.quickbuild.runtime; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.ByteArrayInputStream; @@ -176,12 +177,22 @@ void concurrentDeploysAlwaysLeaveOneWholeLoadableGeneration() throws Exception { final AtomicReference failure = new AtomicReference(); Thread dexDeploys = new Thread(persister(store, failure, 2, 40, true)); Thread resourceDeploys = new Thread(persister(store, failure, 3, 41, false)); + // Or a persist that never returns keeps the Gradle test worker alive after the + // join gave up on it. + dexDeploys.setDaemon(true); + resourceDeploys.setDaemon(true); dexDeploys.start(); resourceDeploys.start(); dexDeploys.join(TimeUnit.SECONDS.toMillis(30)); resourceDeploys.join(TimeUnit.SECONDS.toMillis(30)); + // A join that timed out proves nothing: the worker can still record its failure + // afterwards, so reading failure.get() straight after the wait would pass over a + // deadlocked persist as though it had succeeded. + assertWithMessage("dex deploy thread did not finish").that(dexDeploys.isAlive()).isFalse(); + assertWithMessage("resource deploy thread did not finish").that(resourceDeploys.isAlive()) + .isFalse(); assertThat(failure.get()).isNull(); // load() discards the store and answers null the moment the published meta names // a file that is not there - which is exactly what an inheritance read From d5b6cf693d8510cd7a33dff4422cdddd75ffde59 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 16:37:43 -0700 Subject: [PATCH 30/40] ADFA-4128: refuse an abandoned generation's swaps by generation, not by watermark A cold start restores persisted gen 10 on qb-boot-restore while CoGo's catch-up gen 11 arrives and fails; abandon(11) as a high-water mark then refused gen 10's queued swap, and a refused swap reports committed, so the restore logged success and recreated the activity over the baseline table with no banner. Abandonment is now a set of generations, pruned as swaps commit since the overtaken rule already covers everything below the committed one. The dropped-swap log lines name the actual reason. Answers https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659449 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../quickbuild/runtime/ResourceStore.java | 73 +++++++++++++------ .../ResourceStoreAbandonedSwapTest.java | 33 +++++++-- 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index c4e7132e41..4f2715b5a7 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -13,7 +13,10 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; import java.util.List; +import java.util.Set; /** * Owns the payload's resource and asset overrides. @@ -106,15 +109,17 @@ private static void reportSwapFailure(SwapOutcome onOutcome, Throwable error) { private long swappedGeneration = -1; /** - * Newest generation whose deploy was abandoned, or -1 when none has been. + * Generations whose deploy was abandoned and whose swaps must therefore be refused. * * A swap is queued on the main thread and commits after the deploy method that queued it has returned, so a deploy that fails a later step - applyTable posts before applyAssets can throw - has its rollback run while its own table swap is still queued. Without this the abandoned generation's table commits over the dex the rollback just restored, and the screen renders a generation nothing else in the process believes is live. * * Refusing the commit is the whole remedy. Undoing one is not available: the store keeps single provider slots and closes the previous provider after each swap, and the API 28/29 path cannot unmount an added asset path at all. * + * A set rather than a high-water mark, because two generations can be in flight at once with the OLDER one still healthy: a cold start restores persisted gen 10 on its own thread while CoGo's catch-up gen 11 arrives and fails. A watermark at 11 refused gen 10's swap, and a refused swap reports committed, so the restore logged success over the baseline table. Entries below {@link #swappedGeneration} are dropped as swaps commit, since the overtaken rule already refuses them. + * * Written and read under the monitor, like {@link #swappedGeneration}. */ - private long abandonedGeneration = -1; + private final Set abandonedGenerations = new HashSet<>(); /** * @param strategy @@ -132,15 +137,13 @@ private ResourceStore() { /** * Records that a generation's deploy was abandoned, so any swap it has already queued is refused rather than committed. * - * Called by the runtime from both places that give up on a generation: a swap that failed, and a deploy step that threw after an earlier swap was already posted. + * Called by the runtime from every place that gives up on a generation: a swap that failed, a deploy step that threw after an earlier swap was already posted, and the boot restore's equivalents of both. * * @param generation - * the abandoned generation; an older one than the newest already abandoned is ignored + * the abandoned generation; only its own swaps are refused, never another generation's */ synchronized void abandon(long generation) { - if (generation > abandonedGeneration) { - abandonedGeneration = generation; - } + abandonedGenerations.add(generation); } /** @@ -261,6 +264,24 @@ void attachTo(Resources resources) { } } + /** + * Records a committed swap, and forgets abandoned generations the overtaken rule now covers. + * + * Package-private so the pruning is JVM-tested; the three swap bodies call it under the monitor. + * + * @param generation + * the generation whose swap just took + */ + synchronized void recordSwapped(long generation) { + swappedGeneration = generation; + Iterator abandoned = abandonedGenerations.iterator(); + while (abandoned.hasNext()) { + if (abandoned.next() < generation) { + abandoned.remove(); + } + } + } + /** * Whether a queued swap must be dropped instead of committed. * @@ -271,13 +292,13 @@ void attachTo(Resources resources) { * @return true when the swap must be dropped */ synchronized boolean refusesSwap(long generation) { - return generation < swappedGeneration || generation <= abandonedGeneration; + return generation < swappedGeneration || abandonedGenerations.contains(generation); } /** * The newest generation whose swap has committed, or -1 before the first. * - * For the failure path: a deploy that threw after its table swap had already committed has left that table live, and nothing can take it down again (see {@link #abandonedGeneration}). Read only after {@link #abandon} for the same generation, so a swap still queued at that point is refused rather than committing later and changing the answer. + * For the failure path: a deploy that threw after its table swap had already committed has left that table live, and nothing can take it down again (see {@link #abandonedGenerations}). Read only after {@link #abandon} for the same generation, so a swap still queued at that point is refused rather than committing later and changing the answer. * * @return the committed generation, which the failure path compares with the one that failed */ @@ -333,9 +354,8 @@ public void run() { // generation's label, and hand that apk to every later activity; mounting // an abandoned one would mount the table of a generation whose rollback // has already run. - RuntimeLog.w("dropping legacy table swap for gen " + generation - + "; gen " + swappedGeneration + " committed, gen " - + abandonedGeneration + " abandoned"); + RuntimeLog.w("dropping legacy table swap for gen " + generation + ": " + + refusalReason(generation)); return; } Resources appResources = appContext.getResources(); @@ -351,7 +371,7 @@ public void run() { } // Recorded only after the mount took, as in both loader swaps. legacyTableZip = zip; - swappedGeneration = generation; + recordSwapped(generation); LegacyResourceSwap.flushCaches(appResources); } } @@ -390,9 +410,8 @@ public void run() { // overtaken one would put the older table back under the newer generation's // label; installing an abandoned one would serve a table whose dex has // already been rolled back. - RuntimeLog.w("dropping table swap for gen " + generation + "; gen " - + swappedGeneration + " committed, gen " + abandonedGeneration - + " abandoned"); + RuntimeLog.w("dropping table swap for gen " + generation + ": " + + refusalReason(generation)); Streams.closeQuietly(next); return; } @@ -411,7 +430,7 @@ public void run() { } // Recorded only after the install took: a rejected swap leaves the previous set // live, so it must not block the next deploy from installing over it. - swappedGeneration = generation; + recordSwapped(generation); attachAppResources(appContext); Streams.closeQuietly(previous); } @@ -522,9 +541,8 @@ public void run() { // Overtaken or abandoned, same as the table swap: a newer generation's providers // are already installed and this pair would replace them with the older override // dir, or this generation's own deploy has already been rolled back. - RuntimeLog.w("dropping assets swap for gen " + generation + "; gen " - + swappedGeneration + " committed, gen " + abandonedGeneration - + " abandoned"); + RuntimeLog.w("dropping assets swap for gen " + generation + ": " + + refusalReason(generation)); Streams.closeQuietly(next); Streams.closeQuietly(nextDir); return; @@ -545,7 +563,7 @@ public void run() { throw error; } // Recorded only after the install took, as in the table swap. - swappedGeneration = generation; + recordSwapped(generation); attachAppResources(appContext); Streams.closeQuietly(previous); Streams.closeQuietly(previousDir); @@ -559,6 +577,19 @@ public void run() { } } + /** + * Names why {@link #refusesSwap} refused a generation, for the dropped-swap log lines. + * + * @param generation + * the refused generation + * @return the reason, in the form the three swap bodies append to their log line + */ + private synchronized String refusalReason(long generation) { + return abandonedGenerations.contains(generation) + ? "abandoned" + : "overtaken by gen " + swappedGeneration; + } + /** * Runs a provider swap on the main thread, inline when already there. * diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreAbandonedSwapTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreAbandonedSwapTest.java index 3d65f32d60..0786164393 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreAbandonedSwapTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreAbandonedSwapTest.java @@ -13,7 +13,22 @@ */ class ResourceStoreAbandonedSwapTest { - /** An out-of-order abandon must not lower the mark and let a refused swap through. */ + /** + * The regression: an older generation still in flight survives a newer one's failure. + * + * A cold start restores persisted gen 10 on its own thread while CoGo's catch-up gen 11 arrives and fails. Abandonment used to be a high-water mark, so gen 11's failure refused gen 10's queued swap; a refused swap reports committed, and the restore logged success over the baseline table. + */ + @Test + void abandoningANewerGenerationDoesNotRefuseAnOlderOnesQueuedSwap() { + ResourceStore store = new ResourceStore(ResourceSwapStrategy.RESOURCES_LOADER); + + store.abandon(11); + + assertThat(store.refusesSwap(10)).isFalse(); + assertThat(store.refusesSwap(11)).isTrue(); + } + + /** Abandoning a second generation must not release the first one's refusal. */ @Test void abandoningAnOlderGenerationDoesNotUndoANewerAbandon() { ResourceStore store = new ResourceStore(ResourceSwapStrategy.RESOURCES_LOADER); @@ -25,24 +40,28 @@ void abandoningAnOlderGenerationDoesNotUndoANewerAbandon() { assertThat(store.refusesSwap(10)).isFalse(); } - /** Abandoning an older generation does not retroactively refuse a newer one's swap. */ + /** A committed swap prunes the abandoned set below it, and the overtaken rule takes over the refusal. */ @Test - void aSwapForAGenerationNewerThanTheAbandonedOneStillCommits() { + void aCommittedSwapForgetsAbandonedGenerationsItOvertook() { ResourceStore store = new ResourceStore(ResourceSwapStrategy.RESOURCES_LOADER); - store.abandon(7); + store.abandon(5); + store.abandon(9); + store.recordSwapped(7); + assertThat(store.refusesSwap(5)).isTrue(); + assertThat(store.refusesSwap(9)).isTrue(); assertThat(store.refusesSwap(8)).isFalse(); } - /** A generation older than the abandoned one is abandoned too: its deploy cannot have outlived the newer one's failure. */ + /** Abandoning an older generation does not retroactively refuse a newer one's swap. */ @Test - void aSwapForAGenerationOlderThanTheAbandonedOneIsRefused() { + void aSwapForAGenerationNewerThanTheAbandonedOneStillCommits() { ResourceStore store = new ResourceStore(ResourceSwapStrategy.RESOURCES_LOADER); store.abandon(7); - assertThat(store.refusesSwap(6)).isTrue(); + assertThat(store.refusesSwap(8)).isFalse(); } /** The regression: after the deploy is abandoned, its own queued swap is refused. */ From fd058989d22342520b929e9193c127f72361854d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 16:37:44 -0700 Subject: [PATCH 31/40] ADFA-4128: sweep the API 28/29 apk cache from the first write, off the main thread onActivityCreated runs on the main thread inside the first activity's creation, and the sweep it called there is a readdir plus one unlink per apk the previous process wrote - the launch-path disk IO the boot restore was just moved off main to avoid. The sweep now lives in ResourceStore, under the lock every legacy write takes, and runs once before this process writes its first relinked apk: that is still ahead of the first mount, it runs on whichever off-main thread the write arrives on, and no latch is needed to keep a deploy's write from racing it. Answers https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659389 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../runtime/LegacyResourceSwap.java | 2 +- .../quickbuild/runtime/QuickBuildRuntime.java | 35 +-------- .../quickbuild/runtime/ResourceStore.java | 47 +++++++++++- .../ResourceStoreLegacyWriteSweepTest.java | 73 +++++++++++++++++++ 4 files changed, 124 insertions(+), 33 deletions(-) create mode 100644 quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreLegacyWriteSweepTest.java diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java index 75ef8f739e..a68b267f19 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java @@ -13,7 +13,7 @@ * * Persist the relinked apk, append it to the live AssetManager through the hidden addAssetPath, then flush the Resources caches so the deploy's activity recreate resolves against the new table. The new package shares the old package id and resource ids, and the last-added package wins the lookup. * - * Degraded by design relative to the API 30+ loader path: an added path can never be removed, so each generation appends one more package until the process restarts, and a Resources with its own AssetManager only picks the table up when {@link ResourceStore#attachTo} reaches it. {@link #deleteStaleApks} sweeps the directory at startup instead, since nothing a previous process mounted survives its death. + * Degraded by design relative to the API 30+ loader path: an added path can never be removed, so each generation appends one more package until the process restarts, and a Resources with its own AssetManager only picks the table up when {@link ResourceStore#attachTo} reaches it. {@link #deleteStaleApks} sweeps the directory before this process writes its first apk instead, since nothing a previous process mounted survives its death. */ final class LegacyResourceSwap { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 0055a567ae..a37a76c661 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -223,9 +223,6 @@ private static InputStream streamOf(ParcelFileDescriptor fd) { /** Uptime at which the pending reload's payload arrived, the start of the reported duration. */ private volatile long pendingReloadStartUptime; - /** Latches the legacy resource-apk cache sweep, which is only safe before the first swap. */ - private boolean sweptLegacyResourceCache; - /** Newest generation already recorded as good, so the write happens once rather than per resume. */ private volatile long lastMarkedGoodGeneration = -1; @@ -460,10 +457,10 @@ public void run() { * the activity being created, used only for its application context; every step is idempotent, so this runs safely on each activity */ void onActivityCreated(Activity activity) { - // First moment a usable Context exists; bind() is idempotent. - // The sweep runs before bind and before the boot resources apply, because it is - // only safe while this process has mounted no relinked apk of its own. - sweepLegacyResourceCache(activity.getApplicationContext()); + // First moment a usable Context exists; bind() is idempotent. This runs on the + // main thread inside the first activity's creation, so nothing here may touch + // disk: the API 28/29 apk-cache sweep lives in ResourceStore, ahead of the first + // write on whichever thread makes it. client.bind(activity.getApplicationContext()); PayloadStore.INSTANCE.attachPersistence(activity.getApplicationContext()); applyPendingBootResources(activity.getApplicationContext()); @@ -1112,30 +1109,6 @@ private void start() { installCrashGuard(); } - /** - * Deletes the relinked apks a previous process left in the API 28/29 resource cache, once. - * - * Those files can only be unmounted by the process dying, so the process that wrote them cannot clean them up and the cache would otherwise grow by one apk per deploy. Latched and run before this process mounts any of its own, since a mounted path deleted underneath the AssetManager cannot be recovered. - * - * @param context - * application context, for the cache directory - */ - private void sweepLegacyResourceCache(android.content.Context context) { - if (sweptLegacyResourceCache) { - return; - } - sweptLegacyResourceCache = true; - try { - int deleted = LegacyResourceSwap.deleteStaleApks( - new File(context.getCacheDir(), LegacyResourceSwap.TABLE_DIR)); - if (deleted > 0) { - RuntimeLog.i("swept " + deleted + " stale relinked apk(s) from a previous process"); - } - } catch (Throwable error) { - RuntimeLog.w("could not sweep the legacy resource cache", error); - } - } - /** Reads whether a boot resource restore is still in flight; the seam {@link #frameCompletion} samples on the draw pass. */ interface BootRestoreProbe { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java index 4f2715b5a7..51f9284b36 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -121,6 +121,16 @@ private static void reportSwapFailure(SwapOutcome onOutcome, Throwable error) { */ private final Set abandonedGenerations = new HashSet<>(); + /** + * Serializes the one-time sweep of the API 28/29 apk cache with every write into it. + * + * Its own lock rather than the store's monitor: the sweep and the write are disk IO, and the swap bodies take the monitor on the main thread. + */ + private final Object legacyCacheLock = new Object(); + + /** Whether this process has swept the API 28/29 apk cache; guarded by {@link #legacyCacheLock}. */ + private boolean sweptLegacyCache; + /** * @param strategy * the swap mechanism to use; injected so tests can drive each branch without an SDK level @@ -306,6 +316,41 @@ synchronized long swappedGeneration() { return swappedGeneration; } + /** + * Writes a relinked apk into the API 28/29 cache, sweeping the previous process's apks first. + * + * The sweep has to happen before this process mounts any apk of its own, since a mounted path deleted underneath the AssetManager cannot be recovered, and it has to stay off the main thread: it is a readdir plus one unlink per apk the previous process wrote, unbounded, on the low-end devices this path exists for. Doing it here, under the lock every write takes, gives both for free - the first writer sweeps, on whichever thread it arrived on, and no other write can slip in between the sweep and the write. + * + * Package-private so the once-before-first-write rule is JVM-tested. + * + * @param in + * the relinked apk bytes + * @param dir + * the cache directory + * @param generation + * the generation naming the file + * @return the written apk + * @throws IOException + * when the write fails; a failed sweep is logged and does not fail the write + */ + File writeLegacyApk(InputStream in, File dir, long generation) throws IOException { + synchronized (legacyCacheLock) { + if (!sweptLegacyCache) { + sweptLegacyCache = true; + try { + int deleted = LegacyResourceSwap.deleteStaleApks(dir); + if (deleted > 0) { + RuntimeLog.i("swept " + deleted + + " stale relinked apk(s) from a previous process"); + } + } catch (Throwable error) { + RuntimeLog.w("could not sweep the legacy resource cache", error); + } + } + return LegacyResourceSwap.writeResourceApk(in, dir, generation); + } + } + /** * API 28/29 swap: write the apk to disk, then addAssetPath it into the application AssetManager and flush caches on the main thread. * @@ -332,7 +377,7 @@ private void applyTableLegacy(ParcelFileDescriptor tableFd, final long generatio final File zip; try { File dir = new File(appContext.getCacheDir(), LEGACY_TABLE_DIR); - zip = LegacyResourceSwap.writeResourceApk(in, dir, generation); + zip = writeLegacyApk(in, dir, generation); } finally { try { in.close(); diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreLegacyWriteSweepTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreLegacyWriteSweepTest.java new file mode 100644 index 0000000000..376d21ec44 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceStoreLegacyWriteSweepTest.java @@ -0,0 +1,73 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Pins when the API 28/29 apk cache is swept: once per process, immediately before this process writes its first relinked apk, on the writer's own thread. + * + * The sweep used to run on the main thread inside the first activity's creation - a readdir plus one unlink per apk the previous process wrote, on every cold start, on the low-end devices the legacy path serves. It has to stay ahead of the first mount, because a mounted path deleted underneath the AssetManager cannot be recovered; running it from the first write gives that ordering without a second thread or a latch. + */ +class ResourceStoreLegacyWriteSweepTest { + + private static ByteArrayInputStream apk() { + return new ByteArrayInputStream("apk".getBytes(StandardCharsets.UTF_8)); + } + + @TempDir + File cacheDir; + + @Test + void anEmptyCacheStillTakesTheWrite() throws IOException { + ResourceStore store = new ResourceStore(ResourceSwapStrategy.LEGACY_ASSET_PATH); + + File written = store.writeLegacyApk(apk(), new File(cacheDir, "fresh"), 2); + + assertThat(written.isFile()).isTrue(); + } + + /** The regression the ordering guards against: an apk this process wrote is never swept, so its mount stays valid. */ + @Test + void laterWritesDoNotSweepThisProcessesOwnApks() throws IOException { + stale("gen-1.zip"); + ResourceStore store = new ResourceStore(ResourceSwapStrategy.LEGACY_ASSET_PATH); + + store.writeLegacyApk(apk(), cacheDir, 5); + store.writeLegacyApk(apk(), cacheDir, 6); + + assertThat(names()).containsExactly("gen-5.zip", "gen-6.zip"); + } + + @Test + void theFirstWriteSweepsThePreviousProcessesApks() throws IOException { + stale("gen-1.zip"); + stale("gen-2.zip"); + ResourceStore store = new ResourceStore(ResourceSwapStrategy.LEGACY_ASSET_PATH); + + File written = store.writeLegacyApk(apk(), cacheDir, 5); + + assertThat(written.isFile()).isTrue(); + assertThat(names()).containsExactly("gen-5.zip"); + } + + private List names() { + List names = new ArrayList<>(); + for (File entry : cacheDir.listFiles()) { + names.add(entry.getName()); + } + return names; + } + + private void stale(String name) throws IOException { + Files.write(new File(cacheDir, name).toPath(), "old".getBytes(StandardCharsets.UTF_8)); + } +} From bd86a62f549321e747f8f057ce9d62de29f2cae0 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 16:37:44 -0700 Subject: [PATCH 32/40] ADFA-4128: stash a persisted generation for boot restore only when it carries resources loadPersisted stashed every adopted generation as pending boot resources, so a dex-only one - the usual case, and what a restart deploy persists - started a restore thread that swapped nothing, reported itself landed and recreated the first activity for it on every cold start. Only a Loaded with an arsc or an assets file is pending now. Answers https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659396 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../runtime/PayloadPersistence.java | 11 +++++++++ .../quickbuild/runtime/PayloadStore.java | 4 +++- .../runtime/PersistedSelectionTest.java | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java index 7695ab4572..8173318653 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java @@ -793,6 +793,17 @@ static final class Loaded { this.arscFile = arscFile; this.assetsFile = assetsFile; } + + /** + * Whether this generation has anything for the boot-time resource restore to apply. + * + * A dex-only generation - the usual one, and the one a restart deploy persists - has nothing to swap, and treating it as pending used to start a restore thread that landed nothing and then recreated the first activity for it. + * + * @return true when a resource apk or an assets zip was persisted + */ + boolean hasResources() { + return arscFile != null || assetsFile != null; + } } /** The payload files currently in the store (post-persist view). */ diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java index bfb722238c..1e7a51b765 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java @@ -312,7 +312,9 @@ private void loadPersisted(ClassLoader apkLoader, long baselineGeneration) { ? current.classLoader : new InMemoryDexClassLoader(ByteBuffer.wrap(loaded.dex), apkLoader); current = new Payload(loaded.generation, loader); - pendingBootResources = loaded; + // Only a generation with something to swap is pending: a dex-only one has no + // restore to run, and running one anyway recreated the first activity for it. + pendingBootResources = loaded.hasResources() ? loaded : null; // The crash guard's only handle on a startup crash: this generation arrived from a // restart deploy, so nothing in this process is pending to pin the blame on. bootedPersistedGeneration = loaded.generation; diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java index 7e03f5dbde..dd7a41cf22 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java @@ -17,6 +17,29 @@ class PersistedSelectionTest { @TempDir File dir; + /** + * A dex-only generation has no boot restore to run. Stashing it as pending used to start a restore thread that swapped nothing and then recreated the first activity for it, on every cold start of a project that never deployed resources. + */ + @Test + void aDexOnlyGenerationHasNoResourcesToRestore() { + PayloadPersistence.Loaded loaded = new PayloadPersistence.Loaded(3, + "dex".getBytes(StandardCharsets.UTF_8), null, null); + + assertThat(loaded.hasResources()).isFalse(); + } + + @Test + void aGenerationWithATableOrAssetsHasResourcesToRestore() { + byte[] dex = "dex".getBytes(StandardCharsets.UTF_8); + + assertThat(new PayloadPersistence.Loaded(3, dex, new File(dir, "res.zip"), null) + .hasResources()).isTrue(); + assertThat(new PayloadPersistence.Loaded(3, dex, null, new File(dir, "assets.zip")) + .hasResources()).isTrue(); + assertThat(new PayloadPersistence.Loaded(3, null, new File(dir, "res.zip"), null) + .hasResources()).isTrue(); + } + @Test void anEmptyStoreBootsTheBakedBaseline() { assertThat(PersistedSelection.selectPersisted(8, store(), fingerprint())).isNull(); From 3b485bcb498fc931200c473db8a40dc688bfade3 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 16:37:44 -0700 Subject: [PATCH 33/40] ADFA-4128: give the boot restore's failure listener the deploy path's two guards A persisted generation carrying a table and assets lands in two swaps. When the first failed, the listener reported the mixed state but never abandoned the generation, so the second swap committed anyway; and when both failed, onBootRestoreFailed ran twice - two banners, two crash reports for one boot. The listener now abandons the generation and reports only the first failure, which SwapAckGate.failed() reports by settling the gate exactly once. The deploy path's listener takes the same first-failure guard. Answers https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659456 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../quickbuild/runtime/QuickBuildRuntime.java | 17 ++++++++++++-- .../quickbuild/runtime/SwapAckGate.java | 8 ++++++- .../quickbuild/runtime/SwapAckGateTest.java | 22 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index a37a76c661..636e65322f 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -387,7 +387,10 @@ public void onSwapFailed(Throwable error) { // undoes it is still on another thread. Marking rather than removing // the callback also covers the inline swap, which fails before the // recreate has been posted at all. - ackGate.failed(); + if (!ackGate.failed()) { + // Both posts refused by a quitting looper: one rollback, not two. + return; + } abandonedReloadGeneration = generation; // A deploy carrying both payloads has a second swap that may still be // queued behind this one; committing it would serve this generation's @@ -1053,7 +1056,17 @@ public void onSwapCommitted() { @Override public void onSwapFailed(Throwable error) { - gate.failed(); + // Same two guards as the deploy path's listener. A persisted generation + // carrying both a table and assets has a second swap queued behind the one + // that failed; without the abandon it commits anyway, and the process runs + // this generation's assets over the baseline table while the banner says + // only the table is missing. Reporting only the first failure keeps a + // second one - both posts refused by a quitting looper - from raising a + // second banner and a second crash report for one boot. + if (!gate.failed()) { + return; + } + ResourceStore.INSTANCE.abandon(generation); onBootRestoreFailed(generation, error); } }; diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java index ea60762e6a..c3904987c3 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGate.java @@ -46,9 +46,15 @@ synchronized boolean committed() { * Cancels the ack for good. * * A swap that failed is reported by the failure path instead, which rolls the store back and names the generation to CoGo. A second swap of the same deploy committing afterwards must not turn that into a success. + * + * @return true when this call settled the gate, so the caller owns the one failure report; false when it was already settled, by an earlier failure or by the ack, and a second report would double up */ - synchronized void failed() { + synchronized boolean failed() { + if (settled) { + return false; + } settled = true; + return true; } /** diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGateTest.java index 494095cafb..e2e48f4bd6 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGateTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/SwapAckGateTest.java @@ -47,6 +47,16 @@ void aFailedSwapNeverAcks() { assertThat(gate.noSwapPosted()).isFalse(); } + /** A failure after the ack was already owed changes nothing; the ack stands. */ + @Test + void aFailureAfterTheAckDoesNotOwnAReport() { + SwapAckGate gate = new SwapAckGate(1); + + assertThat(gate.committed()).isTrue(); + + assertThat(gate.failed()).isFalse(); + } + /** One swap of a pair failing cancels the ack, even though the other one lands. */ @Test void aFailureCancelsTheAckWhenTheOtherSwapStillCommits() { @@ -76,6 +86,18 @@ void bothSwapsMustCommitBeforeTheAckIsOwed() { assertThat(gate.committed()).isTrue(); } + /** + * The failure report is owed once too: a deploy carrying a table and assets whose two posts are both refused by a quitting looper fails twice, and the second must not raise a second banner and crash report. + */ + @Test + void onlyTheFirstFailureOwnsTheReport() { + SwapAckGate gate = new SwapAckGate(2); + + assertThat(gate.failed()).isTrue(); + assertThat(gate.failed()).isFalse(); + assertThat(gate.committed()).isFalse(); + } + /** The ack is owed once: a late or duplicated commit finds the gate settled. */ @Test void theAckIsOwedOnlyOnce() { From 0593f66328fd733c4bd104446a197b4607dd54c3 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:04:45 -0700 Subject: [PATCH 34/40] ADFA-4128: report a payload that fails before acceptance instead of rolling back handlePayload's catch passed the pre-apply snapshot to failReload, but that snapshot is still null for a failure before the acceptance check - a dex read that throws, a malformed metadata document. failReload then restored null whenever the live generation equalled the failed one, so a replayed generation whose read failed went inert and quarantined the generation the app was running. A pre-acceptance failure now takes a report-only path: banner and crash report, no restore, no quarantine. Answers https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659404 No JVM test: handlePayload needs ParcelFileDescriptor and SystemClock. Reasoned from the code, not run on a device. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../quickbuild/runtime/QuickBuildRuntime.java | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 636e65322f..8e2607b099 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -307,6 +307,10 @@ void handlePayload(long generation, ParcelFileDescriptor dexPayload, String metadataJson) { long startUptime = SystemClock.uptimeMillis(); PayloadStore.Payload previous = null; + // False until the acceptance check below passes. A failure before it - a dex read + // that throws, a malformed metadata document - has adopted nothing and posted no + // swap, so it owes a report and nothing else; see the catch. + boolean accepted = false; InputStream arscIn = null; InputStream assetsIn = null; try { @@ -330,6 +334,7 @@ void handlePayload(long generation, ParcelFileDescriptor dexPayload, + (previous == null ? "no baseline" : "gen " + previous.generation) + ")"); return; } + accepted = true; if (metadata.restart && dexBytes == null) { // Without a dex, the relaunch would boot old classes under a new // generation label. A CoGo bug if it ever happens. @@ -430,6 +435,17 @@ public void run() { RuntimeLog.w("dropping payload gen " + generation + ": " + overtaken.getMessage()); } catch (Throwable error) { RuntimeLog.e("payload gen " + generation + " failed to apply", error); + Streams.closeQuietly(dexPayload); + Streams.closeQuietly(resourcesPayload); + Streams.closeQuietly(assetsPayload); + if (!accepted) { + // `previous` is still null here, and failReload would restore that null + // whenever the store's live generation equals this one - a replayed + // generation whose dex read failed went inert and quarantined the live + // generation. Nothing was adopted or posted, so there is nothing to undo. + reportUnadoptedFailure(generation, error); + return; + } // A step that already ran may have queued a swap that will still commit on main: // applyTable posts before applyAssets can throw. Nothing cancels that swap, so // the recreate must not run - it would render this generation's table over the @@ -441,9 +457,6 @@ public void run() { // The posted swap is refused rather than committed: the store cannot undo a // swap that took, so the only place to stop it is before it commits. ResourceStore.INSTANCE.abandon(generation); - Streams.closeQuietly(dexPayload); - Streams.closeQuietly(resourcesPayload); - Streams.closeQuietly(assetsPayload); failReload(generation, previous, error); } finally { // Also covers the early returns: an overtaken or restart deploy leaves here @@ -782,6 +795,21 @@ private void failReloadNow(long generation, PayloadStore.Payload rollback, Throw mixed ? CrashSummary.forMixedReport(error) : CrashSummary.forReport(error)); } + /** + * Reports a payload that failed before {@link #handlePayload}'s acceptance check, where the store was never consulted and nothing was posted. + * + * Deliberately not {@link #failReload}: that path restores the pre-apply snapshot whenever the store's live generation equals the failed one, and before the acceptance check the snapshot has not been taken - so a replayed generation whose dex read failed restored null and went inert, quarantining the generation the app was happily running. The report and banner still fire, or the host's only signal is its deploy timeout. Safe on the binder thread: the report is oneway and the banner re-posts to main. + * + * @param generation + * the generation that failed, which CoGo marks bad + * @param error + * the failure, summarized into both the report and the banner + */ + private void reportUnadoptedFailure(long generation, Throwable error) { + setOverlayState(OverlayState.crashed()); + client.reportCrash(generation, CrashSummary.forReport(error)); + } + /** * Chains a handler that quarantines and reports the generation a crash belongs to, before the app dies. * From 44c598970a11ebcde085b49f3a51b3c95efca97a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:04:45 -0700 Subject: [PATCH 35/40] ADFA-4128: keep unbind and rebind outside the client monitor, and drop the host on a null proxy abandonHandshake was synchronized as a whole, so unbindService - a synchronous binder transaction - ran under the monitor the framework's main-thread callbacks take, which is the stall dropHost's KDoc says the design avoids. Only the host test and the null write need the monitor. onServiceConnected's null-proxy branch left host set while it unbound and scheduled a rebind, and the rebind runnable returns early while a host is set; the other failure paths drop the host first, so this one does too. Answers https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659416 and https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659441 Not run on a device; both are reasoned from the code. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../quickbuild/runtime/QuickBuildClient.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index 8610133031..b39b3df941 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -44,7 +44,7 @@ final class QuickBuildClient implements ServiceConnection { /** * The live host proxy, or null while disconnected. * - * Volatile for the binder-thread reads in the report methods; every WRITE takes the monitor, so that {@link #abandonHandshake}'s test-and-teardown is one step against the four framework callbacks as well as against the other synchronized methods. + * Volatile for the binder-thread reads in the report methods; every WRITE takes the monitor, so that {@link #abandonHandshake}'s test-and-null-write is one step against the four framework callbacks as well as against the other synchronized methods. */ private volatile IQuickBuildHost host; @@ -151,7 +151,10 @@ public void onServiceConnected(ComponentName name, IBinder service) { // here. bindService against a connection the framework still holds a live // binding for is answered from the existing record rather than by a fresh // connect, so a rebind stacked on top of one we never released can be dropped - // on the floor - and this branch has no other way back. + // on the floor - and this branch has no other way back. The host is dropped + // first, like the other failure paths: the rebind runnable returns early while + // a host is set, so a stale one left here would strand the rebind. + dropHost(); unbindQuietly(); scheduleRebind(); return; @@ -248,17 +251,19 @@ void reportReloaded(long generation, long reloadMillis) { * * The handshake runs on its own thread, so a slow one outlives its binding: CoGo's service dies, {@link #onServiceDisconnected} nulls the host, the framework reconnects, and a second handshake succeeds against a new proxy. Unguarded, the first thread's failure then nulls that live host, unbinds a healthy channel and schedules a rebind - and until the rebind lands every {@code reportReloaded} and {@code reportCrash} only logs "not connected", so each deploy in the window can end only in the host's own timeout. * - * Under the monitor, because the test and the teardown have to be one step: {@code host} is written from the framework's callback thread as well as from here, and those writes take the monitor too ({@link #dropHost}, {@link #onServiceConnected}). Without that the exclusion held only against other callers of the synchronized methods, and a disconnect-then-reconnect on the main thread could still slip between this method's read of {@code host} and its write. + * The test and the null write are one step under the monitor: {@code host} is written from the framework's callback thread as well as from here, and those writes take the monitor too ({@link #dropHost}, {@link #onServiceConnected}). Without that the exclusion held only against other callers of the synchronized methods, and a disconnect-then-reconnect on the main thread could still slip between this method's read of {@code host} and its write. The unbind and the rebind run outside it, for the reason {@link #dropHost} gives: unbindService is a synchronous binder transaction, and holding the monitor across it would stall the main-thread callbacks behind this handshake thread. * * @param connected * the proxy whose handshake failed */ - private synchronized void abandonHandshake(IQuickBuildHost connected) { - if (host != connected) { - RuntimeLog.w("stale handshake failure; a newer binding is live, leaving it alone"); - return; + private void abandonHandshake(IQuickBuildHost connected) { + synchronized (this) { + if (host != connected) { + RuntimeLog.w("stale handshake failure; a newer binding is live, leaving it alone"); + return; + } + host = null; } - host = null; unbindQuietly(); scheduleRebind(); } From fd6feac9e822b319dff9d8784de9bae82f607d69 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:04:45 -0700 Subject: [PATCH 36/40] ADFA-4128: make the status banner a polite live region The banner is the only in-app trace of a failed deploy and takes no focus, so a screen reader never announced it. Answers https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659423 Not yet checked with TalkBack on a device. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../itsaky/androidide/quickbuild/runtime/StatusOverlay.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java index e1054a653b..da490ff867 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java @@ -170,6 +170,10 @@ private void applyStatusBarInset(ViewGroup decor, TextView banner) { private TextView createBanner(Activity activity) { TextView banner = new TextView(activity); banner.setTag(VIEW_TAG); + // The banner is the only in-app trace of a failed deploy, and it is drawn over the + // user's own screen with no focus of its own; without a live region a screen reader + // never announces it. + banner.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); banner.setTextColor(Color.WHITE); banner.setTextSize(12f); // The text is sp, so it grows with the system font scale: measured on an A56, the From af5407c70d6410c1688cc834ecfbe853623b6ff5 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:04:45 -0700 Subject: [PATCH 37/40] ADFA-4128: clear a persisted payload the stamped baseline rejects The store is keyed on the baseline dex alone, so a dex-identical rebaseline left the superseded epoch's files on disk and the next deploy's persist inherited that epoch's meta as its own history. selectPersisted now clears the store when it rejects a payload. Answers https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659444 PersistedSelectionTest.aRejectedPersistedPayloadIsClearedFromDisk fails without the fix: "value of: load(...) expected: null but was: PayloadPersistence$Loaded@226b143b". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../quickbuild/runtime/PersistedSelection.java | 9 ++++++++- .../quickbuild/runtime/PersistedSelectionTest.java | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java index ec8cbc63a0..447772c2c6 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java @@ -21,7 +21,14 @@ final class PersistedSelection { static PayloadPersistence.Loaded selectPersisted(long stampedBaselineGeneration, PayloadPersistence store, String baselineFingerprint) { PayloadPersistence.Loaded loaded = store.load(baselineFingerprint); - if (loaded == null || !Generations.accepts(stampedBaselineGeneration, loaded.generation)) { + if (loaded == null) { + return null; + } + if (!Generations.accepts(stampedBaselineGeneration, loaded.generation)) { + // Cleared, not merely skipped: the store is keyed on the baseline dex alone, so + // a dex-identical rebaseline keeps the superseded epoch's files on disk, and the + // next deploy's persist would inherit that epoch's meta as its own history. + store.clear(); return null; } return loaded; diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java index dd7a41cf22..a319cdcc9b 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java @@ -85,6 +85,20 @@ void aStampedRebaselineRejectsThePreviousEpochsPersistedPayload() throws Excepti assertThat(PersistedSelection.selectPersisted(8, store, fingerprint())).isNull(); } + @Test + void aRejectedPersistedPayloadIsClearedFromDisk() throws Exception { + // Skipping the superseded epoch's files is not enough: the store is keyed on the + // baseline dex alone, so the next persist would read them as its own history and + // inherit the old epoch's meta. + PayloadPersistence store = store(); + persist(store, 7); + + PersistedSelection.selectPersisted(8, store, fingerprint()); + + assertThat(store.load(fingerprint())).isNull(); + assertThat(store.dir().exists()).isFalse(); + } + @Test void aStoreKeyedToAnotherBaselineIsNotAdopted() throws Exception { PayloadPersistence store = store(); From 36974f7c8870cf7714958d7dc098c33d1f64ceae Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:04:45 -0700 Subject: [PATCH 38/40] ADFA-4128: pass the stamp read failure to the logger Answers https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659427 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../androidide/quickbuild/runtime/BaselineGeneration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java index a03d586bc1..52c4b3caec 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java @@ -45,7 +45,7 @@ static long read(InputStream in) { try { return parse(new String(Streams.readFully(in), "UTF-8")); } catch (Throwable error) { - RuntimeLog.w("unreadable baseline-generation stamp: " + error); + RuntimeLog.w("unreadable baseline-generation stamp", error); return UNSTAMPED; } finally { Streams.closeQuietly(in); From a25bf87fdb2001fff40d93021f1610d90a6ab2a6 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:04:45 -0700 Subject: [PATCH 39/40] ADFA-4128: round 5 doc and comment fixes on quickbuild/runtime - FirstFrameGate: cite ADFA-5524 for the unreleased-recreate case. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659409 - PayloadStore.restore: documented as the test seam it is; production rollback goes through restoreIfCurrent. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659432 - AssetExtractor.writeFile: temp-deletion note in the description, remaining and the return documented. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659435 - ActivityTracker.onActivityCreated: the first activity attaches nothing; the boot restore's recreate delivers the restored table. https://github.com/appdevforall/CodeOnTheGo/pull/1716#discussion_r3951659461 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../androidide/quickbuild/runtime/ActivityTracker.java | 2 +- .../androidide/quickbuild/runtime/AssetExtractor.java | 8 ++++++-- .../androidide/quickbuild/runtime/FirstFrameGate.java | 2 +- .../androidide/quickbuild/runtime/PayloadStore.java | 6 ++---- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java index f9b523f829..d72100a28d 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java @@ -39,7 +39,7 @@ final class ActivityTracker implements Application.ActivityLifecycleCallbacks { /** * Records the activity, lets the runtime do its first-activity Context work, then attaches swapped resources. * - * The runtime call comes first because it is what creates the resource loader when a cold start adopts a persisted generation; attaching before it would be a no-op, leaving this activity resolving against the baseline table for its whole lifetime. + * The runtime call comes first so the boot restore is started before anything inflates. The first activity still attaches nothing: that restore runs on its own thread and the resource loader is created by the swap it posts, after this callback has returned - so {@code attachTo} finds no loader here, and {@code QuickBuildRuntime.onBootRestoreLanded}'s recreate is what delivers the restored table to this activity. Every later activity attaches the live loader on creation. * * @param activity * the activity being created diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java index fd530b9caa..5f6dea3b21 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java @@ -176,11 +176,15 @@ private static String readMarker(File marker) { /** * Writes to a temp file and renames, so a failure mid-copy never leaves a half-written asset. * + * The temp is deleted on every failure, not only a failed rename: it sits in the tree {@link DirectoryAssetsProvider} serves, so a partial file left there is one the app can open by name. + * * @param in * the current zip entry's bytes; read to the end of the entry, never closed * @param target - * the final path, already checked to sit inside the destination directory The temp is deleted on every failure, not only a failed rename: it sits in the tree {@link DirectoryAssetsProvider} serves, so a partial file left there is one the app can open by name. - * + * the final path, already checked to sit inside the destination directory + * @param remaining + * the cumulative byte budget left for the whole zip; exceeding it fails the copy + * @return the bytes written, for the caller to subtract from {@code remaining} * @throws IOException * when a parent directory cannot be created, the copy fails, or the rename into place fails twice */ diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java index 64d0c7c5ed..3e6cce839b 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/FirstFrameGate.java @@ -9,7 +9,7 @@ * * A generation that never draws is released by the runtime's own fallbacks instead: an activity with no live view tree, and the branch where the resumed activity is gone by the time the recreate runs, both complete without a frame. That is the deliberate looser case - waiting for a frame that will never arrive would strand the deploy unacked. * - * One case is deliberately not released: a recreate that succeeds but never resumes, because the user backgrounded the app mid-relaunch and the task was then swiped away. No draw callback is ever installed, so the deploy ends only in CoGo's timeout, and until the next save {@link #pending} still names this generation, so an unrelated crash in the process would be reported against it. Releasing from onActivityDestroyed is not the fix, since recreate() itself destroys the armed activity on every normal reload; a destroy-based release would need to know a relaunch is still pending, which nothing here tracks yet. + * One case is deliberately not released: a recreate that succeeds but never resumes, because the user backgrounded the app mid-relaunch and the task was then swiped away. No draw callback is ever installed, so the deploy ends only in CoGo's timeout, and until the next save {@link #pending} still names this generation, so an unrelated crash in the process would be reported against it. Releasing from onActivityDestroyed is not the fix, since recreate() itself destroys the armed activity on every normal reload; a destroy-based release would need to know a relaunch is still pending, which nothing here tracks yet. Tracked as ADFA-5524. */ final class FirstFrameGate { diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java index 1e7a51b765..999299c3b7 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java @@ -231,12 +231,10 @@ PayloadPersistence persistence() { } /** - * Rolls back to a {@link #snapshot} after a failed reload. - * - * The app then visibly runs the old generation, and the host hears about it via reportCrash, rather than claiming a generation whose classes never rendered. + * Test seam: seeds the store with a payload without a real dex. Production rollback goes through {@link #restoreIfCurrent}, which decides and restores under one lock. * * @param payload - * the value {@link #snapshot} returned before the failed apply; restored verbatim, null included + * the payload to make live; stored verbatim, null included */ synchronized void restore(Payload payload) { current = payload; From e17b8bcf50b1a8f6eb65af72a039a359a2f1d1ca Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:45:09 -0700 Subject: [PATCH 40/40] style: spotless reformat, no functional change Eclipse member ordering for the two Java files added in the round 5 fixes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../quickbuild/runtime/QuickBuildRuntime.java | 30 +++++++++---------- .../runtime/PersistedSelectionTest.java | 20 ++++++------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java index 8e2607b099..e796a335f6 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -795,21 +795,6 @@ private void failReloadNow(long generation, PayloadStore.Payload rollback, Throw mixed ? CrashSummary.forMixedReport(error) : CrashSummary.forReport(error)); } - /** - * Reports a payload that failed before {@link #handlePayload}'s acceptance check, where the store was never consulted and nothing was posted. - * - * Deliberately not {@link #failReload}: that path restores the pre-apply snapshot whenever the store's live generation equals the failed one, and before the acceptance check the snapshot has not been taken - so a replayed generation whose dex read failed restored null and went inert, quarantining the generation the app was happily running. The report and banner still fire, or the host's only signal is its deploy timeout. Safe on the binder thread: the report is oneway and the banner re-posts to main. - * - * @param generation - * the generation that failed, which CoGo marks bad - * @param error - * the failure, summarized into both the report and the banner - */ - private void reportUnadoptedFailure(long generation, Throwable error) { - setOverlayState(OverlayState.crashed()); - client.reportCrash(generation, CrashSummary.forReport(error)); - } - /** * Chains a handler that quarantines and reports the generation a crash belongs to, before the app dies. * @@ -1058,6 +1043,21 @@ private void reloadOnMain(long generation, PayloadStore.Payload rollback) { } } + /** + * Reports a payload that failed before {@link #handlePayload}'s acceptance check, where the store was never consulted and nothing was posted. + * + * Deliberately not {@link #failReload}: that path restores the pre-apply snapshot whenever the store's live generation equals the failed one, and before the acceptance check the snapshot has not been taken - so a replayed generation whose dex read failed restored null and went inert, quarantining the generation the app was happily running. The report and banner still fire, or the host's only signal is its deploy timeout. Safe on the binder thread: the report is oneway and the banner re-posts to main. + * + * @param generation + * the generation that failed, which CoGo marks bad + * @param error + * the failure, summarized into both the report and the banner + */ + private void reportUnadoptedFailure(long generation, Throwable error) { + setOverlayState(OverlayState.crashed()); + client.reportCrash(generation, CrashSummary.forReport(error)); + } + /** * The {@link #applyPendingBootResources} body: extracts, posts the swaps, and settles the restore on their outcome. * diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java index a319cdcc9b..c43fcf282f 100644 --- a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java @@ -75,16 +75,6 @@ void aPersistedPayloadEqualToTheStampIsARejectedReplay() throws Exception { assertThat(PersistedSelection.selectPersisted(8, store, fingerprint())).isNull(); } - @Test - void aStampedRebaselineRejectsThePreviousEpochsPersistedPayload() throws Exception { - // A manifest-only rebaseline leaves the baseline dex byte-identical, so the - // fingerprint matches; only the stamp says gen 7 is from the superseded epoch. - PayloadPersistence store = store(); - persist(store, 7); - - assertThat(PersistedSelection.selectPersisted(8, store, fingerprint())).isNull(); - } - @Test void aRejectedPersistedPayloadIsClearedFromDisk() throws Exception { // Skipping the superseded epoch's files is not enough: the store is keyed on the @@ -99,6 +89,16 @@ void aRejectedPersistedPayloadIsClearedFromDisk() throws Exception { assertThat(store.dir().exists()).isFalse(); } + @Test + void aStampedRebaselineRejectsThePreviousEpochsPersistedPayload() throws Exception { + // A manifest-only rebaseline leaves the baseline dex byte-identical, so the + // fingerprint matches; only the stamp says gen 7 is from the superseded epoch. + PayloadPersistence store = store(); + persist(store, 7); + + assertThat(PersistedSelection.selectPersisted(8, store, fingerprint())).isNull(); + } + @Test void aStoreKeyedToAnotherBaselineIsNotAdopted() throws Exception { PayloadPersistence store = store();