Liveness barriers and finalization

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 in scheduleUse, 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 to this.
  • After the scheduleUse call completes, nobody is holding strong references to the Resource 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 spiteful smart engine may fail to consider AddToKeptObjects a side effect that prevents eliminating the WeakRef constructor call entirely. (Similar considerations would also apply to new 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 === {}) or new 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();
1 Like

There is a small typo in your example by the way, alloc is called twice during instantiation of Resource.


Perhaps testing the identity of this would ensure it is marked as live ?

class Resource {
	#handle = alloc();
	static #registry = new FinalizationRegistry(res => {
		free(res);
	});
	static #live = new WeakSet();

	constructor() {
		this.#live.add(this);
		Resource.#registry.register(this, this.#handle);
	}

	scheduleUse() {
		setTimeout(() => {
			assert(Resource.#live.has(this)); // observe `this`
			use(this.#handle);
		}, 1000);
	}
}

new Resource().scheduleUse();

That's a fun example!

To answer your questions:

  1. No, notes are not normative.
  2. That implementation works. An engine which fails to consider AddToKeptObjects a side effect is simply incorrect: optimizations are only allowed if they do not lead to observably different behavior than blindly following the spec steps would.
  3. That phrase is intentionally ambiguous, but yes, I would say both of those snippets would observe the identity of the object.
1 Like

That optimization is indeed allowed, and was specifically considered while writing the spec. See discussion at Amend liveness definition to mean observable object identity. by syg · Pull Request #142 · tc39/proposal-weakrefs · GitHub

The problem is that you're conceptually associating data to a JavaScript object, but then you're not using that data through the JavaScript object, but directly, yet rely on the liveness of the JavaScript object to inform the state of the data. I'd be curious to understand under which real world scenario this could happen.

As @aclaymore mentions, any observation of the identity would be sufficient. I believe even a (void this) would work.

FinalizationRegistry and WeakRef are advanced APIs and one must understand what they're doing to use them sanely. Both this optimization and as you state the collection of resource registry themselves are examples of gotchas. That latter one still bites me every now and then.

2 Likes

Fixed the typo, thanks.

I think similar reasoning could be applied to the WeakSet case; an optimizer could transform the private static WeakSet into an instance-held value in order to eliminate the singleton object:

  • Resource.#live.add(obj) → (if the result is discarded) void obj.%live_has_me = true
  • Resource.#live.delete(obj)(%tmp = obj.%live_has_me, obj.%live_has_me = false, %tmp) → (if the result is discarded) void obj.%live_has_me = false
  • Resource.#live.has(obj)obj.%live_has_me

(I am using % to denote entities invisible to user code.) Then scalar replacement and constant propagation can kick in like before, eliminating the Resource instance again. In any other such case, the implementation should at least in principle be able to see through the busywork and ‘win’ this UB cat-and-mouse game:

  • new Map().set(this); can be deleted via dead store elimination;
  • (this === {}), (this !== null) or void this can be elided via constant folding, as they always evaluate to false, true and undefined respectively.

In practice I don’t expect optimizers to be that sophisticated usually, but I am still not confident that the spec disallows this either. Sure, the abstract algorithm of looking up an element in a WeakSet performs the SameValue operation, so that arguably counts as ‘observing’ object identity. Does that mean that SameValue is a liveness barrier, or is the liveness barrier only reached at step 7 of SameValueNonNumeric? (This has consequences for whether this === 420 or this === {} is a liveness barrier.) On what basis does OrdinaryGet/OrdinaryGetOwnProperty not observe object identity (thus allowing scalar replacement to be performed)? It seems to me that the answer is at best only implicit in the specification text.

I mentioned Java, where the same problem exists, and the answer is reachabilityFence. The above example is basically how FFI is normally done in that language: native routines are exposed directly to user code that runs in the JVM, user code uses them to allocate resources and store the handle in a private field, then it may either provide an interface to deallocate them manually (e.g. AutoCloseable), put a phantom reference on a ReferenceQueue or register a cleanup action with a Cleaner, while another thread actually invokes the finalizers. Since FinalizationRegistry is basically a ripoff of the latter, I imagine it might be used similarly. ECMAScript forbidding finalization from happening in the middle of synchronous execution makes the problem surface smaller, but a programmer may still introduce an async suspension point somewhere, causing the issue to reappear. It’s not even the class itself that has to invoke asynchrony:

// 'my-little-resource'
import { alloc, free, use } from 'my-little-ffi-lib';

export class Resource {
	#handle;
	static #registry = new FinalizationRegistry(res => {
		free(res);
	});
	constructor() {
		Resource.#registry.register(this, this.#handle = alloc());
	}
	use() {
		use(this.#handle);
	}
}

