juanfont/headscale · error

reading path: %s, err: %w

Error message

reading path: %s, err: %w

What it means

os.ReadFile failed while loading (or hot-reloading) the extra-records JSON file; the path and the underlying OS error are both included. This fires from both NewExtraRecordsManager (startup) and the fsnotify reload loop (runtime), since both call readExtraRecordsFromPath.

Source

Thrown at hscontrol/dns/extrarecords.go:193

	// Release the lock before the (potentially blocking) send so a slow or
	// absent consumer cannot stall Records() readers, and abort the send on
	// shutdown instead of leaking this goroutine on the closed-down channel.
	e.mu.Unlock()

	select {
	case e.updateCh <- toSend:
	case <-e.closeCh:
	}
}

// readExtraRecordsFromPath reads a JSON file of [tailcfg.DNSRecord]
// and returns the records and the hash of the file.
func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error) {
	var zero [32]byte

	b, err := os.ReadFile(path)
	if err != nil {
		return nil, zero, fmt.Errorf("reading path: %s, err: %w", path, err)
	}

	// If the read was triggered too fast, and the file is not complete, ignore the update
	// if the file is empty. A consecutive update will be triggered when the file is complete.
	if len(b) == 0 {
		return nil, zero, nil
	}

	var records []tailcfg.DNSRecord

	err = json.Unmarshal(b, &records)
	if err != nil {
		return nil, zero, fmt.Errorf("unmarshalling records, content: %q: %w", string(b), err)
	}

	hash := sha256.Sum256(b)

	return records, hash, nil

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Check the OS error in the chain (ENOENT, EACCES) — ENOENT during reload usually means the writer uses rename; ensure the final path exists after each write.
  2. Restore correct ownership/permissions so the headscale process can read the file.
  3. At startup, verify the path exists and is a regular file before starting headscale.
  4. For reload failures, the manager keeps the last good records; fix the file and touch it to trigger another reload.
Defensive patterns

Strategy: retry

Validate before calling

// Before starting headscale:
fi, err := os.Stat(cfg.ExtraRecordsPath)
if err != nil || !fi.Mode().IsRegular() {
    return fmt.Errorf("extra records file missing or not regular: %w", err)
}

Try / catch

records, hash, err := readExtraRecordsFromPath(path)
if err != nil {
    if errors.Is(err, fs.ErrNotExist) {
        // transient during atomic-rename reloads: keep previous records,
        // wait for the next fsnotify event instead of failing
        return
    }
    return err
}

Prevention

When it happens

Trigger: The file was deleted or renamed between the fsnotify event and the read (classic atomic-rename race: editors write a temp file and rename over the target), permissions lost, or the path became unreadable at runtime. Note: an empty file is tolerated (treated as no update), so this error means the read itself failed, not emptiness.

Common situations: Config management (ansible/sed -i) replacing the file via rename while headscale watches it; file removed by cleanup jobs; permission changes; NFS mounts dropping.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/f3bc2f719279532d. Report an issue: GitHub.