Skip to content

Fix: Clone the model name off gjson's body copy - #1604

Merged
huang195 merged 1 commit into
rossoctl:mainfrom
huang195:fix/gjson-model-name-retention
Oct 30, 2026
Merged

huang195 merged 1 commit into
rossoctl:mainfrom
huang195:fix/gjson-model-name-retention

Conversation

@huang195

Copy link
Copy Markdown
Member

What

requestModel returned gjson's Result.String() directly. gjson.ParseBytes parses a copy of the whole body, and String() on a string node is a substring of that copy — so the ~15-byte model name handed to callers pinned the entire request body.

Every place a model name outlives the request retains that copy:

  • ext.RequestedModel in the inference extension (lives in the session event)
  • details["from"] in the model_rewritten invocation record

The inference-router calls RequestModel() on every inference request, so this is one full body copy per request, invisible to the interner (it dedupes by content, but the first insert still carries the big backing array).

Evidence

A live laptop proxy (7 min uptime, 160MB in-use heap) pinned 95MB (59%) in gjson.ParseBytes, retained via:

inferencerouter.(*Router).chooseModel
  → pipeline.(*Context).SetRequestModel
    → requestModel
      → gjson.ParseBytes   95MB flat

The regression test pins the property: 10 names kept from 4MiB bodies held exactly 40MiB before the fix, ~0 after (GC'd).

Fix

strings.Clone at requestModel's single return — every caller (RequestModel, SetRequestModel, the router) is covered at once.

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

gjson.ParseBytes parses a copy of the whole body, and Result.String()
returns a substring of that copy. requestModel handed that substring to
its callers, so every place a model name lives longer than the request —
RequestedModel in the inference extension, the model_rewritten
invocation details — pinned the entire request body. One full body copy
per inference request, invisible to the interner: measured at 95MB
(59% of a 160MB in-use heap) on a live laptop proxy, via
inferencerouter.chooseModel -> SetRequestModel -> requestModel.

strings.Clone at the single return covers every caller at once.

Pinned by TestRequestModelDoesNotPinTheBody: 10 names kept from 4MiB
bodies pinned exactly 40MiB before the fix.

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:50
@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 9 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: 84c2b6f9-f678-4828-9449-21f93afbfd7e
Commits

Reviewing files that changed from the base of the PR and between 7957069 and 1af1189.

Files selected for processing: 2
  • core/pipeline/model.go
  • core/pipeline/model_test.go
  • 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.

@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

Correct, minimal fix. gjson.ParseBytes does Parse(string(json)), so every Result.String() from it is a substring of a full body copy; cloning at requestModel's single return covers RequestModel, SetRequestModel and the inference router at once. I checked the rest of core/ and this is the only ParseBytes/Parse call site in non-test code. The GetBytes callers (limits, strip, toolprune, snapshot) are unaffected because GetBytes already copies its result strings off the input.

Verified locally (go1.26.10): go test ./pipeline/ passes, go vet and gofmt are clean, and with the clone reverted the new regression test fails with heap = 40MiB after keeping 10 model names from 4MiB bodies. So the test really does pin the property.

One optional nit inline about making the heap assertion delta-based.

Author: huang195 (MEMBER — maintainer)
Areas reviewed: Go (core/pipeline), tests, security
Agent/IDE config (.claude/.vscode): none
Commits: 1 commit, all signed-off: yes
CI status: passing (a few Go CI jobs still pending at review time; none failing)

runtime.ReadMemStats(&ms)
// 10 bodies x 4MiB = 40MiB pinned under the bug; live heap must stay well
// under that even with noise from the rest of the package's tests.
if got := ms.HeapAlloc; got > 20<<20 {

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: HeapAlloc is the whole package's live heap, so this threshold depends on whatever earlier tests in pipeline leave reachable. It's fine today (0 t.Parallel() in the package, and the bug case shows a clean 40MiB against a 20MiB bar), but taking a ReadMemStats baseline after a runtime.GC() before the loop and asserting on the delta would keep the test independent of package growth. Optional.

@huang195
huang195 merged commit 32660f0 into rossoctl:main Oct 30, 2026
41 checks passed
@huang195
huang195 deleted the fix/gjson-model-name-retention branch October 30, 2026 14:00
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Oct 30, 2026
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>
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