I came upon an interesting corner case while reading the FinalizationRegistry
specification.
Suppose I have code like this:
import { alloc, free, use } from 'my-little-ffi-lib';
import { setTimeout } from 'gc-is-magic';
class Resource {
#handle;
static #registry = new FinalizationRegistry(res => {
free(res);
});
constructor() {
Resource.#registry.register(this, this.#handle = alloc());
}
scheduleUse() {
setTimeout(() => {
use(this.#handle);
}, 1000);
}
}
new Resource().scheduleUse();
Now an engine could reason like this:
- Since the
#handle
private field has no accessors and is only ever set during object construction, it can be treated as read-only everywhere else and is therefore a good candidate for scalar replacement optimization. - As such, scalar replacement of
this.#handle
can be performed in the closure inscheduleUse
, in accordance with ECMA-262 12th Ed. §9.9.3 Note 1 (and also expressly considered in https://github.com/tc39/proposal-weakrefs/issues/179#issuecomment-583128004). After this optimization is performed, the closure will not hold a reference tothis
. - After the
scheduleUse
call completes, nobody is holding strong references to theResource
instance. Since the engine is idle, it can invoke finalization callbacks. -
free
can be therefore invoked with the held handle. A little less than a second later, a use-after-free bug occurs.
Java has a similar problem (except worse, since scalar replacement can render an object collectible in the middle of a synchronous function), and the standard solution in that language is java.lang.ref.Reference.reachabilityFence
. Now, I believe I can write an ECMAScript version of reachabilityFence
like this:
function reachabilityFence(object) {
try { new WeakRef(object); } catch {}
}
Constructing a WeakRef
has the side effect of invoking the AddToKeptObjects abstract operation, which forces the object to be considered live until the end of the current job.
So my questions are:
- (An easy one.) Are notes normative? I would assume not, since that is the case with footnotes in the ISO C standard, but perhaps the TC39 has different customs. The specification doesn’t seem to address this explicitly.
- Is the above implementation of a liveness barrier correct? I kind of worry that a sufficiently
spitefulsmart engine may fail to consider AddToKeptObjects a side effect that prevents eliminating theWeakRef
constructor call entirely. (Similar considerations would also apply tonew WeakRef(object).deref();
) - Suppose I wanted to create a liveness barrier without relying on [[KeptAlive]]. Is this possible at all? What constitutes ‘observing the identity of the object’? The only defintion of the term in ECMA-262 12th Ed. seems to be in §9.9.2 Note 4, which again, may or may not be normative: ‘observing a strict equality comparison between objects or observing the object being used as key in a Map’. Does that mean that
if (object === {})
ornew Map().set(object);
would do as well?
Even more headaches appear when one considers what happens when Resource.#registry
itself is collected first (cf. https://github.com/tc39/proposal-weakrefs/issues/66; this is what originally motivated me to look into liveness barriers, but this post is already long enough as it is).
adaptation of the above example to node.js (v12.22.7, V8 7.8.279.23-node.56)
#!/bin/sh
//bin/true; exec node --expose-gc --harmony-weak-refs "$0" "$@"
if (typeof FinalizationRegistry === 'undefined' && typeof FinalizationGroup !== 'undefined') {
FinalizationRegistry = class FinalizationRegistry extends FinalizationGroup {
constructor(callback) {
super(iter => {
for (const item of iter)
callback(item);
});
}
};
}
function alloc() {
return { message: 'everything is fine' };
}
function free(handle) {
Object.defineProperty(handle, 'message', {
get() { throw new TypeError('use after free'); }
});
}
function use(handle) {
console.log(handle.message);
}
class Resource {
#handle;
static #finreg = new FinalizationRegistry(res => {
free(res);
});
constructor() {
Resource.#finreg.register(this, this.#handle = alloc());
}
scheduleUse() {
/* assume scalar replacement optimization is performed inside the closure */
const handle = this.#handle;
setTimeout(() => {
use(handle);
}, 100);
}
}
new Resource().scheduleUse();
/* force garbage collection and finalization */
global.gc();