Skip to content

Check a shared string's copy against LimitMemory before ConvertResult makes it - #4256

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/4175-limitmemory-shared-text
Oct 10, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/4175-limitmemory-shared-text

Conversation

@lahma

@lahma lahma commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4175

What the issue says, and what I found

Long slice views and long a + b values can share one string's characters. LimitMemory charges each of them only for what it adds, so a host that reads all of them copies the shared text once per value. I reproduced both shapes from the issue. The documentation half of the issue already shipped in #4182, and #4185 made MaxOutputCharacters refuse a string by its length before copying it.

One gap was left, in the read path the docs recommend. Engine.ConvertResult runs as an engine entry, so LimitMemory bounds it even with ResultLimits.Unlimited, which is the default. But the converter checks constraints once per Engine.ConstraintCheckInterval values (10,000), not per character. Each value here costs a 2 MB copy, so the conversion copied all 64 values before the entry's closing check refused it. With 10,000 such values it would copy 20 GB before the first check.

The fix

ResultConverter.ConvertString now checks a slice view or deferred concatenation that nothing has read yet against the remaining budget, by its length, before copying it. It calls a new internal MemoryLimitConstraint.CheckBeforeCopying. A copy that would not fit is charged and refused, the same way ChargeDeferredConcatenation refuses an over-long node. A copy that fits is not charged up front, because the allocation counter sees it once it is made.

Flat text is returned without a copy, so it is never checked: 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.

Measured with a host probe on net10.0 under LimitMemory(16 MB):

shape charged to the script host ToObject() ConvertResult(Unlimited) before after
64 slice views of one 1M-char string 2.1 MB 134.2 MB 134.2 MB, then refused 14.7 MB, refused
64 × rope + 'q' over one 1M-char rope 2.1 MB 134.2 MB 134.2 MB, then refused 14.7 MB, refused
64 split() segments of one string 6.3 MB 134.2 MB 134.2 MB, then refused 14.7 MB, refused
control: 64 × the same flat string 2.1 MB 0 0, converts 0, converts
control: 20 nested [x, x] arrays, no strings 0 100.7 MB 16.0 MB, refused 16.0 MB, refused

What LimitMemory already bounded, all refused under 16 MB before this change:

  • one doubling past the budget;
  • eight independent 2 MB doublings;
  • the script copying each slice itself (toUpperCase, JSON.stringify) or reading a character of each rope;
  • a host callback calling ToObject() on the values during the run.

What stays unbounded, and why

ToObject() and ToString() remain unbounded, as documented since #4182. Neither has an operation to charge, and a bare JsString has no engine. They also amplify shared structure that contains no strings: in the last control row, twenty nested [x, x] arrays become 100 MB on ToObject(). So a fix specific to strings would not make either of them a bounded read.

Charging views and ropes for their full logical length when they are built would bring back the quadratic charge they exist to remove. A tokenizer doing s = s.slice(100) over 1M characters is charged 5.5 MB today and would be charged 11 GB. An accumulator doing 10,000 × s = s + chunk is charged 3.2 MB and would be charged 10 GB.

Cost

Every string ConvertResult converts now pays one extra null test on its backing field. A slice view or deferred concatenation that has not been read yet, under LimitMemory, also pays two type tests and one read of the allocation counter. The change is in ConvertResult only: no interpreter path is touched, and JsString and MemoryLimitConstraint.Check() are unchanged.

Tests

  • TheMemoryLimitAloneStopsConvertResultBeforeItCopiesSharedValuesPastTheBudget (slice views, concatenations): both cases fail on the unfixed code, which copied 16.8 MB against a 4 MB budget where the test allows 8 MB.
  • ConvertResultUnderTheMemoryLimitCopiesWhatFitsAndNeverWeighsFlatText is a guard. It passes on the unfixed code. I confirmed it fails against a variant of the fix that checks every string, including flat ones.
  • Full Jint.Tests.PublicInterface on net8.0, net10.0 and net472: 10,984 passed, 63 skipped, 0 failed.
  • Full Jint.Tests on net8.0, net10.0 and net472: 35,928 passed, 16 skipped, 0 failed.

Docs updated to match: the constraints and untrusted-code guides, the ConvertResult and MemoryLimitConstraint remarks, THREAT_MODEL TM-06 and TM-17, migration guide 4.27, and Jint/Constraints/AGENTS.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YVwU18SgMKKzYrrCh4TMpT

… makes it

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
sebastienros#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 sebastienros#4175

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVwU18SgMKKzYrrCh4TMpT
@lahma
lahma marked this pull request as ready for review October 10, 2026 07:45
@lahma
lahma merged commit e861d59 into sebastienros:main Oct 10, 2026
13 checks passed
@lahma
lahma deleted the fix/4175-limitmemory-shared-text branch October 10, 2026 07:46
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.

LimitMemory does not bound many result values that share characters: a host reading them all copies the shared text once per value

1 participant