Object.freeze(Resource.prototype);
// some other module
import { Resource } from 'my-little-resource';
import { askUser } from 'gc-is-magic';

const res = new Resource();
if (await askUser("are you sure?")) {
	res.use();
}

Inlining Resource.prototype.use, followed by scalar replacement (as before) reproduces the same problem.

That is actually the reason your initial example may fail, because the class captures a reference to its private field that contains the state under finalization, without keeping the instance alive.

No. In any external usage, the program will require keeping a reference to the resource object itself, and the identity will be observed by the object's method call, which has to use this while reaching the private field.

Which is why I said the example didn't seem to be a real world scenario, and even then, you can always structure the code so that an implementation won't optimize it.

To be honest, I actually doubt any implementation would optimize a private field lookup in the first place.

The PR ticket you linked specifically calls out early dispatch and inlining as allowed. The specification doesn’t distinguish ‘internal’ and ‘external’ usage: all user code is equivalent, and an engine can perform transformations on it that would be otherwise impossible to express due to name resolution and scoping rules. That should make cross-module inlining allowed.

If you what you claim here by ‘the object's method call […] has to use this while reaching the private field’ is that private field accesses observe object identity, then even the original example cannot trigger a use-after-free. But the PrivateGet algorithm does not look like it observes object identity any more than OrdinaryGet does, so scalar replacement performed against one should be as valid as against the other.

In practice, I’d expect the biggest obstacle against such shenanigans to be the reason I had to add Object.freeze(Resource.prototype) to the second example: an engine must consider the fact that in the meantime, i.e. before the asynchronous job completes, someone may have monkey-patched Resource.prototype.use so that it observe the identity of this after all (whatever that may mean).

A particularly ironic interpretation of ‘observing object identity’

If we take ECMA-262 12th Ed. §9.9.2 Note 4 seriously, then it seems even the motivating example in that PR can fail:

const o = {};
const wr = new WeakRef(o);
await 1;
/* (A) */
assert(wr.deref() === o);

In a WeakRef-oblivious execution with respect to { o }, we have wr.deref() return undefined. The subsequent strict comparison between undefined and o therefore returns false because of a type mismatch. This does not constitute ‘observing a strict equality comparison between objects’ (note plural); you don’t need to know which object o is to know it is not undefined. Therefore, o is eligible for collection at (A)!

1 Like

It would seem that: 'this' as it's own expression is considered observing the object - so can not be completely optimized out in terms of liveness at that point in time. Whereas: 'this.prop' is not considered observing the object.

Is this correct? If so it might be nice to have a spec note that says this?

How about (o => o.prop)(this) and (0, this).prop? Would it also make this.prop(...args) and this.prop.call(this, ...args) different with respect to liveness of this?

1 Like

I agree with @aclaymore, and I'd go further to say that IMO anything that is not syntactically just a GetValue on a property reference where the base a simple identifier for the object means there is some expression that must be evaluated that will include the object reference, and requires the object to be considered live. Calling a method on an object, not simply getting the function prop value, is a dynamic operation that includes the object reference, which could be observed by the receiver.

I'm not sure how this could, or if it should, be clarified.

For me at least it does seem somewhat surprising that a closure that has void this should not optimise out the liveness of this caused by the closure, but it can for some instances of this.#prop. As the former is a clearly more trivial expression to optimise out.

Though I can also see how if code is using a FFI it is (maybe?) less likely to be code intended to be portable to any ecmascript compliant engine, and instead only have a few targets that it could test for.

2 Likes

If a common ctypes-style API becomes available, this style of pure-ECMAScript FFI may become popular; and with it, the problems with a vague definition of liveness.


I think the only way out is to specify explicit liveness barriers in the specification text (which is what syg was reluctant to do in PR 142), presumably in places like:

  • SameValue, SameValueZero, SameValueNonNumeric, IsStrictlyEqual, (maybe) IsLooselyEqual
  • AddToKeptObjects, (maybe) ClearKeptObjects (this would allow removing [[KeptAlive]] from the definition of liveness as an explicit special case)
  • FinalizationRegistry.prototype.register

and probably also include an explicit FinalizationRegistry.barrier built-in.

This will mean that this === {} will not be able to be optimised to false, but at best to (%Barrier(this), false), but I think I can live with that.

1 Like

As others have said, the notion of what constitutes an "observation" is intentionally left vague.

IIUC, @felix.s is looking for a fully portable liveness fence that works across all implementations, for all programs. I'm not sure that exists.

