Filesystem Watching and Reload

Updated

September 8, 2026

Filesystem Watching and Reload

Overview

Agents and servers often reload config when a file changes. Approaches:

  1. Polling Stat mtime/size (portable, simple)
  2. OS watchers (inotify, FSEvents, ReadDirectoryChanges)—via github.com/fsnotify/fsnotify or raw x/sys
  3. Signal SIGHUP for classic Unix reload

This chapter keeps patterns minimal and production-safe (debounce, atomic replace races).

Polling watcher (stdlib only)

type pollWatch struct {
    path    string
    lastMod time.Time
    lastSz  int64
}

func (p *pollWatch) changed() (bool, error) {
    st, err := os.Stat(p.path)
    if err != nil {
        return false, err
    }
    mod, sz := st.ModTime(), st.Size()
    if mod.Equal(p.lastMod) && sz == p.lastSz {
        return false, nil
    }
    p.lastMod, p.lastSz = mod, sz
    return true, nil
}

func watchLoop(ctx context.Context, path string, every time.Duration, onChange func() error) error {
    var w pollWatch{path: path}
    _, _ = w.changed() // seed
    t := time.NewTicker(every)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-t.C:
            ok, err := w.changed()
            if err != nil {
                // missing file: keep trying
                continue
            }
            if ok {
                if err := onChange(); err != nil {
                    slog.Error("reload", "err", err)
                }
            }
        }
    }
}

Atomic replace (write temp + rename) may produce one or two events; always re-read the final path.

Debounce

Editors and write tools can burst events:

func debounce(ctx context.Context, in <-chan struct{}, d time.Duration) <-chan struct{} {
    out := make(chan struct{}, 1)
    go func() {
        defer close(out)
        var timer *time.Timer
        for {
            select {
            case <-ctx.Done():
                return
            case _, ok := <-in:
                if !ok {
                    return
                }
                if timer != nil {
                    timer.Stop()
                }
                timer = time.AfterFunc(d, func() {
                    select {
                    case out <- struct{}{}:
                    default:
                    }
                })
            }
        }
    }()
    return out
}

Reload after quiet period (e.g. 200ms).

fsnotify sketch (common library)

// go get github.com/fsnotify/fsnotify
w, err := fsnotify.NewWatcher()
_ = w.Add(filepath.Dir(configPath))
for {
    select {
    case ev := <-w.Events:
        if ev.Name == configPath && (ev.Has(fsnotify.Write) || ev.Has(fsnotify.Create) || ev.Has(fsnotify.Rename)) {
            // signal reload (debounced)
        }
    case err := <-w.Errors:
        slog.Error("watch", "err", err)
    case <-ctx.Done():
        return
    }
}

Watch the directory if atomic rename replaces the inode—watching only the file can miss renames on some OSes.

SIGHUP reload

hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
    for {
        select {
        case <-ctx.Done():
            return
        case <-hup:
            _ = reload()
        }
    }
}()

Works when you control the operator (kill -HUP); pairs well with file watch.

Safe reload checklist

  1. Read entire config into memory
  2. Validate
  3. Swap atomically (atomic.Value or mutex + pointer)
  4. Never half-apply on error—keep previous good config
type Config struct{ /* ... */ }

var current atomic.Pointer[Config]

func reload(path string) error {
    b, err := os.ReadFile(path)
    if err != nil {
        return err
    }
    cfg, err := parseConfig(b)
    if err != nil {
        return err
    }
    current.Store(&cfg)
    return nil
}

Minimal tool: reload-demo

# terminal 1
go run . -config /tmp/c.json
# terminal 2
echo '{"n":1}' > /tmp/c.json
# process logs "reloaded"

Rules of thumb

Do Don’t
Debounce reloads Parse on every partial write
Keep last good config Crash process on bad temporary JSON
Prefer dir watch + rename awareness Assume one Write event == complete file
Bound poll interval Poll every 1ms on huge fleets

Try next

  1. Poll-watch a config; prove atomic rename still reloads.
  2. Feed invalid JSON; process must keep serving old config.
  3. Add SIGHUP path alongside poll.