Skip to content

Stop the widget chart drawing past its data, and add a refresh button - #341

Merged
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:fix/ios-widget-refresh
Sep 9, 2026
Merged

Stop the widget chart drawing past its data, and add a refresh button#341
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:fix/ios-widget-refresh

Conversation

@Ryanmello07

@Ryanmello07 Ryanmello07 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The report was "the widgets don't update". Three separate defects were behind it, and the worst one is not staleness.

The chart was drawing data it did not have

Every entry in a timeline carries the same snapshot at a later date — getTimeline reads the App Group once and re-dates it. But the throughput window was anchored to entry.date, and the draw loop walks while bucket <= now substituting byStart[bucket] ?? 0 for missing buckets.

Past the newest bucket the tunnel published, that substitution stops meaning "no traffic" and starts meaning "not measured yet" — and it was drawn as a flat zero line. With four entries five minutes apart, the last one fabricated 15 minutes of "no traffic" across a quarter of the plot while traffic was flowing, under a header still reporting a multi-Mbps peak from buckets that had scrolled off the left.

The zero fill itself is correct for interior gaps: the accumulator appends no bucket for an idle minute, so an absent interior bucket genuinely means zero. The bug is the loop bound walking past the data horizon. So this anchors the window to the snapshot's own clock instead — min(entry.date, entry.tunnel.updatedAt), clamped rather than swapped so a future change to the entry count degrades to frozen-but-true instead of collapsing again.

Reporting staleness is the footer's job. The chart's job is to be true.

The freshness label under it under-reported

updatedLabel was computed from entry.date, so it stepped in five-minute jumps and then froze for whatever remained of the interval — and four entries at five minutes covered only 15 minutes of a 20-minute policy, so the last stretch of every cycle showed an age that had stopped advancing. It becomes a relative Text, which is the only element WidgetKit advances on screen without a reload, bound to the snapshot's updatedAt rather than the render's date.

Reviewer note: this reopens a decision recorded in the code — the comment at DashboardView.swift and the matching one in ProviderGlobeWidget.swift both note Text(date, style: .relative) being rejected for reserving its widest width. The distinction I am relying on is that those were trailing elements competing inside an HStack, while this is a lone caption with the elastic part terminal. That is reasoning, not a measurement, and it wants an on-device look; the fallback is the formatted label. Note also that .relative renders "3 min", not "3 min. ago".

The cadence was over-subscribed, not too slow

The same 40–70/day budget figure was restated in three files, and each then picked a number in isolation: the timeline asked every 20 minutes (72/day) while the extension's routine throttle asked every 15 (96/day). The two clocks do not add up — every reload re-arms the timeline's .after(...), so the faster one wins and the slower one's budget is spent for nothing. Over-requesting is not free; the system answers an over-subscribed budget with deferrals, which is how a design asking twice an hour ended up refreshing less often than either of its own numbers.

Worse, the extension's throttles were per-reason rather than per-kind, so one widget could be asked for far more often than any of those numbers: the globe every 2 minutes (720/day) and contracts every 3 (480/day).

This collapses every cadence into one WidgetRefreshPolicy with the arithmetic as its doc comment, makes the throttles per-kind, and makes the extension's a backstop that is deliberately slower than the timeline policy so it fills a gap rather than racing it. Entry count is derived so the last entry lands on the policy date, capped at six because WidgetKit archives every entry's rendered view up front and the globe archives a full render each.

Where freshness actually comes from

Not from that clock. From the two paths the repo already documents as not charged against the budget: a reload caused by an in-widget intent, and one requested while the app is in the foreground. Both are added here.

A refresh button on all three widgets. RefreshWidgetsIntent reads the current updatedAt as a baseline before signalling (or the wait can miss its own answer), asks the tunnel to publish, polls up to 2 s for a newer write, then reloads. The request crosses as a file plus a Darwin notification, because Darwin notifications are not queued for a suspended process and this extension is expected to be suspended — the writer serves a dropped one on its next 60 s tick instead of losing the tap. The file carries a 30 s window and is consumed either way, so a tap made while the tunnel was down cannot cause a surprise write when it next starts.

