From a16a91b381cbf039114b20b5702c7b43344345fe Mon Sep 17 00:00:00 2001 From: fonkamloic Date: Tue, 11 Aug 2026 14:33:42 -0400 Subject: [PATCH 1/3] feat(ios): report the ABI-mismatch reload skip to telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devices that skip the cold-start reload because the installed patch was built for a different engine ABI sat on baseline invisibly. The skip now posts a client-error report (kind: incompatible_reload, stored vs live fingerprints) — fire-and-forget so the boot path never waits on telemetry, and latched once per process: init() can legally run more than once (re-init on resume) and each run re-enters the reload, so without the latch every re-init would re-POST the same stranding event. No server change: the endpoint already stores structured kind/reason reports. Suite 134/134. --- lib/src/code_push.dart | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/src/code_push.dart b/lib/src/code_push.dart index f0d9dac..fbc8269 100644 --- a/lib/src/code_push.dart +++ b/lib/src/code_push.dart @@ -176,6 +176,10 @@ abstract final class CodePush { static String? _loadedPatchId; static String? _loadedPatchHash; + /// Once-per-process latch for the incompatible-reload telemetry — + /// repeated init() calls re-enter the reload and must not re-POST. + static bool _reportedIncompatibleReload = false; + /// Initializes automatic code push update checking with crash protection. /// /// Call this once in your app's startup. It will: @@ -849,11 +853,12 @@ abstract final class CodePush { required String reason, required String? expectedFingerprint, required String? actualFingerprint, + String kind = 'incompatible_baseline', }) async { try { final payload = { 'app_id': appId, - 'kind': 'incompatible_baseline', + 'kind': kind, 'reason': reason, 'platform': _platform, if (patchId != null) 'patch_id': patchId, @@ -1866,6 +1871,26 @@ abstract final class CodePush { _iosResetBootCounter(patchDir); status.value = 'Installed patch was built for a different ' 'engine — waiting for a compatible update'; + // Fleet observability: without this, engine-changed devices + // sit on baseline invisibly. Fire-and-forget so the boot path + // never waits on telemetry. Deduped per process: init() can + // legally run more than once (e.g. re-init on resume), and + // each run re-enters this reload — without the latch every + // re-init would re-POST the same stranding event. + if (_reportedIncompatibleReload) return; + _reportedIncompatibleReload = true; + unawaited( + _reportIncompatibleBaseline( + serverUrl: serverUrl, + appId: appId, + patchId: patchId, + kind: 'incompatible_reload', + reason: 'Installed patch was built for a different engine ' + 'ABI; reload skipped, baseline running', + expectedFingerprint: info?['engine_abi']?.toString(), + actualFingerprint: liveAbi, + ), + ); return; case IosReloadGateDecision.dropCorrupt: // Corruption, NOT a bad patch — delete without quarantining From 0a2958e6bda3f5c2cbac66f6122c2164135f97a8 Mon Sep 17 00:00:00 2001 From: fonkamloic Date: Tue, 11 Aug 2026 14:56:50 -0400 Subject: [PATCH 2/3] review: latch the reload report on DELIVERY, with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latch was set before the fire-and-forget POST resolved, so a device that booted offline lost the stranding report for the whole process. Extracted into debugReportIncompatibleReload: latch set optimistically (no duplicate in-flight posts), cleared again when the POST never completed an HTTP round trip, so a later re-init retries — mirroring the sibling baseline-mismatch dedup's delivered-only recording. A bool (not a pair-keyed set) stays sufficient: an engine ABI cannot change within a running process. 3 tests against a loopback capture server: payload shape (kind + stored/live fingerprints), delivered-latch, offline-retry. Suite 137/137. --- lib/src/code_push.dart | 56 +++++++++++++++----- test/ios_reload_telemetry_test.dart | 81 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 test/ios_reload_telemetry_test.dart diff --git a/lib/src/code_push.dart b/lib/src/code_push.dart index fbc8269..d0d55e2 100644 --- a/lib/src/code_push.dart +++ b/lib/src/code_push.dart @@ -177,9 +177,46 @@ abstract final class CodePush { static String? _loadedPatchHash; /// Once-per-process latch for the incompatible-reload telemetry — - /// repeated init() calls re-enter the reload and must not re-POST. + /// repeated init() calls re-enter the reload and must not re-POST a + /// DELIVERED report. Cleared again on transport failure so devices + /// that boot offline retry on a later re-init. static bool _reportedIncompatibleReload = false; + /// Fires the incompatible-reload stranding report. Latched per + /// process on DELIVERY: set optimistically (no duplicate in-flight + /// posts), cleared again if the POST never completed an HTTP round + /// trip (device offline at boot) so a later re-init retries. One + /// delivered report per process is exactly sufficient — an engine + /// ABI cannot change within a running process. + @visibleForTesting + static Future debugReportIncompatibleReload({ + required String serverUrl, + required String appId, + required String? patchId, + required String? storedAbi, + required String? liveAbi, + }) async { + if (_reportedIncompatibleReload) return; + _reportedIncompatibleReload = true; + final delivered = await _reportIncompatibleBaseline( + serverUrl: serverUrl, + appId: appId, + patchId: patchId, + kind: 'incompatible_reload', + reason: 'Installed patch was built for a different engine ' + 'ABI; reload skipped, baseline running', + expectedFingerprint: storedAbi, + actualFingerprint: liveAbi, + ); + if (!delivered) _reportedIncompatibleReload = false; + } + + /// Test-only: clears the incompatible-reload latch. + @visibleForTesting + static void debugResetIncompatibleReloadLatch() { + _reportedIncompatibleReload = false; + } + /// Initializes automatic code push update checking with crash protection. /// /// Call this once in your app's startup. It will: @@ -1873,22 +1910,15 @@ abstract final class CodePush { 'engine — waiting for a compatible update'; // Fleet observability: without this, engine-changed devices // sit on baseline invisibly. Fire-and-forget so the boot path - // never waits on telemetry. Deduped per process: init() can - // legally run more than once (e.g. re-init on resume), and - // each run re-enters this reload — without the latch every - // re-init would re-POST the same stranding event. - if (_reportedIncompatibleReload) return; - _reportedIncompatibleReload = true; + // never waits on telemetry; delivery/latch semantics live in + // the helper. unawaited( - _reportIncompatibleBaseline( + debugReportIncompatibleReload( serverUrl: serverUrl, appId: appId, patchId: patchId, - kind: 'incompatible_reload', - reason: 'Installed patch was built for a different engine ' - 'ABI; reload skipped, baseline running', - expectedFingerprint: info?['engine_abi']?.toString(), - actualFingerprint: liveAbi, + storedAbi: info?['engine_abi']?.toString(), + liveAbi: liveAbi, ), ); return; diff --git a/test/ios_reload_telemetry_test.dart b/test/ios_reload_telemetry_test.dart new file mode 100644 index 0000000..058aa70 --- /dev/null +++ b/test/ios_reload_telemetry_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterplaza_code_push/flutterplaza_code_push.dart'; + +/// The incompatible-reload stranding report: correct payload, latched +/// per process on DELIVERY (a completed HTTP round trip), and retried +/// when the device was offline at boot. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + // The test binding stubs HttpClient to a 400-only fake; these tests + // need the real loopback stack. + HttpOverrides.global = null; + + late HttpServer server; + late List> posts; + + setUp(() async { + CodePush.debugResetIncompatibleReloadLatch(); + posts = []; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((HttpRequest req) async { + posts.add( + jsonDecode(await utf8.decoder.bind(req).join()) // + as Map, + ); + req.response.statusCode = HttpStatus.ok; + await req.response.close(); + }); + }); + + tearDown(() async { + await server.close(force: true); + CodePush.debugResetIncompatibleReloadLatch(); + }); + + Future fire({String? url}) => CodePush.debugReportIncompatibleReload( + serverUrl: url ?? 'http://127.0.0.1:${server.port}', + appId: 'test-app', + patchId: 'p1', + storedAbi: 'flutter-3.41.2', + liveAbi: 'flutter-3.41.6', + ); + + test('posts kind incompatible_reload with stored vs live ABI', () async { + await fire(); + + expect(posts, hasLength(1)); + final p = posts.single; + expect(p['kind'], 'incompatible_reload'); + expect(p['app_id'], 'test-app'); + expect(p['patch_id'], 'p1'); + expect(p['expected_engine_fingerprint'], 'flutter-3.41.2'); + expect(p['actual_engine_fingerprint'], 'flutter-3.41.6'); + }); + + test('a delivered report latches — re-init does not re-POST', () async { + await fire(); + await fire(); + + expect(posts, hasLength(1), + reason: 'one delivered report per process is sufficient'); + }); + + test('transport failure clears the latch so a later re-init retries', + () async { + // A port with no listener — connection refused, no round trip. + final deadServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final deadPort = deadServer.port; + await deadServer.close(force: true); + + await fire(url: 'http://127.0.0.1:$deadPort'); + expect(posts, isEmpty); + + // Device back online (the real server): the retry must fire. + await fire(); + expect(posts, hasLength(1), + reason: 'an offline boot must not permanently lose the report'); + }); +} From 85e63bf144d447bc8a72ec832972a3d52387f53c Mon Sep 17 00:00:00 2001 From: fonkamloic Date: Tue, 11 Aug 2026 15:04:13 -0400 Subject: [PATCH 3/3] review: production trigger is private; debug method is a thin wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _reportIncompatibleReload holds the latch+POST logic and is what the reload path calls; debugReportIncompatibleReload is now only the test seam over it — matching the sibling reporter's structure. Suite 137/137. --- lib/src/code_push.dart | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/src/code_push.dart b/lib/src/code_push.dart index d0d55e2..f6fd62b 100644 --- a/lib/src/code_push.dart +++ b/lib/src/code_push.dart @@ -188,8 +188,7 @@ abstract final class CodePush { /// trip (device offline at boot) so a later re-init retries. One /// delivered report per process is exactly sufficient — an engine /// ABI cannot change within a running process. - @visibleForTesting - static Future debugReportIncompatibleReload({ + static Future _reportIncompatibleReload({ required String serverUrl, required String appId, required String? patchId, @@ -211,6 +210,25 @@ abstract final class CodePush { if (!delivered) _reportedIncompatibleReload = false; } + /// Test-only wrapper over [_reportIncompatibleReload] — the + /// production trigger is the private method (reload path); this seam + /// exists so the payload and latch semantics are assertable on host. + @visibleForTesting + static Future debugReportIncompatibleReload({ + required String serverUrl, + required String appId, + required String? patchId, + required String? storedAbi, + required String? liveAbi, + }) => + _reportIncompatibleReload( + serverUrl: serverUrl, + appId: appId, + patchId: patchId, + storedAbi: storedAbi, + liveAbi: liveAbi, + ); + /// Test-only: clears the incompatible-reload latch. @visibleForTesting static void debugResetIncompatibleReloadLatch() { @@ -1913,7 +1931,7 @@ abstract final class CodePush { // never waits on telemetry; delivery/latch semantics live in // the helper. unawaited( - debugReportIncompatibleReload( + _reportIncompatibleReload( serverUrl: serverUrl, appId: appId, patchId: patchId,