Skip to content

Check MaxOutputCharacters against a string's length before ConvertResult copies it - #4185

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/result-output-characters-before-copy
Sep 25, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/result-output-characters-before-copy

Conversation

@lahma

@lahma lahma commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Engine.ConvertResult counted ResultLimits.MaxOutputCharacters after ToString(). With MaxStringLength unset, it flattened and copied the string that crossed the limit before refusing it. That string could be a slice view or a deferred a + b of any length up to JsString.MaxLength. #4182 found this and documented it as a residual; this PR fixes it.

The defect

ResultConverter.ConvertPrimitive, the StringInstance case and the property-name loop each did:

CountStringLength(jsString.Length);   // MaxStringLength: before the copy
var text = jsString.ToString();       // flattens a rope, materializes a slice view
CountOutputCharacters(text.Length);   // MaxOutputCharacters: after the copy

The fix

All three sites now go through a single ConvertString. It checks both limits against JsString.Length before ToString(). Every JsString representation answers Length from a field, so the check flattens nothing:

  • SlicedString._length
  • RopeString._length
  • ConcatenatedString's builder length
  • LazyJsString._length
  • the flat _value.Length

The running total is still committed from the copied text's length. For every honest string the count, the refusal, Limit, Maximum and Observed are unchanged. A host LazyJsString that materializes more characters than it declared still cannot slip them past the total.

Measured (MemoryLimitConstraint.AllocatedBytes over the ConvertResult entry, the same probe as #4182's test)

Case Before (net10.0 / net472) After (net10.0 / net472)
MaxOutputCharacters = 500_000 alone, one 1,048,575-char slice view 2,098,816 / 2,098,736 B 1,640 / 1,560 B
same, one 1,048,577-char deferred a + b 2,099,520 / 4,196,760 B 1,640 / 1,560 B
same, new String(slice view) 2,099,368 / 2,099,552 B 8,304 / 2,376 B
same, new String(a + b) 2,099,520 / 4,196,080 B 2,192 / 1,560 B
MaxOutputCharacters = 1_500_000 alone, 64 slice views of one 1 MB string (refused on the 2nd) 4,196,640 / 4,197,240 B 2,099,464 / 2,100,064 B

In the last row, the first view fits the limit and is copied on purpose (about 2 MB). The fix removes the copy of the view that crosses the limit.

Tests

Two tests in HostDeferredStringMemoryLimitTests, five cases:

  • OutputCharactersAloneRefusesAStringBeforeCopyingIt: a slice view, a deferred concatenation, and a String object over each, all under MaxOutputCharacters alone. Each asserts OutputCharacters, Maximum, Observed and fewer than 64 KiB allocated.
  • OutputCharactersRefusesTheStringThatWouldCrossTheLimitBeforeCopyingIt: the running-total case. It asserts Observed and less than 3,000,000 B allocated.

All five cases fail on the unfixed converter on both net10.0 and net472, and only on the allocation assertion. For example: Expected constraint.AllocatedBytes to be less than 65536L because copying the value would allocate 2 MB, but found 2098816L. The existing #4182 test (ConvertResultRefusesValuesSharingOneStringBeforeCopyingThem, refused on StringLength under Conservative) and HostResultLimitsTests.StringAndAggregateCharacterLimitsAreIndependent (Observed 10) pass unchanged.

Docs

Three places told hosts that MaxOutputCharacters is counted after each copy and that they must set both limits. Each now says it is checked by length before the copy and on its own bounds the characters one conversion copies:

  • docs/guide/constraints.md, "Bound what the host copies out of a result": the paragraph that said one conversion copies up to MaxStringLength + MaxOutputCharacters (3,000,000 under Conservative) now gives MaxOutputCharacters (2,000,000).
  • The Engine.ConvertResult XML remarks.
  • .github/THREAT_MODEL.md TM-17: the residual-mitigation bullet is removed and an existing-mitigation bullet is added. Its "enforces the selected limits before known-size output allocations" was not true for MaxOutputCharacters on strings until now.

The sample (guide-bounded-result) is unchanged.

Verification (Release, rebased on 85571424b)

  • Jint.Tests net10.0: 12,802 total, 12,797 passed, 5 skipped, 0 failed
  • Jint.Tests net472: 8,850 total, 8,844 passed, 6 skipped, 0 failed
  • Jint.Tests.PublicInterface net10.0: 3,824 total, 3,803 passed, 21 skipped, 0 failed
  • Jint.Tests.PublicInterface net472: 3,031 total, 3,010 passed, 21 skipped, 0 failed
  • MigrationGuideTests, AgentInstructionFileTests, SpecCitationTests: 11/11 passed

I ran no benchmark: ConvertResult is not a hot path. Per string, the change adds one comparison before the copy.

Base: 85571424b23877e045b499374a723fb3e33edc84

Part of #4175

🤖 Generated with Claude Code

…ult copies it

ResultConverter counted MaxOutputCharacters after value.ToString(), so with
MaxStringLength unset a conversion flattened and copied the string that
crossed the limit before refusing it: a slice view or a deferred a + b of
any length up to JsString.MaxLength. Under MaxOutputCharacters = 500,000
alone, refusing one 1,048,576-character value allocated 2.1 MB (4.2 MB for
a rope on net472); refusing the second of 64 slice views under 1,500,000
allocated 4.2 MB, one copy past the limit.

Primitive strings, String objects and property names now go through one
ConvertString that checks both character limits against JsString.Length
before copying. Every JsString representation answers Length from a field,
so the check flattens nothing. What counts toward the total is still the
copied text's length, so a host LazyJsString that materializes more than it
declared cannot slip characters past it. The two refusals above now
allocate 1.6-8.3 KB and 2.1 MB.

The constraints guide, the ConvertResult remarks and THREAT_MODEL.md TM-17
said MaxOutputCharacters was counted after each copy and told hosts to set
both limits; they now say it is checked before the copy and bounds the
characters a conversion copies on its own.

Part of sebastienros#4175

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lahma
lahma merged commit 53f38d9 into sebastienros:main Sep 25, 2026
11 checks passed
@lahma
lahma deleted the fix/result-output-characters-before-copy branch September 25, 2026 19:39
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