Add haxe.GcFinalizer with handle-based API#12766
Add haxe.GcFinalizer with handle-based API#12766Simn merged 6 commits intoHaxeFoundation:developmentfrom
Conversation
Provides a cross-platform API for GC finalization callbacks. When a registered target object is collected, a user-provided callback is invoked with a held value (not the dying object). Supported targets: js, python, cpp, eval, lua, jvm. Unsupported targets throw NotImplementedException.
|
Interesting! Just the other day I was looking for something like this in the context of threads. I don't like this I generally prefer when functions return you an interface handle that you can Do you think that would work? |
That would be great. Although personally, I would say "close" is not the best choice for a general name, or even the specific one. One doesn't "open" a thread callback. Common alternatives include "cancel" (C#'s |
|
My workaround for that terminology problem is that I understand it as closing the handle, which is a common term. What exactly that means then depends on what the handle actually handles. |
Replace register(target, heldValue, ?unregisterToken) + unregister(token) with register(target, heldValue):ICloseable. Calling close() on the returned handle cancels the finalization. This removes ObjectMap dependency from all non-JS targets and eliminates silent token leak issues. Add haxe.ICloseable as a general-purpose interface following IThreadCallbackHandle precedent.
|
I like to follow YAGNI principles, but i think it's "neighborly" to offer an Interface as a sort of contract for this behavior. We don't necessarily need to care about how the garbage collection actually happens, as long as it just follows the contract from our point of view. Also, our platforms don't consistently provide all the features we need here, I think it's prudent to offer a mechanism to enable supporting it cleanly. |
Is a workaround really the best choice here? Names, like jokes, work better when they don't rely on explanations ;) |
|
I don't have any real skin in the game on naming here, never had to do something like that. We could put the interface somewhere out of the way, I bet only AI will go digging for it. |
|
I don't see the controversy here. You register a callback and doing so gives you a handle, which you can close, which in turn cancels/detaches/unregisters/aborts/stops/kills the callback. Out of all these, the only acceptable ones would be detach and unregister because the others suggest to me that an ongoing callback execution could be interrupted. We'll have to fix the C++ implementation because it doesn't work, and even if it did work that code doesn't look right... maybe @Aidan63 can help with that. |
There was a problem hiding this comment.
Seems like its getting confused about the {} type and the erased T parameter, I doubt anyones used fromStaticFunction like that (and I'm not sure they should), but I can take a look anyway.
I've taken a general look at this and it seems... madly convoluted. I think I understand what the cpp implementation is trying to do, even if just about nothing with it does / would work. In general I'm still not entirely sure why there's this token system, what is it trying to solve that a more simple function finalise<T>(obj:T, callback:T->Void):IClosable doesn't?
If close makes sense in the context of handles, then maybe it's better to call it |
Rename haxe.ICloseable to haxe.IHandle per reviewer consensus
(tobil4sk suggested, Simn endorsed). The name makes the semantic
connection clear: calling close() on a handle.
Stub out the cpp GcFinalizer with NotImplementedException. The
previous implementation had correctness issues flagged by Aidan63:
dynamic callback invocation allocates during GC finalization
(crashes hxcpp), and Reflect.setField on arbitrary {} objects
won't work on typed cpp classes. Simn asked Aidan63 to help
provide a proper cpp implementation.
|
Pushed changes addressing the feedback: Rename: C++ implementation: Stubbed out with Re: the API design question ("why not just The held-value pattern comes from JS's (Side note on spelling: the codebase uses American |
|
@Aidan63 I've addressed the atomics feedback — For the cpp implementation, I wanted to ask your advice since you know hxcpp internals. The core constraint is that we can't allocate or do GC operations inside an hxcpp finalizer callback. The options I've been considering:
Which pattern would you recommend? Or is there a better approach I'm not seeing? The key requirement is: when |
|
Also, FWIW, keep in mind CPP already throws a NotImplemented exception. I was going to take a stab at those in a separate PR, but if it helps keep context together, we can do it all here. |
|
I think the zombie queue sounds best, that method also seems easiest to switch over to something fancier down the line. I'm thinking eventually hxcpp might have a dedicated finalisation queue and thread, when the GC would call the finaliser it instead puts the object onto a queue and the finaliser thread would take items off the queue and invoke the callback concurrently with user haxe code. I had a quick look at immix.cpp and there are already four queues it iterates over which store various finalisers! So there might be some rationalisation to be done here. |
Update: cpp implementation +
|
cpp: WeakRef-based polling (same pattern as JVM's ReferenceQueue) avoids all GC-safety concerns — no allocations during finalization, no static function pointer constraints. eval/JVM: Use AtomicBool with compareExchange for cancelled flag, null out callback and heldValue after processing to prevent retention. Tests: Add cpp to supported target conditionals.
|
Following the jvm and eval approach seems like a good idea until I get a chance to look at the callable issue or a hxcpp impl. The atomic bools also look like a good change, I think it might be pretty simple to enhance that slightly more as well. E.g. If we set the atomic bool in the handles close to true we could then null out all fields on the registration which might help with the limit that the user needs to call poll regually for for cleanup to happen on some targets. E.g. in the hande close function close() {
if (false == reg.cancelled.compareExchange(false, true)) {
// Null / clear whatever we want in the registration.
}
}Then instead of calling if (false == reg.cancelled.compareExchange(false, true)) {
reg.callback(reg.heldValue);
}I think that logic makes sense, but someone else might want to give it a look over. |
Both Handle.close() and callback invocation now race via compareExchange to claim ownership of the registration. The winner eagerly nulls callback/heldValue, ensuring fields are freed immediately regardless of which side fires first. Applies to eval, JVM, and cpp implementations.
I thought about this for a while, and I'm still not a fan. The terminology is a bit fuzzy here, but I would say that a "handle":
Deregistering a callback is a safe, fast in-process operation. Closing an open file requires a kernel call as the absolute minimum, which means a context switch and a possibly failure. If it happens to be NFS, then this can take tens if not hundreds of milliseconds and failure is a rather likely outcome. Why not call it
Not really, since |
|
@Aidan63 Thanks for the suggestion — this is already in place across all three targets. Both |
|
@back2dos hazarding an opinion here... Handle does feel more closely related to resource references for the kernel, but "registration" feels mismatched too in some way, at least for coro (we don't really "register" a coroutine). Naming things always sucks! |
|
Libuv calls everything a handle too and has both timer and async handles, which is a good argument from authority. Let's stick to this so we don't get lost in endless naming discussions. |
Libuv calls these uv_handle. That is very specific, while whatever is happening here is not. My issue is less with the name, but that I don't really understand what the category is, that's being "generalized" over. Simple, practical question: should |
I'd have to think about all the possible scenarios, but my inclination would be that What's being generalized is the notion of "here's a thing that you can close when you're done with it". |
|
This is definitely a grey area. close() on a finalizer is fast and in-process. A close() on an Output could block, fail, require flushing... involve syscall... That sort of uncertainty undermines a unified naming abstraction IMHO. (This is just me thinking with my meat brain, Claude seems to have no problem either way). |
|
I asked my Claude too: The naming question: I side with Against Against For back2dos's key question ("should My suggestion: Keep The endless naming discussion is itself the biggest argument for just picking |
|
I like this because now we can just blame Claude instead of piling it on Simn like usual. |
|
@Simn If you're happy with where this landed, would you mind approving? CI is green and I believe all the feedback has been addressed. |

Summary
haxe.GcFinalizer<T>— a cross-platform API for GC finalization callbacks, modeled after JSFinalizationRegistryhaxe.ICloseable— a general-purpose interface for releasable resources (followsIThreadCallbackHandleprecedent)API
register()returns anICloseablehandle. Callingclose()cancels the finalization. No token-based unregister — JS users who need batch unregister can usejs.lib.FinalizationRegistrydirectly.Target implementations
FinalizationRegistryh.unregister(syntheticToken)weakref.finalizefin.detach()cpp.vm.Gc.setFinalizerwith registration data on targetreg.cancelled = trueeval.vm.Gc.finalisewith closure-captured statereg.cancelled = true__gcmetamethod (Lua 5.2+)proxy.cb = nilWeakReference+ReferenceQueuepollingreg.cancelled = trueNotImplementedExceptionChanges from initial version
Redesigned per review feedback: replaced token-based
register(target, heldValue, ?unregisterToken)+unregister(token)with handle-basedregister(target, heldValue):ICloseable. This eliminatesObjectMapdependency from all non-JS targets and prevents silent token leak issues.Test plan
close()prevents callback (cpp,eval)NotImplementedException