Filesystem Watching and Reload
Filesystem Watching and Reload
Overview
Agents and servers often reload config when a file changes. Approaches:
- Polling
Statmtime/size (portable, simple)
- OS watchers (inotify, FSEvents, ReadDirectoryChanges)—via
github.com/fsnotify/fsnotifyor rawx/sys
- Signal
SIGHUPfor 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
- Read entire config into memory
- Validate
- Swap atomically (
atomic.Valueor mutex + pointer)
- 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
- Poll-watch a config; prove atomic rename still reloads.
- Feed invalid JSON; process must keep serving old config.
- Add SIGHUP path alongside poll.