It is "easy" to craft such a fence for a given implementation for all programs. If you know what the optimizer considers observable, you can craft a liveness fence directly off that knowledge.

It is much harder, but still possible, I think, to craft such a fence for all implementations for a specific program, for a reasonable definition of observability. This is already getting into underspecification territory, since to be completely precise we must first say what we consider to be observable. My intention was the following.

There are some reasonable assumptions we can make for all implementations in practice: host APIs are observable and stuff that call out to IO are observable. An observability-preserving optimization must ensure that the all executions that can arise from the transformed program do not result in different values being observed or the observability changing, etc. Of course, if we consider FinalizationRegistry callbacks and WeakRef results to be observable, then we can't really do anything. So the conceit here is to pretend they don't exist when reasoning about executions: if, in the process of pretending they don't exist by saying all WeakRefs are always empty, the identity of some object affects the observable results of an execution (formally, I was imagining something like, if changing a reduction step that has an object's identity as input affects the observable outcome of the execution), then I consider that program to be "observing that object's identity". I think this is a fine formal semantics thing to say, it's not very edifying for practitioners. If you've reasoned about the observability of all possible executions of your program pretending WeakRefs don't exist and found out that some object's identity doesn't affect observable outcome, then you can write a liveness fence tailored for that specific object by forcing its identity to affect the observable outcome. IOW, you need to do whole program, all-possible-execution-style analysis.

In practice, that analysis is infeasible and there are shortcuts. It's probably not enough to just write this === {}, you'd need to force it to be observed somehow. Writing it to a global array or something somewhere probably suffices for all implementations in practice, points-to analysis is too hard and imprecise in JS to optimize away those kind of objects.

Finally, I don't know if it's possible to craft a liveness fence for all implementations for all programs. eval and new Function are well-known "have a bad time" deoptimizers, but like, that's just because it's stupid to trade off the analysis effort and time for dynamic code input strings and patch existing code, etc, so nobody makes that tradeoff. But I don't know as a semanticist I can say they always guarantee observability.

The FFI bit is interesting too. ecma262 doesn't say anything about FFIs or observability formally. If your environment has an FFI that makes stuff observable, then it's up to that environment to to flesh out what it considers observable.

Edit: Reading more of the above, new WeakRef(obj) or deref() on an non-empty WeakRef is probably the simplest universal liveness fence in practice, simpler than what I say above in the post. I'm still not sure if it's universal from a pure semantics POV given that changes to [[KeptAlive]] might not be observable if finalizers themselves aren't observable, but in practice, I'm pretty sure nobody is going to be trying to optimize [[KeptAlive]] stuff away.

2 Likes

@shu, if I understood your very insightful answer, wouldn't the following be guaranteed to create a liveness barrier for this program:

import { alloc, free, use } from 'my-little-ffi-lib';
import { setTimeout } from 'gc-is-magic';

class Resource {
	#handle;
	#selfRef;

