Skip to content

Feat: Serve pprof on the stats server when loopback-only - #1603

Merged
huang195 merged 3 commits into
rossoctl:mainfrom
huang195:feat/stats-pprof
Oct 30, 2026
Merged

huang195 merged 3 commits into
rossoctl:mainfrom
huang195:feat/stats-pprof

Conversation

@huang195

Copy link
Copy Markdown
Member

What

Mounts Go's net/http/pprof under /debug/pprof/* on the stats server (:9093 in-cluster, :47602 on a laptop install), enabled only when listener.bind_loopback_only is set.

Why

Nothing in the tree could answer "what is the proxy holding in memory". Measured on the laptop today (2026-10-30): cortex showed 3.4 GB RSS, but ~1.5 GB of that was pages the GC had already freed — macOS counts MADV_FREE pages as resident until the kernel is pressured. The pressure floor was 1.9 GB, and decomposing that further was impossible with no heap endpoint. go tool pprof -inuse_space is the only tool that splits live heap from churn, so the local install now serves it:

curl -s --noproxy '*' http://127.0.0.1:47602/debug/pprof/heap -o /tmp/heap.pb.gz
go tool pprof -top -inuse_space ~/.local/bin/cortex /tmp/heap.pb.gz

The gate

A heap profile is a dump of process memory — request bodies, prompts and tokens included. It rides the same listener.bind_loopback_only gate as sessionapi.WithClearAllowed (the DELETE guards), so:

  • laptop install (default config): served
  • cluster deployments, where the stats port is not loopback: surface unchanged, no profile data

TestStatServerPprofOffByDefault pins that a server built without the option serves no profile; TestStatServerPprofServesProfiles pins that one built with it serves a parseable heap profile. Verified end-to-end on a scratch instance both ways, including go tool pprof parsing the served profile.

Notes

  • No new dependency — net/http/pprof is stdlib.
  • The stats index page links the profiles only when they are served.
  • CLAUDE.md's "there is no pprof endpoint anywhere in this tree" line is updated, and docs/laptop-service.md documents the laptop usage.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

The proxy's memory figures were unreadable: RSS on macOS counts pages
the GC has freed but the kernel has not reclaimed, and nothing in the
tree could decompose the heap. The stats server now mounts net/http/
pprof under /debug/pprof/ when listener.bind_loopback_only is set —
the same gate as the session API's clear — because a heap profile
dumps process memory, prompts and tokens included. Cluster
deployments, where the stats port is not loopback, keep today's
surface unchanged.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 requested a review from a team as a code owner October 30, 2026 13:14
@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 21 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4ac62e81-c8c8-4f64-9b87-06dbdad70abf
Commits

Reviewing files that changed from the base of the PR and between 20ffb36 and 3880b7a.

Files selected for processing: 5
  • CLAUDE.md
  • cmd/cortex/main.go
  • core/observe/statserver.go
  • core/observe/statserver_test.go
  • docs/laptop-service.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

A page can rebind a DNS name to 127.0.0.1 and have the victim's
browser read a loopback port, and a heap profile is process memory.
The five /debug/pprof handlers now answer 403 unless the Host is
exactly localhost, 127.0.0.1 or ::1 — the same defense, and the same
predicate, as the session API's erase guards, copied rather than
shared because observe must not import sessionapi.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>

@clawgenti clawgenti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Clean, well-scoped change: net/http/pprof is mounted on the stats server's own mux (not DefaultServeMux), off by default, and enabled only from cmd/cortex via listener.bind_loopback_only. I traced the gate end to end and found no path that serves pprof on a non-loopback bind today. No blocking issues. The suggestions below are defense-in-depth plus a broken doc command.

Security analysis of the gate

  • ApplyPreset runs last and rewrites stats.address with forceLocalhost, so with the flag set every form ends up on 127.0.0.1:<port>: :9093, 0.0.0.0:9093, [::]:9093, [::1]:9093 and localhost:9093 all do. An empty address is defaulted to :9093 in Load before that (an empty address passed to net.Listen would mean every interface on a random port). An address SplitHostPort cannot parse comes back from forceLocalhost unchanged, but net.Listen rejects it too, so it fails closed.
  • The listener plan is built from the same cfg that WithPprof reads, and the stats server is built once at boot, so a hot reload cannot flip the flag under a server that is already bound.
  • Only cmd/cortex passes WithPprof. cortex-envoy and cortex-praxis import core/observe but never enable it. I found no in-repo cluster/Helm config that sets bind_loopback_only: true; the only one is demos/coding-agent-cpex (a laptop demo).
  • One side effect worth knowing: importing net/http/pprof registers handlers on http.DefaultServeMux in all three binaries. I checked every http.Server and ListenAndServe in cmd/ and core/, and none serves the default mux, so nothing is exposed today (see the inline nit).

