badf17bebd
S-1: /sharing/status coerced idle->running on the PHASE channel, so the first poll of every steady-state page load reported a terminal job that never ran and the client's repaint-reload fired ~1.2s apart, forever. The coercion's real duty (liveness must never be contradicted) belongs to the 'running' LEVEL field beside it, and is now pinned by its own regression test. S-4 core: a terminal 'running' is served exactly once, so a REAL bring-up cannot re-arm the reload on the page it just caused. failed/needs_password/in-flight are never consumed. Unified async-job feedback stays the ROADMAP item. S-2/S-5: new connect card with the Windows form, the Mac form and the direct smb://<IP>, read from the SAMBA container's netns (the controller is on a docker bridge and would answer 172.x). Derived per render, cached nowhere - the address is a DHCP lease. Underivable => the line is omitted. sharing.html's <script> block is byte-identical to v0.150.0. Red-proofed three ways. 23/23 packages green.
192 lines
7.4 KiB
Go
192 lines
7.4 KiB
Go
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, "")
|
|
}
|