	static #registry = new FinalizationRegistry(res => {
		free(res);
	});

	constructor() {
		this.#selfRef = new WeakRef(this);
		Resource.#registry.register(this, this.#handle = alloc());
	}

	scheduleUse() {
		setTimeout(() => {
			assert(this.#selfRef.deref() === this);
			use(this.#handle);
		}, 1000);
	}
}

new Resource().scheduleUse();

Even if the implementation optimized the property access, it wouldn't be allowed to optimize the identity check with the result of deref(), as the result of the check is different if the resource is collected or not, and has an observable effect. It also would have the advantage of making sure a use after free never happens in the presence of a too eager optimizer.

The thing is, FFI never has to deal with this; it only operates on this.#handle. It would be strange to say that func(this.#handle) observes this if func happens to come from FFI, but not otherwise.


Going back to Pull Request 142 now, as I read it, it seems to have had two goals:

  • To forbid optimisations with rematerialisation-like effects, where a strongly-referenced object is collected and later comes back to life with all its aspects intact
  • To allow engines applying optimisations like scalar replacement and value propagation to collect an object early, while scavenging only those aspects of it that are necessary for subsequent execution

What I think has not been sufficiently acknowledged is that those goals are very much in tension. Performing the latter kind of optimisation can leave you with all the consequences of rematerialisation:

  • this.prop? ‘I only need the value held in prop, I don’t need the entire object.’
  • this === {}? ‘I don’t need to know what this is, I know in advance it cannot be the same as an object freshly minted from a literal.’
  • (wr = new WeakRef(this), await 0, wr.deref() === this)? ‘In a WeakRef-oblivious execution, this compares an object against undefined; I only need to know that this was some object, not which one it was (a.k.a. death by irony).’

And so on. Do it enough times, and you end up with a Ship of Theseus: ‘I don’t need to keep that object live, as long as I have something that behaves exactly like it’.

Consider also this example:

// assume the registry lives forever
v = new FinalizationRegistry(f => f());

{
	let killed = false;
	const delia = {};
	R1: v.register(delia, () => {
		killed = true;
	});
	L: while (!killed) await sleep(1);
	R2: v.register(delia, () => {
		console.log("I have not come for what you've hoped to do.");
	});
}

Question: can the loop L terminate? Naïvely, this should not be possible. If the loop does not terminate, then subsequent code is unreachable, and the object is not live. But if it’s actually collected and the first callback runs, the object is subsequently used for another registration, making the object live and invalidating the collection. The specification allows the engine to err only in favour of liveness. As such, the only compliant behaviour is to never run either callback and loop forever.

However, if the engine is allowed to ‘scavenge an object for its effects on subsequent code’, then arguably L can terminate. The engine may decide that at R2 the only relevant aspect of the object is that it’s going to be collectible soon. As such, the engine may substitute another ‘lame-duck’ object, e.g. mint an ad-hoc empty object, or simply ignore the object entirely and schedule a callback immediately with the given held value. But that means R2 doesn’t constitute observing the object’s identity, and it already becomes eligible for collection after R1, which in turn may be optimised similarly, thus optimising the object out of existence entirely.

Of course, the latter interpretation is rather perverse, and it’s the reason why I suggested formally recognising register as a liveness fence. (Right now this seems to be only so in a non-normative note. At the very least, it should be a conditional liveness fence: the registered object should be considered live before the registration if the registry is.) But I think such reasoning is generally admissible given the specification text (at least not any less than scalar replacement of properties), and the behaviour it implies may emerge from a number of individually-justifiable optimisations.

1 Like

That sounds very reasonable to me. I've always considered FinalizationRegistry and WeakRef to be mechanisms to observe the liveness of an identity, unlike WeakMap and WeakSet which do not directly allow the program to observe the liveness of an object identity, but are a mechanism to defer that ability through to the mapping value.

In both cases however the identity of the object should be intact. I don't think that prevents the engine to perform optimizations in other places where the object is used, just enforces a liveness barrier at some specific points where the program intends to observe liveness.

Is the optimizer allowed to consider WeakRef-oblivious executions? Aka should the optimizer be allowed to consider the program cannot observe liveness of an identity? I believe that an optimizer should only be allowed to make that assumption if the program doesn't have access to FinalizationRegistry and WeakRef.

Btw, thanks for bringing this up. It has definitely allowed me to further understand the impact of optimization on liveness observation, and challenged me to think about a problem I hadn't completely understood at first.

fyi, if your target is nodejs, a trick to auto-garbage ffi-handles is by wrapping them in an ArrayBuffer which javascript can then garbage-collect. this removes your headache of manually keeping track of every alloc() and free() calls.

/*
 * sqlite_ffi.c
 *
 * trick to auto-garbage-collect sqlite-handles by wrapping them in javascript
 * arraybuffers
 *
 * note error-checking codes have been stripped to improve readability
 */

#include <sqlite3.h>
#include <node_api.h>
#define UNUSED(x) (void)(x)

static void sqliteHandleCloseAndRelease(
    napi_env env,
    void *handle,
    void *finalize_hint
) {
// this is the "free" function called by javascript garbage-collector
// when ArrayBuffer falls out-of-scope
    UNUSED(env);
    UNUSED(finalize_hint);
    // debug
    printf("garbage-collecting sqlite-handle %zd", handle);
    // close db-connection associated with handle
    sqlite3_close_v2(*(sqlite3 **) handle);
    // release handle
    free(handle);
}

static napi_value sqliteHandleCreateWithGarbageCollection(
    napi_env env,
    napi_callback_info info
) {
// this function will create a sqlite-handle and wrap it in
// javascript-arraybuffer that can be automatically garbage-collected
    UNUSED(info);
    // declare var
    napi_value val = NULL;
    // allocate handle
    int64_t *handle = (int64_t *) malloc(sizeof(int64_t));
    napi_create_external_arraybuffer(env,       // napi_env env,
        (void *) handle,        // void* external_data,
        sizeof(int64_t),        // size_t byte_length,
        sqliteHandleCloseAndRelease,    // napi_finalize finalize_cb,
        NULL,                   // void* finalize_hint,
        &val);                  // napi_value* result
    return val;
}


static napi_value sqliteOpen(...) {...}
static napi_value sqliteExec(...) {...}

napi_value napi_module_init(
    napi_env env,
    napi_value exports
) {
#define NAPI_EXPORT_MEMBER(name) \
    {#name, NULL, name, NULL, NULL, NULL, napi_default, NULL}
    const napi_property_descriptor propList[] = {
        NAPI_EXPORT_MEMBER(sqliteHandleCreateWithGarbageCollection),
        NAPI_EXPORT_MEMBER(sqliteOpen),
        NAPI_EXPORT_MEMBER(sqliteExec),
    };
    napi_define_properties(env, exports,
        sizeof(propList) / sizeof(napi_property_descriptor), propList);
    return exports;
}

/*
 * sqlite_ffi.js
 */

/*jslint devel*/
import sqliteFfi from "./sqlite_ffi_napi.node";

// private dictionary encapsulating all sqlite-connection-handles
let sqliteHandleDict = new WeakMap();

async function sqliteOpenAsync(filename) {
    let db = {};
    let handle = sqliteFfi.sqliteHandleCreateWithGarbageCollection();
    sqliteHandleDict.set(db, {
        handle
    });
    await sqliteFfi.open(handle, filename);
    return db;
}

async function sqliteExecAsync(db, sql) {
    let handle = sqliteHandleDict.get(db);
    let result = await sqliteFfi.exec(handle, sql);
    return result;
}

(async function () {
    let db1 = await sqliteOpenAsync(":memory:");
    let result = await sqliteExecAsync(db1, "SELECT 1 AS foo; SELECT 2 AS bar");
    console.log(result);
    // stdout:
    // [[{foo: 1}], [{bar: 2}]]
}());

// nodejs will automatically close and free sqlite-handle to "db1" when "db1"
// falls out-of-scope and is garbage-collected

I think the way such reasoning may be actually applied in an optimisation is by having this:

const wr = new WeakRef(this);
await 0;
console.log(wr.deref() === this);

re-written into this:

const wr = new WeakRef(this);
await 0;
console.log(wr.deref() !== void 0);

Since there are only two possibilities for what wr.deref() may return — this or undefined — checks for one may be re-written as checks against the other. After such a re-write, it may happen that nothing below the check actually holds a reference to this any more. So wr may as well have been cleared, as we’re past the suspension point where the lifetime of this was guaranteed to be extended.

(And if the RHS is something other than this or undefined, it’s even easier, because you can just constant-fold it to false immediately.)

What I pointed out above is that such a transformation arguably does not contradict the definition of liveness in the specification. Again, a set of objects S is live if a possible future S-WeakRef-oblivous execution observes the identity of one of its members. If we take ‘observing the identity’ to mean ‘if we took this execution and substituted the appearance of this object we’re ostensibly using for some other object at some point, it would have executed differently’: well, in a WeakRef-oblivious execution with respect to { this }, wr.deref() === this returns the same value as, say, wr.deref() === new Object(): either is false, because it strictly compares undefined to an object.

More intuitively, we don’t observe the identity of this, because the check we perform is equivalent to a simpler operation of checking if wr still points to some referent — and an engine can put that knowledge to some use.

Putting a liveness barrier in SameValue et al. would make this impossible.

1 Like

I believe that's where I disagree. If we say that .deref() is a liveness barrier (outside of WeakRef-oblivious executions), then the optimization shouldn't assume the identity of the result of that operation, and cannot substitute the usage of that value.

To be more clear wr.deref() === this is not equivalent to wr.deref() === new Object(), and the optimizer may not make an assumption that either would be false, because deref should observe the same identity when it's not empty, and the optimizer shouldn't take into consideration WeakRef-oblivious executions.

I don't believe a liveness barrier in SameValue is necessary, only that new WeakRef, WeakRef.prototype.deref, and FinalizationRegistry.prototype.register all observe the liveness and the identity of the referent.

Isn't this disallowed? Assuming wr.deref() === this is itself observed somehow, I think the spirit of the current language does say that an optimizer cannot collapse an object's identity into equivalence classes like "some object that's not this" and transform according to that assumption.

Interesting. If a compiler considers R2 to be observing the identity of delia, then R2 is unreachable. If a compiler considers R2 to not be observing the identity of delia, then R2 is reachable. But if R2 is reachable in an execution, then delia must also be live, which contradicts its reachability. Unless, of course, it's allowed to do rematerialization, which is disallowed. Perhaps I'm missing something... This feels similar to my intention at the time to also disallow the "object identity equivalence-class collapse" kind of optimizations.

I take the point that the language could be made more formal, however.