fix(stream): report stream failures via StreamError + retry transient stat

Stream-request failures previously reported status:"failed", which the web
treated as a download failure — it left the task unstreamable and surfaced a
misleading 20s timeout. Report them through a dedicated StreamError field
instead, so the web clears the stream flag and shows the real reason without
touching the download status.

- StatusUpdate gains StreamError (json: streamError)
- OnStreamRequested reports failures via a reportStreamError helper (path
  rejected, file not found, no video in dir) instead of status:"failed"
- os.Stat is retried 3× (300ms) before giving up — NFS can transiently fail
  (ESTALE/EAGAIN/timeout), the root of the intermittent "works on the 3rd try"
- dispatch OnStreamRequested off the sync loop (goroutine): it does blocking
  I/O (stat retries, ffprobe in SetFile) that would otherwise stall task
  dispatch + status reporting for other items
This commit is contained in:
Deivid Soto 2026-06-02 20:31:49 +02:00
parent f7ea06c70a
commit 2b5a45674a
3 changed files with 44 additions and 31 deletions

View file

@ -246,8 +246,12 @@ func (d *Daemon) Run(ctx context.Context) error {
}
}
d.sync.OnStreamRequest = func(req StreamRequest) {
// Off the sync loop: the handler does blocking I/O (os.Stat retries on
// NFS, then ffprobe in SetFile) — running it inline would stall task
// dispatch + status reporting for other items. The single-stream model
// (atomic SetFile swap, last-wins) tolerates concurrent requests.
if d.OnStreamRequested != nil {
d.OnStreamRequested(req)
go d.OnStreamRequested(req)
}
}
d.sync.OnStreamSession = func(sess StreamSession) {

View file

@ -134,6 +134,11 @@ type StatusUpdate struct {
StreamURL string `json:"streamUrl,omitempty"`
StreamReady bool `json:"streamReady,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
// StreamError reports a failed /stream attempt (path rejected, transient
// FS error, etc.) WITHOUT marking the download itself failed — the web
// clears streamRequested + surfaces this so the player fails fast with the
// real reason instead of a 20s "agent didn't respond" timeout.
StreamError string `json:"streamError,omitempty"`
// mode=seed_file: agent computes the info_hash from the local file
// and reports it back so the web player can target /stream/<hash>.
InfoHash string `json:"infoHash,omitempty"`