diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md index 52e9fa36f4e..650270712b9 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md @@ -1894,11 +1894,12 @@ and diff hygiene pass. A fresh production RNW build remains 49 assets, 9,281,663 raw bytes, and 2,684,764 gzip bytes. The native generated-mutation and concurrency slice passes 1,152 generated -rejections and 72 concurrent cache flows per platform. The complete iOS -fault executable and all 37 Android module tests pass. Android lint reports zero -errors after replacing API-26-only base64 calls and guarding optional WebView -features; Swift format lint, mobile lint/typecheck/format, max-lines, and diff -hygiene also pass. +rejections and 120 concurrent cache flows per platform. The expanded matrix +adds competing same-host distinct generations, live-session activation and +cleanup, and interleaved commit/abort mutations. The complete iOS fault +executable and Android module tests pass. Android lint reports zero errors after +replacing API-26-only base64 calls and guarding optional WebView features; Swift +format lint, mobile lint/typecheck/format, max-lines, and diff hygiene also pass. The bridge mutation and lifecycle slice rejects ambiguous raw JSON before schema traversal, runs 2,016 generated operation payload/size cases across all @@ -2815,4 +2816,5 @@ package verification pass. The rebuilt package is | 2026-07-28 | Complete | Every hosted mutation reauthorizes its exact workspace, repository, task target, native-chat session, or Agent History session immediately before its privileged write. Three deterministic race suites revoke authority during awaited preflight across file, Markdown, Source Control, provider review, task, native-chat, workspace creation, browser, and Agent History operations. Privileged state is keyed by the paired Desktop public key. | | 2026-07-28 | Complete | A 25-case schema-valid semantic response corpus rejects cross-workspace/tab/repository/target/host/scope/configuration/cursor results and per-request collection overruns. Session events are workspace-bound. All 18 mobile-web files / 90 tests, 10 focused mobile files / 48 tests, the full 576-file mobile suite / 3,440 tests with 2 expected skips, all typechecks/lints/55 reliability gates, max-lines, localization, and package verification pass. Build `bcd9c95e3a1e416d82a240ff10ef311375d03cbc3210d44876c7bca896e093b7` contains 50 assets, 9,299,540 raw bytes, and 2,691,263 gzip bytes. | | 2026-07-28 | Finding | The full root run passes 3,829 files / 40,205 tests with 70 expected skips but the unrelated `ProjectViewWrapper` dynamic-import boundary again reaches its 30-second timeout under full-suite load. Its isolated rerun passes 2/2 in 4.69 seconds. | -| 2026-07-28 | Next | Run exact release-app corpus injection, concurrent cache mutation, privacy audit, broader live two-host/topology races, and independent security review before the remaining physical-device, packaged-release, rollback, and App Store gates. | +| 2026-07-28 | Complete | Mirrored Swift and Kotlin package-store suites now pass 120 concurrent cache flows per platform. The added same-host matrix covers 16 competing distinct-generation commits, 16 live-session activation/cleanup flows with post-activation reads, and 16 interleaved commit/abort mutations; final activation retains at most active plus previous, and host removal leaves no cache subtree. | +| 2026-07-28 | Next | Run exact release-app corpus injection, privacy audit, broader live two-host/topology races, and independent security review before the remaining physical-device, packaged-release, rollback, and App Store gates. | diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index 4c4fe999919..dfa2a149cf5 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -164,12 +164,13 @@ prototype: malformed metadata, incomplete/corrupt generations, wrong-host opens, and corruption discovered after a session has opened with bounded errors. - Mirrored native generated tests reject 1,152 malformed manifests, chunks, - offsets, paths, and SHA-256 tokens per platform. Both stores pass 24 - concurrent distinct-host package lifecycles, 24 duplicate-generation commits, - and 24 same-host open/read/activate/close flows. Android package code remains - compatible with minSdk 24 by using the API-8 platform base64 codec, and - optional WebView message/document-start features fail closed behind explicit - availability checks. + offsets, paths, and SHA-256 tokens per platform. Both stores pass 120 + concurrent cache flows: distinct-host lifecycles, duplicate commits, + competing same-host generations, live-session activation and cleanup, and + interleaved commit/abort mutations. Android package code remains compatible + with minSdk 24 by using the API-8 platform base64 codec, and optional WebView + message/document-start features fail closed behind explicit availability + checks. - The native shell negotiates only the named operations in the production grant registry across workspace, session, terminal, file, source-control, review, task, account, browser, speech, native-chat, navigation, and narrow diff --git a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md index 51db19f04f3..b8ff1fe20d4 100644 --- a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md +++ b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md @@ -322,9 +322,13 @@ verification pass. The full root run passes 3,829 files / 40,205 tests with 70 expected skips except for one unrelated 30-second dynamic-import timeout under load; its isolated 2-test rerun passes in 4.69 seconds. -The remaining security work below is exact release-app corpus testing, -concurrent cache mutation, broader live cross-scope races, privacy audit, and -independent review. +The mirrored native package-store suites now pass 120 concurrent cache flows per +platform. The expanded same-host matrix covers competing distinct generations, +live-session activation and cleanup with post-activation reads, interleaved +commit/abort mutations, bounded final retention, and host removal. + +The remaining security work below is exact release-app corpus testing, broader +live cross-scope races, privacy audit, and independent review. ## 1. Production Cutover and Cleanup @@ -383,8 +387,10 @@ independent review. and an oversized request before authority access. The exported result and subscription event schemas reject a generated valid-envelope payload corpus and retire invalid events. Pending subscription - cancellation/client replacement/disposal races pass. A fresh exact-app - rerun, concurrent cache mutation, and the other listed boundaries remain. + cancellation/client replacement/disposal races pass. Mirrored Swift and + Kotlin stores pass 120 concurrent cache flows, including same-host + generation, activation/cleanup, commit/abort, and removal mutations. A + fresh exact-app rerun and the other listed boundaries remain. The page-side semantic corpus rejects 25 schema-valid cross-operation identity, action, cursor, and request-specific-limit mutations. - [ ] Attempt cross-host, cross-build, cross-workspace, cross-session, replay, diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreConcurrencyTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreConcurrencyTest.kt index 6adcc4d777a..06c49e1daf9 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreConcurrencyTest.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreConcurrencyTest.kt @@ -63,6 +63,80 @@ class MobileWebPackageStoreConcurrencyTest { assertFalse(staging.listFiles()?.isNotEmpty() == true) } + @Test + fun preservesCompetingLiveGenerationsDuringConcurrentActivation() { + val root = temporary.newFolder() + val store = concurrencyStore(root) + val failures = ConcurrentLinkedQueue() + val fixtures = (0 until 16).map { + concurrencyFixture("generation-$it") + } + + runConcurrent(fixtures.size, failures) { index -> + concurrencyStagePackage(store, "generation-host", fixtures[index]) + } + assertTrue(failures.joinToString("\n") { it.stackTraceToString() }, failures.isEmpty()) + + val sessions = fixtures.map { store.openSession("generation-host", it.buildId, 1) } + runConcurrent(sessions.size, failures) { index -> + val sessionId = sessions[index].getValue("sessionId") + assertArrayEquals(fixtures[index].bytes, store.readAsset(sessionId, "index.html").bytes) + assertEquals(fixtures[index].buildId, store.markSessionHealthy(sessionId)) + assertArrayEquals(fixtures[index].bytes, store.readAsset(sessionId, "index.html").bytes) + } + assertTrue(failures.joinToString("\n") { it.stackTraceToString() }, failures.isEmpty()) + + val active = store.openSession("generation-host", null, 1) + val activeBuildId = active.getValue("buildId") + val activeFixture = fixtures.first { it.buildId == activeBuildId } + assertArrayEquals( + activeFixture.bytes, + store.readAsset(active.getValue("sessionId"), "index.html").bytes + ) + sessions.forEach { store.closeSession(it.getValue("sessionId")) } + assertEquals(activeBuildId, store.markSessionHealthy(active.getValue("sessionId"))) + val generations = File( + root, + "${concurrencySha256("generation-host".toByteArray())}/generations" + ) + assertTrue(generations.listFiles().orEmpty().size <= 2) + store.closeSession(active.getValue("sessionId")) + } + + @Test + fun serializesConcurrentCommitAndAbortMutations() { + val root = temporary.newFolder() + val store = concurrencyStore(root) + val failures = ConcurrentLinkedQueue() + val fixtures = (0 until 16).map { concurrencyFixture("stage-$it") } + val stages = fixtures.map { store.beginStage("stage-host", it.manifest, it.canonical) } + + runConcurrent(stages.size, failures) { index -> + if (index % 2 == 0) { + concurrencyFinishStage(store, stages[index], fixtures[index]) + } else { + store.abortStage(stages[index]) + } + } + assertTrue(failures.joinToString("\n") { it.stackTraceToString() }, failures.isEmpty()) + + fixtures.forEachIndexed { index, fixture -> + if (index % 2 == 0) { + val session = store.openSession("stage-host", fixture.buildId, 1) + assertArrayEquals( + fixture.bytes, + store.readAsset(session.getValue("sessionId"), "index.html").bytes + ) + store.closeSession(session.getValue("sessionId")) + } else { + assertTrue(runCatching { store.openSession("stage-host", fixture.buildId, 1) }.isFailure) + } + } + store.removeHost("stage-host") + val hostRoot = File(root, concurrencySha256("stage-host".toByteArray())) + assertFalse(hostRoot.exists()) + } + private fun runConcurrent( count: Int, failures: ConcurrentLinkedQueue, @@ -97,6 +171,14 @@ class MobileWebPackageStoreConcurrencyTest { fixture: ConcurrencyFixture ) { val stageId = store.beginStage(host, fixture.manifest, fixture.canonical) + concurrencyFinishStage(store, stageId, fixture) + } + + private fun concurrencyFinishStage( + store: MobileWebPackageStore, + stageId: String, + fixture: ConcurrencyFixture + ) { store.writeAssetChunk( stageId, "index.html", diff --git a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreConcurrencyTests.swift b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreConcurrencyTests.swift index 7e98f898ce6..d22a780b589 100644 --- a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreConcurrencyTests.swift +++ b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreConcurrencyTests.swift @@ -5,6 +5,8 @@ enum MobileWebPackageStoreConcurrencyTests { static func run(root: URL) throws { try exerciseIndependentHosts(root: root.appendingPathComponent("hosts")) try exerciseDuplicateGeneration(root: root.appendingPathComponent("duplicate")) + try exerciseCompetingGenerations(root: root.appendingPathComponent("generations")) + try exerciseCommitAndAbort(root: root.appendingPathComponent("commit-abort")) } private static func exerciseIndependentHosts(root: URL) throws { @@ -81,6 +83,120 @@ enum MobileWebPackageStoreConcurrencyTests { let remaining = try? FileManager.default.contentsOfDirectory(atPath: staging.path) precondition(remaining?.isEmpty != false) } + + private static func exerciseCompetingGenerations(root: URL) throws { + let store = MobileWebPackageStore(cacheRoot: root) + let fixtures = try (0..<16).map { + try concurrencyFixture(content: "generation-\($0)") + } + let failures = ConcurrentFailureCollector() + + DispatchQueue.concurrentPerform(iterations: fixtures.count) { index in + do { + try concurrencyStagePackage(store: store, host: "generation-host", fixture: fixtures[index]) + } catch { + failures.append(error) + } + } + precondition(failures.isEmpty) + + let sessions = try fixtures.map { + try store.openSession( + hostIdentity: "generation-host", + buildId: $0.buildId, + bridgeVersion: 1 + ) + } + DispatchQueue.concurrentPerform(iterations: sessions.count) { index in + do { + let sessionId = sessions[index]["sessionId"]! + let before = try store.readAsset(sessionId: sessionId, path: "index.html") + precondition(before.data == fixtures[index].bytes) + let healthyBuildId = try store.markSessionHealthy(sessionId: sessionId) + precondition(healthyBuildId == fixtures[index].buildId) + let after = try store.readAsset(sessionId: sessionId, path: "index.html") + precondition(after.data == fixtures[index].bytes) + } catch { + failures.append(error) + } + } + precondition(failures.isEmpty) + + let active = try store.openSession( + hostIdentity: "generation-host", + buildId: nil, + bridgeVersion: 1 + ) + let activeBuildId = active["buildId"]! + let activeFixture = fixtures.first { $0.buildId == activeBuildId }! + let activeAsset = try store.readAsset(sessionId: active["sessionId"]!, path: "index.html") + precondition(activeAsset.data == activeFixture.bytes) + for session in sessions { + store.closeSession(sessionId: session["sessionId"]!) + } + let retainedBuildId = try store.markSessionHealthy(sessionId: active["sessionId"]!) + precondition(retainedBuildId == activeBuildId) + let generationRoot = + root + .appendingPathComponent(concurrencySha256(Data("generation-host".utf8))) + .appendingPathComponent("generations") + let retained = try FileManager.default.contentsOfDirectory(atPath: generationRoot.path) + precondition(retained.count <= 2) + store.closeSession(sessionId: active["sessionId"]!) + } + + private static func exerciseCommitAndAbort(root: URL) throws { + let store = MobileWebPackageStore(cacheRoot: root) + let fixtures = try (0..<16).map { + try concurrencyFixture(content: "stage-\($0)") + } + let stages = try fixtures.map { + try store.beginStage( + hostIdentity: "stage-host", + manifestJson: $0.manifest, + canonicalManifestJson: $0.canonical + ) + } + let failures = ConcurrentFailureCollector() + + DispatchQueue.concurrentPerform(iterations: stages.count) { index in + if index.isMultiple(of: 2) { + do { + try concurrencyFinishStage(store: store, stageId: stages[index], fixture: fixtures[index]) + } catch { + failures.append(error) + } + } else { + store.abortStage(stageId: stages[index]) + } + } + precondition(failures.isEmpty) + + for index in fixtures.indices { + if index.isMultiple(of: 2) { + let session = try store.openSession( + hostIdentity: "stage-host", + buildId: fixtures[index].buildId, + bridgeVersion: 1 + ) + let asset = try store.readAsset(sessionId: session["sessionId"]!, path: "index.html") + precondition(asset.data == fixtures[index].bytes) + store.closeSession(sessionId: session["sessionId"]!) + } else { + do { + _ = try store.openSession( + hostIdentity: "stage-host", + buildId: fixtures[index].buildId, + bridgeVersion: 1 + ) + preconditionFailure("aborted stage opened a generation") + } catch {} + } + } + try store.removeHost(hostIdentity: "stage-host") + let hostRoot = root.appendingPathComponent(concurrencySha256(Data("stage-host".utf8))) + precondition(!FileManager.default.fileExists(atPath: hostRoot.path)) + } } private final class ConcurrentFailureCollector: @unchecked Sendable { @@ -117,6 +233,14 @@ private func concurrencyStagePackage( manifestJson: fixture.manifest, canonicalManifestJson: fixture.canonical ) + try concurrencyFinishStage(store: store, stageId: stageId, fixture: fixture) +} + +private func concurrencyFinishStage( + store: MobileWebPackageStore, + stageId: String, + fixture: ConcurrencyFixture +) throws { try store.writeAssetChunk( stageId: stageId, path: "index.html",