charmbracelet/crush · error
cannot notify change for unopened file: %s
Error message
cannot notify change for unopened file: %s
What it means
NotifyChange only sends didChange for files the client has previously opened: it looks up the URI in c.openFiles, and if absent returns "cannot notify change for unopened file". The LSP protocol requires didOpen before didChange, so the client enforces this ordering and refuses to send a change notification for a document the server has never seen.
Source
Thrown at internal/lsp/client.go:446
return nil
}
// NotifyChange notifies the server about a file change.
func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
if c == nil {
return nil
}
uri := string(protocol.URIFromPath(filepath))
content, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("error reading file: %w", err)
}
fileInfo, isOpen := c.openFiles.Get(uri)
if !isOpen {
return fmt.Errorf("cannot notify change for unopened file: %s", filepath)
}
// Increment version
fileInfo.Version++
// Create change event
changes := []protocol.TextDocumentContentChangeEvent{
{
Value: protocol.TextDocumentContentChangeWholeDocument{
Text: string(content),
},
},
}
return c.client.NotifyDidChangeTextDocument(ctx, uri, int(fileInfo.Version), changes)
}
// IsFileOpen checks if a file is currently open.View on GitHub (pinned to 7944b8e522)
Solutions
- Call OpenFile (or OpenFileOnDemand) for the path before the first NotifyChange.
- After Restart, re-open all tracked files before resuming change notifications.
- In the caller, treat this error by falling back to OpenFile when the file is unopened.
- Ensure the same canonical path string is used for both open and change calls (resolve symlinks).
Example fix
// before
client.NotifyChange(ctx, path) // error if never opened
// after
if err := client.OpenFile(ctx, path); err != nil {
return err
}
client.NotifyChange(ctx, path) Defensive patterns
Strategy: fallback
Validate before calling
// Track open state yourself and open before changing
if !openedPaths[path] {
if err := client.OpenFile(ctx, path); err != nil {
return err
}
openedPaths[path] = true
} Type guard
func isUnopenedFileErr(err error) bool {
return err != nil && strings.Contains(err.Error(), "cannot notify change for unopened file")
} Try / catch
if err := client.NotifyChange(ctx, path); err != nil {
if isUnopenedFileErr(err) {
// fallback: open then notify
if oerr := client.OpenFile(ctx, path); oerr != nil {
return oerr
}
return client.NotifyChange(ctx, path)
}
return err
} Prevention
- Always pair didOpen with subsequent didChange calls in your edit pipeline.
- After Restart, rebuild your open-file set before resuming edits.
- Use one canonical, symlink-resolved path for both open and change calls.
When it happens
Trigger: Calling NotifyChange on a filepath that was never passed to OpenFile, or after the client restarted (Restart clears openFiles) while callers keep notifying changes; calling with a path whose computed URI differs from the one used at open time.
Common situations: Fire-and-forget edit pipelines that call notifyLSPs without an earlier OpenFile; after Restart, stale watchers keep firing NotifyChange for files no longer tracked; path casing/symlink differences make the URI lookup miss.
Related errors
- failed to search for symbol: %w
- symbol '%s' not found in grep results
- no LSP client handles any file matching '%s'
- permission request failed: %w
- failed to read file: %w
AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29).
Data as JSON: /api/errors/c7ed80827a65e19f.
Report an issue: GitHub.