It deliberately does not reuse WidgetPreviewVisibility, which is the right shape and the wrong channel: applyPreviewVisibility returns early unless the flag actually flipped, so a second tap inside the 90 s mark would be a silent no-op; one tap would pin the extension to a 2 s write cadence for 90 s; and it requests no reload at all.

A foreground reload. Nothing on any app lifecycle path reloaded the widgets — the only app-side paths were a genuine NEVPNStatus transition and, by accident, opening Account > Widgets (which arms the preview mark, drops the writer to 2 s, and trips the routine throttle). Opening the app is now the reliable un-stick.

Also: vpnStatusDidChange reloaded unthrottled on every transition, and one connect delivers connecting, connected and sometimes reasserting, which the widgets draw identically. Now gated on the drawn state actually changing.

The button cannot fake success

The freshness label is bound to the snapshot's age, not the render's. Tunnel up, the extension writes and it snaps to seconds. Tunnel down there is no writer — teardown() cancels the timers on stop — so it keeps counting up, honestly reporting "I re-read; the data is still that old". The tap is still worth something there: the reload re-reads live NEVPNStatus, so a tunnel brought up from Settings with the app force-quit shows as connected.

What this cannot deliver

  • A live graph. WidgetKit archives the views at timeline-build time; between reloads nothing runs. Date-style Text is the complete set of elements that move. ThroughputChartView's own header already says as much.
  • A fast automatic cadence. ~40–70 reloads/day is a system budget. Asking for one minute produces deferrals, not one-minute refreshes.
  • Sub-minute data, tap or no tap. bucketSeconds is 60 and a bucket is only known once elapsed, so the newest point is always up to a minute old.
  • Anything while the tunnel is down. No writer process exists to answer.
  • Proof that the cadence change helps. There is no instrumentation anywhere in the widget target — no reload counters, no getCurrentConfigurations. That the automatic path improves rests on documented WidgetKit deferral behaviour, not on a measurement in this tree. Worth adding a getTimeline timestamp to the App Group in a follow-up, or the next person changing these constants is in the same position.

Tests

WidgetRefreshPolicyTests and WidgetReloadThrottleTests are the first automated assertions on any of these numbers. They pin the two decisions that are invisible at the call site and were each wrong in the shipped code: entries must reach the policy date, and the extension backstop must be slower than the timeline policy. The budget assertion fails against the shipped 20-minute value.

The throttle tests wait on a condition rather than a fixed sleep — a sleep-based version passed on an idle machine and failed under parallel load, which is the worst kind of test to leave in a suite.

Honest gap: the chart anchor, the most important change here, has no automated coverage. draw runs inside a Canvas closure and the widget target has no test target. It is covered by eye only.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa


Second commit: scale the throughput chart to what it draws

On-device testing of the first commit surfaced that the chart was still wrong, in a way the first fix did not touch and could not have. Three further defects, each with a test that failed before the change and passes after.

The scale came from buckets that are not on screen — the dominant one

peak and peakPackets were computed over every bucket in the snapshot, while draw plots only the last hour. Because WidgetThroughputAccumulator.currentBucket appends a bucket only for a minute that carried traffic, and bucketCount = 60 evicts by array length rather than by age, sixty buckets routinely span many hours.

So a burst from outside the window set the scale for a curve it was not part of: real recent traffic rendered as a flat line hugging the axis, and the header reported a peak rate from hours ago that never changed. That is exactly "the lines don't move and don't look right".

It is also sticky. init(resuming:) restores the history, so restarting the tunnel does not clear it — a poisoned bucket only leaves after sixty further traffic-bearing minutes, which for an intermittent user is days.

Scoping the peaks to the drawn window fixes a third thing for free: the provider placeholder is shown only when both peaks are zero, so one old providing session was suppressing it forever and drawing a flat pinned line instead.

