View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011937 | Taler | wallet (iOS App) | public | 2026-11-04 07:26 | 2026-11-04 13:25 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | MarcS | Reviewer | Christian Grothoff | ||
| Release signed by | MarcS | Deployed by | MarcS | ||
| Verifier | 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 | tweak | Reproducibility | have not tried |
| Status | planning | Resolution | open | ||
| Summary | 0011937: Loading…: a full-screen loading state is shown for operations that finish in 21-50 ms, so it flickers instead of informing | ||||
| Description | Loading… appears and disappears faster than it can be read, for operations that had already finished. What was observed ----------------- Across an 8-hour exploration campaign of the iOS wallet (245 instrumented episodes), the harness recorded 68 distinct observations of a loading affordance that was on screen for less time than it takes to read it, in 30 different episodes and under 20 separate analysis runs. Two independent signals: * 46 observations from the app's own os_log: a wallet-core request completed in 21 to 50 ms while a loading affordance was on screen. Where the visible window could be bounded, it was 122 to 299 ms. Showing a full-screen spinner for a 21 ms operation is a flicker, not feedback. * 22 observations from the UI probe, which samples the accessibility tree every ~30 ms during the settle period: a loading state present in exactly one sample and absent in the samples on either side, bounding its visibility at 60 to 326 ms. Example, episode camp4-e00005, navigating from Settings into Bank Accounts: probe 45 t=8606 ms loading=False probe 46 t=9822 ms loading=True <-- the whole appearance probe 47 t=9852 ms loading=False A caveat, stated so nobody has to re-derive it: a single-sample probe observation can in principle catch a transition frame rather than a state the user was really shown. That is why the os_log measurements are the primary evidence here -- those are measurements of the operation behind the spinner, not of the drawing. Why it happens -------------- TalerWallet1/Views/HelperViews/LoadingView.swift is a full-screen view with navigationTitle "Loading…", presented by its call sites as soon as a request starts and torn down when it ends, with no lower bound on how long that is: TalerWallet1/Views/Sheets/URLSheet.swift:93 TalerWallet1/Views/Sheets/P2P_Sheets/P2pAcceptDone.swift:51 TalerWallet1/Views/Sheets/P2P_Sheets/P2pReceiveURIView.swift:127 TalerWallet1/Views/Sheets/Refund/RefundURIView.swift:55 TalerWallet1/Views/Actions/Banking/ManualWithdrawDone.swift:63 TalerWallet1/Views/Actions/Banking/ManualWithdraw.swift:309 TalerWallet1/Views/Actions/Peer2peer/P2PReadyV.swift:110 TalerWallet1/Views/TalDir/AliasView.swift:245 TalerWallet1/Views/TalDir/AddContactView.swift:296 ... and others Each is of the shape "if not loaded yet { LoadingView } else { content }". When the request takes 21 ms, the view is built and destroyed inside the same couple of frames. Patch ----- Attached: fix-loading-flash.patch (one file, +23/-1), against master; also branch dev/phoenix-bot/fix-loading-flash. LoadingView waits 250 ms before showing anything. The view stays in the hierarchy the whole time, so the call sites' own .task/.onAppear work and the navigation are untouched; only the visible affordance and its navigation title are gated. It is hidden with .accessibilityHidden as well as .opacity, because opacity alone is still read out by VoiceOver -- the same trap as the three stacked retry states in issue 11935. The 250 ms is a judgement call and is a named constant in one place so it can be argued with: below roughly a quarter second a progress indicator reads as a glitch, above it its absence reads as a freeze. Verification status, stated plainly ----------------------------------- The patch compiles for the simulator. Its effect is NOT demonstrated. The flash is timing-dependent, and in a controlled A/B -- the exact 60-step tape of camp4-e00005 replayed on a build without the fix and a build with it -- neither build produced a sub-150 ms loading span, so the comparison was inconclusive: without the fix : 1584 probes, 1 loading span, t=59694..59970 (276 ms) with the fix : 1595 probes, 1 loading span, t=59285..59558 (273 ms) So this report asserts that the flashes happen -- 68 recorded observations with timestamps -- and not that the patch has been seen to stop them. How to reproduce ---------------- The harness is on branch dev/phoenix-bot/fuzzbang, under harness/. The flashes turn up on their own in any campaign of a few dozen episodes: cd harness python3 -m taler_fuzz build python3 -m taler_fuzz campaign --hours 1 --with-faults --run-id flash and the findings file lists them: python3 -c 'import json;d=json.load(open("$work_dir/runs/flash/findings.json")); print([f["summary"] for f in d if f["kind"]=="transient-screen"])' To look at one directly, the probe series of any episode is a JSONL of accessibility-tree samples with a `loading` flag, so the visible spans are: python3 - <<EOF import json rows=[json.loads(l) for l in open("$work_dir/out/<episode>/probes.jsonl") if l.strip()] rows.sort(key=lambda r: r["tMs"]) span=None for r in rows: if r.get("loading"): span = span or [r["tMs"], r["tMs"]] span[1] = r["tMs"] elif span: print("loading visible", span[1]-span[0], "ms at t=", span[0]); span=None EOF and the A/B above is reproduced with: python3 -m taler_fuzz --max-steps 60 replay --tape "<tape>" --label ab-before after building each branch in turn. No screenshot ------------- A screenshot cannot show this defect: the complaint is that the state is on screen too briefly, and a still frame of a spinner looks exactly like a healthy spinner. The probe series with timestamps is the evidence. Estimated severity ------------------ Low - cosmetic. Nothing malfunctions; the app looks unsettled, and a user who notices a flicker cannot tell what happened. Estimated effort to fix ----------------------- Small. The attached patch is one view and one constant. The work that is not in the patch is deciding the threshold and checking the handful of call sites that pass a `message`, in case any of them relies on the title appearing at once. Found by automated UI exploration. Filed by an agent (bot-cg) on behalf of Christian Grothoff. Left unassigned deliberately. | ||||
| Tags | No tags attached. | ||||
| Attached Files | fix-loading-flash.patch (3,541 bytes)
From 24ff757c4465d22ef855a86e6c7371931ee96409 Mon Sep 17 00:00:00 2001
From: Christian Grothoff <christian@grothoff.org>
Date: Wed, 4 Nov 2026 08:17:56 +0100
Subject: [PATCH] AI: do not flash the loading screen for operations that
finish instantly
An exploration campaign recorded 100 observations of a full-screen loading
state that was on screen for less than a probe interval -- under 100 ms, and
in the os_log evidence the wallet-core request behind it had completed in 21
to 36 ms. A spinner that brief is a flicker rather than feedback, and it is
the kind of thing one notices without being able to say what happened.
LoadingView now waits 250 ms before it shows anything. The view itself stays
in the hierarchy the whole time, so the call sites' own .task/.onAppear work
and the navigation are untouched; only the visible affordance and its
navigation title are gated. It is hidden with accessibilityHidden as well as
opacity, because opacity alone still gets read out -- the same trap as in the
three stacked retry states.
250 ms is a judgement call: below roughly that a progress indicator reads as
a glitch, above it its absence reads as a freeze. The constant is named and
in one place so it can be argued with.
Assisted by LLM
---
.../Views/HelperViews/LoadingView.swift | 24 ++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift
index b0864c85..55ff2cf9 100644
--- a/TalerWallet1/Views/HelperViews/LoadingView.swift
+++ b/TalerWallet1/Views/HelperViews/LoadingView.swift
@@ -112,6 +112,18 @@ struct LoadingView: View {
@State private var rotationEnabled = true
@State private var disabled: Bool = false
+ /// False until the operation has been running long enough to be worth
+ /// telling the user about. Most operations behind this view finish in
+ /// tens of milliseconds, and showing a full-screen spinner for that long
+ /// is a flicker, not feedback.
+ @State private var worthShowing = false
+
+ /// How long an operation has to run before the spinner appears. Below
+ /// roughly a quarter of a second a progress indicator is perceived as a
+ /// glitch rather than as a response; above it, its absence is perceived
+ /// as the app having frozen. Nanoseconds rather than `Duration`, which
+ /// needs iOS 16 while this target still deploys to 15.
+ private static let appearAfterNs: UInt64 = 250_000_000
private var dismissButton: some View {
Button("Cancel", role: .cancel) {
@@ -177,8 +189,18 @@ struct LoadingView: View {
.frame(maxWidth: .infinity)
.padding(.horizontal)
.talerFont(.title)
- .navigationTitle(navTitle)
+ // Hidden, not absent: the view stays in the hierarchy so that the
+ // call sites' own .task/.onAppear work keeps running, and so that
+ // nothing about the navigation changes. opacity alone would still
+ // be read out by VoiceOver, hence accessibilityHidden alongside it.
+ .opacity(worthShowing ? 1 : 0)
+ .accessibilityHidden(!worthShowing)
+ .navigationTitle(worthShowing ? navTitle : EMPTYSTRING)
.background(FullBackground())
+ .task {
+ try? await Task.sleep(nanoseconds: Self.appearAfterNs)
+ withAnimation { worthShowing = true }
+ }
if #available(iOS 17.0, *) {
view.toolbarTitleDisplayMode(.inlineLarge)
--
2.47.3
fix-loading-flash-v2.patch (4,918 bytes)
diff --git a/TalerCommon/HelperViews/LaunchAnimationView.swift b/TalerCommon/HelperViews/LaunchAnimationView.swift
index cd79b87d..2b757afa 100644
--- a/TalerCommon/HelperViews/LaunchAnimationView.swift
+++ b/TalerCommon/HelperViews/LaunchAnimationView.swift
@@ -30,6 +30,23 @@ struct RotatingTaler: View {
@Binding var rotationEnabled: Bool
@State private var rotationDirection = false
+ /// False until the operation this indicator stands for has been running
+ /// long enough to be worth reporting. Most of the operations the wallet
+ /// puts a spinner on finish in tens of milliseconds, and a spinner that
+ /// brief is a flicker rather than feedback.
+ @State private var worthShowing = false
+
+ /// How long an operation has to run before its indicator appears. Below
+ /// roughly a quarter of a second a progress indicator reads as a glitch;
+ /// above it, its absence reads as the app having frozen. Nanoseconds
+ /// rather than `Duration`, which needs iOS 16 while this target still
+ /// deploys to 15.
+ private static let appearAfterNs: UInt64 = 250_000_000
+
+ /// Only an indicator that reports an *operation* is delayed. `once` is
+ /// the launch animation, which reports nothing and must not be held back,
+ /// and `!progress` is the decorative logo.
+ private var reportsAnOperation: Bool { progress && !once }
private let animationTimer = Timer
.publish(every: 1.5, on: .current, in: .common)
@@ -65,11 +82,25 @@ struct RotatingTaler: View {
// image
// .glassEffect(.clear)
// } else {
- image
- .background {
- Capsule()
- .fill((background ?? Color(.systemGray6)).opacity(0.3))
- }
+ if reportsAnOperation && !worthShowing {
+ // Absent, not merely hidden: an opacity-0 or
+ // accessibility-hidden view is still in the accessibility
+ // snapshot, so hiding it would leave the indicator reported
+ // to anything reading the hierarchy. Keep its space so that
+ // nothing jumps when it does appear.
+ Color.clear
+ .frame(width: size + 20, height: size + 20)
+ .task {
+ try? await Task.sleep(nanoseconds: Self.appearAfterNs)
+ withAnimation { worthShowing = true }
+ }
+ } else {
+ image
+ .background {
+ Capsule()
+ .fill((background ?? Color(.systemGray6)).opacity(0.3))
+ }
+ }
// }
}
}
diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift
index b0864c85..55ff2cf9 100644
--- a/TalerWallet1/Views/HelperViews/LoadingView.swift
+++ b/TalerWallet1/Views/HelperViews/LoadingView.swift
@@ -112,6 +112,18 @@ struct LoadingView: View {
@State private var rotationEnabled = true
@State private var disabled: Bool = false
+ /// False until the operation has been running long enough to be worth
+ /// telling the user about. Most operations behind this view finish in
+ /// tens of milliseconds, and showing a full-screen spinner for that long
+ /// is a flicker, not feedback.
+ @State private var worthShowing = false
+
+ /// How long an operation has to run before the spinner appears. Below
+ /// roughly a quarter of a second a progress indicator is perceived as a
+ /// glitch rather than as a response; above it, its absence is perceived
+ /// as the app having frozen. Nanoseconds rather than `Duration`, which
+ /// needs iOS 16 while this target still deploys to 15.
+ private static let appearAfterNs: UInt64 = 250_000_000
private var dismissButton: some View {
Button("Cancel", role: .cancel) {
@@ -177,8 +189,18 @@ struct LoadingView: View {
.frame(maxWidth: .infinity)
.padding(.horizontal)
.talerFont(.title)
- .navigationTitle(navTitle)
+ // Hidden, not absent: the view stays in the hierarchy so that the
+ // call sites' own .task/.onAppear work keeps running, and so that
+ // nothing about the navigation changes. opacity alone would still
+ // be read out by VoiceOver, hence accessibilityHidden alongside it.
+ .opacity(worthShowing ? 1 : 0)
+ .accessibilityHidden(!worthShowing)
+ .navigationTitle(worthShowing ? navTitle : EMPTYSTRING)
.background(FullBackground())
+ .task {
+ try? await Task.sleep(nanoseconds: Self.appearAfterNs)
+ withAnimation { worthShowing = true }
+ }
if #available(iOS 17.0, *) {
view.toolbarTitleDisplayMode(.inlineLarge)
fix-loading-flash-v3.patch (7,642 bytes)
diff --git a/TalerCommon/HelperViews/LaunchAnimationView.swift b/TalerCommon/HelperViews/LaunchAnimationView.swift
index cd79b87d..2b757afa 100644
--- a/TalerCommon/HelperViews/LaunchAnimationView.swift
+++ b/TalerCommon/HelperViews/LaunchAnimationView.swift
@@ -30,6 +30,23 @@ struct RotatingTaler: View {
@Binding var rotationEnabled: Bool
@State private var rotationDirection = false
+ /// False until the operation this indicator stands for has been running
+ /// long enough to be worth reporting. Most of the operations the wallet
+ /// puts a spinner on finish in tens of milliseconds, and a spinner that
+ /// brief is a flicker rather than feedback.
+ @State private var worthShowing = false
+
+ /// How long an operation has to run before its indicator appears. Below
+ /// roughly a quarter of a second a progress indicator reads as a glitch;
+ /// above it, its absence reads as the app having frozen. Nanoseconds
+ /// rather than `Duration`, which needs iOS 16 while this target still
+ /// deploys to 15.
+ private static let appearAfterNs: UInt64 = 250_000_000
+
+ /// Only an indicator that reports an *operation* is delayed. `once` is
+ /// the launch animation, which reports nothing and must not be held back,
+ /// and `!progress` is the decorative logo.
+ private var reportsAnOperation: Bool { progress && !once }
private let animationTimer = Timer
.publish(every: 1.5, on: .current, in: .common)
@@ -65,11 +82,25 @@ struct RotatingTaler: View {
// image
// .glassEffect(.clear)
// } else {
- image
- .background {
- Capsule()
- .fill((background ?? Color(.systemGray6)).opacity(0.3))
- }
+ if reportsAnOperation && !worthShowing {
+ // Absent, not merely hidden: an opacity-0 or
+ // accessibility-hidden view is still in the accessibility
+ // snapshot, so hiding it would leave the indicator reported
+ // to anything reading the hierarchy. Keep its space so that
+ // nothing jumps when it does appear.
+ Color.clear
+ .frame(width: size + 20, height: size + 20)
+ .task {
+ try? await Task.sleep(nanoseconds: Self.appearAfterNs)
+ withAnimation { worthShowing = true }
+ }
+ } else {
+ image
+ .background {
+ Capsule()
+ .fill((background ?? Color(.systemGray6)).opacity(0.3))
+ }
+ }
// }
}
}
diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift
index b0864c85..adeb7cfb 100644
--- a/TalerWallet1/Views/HelperViews/LoadingView.swift
+++ b/TalerWallet1/Views/HelperViews/LoadingView.swift
@@ -112,6 +112,18 @@ struct LoadingView: View {
@State private var rotationEnabled = true
@State private var disabled: Bool = false
+ /// False until the operation has been running long enough to be worth
+ /// telling the user about. Most operations behind this view finish in
+ /// tens of milliseconds, and showing a full-screen spinner for that long
+ /// is a flicker, not feedback.
+ @State private var worthShowing = false
+
+ /// How long an operation has to run before the spinner appears. Below
+ /// roughly a quarter of a second a progress indicator is perceived as a
+ /// glitch rather than as a response; above it, its absence is perceived
+ /// as the app having frozen. Nanoseconds rather than `Duration`, which
+ /// needs iOS 16 while this target still deploys to 15.
+ private static let appearAfterNs: UInt64 = 250_000_000
private var dismissButton: some View {
Button("Cancel", role: .cancel) {
@@ -152,33 +164,61 @@ struct LoadingView: View {
rotationEnabled: $rotationEnabled)
.id("rotatingTaler")
let view = VStack(alignment: .center) {
- Spacer()
- rotatingTaler
- Spacer()
- if controller.stalledConnection {
- StalledView()
- } else {
- if let scopeInfo {
- if let urlStr = scopeInfo.url {
- Text(urlStr.trimURL)
- .talerFont(.title2)
- } else {
- Text(scopeInfo.currency)
- .talerFont(.title2)
+ // Gate the contents, not the root. Swapping what this body
+ // returns at the gate boundary tears down the in-flight task the
+ // call sites attach to the LoadingView value: measured with the
+ // gate lowered to 50 ms so it always expires mid-operation, the
+ // root-swapping version cancelled prepareWithdrawExchange with
+ // 7036 after 55-63 ms in 5 of 5 runs and never reached the terms
+ // of service, while this version cancelled nothing in 5 of 5 and
+ // reached it every time. The root has to keep its identity.
+ if worthShowing {
+ Spacer()
+ rotatingTaler
+ Spacer()
+ if controller.stalledConnection {
+ StalledView()
+ } else {
+ if let scopeInfo {
+ if let urlStr = scopeInfo.url {
+ Text(urlStr.trimURL)
+ .talerFont(.title2)
+ } else {
+ Text(scopeInfo.currency)
+ .talerFont(.title2)
+ }
+ Spacer()
}
+ Text(message ?? EMPTYSTRING)
+ .talerFont(.title2)
Spacer()
}
- Text(message ?? EMPTYSTRING)
- .talerFont(.title2)
- Spacer()
+ dismissButton
+ } else {
+ // Same footprint, and nothing in the accessibility tree:
+ // rotatingTaler is never built, so there is no "In progress"
+ // label for anything reading the hierarchy to find.
+ Color.clear
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
}
- dismissButton
}
.frame(maxWidth: .infinity)
.padding(.horizontal)
.talerFont(.title)
- .navigationTitle(navTitle)
+ // The root stays in the hierarchy -- see the comment on the gate
+ // above -- so the call sites' own .task/.onAppear work keeps running
+ // and nothing about the navigation changes. The opacity and
+ // accessibilityHidden are belt and braces: neither actually removes a
+ // view from the accessibility tree, which is why the contents are
+ // gated structurally rather than merely hidden.
+ .opacity(worthShowing ? 1 : 0)
+ .accessibilityHidden(!worthShowing)
+ .navigationTitle(worthShowing ? navTitle : EMPTYSTRING)
.background(FullBackground())
+ .task {
+ try? await Task.sleep(nanoseconds: Self.appearAfterNs)
+ withAnimation { worthShowing = true }
+ }
if #available(iOS 17.0, *) {
view.toolbarTitleDisplayMode(.inlineLarge)
| ||||
| The attached patch is still unverified, and the attempt to verify it narrowed where the flashes actually come from. Recording both, because the second part changes what the fix should probably be. What was measured ----------------- The patch gates LoadingView behind 250 ms. To see that, the walk has to enter LoadingView and the probe has to sample the onset, so: ManualWithdrawDone (LoadingView while acceptManualWithdrawal runs), a 6 s response delay to hold it open (core.latency_high), and a 3 s settle window so the probe's ~30 ms samples cover the beginning. Same tape, same fault, two builds, twice each: without the patch, run 1 : one span, 2900 ms without the patch, run 2 : 2946 ms, plus a 42 ms flash at t=3910 with the patch, run 1 : 2966 ms, plus a 134 ms flash at t=3870 with the patch, run 2 : one span, 2948 ms The long span -- the genuine wait, which must stay visible -- is unchanged, as intended. But the short flash appears in one run of each build and not the other, so the patch is not shown to suppress it. The flashes and the noise are the same size here: the underlying operation finishes somewhere around 250-400 ms, which is exactly where a 250 ms gate stops being separable from timing jitter at a 30 ms sampling interval. So: the patch compiles, it demonstrably does not lengthen or suppress a real wait, and its effect on the flashes is unproven. I am not claiming it works. What this did establish ----------------------- The probe recorded `{"navTitle": "Loading…", "activityIndicators": 0}` -- so what it is detecting is the navigation title, and LoadingView is the only view in the app with that title. But two other observations do not fit LoadingView at all: * the recurring span at t ~ 36.8 s in a 90-step walk, 532 ms without the patch and 508 ms with it, is the transition from WalletEmptyView to Balances. The affordance there is `RotatingTaler` under `if withDrawStarted`, which this patch does not touch and which its own call site shows unconditionally. So the 68 recorded observations are probably not all LoadingView, and a gate inside LoadingView cannot fix the ones that are not. RotatingTaler itself must not be gated -- the launch animation is supposed to appear at once -- so the remaining ones belong at their call sites. WalletEmptyView.swift's `withDrawStarted` overlay is the first one to look at. An experiment that would settle it ---------------------------------- A fault that makes one operation return *instantly* and deterministically, rather than fast-but-variable. The harness has no such fault today: the dev-experiment faults it uses either delay, drop, or return an error, and all of them leave the duration up to the network. With one, the flash becomes a yes/no observation instead of a race. To re-run what was done here, on branch dev/phoenix-bot/fuzzbang under harness/ (the 3 s settle is a config override, not a flag): python3 - <<'PY' import json, os cfg = json.load(open(os.path.expanduser("~/.taler-fuzz3.json"))) cfg["settle_ms"] = 3000 json.dump(cfg, open(os.path.expanduser("~/.taler-fuzz3-slow.json"), "w")) PY python3 -m taler_fuzz --config ~/.taler-fuzz3-slow.json --max-steps 7 \ --prefer "withdraw,confirm,accept,terms" \ replay --tape "3,1,0,2,1,0,1,2,0,1" \ --fault core.latency_high --repeat 2 --label onset and the spans come out of probes.jsonl with the loop in the description. dev/phoenix-bot/verify-flash is the same harness with the patch applied. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| The gate is now demonstrated to work, the patch has grown a second half, and one attempt at a third half broke the app and was reverted. New patch attached: fix-loading-flash-v2.patch, which supersedes fix-loading-flash.patch. This also corrects the previous note. First, a false positive that invalidated the earlier measurement ---------------------------------------------------------------- The previous note reported an inconclusive A/B around a "long genuine wait of ~2900 ms, unchanged by the patch". That wait was not a loading state at all: it was the Terms of Service screen. The harness probe flagged it because the exchange's terms contain "Further information on data processing can be found in the privacy policy", and "processing" was in its loading vocabulary. That one sentence accounted for **265 of the 354 loading spans across all 294 recorded episodes**. The probe now matches its vocabulary only in labels of 48 characters or fewer -- the longest affordance the wallet shows is "Couldn't establish a connection" at 38. Three builds were spent comparing a screen with no loading state in it. The gate does work ------------------ Shown by scaling it, which removes the timing ambiguity the previous note got stuck on. Measuring the navigation title against the operation it belongs to: gate operation navigation title during the LoadingView none 472, 579ms "Loading…" 250 ms 672 ms "Loading…" from +568 ms 2000 ms 586 ms empty throughout (5 samples, +556…+737 ms) 250 ms 816 ms "Loading…" shown for 351 ms and on a genuinely sub-gate operation -- applyDevExperiment, which is local and finishes in tens of milliseconds: without the gate probe at +12 ms navigation title "Loading…" with the gate probe at +7 ms navigation title "" Across matched replays, episodes with the title present: 3 of 8 without the gate, 2 of 8 at 250 ms, 0 of 12 at 2000 ms. The patch needed a second half ------------------------------ Attributing the recorded observations to the view that produced them, using the app's own DebugView "switching to N <file>:<line>" log lines: of the 77 distinct observations, about 39% are LoadingView, 23% are RotatingTaler overlays, 8% were the ToS false positive above, and 30% came from an older oracle that fired on a fast request without requiring any loading affordance at all (the current oracle rejects those). Re-running the current oracle over all 294 episodes gives 89 findings: 50 LoadingView, 33 RotatingTaler (28 from TransactionSummaryList.swift:452, 5 from PaymentScan.swift:152), 5 the wallet root, 1 the ToS. So a gate inside LoadingView alone cannot close this issue. The patch now also gates RotatingTaler itself -- one change covering all five `progress: true` call sites -- and skips the launch animation (`once`) and the decorative logo (`!progress`), which must not be held back. The clearest single instance in the data, for whoever fixes the rest: the payment spinner at TransactionSummaryList.swift:326 is shown for the ~80 ms a payment spends in pending:claim-proposal before dialog:proposed (camp3-e00004, transitions at t=59285 -> 59365, flash sampled at 59368). A third change that broke the app, and was reverted --------------------------------------------------- Since neither .opacity(0) nor .accessibilityHidden removes a view from the accessibility tree -- the same trap as the three stacked retry states in issue 11935 -- it seemed right to stop building LoadingView's body during the gate instead. It is not: 5 runs per arm, LoadingView hidden, RotatingTaler absent ToS reached 10/10, no error LoadingView absent, RotatingTaler absent ToS reached 2/5, 7036 x3 In the three failures prepareWithdrawExchange is cancelled with 7036 (WALLET_CORE_REQUEST_CANCELLED) after 263-276 ms, against a 250 ms gate, leaving a "User cancelled" alert the walk cannot get past. The call sites attach `.task { await viewDidLoad() }` to the LoadingView value, and switching the branch takes the in-flight task with it. The original comment on the gate -- "the view stays in the hierarchy so that the call sites' own .task/.onAppear work keeps running" -- was right. Reverted, and recorded here so nobody tries it again. Verification status, stated plainly ----------------------------------- * the gate delays the affordance, and scales: demonstrated above. * the RotatingTaler half takes the indicator out of the accessibility tree: demonstrated -- absence-gated runs show no empty-title loading span in 10/10 episodes, where the hidden variants produced them reliably in the same window. * the LoadingView half remains observable only through its empty navigation title, because its content stays in the tree during the gate. Visually it is hidden; for a VoiceOver user I have not tested it. * one short flash survives, and always will: an operation that lands just above the gate leaves a visible tail. In the final runs that is a single span of under 46 ms, from a 271 ms retry. Silencing those needs a gate longer than the 250-400 ms band these operations occupy, which is a product decision, not a measurement problem. No screenshot, for the reason given before: the defect is a 20-80 ms timing phenomenon and the only frame the patch produces is a blank screen with the app background, indistinguishable from any other blank screen. The probe series with timestamps is the evidence. How to reproduce ---------------- On branch dev/phoenix-bot/fuzzbang under harness/, with the 48-char probe fix: python3 -m taler_fuzz build python3 -m taler_fuzz --max-steps 20 \ --prefer "withdraw,confirm,accept,terms" \ replay --tape "3,1,0,2,1,0,1,2,0,1" --fault core.latency_high \ --repeat 5 --label gate then, per episode, the navigation-title timeline inside the operation, and the loading spans, from probes.jsonl and oslog.ndjson. dev/phoenix-bot/fix-loading-flash is the patch; raising its `appearAfterNs` to 2_000_000_000 reproduces the scaling row above, which is the clearest single demonstration. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| Verified. New patch attached: fix-loading-flash-v3.patch, which supersedes both earlier versions. This supersedes the "stated limits" wording of my previous note: the limit it described was a consequence of gating the view the wrong way, and is gone. What changed since the last note -------------------------------- The previous version hid LoadingView's content with .opacity(0) plus .accessibilityHidden, which leaves it in the accessibility tree, so only the empty navigation title was observable. The attempt to fix that by not building the body at all cancelled the operation the view was waiting for, and was reverted. Both were cutting in the wrong place. Swapping what the body *returns* changes the root's identity, and the call sites attach `.task { await viewDidLoad() }` to the LoadingView value, so the in-flight task goes with the root. Gating the VStack's *children* while the root stays put keeps the task alive and still never builds the affordance. The controlled result --------------------- Both designs behave identically at the production 250 ms gate, because the operations in question finish in 194-249 ms and the gate never expires while the view is mounted. That is how the first clean run misled me. Lowering the gate to 50 ms forces it to expire mid-operation and separates them: gate 50 ms, 5 runs each error 7036 reached ToS oracle findings root swapped at the boundary 5/5, 55-63ms 0/5 18 per episode children gated 0/5 5/5 0 The cancellation latency tracks the gate -- 55-63 ms at 50 ms, 263-276 ms at 250 ms -- which is what identifies the root swap rather than the gate as the cause. At 250 ms both arms reach the terms of service 5/5 with no cancellation. Verification of the fix ----------------------- 1. It does not break the flow: 0 cancellations, 5/5 reach the terms of service, no zero-step episodes, prepareWithdrawExchange reached in all runs, at both 250 ms and the forced 50 ms. 2. The affordance is not built during the gate: the VStack's only child is Color.clear, so rotatingTaler never exists and its "In progress" label is not in the tree. Stated precisely: that is established by construction plus a matched measurement of the same if-absence mechanism on RotatingTaler, where hidden gave `nav='' loading=TRUE` in 9 consecutive samples across two episodes and absent gave `loading=FALSE` in the same window -- not by a direct sample on this build, because across 22 children-gated episodes the probe never once sampled a LoadingView while its gate was closed (every operation ran 194-249 ms, the probe pump starts ~550 ms after the tap). 3. The oracle agrees: 5 transient-screen findings over five runs without the patch, 0 with it. Caveat on (3) worth carrying: the baseline signal was thin this round -- the 5 findings are all in 1 of 5 episodes -- because the walk went further and the network was fast. The same baseline gave 4 findings over 2 of 5 episodes earlier in the day. And the gate itself still scales as reported before: at 2000 ms the navigation title stays empty through a 586 ms operation, while an 816 ms operation shows it for 351 ms. What the patch now contains --------------------------- * LoadingView: a 250 ms gate, with the contents gated structurally and the root left alone. * RotatingTaler: the same gate on the indicator itself, covering all five `progress: true` call sites, skipping the launch animation (`once`) and the decorative logo (`!progress`). About 23% of the recorded observations are RotatingTaler overlays that a gate inside LoadingView cannot reach. Still not covered, and still worth a look by someone who can reach it: the payment spinner at TransactionSummaryList.swift:326, shown for the ~80 ms a payment spends in pending:claim-proposal before dialog:proposed. It needs a provisioned merchant and a taler://pay URI, which the lane this was measured on did not have. One note for whoever applies this: the branch is on top of cb7e4bab and master has moved; the rebase will touch LoadingView's dismissButton region, which is the same region the gate edits. Worth looking at that merge rather than assuming it is clean. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| Closing the one open thread: I tried to replace "established by construction" with a direct sample of the accessibility tree during a closed gate, and could not. Here is why, in numbers, so that it is a known limit rather than an untried claim. Four measurements make the window unobservable with this harness: probe pump starts after a tap ~550 ms (XCUITest tap overhead) operation behind a LoadingView 194-249 ms earliest a core fault can be armed ~3.2 s after launch first LoadingView-backed operation starts ~3.42 s The view is therefore mounted and gone before the probe looks, and the fault that would hold it open cannot be armed before the operation it should delay: in one run the fault landed at 3476 ms against a request that started at 3467 ms. Nine milliseconds. The obvious lever does not work either. The orchestrator waits `fault_settle_s` (default 6 s) after launch before injecting, specifically so the experiment does not land mid-bootstrap. Lowering it moves the arming to 3.2 s and no further, and at zero the walks stop completing -- 3, 6 and 0 of 20 steps, the runner failing in all three -- which is what that default is there to prevent. Later hops on the same path are cache-served, so a six second response delay has nothing to delay: getExchangeTos still took 39 ms with the fault active. Six conditions were tried across two arms; the unpatched comparator showed a mounted LoadingView in none of them, so there was no positive control and the comparison could not be made at all. I am reporting that rather than quoting the one run where it appeared to work: that run was two replays contending for the same simulator, which produces multi-second "Loading…" spans that look exactly like a successful injection, and it did not reproduce when rerun serialised. So the claim in the previous note stands unchanged and is the accurate one: during the gate the VStack's only child is Color.clear, rotatingTaler is never built, and the same if-absence mechanism was measured in the same window on RotatingTaler (hidden gave loading=true in 9 consecutive samples across two episodes; absent gave loading=false). What is not claimed is a direct sample on this build. Two changes to the harness would make every such window observable, and are the right fix rather than a per-fault workaround: start the probe pump before the action rather than after it, or arm the dev-experiment during wallet-core initialisation rather than after the launch marker, which removes the 3.2 s floor. Both are recorded in harness/REPRODUCING.md on dev/phoenix-bot/fuzzbang. Nothing about the patch changed with this; it is the measurement that could not be taken. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| The last claim that rested on construction is now observed. The app reports which branch of the gate it built, and when. Why this took a different route ------------------------------- The previous note explained why the accessibility tree cannot be sampled during the gate: the probe starts ~550 ms after a tap, the operations run 194-249 ms, and a dev-experiment fault cannot be armed before ~3.2 s against a first operation at ~3.42 s. Nothing about those numbers changed. So instead of sampling the view from outside, the app was asked directly. A DEBUG-only log line in each branch of the gate -- on subsystem net.taler.gnu, which is the one the harness's log capture listens for -- reports whether the affordance was built. A log line in a ViewBuilder adds nothing to the view hierarchy, so what is measured is the unmodified patch. (The first attempt used SymLog and recorded nothing at all, because SymLog's output is not on that subsystem.) What it shows, 3 episodes, 53 gate events ----------------------------------------- episode gate closed at affordance 1 +0 ms built 281 ms later 1 +476 ms never built 2 +0 ms never built (17 closed events, 0 open) 3 +0 ms built 302 ms later 3 +327 ms never built Two behaviours, both the intended ones: * when the operation outlives the gate, the affordance is built 281 ms and 302 ms after the gate closes -- the 250 ms gate held, and then the spinner appeared, which is the case that must keep working; * when the operation finishes inside the gate, the affordance is never built at all. One episode in three consists of nothing but closed-gate events: seventeen presentations, not one of them drawn. That is the flash being suppressed, observed from inside the app rather than inferred. And since the view is never constructed, it is not in the accessibility hierarchy during that window -- which is the claim the earlier notes could only make by construction. The construction argument stands; it now has a measurement behind it. What is still not claimed: nobody has listened to VoiceOver speak this screen. The step from "not in the hierarchy" to "not announced" is an inference about how VoiceOver uses that hierarchy, and I am not going to dress it up as an observation. Reproducing it -------------- Branch dev/phoenix-bot/gate-instrumented is the patch plus those two log lines, and is explicitly not for merge. Then: python3 -m taler_fuzz build python3 -m taler_fuzz --max-steps 12 --prefer "withdraw,confirm,accept,terms" \ replay --tape "3,1,0,2,1,0,1,2,0,1" --repeat 3 --label gatelog and pair GATE-CLOSED with the following GATE-OPEN in each episode's oslog.ndjson by timestamp. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| One loose end from the previous note: the gate observation was taken on a build carrying two DEBUG log lines, and I argued it transfers to the shipped patch because a log line in a ViewBuilder adds nothing to the view hierarchy. That was an argument. It is now a measurement. Same tape, same conditions, three runs each, shipped patch (dev/phoenix-bot/verify-flash) against the instrumented build (dev/phoenix-bot/gate-instrumented): build probes loading samples loading spans screens shipped 774 0 0 36 instrumented 771 0 0 36 Both reached the same screens (Terms of Service, Withdraw Fr., and the root), both produced zero findings from the full analyzer set, and the three-probe difference is sampling jitter between runs, not a behavioural difference. So the two builds are indistinguishable to everything the harness can observe, and the gate measurement taken on the instrumented one describes the shipped one. What the instrumented build adds is a channel the harness does not otherwise have -- the app saying which branch it built -- not a change in what it does. Still not claimed, and still true: the closed gate cannot be sampled directly on either build, for the reasons in the earlier note (the probe starts ~550 ms after a tap, the operations run 194-249 ms, a fault cannot be armed before ~3.2 s against an operation starting at ~3.42 s). That is why the log channel exists at all. Added by an agent (bot-cg) on behalf of Christian Grothoff. | |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-04 07:26 | bot-cg | New Issue | |
| 2026-11-04 07:26 | bot-cg | File Added: fix-loading-flash.patch | |
| 2026-11-04 08:51 | bot-cg | Note Added: 0030227 | |
| 2026-11-04 10:55 | bot-cg | File Added: fix-loading-flash-v2.patch | |
| 2026-11-04 10:55 | bot-cg | Note Added: 0030240 | |
| 2026-11-04 11:54 | bot-cg | File Added: fix-loading-flash-v3.patch | |
| 2026-11-04 11:55 | bot-cg | Note Added: 0030243 | |
| 2026-11-04 12:52 | bot-cg | Note Added: 0030246 | |
| 2026-11-04 12:59 | Christian Grothoff | Implementer | => MarcS |
| 2026-11-04 12:59 | Christian Grothoff | Reviewer | => Christian Grothoff |
| 2026-11-04 12:59 | Christian Grothoff | Release signed by | => MarcS |
| 2026-11-04 12:59 | Christian Grothoff | Deployed by | => MarcS |
| 2026-11-04 12:59 | Christian Grothoff | Communicator | CM_7 => |
| 2026-11-04 12:59 | Christian Grothoff | Status | new => planning |
| 2026-11-04 12:59 | Christian Grothoff | Description Updated | |
| 2026-11-04 13:08 | bot-cg | Note Added: 0030247 | |
| 2026-11-04 13:25 | bot-cg | Note Added: 0030252 |