-
-
Notifications
You must be signed in to change notification settings - Fork 163
fix(runtime): an inherited static reads its declaring class evaluation's captures (#11200, #10911) #11230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
fix(runtime): an inherited static reads its declaring class evaluation's captures (#11200, #10911) #11230
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
e3f3856
fix(runtime): an inherited static reads its DECLARING class evaluatio…
ac25e64
fix(runtime): a static getter inherited through a per-evaluation clas…
ef8fdcd
perf(runtime): skip the repeated class-object check on the capture-sl…
70cd83e
changelog: #11230 inherited static reads its declaring class's captures
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| - **An inherited static reached through a subclass reads its declaring class's captured bindings, not the subclass's (#11200, #10911).** A class that reads an enclosing binding keeps its captured values on each evaluated class object, in `__perry_ctor_caps`. A CommonJS module body is lowered as a function body, so any class in it that reads a module-scope `const` or function counts as capturing. A capturing static's prologue reads slot `index` through `js_class_capture_value_for_receiver`, which took the receiver (or the static-dispatch owner, which is also the receiver) and read its caps array directly. For `Sub.make()` running `Base.make`, that is the SUBCLASS's array, which is laid out for the subclass's own capture list, so the read returned an unrelated binding. mongodb 7.5.0's `CursorResponse.make(bson)` (inherited from `MongoDBResponse`) saw `typeof isErrorResponse === "object"`, and `collection.find().toArray()` threw `TypeError: value is not a function`. The read now walks each candidate's heritage to the evaluation of the DECLARING template (per-evaluation pinned parent first, then the template-keyed dynamic parent, the order `instanceof` uses) and reads only that evaluation's array. When no such evaluation is reachable it uses the declaration snapshot, and never another class's array. The same walk fixes #10911's capturing static METHOD on a factory class expression inherited by a top-level declaration (`class A3 extends fCapM("a") {}; A3.tagv()` returned `undefined`). A ClassRef has no caps array, and a class expression registers no snapshot, so that read had nothing to fall back on. | ||
| - A static GETTER inherited through a per-evaluation class object now binds `this` to the class the read started from. `get_field_by_name`'s pinned-parent recursion re-entered with the parent evaluation as the object, so `Sub.tag` ran with `this === Base`. It now stashes the original receiver through `accessor_receiver_override_begin`, the same way the ClassRef static-prototype walk does. | ||
| - Files: `crates/perry-runtime/src/object/class_constructors.rs` (`capture_owner_for_template`, `class_object_capture_slot`) and `crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs`. Gap test `test-files/test_gap_11200_inherited_static_module_captures.ts` (+ `fixtures/issue_11200_inherited_static_captures/`) covers the mongodb shape, 4-level inheritance, a computed `(rt ?? Base).make` receiver, `.call` with a subclass receiver, static getters, subclass-own capturing statics, and the #10911 factory rows. |
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
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
12 changes: 12 additions & 0 deletions
12
test-files/fixtures/issue_11200_inherited_static_captures/doc.cjs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.Doc = void 0; | ||
| class Doc { | ||
| constructor(bson, offset = 0, isArray = false, elements) { | ||
| this.cache = Object.create(null); | ||
| this.bson = bson; | ||
| this.elements = elements ?? [bson.length]; | ||
| } | ||
| get(name) { return this.cache[name] ?? null; } | ||
| } | ||
| exports.Doc = Doc; |
54 changes: 54 additions & 0 deletions
54
test-files/fixtures/issue_11200_inherited_static_captures/responses.cjs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| "use strict"; | ||
| // Mirrors the shape of mongodb 7.5.0's lib/cmap/wire_protocol/responses.js: | ||
| // a static `make` on the base reads module-scope bindings (a namespace object, | ||
| // a function, a const table), and is inherited by subclasses that carry their | ||
| // OWN, differently laid out, module-scope captures. | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| const lib_1 = { parse(b) { return [b.length]; } }; | ||
| const doc_1 = require("./doc.cjs"); | ||
| const Off = { a: 0, b: 1 }; | ||
| const PREFIX = "resp:"; | ||
| function isErr(b, els) { | ||
| for (let i = 0; i < els.length; i++) { | ||
| if (els[i] === Off.b + 100) return true; | ||
| } | ||
| return b === "err"; | ||
| } | ||
| class ErrorBox { | ||
| constructor(message) { this.message = message; } | ||
| } | ||
| class Base extends doc_1.Doc { | ||
| static make(bson) { | ||
| const elements = (0, lib_1.parse)(bson); | ||
| const isError = isErr(bson, elements); | ||
| return isError ? new Base(bson, 0, false, elements) : new this(bson, 0, false, elements); | ||
| } | ||
| static describe() { | ||
| return PREFIX + this.name + ":" + typeof isErr + ":" + typeof lib_1.parse + ":" + Off.b; | ||
| } | ||
| static get tag() { | ||
| return PREFIX + this.name + "/" + new ErrorBox("x").constructor.name; | ||
| } | ||
| } | ||
| exports.Base = Base; | ||
| class Sub extends Base { | ||
| constructor() { | ||
| super(...arguments); | ||
| this._batch = null; | ||
| this.iterated = 0; | ||
| } | ||
| get id() { | ||
| try { return lib_1.parse(this.cursor); } | ||
| catch (cause) { throw new ErrorBox(cause.message); } | ||
| } | ||
| static kind() { return "sub-" + PREFIX + typeof ErrorBox; } | ||
| } | ||
| exports.Sub = Sub; | ||
| class Sub2 extends Sub { | ||
| get more() { return Off.a + String(doc_1.Doc.name); } | ||
| } | ||
| exports.Sub2 = Sub2; | ||
| class Leaf extends Sub2 { | ||
| static leafOnly() { return isErr("err", [0]) + ":" + PREFIX; } | ||
| } | ||
| exports.Leaf = Leaf; |
107 changes: 107 additions & 0 deletions
107
test-files/test_gap_11200_inherited_static_module_captures.ts
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| // #11200 / #10911: an INHERITED static reached through a subclass must read the | ||
| // DECLARING class's captured bindings. | ||
| // | ||
| // CommonJS module bodies lower as function bodies, so a class there that reads | ||
| // a module-scope binding is a capture-carrying class: its captures live on the | ||
| // class object as a per-evaluation array. Static dispatch through a subclass | ||
| // (`Sub.make()` running `Base.make`) handed the capture read the SUBCLASS | ||
| // object, and the read took slot `index` of the subclass's own array -- laid | ||
| // out for the subclass's capture list, so it returned an unrelated binding. | ||
| // mongodb 7.5.0's `CursorResponse.make(bson)` saw `typeof isErrorResponse === | ||
| // "object"` and `find().toArray()` threw `value is not a function`. | ||
| // | ||
| // The #10911 half: a top-level `class A extends factory()` inheriting a | ||
| // capturing static METHOD from the factory's class expression read the | ||
| // capture as `undefined` (a ClassRef carries no capture array, and a class | ||
| // expression registers no declaration snapshot). | ||
| import * as resp from "./fixtures/issue_11200_inherited_static_captures/responses.cjs"; | ||
|
|
||
| function say(label: string, f: () => unknown) { | ||
| try { | ||
| console.log(label, String(f())); | ||
| } catch (e: any) { | ||
| console.log(label, "THREW", e?.constructor?.name, e?.message); | ||
| } | ||
| } | ||
|
|
||
| const R: any = resp; | ||
|
|
||
| // 1. The mongodb shape: `make` on the base, called through each subclass. | ||
| say("Base.make", () => R.Base.make("abc").constructor.name); | ||
| say("Sub.make", () => R.Sub.make("abc").constructor.name); | ||
| say("Sub2.make", () => R.Sub2.make("abc").constructor.name); | ||
| say("Leaf.make", () => R.Leaf.make("abc").constructor.name); | ||
| say("Sub.make(err)", () => R.Sub.make("err").constructor.name); | ||
| // `(responseType ?? MongoDBResponse).make(bson)` -- a computed receiver. | ||
| for (const responseType of [undefined, R.Sub, R.Leaf]) { | ||
| say("(rt ?? Base).make", () => (responseType ?? R.Base).make("xy").constructor.name); | ||
| } | ||
| say("Sub.make.elements", () => JSON.stringify(R.Sub.make("abcd").elements)); | ||
|
|
||
| // 2. Module-scope function / namespace object / const table, read by an | ||
| // inherited static, at every depth. | ||
| say("Base.describe", () => R.Base.describe()); | ||
| say("Sub.describe", () => R.Sub.describe()); | ||
| say("Sub2.describe", () => R.Sub2.describe()); | ||
| say("Leaf.describe", () => R.Leaf.describe()); | ||
|
|
||
| // 3. A static getter using `this` and a captured class, via subclasses. | ||
| say("Base.tag", () => R.Base.tag); | ||
| say("Sub.tag", () => R.Sub.tag); | ||
| say("Leaf.tag", () => R.Leaf.tag); | ||
|
|
||
| // 4. The subclasses' own capturing statics still read their own captures. | ||
| say("Sub.kind", () => R.Sub.kind()); | ||
| say("Leaf.kind", () => R.Leaf.kind()); | ||
| say("Leaf.leafOnly", () => R.Leaf.leafOnly()); | ||
|
|
||
| // 5. Instances built through the inherited static keep working. | ||
| say("Leaf.make.more", () => R.Leaf.make("q").more); | ||
| say("Sub.make instanceof", () => R.Sub.make("q") instanceof R.Sub); | ||
|
|
||
| // 6. Inherited static via `.call` with a subclass receiver. | ||
| say("Base.make.call(Sub2)", () => R.Base.make.call(R.Sub2, "zz").constructor.name); | ||
| say("Base.describe.call(Leaf)", () => R.Base.describe.call(R.Leaf)); | ||
|
|
||
| // 7. #10911: capturing statics of a factory class expression, inherited by | ||
| // top-level declarations, including two levels down. | ||
| function fCapM(tag: string) { | ||
| return class Out { | ||
| static who() { return this; } | ||
| static tagv() { return tag; } | ||
| static get tagg() { return tag + ":" + this.name; } | ||
| }; | ||
| } | ||
| class A3 extends fCapM("a") {} | ||
| class B3 extends fCapM("b") {} | ||
| class C3 extends A3 {} | ||
| say("A3.who()===A3", () => (A3 as any).who() === A3); | ||
| say("A3.tagv()", () => (A3 as any).tagv()); | ||
| say("B3.tagv()", () => (B3 as any).tagv()); | ||
| say("C3.tagv()", () => (C3 as any).tagv()); | ||
| say("A3.tagg", () => (A3 as any).tagg); | ||
| say("C3.tagg", () => (C3 as any).tagg); | ||
| const O = fCapM("o"); | ||
| say("O.tagv()", () => (O as any).tagv()); | ||
| say("A3.tagv() again", () => (A3 as any).tagv()); | ||
|
|
||
| // 8. A capturing factory class whose subclass carries its own captures. | ||
| function fPair(left: string) { | ||
| const helper = (s: string) => "<" + s + ">"; | ||
| return class P { | ||
| static show() { return helper(left) + "@" + this.name; } | ||
| }; | ||
| } | ||
| function fChild(right: number) { | ||
| const P = fPair("L" + right); | ||
| return class Q extends P { | ||
| static own() { return right * 2; } | ||
| }; | ||
| } | ||
| const Q1: any = fChild(1); | ||
| const Q2: any = fChild(2); | ||
| say("Q1.show()", () => Q1.show()); | ||
| say("Q2.show()", () => Q2.show()); | ||
| say("Q2.own()", () => Q2.own()); | ||
| class Q3 extends Q2 {} | ||
| say("Q3.show()", () => (Q3 as any).show()); |
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Root the class object and key across the recursive lookup.
If an inherited getter allocates and returns
undefined, the lookup falls through using the original rawobjandkey. A moving collection can make those pointers stale. The new scope roots the saved override, but not these fallback inputs. Root both inputs before recursion and retrieve their current pointers before the fallback. The accessor override’s root does not update the raw pointers. (raw.githubusercontent.com)🤖 Prompt for AI Agents