Author: huang195 (MEMBER, maintainer)
Areas reviewed: Go (core/observe, cmd/cortex, core/config gate path), tests, docs
Agent/IDE config (.claude/.vscode): none (CLAUDE.md is a doc-text edit only)
Commits: 1 commit, all signed-off: yes
CI status: passing (go test ./observe/ and go vet also pass locally)

if o.tlsBridgeUnread != nil {
mux.Handle("/tls-bridge/unread", o.tlsBridgeUnread)
}
if o.pprof {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The PR describes this as "the same gate as the session API's clear", but clearRefusal makes three checks and pprof adopts only the last one. Clear also rejects a non-loopback Host (loopbackHost) and any request carrying an Origin header, which is the DNS-rebinding and browser defense (see the comment on logClearRefusal). Without those checks:

  • A rebinding page can read /debug/pprof/* same-origin.
  • Any web page can fire a simple no-cors GET at http://127.0.0.1:47602/debug/pprof/trace?seconds=86400 or /profile?seconds=.... The stats server sets no WriteTimeout, so pprof puts no cap on seconds. The tracer/profiler runs for as long as the browser keeps the connection open, and it holds the process-wide CPU-profile slot. heap?gc=1 in a loop forces repeated GCs.

The data exposed is less sensitive than the PR states (see the comment on line 71), so I'm not treating this as blocking. Still, wrapping the pprof routes in the same Host/Origin check would cost a few lines and make the "same gate" claim true. One way: move loopbackHost into a small shared helper and wrap with func(h http.HandlerFunc) http.HandlerFunc.

Comment thread cmd/cortex/main.go
}
// A heap profile dumps process memory — prompts and tokens included — so it
// rides the same loopback-only gate as the session API's clear.
statOpts = append(statOpts, observe.WithPprof(cfg.Listener.BindLoopbackOnly))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (defense in depth): The gate is the config flag, so it relies on an invariant that lives elsewhere: ApplyPreset has to keep running forceLocalhost over Stats.StatsAddress after every default. This tree has already been bitten by that ordering once (#1564, see the comment in presets.go). It would be more robust to also check the socket that was actually bound, e.g.

statsLn := listeners.take("stats")
pprofOK := cfg.Listener.BindLoopbackOnly && isLoopbackAddr(statsLn.Addr())
statOpts = append(statOpts, observe.WithPprof(pprofOK))

Here isLoopbackAddr is addr.(*net.TCPAddr).IP.IsLoopback(). Then a future refactor of the preset ordering, or a new way of setting the stats address, cannot put heap/goroutine/trace endpoints on a wildcard bind. A table test on forceLocalhost covering [::]:p, 0.0.0.0:p, localhost:p and [::1]:p would also pin the half that lives in core/config.

Comment thread core/observe/statserver.go Outdated
}

// WithPprof mounts Go's net/http/pprof handlers under /debug/pprof/. Off unless
// asked for: a heap profile is a dump of process memory — request bodies and

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (accuracy): "A heap profile is a dump of process memory — request bodies and tokens included" isn't right for Go's pprof. A heap profile is sampled allocation sites: stack traces with object counts and byte sizes, plus mapping/build-ID metadata. It contains no object contents, so no prompts, bodies or tokens (the tree uses no pprof.Do labels that could carry values). goroutine?debug=2 prints arguments only as hex words. The more sensitive pieces are cmdline (process argv) and the code-structure and timing information.

Keeping the gate conservative is fine. But the stated rationale is repeated in main.go, CLAUDE.md (two places), docs/laptop-service.md and the test comment, and it will steer later decisions such as whether to enable this in-cluster behind port-forward. I'd reword it to something like "exposes internal code structure, argv, and lets a caller start CPU/trace profiling".

Comment thread docs/laptop-service.md Outdated
```

The stats port also serves Go's pprof profiles under `/debug/pprof/` —
`curl -s http://127.0.0.1:47602/debug/pprof/heap | go tool pprof -top -inuse_space cortex -`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This command fails as written. go tool pprof doesn't read a profile from stdin, so - is treated as a filename (verified with go1.26.10: -: stat -: no such file or directory / failed to fetch any source profiles). The binary argument isn't needed either, because Go heap profiles carry their own symbols. Simplest form:

go tool pprof -top -inuse_space http://127.0.0.1:47602/debug/pprof/heap

or the two-step curl -s --noproxy '*' ... -o /tmp/heap.pb.gz from the PR description. The --noproxy '*' is worth keeping on a laptop whose shell has HTTP_PROXY pointed at cortex.

"log/slog"
"net"
"net/http"
"net/http/pprof"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The init() of net/http/pprof also registers /debug/pprof/* on http.DefaultServeMux. Because it's imported here, that now happens in cortex, cortex-envoy and cortex-praxis, whatever WithPprof says. Nothing in the tree serves the default mux today (I checked every http.Server and ListenAndServe under cmd/ and core/), so this is latent. But a future http.ListenAndServe(addr, nil) or an http.Server with no Handler would serve ungated pprof on whatever it binds. (x/net/trace, pulled in through gRPC, already does the same thing for /debug/requests.) A one-line comment here noting the side effect would help, and so would a test asserting that no production server uses a nil handler.

Comment thread core/observe/statserver_test.go Outdated
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /debug/pprof/heap status = %d, want 200", resp.StatusCode)
}
if len(body) == 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The PR description says this test pins "a parseable heap profile", but it only checks for a non-empty body. Checking for the gzip magic (body[0] == 0x1f && body[1] == 0x8b), or Content-Type: application/octet-stream, would actually separate a profile from, say, the HTML index or an error page. The off-by-default test could also assert the index page has no /debug/pprof/ link.

@huang195

Copy link
Copy Markdown
Member Author

Addressed the review's should-fix: the five /debug/pprof/* handlers now answer 403 unless the Host is exactly localhost, 127.0.0.1 or ::1 (with or without port) — the same DNS-rebinding defense, and the same predicate, as the session API's erase guards (core/sessionapi/clear.go). Copied rather than shared: observe importing sessionapi would be the wrong dependency direction.

Fails closed: an absent Host (HTTP/1.0) is refused too. TestStatServerPprofRefusesReboundHost pins both directions — rebound names (evil.example, 127.0.0.1.evil.example, [::1].evil.example) get 403, loopback names get profiles. Verified on a scratch instance over the wire as well.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Review round on rossoctl#1603: the Host check alone does not stop a browser,
because a simple no-cors GET carries a loopback Host and needs no
preflight — browsers always send Origin, so its presence is now a
refusal, the erase guards' third check. And the profiles expose less
than the first round claimed: a heap profile is sampled allocation
sites, not object contents, so the rationale now says what they do
expose (stack traces, argv, caller-started CPU/trace profiling).
Also fixes a laptop-service command that piped a profile into go tool
pprof, which cannot read stdin, and pins the served body as a gzip
stream rather than merely non-empty.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195

Copy link
Copy Markdown
Member Author

Review round pushed as 3880b7a1 (plus b1b8b1a2, which landed after the review was written). Suggestion by suggestion:

  • Host/Origin guard — the loopback-Host half landed in b1b8b1a2; this round adds the Origin refusal (TestStatServerPprofRefusesBrowserOrigin), which is the half the Host check cannot cover: a simple no-cors GET from a page carries a loopback Host and needs no preflight. Not capping ?seconds= — on a loopback port the threat is browsers, not local processes, and a local process needs no pprof to burn CPU.
  • Defense in depth on the bound socket — skipped. forceLocalhost's ordering is already pinned by core/config/loopback_bind_test.go (wildcards included), and the repo's rule is that config mistakes stay config problems until they happen often.
  • Sensitivity claim — fixed everywhere it was repeated (statserver, main.go, CLAUDE.md, laptop-service.md, test comment). You're right: sampled allocation sites, not object contents. The comments now say stack traces, argv, caller-started CPU/trace profiling.
  • Broken doc command — fixed; the two-step curl -o + go tool pprof form, with a note on why the pipe can't work and why --noproxy matters here.
  • DefaultServeMux side effect — noted in WithPprof's doc comment: nothing in the tree may serve a nil-Handler http.Server, or these are exposed ungated. Skipped the nil-handler test — one comment states the invariant, and a test that greps for future code doesn't exist yet.
  • Test strength — done: the served body must be a gzip stream (magic bytes), and the off-by-default test also asserts the index carries no /debug/pprof/ link.

Full core + cmd/cortex suites, vet, gofmt all green.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

@huang195
huang195 merged commit 7957069 into rossoctl:main Oct 30, 2026
40 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Oct 30, 2026
@huang195
huang195 deleted the feat/stats-pprof branch October 30, 2026 13:49
huang195 added a commit that referenced this pull request Oct 30, 2026
The proxy got pprof in #1603; the viewer did not, and it has needed
it — agentop has been the larger of the two processes more than once
(the #1542 stream leak lived here), and nothing on the box could
decompose its heap. The viewer has no HTTP server to hang the
endpoints on, so AGENTOP_PPROF_ADDR starts one for the viewer's
lifetime:

  AGENTOP_PPROF_ADDR=127.0.0.1:47603 agentop observe
  go tool pprof http://127.0.0.1:47603/debug/pprof/heap

Off unless set: the profiles expose the process's internals, and the
viewer's heap holds the session content it is displaying. The handlers
carry the same browser defenses as #1603 and the session API's erase
guards — a non-loopback Host or any Origin is a 403 — copied rather
than shared because this is a different binary. A bind failure is
reported on stderr and not fatal. The listener starts and is announced
before tea takes the alt screen, where stderr stops reaching the user.

Verified live: the listener serves while the TUI runs, go tool pprof
reads the heap, and both browser guards answer 403.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
huang195 added a commit that referenced this pull request Oct 30, 2026
Two gaps the pprof pass on a live proxy caught, both still open after the
memory fixes in #1604 and #1606:

requestModel copied every routed request body. gjson.ParseBytes is
Parse(string(body)), so reading the top-level keys for "model" made one
full copy of the body per request — ~1GB/hour of churn on a proxy that
was mostly idle, third behind tool-prune's copies. #1604 stopped the copy
being pinned; this stops it being made. The scan now runs over
unsafe.String aliasing the body: safe because gjson never writes what it
parses, the body is not written concurrently on the request path, and the
only string that escapes the scan is the name, which #1604 already
clones.

TestRequestModelDoesNotCopyTheBody pins the property: one call on a 4MiB
body allocates under 64KiB, where the old code allocated the 4MiB.

/debug/pprof/block and /mutex were permanently empty. The handlers from
#1603 exist, but the runtime records contention only when asked, and
nothing ever called SetBlockProfileRate or SetMutexProfileFraction — the
two endpoints answered 200 with zero samples, on the box and on every
deployment with loopback-only listeners. WithPprof now turns the
recorders on with the handlers, under the same loopback gate. Both cost
nothing unless the event they measure happens.

TestStatServerPprofEnablesContentionProfiles reads the rates back
process-globally: both were left at zero before.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
evaline-ju pushed a commit to evaline-ju/cortex that referenced this pull request Oct 30, 2026
…elpers

The loopback-Host predicate existed as three unexported copies (the
session API's erase guards, the stats server's pprof endpoints, the
agentop viewer) and the guarded pprof mounting as two. core/listener/
httpx now owns both: LoopbackHost and RegisterPprof, with the guard
table and heap-profile shape pinned once in httpx's own tests. agentop
keeps one wiring test; observe's and sessionapi's existing tests now
exercise the shared code through their own servers.

Also merges main into the branch: the PR branched before rossoctl#1603 landed,
so the observe half of this dedupe had no code to change without it.

Fixes review: PR rossoctl#1608 review finding 1 — a security predicate copied
per binary drifts when one copy is fixed and the others are not.
Files: cmd/agentop/pprof.go, cmd/agentop/pprof_test.go,
core/listener/httpx/pprof.go, core/listener/httpx/pprof_test.go,
core/listener/httpx/render.go, core/observe/statserver.go,
core/sessionapi/clear.go, core/sessionapi/delete_test.go

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
evaline-ju pushed a commit to evaline-ju/cortex that referenced this pull request Oct 30, 2026
…address

The Host and Origin guards stop browsers, not a curl that sets
Host: localhost, so AGENTOP_PPROF_ADDR=:47603 or 0.0.0.0:47603 would
hand the heap — the session content the viewer is displaying — to
anyone on the network. startPprof now refuses a bind that did not land
on loopback, checked on the bound address rather than the string, and
returns the address it landed on so a :0 port is announced as its real
port. rossoctl#1603 gates its pprof the same way, on the stats port's
bind_loopback_only.

Fixes review: clawgenti's approve-with-comments on rossoctl#1608 — the
unguarded bind address, the :0 announce, and the missing startPprof
test, which the returned address makes possible over a real socket.
Files: cmd/agentop/pprof.go, cmd/agentop/pprof_test.go,
cmd/agentop/README.md

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants