v0.151.0 — the Megosztás page stops reloading, and says how to connect

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.
This commit is contained in:
2026-07-20 10:46:21 +02:00
parent 8db9232dea
commit badf17bebd
13 changed files with 663 additions and 14 deletions
@@ -92,6 +92,41 @@ func (s *sambaEnsureState) snapshot() *sambaEnsureJob {
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).
//