Skip to content

Outstanding-allocation tracker behind a build flag - #776

Open
barryw wants to merge 3 commits into
GideonZ:masterfrom
barryw:heap-allocation-tracker
Open

barryw wants to merge 3 commits into
GideonZ:masterfrom
barryw:heap-allocation-tracker

Conversation

@barryw

@barryw barryw commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

machine:heap (#766) says a leak exists. This says where. It found four of the
five leaks in #770, #772 and #773, none of which were reachable by reading
code -- each had correct-looking ownership on every path around it.

Shape

  • software/system/heap_track.{c,h} -- the table and the report. All of it
    behind HEAP_TRACK; with the flag undefined the entry points are macros that
    expand to nothing, no table is reserved and the allocator does no extra work.
  • heap_4.c -- four guarded lines, no logic. I put the implementation in its
    own file specifically so the FreeRTOS vendor diff stays trivial.
  • memory_wrap.cc -- one call. operator new and malloc both funnel through
    get_mem(), so without re-attributing there, every allocation reads as that
    wrapper rather than as the code that asked.
  • route_machine.cc -- GET /v1/machine:heap_allocations and
    PUT /v1/machine:heap_allocations_reset, compiled only with the flag.
  • Makefiles -- $(EXTRA_DEFINES) appended to OPTIONS (the hook Heap leak detection: a diagnostics endpoint that found a 16 KB-per-call leak #763 proposed,
    which did not land with Add a machine:heap endpoint for measuring heap leaks #766) plus heap_track.c in the source list, for the
    six actively built targets.

Slots are addressed directly from the pointer rather than searched: record and
forget sit in the allocator path and a linear scan there is not affordable once
every allocation is tracked. Four ways per set, because a plain direct-mapped
table lost a noticeable number of records to collisions on real runs. A full
set drops its oldest entry and counts it in evicted, so a table that is
under-reporting says so rather than quietly lying.

Gating, demonstrated

flag off:  update.u64 5,374,124 bytes,  no machine:heap_allocations in the image
flag on:   update.u64 5,375,552 bytes,  both endpoints present

The 128 KB table is BSS, so it does not appear in the image either way. Both
configurations build clean on the CI-matched Quartus 18.1 nios2 toolchain.

The part that actually finds leaks

tests/tools/heap_diff.py. A live block is not a leaked block, and on real
firmware most outstanding blocks are legitimate -- the directory on screen, the
current session, cached structures. Reading the endpoint once and treating the
big entries as leaks is how you fix the wrong thing.

So it runs the same work twice and reports which callers grew between rounds.
Steady callers are holding live objects; growing ones are leaking, by the
amount they grow. That is what separated four stranded FileInfo copies per
run from the seven that were merely the listing on screen in #773 -- and what
stopped me attributing that leak to the disk image mount cache, whose totals
looked right for it and were not.

tests/tools/heap_diff.py -H <device> -- ./run-tests -H <device> -s prg-context-menu

It goes through tests/lib/rest.py like everything else; transport-usage
passes.

Status and what I want to hear

Not yet verified on hardware in this form. The tracker it descends from has
been in daily use all week, but this is a rewrite -- four-way sets, sorted
report, compiled-out gating -- and I have not yet run this code on a device.
Happy to do that before it leaves draft; I did not want to build on a shape
nobody has agreed to.

The open questions are in #775, and the two that matter:

  1. Vendor hooks or not. Completeness argues for pvPortMalloc/vPortFree
    as here. Hooking only memory_wrap.cc would touch no FreeRTOS code and
    would still have caught every leak found so far, at the cost of missing task
    stacks, queues and direct pvPortMalloc callers.
  2. Whether you want a debugging tool in the shipping tree at all, gated but
    present. If the answer is that it should stay on a branch, that is a fine
    answer and nothing is wasted -- it lives on one today.

@chrisgleissner -- this touches no part of the e2e runner, and heap_diff.py
is standalone, so it should not collide with your multi-device work.

@chrisgleissner

chrisgleissner commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Hi @barryw ,

Thanks for raising that and for sharing more of the amazing and very powerful infrastructure you have built in such a short time.

It is clearly powerful as it has found multiple issues with ease, so whether or not it is useful is IMHO not a question. In terms of the concrete approach taken - there may be alternatives out there when it comes to static code analysis, runtime monitoring, etc. However, an easy, readily available solution that is part of the repo and does not require separate tool installations always wins in my book unless it smells of "reinventing the wheel", which this one does not in my opinion.

A few thoughts after looking at this alongside the REST API we already have:

  • I normally favour developer-facing diagnostics being available in standard builds rather than hidden behind flags. In this case, though, I think the flag is justified: the tracker reserves 128 KB and adds work to the allocator path even when nobody is calling the REST endpoint. That's materially different from something lightweight like machine:heap.

  • On the API shape, I originally thought we should make this more idiomatic REST with nested resources. Having gone back through the existing API, I'm less convinced. We already consistently use the route:command model throughout - machine:reset, drives/{drive}:mount, streams/{stream}:start, etc. At this point I think consistency and least surprise within our API matter more than making these two endpoints REST-by-the-book.

  • I would, however, avoid adding two commands for what is really one concept. Something like this feels cleaner to me:

GET /v1/machine:heap_allocations
PUT /v1/machine:heap_allocations_reset

GET reports the tracked outstanding allocations; PUT resets the tracking state. I at first thought about DELETE without the _reset being better, but that makes it sound as though the caller would delete heap allocations, which is wrong and would be alarming.

That also follows the precedent of endpoints such as machine:debugreg, where the HTTP method determines what happens to the same conceptual resource.

I'd also prefer allocations over heapblocks. What we're exposing here isn't really FreeRTOS heap blocks as an implementation detail - it's outstanding allocations, grouped by their allocating caller.


More generally, I'd like us to avoid gradually accumulating lots of narrowly named endpoints for individual firmware internals.

Developer visibility is extremely useful, but ideally the API should still feel cohesive, predictable and extensible a few years from now. machine:heap plus one related allocation-tracking endpoint feels reasonably contained to me.

I don't think we've reached this point yet. If we feel we are getting close, we may also consider exposing more internals via a dedicated /metrics Prometheus endpoint which can be done with very minimal overhead; something @GideonZ and I discussed before.

barryw added a commit to barryw/1541ultimate that referenced this pull request Aug 13, 2026
Review feedback on GideonZ#776: what the endpoint reports is outstanding allocations
grouped by the caller that made them, not FreeRTOS heap blocks. "Blocks" named
the allocator's internal structure rather than the thing being measured, which
is the sort of implementation detail an API should not leak.

  GET /v1/machine:heap_allocations
  PUT /v1/machine:heap_allocations_reset

The payload follows the same rename, since it carried the same wrong noun:
live_blocks becomes live_allocations, and each caller line reads "N allocations"
rather than "N blocks". heap_diff.py parses that line, so it moves with it.

The tracker's internal names are untouched. Inside the allocator they really are
blocks.
@barryw

barryw commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@chrisgleissner Thanks — renamed, and I took your naming point further than the URL.

You are right that "blocks" named the allocator's internal structure rather than the thing being measured, and the payload carried the same wrong noun, so both moved:

GET /v1/machine:heap_allocations
PUT /v1/machine:heap_allocations_reset

live_blocks is now live_allocations, and each caller line reads "3 allocations 1024 bytes ra 0x…". heap_diff.py parses that line, so it moved with it. The tracker's internals still say blocks — inside the allocator they genuinely are blocks; it was only the API surface that misdescribed itself.

Both configurations rebuilt on the CI-matched Quartus 18.1 nios2 toolchain after the rename:

flag off:  update.u64 5,374,124 bytes,  no machine:heap_allocations in the image
flag on:   update.u64 5,375,552 bytes,  both endpoints present

Worth noting the #ifdef HEAP_TRACK means the flag-off build never compiles the renamed code, so the flag-on build is the only one that could have caught a mistake in the API_CALL registration. That is the one that matters here and it is clean.

On the flag: agreed, and for the reason you give. machine:heap is three counters read on demand; this reserves 128 KB and adds work to every allocation. If it were free I would argue for always-on too.

On route:command over nested resources — agreed, and thank you for going back through the existing API rather than taking the first answer.

One thing I would put back to you. You wrote that you would avoid two commands for what is really one concept, and cited machine:debugreg as the precedent where the method decides. But debugreg really is one command name with two methods (route_machine.cc:264 and :272), whereas heap_allocations plus heap_allocations_reset is still two names sharing a prefix. Your principle seems to point at:

GET /v1/machine:heap_allocations     # report
PUT /v1/machine:heap_allocations     # reset

I have implemented your written version rather than guess at which you meant. The argument for keeping _reset is real — a bare PUT with no body that silently resets is less self-documenting, and debugreg's PUT at least takes a required value. Happy either way; it is a two-line change and it is your call on which reads better across the API as a whole.

On endpoint sprawl and a /metrics endpoint — no argument from me, and it deserves its own discussion rather than being settled in the margins of this PR. For what it is worth I do not think this endpoint is a candidate to fold into it. A per-call-site allocation table is exactly the unbounded, dynamic label dimension a scrape target should not carry; coarse heap gauges belong in /metrics, and "which caller is leaking" belongs behind a debug flag. They answer different questions, and I think both are worth having.

This stays a draft either way, pending the design questions in #775 and the $(EXTRA_DEFINES) makefile hook from #763 that did not land with #766.

@chrisgleissner

chrisgleissner commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

Hi @barryw ,

great work on all the PRs.

One question - where do we stand with the various PRs and especially the draft PRs? Is there anything that you are waiting on from me or Gideon for you to move on with them and get them into a merge-ready state?

I see you currently have these PRs open:

It would be good to understand what their dependencies are and what, if anything, blocks them from moving out of draft status, and then from being merged.

This also in regards to this PR dependency write-up from above:

"This stays a draft either way, pending the design questions in #775 and the $(EXTRA_DEFINES) makefile hook from #763 that did not land with #766."

Many thanks
Christian

@barryw

barryw commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Hi Christian — thanks for asking. The short version is that #789 is first in the chain; #788 and #791 are waiting on it only so their full E2E gates can be rerun green. #776 is independent and is waiting on a design decision rather than another implementation PR.

#789 — configuration effectuation lifecycle

  • CI is green, the branch is mergeable, the focused regression passes on its first run after flashing, full hardware E2E is 22/22, and all application/space-gate builds pass.
  • Your churn question was measured and resolved; the current direction is the smaller change.
  • From my side the implementation is ready for review.
  • The remaining ask is a sanity check from Gideon that the lifecycle boundary now matches the intent behind his comment on CFG loading again effectuates unrelated configuration groups #787. If it does, I can take it out of draft immediately. If not, I need the preferred boundary called out.

#788 — live network service switches

#791 — runtime palette UCI commands

#776 — allocation tracker

  • This one is independent of the three above. CI is green and both flag-off and flag-on builds are proven, but this rewritten tracker has not yet had its final hardware run.
  • The quoted $(EXTRA_DEFINES) dependency is stale: Outstanding-allocation tracker behind a build flag #776 now includes that small hook itself, so it is not blocked on Heap leak detection: a diagnostics endpoint that found a 16 KB-per-call leak #763.
  • The remaining Gideon decision is whether to accept the gated diagnostic tool in the shipping tree and whether the complete pvPortMalloc/vPortFree hooks in heap_4.c are acceptable, versus the narrower memory_wrap.cc coverage.
  • One small API choice remains from our earlier exchange: keep the currently implemented PUT /v1/machine:heap_allocations_reset, or use PUT /v1/machine:heap_allocations so the HTTP method distinguishes report from reset. If you have a preference, I will use it; otherwise I will leave the explicit _reset form.
  • Once those choices are settled, I will run the final hardware differential test and move it out of draft if it behaves as expected.

So the practical order is:

  1. Review/settle Fix configuration store effectuation lifecycle #789.
  2. I rebase and fully retest Apply network service switches live #788 and Add UCI runtime palette commands #791, then mark both ready.
  3. Gideon settles the two Outstanding-allocation tracker behind a build flag #776 design points; I hardware-test that final shape and mark it ready.

There is no implementation work I am waiting for you to do. What would help most is review of #789, review of the palette documentation PR, and the two explicit decisions on #776.

@chrisgleissner

chrisgleissner commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks for the overview @barryw . I now asked a question on #789. It is sometimes hard to see what exactly a PR is waiting for, especially if a question was asked during the PR and the discussion then moved on.

Thus, to avoid that PRs sit in Draft status indefinitely, I recommend to occasionally check back here and see if any question you are waiting for has been answered, and then just ask it again in a new comment.

@GideonZ FYI Barry's reply above shows how all his PR's are related and where he is waiting for your steer.

@barryw

barryw commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

@chrisgleissner I get emails when my PRs or issues change, so I keep on top of stuff. ;)

@chrisgleissner

chrisgleissner commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

@barryw Thanks for moving this and other PRs to merge ready (provided they are merge ready, i.e. all open questions resolved, tested. etc.) and resolving any merge conflicts. Keen to get stuff merged.

machine:heap says a leak exists. It does not say where, and that has been the
slow part of every leak fixed recently: each one had correct-looking ownership
on every path around it, so reading code did not find them.

This records every live allocation with the address that asked for it, and
reports them grouped by that address. Slots are addressed directly from the
pointer rather than searched, because record and forget sit in the allocator
path and a linear scan there is not affordable once every allocation is
tracked. Four ways per set: a plain direct-mapped table lost a noticeable
number of records to collisions on real runs, and when a set is genuinely full
the oldest entry is dropped and counted, so an under-reporting table says so.

operator new and malloc both funnel through get_mem(), so the tracker
re-attributes each block to the real caller there; without that every
allocation would read as the wrapper.

Nothing is built unless HEAP_TRACK is defined: the entry points compile to
nothing, no table is reserved, and the allocator does no extra work. The
Makefiles gain $(EXTRA_DEFINES) so a diagnostic image is

    make u64_no_esp EXTRA_DEFINES=-DHEAP_TRACK=1

The FreeRTOS heap itself takes four guarded lines and no logic.

tests/tools/heap_diff.py is the part that finds leaks rather than merely
listing allocations. A live block is not a leaked block, and on real firmware
most outstanding blocks are legitimate, so it runs the same work twice and
reports which callers grew between rounds. Reading the endpoint once and
treating the large entries as leaks is how you fix the wrong thing; that
mistake nearly attributed the FileInfo leak to the mount cache, because the
totals looked right for it.
Review feedback on GideonZ#776: what the endpoint reports is outstanding allocations
grouped by the caller that made them, not FreeRTOS heap blocks. "Blocks" named
the allocator's internal structure rather than the thing being measured, which
is the sort of implementation detail an API should not leak.

  GET /v1/machine:heap_allocations
  PUT /v1/machine:heap_allocations_reset

The payload follows the same rename, since it carried the same wrong noun:
live_blocks becomes live_allocations, and each caller line reads "N allocations"
rather than "N blocks". heap_diff.py parses that line, so it moves with it.

The tracker's internal names are untouched. Inside the allocator they really are
blocks.
@barryw
barryw marked this pull request as ready for review September 3, 2026 06:17
@chrisgleissner

Copy link
Copy Markdown
Collaborator

Hi @barryw ,

the title of this PR says 'Draft: ' but the status of the PR itself is no longer 'Draft'.

To avoid ambiguity, I suggest we only use the 'Draft' status of PRs (which clearly flags them as such even in the PR overview) and don't duplicate the word 'Draft: ' in the PR title.

Is this PR ready for review and merge?

Thanks
Christian

@barryw barryw changed the title Draft: outstanding-allocation tracker behind a build flag Outstanding-allocation tracker behind a build flag Sep 3, 2026
@barryw

barryw commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Hi @barryw ,

the title of this PR says 'Draft: ' but the status of the PR itself is no longer 'Draft'.

To avoid ambiguity, I suggest we only use the 'Draft' status of PRs (which clearly flags them as such even in the PR overview) and don't duplicate the word 'Draft: ' in the PR title.

Is this PR ready for review and merge?

Thanks Christian

Thanks, @chrisgleissner. Fixed. This is ready, but @GideonZ wants to noodle on it a bit

@chrisgleissner chrisgleissner added 3.16 Targets 3.16 release enhancement labels Sep 9, 2026
@chrisgleissner
chrisgleissner changed the base branch from test-merge to master September 13, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3.16 Targets 3.16 release enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants