package web import ( "sync" "time" ) // Samba bring-up progress (v0.147.0, feedback slice 4b). // // THE PROBLEM: enabling Megosztás on a fresh box ran `ReconcileSamba()` synchronously inside the // POST handler. On a golden that had not baked felhom-samba, that call is `docker compose up -d` // pulling ~100MB from a private registry — minutes of an apparently-hung form post, then a redirect // with a flash reading „Beállítás mentve." whether or not anything had actually come up. Observed // live, twice. The image is now baked (felhom-agent build-golden.sh, golden >= 0.147.x), but this // card still covers the pre-0.147 goldens and every future image update. // // SHAPE: deliberately the storage-init / netstorage-add one (storage_init_job.go) — detached job, // single-flight slot, deep-copied snapshot, phase strings the template maps to Hungarian. No new // framework; a unified async-job feedback layer is a ROADMAP item, not this slice. // // State is in-memory and lost on restart, exactly like RestoreOpStatus. That is acceptable here: the // terminal truth is „is the container running", which the page re-reads from the stack manager on // every load anyway — the job only explains the WAIT. type sambaEnsureJob struct { Phase string `json:"phase"` Error string `json:"error,omitempty"` StartedAt time.Time `json:"started_at"` UpdatedAt time.Time `json:"updated_at"` } const ( // sambaPhasePulling — the pinned image is NOT in local Docker storage, so compose will fetch it. // This is the phase worth naming: it is the multi-minute one, and the only honest explanation for // why nothing appears to happen. sambaPhasePulling = "pulling" // sambaPhaseStarting — image already local (baked golden / previously pulled): seconds. sambaPhaseStarting = "starting" // sambaPhaseRunning — terminal success, PROBED (compose up -d exits 0 on a crash-loop, so the // job's success condition is container liveness, never the compose exit code). sambaPhaseRunning = "running" // sambaPhaseNeedsPassword — not a failure: sharing is on but the household password is unset, so // reconcile deliberately deploys nothing. The card must say so instead of spinning forever. sambaPhaseNeedsPassword = "needs_password" sambaPhaseFailed = "failed" sambaPhaseIdle = "idle" ) // A pull that has not finished in 15 minutes is not slow, it is broken (the registry is unreachable // or the disk is full) — end the job so the card can say so rather than spin indefinitely. const sambaEnsureDeadline = 15 * time.Minute type sambaEnsureState struct { mu sync.Mutex running bool cur *sambaEnsureJob } func (s *sambaEnsureState) acquire(job *sambaEnsureJob) bool { s.mu.Lock() defer s.mu.Unlock() if s.running { return false } s.running = true cp := *job s.cur = &cp return true } func (s *sambaEnsureState) release() { s.mu.Lock() s.running = false s.mu.Unlock() } func (s *sambaEnsureState) set(job *sambaEnsureJob) { s.mu.Lock() cp := *job s.cur = &cp s.mu.Unlock() } // snapshot returns a copy of the last / in-flight job (nil = never ran this process). func (s *sambaEnsureState) snapshot() *sambaEnsureJob { s.mu.Lock() defer s.mu.Unlock() if s.cur == nil { return nil } cp := *s.cur return &cp } // consumeIfRunning is snapshot() with SERVE-ONCE semantics for the one phase the client reads as an // edge rather than a level. // // `running` means "the bring-up you were watching has finished" — the client answers it by repainting // the page (a full reload, because the „Állapot" badge is server-rendered). A phase that stays // `running` in the snapshot therefore re-arms that reload on every subsequent page load: the loop // S-1 fixed by dropping the idle→running coercion would come straight back after the next REAL // bring-up, since the finished job outlives it in memory (S-4 core, // felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md). Reporting it exactly once is what // makes the edge an edge. // // Consumed: terminal `running`, and only while the single-flight slot is free — the job goroutine // sets the phase before its deferred release(), and eating it inside that window would lose the // success the customer is waiting for. // // NOT consumed: `failed` and `needs_password` (durable explanations — the client stops the timer and // shows a card, with no reload, so stickiness is informative and cannot loop) and every in-flight // phase (`pulling`/`starting`, which must survive being polled). // // Accepted cost: with two tabs open on /sharing during a bring-up, whichever polls first gets the // success banner and the other sees plain `idle`. Both then show the true state — `running` is still // on the level channel and the badge re-renders from the liveness probe either way. func (s *sambaEnsureState) consumeIfRunning() *sambaEnsureJob { s.mu.Lock() defer s.mu.Unlock() if s.cur == nil { return nil } cp := *s.cur if !s.running && cp.Phase == sambaPhaseRunning { s.cur = nil } return &cp } // startSambaEnsure claims the single-flight slot and launches the detached reconcile. false = one is // already in flight (a double-submit must not start a second compose up on the same stack dir). // // The opening phase is decided BEFORE the work starts, by asking whether the image is already local // — afterwards the answer is always yes and the card could never truthfully say „letöltés". func (s *Server) startSambaEnsure() bool { now := time.Now().UTC() phase := sambaPhaseStarting if s.stackMgr != nil && !s.stackMgr.SambaImagePresent() { phase = sambaPhasePulling } job := &sambaEnsureJob{Phase: phase, StartedAt: now, UpdatedAt: now} if !s.sambaEnsure.acquire(job) { return false } go s.runSambaEnsureJob(job) return true } func (s *Server) runSambaEnsureJob(job *sambaEnsureJob) { defer s.sambaEnsure.release() advance := func(phase, errMsg string) { job.Phase = phase job.Error = errMsg job.UpdatedAt = time.Now().UTC() s.sambaEnsure.set(job) } done := make(chan error, 1) go func() { done <- s.stackMgr.ReconcileSamba() }() select { case err := <-done: if err != nil { s.logger.Printf("[ERROR] [sharing] samba reconcile failed after %s: %v", time.Since(job.StartedAt).Round(time.Second), err) advance(sambaPhaseFailed, err.Error()) return } case <-time.After(sambaEnsureDeadline): // The reconcile goroutine is left running — compose owns its own lifecycle and killing it // mid-pull would leave a partial layer set. We stop REPORTING on it, which is the honest // thing the customer needs; a later page load re-probes liveness for the real answer. s.logger.Printf("[ERROR] [sharing] samba reconcile still running after %s — giving up on the progress card", sambaEnsureDeadline) advance(sambaPhaseFailed, "a megosztási szolgáltatás előkészítése túl sokáig tartott") return } // Reconcile returned nil — but nil ALSO covers "deliberately did nothing". Distinguish the two, // because a card that says „fut" while nothing is deployed is the same silence in a new costume. if s.settings != nil && !s.settings.GetSMBSettings().UserSet { advance(sambaPhaseNeedsPassword, "") return } if !s.stackMgr.SambaRunning() { s.logger.Printf("[ERROR] [sharing] samba reconcile reported success but the container is not running") advance(sambaPhaseFailed, "a megosztási szolgáltatás nem indult el") return } s.logger.Printf("[INFO] [sharing] samba ready after %s", time.Since(job.StartedAt).Round(time.Second)) advance(sambaPhaseRunning, "") }