Repository navigation
Say that LimitMemory bounds what a script allocates, not what a host copies out of its result - #4182
Merged
lahma merged 1 commit intoSep 25, 2026
Conversation
…copies out of its result A long slice is a view of the string it was cut from and a long a + b refers to its operands, so many values sharing one string's characters are each charged only for what they add. JsValue.ToString() and ToObject() then copy every one of them in full, after the operation and outside every constraint: 64 slices of one 1,048,576-character string cost the script about 2 MB under a 16 MB limit and cost the host's ToObject() more than 130 MB. docs/guide/constraints.md gains a section saying so, with a compiled sample contrasting ToObject() with Engine.ConvertResult under ResultLimits, and states what each result limit checks: MaxStringLength refuses a string by its length before copying it, MaxOutputCharacters is counted after each copy, so the two together bound a conversion. The same scope is stated in the XML remarks of MemoryLimitConstraint, Engine.ConvertResult and JsValue.ToObject, in untrusted-code.md's host responsibilities, and as residual mitigations in THREAT_MODEL.md TM-06 and TM-17. HostDeferredStringMemoryLimitTests pins the documented bounded read for both shapes: ConvertResult under ResultLimits.Conservative refuses the first value on StringLength while the conversion entry allocates less than one copy. Both cases fail when the length check is moved after the copy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Sep 25, 2026
lahma
added a commit
that referenced
this pull request
Oct 10, 2026
… makes it (#4256) Engine.ConvertResult runs as an engine entry, so LimitMemory bounds it even with ResultLimits.Unlimited, the default. But the converter checks constraints once per Engine.ConstraintCheckInterval values (10,000), not per character, and a slice view or a deferred a + b costs the conversion a copy of its whole length. Under LimitMemory(16 MB), 64 slices of one 1,048,576-character string cost the script 2.1 MB and cost ConvertResult 134 MB of copies before the entry's closing check refused it; 10,000 such values would all be copied before the first check. ResultConverter.ConvertString now checks a slice view or deferred concatenation that nothing has read yet against what is left of the budget, by its length, before copying it. It goes through the new internal MemoryLimitConstraint.CheckBeforeCopying, which charges and refuses a copy that would not fit, the way ChargeDeferredConcatenation refuses an over-long node. The same conversion now refuses with 14.7 MB copied. Flat text is handed back without a copy and is never checked, so a host string larger than the whole budget still converts. A host LazyJsString is the host's own allocation and stays on the existing cadence. JsValue.ToObject() and ToString() stay unbounded, as documented since #4182. They also amplify shared structure that contains no strings: twenty nested [x, x] arrays become 100 MB on ToObject(). The constraints and untrusted-code guides, the ConvertResult and MemoryLimitConstraint remarks, THREAT_MODEL TM-06/TM-17, migration guide 4.27 and Jint/Constraints/AGENTS.md now say so. Fixes #4175 Claude-Session: https://claude.ai/code/session_01YVwU18SgMKKzYrrCh4TMpT Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#4173 charges a deferred
+when it is built, so no single value a script hands back can cost more than its budget to read. #4175 is what that leaves open: many values that share characters. This PR does the first thing the issue lists. The documentation now says thatLimitMemorybounds what the script allocates while it runs, not what a host copies out of the result afterwards, and it names the read that is bounded.What the docs now say
docs/guide/constraints.md. TheLimitMemoryparagraph now states the scope and links to a new section, Bound what the host copies out of a result. The section says:slicecan be a view of the string it was cut from, and a longa + brefers to its operands. Each is charged only for what it adds.ToString()andToObject()copy every value in full, after the operation has ended and outside every constraint. The section gives the measured numbers.engine.ConvertResult(value, ResultLimits.Conservative). A compiled sample (docs:guide-bounded-resultinJint.Documentation.Samples) shows it besideToObject().MaxStringLengthis checked against a string's length before its characters are copied.MaxOutputCharactersis counted after each copy.Conservative.ResultLimitExceededException, and itsLimitnames the bound that was reached.ConvertResultusesOptions.ResultLimits. That is unlimited by default, andUntrustedCodeLimits.ResultLimitsunderForUntrustedCode. An unlimited conversion still runs underLimitMemory, but it is charged only after the copy is made.The #4173 paragraph that mixed both points keeps only the build-time charge.
XML remarks. No paragraph was added past the four
docs/xml-doc-style.mdallows.MemoryLimitConstraint: the fourth paragraph is reworded, still four lines. TheAllocatedBytessentence it displaced moves to that property's own<remarks>.Engine.ConvertResult: a third paragraph says which two limits to set and what each checks.JsValue.ToObject: a two-line<remarks>says nothing bounds what it copies, and points toConvertResult.Elsewhere.
untrusted-code.mdgets one host-responsibility bullet.THREAT_MODEL.mdgets a residual-mitigation bullet in TM-06 (memory) and one in TM-17 (result conversion). The TM-17 bullet records theMaxOutputCharactersordering.Jint/Constraints/AGENTS.mdis unchanged, at 32,582 bytes. Its Charge a deferred string concatenation to LimitMemory when it is built #4173 bullet already says "values sharing one operand stay open;ResultLimitsbounds those".Why: the two shapes from #4175, measured on this branch
The numbers come from a probe console outside the repository. They are
GC.GetAllocatedBytesForCurrentThreaddeltas andMemoryLimitConstraint.AllocatedBytes, never timings. The engine hasLimitMemory(16 MiB), and each variant ran in its own process.ToObject()ConvertResult(…, Conservative)ConvertResult, onlyMaxOutputCharacters = 2MConvertResult, defaultResultLimitss + 'q'over one 1M-char ropeStringLength, 0.0 MBMemoryLimitExceededExceptionsliceviews of one 1M-char stringStringLength, 0.0 MBMemoryLimitExceededExceptionMaxStringLength)OutputCharactersThe last two columns are why the docs say "set both":
MaxOutputCharacters, a conversion copies the string that crosses the limit before it refuses.LimitMemorysees the copy only after it has been made.The issue body says
MaxOutputCharacters"checks the logical length before anything is flattened". That is not what the code does:ResultConvertercounts output characters afterToString(). The #4162 measurement it quotes was refused byMaxStringLength.Tests
HostDeferredStringMemoryLimitTests.ConvertResultRefusesValuesSharingOneStringBeforeCopyingThemhas two cases, slice views and concatenations over one operand. Each asserts thatConvertResult(value, ResultLimits.Conservative)throwsResultLimitExceededExceptionwithLimit == StringLength. Each also asserts that the conversion entry'sAllocatedBytesis under 1 MB; copying one value would allocate 2 MB. The test uses only the public API. Neither case flattens its subject.Broken on purpose. I moved
CountStringLengthaftervalue.ToString()inResultConverter.ConvertPrimitive. Both cases failed withExpected constraint.AllocatedBytes to be less than 1048576L … but found 2100032L(and2100720L). I then restored the file.All runs are Release.
Jint.Tests.PublicInterface, whole project,net10.0, no--timeout: 3,819 total, 0 failed, 21 skipped (the existing skips).HostDeferredStringMemoryLimitTests,HostResultLimitsTestsandPublicApiDocumentationTestonnet8.0andnet472: 42 total each, 0 failed.MigrationGuideTests,AgentInstructionFileTestsandSpecCitationTests(net10.0): 11 of 11 passed, before and after the rebase.PublicApiTestis unchanged. The change is XML docs only, with no signatures touched.Jintbuilds with 0 warnings on all five TFMs, and every newcrefresolves in each TFM'sJint.xml.Jint.Documentation.Samplesbuilds with 0 warnings.npm run samples:check: 10 snippets current.npm run lint: 0 issues in 97 files.DOCS_BASE=/jint/ npm run buildandnpm run check-links: no dead internal links, assets or anchors. That includes the new#bound-what-the-host-copies-out-of-a-result.npm run test:tools: 6 of 7. The failing one,check-links.test.mjs"rejects missing generated targets…", fails on Windows only. It expectsguide/start.htmland getsguide\start.html. This PR does not touchdocs/scripts.No benchmark was run: the engine has no code change.
Base:
7d9cdcc8fa1179b8ea92db17f617f6154dc56b7e(upstreammain).Part of #4175. The issue's engine-side options are still open: charging a view or rope root when it leaves through the result path, or capping the aggregate logical length reachable from the completion value. This PR only documents the bounded read that exists today.
🤖 Generated with Claude Code