Skip to content

fix: report heap and render failures instead of faking success - #43

Merged
IronsDu merged 21 commits into
mainfrom
fix/heap-error-reporting
Sep 15, 2026
Merged

IronsDu merged 21 commits into
mainfrom
fix/heap-error-reporting

Conversation

@IronsDu

@IronsDu IronsDu commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Testing with go tool pprof (go1.26.8) exposed several paths where a failure reached clients as a 200 carrying something that was not a profile.

GetHeapSample() does not return an empty string when heap sampling is off (TCMALLOC_SAMPLE_PARAMETER unset, default 0). It returns pprof's %warn advisory followed by a syntactically valid but empty profile:

%warn
%warn This heap profile does not have any data in it, because
heap profile:      0:        0 [     0:        0] @ heap_v2/0
MAPPED_LIBRARIES:

so the sample.empty() check never fired. Measured against a server started without the variable, the three heap endpoints disagreed with each other: /pprof/heap answered 200 with the warning as its body, /api/heap/svg_raw answered 500, and /api/heap/flamegraph_raw answered 200 with a 13KB graph -- all three from the same empty input. The zero sampling rate (or a leading %warn) is now detected in one place, so all three report the same explicit error. With go tool pprof, the failure now reads

server response: 500 Internal Server Error - Could not read heap sample:
heap sampling is off or produced no data. Set TCMALLOC_SAMPLE_PARAMETER ...

where it previously reported a successful fetch containing zero samples.

Three further fixes in the same area:

  • analyze*() failures produced invalid JSON. The core returns a {"error": ...} string and the handler passed it to HandlerResponse::error(), which wrapped it again without escaping, emitting {"error":"{"error": "Failed to..."}. handleHeapAnalyze additionally threw the reason away and substituted "Failed to generate heap flame graph", which is how "already in use" stopped being visible. The inner message is now extracted before wrapping.
  • /pprof/heap and /pprof/growth errors now use Go's serveError() shape (text/plain plus X-Go-Pprof: 1) rather than application/json, so the tool shows the message instead of misreading the body.
  • generateFlameGraph() accepted flamegraph.pl's answer to empty input, which is a structurally valid SVG whose entire content is "ERROR: No valid input provided to flamegraph.pl." That passed the SVG check and shipped as HTTP 200 with a graph-shaped error. It is now a 500 explaining that the window was too short or the process idle, which is what a 2s sample of an idle process actually is.

The parsing helpers move to src/internal/result_parsing.h so they can be tested without profiler state; test_result_parsing.cpp covers the exact %warn payload tcmalloc emits, the zero-rate marker, message extraction, and that re-wrapping an extracted message stays valid JSON. Verified to catch the old behaviour: with the checks reverted, 4 of its cases fail.

Validated end to end with go tool pprof against a live server: -top resolves cpuIntensiveTask/memoryIntensiveTask/rand for CPU and memoryIntensiveTask/ std::vector::_M_realloc_append for heap, and -http renders graph, flamegraph, peek and top views with the SVG carrying real node content.

Testing with `go tool pprof` (go1.26.8) exposed several paths where a failure
reached clients as a 200 carrying something that was not a profile.

GetHeapSample() does not return an empty string when heap sampling is off
(TCMALLOC_SAMPLE_PARAMETER unset, default 0). It returns pprof's %warn advisory
followed by a syntactically valid but empty profile:

    %warn
    %warn This heap profile does not have any data in it, because
    heap profile:      0:        0 [     0:        0] @ heap_v2/0
    MAPPED_LIBRARIES:

so the `sample.empty()` check never fired. Measured against a server started
without the variable, the three heap endpoints disagreed with each other:
/pprof/heap answered 200 with the warning as its body, /api/heap/svg_raw
answered 500, and /api/heap/flamegraph_raw answered 200 with a 13KB graph --
all three from the same empty input. The zero sampling rate (or a leading
%warn) is now detected in one place, so all three report the same explicit
error. With `go tool pprof`, the failure now reads

    server response: 500 Internal Server Error - Could not read heap sample:
    heap sampling is off or produced no data. Set TCMALLOC_SAMPLE_PARAMETER ...

where it previously reported a successful fetch containing zero samples.

Three further fixes in the same area:

- analyze*() failures produced invalid JSON. The core returns a
  `{"error": ...}` string and the handler passed it to HandlerResponse::error(),
  which wrapped it again without escaping, emitting
  `{"error":"{"error": "Failed to..."}`. handleHeapAnalyze additionally threw the
  reason away and substituted "Failed to generate heap flame graph", which is how
  "already in use" stopped being visible. The inner message is now extracted
  before wrapping.
- /pprof/heap and /pprof/growth errors now use Go's serveError() shape
  (text/plain plus X-Go-Pprof: 1) rather than application/json, so the tool shows
  the message instead of misreading the body.
- generateFlameGraph() accepted flamegraph.pl's answer to empty input, which is a
  structurally valid SVG whose entire content is
  "ERROR: No valid input provided to flamegraph.pl." That passed the SVG check
  and shipped as HTTP 200 with a graph-shaped error. It is now a 500 explaining
  that the window was too short or the process idle, which is what a 2s sample of
  an idle process actually is.

The parsing helpers move to src/internal/result_parsing.h so they can be tested
without profiler state; test_result_parsing.cpp covers the exact %warn payload
tcmalloc emits, the zero-rate marker, message extraction, and that re-wrapping an
extracted message stays valid JSON. Verified to catch the old behaviour: with the
checks reverted, 4 of its cases fail.

Validated end to end with go tool pprof against a live server: `-top` resolves
cpuIntensiveTask/memoryIntensiveTask/rand for CPU and memoryIntensiveTask/
std::vector::_M_realloc_append for heap, and `-http` renders graph, flamegraph,
peek and top views with the SVG carrying real node content.
The panel offered three heap entry points -- /api/heap/analyze and the two
*_raw renderers -- and all three are window-based: they record only the
allocations made during a fixed 1s sampling window. The state-based view
(GET /pprof/heap, tcmalloc's cumulative snapshot of what is currently in the
heap) had no UI entry point at all, so a process holding a large heap but no
longer allocating could not be inspected from the panel: every button returned
"No heap profile data was produced", and seeing the resident heap required
leaving the UI for curl.

The Heap section is now two labelled cards so the two mechanisms are not
confused for one another:

- "窗口式:采样 1 秒内的分配" keeps the existing analyze/download buttons
- "状态式:当前堆的累计快照" adds 查看 and 下载 actions driving /pprof/heap

The view action opens the profile as text in a new tab (it *is* plain text, so
the browser renders it directly) and logs the line count, size and first line;
the download action saves it as heap.prof and logs the `go tool pprof` command
to analyse it. Fetch failures are reported in the log panel, with the 500 case
explained as the missing TCMALLOC_SAMPLE_PARAMETER rather than a bare status,
since that variable must be set before the process starts.

Also records in ROADMAP the two rendering observations made while reading the
embedded pprof's output: function names carry a `<hex address>` suffix into the
flame graph (and, for template instantiations, split what should be one frame
into several), and some symbols arrive still mangled, typically rendering as a
context-free "operator()[inline]" because the frame is a lambda's operator().

Verified on a live server: both cards render, the extracted page JavaScript
passes `node --check`, /pprof/heap answers 200 with text/plain and a
Content-Disposition filename, and the downloaded profile analyses under
`go tool pprof` (1.02MB attributed to memoryIntensiveTask). A fresh process
returns growing counters (1: 20000 -> 2: 40000 -> 7: 60000); a zero-sample
result with `@ heap_v2/524288` is a genuine "nothing allocated in this window"
and is not treated as an error by the new empty-sample check.
…here

Two corrections, both from the same misreading on my part.

Heap duration is not meaningless -- it is just not a sampling *rate*. gperftools
fixes the rate through TCMALLOC_SAMPLE_PARAMETER at process start, so duration
cannot improve precision; but it decides how long allocations are collected, so
a process that allocates sparsely needs a longer window to produce anything.
Hard-coding 1s left that case with no recourse beyond dropping to the low-level
startHeapProfiler()/stopHeapProfiler() pair. analyzeHeapProfile() takes
(duration, output_type) again (default 1, clamped 1-300), and the handlers,
routes and panel all pass it through.

Measured: /api/heap/analyze now takes 2.2s, 4.0s and 7.1s for duration 1, 3 and
6, i.e. the window is honoured rather than ignored.

The two renderers were also not wired up properly. They pulled GetHeapSample(),
which is the state-based path, so they ignored any window and depended on
TCMALLOC_SAMPLE_PARAMETER being set at startup -- which is why
/api/heap/svg_raw failed with 500 while /api/heap/flamegraph_raw rendered a
graph from identical input. They now call a new getRawHeapProfileSample(duration),
which runs its own HeapProfilerStart/GetHeapProfile/Stop window and returns the
profile text, so all three window-based endpoints share one mechanism, honour
duration, and work without the startup variable. With sampling off they all
return SVG while only the state-based /pprof/heap reports its error.

Panel: the window card gained a duration input, and a 查看图表 button so the two
*_raw renderers are reachable for viewing rather than only for download --
previously the only consumer of those endpoints was the download button, with
viewing going through /api/heap/analyze. Chart type, duration and endpoint
selection now share one code path (heapChartEndpoint/heapDuration), and the
viewer page forwards duration.

Verified live: with the variable unset, all three window endpoints return 200
SVG and /pprof/heap returns 500 with its explanation; with it set, a fresh
process reports growing counters and go tool pprof attributes 1.02MB to
memoryIntensiveTask. Extraction of the page script passes node --check.
The panel could show the live heap only as raw profile text. The rendering path
for it already existed -- both heap renderers shell out to the embedded pprof --
but they always obtained their input from a collection window, so there was no
way to draw the state-based view even though the API to fetch it existed. A
graph of "what is in the heap right now" was reachable only by exporting the
profile and running a separate tool.

Both renderers now take a source selector, exposed as ?source=state:

  /api/heap/svg_raw?source=state              current heap, pprof renderer
  /api/heap/flamegraph_raw?source=state       current heap, FlameGraph renderer
  /api/heap/svg_raw?duration=N                collection window (default)

They share one heapSampleForSource() helper, so choosing a source is a single
decision rather than two divergent code paths, and a core {"error": ...} reply
is now unwrapped instead of being mistaken for profile text. The failure message
also depends on the source: the state path points at TCMALLOC_SAMPLE_PARAMETER,
the window path no longer claims a variable it does not use (a leftover from
when it pulled GetHeapSample()).

Panel: the two heap cards are merged into one with a 数据源 dropdown, since the
mechanism is what varies, not the buttons. Choosing 状态式 hides the collection
window (meaningless there), explains that the view reflects resident memory,
and keeps all four actions working -- 查看图表, 下载图表, 查看原始 profile and
下载 profile 文本. analyzeHeap() redirects to the graph view when the state
source is selected, because /api/heap/analyze only performs a window collection.
Query assembly lives in one heapQuery() helper used by every action.

Verified live: with sampling enabled, both ?source=state endpoints return SVG
(18767 and 16031 bytes) and the window form still works; the page carries the
selector and all ten heap functions, and the extracted script passes
node --check. New tests assert both renderers accept (duration, state_based) and
that only the state path blames the sampling variable.
Reported as "下载图表 does nothing". Investigating turned up four separate
defects in the heap chart path, three of them affecting more than the button.

1. The download built its URL from duration alone, dropping source=state. With
   状态式 selected it silently fetched and saved a *window* collection -- a wrong
   result rather than a visible failure. Both actions now share one
   fetchHeapChart(), so they cannot diverge again.

2. The download never inserted its <a> into the DOM before click(). A detached
   anchor works in some browsers and not others, which matches "no response".
   The same omission existed in the CPU and growth downloads (my
   downloadHeapProfile had always done it correctly); all four now attach.

3. 查看图表 relied on window.open() of an endpoint that sets
   Content-Disposition: attachment, so it downloaded instead of displaying --
   which is why it looked like a duplicate of the download button. It now opens
   a blob URL, where that header does not apply, so the SVG is actually shown,
   and it reports the server's error text on failure instead of a bare status.

4. handleHeapSvgRaw called `pprof --svg` with pprof's default --inuse_space.
   A collection window usually catches allocations that are already freed, so
   the in-use column is empty and the render fails with "No nodes to print" --
   ?duration=1 returned 500 while the identical sample rendered fine as a flame
   graph (which plots cumulative allocation). It now uses --alloc_space, and
   ?duration=1 returns a 23KB graph. Its failure message also includes pprof's
   output rather than a bare "Failed to generate SVG".

While fixing the last one: four handlers inline flamegraph.pl and each had only
the weak <?xml/<svg check, so flamegraph.pl's "valid SVG whose only content is
ERROR: ..." reached callers as a 200 graph. An earlier fix had added the ERROR
check to generateFlameGraph() only. All four now call a shared
flameGraphResponse(); ?duration=1 on the flamegraph endpoint now returns 500
explaining that no stack samples were collected instead of a 579-byte SVG
reading "ERROR: No valid input provided to flamegraph.pl."

Verified on a live server: svg_raw renders at duration 1 and 3, flamegraph_raw
renders at 3 and reports a clear 500 at 1, and both ?source=state forms render.
The page script passes node --check. ROADMAP already carried the "map --svg
failures swallow the renderer's stderr" item; the heap svg path is now fixed,
the CPU path (profiler_manager.cpp) still needs it.
… fault

A freshly started process has an empty state-based snapshot: tcmalloc reports
nothing until its first sampling event, so /pprof/heap returns a valid profile
with zero records and pprof answers "No nodes to print". That reached the caller
as "pprof could not render this heap sample into an SVG", which reads like a bug
in this project rather than a property of the data.

The svg renderer now recognises pprof's empty-profile output and answers
according to which source was asked for: the state path explains the cold start
and suggests either waiting for allocations or switching to the window source,
which collects its own samples; the window path talks about the window being too
short. The panel's 状态式 hint says the same thing up front.

Observed live on a fresh server: at t+3s the state view returned a 2.6MB graph,
while an earlier run reported zero for over 15s before recovering -- the delay
depends on when the first sampling event lands, which is why the message points
at the condition rather than promising a fixed wait.
/api/heap/flamegraph_raw failed intermittently for a fixed duration -- 2 times
out of 5 at ?duration=2 -- while the identical sample rendered reliably through
/api/heap/svg_raw. Two causes, both in the collapsed pipeline:

- `pprof --collapsed` was left at pprof's default, which plots what is live
  *now*. A short window usually catches allocations that are already freed, so
  the collapsed file came out with zero stack lines and flamegraph.pl rejected
  it. This is the same problem already fixed for the svg renderer by switching
  to --alloc_space; the collapsed command needed it too. Applied to all three
  collapsed sites (heap raw, heap flamegraph, growth flamegraph).
- The "does the collapsed file have data" check counted any non-empty line not
  starting with '#' as a stack. pprof's two "Using local file ..." progress
  lines qualify, so the check passed on an empty result and the failure surfaced
  two layers later inside flamegraph.pl's output. Those lines are now skipped.

Measured after: 6 consecutive ?duration=2 requests all return 200, and
svg_raw, both ?source=state forms, growth flamegraph and analyze all render.

This is the fourth defect of the same shape in this area -- a renderer's failure
becoming a 200, or a check that cannot observe the condition it is testing --
which is why the earlier fixes kept being reported one at a time by hand.
A previous commit (dd01bf9) claimed the chart download and view were reworked
onto a shared fetchHeapChart(). That function was never written: the edit script
asserted on a stale anchor and exited before writing the file, and I reported the
work as complete without checking. The result was worse than the bug it was meant
to fix -- downloadHeapChart() called an undefined function, so:

  - the click threw "fetchHeapChart is not defined" synchronously, before the
    .catch() handler was attached, leaving the countdown running and the button
    disabled forever;
  - viewHeapChart() kept its old window.open() form, so it still downloaded
    rather than displayed.

Reported by the user as "下载图表 does nothing", which is exactly what it did.

This commit writes the function, moves viewHeapChart() onto it (blob URL, so the
SVG is displayed despite Content-Disposition), and wraps the download's call in
Promise.resolve().then(...) so a synchronous throw cannot strand the button
again.

The lesson is about verification, not the code: every earlier "verified" claim in
this series came from curl against the server, which cannot exercise the browser
at all. These four actions are now checked by driving the real page in headless
Chrome over CDP -- expected request issued (with source=state), view opens a blob
URL of the right size, download leaves a file on disk (94580 bytes window, 18810
state), no page errors. Also removed the unused endpoint/duration locals left
behind when the fetch moved into the shared helper.
CI's ASan job failed on HttpHandlersTest. Reproduced locally with the same flags
(-fsanitize=address, ASAN_OPTIONS=detect_leaks=1:halt_on_error=1) and the report
names the cause exactly:

    Direct leak of 11630 byte(s)
        #1 GetHeapProfile (/lib64/libtcmalloc.so.4)
        #2 ProfilerManager::stopHeapProfiler() profiler_manager.cpp:268

GetHeapProfile() returns a malloc'd string that the caller must free; the code
let it convert straight into a std::string, discarding the pointer and leaking
the whole profile on every stop. Four call sites leaked ~46KB.

The leak is older than this branch -- but it was unreachable before it.
stopHeapProfiler() had no callers anywhere in the original tree: analyzeHeapProfile
stopped the profiler inline (ProfilerStop + its own state update), and the
original test suite never touched a heap profiler session. Routing
analyzeHeapProfile() and the new getRawHeapProfileSample() through
stopHeapProfiler() is what made the leaking line reachable. The new
HttpHandlersTest cases are the first to call start/stop directly, which is why
CI saw it and the existing tests never did.

The new code in getRawHeapProfileSample() already took ownership and freed
correctly; this brings stopHeapProfiler() in line with it.

Verified: the full suite passes under the CI ASan configuration, 6/6, with no
leak report.
Nine endpoints across three profiler types collapse into four:

  /api/{cpu,heap,growth}/analyze        -> /api/pprof/{cpu,heap,growth}
  /api/{cpu,heap,growth}/svg_raw        -> /api/pprof/{cpu,heap,growth}?renderer=callgraph
  /api/{cpu,heap,growth}/flamegraph_raw -> /api/pprof/{cpu,heap,growth}?renderer=flamegraph
  /api/heap/*?source=state              -> /api/pprof/heap/snapshot

The old paths are removed, not aliased.

`analyze` and `svg_raw` were the same operation -- sample for N seconds, then
render -- differing only in renderer and whether the response forced a download.
Each pair therefore sampled the process twice for one picture. Renderer is now a
parameter and delivery is another. The three /show_*_svg.html viewers, and the
inline-SVG branch that fed them, are gone with them.

Two naming corrections came out of writing this down:

- `output_type=pprof` became `renderer=callgraph`. Measured: the pprof script
  emits a node/edge call graph (56 polygons, a legend, zero rects) while
  FlameGraph emits stacked frames (37 rects, id="frames"). They are different
  pictures, and the old value named a tool rather than the picture.
- The heap snapshot is its own endpoint rather than a `source=` flag. State-based
  ("what is in the heap now") and window-based ("what was allocated during the
  window") are different questions, so they get different URLs;
  `format=profile|svg` selects raw text or a rendered chart.

`duration` now defaults to 10s everywhere (was 1s for heap). Measured on the
bundled workload: a 3s window failed to collect enough samples 35-45% of the
time, 10s never failed. Responses are `inline` by default, which is purely the
absence of Content-Disposition -- verified that identical bytes navigate as a
document without it and abort with it.

In-page zoom is not promised anywhere, and ROADMAP records why: the generated
SVGs embed pan/zoom libraries that are never initialised (FlameGraph's zoom()
looks for a #viewport its own output lacks; the pprof SVGPan has no onload hook).
That is what motivated dropping the viewers rather than shipping buttons that
cannot work.

ProfilerHttpHandlers now exposes one entry point per profiler taking a
ChartOptions, and the six copies of the same shell-out rendering collapsed into
renderChart/renderFlameGraph/renderCallGraph. src/http_handlers.cpp lost ~40% of
its lines and web_resources.cpp ~44%.

Adds the two verification scripts asked for, and uses them here:

- scripts/verify-web-ui.mjs drives the real panel in headless Chrome and asserts
  each action issues its request, saves a file, and leaves no button disabled.
  Front-end defects in this series were reported as "the button does nothing"
  after being declared fixed on the strength of curl, which cannot execute page
  JavaScript at all. 23/23 checks pass.
- scripts/check-sanitizers.sh reproduces CI's ASan/UBSan/TSan locally; the
  LeakSanitizer failure in the previous commit reached CI because nothing here
  ran with -fsanitize=address. ASan passes with the CI flags and suppressions.

Verified: release build clean, 6/6 tests, ASan 6/6 with no leak report, browser
regression 23/23, all twelve old paths 404, /pprof/* unchanged and still resolving
symbols.
…he panel

Two problems behind "inline and the other mode look the same in the panel".

The panel's download buttons hard-coded output=attachment, so the inline mode had
no way to be exercised from the UI at all -- it only worked if you assembled the
URL yourself, which is exactly the sort of thing that makes a feature look
absent. Each section now has both a 打开 (inline) and a 下载 (attachment) button.

The adapter also downgraded every SVG to CT_TEXT_XML, the generic XML type,
discarding the image/svg+xml that HandlerResponse::svg() sets. Drogon has
CT_IMAGE_SVG_XML, so the correct type is used now. Rendering happened to work
with text/xml, but the declared type is what governs how a browser treats the
document -- "save image as" among other things -- and there was no reason to
report the wrong one. Verified: inline -> image/svg+xml with no Content-Disposition
and the browser renders a 1200x422 document with 38 frames; attachment -> same
type plus Content-Disposition and navigation aborts into a download.

verify-web-ui.mjs gained three assertions for the delivery modes, because the
existing checks could not have caught this: they only ever clicked download
buttons, so they never observed inline at all. It also asserts the four 打开
actions exist. 27/27 checks pass, up from 23.

The script's own framing was the gap: it verified "the button does its job"
without verifying "the other button does a different job". A feature that is
unreachable from the UI reads as missing no matter how correct the server is.
The previous commit's message said it documented inline vs attachment in the
README; it did not. The edit script died on a quoting error before writing the
file, and I committed without checking that the change had landed -- the same
"reported done without verifying" pattern as the fetchHeapChart commit.

Content that should have been in it: a table showing the two modes differ only in
whether Content-Disposition is sent (both use image/svg+xml), so pasting a URL in
the address bar renders the chart while output=attachment downloads it, plus the
note that inline display is not the same as zoomable.

Verified this time: `grep -c '两种交付方式' README.md` returns 1 and the link
checker passes.
The snapshot section offered a fixed pair of buttons per product (open/download
profile text, open/download SVG), which read as four unrelated actions rather than
one choice of output. It is now one dropdown -- raw profile text, flame graph,
call graph -- with a single 打开 / 下载 pair, so the three products of the same
snapshot are visible as options:

  ?format=profile
  ?format=svg&renderer=flamegraph
  ?format=svg&renderer=callgraph

The renderer was reachable before but effectively undiscoverable: the snapshot
section had no selector of its own and read the Heap Profiler block's dropdown, so
which picture you got depended on a control in a different section. Each section
now owns its selector.

Three defects found while doing it, all from a partially-applied edit of mine:

- snapshotUrl took one parameter but callers passed (output, delivery), so the
  delivery argument was silently dropped and every download would have come back
  as an inline response.
- snapshotOutputLabel was called in two places and never defined; the page threw
  ReferenceError on the first click. The C++ build cannot catch this because the
  script is a string literal.
- openSnapshot still built its URL by string concatenation on top of snapshotUrl,
  duplicating what the helper does.

The second one is why the regression script now checks for JavaScript errors and
why the panel is driven through real clicks rather than inspected: a missing
function in that string literal compiles perfectly.

verify-web-ui.mjs covers all three dropdown options now (33 checks, up from 30),
asserting each produces a distinct request and a distinct file.
… said it did

The documentation audit turned up one code defect and a set of stale references.

Code: the docs promised `duration` is clamped to 1..300, but nothing clamped it,
and the two backends disagreed about out-of-range values -- getRawCPUProfile()
rejected them (which surfaced as the misleading "Failed to collect a CPU profile in
the requested window") while getRawHeapProfileSample() clamped them. The same
query therefore behaved differently per endpoint, and the documented guarantee was
fiction. Clamping now happens once at the HTTP boundary with a test, and measured:
-99 and 0 both run a 1s window, 9999 runs 300s instead of hanging.

Docs, caught by scanning for symbols the refactor removed:

- README: the writable-CWD warning still named /api/*/analyze, /api/*/svg_raw and
  /api/*/flamegraph_raw; the embedded example called handleCpuAnalyze(10,
  "flamegraph"), a function that no longer exists; the heap comparison table still
  said duration defaults to 1s.
- 01_quick_start.md: same dead handleCpuAnalyze call.
- 02_api_reference.md: listed six handlers that no longer exist
  (handleCpu{svg_raw,flamegraph_raw}, handleHeap{svg_raw,flamegraph_raw},
  handleGrowth{svg_raw,flamegraph_raw}) and never documented ChartOptions, which is
  now the whole interface. Added a ChartOptions section and dropped the dead rows.
- mainpage.dox: still advertised the removed /show_*.html viewer pages and the old
  /api/{cpu,heap,growth}/{analyze,svg_raw,flamegraph_raw} paths.
- 04_troubleshooting.md: described the async-worker set with removed endpoint names.

CONTRIBUTING.md gains a section on the two verification scripts, because ctest
alone does not cover what they cover: a leak report needs a sanitizer build, and
front-end behaviour needs a browser (curl cannot execute page JavaScript). It also
notes that the panel's JavaScript lives in a C++ string literal, so the compiler
checks none of it -- which is how an undefined function survived a full build.

CHANGELOG and plan.md keep their old endpoint names on purpose: they are records of
what was true at the time, and rewriting them would misrepresent the history.

Audited: no removed symbol remains in any current doc, every endpoint referenced by
the docs is registered, documented defaults match the code, all internal links
resolve. Full suite 6/6, ASan 6/6 via scripts/check-sanitizers.sh.
CI failed FullFlowTest and HttpHandlersTest while every local run passed. The
difference is parallelism: CI's job takes 7.1s wall for tests that need 2.7s and
10.5s individually, so its binaries overlap. `ctest -j4` reproduces the failure
here, which is what finally pinned it down.

The profiler's intermediate artifacts had fixed names in a shared location --
/tmp/cpp_profiler/pprof_cpu_temp.prof, /tmp/cpu_collapsed.prof, /tmp/heap_collapsed.prof,
and the per-endpoint render inputs like /tmp/cpu_chart.prof. Two test binaries
running at once therefore truncated and recreated each other's files. The captured
failure is exact:

    Starting CPU profiler for 1 seconds...
    Stopping CPU profiler...
    Profile data size: 0 bytes
    test_full_flow.cpp:170: Failure

The same run also showed flamegraph.pl rejecting /tmp/cpu_collapsed.prof with "No
valid input", i.e. a second process had emptied that file mid-render. Artifact names
now carry the process id. Note this is not purely a test concern: two profiled
processes on one host share /tmp/cpp_profiler by default and would corrupt each
other the same way.

Second problem, same test: it asserted a 1s CPU profile is non-empty after
*sleeping* through the window. gperftools samples threads that are executing, so an
idle window returning an empty profile is correct behaviour, and the assertion was
really testing whether the machine happened to be busy. The sampling tests now burn
CPU for the window (burnCPUFor) and fail only on a genuine fault.

scripts/check-sanitizers.sh gained CTEST_JOBS=<n> so the parallel case can be
reproduced deliberately rather than only in CI.

Verified: serial 6/6; `ctest -j6` six consecutive clean runs; ASan with
CTEST_JOBS=6 clean. Before the fix, `ctest -j4` failed FullFlowTest repeatably.
The user caught this line in the feature list:

    一键分析 — /api/*/analyze 直接返回火焰图 SVG,浏览器可看

Both halves were wrong. That path has not existed since the API consolidation
(it is /api/pprof/*), and "火焰图" is only one of two renderers now --
renderer=callgraph draws a call graph, and the default is flamegraph rather than
the only option. My earlier stale-reference sweep missed it because it searched
for concrete prefixes like /api/cpu/ and this line used the wildcard form.

Widening the sweep to wildcard and prose forms turned up three more:

- README still listed /show_*.html as "内置的 SVG 查看页". Those pages were
  removed along with the inline-SVG branch, so the naming rules now describe what
  actually exists: /pprof/* for raw profiles, /api/pprof/* for rendered charts,
  plus /api/status, /api/thread/stacks and the panel.
- 04_troubleshooting contained two answers that contradicted each other. The
  "what does duration mean" answer still said the duration is meaningless and the
  window is a fixed 1 second, while a later answer correctly said it defaults to
  10 seconds and controls coverage. The first also described precision as if the
  window governed it, when only TCMALLOC_SAMPLE_PARAMETER does. Rewritten to
  separate coverage (duration) from precision (the environment variable).
- 02_api_reference documented analyzeHeapProfile without its duration parameter
  and asserted "没有 duration 参数" -- a leftover from before the window was made
  configurable. The signature, the parameter list and that sentence are corrected,
  and it now notes that the C++ default is 1s while the HTTP default is 10s so the
  difference is not mistaken for a bug.

Audited after: no wildcard or prose reference to a removed path remains, every
documented default matches the code (HTTP 10s, C++ 1s, /pprof/profile 30s), all
internal links resolve.
…stack output

ProfilerManager::getThreadStacks() had no callers. It was 122 lines that read
/proc/self/task/*/stat and wchan, while /api/thread/stacks has always been served
by getThreadCallStacks(). Easy to miss because a same-named JavaScript function
lives in web_resources.cpp.

The two were not duplicates, but the survivor already answers the question the
other one existed for: "where is each thread stuck" is the first non-machinery
frame of the captured stack, which I verified matches /proc's wchan one for one
(epoll_wait/ep_poll, clock_nanosleep/hrtimer_nanosleep,
__futex_abstimed_wait_cancelable64/futex_do_wait) and adds the call path that
wchan cannot show.

That left exactly one thing only the deleted function provided: the thread name.
It is now part of the live output, read from /proc/<tid>/comm, so a reader can
tell that thread 1234 is DrogonIoLoop rather than guessing from a bare tid:

    Thread 1234 (DrogonIoLoop):

New tests/test_thread_stacks.cpp covers this (4 cases). It parks a thread that
announces readiness before waiting, so the capture cannot run before there is
anything to capture, and it asserts a thread was actually captured rather than
only that the header is present -- otherwise an empty capture passes. Verified by
inversion: stubbing readThreadName to return "" makes
ReportsThreadNamesAlongsideTids fail, and restoring it passes.

Dead-code audit of the rest, since one unused function suggested others:
- 55 member declarations across the headers, checked for call sites: no others.
  The five initial candidates were false positives -- handler methods referenced
  by function pointer in the route table, signalHandler registered through
  sigaction, a lambda local that my regex mistook for a member, and the three
  signal-configuration APIs (setStackCaptureSignal, getStackCaptureSignal,
  setSignalChaining), which are public configuration surface with a runnable
  example in example/custom_signal.cpp and a documented purpose -- an earlier
  "unused" verdict of mine came from grepping examples/ instead of example/.
- Free and static helpers in src/: all referenced. The hits inside embed_pprof.h
  are the embedded Perl script's own JS, not C++ call sites.

Verified: 7/7 tests serial and with ctest -j6, ASan 7/7 with CTEST_JOBS=6,
browser regression 33/33, and the live endpoint emitting names.
…they exposed

The example now gives its background threads recognisable names via
pthread_setname_np, so /api/thread/stacks can be checked against what the kernel
reports:

  profiler-worker   CPU/allocation load, then a 5s sleep
  profiler-sleeper  wakes every 1.5s
  profiler-parked   waits on a condition variable forever

Their stack tops come out as clock_nanosleep / clock_nanosleep / futex wait, which
is the "where is each thread stuck" answer the endpoint exists to give. Note the
output shows profiler-sleepe -- the kernel truncates thread names to 15 characters,
which is also why the endpoint reads /proc/<tid>/comm rather than inventing a name.
README documents the three threads and that truncation so it is not mistaken for a
typo.

Building this surfaced a real defect in the panel. Download filenames were derived
from a timestamp truncated to seconds, so two downloads finishing in the same
second collided and the browser replaced the first file with the second:

  flamegraph: begin:heap_snapshot_2026-09-15T14-01-16.svg
  callgraph:  begin:heap_snapshot_2026-09-15T14-01-16.svg

Taking the flame graph and then the call graph of the heap snapshot therefore lost
the first one, while the panel logged both as saved. The timestamp now keeps
milliseconds.

The regression script had been reporting success on that overwrite, because its
download check compared file *counts* -- replacing a file leaves the count
unchanged. It now records the set of filenames and requires a new name to appear,
and asserts that all seven downloads produced distinct files. That assertion is
what caught the remaining instance.

Verified: 34/34 browser checks, four consecutive runs with no failures (previously
2 of 3 runs failed on the call-graph snapshot); 7/7 tests serial and with
ctest -j6; ASan 7/7 with CTEST_JOBS=6.
My previous change added free() to the string returned by GetHeapProfile(), on the
strength of its header comment ("allocated using malloc() and should be free()-ed")
and upstream's own test doing the same. CI then aborted:

    ERROR: AddressSanitizer: attempting free on address which was not malloc()-ed
        #1 profiler_manager.cpp:275 in ProfilerManager::stopHeapProfiler()

The header's contract does not survive a statically linked tcmalloc, which is what
vcpkg builds. Investigating the gperftools sources across tags:

  - 2.15 and earlier: GetHeapProfile() mallocs the buffer inside the library, so
    under static tcmalloc the allocation belongs to the library's allocator and the
    process's malloc interposer never saw it -- free() aborts. This is the CI case.
  - 2.18: the final buffer is still malloc'd, but intermediate chunks go through
    LowLevelAlloc, whose Free is declared in a private header.
  - tc_free() exists only from 2.16.90, and upstream exports no public free for
    this buffer in any version.

So there is no deallocator that is correct across the supported range. The buffer is
about 11KB per stop, which is worth far less than a crash: it is now copied out and
left to the OS, with a matching leak:GetHeapProfile suppression in lsan.supp. That
suppression fires once per process, which the ASan output confirms.

Honest limit on verification: I could not reproduce the abort locally. I built an
ASan configuration against vcpkg's static libtcmalloc.a to match CI, and even with
free() reinstated the test passes here -- CI's gperftools build still differs in a
way I cannot see from the outside. What is verified is that the offending call is
gone, the buffer is still read correctly, the leak is suppressed, and that
static-tcmalloc configuration passes 7/7 under ASan (a configuration local runs had
never covered, since the system shared library was always picked up instead).

Also restores the earlier local-vs-CI lesson: `scripts/check-sanitizers.sh` used the
shared library implicitly. Its documentation now notes the static variant.
…'s buffer

Both window-based paths -- stopHeapProfiler() and getRawHeapProfileSample() --
now write the snapshot with HeapProfilerDump() and read that file back, so the
profile memory is ours from the start. GetHeapProfile() is no longer called
anywhere, which retires the whole problem it created: its return value has no
deallocator that is correct across the supported gperftools range (static tcmalloc
hands it out from the library's own allocator, so free() aborts under ASan;
tc_free() only exists from 2.16.90; 2.18 routes intermediate chunks through an
internal arena whose Free is private). The previous commit avoided the crash by
retaining the buffer and suppressing the leak; this removes both the leak and the
suppression, which is what the suppression was papering over.

Names had to be derived, not assumed: HeapProfilerDump() appends ".<sequence>.heap"
to the prefix startHeapProfiler() was given. Measured with a standalone probe --
prefix /tmp/hptest/prefixA produced /tmp/hptest/prefixA.0001.heap -- so
stopHeapProfiler() looks for the dump beside output_path under that derived name
and then writes its contents to the path the caller actually asked for. My first
attempt searched profile_dir_ instead and silently produced a 0-byte file, which
the new content test would have caught.

Answering a fair question from review: why not use the simpler
GetHeapSample(std::string*), which writes into the caller's string with no
allocation at all? Because it answers a different question. Measuring both from the
same moment in a window session, with 200x64KB held live:

  GetHeapSample, no TCMALLOC_SAMPLE_PARAMETER   %warn (sampling off, no data)
  GetHeapSample, 524288                         17:  1114112   (1.1 MB)
  window dump                                   202: 13121057  (13.1 MB -- the real total)

It is pull-based statistical sampling, not the window's record of allocations, so
substituting it would silently under-report by roughly 12x -- worse than a crash
because nothing fails. The state-based snapshot endpoint uses it correctly, and
README now says why the two cannot be interchanged. ROADMAP records the
measurement so the idea is not revisited blind.

Tests: ProfilerLifecycleTest.StopHeapProfilerWritesRealProfileContent checks the
file's contents, not just the running flag -- the existing state assertions would
have passed with an empty file, which is exactly what my mis-derived path
produced. Verified: 7/7 serial and with ctest -j6; ASan 7/7 against both the system
shared tcmalloc and vcpkg's static libtcmalloc.a, with no suppression triggered in
either.
…ne numbers

Audited the documentation against the code rather than against itself. Ground truth
was taken from the source: registered routes, option defaults, CMake options,
exported target names, generated build flags, and the CI's vcpkg pin.

Two statements were wrong:

- 02_api_reference called it a known defect that the destructor only calls
  IsHeapProfilerRunning() and never HeapProfilerStop(). That was fixed earlier in
  this branch -- the destructor does call HeapProfilerStop() now, with a regression
  test -- so the page was advertising a bug that no longer exists. Replaced with
  what the destructor does and why the heap profiler must be stopped (process-global
  tcmalloc state keeps recording after the object dies).
- The same page warned that the header comment for ProfilerState::duration says
  "seconds" and is stale. The header says milliseconds now. Replaced with the
  distinction that actually trips people up: ProfilerState::duration is
  milliseconds while ChartOptions::duration and analyzeCPUProfile's duration are
  seconds.

Hardcoded source line numbers are gone. All eight references had already drifted --
`src/profiler_manager.cpp:94-96` pointed at directory creation, `112-128` at the
constructor's tail -- and they will keep drifting as the file is edited. They now
name the function or symbol instead (ProfilerManager::~ProfilerManager,
setStackCaptureSignal(), isJsonError(), captureAllThreadStacks(), ...), which
survives refactoring. The one reference that was still accurate by luck, in
symbolize.cpp, was converted too so it does not become the next false one.

Also fixes two staleness gaps found on the way:

- README's project tree predated scripts/check-sanitizers.sh and
  scripts/verify-web-ui.mjs, and its Development section listed only
  check-format.sh. Both scripts are now in the tree and documented, with the note
  that curl cannot replace a browser for front-end changes and that the panel's
  JavaScript lives in a C++ string literal the compiler never checks.
- The heap comparison table still named the old GetHeapProfile() path, which the
  previous commit replaced with HeapProfilerDump().

Checked and found accurate, so left alone: CMake options (7 listed, 7 defined),
build types (Release really does compile with -g -O2 -- the project appends those
after CMake's defaults), package name and namespace, exported target names, all 26
entries of the project tree, the documented status codes (409 for /api/pprof/cpu,
Go-style 500 for /pprof/profile), the vcpkg baseline against the CI pin, documented
defaults (HTTP 10s, C++ 1s, /pprof/profile 30s), all 28 function names in the API
reference, every internal link and every README table-of-contents anchor.
@IronsDu
IronsDu merged commit 6bb64fd into main Sep 15, 2026
6 checks passed
@IronsDu
IronsDu deleted the fix/heap-error-reporting branch September 15, 2026 22:30
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