dmtrKovalenko/fff · error
watch callback has been closed
Error message
watch callback has been closed
What it means
ffiSetWatchCallback checks that the provided JSCallback still has a valid native function pointer before passing it to the FFI. Bun's ffi can close/null a callback pointer (e.g. after garbage collection or explicit close); if callback.ptr is null the binding refuses to register it, since the native side would later call an invalid function pointer.
Solutions
- Keep a strong reference to the callback object for the lifetime of the watch so it is never GC'd/closed.
- Create a fresh JSCallback and call ffiSetWatchCallback again instead of reusing a closed one.
- Close the old callback only after replacing it with a new registration.
Example fix
// before
function setup() {
ffiSetWatchCallback(handle, new JSCallback(onEvents)); // may be GC'd
}
// after
const watchCb = new JSCallback(onEvents); // module-level, kept alive
ffiSetWatchCallback(handle, watchCb); Defensive patterns
Strategy: validation
Validate before calling
function assertLiveCallback(cb) { if (cb.ptr === null) throw new Error('callback closed; create a new JSCallback'); } Type guard
function isLiveCallback(cb) { return cb != null && typeof cb.ptr === 'object' && cb.ptr !== null; } Try / catch
try { ffiSetWatchCallback(handle, cb); } catch (e) { if (e.message.includes('closed')) { cb = new JSCallback(onEvents); ffiSetWatchCallback(handle, cb); } } Prevention
- Hold a module-level reference to watch callbacks so GC cannot collect them.
- Never reuse a callback after close; construct a new one.
- Replace-then-close when swapping callbacks at runtime.
When it happens
Trigger: Passing a JSCallback whose underlying pointer was closed — typically because the callback object was garbage collected, the FfiCallback was explicitly closed, or a closed callback was reused across registrations.
Common situations: Storing the callback only inside a short-lived scope so GC finalizes it while the watch outlives it; re-registering a callback after a previous teardown closed it; hot-reload of modules dropping old callback objects.
Related errors
- fff native library not found. Run `npx @ff-labs/fff-node…
- File picker not initialized. Call fff_create_instance first.
- No watch callback registered. Call fff_set_watch_callback…
- FFI returned null pointer
- fff_search_mixed returned null search result
AI-assisted analysis of dmtrKovalenko/fff@7f8537e70f (2026-09-10).
Data as JSON: /api/errors/56217130c2a51cfd.
Report an issue: GitHub.
Appendix: source
Thrown at packages/fff-bun/src/ffi.ts:1443
}
symbols.fff_free_watch_events(bp);
return events;
}
/**
* Register the instance-wide watch callback. Must be called before the
* first `ffiWatch`. The caller owns `callback` (a threadsafe `JSCallback`
* built in finder.ts) and must keep it alive until after `ffiDestroy`
* returns for this handle — that call is the delivery quiescence barrier.
*/
export function ffiSetWatchCallback(
handle: NativeHandle,
callback: JSCallback,
): Result<void> {
const library = loadLibrary();
if (callback.ptr === null) {
return err("watch callback has been closed");
}
const resultPtr = library.symbols.fff_set_watch_callback(handle, callback.ptr, null);
return parseVoidResult(resultPtr);
}
/**
* Subscribe to filesystem changes; batches are delivered through the
* instance callback registered with `ffiSetWatchCallback`, tagged with the
* watch id this function returns.
*
* @returns The native watch id carried in `FffResult.int_value`.
*/
export function ffiWatch(
handle: NativeHandle,
pattern: string,
ignore: string[] = [],
): Result<number> {
const library = loadLibrary();View on GitHub (pinned to 7f8537e70f)