Skip to content

node:string_decoder: construct subclasses of StringDecoder - #42582

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/3a0979ae/string-decoder-subclass
Sep 14, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/3a0979ae/string-decoder-subclass

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • class Sub extends StringDecoder {}; new Sub("utf8") returns the class Sub itself. s.write is undefined, and Sub gains an own encoding property. Reflect.construct(StringDecoder, ["utf8"], Object) returns Object. Node returns an instance.
  • JSStringDecoderConstructor registers one native function for [[Call]] and [[Construct]] (src/jsc/bindings/JSStringDecoder.h:118). JSC passes new.target in the this slot of a native constructor, so the branch for StringDecoder.call(receiver, encoding) (JSStringDecoder.cpp:592) takes Sub as the receiver.
  • Found by an audit. No issue reports it.

Fix

  • Register separate call and construct functions that share one createDecoder body. For a [[Construct]] it takes the structure from new.target with InternalFunction::createSubclassStructure, as JSHash and the generated classes do. call keeps the receiver branch unchanged.
  • The Node.js compatibility page no longer lists this gap. Not in this PR: JSMockFunction.cpp:459 has the same cause, and Don't register native functions as their own constructors #34575 and bun:test: mock functions match Jest for Proxy receivers, new, and mock.instances #41398 carry it.
  • new StringDecoder() costs the same as before (ThinLTO A/B in the notes).
  • Verified: test/js/node/string_decoder/string-decoder.test.js (143 pass, released bun fails the 45 new cases), Node's test-string-decoder*.js, express-body-parser-test.test.ts. Self-reviewed: 4 concerns raised, 4 addressed.

Background

  • [[Call]] is f(). [[Construct]] is new f() or Reflect.construct. A JSC InternalFunction takes one native function for each.
  • new.target is the constructor that new was applied to: Sub for new Sub(). The new object takes its prototype from it.
  • A JSC Structure holds the prototype of an object. createSubclassStructure returns the base structure with the prototype of new.target.
  • The receiver branch serves iconv-lite 0.4 (under express body-parser), which runs StringDecoder.call(this, enc) on a plain object. It stores the native decoder on that object.
Notes

Repro

const { StringDecoder } = require("string_decoder");
class Sub extends StringDecoder {}
const s = new Sub("utf8");
console.log(s === Sub, typeof s, s instanceof Sub, Reflect.ownKeys(Sub));
// bun 1.4.3:    true function false [ "length", "name", "prototype", "encoding" ]
// node v26.3.0: false object true [ 'length', 'name', 'prototype' ]
// this branch:  false object true [ "length", "name", "prototype" ]

Sibling sites (every place that registers one function for both [[Call]] and [[Construct]])

Cost of new StringDecoder()

A first version had a parse helper and called JSStringDecoder::create from both call and construct. With two call sites, allocateCell<JSStringDecoder> went out of line and new StringDecoder() got slower. One shared createDecoder keeps a single call site for the allocation, and construct compiles to 9 instructions that end in jmp createDecoder.

ThinLTO release build, only this translation unit replaced, one pinned core, 300 reps of 200000 constructions, p50 in ns over 5 alternating rounds:

variant new StringDecoder() new StringDecoder("utf8")
main 11.6 to 12.3 19.5 to 21.1
first version 14.9 to 16.1 19.8 to 20.9
this PR 11.4 to 12.6 18.0 to 19.3

Behavior details

  • Order matches Node: the prototype of new.target is read before the encoding is parsed (probed with a Proxy new.target and an encoding object with a toString).
  • call is unchanged, including the thisValue != constructor check, so StringDecoder.call(StringDecoder, enc) still returns a fresh decoder. The two putDirect lines in the receiver branch are textually untouched, so node:string_decoder: make encoding an enumerable own data property #34609 and other work on that branch should merge without a conflict.
  • A new.target whose prototype is not an object gets StringDecoder.prototype, as for every JSC built-in class. Node gives Object.prototype there, because its StringDecoder is an ordinary function.
  • Cross-realm: a class defined in a node:vm context or a ShadowRealm can extend StringDecoder. The function realm of new.target is not always a Zig::GlobalObject, so the code goes through defaultGlobalObject(). A revoked Proxy as new.target throws a TypeError.
  • Remaining difference, not changed here: Node's end(buf) and text() call this.write(), so a subclass that overrides write sees those calls. Bun's native end and text call the native write directly. A plain instance with a patched write shows the same difference today.
  • Not changed here: lastNeed, lastTotal and lastChar throw on a StringDecoder.call(this) receiver, because the prototype accessors have a class check. Instances of a subclass are real JSStringDecoder cells, so the accessors work on them.

Suites run with the debug (ASAN) build

  • test/js/node/string_decoder/string-decoder.test.js: 143 pass. The whole decode suite now also runs against class SubStringDecoder extends StringDecoder. Released bun 1.4.3: 98 pass, 45 fail.
  • The same file and a script of every throw path under BUN_JSC_validateExceptionChecks=1: clean.
  • test/js/node/test/parallel/: test-string-decoder.js, test-string-decoder-end.js, test-string-decoder-fuzz.js, test-crypto-hash.js, test-fs-read-stream-encoding.js, test-readline-keys.js, test-stream-decoder-objectmode.js, test-stream-push-strings.js, test-stream-readable-setEncoding-existing-buffers.js, test-stream-readable-setEncoding-null.js, test-stream2-decode-partial.js, test-stream2-set-encoding.js.
  • test/js/third_party/body-parser/express-body-parser-test.test.ts, and iconv-lite 0.4.24 (StringDecoder.call(this, enc) with InternalDecoder.prototype = StringDecoder.prototype) by hand.

Self-review

The review asked for four changes. All are in this PR: the stale docs line, the sibling-sites list, the slower new StringDecoder() of the first version, and multi-line comments in src/.


[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/string_decoder/string-decoder.test.js
ninja: Entering directory `/workspace/bun/build/debug'
[1/164] cc obj/src/jsc/bindings/sqlite/sqlite3.c.o
[2/164] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[3/164] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes
... (truncated)

release without fix: 45 FAILED
bun test v1.4.3-canary.1 (6a92015fc)

test/js/node/string_decoder/string-decoder.test.js:
(pass) require('string_decoder') [0.10ms]
(pass) Bun.inspect(StringDecoder) [0.09ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8 [1.88ms]
(pass) FakeStringDecoderCall > StringDecoder-ucs-2 [0.94ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le [0.04ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8-additional [0.11ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le-additional [0.06ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64 testbuf [0.18ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64url testbuf [0.10ms]
(pass) FakeStringDecoderCall > StringDecoder.end > hex testbuf [0.11ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 testbuf [0.06ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf16le testbuf [0.04ms]
(pass) FakeStringDecoderCall > StringDecoder.end > ucs2 testbuf [0.05ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 partial "�a" [0.03ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 partial "��"
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 partial "��"
(pass) FakeS
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/string_decoder/string-decoder.test.js
bun test v1.4.3 (6a92015fc)

test/js/node/string_decoder/string-decoder.test.js:
(pass) require('string_decoder') [6.27ms]
(pass) Bun.inspect(StringDecoder) [5.39ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8 [340.80ms]
(pass) FakeStringDecoderCall > StringDecoder-ucs-2 [239.90ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le [4.82ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8-additional [9.54ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le-additional [8.09ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64 testbuf [25.18ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64url testbuf [17.47ms]
(pass) FakeStringDecoderCall > StringDecoder.end > hex testbuf [13.70ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 testbuf [15.44ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf16le testbuf [11.48ms]
(pass) FakeStringDecoderCall > StringDecoder.end > ucs2 testbuf [12.16ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 706ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] cc obj/src/jsc/bindings/sqlite/sqlite3.c.o
[2/125] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[3/125] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api
... (truncated)
diff hotspot
docs/runtime/nodejs-compat.mdx                     |   2 +-
 src/jsc/bindings/JSStringDecoder.cpp               |  45 ++++++--
 src/jsc/bindings/JSStringDecoder.h                 |   5 +-
 test/js/node/string_decoder/string-decoder.test.js | 114 ++++++++++++++++++++-
 4 files changed, 151 insertions(+), 15 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
docs/runtime/nodejs-compat.mdx                          1      1     18
src/jsc/bindings/JSStringDecoder.cpp                    5     12     18
src/jsc/bindings/JSStringDecoder.h                      1      1     18
test/js/node/string_decoder/string-decoder.test.js      2      5     18

JSStringDecoderConstructor used one native function for [[Call]] and
[[Construct]]. JSC passes new.target in the this slot of a native
constructor, so `new Sub()` for `class Sub extends StringDecoder`
reached the `StringDecoder.call(receiver, encoding)` arm with the class
as the receiver. The arm wrote the decoder state onto the class and
returned the class.

Register separate call and construct functions. Both go through one
createDecoder() body. For a [[Construct]] it takes the structure for
new.target (InternalFunction::createSubclassStructure), so the instance
has the prototype of the subclass. call() keeps the receiver arm for
util.inherits-style constructors and is otherwise unchanged.

One shared body keeps a single call site for the cell allocation, so
the allocation still inlines. construct() compiles to a tail call into
it. `new StringDecoder()` costs the same as before in a ThinLTO build.

The Node.js compatibility page no longer lists subclassing as a gap.
@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:37 AM PT - Sep 13th, 2026

✅ @robobun, your commit 2717beff8e77e9b4e50bec74a5550d9c9866eb42 passed in Build #115094! 🎉


🧪   To try this PR locally:

bunx bun-pr 42582

That installs a local version of the PR into your bun-42582 executable, so you can run:

bun-42582 --bun

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on bun 1.4.3-canary.1+6a92015fc and on main b99371011f (linux x64) with the script in the PR notes. Bun prints true function false [ "length", "name", "prototype", "encoding" ]. Node v26.3.0 prints false object true [ 'length', 'name', 'prototype' ].
  • Fail-before: USE_SYSTEM_BUN=1 bun test test/js/node/string_decoder/string-decoder.test.js fails 45 of 143 tests, all of them the new subclass cases.
  • With the fix: bun bd test test/js/node/string_decoder/string-decoder.test.js passes 143 of 143.
  • The PR waits for CI and for a maintainer review.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: d88b3863-b872-431f-8b2a-aa971b75c19b

📥 Commits

Reviewing files that changed from the base of the PR and between 09bb546 and 2717bef.

📒 Files selected for processing (4)
  • docs/runtime/nodejs-compat.mdx
  • src/jsc/bindings/JSStringDecoder.cpp
  • src/jsc/bindings/JSStringDecoder.h
  • test/js/node/string_decoder/string-decoder.test.js

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

Changes

StringDecoder subclassing

Layer / File(s) Summary
Shared decoder initialization
src/jsc/bindings/JSStringDecoder.h, src/jsc/bindings/JSStringDecoder.cpp
JSStringDecoderConstructor now uses separate call and construct handlers. Both paths use shared decoder creation logic with encoding validation and subclass-aware structure selection.
Subclass behavior coverage and documentation
test/js/node/string_decoder/string-decoder.test.js, docs/runtime/nodejs-compat.mdx
Tests cover subclass construction, alternate newTarget values, proxies, VM contexts, prototype behavior, and decoder state. The compatibility note removes the subclassing limitation.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 2717b

StringDecoder subclass construction and existing call behavior have no identified merge-blocking issue.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: constructing subclasses of StringDecoder correctly.
Description check ✅ Passed The description explains the problem, fix, scope, compatibility impact, performance, and verification results. It does not use the exact template headings, but it provides the required information in …

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — focused fix following the established getFunctionRealm → createSubclassStructure pattern, with thorough coverage.

What was reviewed:

  • Exception handling in createDecoder: every fallible call (getFunctionRealm, createSubclassStructure, parseEnumeration, toString, view) has RETURN_IF_EXCEPTION before use; the thin construct() wrapper encodes nullptr → empty on the throw path, matching the sibling bindings.
  • asObject(callFrame->newTarget()) is safe — [[Construct]] guarantees an object newTarget; the call() receiver branch is byte-for-byte preserved for iconv-lite.
  • Tests: combined-object .toEqual shape, finally { delete Object.encoding } guards the unfixed-build side effect, revoked-Proxy and cross-realm cases exercise both RETURN_IF_EXCEPTION sites in the new branch.
Extended reasoning...

Overview

This PR splits JSStringDecoderConstructor's single native function into separate call() and construct() host functions so class Sub extends StringDecoder works. The shared createDecoder() helper resolves a subclass structure via InternalFunction::createSubclassStructure when newTarget differs from the base constructor, going through defaultGlobalObject(getFunctionRealm(...)) for cross-realm cases. Files touched: src/jsc/bindings/JSStringDecoder.{cpp,h} (~40 net lines), test/js/node/string_decoder/string-decoder.test.js (+110 lines of tests), and a one-line caveat removal from docs/runtime/nodejs-compat.mdx.

Security risks

None identified. This is prototype-chain plumbing for a Node compat constructor; no auth, crypto, filesystem, or network surface. The only user-observable side effect (reading newTarget.prototype, which can trigger a Proxy trap) is guarded by RETURN_IF_EXCEPTION and matches the ECMAScript-mandated ordering. No new allocation-size arithmetic or untrusted-length handling.

Level of scrutiny

Medium — native JSC binding code with exception-scope requirements. I checked each fallible call in createDecoder against REVIEW.md's "RETURN_IF_EXCEPTION before result is used" rule: getFunctionRealm (revoked Proxy throws — tested), createSubclassStructure (Proxy prototype trap — tested), parseEnumeration, toString, view, and ERR::UNKNOWN_ENCODING are all covered. The pattern is byte-for-byte identical to at least nine sibling bindings (JSDOMFile.cpp, NodeFSStatBinding.cpp, JSBakeResponse.cpp, JSHash.cpp, NodeDirent.cpp, etc.), so this is a mechanical application of the house idiom rather than a novel design. The construct() wrapper has no throw scope, but it's a pure tail-call whose nullptr result encodes to empty — the same shape JSC's own thin construct wrappers use, and the PR reports a clean BUN_JSC_validateExceptionChecks=1 run.

Other factors

Test coverage is strong: the entire pre-existing decode suite is now parametrized over a class extends subclass, and a new describe block covers Reflect.construct with foreign/Object/Proxy/revoked-Proxy newTarget plus node:vm cross-realm. The Object-as-newTarget test wraps assertions in try/finally { delete Object.encoding } so an unfixed build doesn't leak state into later tests — good hygiene per the "tests leave nothing behind" rule. Tests live in the existing module file, use combined-object .toEqual, and add vm as a static import. CODEOWNERS does not cover any changed path, there are no third-party reviews in the timeline, and the bug hunt ran to a dry streak with no findings. The PR description enumerates sibling sites sharing the root cause and explains why JSMockFunction is intentionally excluded (in-flight PRs), satisfying the "fix the whole class or say why not" rule.

@Jarred-Sumner
Jarred-Sumner merged commit fa6fed2 into main Sep 14, 2026
8 of 9 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/3a0979ae/string-decoder-subclass branch September 14, 2026 06:06
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…2582)

### Problem
- `class Sub extends StringDecoder {}; new Sub("utf8")` returns the
class `Sub` itself. `s.write` is `undefined`, and `Sub` gains an own
`encoding` property. `Reflect.construct(StringDecoder, ["utf8"],
Object)` returns `Object`. Node returns an instance.
- `JSStringDecoderConstructor` registers one native function for
[[Call]] and [[Construct]] (`src/jsc/bindings/JSStringDecoder.h:118`).
JSC passes new.target in the `this` slot of a native constructor, so the
branch for `StringDecoder.call(receiver, encoding)`
(`JSStringDecoder.cpp:592`) takes `Sub` as the receiver.
- Found by an audit. No issue reports it.

### Fix
- Register separate `call` and `construct` functions that share one
`createDecoder` body. For a [[Construct]] it takes the structure from
new.target with `InternalFunction::createSubclassStructure`, as `JSHash`
and the generated classes do. `call` keeps the receiver branch
unchanged.
- The Node.js compatibility page no longer lists this gap. Not in this
PR: `JSMockFunction.cpp:459` has the same cause, and oven-sh#34575 and oven-sh#41398
carry it.
- `new StringDecoder()` costs the same as before (ThinLTO A/B in the
notes).
- Verified: `test/js/node/string_decoder/string-decoder.test.js` (143
pass, released bun fails the 45 new cases), Node's
`test-string-decoder*.js`, `express-body-parser-test.test.ts`.
Self-reviewed: 4 concerns raised, 4 addressed.

### Background
- [[Call]] is `f()`. [[Construct]] is `new f()` or `Reflect.construct`.
A JSC `InternalFunction` takes one native function for each.
- new.target is the constructor that `new` was applied to: `Sub` for
`new Sub()`. The new object takes its prototype from it.
- A JSC `Structure` holds the prototype of an object.
`createSubclassStructure` returns the base structure with the prototype
of new.target.
- The receiver branch serves iconv-lite 0.4 (under express body-parser),
which runs `StringDecoder.call(this, enc)` on a plain object. It stores
the native decoder on that object.

<details><summary>Notes</summary>

**Repro**

```js
const { StringDecoder } = require("string_decoder");
class Sub extends StringDecoder {}
const s = new Sub("utf8");
console.log(s === Sub, typeof s, s instanceof Sub, Reflect.ownKeys(Sub));
// bun 1.4.3:    true function false [ "length", "name", "prototype", "encoding" ]
// node v26.3.0: false object true [ 'length', 'name', 'prototype' ]
// this branch:  false object true [ "length", "name", "prototype" ]
```

**Sibling sites** (every place that registers one function for both
[[Call]] and [[Construct]])

- `src/jsc/bindings/JSMockFunction.cpp:459`: `jsMockFunctionCall` reads
`thisValue()`, so `new mockFn()` sees new.target as `this`. Same root
cause. Excluded here because oven-sh#34575 and oven-sh#41398 already change that
function. This PR does not fix `mock()` or `spyOn()`.
- These never read `this` or new.target, so the confusion cannot happen
there: the seven sink constructors from
`src/codegen/generate-jssink.ts:59` (`js_construct` ignores the frame),
`JSDOMConstructorNotConstructable` (throws on both paths), the
`ObjectTemplate` `DummyCallback` in the V8 shim,
`jsNodeVmWasmDisallowed` (`NodeVM.cpp:1046`, throws on both paths),
`vm.createContext` (`NodeVM.cpp:1714`), and `SlowBuffer`.
- oven-sh#42528 makes other native constructors honor new.target. It does not
cover `StringDecoder`.

**Cost of `new StringDecoder()`**

A first version had a parse helper and called `JSStringDecoder::create`
from both `call` and `construct`. With two call sites,
`allocateCell<JSStringDecoder>` went out of line and `new
StringDecoder()` got slower. One shared `createDecoder` keeps a single
call site for the allocation, and `construct` compiles to 9 instructions
that end in `jmp createDecoder`.

ThinLTO release build, only this translation unit replaced, one pinned
core, 300 reps of 200000 constructions, p50 in ns over 5 alternating
rounds:

| variant | `new StringDecoder()` | `new StringDecoder("utf8")` |
| --- | --- | --- |
| main | 11.6 to 12.3 | 19.5 to 21.1 |
| first version | 14.9 to 16.1 | 19.8 to 20.9 |
| this PR | 11.4 to 12.6 | 18.0 to 19.3 |

**Behavior details**

- Order matches Node: the `prototype` of new.target is read before the
encoding is parsed (probed with a Proxy new.target and an encoding
object with a `toString`).
- `call` is unchanged, including the `thisValue != constructor` check,
so `StringDecoder.call(StringDecoder, enc)` still returns a fresh
decoder. The two `putDirect` lines in the receiver branch are textually
untouched, so oven-sh#34609 and other work on that branch should merge without
a conflict.
- A new.target whose `prototype` is not an object gets
`StringDecoder.prototype`, as for every JSC built-in class. Node gives
`Object.prototype` there, because its `StringDecoder` is an ordinary
function.
- Cross-realm: a class defined in a `node:vm` context or a ShadowRealm
can extend `StringDecoder`. The function realm of new.target is not
always a `Zig::GlobalObject`, so the code goes through
`defaultGlobalObject()`. A revoked Proxy as new.target throws a
TypeError.
- Remaining difference, not changed here: Node's `end(buf)` and `text()`
call `this.write()`, so a subclass that overrides `write` sees those
calls. Bun's native `end` and `text` call the native write directly. A
plain instance with a patched `write` shows the same difference today.
- Not changed here: `lastNeed`, `lastTotal` and `lastChar` throw on a
`StringDecoder.call(this)` receiver, because the prototype accessors
have a class check. Instances of a subclass are real `JSStringDecoder`
cells, so the accessors work on them.

**Suites run with the debug (ASAN) build**

- `test/js/node/string_decoder/string-decoder.test.js`: 143 pass. The
whole decode suite now also runs against `class SubStringDecoder extends
StringDecoder`. Released bun 1.4.3: 98 pass, 45 fail.
- The same file and a script of every throw path under
`BUN_JSC_validateExceptionChecks=1`: clean.
- `test/js/node/test/parallel/`: `test-string-decoder.js`,
`test-string-decoder-end.js`, `test-string-decoder-fuzz.js`,
`test-crypto-hash.js`, `test-fs-read-stream-encoding.js`,
`test-readline-keys.js`, `test-stream-decoder-objectmode.js`,
`test-stream-push-strings.js`,
`test-stream-readable-setEncoding-existing-buffers.js`,
`test-stream-readable-setEncoding-null.js`,
`test-stream2-decode-partial.js`, `test-stream2-set-encoding.js`.
- `test/js/third_party/body-parser/express-body-parser-test.test.ts`,
and iconv-lite 0.4.24 (`StringDecoder.call(this, enc)` with
`InternalDecoder.prototype = StringDecoder.prototype`) by hand.

**Self-review**

The review asked for four changes. All are in this PR: the stale docs
line, the sibling-sites list, the slower `new StringDecoder()` of the
first version, and multi-line comments in `src/`.

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/string_decoder/string-decoder.test.js
ninja: Entering directory `/workspace/bun/build/debug'
[1/164] cc obj/src/jsc/bindings/sqlite/sqlite3.c.o
[2/164] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[3/164] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes
... (truncated)

release without fix: 45 FAILED
bun test v1.4.3-canary.1 (6a92015)

test/js/node/string_decoder/string-decoder.test.js:
(pass) require('string_decoder') [0.10ms]
(pass) Bun.inspect(StringDecoder) [0.09ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8 [1.88ms]
(pass) FakeStringDecoderCall > StringDecoder-ucs-2 [0.94ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le [0.04ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8-additional [0.11ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le-additional [0.06ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64 testbuf [0.18ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64url testbuf [0.10ms]
(pass) FakeStringDecoderCall > StringDecoder.end > hex testbuf [0.11ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 testbuf [0.06ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf16le testbuf [0.04ms]
(pass) FakeStringDecoderCall > StringDecoder.end > ucs2 testbuf [0.05ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 partial "�a" [0.03ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 partial "��"
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 partial "��"
(pass) FakeS
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/string_decoder/string-decoder.test.js
bun test v1.4.3 (6a92015)

test/js/node/string_decoder/string-decoder.test.js:
(pass) require('string_decoder') [6.27ms]
(pass) Bun.inspect(StringDecoder) [5.39ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8 [340.80ms]
(pass) FakeStringDecoderCall > StringDecoder-ucs-2 [239.90ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le [4.82ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8-additional [9.54ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le-additional [8.09ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64 testbuf [25.18ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64url testbuf [17.47ms]
(pass) FakeStringDecoderCall > StringDecoder.end > hex testbuf [13.70ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf8 testbuf [15.44ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf16le testbuf [11.48ms]
(pass) FakeStringDecoderCall > StringDecoder.end > ucs2 testbuf [12.16ms]
(pass) FakeStringDecoderCall > StringDecoder.end > utf
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 706ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] cc obj/src/jsc/bindings/sqlite/sqlite3.c.o
[2/125] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[3/125] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
docs/runtime/nodejs-compat.mdx                     |   2 +-
 src/jsc/bindings/JSStringDecoder.cpp               |  45 ++++++--
 src/jsc/bindings/JSStringDecoder.h                 |   5 +-
 test/js/node/string_decoder/string-decoder.test.js | 114 ++++++++++++++++++++-
 4 files changed, 151 insertions(+), 15 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                reads  edits  tests
docs/runtime/nodejs-compat.mdx                          1      1     18
src/jsc/bindings/JSStringDecoder.cpp                    5     12     18
src/jsc/bindings/JSStringDecoder.h                      1      1     18
test/js/node/string_decoder/string-decoder.test.js      2      5     18
```

</details>

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants