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

  1. Keep a strong reference to the callback object for the lifetime of the watch so it is never GC'd/closed.
  2. Create a fresh JSCallback and call ffiSetWatchCallback again instead of reusing a closed one.
  3. 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

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


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)