Liveness barriers and finalization

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