fix: retry recording when storage is slow to mount at boot

A power cut makes the recordings drive's fsck slow, so the mount can
appear tens of seconds after nextnvr starts. The recorders then hit the
unmounted, root-owned mountpoint, failed mkdir, and exited permanently —
recording stayed dead until a manual restart.

- recorder: on mkdir failure, log and retry every 15s instead of exiting,
  so recording resumes once storage returns
- storage: add waitForStorage() and call it before the writers start, so
  startup waits (up to 120s) for the recordings dir to be present and
  writable; live view still starts if storage never appears
This commit is contained in:
Claus Lohmar 2026-10-15 07:09:11 +01:00
parent a54ff08ebb
commit f794c57198
3 changed files with 51 additions and 4 deletions

11
main.go
View file

@ -62,6 +62,17 @@ func main() {
app.go2rtc = go2rtc
}
// Wait for the recordings storage before starting any writers. On a slow
// boot (e.g. fsck after a power cut) the mount can appear tens of seconds
// late; this avoids the recorders failing against an unmounted, root-owned
// mountpoint. If it times out we still start — live view does not need
// storage, and the recorder retries on its own.
if st := waitForStorage(cfg.Storage.RecordingsPath, 120*time.Second); st.OK {
log.Printf("startup: storage ready — %s", cfg.Storage.RecordingsPath)
} else {
log.Printf("startup: storage NOT ready (%s) — starting anyway; recording will retry", st.Message)
}
// Start snapshot engine for all enabled cameras.
snaps := NewSnapshotEngine(cfg.Storage)
snaps.StartAll(cfg.Cameras)

View file

@ -107,10 +107,6 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
}
outDir := filepath.Join(rm.config.RecordingsPath, camDirName)
if err := os.MkdirAll(outDir, 0755); err != nil {
log.Printf("recorder: %s — mkdir %s failed: %v", cam.ID, outDir, err)
return
}
for {
select {
@ -120,6 +116,20 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
default:
}
// Ensure the output directory exists. Storage may not be mounted yet
// (e.g. a slow boot after a power cut) or may have vanished mid-run —
// retry instead of exiting, so recording resumes automatically once
// storage returns and the mount covers the directory again.
if err := os.MkdirAll(outDir, 0755); err != nil {
log.Printf("recorder: %s — storage not ready at %s: %v — retrying in 15s", cam.ID, outDir, err)
select {
case <-proc.done:
return
case <-time.After(15 * time.Second):
}
continue
}
// Generate filename: rec_{YYYY-MM-DD-HH-MM-SS}.mp4
now := time.Now()
timeStr := now.Format("2006-01-02-15-04-05")

View file

@ -174,3 +174,29 @@ func isMountPoint(path string) bool {
}
return cs.Dev != ps.Dev
}
// waitForStorage blocks until the recordings path exists and is writable, or
// until the timeout elapses. It is meant to run before the writers start: on a
// slow boot (e.g. an fsck on the recordings drive after a power cut) the mount
// can appear tens of seconds late, and without this the recorders would stumble
// against an unmounted, root-owned mountpoint. Returns the final status; if the
// timeout expires the caller may still proceed (live view does not need storage,
// and the recorder retries on its own).
func waitForStorage(path string, timeout time.Duration) StorageStatus {
deadline := time.Now().Add(timeout)
warned := false
for {
st := checkStorage(path, false)
if st.OK {
return st
}
if !warned {
log.Printf("startup: waiting for recordings storage at %s (%s)", path, st.Message)
warned = true
}
if !time.Now().Before(deadline) {
return st
}
time.Sleep(2 * time.Second)
}
}