The minute in progress was drawn at full weight

Each point is plotted at its bucket's end, because a bucket's traffic is only known once the minute has elapsed — the code says so at that line. But the loop ran while bucket <= now, so the newest point was a partial minute, and holdToEdge then dragged that partial value flat to the right edge. The end of the curve dived toward zero for reasons that had nothing to do with the network.

A counter going backwards banked an entire session into one minute

delta(from:to:) returned the raw value when it read lower than the previous sample, on the reasoning that a restarted session counts from zero. These counters are cumulative for the whole session, and the drop is not always a restart: the epoch worker snapshots its callback list before taking stateLock, which closeRemoteUserNatClientWithLock holds during a reconnect, so one tick can publish the retiring client's total on top of the new base and the next tick then reads lower.

Taking that as a delta wrote the entire session's byte count into a single minute — on a device that has moved 5 GB, one bucket at roughly 85 MB/s, which then owned the scale until sixty traffic-bearing minutes evicted it. Re-baselining costs at most one sample interval, which is one second. The alternative is unbounded.

Testability

The geometry moves into a pure ThroughputChartView.plot. draw runs inside a Canvas closure where nothing could observe what it computed, which is why none of this had ever been caught — and it is the gap the first commit's description called out as covered by eye only. It is now covered by assertions.

Also

The refresh request no longer consumes the tap and then drops it when it lands inside the write floor, and the intent waits four seconds rather than two. The extension serves the request on a .utility queue, which is precisely the work the system defers under Low Power Mode and thermal pressure, so the old bound could turn a working refresh into a visible no-op — the write landing just after the wait gave up, then going unread until the next timeline reload.

What was investigated and found NOT to be the cause

The reporting user's hypothesis was that the app being backgrounded stops the stats updating. It does not, and the distinction is worth recording: the SDK device, the packet-stats listeners, the accumulator and the writer all live in the packet tunnel extension, which keeps running with the app closed or force-quit. The counters are incremented on the packet path inside that same process, so a suspend cannot silently bank traffic that later lands in one bucket — if the process is frozen, no packets are forwarded, and the gap is genuinely idle. Sampling is a 1 Hz poll gated on change, so idle minutes correctly produce no bucket at all.

Ryanmello07 and others added 3 commits September 7, 2026 21:56
Three defects hid behind one report of "the widgets don't update".

The chart was the worst of them, and it was not staleness but a false
statement. Every entry in a timeline carries the SAME snapshot at a later
date, and the throughput window was anchored to the entry's date, so the
plot walked past the newest bucket the tunnel had published. Out there
`byStart[bucket] ?? 0` stops meaning "no traffic" and starts meaning "not
measured yet" -- drawn as a flat zero line across up to a quarter of the
plot while traffic was flowing. Anchor the window to the snapshot's own
clock instead, clamped so a future change to the entry count degrades to
frozen-but-true rather than collapsing again.

The freshness label under it was derived from the entry date too, so it
stepped in five-minute jumps and then froze for whatever remained of the
interval, under-reporting the age of what was on screen. It becomes a
relative Text, the one element WidgetKit advances without a reload, bound
to the snapshot's age rather than the render's.

The cadence was over-subscribed rather than too slow. The timeline asked
every 20 minutes (72 a day) while the extension asked every 15 (96 a day)
against a budget of roughly 40-70 -- and the per-reason throttles let one
kind be asked for far more often still: 720 a day for the globe, 480 for
contracts. The two clocks do not add up, because every reload re-arms the
timeline's `.after(...)`; the faster one wins and the slower one's budget
is spent for nothing. Collapse every number into one WidgetRefreshPolicy
with the arithmetic written down, make the throttles per-kind, and make
the extension's a backstop that is deliberately slower than the policy.

