View Issue Details

IDProjectCategoryView StatusLast Update
0011937Talerwallet (iOS App)public2026-11-04 13:25
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerMarcS ReviewerChristian Grothoff  
Release signed byMarcS Deployed byMarcS  
VerifierCommunicator 
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeveritytweakReproducibilityhave not tried
Status planningResolutionopen 
Summary0011937: Loading…: a full-screen loading state is shown for operations that finish in 21-50 ms, so it flickers instead of informing
DescriptionLoading… 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.
TagsNo 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.patch (3,541 bytes)   
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-v2.patch (4,918 bytes)   
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)
fix-loading-flash-v3.patch (7,642 bytes)   

Required notifications

Address or linkRemarkAdded byNotifiedActions

The plan

IDSummaryStatusAssigned ToEstimated effort (hours)
This issue has no child issue. Work that is big enough to be divided is divided here, and each part is planned on its own.
This issue (hours)
Its children (hours)0
Together (hours)0
Nothing is missing: the plan can be finished.

Activities

bot-cg [agent]

2026-11-04 08:51

reporter   ~0030227

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.

bot-cg [agent]

2026-11-04 10:55

reporter   ~0030240

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.

bot-cg [agent]

2026-11-04 11:55

reporter   ~0030243

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.

bot-cg [agent]

2026-11-04 12:52

reporter   ~0030246

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.

bot-cg [agent]

2026-11-04 13:08

reporter   ~0030247

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.

bot-cg [agent]

2026-11-04 13:25

reporter   ~0030252

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.

Issue History

Date ModifiedUsernameFieldChange
2026-11-04 07:26bot-cgNew Issue
2026-11-04 07:26bot-cgFile Added: fix-loading-flash.patch
2026-11-04 08:51bot-cgNote Added: 0030227
2026-11-04 10:55bot-cgFile Added: fix-loading-flash-v2.patch
2026-11-04 10:55bot-cgNote Added: 0030240
2026-11-04 11:54bot-cgFile Added: fix-loading-flash-v3.patch
2026-11-04 11:55bot-cgNote Added: 0030243
2026-11-04 12:52bot-cgNote Added: 0030246
2026-11-04 12:59Christian GrothoffImplementer => MarcS
2026-11-04 12:59Christian GrothoffReviewer => Christian Grothoff
2026-11-04 12:59Christian GrothoffRelease signed by => MarcS
2026-11-04 12:59Christian GrothoffDeployed by => MarcS
2026-11-04 12:59Christian GrothoffCommunicatorCM_7 =>
2026-11-04 12:59Christian GrothoffStatusnew => planning
2026-11-04 12:59Christian GrothoffDescription Updated
2026-11-04 13:08bot-cgNote Added: 0030247
2026-11-04 13:25bot-cgNote Added: 0030252