{"record":{"id":"56217130c2a51cfd","repo":"dmtrKovalenko/fff","slug":"watch-callback-has-been-closed","errorCode":null,"errorMessage":"watch callback has been closed","messagePattern":"watch callback has been closed","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/fff-bun/src/ffi.ts","lineNumber":1443,"sourceCode":"  }\n\n  symbols.fff_free_watch_events(bp);\n  return events;\n}\n\n/**\n * Register the instance-wide watch callback. Must be called before the\n * first `ffiWatch`. The caller owns `callback` (a threadsafe `JSCallback`\n * built in finder.ts) and must keep it alive until after `ffiDestroy`\n * returns for this handle — that call is the delivery quiescence barrier.\n */\nexport function ffiSetWatchCallback(\n  handle: NativeHandle,\n  callback: JSCallback,\n): Result<void> {\n  const library = loadLibrary();\n  if (callback.ptr === null) {\n    return err(\"watch callback has been closed\");\n  }\n  const resultPtr = library.symbols.fff_set_watch_callback(handle, callback.ptr, null);\n  return parseVoidResult(resultPtr);\n}\n\n/**\n * Subscribe to filesystem changes; batches are delivered through the\n * instance callback registered with `ffiSetWatchCallback`, tagged with the\n * watch id this function returns.\n *\n * @returns The native watch id carried in `FffResult.int_value`.\n */\nexport function ffiWatch(\n  handle: NativeHandle,\n  pattern: string,\n  ignore: string[] = [],\n): Result<number> {\n  const library = loadLibrary();","sourceCodeStart":1425,"sourceCodeEnd":1461,"githubUrl":"https://github.com/dmtrKovalenko/fff/blob/7f8537e70f0ea1210f9acbbfc4640141105cdc78/packages/fff-bun/src/ffi.ts#L1425-L1461","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nfunction setup() {\n  ffiSetWatchCallback(handle, new JSCallback(onEvents)); // may be GC'd\n}\n// after\nconst watchCb = new JSCallback(onEvents); // module-level, kept alive\nffiSetWatchCallback(handle, watchCb);","handlingStrategy":"validation","validationCode":"function assertLiveCallback(cb) { if (cb.ptr === null) throw new Error('callback closed; create a new JSCallback'); }","typeGuard":"function isLiveCallback(cb) { return cb != null && typeof cb.ptr === 'object' && cb.ptr !== null; }","tryCatchPattern":"try { ffiSetWatchCallback(handle, cb); } catch (e) { if (e.message.includes('closed')) { cb = new JSCallback(onEvents); ffiSetWatchCallback(handle, cb); } }","preventionTips":["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."],"tags":["ffi","bun","callback","lifecycle"],"backgroundTag":"null-argument","analyzedSha":"7f8537e70f0ea1210f9acbbfc4640141105cdc78","analyzedAt":"2026-09-10T07:26:53.407Z","contentChangedAt":"2026-09-10T07:26:53.407Z","schemaVersion":2},"datasetVersion":"2026-09-16T09:17:16.951Z"}