{"record":{"id":"c9b6ed273cdc5b9e","repo":"netbirdio/netbird","slug":"watcher-closed-unexpectedly","errorCode":null,"errorMessage":"watcher closed unexpectedly","messagePattern":"watcher closed unexpectedly","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"client/internal/updater/installer/result.go","lineNumber":138,"sourceCode":"\n\tif err := watcher.Add(dir); err != nil {\n\t\treturn Result{}, fmt.Errorf(\"failed to watch directory: %v\", err)\n\t}\n\n\t// Check again after setting up watcher to avoid race condition\n\t// (file could have been created between initial check and watcher setup)\n\tif result, err := rh.tryReadResult(); err == nil {\n\t\tlog.Infof(\"installer result: %v\", result)\n\t\treturn result, nil\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn Result{}, ctx.Err()\n\t\tcase event, ok := <-watcher.Events:\n\t\t\tif !ok {\n\t\t\t\treturn Result{}, errors.New(\"watcher closed unexpectedly\")\n\t\t\t}\n\n\t\t\tif result, done := rh.handleWatchEvent(event); done {\n\t\t\t\treturn result, nil\n\t\t\t}\n\t\tcase err, ok := <-watcher.Errors:\n\t\t\tif !ok {\n\t\t\t\treturn Result{}, errors.New(\"watcher closed unexpectedly\")\n\t\t\t}\n\t\t\treturn Result{}, fmt.Errorf(\"watcher error: %w\", err)\n\t\t}\n\t}\n}\n\nfunc (rh *ResultHandler) handleWatchEvent(event fsnotify.Event) (Result, bool) {\n\tif event.Name != rh.resultFile {\n\t\treturn Result{}, false\n\t}","sourceCodeStart":120,"sourceCodeEnd":156,"githubUrl":"https://github.com/netbirdio/netbird/blob/93e97f4bf1ad715072dcb3fb6cdb1763431b5a9c/client/internal/updater/installer/result.go#L120-L156","documentation":"Returned by the installer ResultHandler read loop (client/internal/updater/installer/result.go:138) when fsnotify's Events channel closes unexpectedly (the ok flag on receive is false). A closed Events channel means the watcher was shut down (Close called elsewhere or the watcher died), so the handler can no longer observe creation/write events for the installer result file and aborts instead of hanging.","triggerScenarios":"Another goroutine calls watcher.Close() while ResultHandler waits for the installer to write its result file; the watcher object is shared and torn down during updater shutdown; fsnotify runs out of kernel watch resources (inotify limits) and the watcher is removed.","commonSituations":"Updater restart or cancellation racing an in-flight installer run; two components sharing one fsnotify watcher with different lifetimes; constrained environments (containers) with low fs.inotify.max_user_watches causing watcher failure.","solutions":["Give the ResultHandler its own fsnotify watcher so nothing else can close it mid-read.","Retry once: recreate the watcher and re-enter the read loop (the source already rechecks the result file after setup, so a retry converges).","If inotify limits are the cause, raise fs.inotify.max_user_watches / max_user_instances on the host.","As a last resort, poll the result file with a timeout instead of watching."],"exampleFix":"// before: shared watcher closed by another component -> \"watcher closed unexpectedly\"\n\n// after: own watcher + bounded retry inside the handler\nfor attempt := 0; attempt < 2; attempt++ {\n    result, err := rh.readWithFreshWatcher(ctx)\n    if err == nil || !isWatcherClosed(err) {\n        return result, err\n    }\n    log.Warnf(\"watcher closed, retrying (attempt %d)\", attempt+1)\n}","handlingStrategy":"retry","validationCode":null,"typeGuard":"func isWatcherClosed(err error) bool {\n    return err != nil && strings.Contains(err.Error(), \"watcher closed unexpectedly\")\n}","tryCatchPattern":"result, err := rh.waitForResult(ctx)\nif isWatcherClosed(err) {\n    // watcher died mid-wait: rebuild it and retry once; the initial\n    // tryReadResult recheck makes the retry converge if the file appeared\n    result, err = rh.waitForResultWithFreshWatcher(ctx)\n}\nif err != nil {\n    return Result{}, fmt.Errorf(\"read installer result: %w\", err)\n}","preventionTips":["Give each wait loop its own fsnotify watcher; never share watchers across lifetimes.","Stop waits via context cancellation, not by closing the watcher from the outside.","Check inotify limits (max_user_watches, max_user_instances) on constrained hosts."],"tags":["go","netbird","updater","installer","fsnotify","file-watcher"],"backgroundTag":null,"analyzedSha":"93e97f4bf1ad715072dcb3fb6cdb1763431b5a9c","analyzedAt":"2026-08-16T03:09:19.136Z","schemaVersion":2},"datasetVersion":"2026-08-16T08:17:34.114Z"}