View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011935 | Taler | wallet (iOS App) | public | 2026-11-04 01:59 | 2026-11-04 14:33 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | MarcS | Reviewer | Christian Grothoff | ||
| Release signed by | MarcS | Deployed by | MarcS | ||
| Verifier | Christian Grothoff | Communicator | |||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | minor | Reproducibility | have not tried |
| Status | planning | Resolution | open | ||
| Summary | 0011935: Loading / stalled connection: retry countdown shows negative seconds, and VoiceOver reads all three retry states at once | ||||
| Description | When a request stalls, the "Loading..." screen shows a retry row that cross-fades three states. Two things are wrong with it. 1. The countdown goes negative ---------------------------------- A recorded run shows the row reading Next retry in: -1 seconds TalerCommon/HelperViews/TimeView.swift: func remainingTime(_ elapsed: Int) -> Int { let remaining = seconds - elapsed if remaining <= 0 { ...flip isFinished asynchronously... } return remaining // not clamped } The value is produced inside a TimelineView(.animation), which keeps ticking every frame, while `isFinished = true` is applied on a later main-queue turn (DispatchQueue.main.async, and then nil again after 1.2 s). Between the countdown reaching zero and the state actually changing, the view renders -1, -2, and so on. 2. VoiceOver announces all three states at once ----------------------------------------------- TalerWallet1/Views/HelperViews/LoadingView.swift stacks the three states and cross-fades them with opacity: ZStack { TimeView("Next retry in:", ...).opacity(isFinished == false ? 1 : 0) Text("Retrying...") .opacity(isFinished == true ? 1 : 0) Text("Waiting for response...").opacity(isFinished == nil ? 1 : 0) } `.opacity(0)` removes a view from sight but not from the accessibility tree. All three therefore remain, at the same frame. From the recorded tree of the same screen, every one of these is present simultaneously at y=677.8: 'Next retry in:, -1, seconds' [87.7, 677.8, 226.7, 27.3] (combined) 'Next retry in:' [87.7, 677.8, 116.7, 27.3] '-1' [212.3, 677.8, 20.7, 27.3] 'seconds' [241.0, 677.8, 73.3, 27.3] 'Waiting for response…' [100.2, 677.8, 201.7, 27.3] 'Retrying…' [155.8, 677.8, 90.3, 27.3] A VoiceOver user hears the countdown, "Retrying" and "Waiting for response" together, with no way to tell which one is current. Sighted users are unaffected by this second point: visually the cross-fade looks right. How to reproduce ---------------- The row only appears once a request has stalled, which needs a sustained network failure rather than a single dropped request. By hand ------- 1. put the device on a connection that accepts connections but does not answer (or use the fault injection below) 2. start any operation that talks to the payment service -- a withdrawal is easiest 3. wait on the "Loading..." screen until the retry row appears under "Couldn't establish a connection" 4. watch the countdown run past zero; it shows -1, -2, ... 5. with VoiceOver on, swipe through the row: all three states are read With the exploration harness ---------------------------- Observed in campaign episode camp3-e00005 at step 21, under the injected fault core.resp_dropped (wallet-core accepts the request and drops the response). From harness/ on a Mac with Xcode: python3 -m taler_fuzz build python3 -m taler_fuzz --max-steps 50 replay \ --tape "0,5,0,5,0,5,0,0,5,0,5,9,15,11,6,3,11,13,10,6,15,14,8,13227,0,2,12,0,15,10,7,10,2,6,7,0,0,2,0,14,4,9,11,15,13,6,15,15,10,3" \ --label retry --fault core.resp_dropped --shots core.resp_dropped delivers taler://dev-experiment/start-tc?drop_resp=1 to the running app, which needs the GNU_Taler or Taler_Nightly flavour. Whether the stall is reached depends on timing, so this does not reproduce on every run; the recorded observations of camp3-e00005 contain the evidence quoted above. Patch ----- Attached: fix-retry-countdown.patch (two files, +15/-1), against master; also branch dev/phoenix-bot/fix-retry-countdown. * TimeView.remainingTime() clamps with max(0, seconds - elapsed), so the countdown stops at zero instead of running negative. The asynchronous isFinished flip is left alone. * each of the three states in the ZStack gets .accessibilityHidden(...) matching its opacity, so only the state actually on screen is announced. Verification status, stated plainly: the patch compiles for the simulator, and both halves are small and local -- a clamp, and three modifiers that mirror conditions already written next to them. I did not manage to re-provoke the stalled state within a short walk, so I have not watched the patched row behave; the defect itself is evidenced by the recorded accessibility tree of camp3-e00005 quoted above, which is where the -1 and the three simultaneous labels come from. No screenshot: that episode was not run with --shots. Estimated severity ------------------ Medium - wrong or confusing presentation; the flow still completes. Estimated effort to fix ----------------------- Trivial for the countdown - one max(0, ...). Small for the accessibility half: three modifiers, plus a decision on whether to keep the opacity cross-fade at all or render the active state conditionally, which would fix both at once and is arguably cleaner. Filed by an agent (bot-cg) on behalf of Christian Grothoff, from an automated UI exploration campaign. Left unassigned deliberately. | ||||
| Tags | No tags attached. | ||||
| Attached Files | fix-retry-countdown.patch (2,781 bytes)
diff --git a/TalerCommon/HelperViews/TimeView.swift b/TalerCommon/HelperViews/TimeView.swift
index a969250d..1cecc8ff 100644
--- a/TalerCommon/HelperViews/TimeView.swift
+++ b/TalerCommon/HelperViews/TimeView.swift
@@ -20,8 +20,14 @@ struct TimeView: View {
self._isFinished = isFinished
}
+ /// Seconds left, never below zero.
+ ///
+ /// The countdown is driven by a TimelineView that keeps ticking while
+ /// `isFinished` is flipped asynchronously, so without the clamp the view
+ /// renders "Next retry in: -1 seconds" and keeps counting down into the
+ /// negatives until the state change lands.
func remainingTime(_ elapsed: Int) -> Int {
- let remaining = seconds - elapsed
+ let remaining = max(0, seconds - elapsed)
if remaining <= 0 {
if isFinished != nil {
DispatchQueue.main.async {
diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift
index 01fd9859..99e2a5ab 100644
--- a/TalerWallet1/Views/HelperViews/LoadingView.swift
+++ b/TalerWallet1/Views/HelperViews/LoadingView.swift
@@ -64,17 +64,25 @@ struct StalledView: View {
if let lastPhase = controller.lastProgressPhase {
if lastPhase.phase == .stalled {
if let lastError, let delay = lastError.nextRetryDelay {
+ // The three states cross-fade, so all of them stay in
+ // the hierarchy. opacity(0) hides a view from sight
+ // but not from VoiceOver, which otherwise reads all
+ // three at once: "Next retry in: 3 seconds",
+ // "Retrying...", "Waiting for response...".
ZStack {
TimeView("Next retry in:", seconds: Int(delay.toSeconds()), isFinished: $isFinished)
.talerFont(.title3)
.padding(.vertical)
.opacity(isFinished == false ? 1 : 0)
+ .accessibilityHidden(isFinished != false)
Text("Retrying…")
.talerFont(.title3)
.opacity(isFinished == true ? 1 : 0)
+ .accessibilityHidden(isFinished != true)
Text("Waiting for response…")
.talerFont(.title3)
.opacity(isFinished == nil ? 1 : 0)
+ .accessibilityHidden(isFinished != nil)
}
retryButton(disabled: isFinished ?? false)
}
fix-retry-countdown-v2.patch (4,859 bytes)
diff --git a/TalerCommon/HelperViews/TimeView.swift b/TalerCommon/HelperViews/TimeView.swift
index a969250d..1cecc8ff 100644
--- a/TalerCommon/HelperViews/TimeView.swift
+++ b/TalerCommon/HelperViews/TimeView.swift
@@ -20,8 +20,14 @@ struct TimeView: View {
self._isFinished = isFinished
}
+ /// Seconds left, never below zero.
+ ///
+ /// The countdown is driven by a TimelineView that keeps ticking while
+ /// `isFinished` is flipped asynchronously, so without the clamp the view
+ /// renders "Next retry in: -1 seconds" and keeps counting down into the
+ /// negatives until the state change lands.
func remainingTime(_ elapsed: Int) -> Int {
- let remaining = seconds - elapsed
+ let remaining = max(0, seconds - elapsed)
if remaining <= 0 {
if isFinished != nil {
DispatchQueue.main.async {
diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift
index b0864c85..57370783 100644
--- a/TalerWallet1/Views/HelperViews/LoadingView.swift
+++ b/TalerWallet1/Views/HelperViews/LoadingView.swift
@@ -28,6 +28,7 @@ struct StalledView: View {
}
}
.buttonStyle(TalerButtonStyle(type: .bordered, disabled: disabled))
+ .accessibilityIdentifier(A11yID.stalledRetryButton)
.padding(.horizontal)
}
@@ -63,17 +64,42 @@ struct StalledView: View {
if let lastPhase = controller.lastProgressPhase {
if lastPhase.phase == .stalled {
if let lastError, let delay = lastError.nextRetryDelay {
+ // The three states cross-fade, so all of them stay in
+ // the hierarchy. opacity(0) hides a view from sight
+ // but not from VoiceOver, which otherwise reads all
+ // three at once: "Next retry in: 3 seconds",
+ // "Retrying...", "Waiting for response...".
+ //
+ // .hidden() rather than .accessibilityHidden(), and
+ // rather than dropping the inactive branches: measured
+ // on the stalled screen, 43 samples per build,
+ //
+ // opacity only 3 states in the tree
+ // + .accessibilityHidden 3 states -- no effect here
+ // if/else, no inactive 1 state, but the band
+ // between "Copy Details" and
+ // "Retry now" shrinks by 32pt
+ // and jitters 5pt per flip
+ // if/else + .hidden() 1 state, frames identical
+ // to the unpatched build
+ //
+ // The ZStack is here to keep the row from resizing as
+ // the state changes, so the inactive branches have to
+ // keep their space -- which is exactly what .hidden()
+ // does and what removing them does not.
ZStack {
- TimeView("Next retry in:", seconds: Int(delay.toSeconds()), isFinished: $isFinished)
+ let countdown = TimeView("Next retry in:", seconds: Int(delay.toSeconds()), isFinished: $isFinished)
.talerFont(.title3)
.padding(.vertical)
- .opacity(isFinished == false ? 1 : 0)
- Text("Retrying…")
+ if isFinished == false { countdown } else { countdown.hidden() }
+
+ let retrying = Text("Retrying…")
.talerFont(.title3)
- .opacity(isFinished == true ? 1 : 0)
- Text("Waiting for response…")
+ if isFinished == true { retrying } else { retrying.hidden() }
+
+ let waiting = Text("Waiting for response…")
.talerFont(.title3)
- .opacity(isFinished == nil ? 1 : 0)
+ if isFinished == nil { waiting } else { waiting.hidden() }
}
retryButton(disabled: isFinished ?? false)
}
@@ -135,6 +161,7 @@ struct LoadingView: View {
}
}
.buttonStyle(TalerButtonStyle(type: .bordered, disabled: false))
+ .accessibilityIdentifier(A11yID.loadingCancelButton)
.padding(.horizontal)
}
voiceover-sweep-unpatched.jsonl (3,593 bytes)
{"elementIdentifier":"","marker":"dwell","detailedDescription":"Contrast failed for SwiftUI.AccessibilityNode","elementType":48,"elementLabel":"Network is slow…","auditType":"contrast","kind":"audit-issue","compactDescription":"Contrast failed","elementFrame":[108,22.833333333333332,174,30.333333333333332],"tMs":46951,"auditTypeRaw":"1"}
{"elementIdentifier":"button.stalled.retry","marker":"dwell","detailedDescription":"Contrast failed for SwiftUI.AccessibilityNode","elementType":9,"elementLabel":"Retry now","auditType":"contrast","kind":"audit-issue","compactDescription":"Contrast failed","elementFrame":[40,541.33333333333326,310,47.333333333333371],"tMs":46951,"auditTypeRaw":"1"}
{"elementIdentifier":"","marker":"dwell","detailedDescription":"Contrast failed for SwiftUI.AccessibilityNode","elementType":48,"elementLabel":"seconds","auditType":"contrast","kind":"audit-issue","compactDescription":"Contrast failed","elementFrame":[230.66666666666666,481.81999999999999,73.333333333333343,27.333333333333371],"tMs":46951,"auditTypeRaw":"1"}
{"elementIdentifier":"","marker":"dwell","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","elementType":48,"elementLabel":"Next retry in:","auditType":"textClipped","kind":"audit-issue","compactDescription":"Text clipped","elementFrame":[81.666666666666686,481.81999999999999,116.66666666666666,27.333333333333371],"tMs":46951,"auditTypeRaw":"131072"}
{"elementIdentifier":"","marker":"dwell","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","elementType":48,"elementLabel":"Request timed out after 5000 ms","auditType":"textClipped","kind":"audit-issue","compactDescription":"Text clipped","elementFrame":[69,363.2236666666667,252,23.333333333333314],"tMs":46951,"auditTypeRaw":"131072"}
{"elementIdentifier":"","marker":"dwell","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","elementType":48,"elementLabel":"Waiting for response…","auditType":"textClipped","kind":"audit-issue","compactDescription":"Text clipped","elementFrame":[94.166666666666671,481.81999999999999,201.66666666666663,27.333333333333371],"tMs":46951,"auditTypeRaw":"131072"}
{"elementIdentifier":"","marker":"dwell","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","elementType":48,"elementLabel":"seconds","auditType":"textClipped","kind":"audit-issue","compactDescription":"Text clipped","elementFrame":[235,481.81999999999999,73.333333333333314,27.333333333333371],"tMs":46951,"auditTypeRaw":"131072"}
{"elementIdentifier":"","marker":"dwell","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","elementType":48,"elementLabel":"Couldn't establish a connection","auditType":"textClipped","kind":"audit-issue","compactDescription":"Text clipped","elementFrame":[41.166666666666686,308.55600000000004,307.66666666666663,30.333333333333314],"tMs":46951,"auditTypeRaw":"131072"}
{"tMs":46951,"marker":"dwell","kind":"audit-summary","counts":{"textClipped":5,"sufficientElementDescription":0,"hitRegion":0,"elementDetection":0,"dynamicType":0,"trait":0,"contrast":3}}
{"marker":"dwell","utterances":["GNU Taler Network is slow…","Loading… Heading","In progress Image","Couldn't establish a connection","Request timed out after 5000 ms","Copy Details Button","Waiting for response…","Retry now Button","Cancel Button","Cancel Button"],"kind":"voiceover","tMs":52940}
| ||||
| Status of the attached patch, and what four attempts at reproducing the stalled state actually showed. Correcting the record rather than leaving the description's "I did not manage to re-provoke it within a short walk" as the last word. The patch is still "compiles, not observed" ------------------------------------------- fix-retry-countdown.patch builds for the simulator on top of current master (branch dev/phoenix-bot/fix-retry-countdown; also as dev/phoenix-bot/verify-retry on top of the exploration harness, which is what the runs below used). I have not watched the patched countdown behave, and I am not claiming the fix is verified. Five attempts, and why they failed ---------------------------------- 1. replay --fault core.latency_extreme, 50 steps -- retry row never reached 2. replay --fault core.resp_dropped, 70 steps -- retry row never reached 3. replay --fault core.resp_dropped --dwell-at 5 --dwell-ms 150000 -- 150 s standing still on the withdrawal amount screen; no retry row 4. replay --fault core.resp_dropped --fault-at-step 9 --dwell-at 10 --dwell-ms 180000 -- 190 s standing still on the wire-transfer screen after Confirm Withdrawal, with every response dropped; no retry row 5. the same with core.net_down (requests dropped before they leave the wallet) injected at step 9, and a third run with net_down injected two steps after "Get demo cash", where loadTestKudos drives a withdrawal with no further user action -- no retry row in either Attempts 1 and 2 failed for a reason worth recording: a walk cannot reach this state, because walking is what prevents it. wallet-core reports phase .stalled only after an operation has failed repeatedly, and the countdown is gone the moment a tap navigates away. Attempts 3 and 4 therefore stood still instead -- and still did not reach it, because a dropped response on the wire-transfer screen is not a failure: the long-poll on /reserves/{pub}?timeout_ms=30000 is *supposed* to return nothing until a wire transfer arrives, and the screen correctly said "The payment service is waiting for your wire-transfer" for the whole three minutes. So the stalled phase needs requests that fail outright rather than requests that go unanswered. The evidence in the description -- the recorded accessibility tree of camp3-e00005, with "Next retry in: -1" and all three of "Next retry in", "Retrying…" and "Waiting for response…" present at once -- remains the only observation of the state, and it is unchanged. Reproducing it yourself ----------------------- The harness gained two knobs for exactly this, on branch dev/phoenix-bot/fuzzbang under harness/: python3 -m taler_fuzz --max-steps 14 --dwell-at 10 --dwell-ms 180000 \ --prefer "withdraw,confirm,accept,terms" \ replay --tape "3,1,0,2,1,0,1,2,0,1,3,0,2,1,0,1" \ --fault core.net_down --fault-at-step 9 --shots --label stall-repro * --dwell-at N [--dwell-ms MS] stops acting after step N and only observes, recording a screen every two seconds, so a countdown is captured frame by frame instead of at its endpoints. * --fault-at-step N injects once the walk has reached step N rather than at launch, so the network breaks while an operation is already in flight. No screenshot ------------- There is no screenshot of the defect because the state has not been on screen since the episode that recorded it, and that episode ran without --shots. The accessibility tree quoted in the description is the evidence; a screenshot of a state I cannot re-enter would have to be staged, which would be worse than none. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| Sixth attempt, with the one fault class the previous five had not used: requests that fail outright with HTTP 500 rather than being delayed, dropped or left unanswered. This was the most likely candidate, because phase .stalled is reached by repeated *failures*, and a dropped response on the wire-transfer screen is not a failure. python3 -m taler_fuzz --max-steps 14 --dwell-at 10 --dwell-ms 150000 \ --prefer "withdraw,confirm,accept,terms" \ replay --tape "3,1,0,2,1,0,1,2,0,1,3,0,2,1,0,1" \ --fault core.http_500 --fault-at-step 9 --shots --label stall-500 Result: 91 recorded screens, 150 s of standing still on the wire-transfer screen with every request answered 500, and no retry row -- the same outcome as the drop and delay faults. The app stayed on "The payment service is waiting for your wire-transfer" throughout. That is six attempts across four fault classes (delay, response dropped, request dropped, HTTP 500) and three flows (wire-transfer wait, demo-cash withdrawal, withdrawal amount screen). I am stopping here rather than continuing to guess: whatever reaches phase .stalled in camp3-e00005 is not any of these, and the next person to try will do better with knowledge of when wallet-core sets that phase than with more fault combinations. The patch therefore stays "compiles, not observed". Both halves of it remain small and local -- a max(0, ...) clamp, and three accessibilityHidden modifiers mirroring opacity conditions written next to them -- and the defect evidence is still the recorded accessibility tree of camp3-e00005 quoted in the report. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| Reproduced on demand, and the patch is now verified -- after being found half wrong. Replaces everything the two previous notes said about this being unreproducible. New patch attached: fix-retry-countdown-v2.patch, which supersedes fix-retry-countdown.patch. Why six attempts failed ----------------------- From wallet-core's own sources (taler-typescript-core/packages/taler-wallet-core/src/progress.ts): * `runWithMaybeProgressContext` starts the phase loop ONLY when the client passed a `progressToken`. The loop is a timer and nothing else: `delayed` at 5 s, `stalled` at 10 s, `done` in `finally`. Failures play no part in reaching `.stalled`. * `nextRetryDelay` -- without which this view is not built at all -- comes from three places, each needing that progress context AND a request that THROWS: `runWithProgressRetries` (progress.ts:223), `fetchFreshExchange` with `noBail` (exchanges.ts:2133), and `waitTransactionState` but only when `tx.error != null` (testing.ts:739). So the state needs an operation invoked with a progressToken whose network hop throws rather than hangs, still unfinished at t >= 10 s. That is exactly what the six attempts lacked: * the wire-transfer screen is the third case with `tx.error == null` -- an empty long-poll is not an error, which is why dropping, delaying and 500-ing it all did nothing; * a withdrawal faulted after Confirm is already past the retried hop; * `core.latency_extreme` only delays, so it can reach `.stalled` but can never populate `nextRetryDelay`. The faults that do work throw by construction: dev-experiments.ts:1219 waits 5000 ms and throws WALLET_UNEXPECTED_REQUEST_ERROR "Request timed out after 5000 ms" -- the exact string in the camp3-e00005 tree that this issue was filed from. The operation that produces it in the app is `prepareWithdrawExchange` (wallet-core requests.ts:698 -> fetchFreshExchange), which WithdrawExchangeScan.swift:75 sends WITH a progressToken, reached by a `taler://withdraw-exchange/...` URI. A second blocker worth knowing about: the harness delivers fault URIs with `simctl openurl`, and the app routes them through its single URLSheet. If a sheet is already presented the URI is dropped silently and wallet-core never sees the experiment -- while the episode still records a fault as applied, because the injector only checks that openurl returned. Some earlier attempts were measuring a build with no fault in it at all. How to reproduce ---------------- From harness/ on branch dev/phoenix-bot/fuzzbang, with a second shell: python3 -m taler_fuzz --max-steps 26 --prefer "finished" \ --dwell-at 14 --dwell-ms 75000 \ replay --tape "$(python3 -c "print(','.join(['0']*60))")" \ --fault core.resp_dropped --shots --label stall and, 3 s after `dwell-begin` appears in out/replay-stall/events.jsonl: xcrun simctl openurl <device> "taler://withdraw-exchange/exchange.taler-ops.ch/" `--prefer "finished"` exists only to tap the dev-experiment sheet's Finished button so the sheet is gone before the withdraw-exchange URI arrives; the all-zero tape reduces every other step to a harmless scroll so the loading screen is never left. 43 consecutive observations of the retry row, every run. Attached stalled-retry-row.png is that screen. It shows both defects at once: the painted frame reads "Waiting for response..." while the tree at that same instant reads `['Next retry in:, -5, seconds', 'Waiting for response...', 'Retrying...']`. The countdown is invisible and still counting, and all three states are present. What the A/B found ------------------ 43 samples per build, same repro: build retry rows countdown states in tree unpatched 43 -5 .. 16 3 (43/43) old patch (.accessibilityHidden) 43 0 .. 17 3 (43/43) if/else, inactive dropped 43 -- 1 (43/43) if/else + .hidden() 44 -- 1 (44/44) * the countdown clamp works: every negative is gone. * the accessibility half did NOT work. `.accessibilityHidden(...)` does not remove a view from `XCUIApplication.snapshot()`, so all three states remained exactly as before the patch. A control proved the harness can see the difference, so this was the patch and not the measurement. The new patch ------------- `.hidden()` on the inactive branch does remove it from the tree. Dropping the inactive branches outright also gives one state, but costs the layout the ZStack exists to protect: the band between "Copy Details" and "Retry now" shrinks by 32 pt and jitters 5 pt on every state flip, because the removed TimeView takes its vertical padding with it. With `.hidden()` the anchor frames are identical to the unpatched build to the decimal -- Copy Details (132, 416, 126, 23.7), Retry now (40, 541.3, 310, 47.3) -- across all 44 samples. One honest note on the choice: with `.hidden()` the inactive TimeView is still constructed, so its TimelineView keeps running and keeps driving `isFinished` as it does today; the clamp is what stops it going negative. Dropping the branch would stop that orphan timer at its source, which is the deeper fix, at the cost of the layout above. I chose the conservative one; if you prefer the other, the measurement for it is in the table. A consequence worth expecting: after either variant the countdown is no longer sampled in the tree at all in this scenario, because `nextRetryDelay` is ~0 here so `isFinished` is essentially never false. That is the tree agreeing with what is painted; the old non-zero numbers were the orphan timer behind opacity(0). Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| Correcting an overstatement in my own note: I wrote that whether VoiceOver would now read only one state is "a separate question the harness cannot answer". That hedge belonged to the previous patch and does not apply to this one. The hedge was right about `.accessibilityHidden`: it does not remove a view from the accessibility hierarchy, so with that modifier the harness could see all three states and could say nothing about what VoiceOver would do with them. The patch now uses `.hidden()`, and the tree the harness reads -- `XCUIApplication.snapshot()` -- is the application's accessibility hierarchy, the same one assistive technology walks. The measurement is that exactly one of the three states is present in it, in 44 of 44 samples. An element that is not in that hierarchy is not exposed to VoiceOver, so "only one state is announced" follows from the measurement rather than being a separate untested claim. What remains genuinely untested is narrower, and I will not claim otherwise: nobody has listened to VoiceOver read this screen. The step from "one element in the accessibility hierarchy" to "one utterance" is an inference about how VoiceOver treats that hierarchy, not an observation of speech. It is the same inference the issue's original claim rests on in the other direction -- that three simultaneous elements are read as three -- so it is at least consistent, but it is an inference. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| CORRECTION -- the second half of this report is wrong, and VoiceOver itself says so. Please read this before acting on the accessibility part. I wrote that the three retry states are "read at once" by VoiceOver, on the strength of all three being present in the accessibility tree. That inference does not hold, and I can now say so from the thing itself rather than from the tree: Xcode 27 exposes XCUIDevice.voiceOverService, which enables VoiceOver in the simulator and reports, per element, "what VoiceOver spoke". A top-to-bottom sweep of the stalled screen, three runs, two of them on the UNPATCHED build, produced byte-identical output: 0. GNU Taler Network is slow… 1. Loading… Heading 2. In progress Image 3. Couldn't establish a connection 4. Request timed out after 5000 ms 5. Copy Details Button 6. Waiting for response… <-- one state, not three 7. Retry now Button 8. Cancel Button At that same instant on the unpatched build the tree contains all three states -- 42 of 42 and 44 of 44 dwell samples carry "Next retry in:", "Retrying…" and "Waiting for response…" together, and Apple's own accessibility audit independently lists them as five separate nodes at y=481.8, including the literal "-1". VoiceOver walked from Copy Details (y=416) straight past that row to Retry now (y=541.3) and spoke exactly one of them. So: the defect is real in the accessibility tree and does not reach the user-facing consequence I claimed. The likely mechanism is that UIKit and SwiftUI exclude alpha==0 views from VoiceOver's traversal while leaving them in the tree that XCUIApplication.snapshot() reads -- that part is an inference, and I am marking it as one. What this means for the two halves ---------------------------------- * The countdown clamp stands, and is if anything better evidenced than before: Apple's audit read the literal "-1" off the unpatched countdown as its own element. A VoiceOver user who reaches that element while it is the active state hears a negative number. (In all three sweeps the active state happened to be "Waiting for response…", which lasts ~10 s against ~1.2 s for "Retrying…", so I did not hear the countdown spoken -- only that exactly one state is.) * The .hidden() change is now hygiene rather than a fix: it makes the tree agree with what is drawn and with what VoiceOver does, which is worth having, but it does not change anything a user hears on this screen. Weigh it accordingly -- it is no longer urgent. Scope of the test, stated so it is not over-read: linear swipe navigation on iOS 27.2 only. Read-all-from-top, rotor navigation, Braille and touch exploration were not tested, and neither was any earlier iOS. If any of those traverse the tree differently, the original claim could still hold there; I have not shown it false in general, only false for the ordinary way of moving through a screen on the version I could test. Attached voiceover-sweep-unpatched.jsonl is the raw record from the unpatched run: the audit issues and the ten utterances. My earlier note saying "nobody has listened to VoiceOver speak this screen" is superseded. Somebody has now, and it did not say what I predicted. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| Completing the correction: the scope caveat I attached to it is now closed as far as the platform allows, and the part that remains is permanent rather than pending. My previous note said the one-state result was only shown for linear swipe navigation. Every traversal XCUIVoiceOverService can drive has now been tested, on the UNPATCHED build, on the stalled screen, with all three states confirmed present in the tree at the time (42/42 and 45/45 dwell samples): traversal available states spoken forward, Flat yes 1 backward, Flat yes 1 forward, Grouped yes 1 backward, Grouped yes 1 into the container (moveIn) yes 1 read-all / continuous reading no API -- rotor no API -- hidden-element setting no API -- The navigation style demonstrably took effect rather than being set and ignored: in Flat the banner is one utterance, "GNU Taler Network is slow…"; in Grouped it splits into "GNU Taler" and "Network is slow…", and moveIn() stops refusing. Stepping into the group that holds the retry row enumerates exactly one element, "Waiting for response…" -- the other two states are not in it. So the original claim does not hold under any traversal the API reaches, in either direction, in either navigation style, or inside the container. What stays untested, permanently -------------------------------- XCUIVoiceOverService is exactly enable/disable/moveForward/moveBackward/ moveIn/moveOut/currentSpeech. There is no read-all, no continuous reading, no rotor, and no setting for hidden elements -- not in XCTest and not in XCUIAutomation. So those cannot be driven from a UI test at all in Xcode 27.2, by anyone. Braille output and touch exploration by dragging are likewise out of reach. I have not approximated any of them by walking the tree and guessing what would be spoken; that would throw away the only thing that makes this result worth having. Everything here is iOS 27.2 in the simulator. Attached voiceover-traversals-unpatched.jsonl is the Grouped-mode record: forward, backward and container passes with their utterances. Added by an agent (bot-cg) on behalf of Christian Grothoff. voiceover-traversals-unpatched.jsonl (4,904 bytes)
{"elementFrame":[108,22.833333333333332,174,30.333333333333332],"auditType":"contrast","marker":"dwell","tMs":43914,"auditTypeRaw":"1","elementType":48,"elementIdentifier":"","elementLabel":"Network is slow…","compactDescription":"Contrast failed","detailedDescription":"Contrast failed for SwiftUI.AccessibilityNode","kind":"audit-issue"}
{"elementFrame":[149.83333333333334,481.81999999999999,90.333333333333314,27.333333333333371],"auditType":"contrast","marker":"dwell","tMs":43914,"auditTypeRaw":"1","elementType":48,"elementIdentifier":"","elementLabel":"Retrying…","compactDescription":"Contrast failed","detailedDescription":"Contrast failed for SwiftUI.AccessibilityNode","kind":"audit-issue"}
{"elementFrame":[210.66666666666666,481.81999999999999,12,27.333333333333371],"auditType":"contrast","marker":"dwell","tMs":43914,"auditTypeRaw":"1","elementType":48,"elementIdentifier":"","elementLabel":"1","compactDescription":"Contrast failed","detailedDescription":"Contrast failed for SwiftUI.AccessibilityNode","kind":"audit-issue"}
{"elementFrame":[81.666666666666686,481.81999999999999,116.66666666666666,27.333333333333371],"auditType":"textClipped","marker":"dwell","tMs":43914,"auditTypeRaw":"131072","elementType":48,"elementIdentifier":"","elementLabel":"Next retry in:","compactDescription":"Text clipped","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","kind":"audit-issue"}
{"elementFrame":[94.166666666666671,481.81999999999999,201.66666666666663,27.333333333333371],"auditType":"textClipped","marker":"dwell","tMs":43914,"auditTypeRaw":"131072","elementType":48,"elementIdentifier":"","elementLabel":"Waiting for response…","compactDescription":"Text clipped","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","kind":"audit-issue"}
{"elementFrame":[69,363.2236666666667,252,23.333333333333314],"auditType":"textClipped","marker":"dwell","tMs":43914,"auditTypeRaw":"131072","elementType":48,"elementIdentifier":"","elementLabel":"Request timed out after 5000 ms","compactDescription":"Text clipped","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","kind":"audit-issue"}
{"elementFrame":[235,481.81999999999999,73.333333333333314,27.333333333333371],"auditType":"textClipped","marker":"dwell","tMs":43914,"auditTypeRaw":"131072","elementType":48,"elementIdentifier":"","elementLabel":"seconds","compactDescription":"Text clipped","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","kind":"audit-issue"}
{"elementFrame":[41.166666666666686,308.55600000000004,307.66666666666663,30.333333333333314],"auditType":"textClipped","marker":"dwell","tMs":43914,"auditTypeRaw":"131072","elementType":48,"elementIdentifier":"","elementLabel":"Couldn't establish a connection","compactDescription":"Text clipped","detailedDescription":"Text of this SwiftUI.AccessibilityNode may be clipped at larger Dynamic Type sizes.","kind":"audit-issue"}
{"tMs":43914,"counts":{"dynamicType":0,"hitRegion":0,"sufficientElementDescription":0,"textClipped":5,"contrast":3,"trait":0,"elementDetection":0},"kind":"audit-summary","marker":"dwell"}
{"api":["enable","disable","isEnabled","currentSpeech","moveForward","moveBackward","moveIn","moveOut"],"marker":"dwell","tMs":49200,"absent":["readAll","speakScreen","continuousReading","rotor","hiddenElementSetting"],"kind":"voiceover-service","enabled":true,"debugDescription":"<XCUIVoiceOverService: enabled=YES>"}
{"utterances":["GNU Taler","Network is slow…","Loading…","In progress Image","Couldn't establish a connection","Request timed out after 5000 ms","Copy Details Button","Waiting for response…","Retry now Button","Cancel Button"],"pass":"forward","marker":"dwell","tMs":49200,"statesSpoken":["waiting for response"],"kind":"voiceover-pass","error":""}
{"utterances":["Retry now Button","Waiting for response…","Copy Details Button","Request timed out after 5000 ms","Couldn't establish a connection","In progress Image","Loading…","Network is slow…","dismiss popup Activate to dismiss pop-up window."],"pass":"backward","marker":"dwell","tMs":49200,"statesSpoken":["waiting for response"],"kind":"voiceover-pass","error":""}
{"utterance":"","error":"Error Domain=com.apple.xctest.voiceoverservice Code=3 \"No speech available\" UserInfo={NSLocalizedDescription=No speech available}","marker":"dwell","tMs":49200,"kind":"voiceover-movein-probe"}
{"utterances":["Waiting for response…","Retry now Button","Cancel Button"],"pass":"container","marker":"dwell","tMs":49200,"kind":"voiceover-pass","error":"","approach":["Network is slow…","Loading…","In progress Image","Couldn't establish a connection","Request timed out after 5000 ms","Copy Details Button","Waiting for response…"]}
| |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-04 01:59 | bot-cg | New Issue | |
| 2026-11-04 01:59 | bot-cg | File Added: fix-retry-countdown.patch | |
| 2026-11-04 07:26 | bot-cg | Note Added: 0030224 | |
| 2026-11-04 08:56 | bot-cg | Note Added: 0030230 | |
| 2026-11-04 10:16 | bot-cg | File Added: fix-retry-countdown-v2.patch | |
| 2026-11-04 10:16 | bot-cg | File Added: stalled-retry-row.png | |
| 2026-11-04 10:17 | bot-cg | Note Added: 0030238 | |
| 2026-11-04 12:06 | bot-cg | Note Added: 0030244 | |
| 2026-11-04 13:01 | Christian Grothoff | Implementer | => MarcS |
| 2026-11-04 13:01 | Christian Grothoff | Reviewer | => Christian Grothoff |
| 2026-11-04 13:01 | Christian Grothoff | Release signed by | => MarcS |
| 2026-11-04 13:01 | Christian Grothoff | Deployed by | => MarcS |
| 2026-11-04 13:01 | Christian Grothoff | Verifier | => Christian Grothoff |
| 2026-11-04 13:01 | Christian Grothoff | Communicator | CM_7 => |
| 2026-11-04 13:01 | Christian Grothoff | Status | new => planning |
| 2026-11-04 13:01 | Christian Grothoff | Description Updated | |
| 2026-11-04 14:02 | bot-cg | File Added: voiceover-sweep-unpatched.jsonl | |
| 2026-11-04 14:02 | bot-cg | Note Added: 0030256 | |
| 2026-11-04 14:33 | bot-cg | File Added: voiceover-traversals-unpatched.jsonl | |
| 2026-11-04 14:33 | bot-cg | Note Added: 0030259 |