fix(app): surface Limitless flash-drain stalls instead of silent success - #12268
Conversation
|
Thanks @formed2forge — this closes a genuinely nasty silent-failure mode, and the implementation is careful. Detailed pass below. What I verified
Checks are green on this head (Dart Analyze and Tests, Android Compile Smoke, Generated Files, Formatting, Hygiene; the skipped jobs are out-of-scope skips). No split needed — it's one outcome (surface flash-drain stalls) and the l10n churn is intrinsic to it. Minor observations (non-blocking)
Leaving the merge call to a maintainer; the one hardware-dependent assumption behind by AI on behalf of David — happy to re-review after changes; ping @Git-on-my-level with |
|
@mdmohsin7 — ping for review. Code review by Git-on-my-level is complete and positive, CI is green. Just waiting on your sign-off. |
…en Limitless flash sync stalls
Root cause: the Limitless protocol's mode command (msg 8) makes flash-page
drain and recording mutually exclusive — there is no mode that serves stored
pages while a recording session is being written. When the pendant is
hardware-button recording, the drain starves, the 30s stall detector in
FlashPageWalSyncImpl ends the pass, WalSyncs.syncAll discards the result, and
SyncProvider falls through to toCompleted() — the user sees "synced" while
nothing transferred and nothing tells them to stop recording.
Durable guard:
- On a stall, FlashPageWalSyncImpl re-queries device status while still in
batch mode and classifies the stall: newest_flash_page advanced past the
enumerated end while the drain starved => recordingSuspected (the pendant
is minting pages it will not serve). Exposed as FlashSyncStallReason via
WalSyncs.flashStallReason; stamped on the flash_page_download_partial event.
- SyncProvider maps a recordingSuspected stall with no new conversations to a
user-facing error state ("Press the Pendant's button to stop recording,
then sync again") instead of silent completion. Message is l10n'd across
all 49 locales (pendantRecordingSyncBlocked).
- Unknown stalls (plain transfer lulls) keep the existing resume-on-next-sync
behavior; WAL stays 'miss' with an advanced storageOffset either way.
The protocol limitation itself is not fixable app-side; this closes the
silent-ops half of the failure (the user now learns why sync stopped and how
to unblock it). The native Transcribe Later drain engines share the silent
stall pattern (NSLog-only) — deferred as a separate surface.
Verification:
- flutter test test/unit/flash_page_stall_classification_test.dart
test/providers/sync_provider_flash_stall_test.dart — 7/7 pass (regression
test asserts the stall no longer reports success).
- bash app/test.sh — 753/753 pass.
- flutter gen-l10n — zero untranslated messages.
- bash app/scripts/analyze_ratchet.sh — passed.
- Live pendant-in-hand verification pending (pendant currently paired to the
TestFlight build); code path exercised via provider-level tests through the
real _performSync flow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ash sync stalls A full Limitless pendant halts recording (red LED flash) but stays armed in recording mode, and in that state the firmware serves no flash pages: an offline sync starves, the 30s stall detector ends the drain, and the result used to fall through to `toCompleted` — telling the user everything synced when nothing did. This is the real-world trigger behind the silent-stall bug (confirmed on hardware); the existing `recordingSuspected` path can never fire for it, because a full pendant cannot mint new flash pages, so the newest-page-advanced heuristic stays false. Root cause / durable guard: classify a stall with zero `free_capture_pages` as a new `FlashSyncStallReason.deviceFull` (checked before the recording heuristic, since fullness cannot be inferred from page movement). SyncProvider gains a matching error branch and a full-specific message telling the user to press the button to stop recording, then sync again — the exact recovery the firmware requires. l10n: new key `pendantFullSyncBlocked` translated across all 49 locales; `flutter gen-l10n` reports zero untranslated. Tests: extended classifyStall unit tests (zero-free = deviceFull, full takes precedence over newest-page movement, free-remaining stays unknown) and the SyncProvider regression test (deviceFull surfaces an error, not success). Verification: - `flutter test` on the stall + provider suites: 11 passed. - `scripts/analyze_ratchet.sh`: passed. `flutter gen-l10n`: 0 untranslated. - Hardware (iPhone 17 Pro, dev build): with a deterministic test harness that drove the drain into a stall and injected free_capture_pages=0, the classifier logged `deviceFull` and the full-storage message rendered on screen. Real full-pendant repro (passive, ~24h to refill flash) still pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sync error banner clamped its message to `maxLines: 2` + ellipsis, so a
long recovery message was cut mid-word ("…storage is full and i…"), hiding the
very instruction the user needs to act on. It was worst at larger iOS
accessibility text scales, where two lines hold even less. Found while
verifying the pendant-full error on a device with enlarged system fonts.
Extract the banner into a small `SyncErrorCard` widget (reviewable, testable)
that drops the line clamp so the message reflows in full, and top-aligns the
Row so the icon and Retry pill stay put when the text wraps to several lines.
Behavior-preserving for the common short-error case.
Tests: `sync_error_card_test.dart` asserts the message is never clamped
(maxLines null, no ellipsis) and that the full message stays visible without a
layout overflow at a 2x accessibility text scale — the regression that would
have caught the original bug.
Verification:
- `flutter test test/widgets/sync_error_card_test.dart`: 2 passed.
- `scripts/analyze_ratchet.sh`: passed (prefer_const_constructors improved by 1).
- Hardware (iPhone 17 Pro, dev build, enlarged accessibility fonts): the full
"Your Pendant's storage is full…press the Pendant's button…then sync again"
message renders across multiple lines with no truncation (screenshot before
and after the extraction confirm identical, full-message rendering).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Persist a `flash_page_stall_classified` event (reason + whether the post-stall status read was null + free/newest page counters) at the point the drain stall is classified. This is the one read that decides which message the user sees, and it was previously unlogged. Rationale: the deviceFull trigger is confirmed only through injected status in a test harness — a real full pendant reporting `free_capture_pages <= 0` in a clean status read has not yet been observed (the pendant sat ~65% full all session, and one real status read during a stall came back malformed with no free-page field at all). If the next natural full event classifies as `unknown` and silently completes, this record is the difference between "assumption was wrong (full != free==0)" and "the status read failed" — turning the passive full-pendant repro into a conclusive result instead of a guess. Aligns with the repo's "silent ops is not allowed" observability rule. Verification: analyzer ratchet passes; stall + provider suites still pass (11). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase onto current upstream/main hand-merged the .arb sources for pendantRecordingSyncBlocked/pendantFullSyncBlocked (added new keys at the tail of each of the 49 locale files, colliding with hundreds of upstream insertions at the same position) but left the generated app_localizations*.dart getters stale, since regenerating those correctly requires the toolchain rather than a text merge. flutter gen-l10n from the merged .arb sources.
Declares the failure-class boundary these Limitless flash-drain fixes repair: a device-storage drain stall caused by a structurally-unservable device state (protocol mode conflict, full storage still armed for recording) must be classified and surfaced as an actionable error, never silently fall through to a generic "completed, nothing new" success. Two fixes in this PR (recordingSuspected, deviceFull) share this cause and its guard (FlashSyncStallReason + SyncProvider's classified-stall branch), so this records the reusable boundary rather than treating each as an isolated bug.
e391323 to
eb89db2
Compare
|
Rebased onto current upstream/main — one merge conflict in |
kodjima33
left a comment
There was a problem hiding this comment.
Verified the FlashSyncStallReason/deviceFull classification is not yet on main (bug is real). CI green, scoped code change (bulk of diff is auto-generated l10n across 49 locales). Confidence 4/5 bug fix — merging.
Summary
The Limitless pendant's protocol makes flash-page drain and recording mutually exclusive (mode command 8) — there is no mode that serves stored pages while a recording session is open. When a drain stalls because the pendant is recording, or because it's full and stuck in recording mode, the 30s stall detector in
FlashPageWalSyncImplends the pass,WalSyncs.syncAlldiscards the result, andSyncProviderfell through totoCompleted()— the user saw "synced" while nothing transferred and nothing told them why.This PR closes the silent-ops half of that failure:
FlashPageWalSyncImplre-queries device status on a stall, still in batch mode, and classifies it viaFlashSyncStallReason:recordingSuspected(newest_flash_page advanced past the enumerated end while the drain starved — the pendant is minting pages it won't serve) ordeviceFull(flash storage full, pendant halts recording but stays armed and serves no pages until the button stops it). Exposed throughWalSyncs.flashStallReasonand stamped on theflash_page_download_partialevent; the read that decides the reason is now logged viaflash_page_stall_classifiedsince it hadn't been observed under a real full-flash condition yet.SyncProvidermaps a classified stall with no new conversations to a user-facing error state instead of silent completion — l10n'd across all 49 locales (pendantRecordingSyncBlocked,pendantFullSyncBlocked).sync_page.dart) was clamped to 2 lines + ellipsis, truncating the new (longer) recovery messages mid-word, worst at larger accessibility text scales. Extracted into a small, testedSyncErrorCardwidget that reflows the full message instead of clamping it.The protocol limitation itself isn't fixable app-side; this only ensures the user learns why sync stopped and how to unblock it (stop recording via the hardware button, then sync again).
Not covered by this PR: while an upload (as opposed to a BLE drain) is in progress,
SyncProvider.isSyncinghides the "Sync Now" button entirely, so there's no UI pathway to start a new drain if the pendant's flash fills up mid-upload. That's a distinct UX gap needing its own design (separate "offload device" action vs. running drain/upload concurrently) — filed as #12265.Branch history note
This branch was rebased onto current
upstream/main(it had drifted ~8300 commits behind). Five commits unrelated to Limitless sync — an abandoned side-thread on iOS community-build signing config (dynamicDEVELOPMENT_TEAM/APP_GROUP_IDENTIFIER, dev-entitlement stripping) — were dropped during the rebase rather than carried forward: that work already has its own more current, still-open effort (#7641 /fix/ios-community-build), and mixing it into this PR would just reintroduce a stale, superseded version of it. The generatedapp_localizations*.dartl10n files were regenerated withflutter gen-l10nafter the.arbsources were hand-merged (verified as pure additions matching the.arbdiff — see thechore(app): regenerate l10n outputcommit).Test plan
bash app/test.sh— full suite, see CI/PR checks for pass/faildart format --line-length 120on regenerated l10n output — no changes (gen-l10n already formats to the repo's line length).arbmerges are valid JSON and the regenerated.dartgetters are pure additions matching the.arbdiffdeviceFullstall path is still pending upstream (per the original commit's notes: only reproduced via injected status in a test harness so far, not yet observed on real hardware with a genuinely full pendant)Failure-Class: new
New class added in this PR:
FC-drain-stall-completes-silently(.github/failure-classes/FC-drain-stall-completes-silently.json) — a device-storage drain stall caused by a structurally-unservable state (protocol mode conflict, full storage still armed for recording) must be classified and surfaced as an actionable error, never allowed to fall through to a generic "completed, nothing new" success.🤖 Generated with Claude Code