Skip to content

Status bar clock: stop allocating kilobytes per second - #244

Merged
ThomasFarstrike merged 1 commit into
MicroPythonOS:mainfrom
fdb:fix-status-bar-clock-allocation
Aug 10, 2026
Merged

ThomasFarstrike merged 1 commit into
MicroPythonOS:mainfrom
fdb:fix-status-bar-clock-allocation

Conversation

@fdb

@fdb fdb commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The top-bar clock is the single largest steady allocator in the OS:

  • update_time called mpos.time.localtime() THREE times per firing (once each for hours, minutes, seconds), and the timer fires every second.
  • Every localtime() call ran the full POSIX-TZ pipeline in localPTZtime: string splits, filter lists, two transition parses

Together that is ~4,5 KB of allocations per second on an ESP32, causing excessive garbage collection pauses.

Three changes:

  • topmenu.update_time calls localtime() once and indexes the tuple.
  • localPTZtime grows a public tzoffset(timestamp, ptz) that returns (utcoffset, isdst, valid_from, valid_until); that allows us to cache TZ calculations properly, even during DST transitions.
  • mpos.time.localtime() caches (ptz, offset, isdst, valid_from, valid_until) and reuses it while valid_from <= now < valid_until.

The cached path is one time.gmtime() plus one tuple, ~224 B. The full computation now runs about three times a year (two DST transitions and new year), plus on boot and on timezone change. We've done some extra work to make sure the validity interval is exact: the clock is correct to the second through DST transitions, and a backward clock jump (NTP correction) simply misses the cache and recomputes.

Verified against uncached tztime(): 409,690 comparisons across five zones (northern/southern DST, GMT0, angle-bracket half-hour offset, Julian-day rule) with second-by-second sweeps around every transition and year boundary 2025-2027, plus 1.64M randomized interval cross-checks 2020-2035: zero mismatches.

The top-bar clock is the single largest steady allocator in the OS:

- update_time called mpos.time.localtime() THREE times per firing (once
  each for hours, minutes, seconds), and the timer fires every second.
- Every localtime() call ran the full POSIX-TZ pipeline in localPTZtime:
  string splits, filter lists, two transition parses

Together that is ~4,5 KB of garbage per second on an ESP32, causing
excessive garbage collection pauses.

Three changes:

- topmenu.update_time calls localtime() once and indexes the tuple.
- localPTZtime grows a public tzoffset(timestamp, ptz) that returns
  (utcoffset, isdst, valid_from, valid_until). The offset a POSIX-TZ
  rule yields is piecewise constant in the timestamp: it changes only
  at the two DST transitions and at new year, and those instants fall
  directly out of the parse it already does. _timecalc delegates to
  it, so the branch logic exists once.
- mpos.time.localtime() caches (ptz, offset, isdst, valid_from,
  valid_until) and reuses it while valid_from <= now < valid_until.

The cached path is one time.gmtime() plus one tuple, ~224 B. The full
computation now runs about three times a year (two DST transitions and
new year), plus on boot and on timezone change. The validity interval
is exact, not a TTL heuristic: the clock is correct to the second
through DST transitions, and a backward clock jump (NTP correction)
simply misses the cache and recomputes.

Verified against uncached tztime(): 409,690 comparisons across five
zones (northern/southern DST, GMT0, angle-bracket half-hour offset,
Julian-day rule) with second-by-second sweeps around every transition
and year boundary 2025-2027, plus 1.64M randomized interval
cross-checks 2020-2035: zero mismatches.
@ThomasFarstrike

Copy link
Copy Markdown
Contributor

Oh waw, great find! These types of improvements are very much needed and appreciated!

@ThomasFarstrike
ThomasFarstrike merged commit de82d2b into MicroPythonOS:main Aug 10, 2026
13 of 15 checks passed
@ThomasFarstrike

Copy link
Copy Markdown
Contributor

@fdb FYI, @jnuyens flagged a few more of these which I didn't get to yet:

  • ui/view.py:41,89 — focus group never delete()d (leak per nav); screen leaks on remove_and_stop_all_activities path (clean() != delete(); normal back path frees via screen_load_anim(...,auto_del=True) at :109). Small cumulative leak. Delete the popped group; delete screen on the remove-all path.
  • ui/font_manager.py:259,296 — TTF/emoji font caches never freed (no destroy calls) → C-heap fragmentation per app. Add clear_cache() on teardown.
  • ui/topmenu.py:299,423,502 — create_notification_bar/drawer have no re-entry guard; timers + focusable lists leak on re-create.
  • ui/file_explorer_activity.py:293 — action-bar focusables not removed from group on dismiss → deref of freed widgets.
  • task_manager.py:45 — task_list grows unbounded → RAM leak over session. Prune done() tasks. Note: it does seem good to leave them there for a while after exiting, just so a future "process manager" can see tasks ending, maybe.
  • app/activity.py:72 — finish() runs result callback after screen destroyed → use-after-free if it touches widgets. Note: not sure this is an issue, we're pretty resilient against these types of reference errors nowadays.
  • content/app_manager.py:700 — get_services_for_action leaves module in sys.modules (shadows later apps).

@fdb
fdb deleted the fix-status-bar-clock-allocation branch August 10, 2026 19:27
@fdb

fdb commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Made PRs for the first two:

Will see if I can do the other ones. Maybe better to put these as GH issues?

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.

2 participants