That buys honesty, not speed. The freshness a user feels comes from the
two paths that are not charged: a reload from an in-widget intent, and one
requested while the app is in the foreground. So add both -- a refresh
button on all three widgets that asks the tunnel to publish and waits
briefly for the write, and a reload on the app's foreground transition,
which nothing on any app lifecycle path was doing.

The request crosses as a file plus a Darwin notification, because
notifications are not queued for a suspended process and this extension is
expected to be suspended; the writer serves a dropped one on its next tick.
The button cannot fake success: with the tunnel down there is no writer, so
the label keeps counting up.

A live graph remains impossible. WidgetKit archives the views at
timeline-build time and only date-style Texts move between reloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa
The chart took its vertical scale from every bucket in the snapshot, not
from the hour it draws. The accumulator records a bucket only for a minute
that carried traffic, so its sixty buckets can span many hours -- which
means a burst from outside the window routinely set the scale for a curve
it was not part of. Real recent traffic was squashed onto the axis and the
peak label reported a rate from hours ago that never changed. That is the
"the lines don't move and don't look right" report, and it is sticky: the
history is restored on resume, and buckets are evicted by array length, so
a poisoned bucket only leaves after sixty further traffic-bearing minutes.

Scope peak and peakPackets to the drawn window. The labels read the same
plot as the curve, so the header can no longer report a rate that is
nowhere on the chart -- and the provider placeholder, which is shown only
when both peaks are zero, stops being suppressed forever by one old
providing session.

Stop plotting the minute in progress. Each point is drawn at its bucket's
END because the traffic is only known once the minute has elapsed, but the
loop ran to `now`, so the newest point was a partial minute drawn at full
weight and then held flat to the right edge. The end of the curve dived
toward zero for reasons that had nothing to do with the network.

Re-baseline a counter that goes backwards instead of taking it as a delta.
These counters are cumulative for the session, and the drop is not always a
restart: a reconnect can publish one tick carrying the retiring client's
total on top of the new base, and the next tick then reads lower. Taking
that as a delta wrote the ENTIRE session's byte count into a single minute
-- a bucket orders of magnitude above anything real, which then owned the
scale until sixty traffic-bearing minutes evicted it. Re-baselining drops
at most one sample interval, which is one second. The alternative is
unbounded.

The geometry moves into a pure `plot` so it can be asserted at all; `draw`
runs inside a Canvas closure where nothing could observe what it decided,
which is why none of this had ever been caught.

Also: the refresh request no longer consumes the tap and then drops it when
it lands inside the write floor, and the intent waits four seconds rather
than two. The extension serves the request on a .utility queue, which is
exactly the work the system defers under Low Power Mode, so the old bound
turned a working refresh into a visible no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa
The widget kept one bucket per minute over an hour while the app's chart
draws one point per second over a minute. The two were never going to
look alike, and at that resolution a minute of real traffic was a single
point -- which is why the curve read as dead even once it was correct.

The reasoning for the hour confused two kinds of staleness. The snapshot
is written by the tunnel on its own timer whatever the widget is doing,
so whenever WidgetKit rebuilds a timeline it reads a file at most one
write interval old. What goes stale between reloads is the RENDERED view,
and no window size changes that. The hour bought nothing for it and cost
the chart all of its detail.

So record one second per bucket over a minute. The chart derives its
window from the snapshot's own bucketSeconds, so this needed no change
there and a snapshot written by an older build still renders as the hour
it was recorded as. The scale floor moves with the bucket size, staying
the same rate rather than becoming sixty times stricter.

Halve the write interval to 30s. The newest bucket a reload can find is
one write old, and a whole window of lag would leave the widget drawing
the minute BEFORE the last one. Writes are not the budgeted resource --
WidgetKit reloads are -- so this is paid in a few KB, not in refreshes.

The chart tests move to bucket and window multiples rather than literal
seconds, so they keep their meaning if the resolution changes again, and
one pins that a legacy coarse snapshot still renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa
@Ryanmello07
Ryanmello07 merged commit 7c9f929 into urnetwork:main Sep 9, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant