For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/proposal-stabilize.md.
  • 简体中文
  • Stabilize S1

    中文标题:Stabilize(稳定性)及其相关完整性特性

    提案概览
    提案速览

    本提案旨在解决 JavaScript 中的三个与完整性相关的问题:赋值覆盖错误、返回覆盖错误和代理重入风险。它提议引入新的完整性“特性”,这些特性可以可选地应用于对象,可能包括用于缓解每个具体问题的特性,并讨论了捆绑和解耦这些特性的设计空间。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    Stabilize,以及其他完整性特性

    通过扩展现有的完整性“级别”系统,更好地支持高完整性编程。

    向 TC39 提出的新完整性“特性”提案,旨在缓解以下问题:

    • 赋值覆盖错误
    • 返回覆盖错误
    • 代理重入风险

    状态

    TC39 流程

    阶段: 1

    提案负责人:

    • Mark S. Miller (@erights)
    • Chip Morningstar (@fudco)
    • Richard Gibson (@gibson042)
    • Mathieu Hofman (@mhofman)

    演示历史

    背景

    JavaScript 目前有三个完整性级别,冻结(frozen)密封(sealed)不可扩展(non-extensible)。它们被称为“级别”,是因为它们当前处于一个全序的层级结构中:所有冻结的对象都是密封的,所有密封的对象都是不可扩展的。它们被称为“完整性”级别,是因为它们支持高完整性编程。

    例如,Hardened JS 和其他几个系统会冻结freeze原始对象,即那些在代码开始运行之前就存在的内置固有对象。这通过例如防止原型污染(供应链漏洞的一个主要来源)来支持更高的完整性编程。

    然而,即使是对于冻结的对象,标准 JavaScript 也存在以下三个不愉快的问题,我们希望引入新的完整性区分来缓解这些问题。这些完整性区分将处于偏序而非全序关系中,因此我们转变术语,将所有完整性区分称为完整性特性integrity traits)。(该术语借自一种多重继承系统,但在这里它适用于规范内部的行为定义,而非用户层面的抽象机制。)

    赋值覆盖错误

    Object.freeze(Object.prototype);
    
    function Point(x, y) {
      this.x = y; this.y = y;
    }
    
    Point.prototype.toString =
      function () {
        return `<${this.x},${this.y}>`;
      };

    暂不考虑第一条语句中的 Object.freeze,网络上存在大量类似后两条语句的遗留代码,其中 Point 是一个类似类的构造函数 function,其实例会从 Point.prototype 继承一个 toString 方法。在这种常见的遗留模式中,这个 toString 方法是通过对 Point.prototype 进行赋值来定义的,以覆盖实例本应从 Object.prototype 继承的 Object.prototype.toString 方法。

    然而,在 Object.prototype 被朴素冻结的环境中,如上面的第一条语句所示,由于赋值覆盖错误,第三条语句中的赋值会失败。在严格模式下,它会抛出异常而失败。更糟糕的是,在草率模式下,它会静默失败,然后代码继续执行并出现错误行为。赋值覆盖错误是指,一个继承的不可写数据属性(例如 freeze 之后的 Object.prototype.toString 属性)无法通过对继承对象(例如 Point.prototype)进行赋值来覆盖。

    Hardened JS 的 ses-shim 实现通过首先将一组有限的原始属性转换为访问器属性(其 setter 模拟了如果 TC39 没有犯下赋值覆盖错误,这种赋值本应具有的行为),来解决这个限制集合的问题。但是,我们只对有限的集合这样做,是因为这种技术昂贵、笨拙且不透明。

    有了这个有限的解决方法,大量在编写时并不知道 Hardened JS 的现有 JavaScript 代码仍然可以在 Hardened JS 下兼容运行。然而,现有代码在 Hardened JS 下无法运行的绝大部分原因都是覆盖错误。其他冻结原始对象的系统也报告了类似的不兼容性。一些系统由于这些兼容性成本而放弃了冻结原始对象及其带来的完整性好处。事实上,我们发现赋值覆盖错误是 JavaScript 中实现更高完整性编程的最大障碍

    经过广泛调查,我们不知道有任何非测试的生产代码有意利用赋值覆盖错误。因此,我们仍然希望它可以直接在语言层面修复而不会破坏 Web。如果事实证明这是可能的,我们将大大倾向于这样做,而不是本提案中下述的局部缓解方法。然而,迄今为止的实验确实遇到了一个意外的依赖,使得评估 Web 破坏情况变得困难(需要 TODO 链接)。这导致 TC39 退出了早期在语言层面修复它的尝试。因此将其包含在本提案中。

    返回覆盖错误

    class Trojan {
      constructor(key) { return key; }
    }
    
    class PrivateTagger extends Trojan {
      #value
      constructor(key, value) {
        super(key);
        this.#value = value;
      }
    }
    
    new PrivateTagger(freeze(Object.prototype), ''); // adds private field to %Object.prototype%

    上面的 Trojan 构造函数以一个显式的 return 语句结束,返回其 key 参数。这产生了一个奇特的效果:在 PrivateTagger 构造函数中将其作为 super(key) 调用时,会将显式返回的 key 对象视为 PrivateTagger 的实例,将其绑定到 this,并用私有字段 #value 初始化它。即使 key 是预先存在的冻结对象也会发生这种情况。JavaScript 规范将此语义解释为好像每个此类类定义中都存在一个隐藏的 WeakMap。实际上,return-override-weakmap.js 使用这种技术实现了类似 WeakMap 的抽象。这产生了一些令人不快的后果。

    new PrivateTagger(struct, 'a'); // unpleasant shape change

    我们知道的所有浏览器 JavaScript 实现都通过改变对象的“形状”来实现此类内部字段的添加,这与它们实现公有属性的方式类似。例如,在 V8 中,两者都涉及改变对象所谓的隐藏类,该隐藏类用于内部记录具有共同形状的对象。

    structs 和 shared structs的一个工程目标是,同一个(类类似的)struct 定义的所有实例都具有相同的静态可知形状,从而可以将 struct 方法编译成更高速的代码。然而,这可能会与上述使用返回覆盖(以 struct 作为 key)冲突,因为添加私有字段会导致形状改变。理论上,这可以在此类引擎中修复,但会增加额外的实现复杂性。这是没有人愿意为支持一个被广泛诟病的“特性”而付出的代价。

    new PrivateTagger(representative, 'a'); // makes gc of virtual objects observable

    Agoric 平台提供了定义虚拟对象的抽象机制,这是一种对象虚拟内存,其中此类对象的数量可以远超 JavaScript 引擎内存堆中实际可存储的数量。就像虚拟内存系统中的页面一样,此类对象主要表示在长期的外部存储中,并按需“换入”。在这个类比中,相当于换入的物理页是代表representative)——一个代表虚拟对象的常规 JavaScript 对象,只要它不被语言引擎的垃圾回收器回收,它就一直代表该对象。当这样的代表被回收时,虚拟对象仍然存在于外部存储中,以便按需换回。

    为了维持所有这些对象都仿佛在语言堆中的错觉,我们需要小心对象身份,例如通过 === 测试的身份。具体到 ===,确保一个虚拟对象在任何时候都不会有多个存活的代表就足够了。虽然每个代表在 JavaScript 实现层面具有唯一的身份,但 === 永远无法比较同一个虚拟对象的两个代表,因为它只能比较在比较期间同时保留的对象。对于通过 Object.isMapSet 进行的对象身份比较也是如此。特别是,Map 会保留其键,因此用作 Map 键的代表将不会被回收。

    当代表被弱引用时,问题就出现了。JavaScript 中有三种机制可以弱引用对象:

    • WeakRefFinalizationRegistry。我们的虚拟对象系统将这些机制保留给自己,并且不为在虚拟对象系统内运行的程序提供这些机制的任何虚拟化。

    • WeakMapWeakSet。如果真正的 WeakMap 构造函数可供在虚拟对象系统中运行的程序使用,那么作为键的代表可能会被回收,从而丢失与某个值的关联。如果虚拟对象被换回,新的代表将无法在该 WeakMap 中找到,从而破坏了这种错觉。

      为了维护这种错觉,在初始化时,我们的虚拟对象系统将真正的 WeakMapWeakSet 构造函数保留给自己,但提供替代的、能够感知虚拟化的WeakMapWeakSet 构造函数。对于非代表的键,它们会传递到隐藏的真实 WeakMapWeakSet。对于代表,它们与虚拟对象存储系统协作,仿佛键是虚拟对象本身,从而在任何一个表示的存续期之后保留关联。

    • 返回覆盖错误使得类似 weakmap 的功能可以通过语法访问,因此实际上无法虚拟化。如果返回覆盖与虚拟对象代表作为键一起使用,那么每当该代表被回收并由新代表接替时,安装的私有字段将可观察地消失。虚拟化这个隐藏的类似 weakmap 的功能将需要痛苦的改写,以从目标语言中移除所有类的私有字段。这成本过高,不切实际。

    new PrivateTagger(window, 'a'); // fails only on browser global WindowProxy object

    由于浏览器实现浏览器全局 WindowProxy 对象的方式,它们支持返回覆盖错误所要求的私有字段添加会很痛苦。因此,作为一种特殊豁免,浏览器全局 WindowProxy 对象专门被豁免于此要求。

    这种特殊豁免对于语言规范来说是一种尴尬的复杂性,违反了最小惊讶原则,并且使得使用任何其他对象(包括构造的 realm 或 compartment 的全局对象)来完美模拟浏览器全局 WindowProxy 对象变得不切实际。

    代理重入风险

    function foo(suspect) {
      if (!isRecord(freeze(suspect))) {
        throw Error(...);
      }
      // ... suspend invariant ...
      ... suspect.bar ...
      // ... restore invariant ...
    }
    
    foo(new Proxy({ bar: 3 }), {
      get() { foo({}); }
    });

    在进行防御性编程时,我们经常编写像上面 foo 这样的函数,它检查参数,然后在所有参数都经过验证符合预期后才继续执行函数体。在函数体执行期间,经常会有一些部分,其中某些不变量会暂时挂起,稍后不久恢复。例如,一个拼接双向链表的函数必须经历一个双向链表暂时不规范的瞬间。这个不变量挂起的间隔非常微妙,作者应该确保他们只做那些他们有信心在此期间安全执行的事情。

    在示例中,foo 的作者首先尝试验证 suspect 是一个类似于记录的冻结纯数据对象。如果 records and tuple 提案实现了,我们可以想象一个 recordLike 谓词只需检查 typeof suspect === 'record',在这种情况下我们会知道 suspect 是原始数据,就像访问任何其他 JavaScript 原始数据一样安全。我们可以确信,即使在 foo 的不变量挂起期间,评估 suspect.bar 表达式也是安全的,因为我们会知道它不可能导致由 suspect 携带的其他代码交错执行。

    records and tuple 提案启发,Endo 提供了一个 isRecord 谓词,它尽可能地在标准 JavaScript 中进行记录样式的安全检查。它检查对象是否被冻结,是否直接继承自 Object.prototype,并且只包含字符串命名的自有可枚举数据属性。理想情况下,所有这些验证都会向 foo 的作者保证 suspect.bar 表达式同样安全。

    不幸的是,在标准 JavaScript 中,我们无法验证 suspect 不是代理,也无法保证它肯定不是。如果它是代理,它仍然会受到 对象不变量的约束,行为必须像一个冻结对象。对象不变量与 recordLike 谓词一起,将保证每次 suspect.bar 成功评估时,其值都相同。但如果 suspect 是一个代理,在属性访问期间,它仍然可以执行其他代码,包括在 foo 未准备好被重入时重新进入 foo 的代码。

    在当今的标准 JavaScript 中,foo 的作者保护自己免受此影响的唯一方法是,要么要求参数是原始数据(例如字符串),要么在挂起不变量之前预先进行防御性复制,创建那些通过构建已知是安全的纯数据对象的新对象。这种防御性复制非常昂贵。普遍的防御性编程将需要普遍的防御性复制,这是极其昂贵的。

    新的完整性特性

    当前的完整性级别

    我们希望引入新的完整性特性来缓解上述三个问题。但是,什么使得一个提议的新特性成为完整性特性?我们提出以下要求,现有完整性特性已经遵循这些要求:

    • 单调单向开关。例如,一个对象一旦被冻结,就永远被冻结。
    • 更强的对象不变量,更好地支持更高的完整性编程。例如,我们知道一个冻结对象的自有数据属性的值不能改变,即使该对象是代理或任何其他外来对象。
    • 一个代理拥有给定的完整性特性当且仅当其目标拥有该相同的完整性特性。例如,一个代理被冻结当且仅当其目标被冻结。这保留了所有代理完整性特性的簿记仅在于其目标是否拥有这些特性的特性。

    显式与涌现的完整性特性

    > const obj = { x: 8 };
    > !Object.isExtensible(obj); // false
    > Object.isSealed(obj);      // false
    
    > Object.preventExtensions(obj);
    > !Object.isExtensible(obj); // true
    > Object.isSealed(obj);      // false
    
    > delete obj.x;
    > !Object.isExtensible(obj); // true
    > Object.isSealed(obj);      // true

    再次从现有的完整性特性进行概括,我们可以将完整性特性分为

    • 显式,例如不可扩展,其中给定对象是否不可扩展的标志是任何实现都必须显式表示的基本语义状态。一个对象只有通过应用使其变为不可扩展的操作而被显式地设为不可扩展时,它才会变为不可扩展。
    • 涌现,例如密封或冻结,其中对象拥有该完整性特性仅当其他单调条件的合取成立,无论该合取是如何变为真的。在上面的例子中,obj 在其自身的可配置数据属性被删除时变为密封。

    代理只需为显式完整性级别提供陷阱。实际上,代理有 preventExtensionsisExtensible 的陷阱,但没有针对密封或冻结的陷阱。

    显式的 Stablize(稳定化) 将缓解所有三个问题

    首次尝试。但 structs 不能被冻结

    我们起初想只引入一个新的显式完整性特性,即 stable,以缓解所有三个问题,其中所有 stable 对象都是冻结的。然而,这将无法使 structs 和 shared structs 拥有高效固定形状的实现。Structs 和 shared structs 生来就是密封的,但它们生来并非冻结的。但是,只有当它们生来也豁免于返回覆盖错误时,它们才能拥有固定形状的实现。

    我们也无法追溯合理化浏览器全局 WindowProxy 对象对返回覆盖错误的豁免,也无法使用其他对象实现忠实的模拟,因为这些对象通常甚至 不会是不可扩展的。

    完全解耦的完整性特性

    走向另一个极端,让我们考虑将完整性特性功能完全拆分为单独的特性。在此图中,带下划线的粗体斜体标签是显式完整性特性,其他标签是涌现完整性特性,它们之间的箭头表示蕴含关系。在这个完全解耦的图景中,每个显式特性只做一件事,并且它们之间更加正交。

    • fixed - 一个 fixed 对象将豁免于返回覆盖错误。新行为将遵循浏览器全局 WindowProxy 对象的现有先例,这样我们可以追溯地将其合理化视为具有 fixed 特性,而模拟对象也可以被设置为 fixed 从而行为类似。Structs 将生来既是密封的又是 fixed 的,但不是冻结的。fixed 没有技术理由蕴含任何其他完整性特性,尽管可能存在足够的审美论据。
    • overridable - 一个 overridable 对象将豁免于赋值覆盖错误。如果 overridable 蕴含冻结,这是最容易指定的。我们只需说,任何从 overridable 对象继承的数据属性都可以通过赋值来覆盖。如果 overridable 不蕴含冻结,那么它将只改变不可写数据属性,或只是不可写且不可配置的数据属性的行为。这将使得 overridable 能够用于那些只有部分属性是不可写的对象。
    • non-trapping - 如果 non-trapping 蕴含冻结,那么指定(可能实现)它会容易得多,我们在这里假设如此。在这种情况下,一个用作代理目标的 non-trapping 对象将导致该代理永远不会向其处理程序进行陷阱调用。回想一下,如果目标是冻结的,处理程序的陷阱已经不能改变成功结果的内容。它们所能做的只是在陷阱期间交错其他代码,从而引发重入风险,或者抛出异常,阻止报告成功结果。如果目标额外是 non-trapping,那么代理将表现得好像处理程序没有陷阱一样,将所有操作转发到目标。换句话说,这样的代理将在所有方面与目标完全相同,只是它具有不同的对象身份。遵循我们之前的原则,这样的代理自身将是 non-trapping 的。
    function foo(suspect) {
      if (!isRecord(beNonTrapping(suspect))) {
        throw Error(...);
      }
      // ... suspend invariant ...
      ... suspect.bar ...
      // ... restore invariant ...
    }
    
    foo(new Proxy({ bar: 3 }), {
      get() { foo({}); }
    });

    一个 non-trapping 对象如果在属性访问期间具有访问器属性,或者从自身会引发重入风险的对象继承,那么它仍然可能引发重入风险。但是,如果 recordLike 首先检查其参数是 non-trapping 的,再加上所有其他检查,那么它将确实保证通过的对象是一个类似于记录的纯数据对象,在属性访问期间不能交错任何外部代码,从而不能引发重入风险。

    与代理一样,外来对象被允许在访问所谓的数据属性时可观察地交错用户代码或可见效果。事实上,import defer 提案的模块命名空间对象就会这样做。一个像代理一样的外来对象,也可以拒绝对其施加新的显式完整性特性的尝试(例如,一个可增长的 array-buffer 拒绝被设为不可扩展)。一个希望保持这种在属性访问期间可观察的交错用户代码或可见效果的外来对象,因此必须拒绝被设为 non-trapping。

    这成功地缓解了代理重入风险,而无需引入一个谓词来告诉对象是否是代理,那将威胁到实用的膜透明性。相反,recordLike 只需要一个谓词来检测对象是否是 non-trapping 的。与任何其他完整性特性一样,这将从目标透明地反映到其代理。

    解耦不可扩展性

    既然我们正在考虑极端解耦完整性特性,我们也可以考虑将不可扩展性解耦为以下显式正交的特性:

    • permanent-inheritance - 仅提供不可扩展性中锁定对象继承来源的特性。好处同样是追溯合理化和虚拟化能力:

      • Object.prototype 生来可扩展并继承自 null。通过特殊豁免,尽管其可扩展性,其继承关系不能更改。

      • 同样,通过特殊豁免,无法更改一个可扩展的浏览器全局 WindowProxy 对象的继承关系。因为这种能力对非代理对象不可用,它们不能用于忠实地模拟浏览器全局 WindowProxy。

        通过将不可扩展性的这个特性解耦为一个单独的显式完整性特性,我们可以追溯合理化这两者,并实现更忠实的浏览器全局 WindowProxy 对象模拟。

    • no-new-props - 通过将不可扩展性的剩余特性提取到一个单独显式特性中,完成解耦。一个 no-new-props 对象将是一个不能增长任何新的自有属性的对象,尽管我们可能仍然能够更改它继承自哪个对象。

    参数化的代理保护陷阱

    有了五个新的显式完整性特性,看起来我们需要十个新的代理陷阱。一个用于请求完整性特性,一个用于测试完整性特性。这似乎太多了。相反,我们可以引入两个新的参数化代理陷阱,protect(traitName)isProtected(traitName),其中名称仅涵盖显式完整性特性的名称。

    如果我们以这种方式解耦不可扩展性,那么不可扩展性本身将成为一个涌现的完整性特性。这引发了兼容性难题,即现有的 preventExtensionsisExtensible 代理陷阱与新的陷阱如何关联。在什么情况下,对于代理陷阱的操作会路由到哪个处理程序?这个问题我们暂时搁置。

    解耦与重新捆绑 stable

    在这个完全解耦的图景中,stable 将是涌现的,但仍然是最强的。Stable 仍然蕴含所有其他完整性特性。Hardened JS 仍将转向稳定化所有原始对象,使它们变得冻结,豁免于返回覆盖错误、赋值覆盖错误和代理重入风险。每个特性都通过防止粗鲁的非局部意外来帮助支持高完整性程序。

    然而,将 non-trapping 保持在 stable 之下更不合理。我们最初认为让 non-trapping 蕴含冻结,因为一旦代理的目标被冻结,处理程序就不再有多大用处。但是如果在 non-trapping 之上还有未蕴含的完整性级别,那么一旦一个对象是 non-trapping 的,它将不再能够拦截使其变为例如 fixed 或 overridable 的尝试,或测试其是否为 fixed 或 overridable。看来 stable 和 non-trapping 必须互相蕴含,这表明 non-trapping 应该被重新捆绑回 stable,并且 stable 由此变成显式的。

    尽管正交性很好,但许多完整性特性的使用场景只需要使用方便的捆绑。如果我们尽可能重新捆绑,同时仍满足我们令人信服的用例,我们会失去正交性,但我们可能得到一个更易于理解和实现的系统。这个最小图景是什么?

    最小化提议的完整性特性

    这类似于我们的首次尝试,有一个区别:用于缓解返回覆盖错误的 fixed 特性与 stable 分离。Stable 仍然蕴含 fixed,但一个对象可以在不应用其他任何完整性特性的情况下被设为 fixed。此图景还省略了对不可扩展性的解耦,因为目前的需求似乎不够令人信服,不值得其成本。主要好处将是追溯合理化 Object.prototype 和浏览器全局 WindowProxy 对象的 permanent-inheritance 行为,以及能够在不使用 JavaScript 代理的情况下更忠实地模拟 WindowProxy。

    从这里最可能的重新解耦是再次拆分出 overridable,并使其不蕴含冻结。这将缓解从非冻结对象继承的不可写属性的赋值覆盖错误。这当然是连贯且易于理解的。这似乎是第 1 阶段值得研究的好问题。

    另请参阅

    Propose to tc39 without override mistake #105

    harden as a new integrity level #1912

    Normative: Make non-writable prototype properties not prevent assigning to instance #1320