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).
//
+4
View File
@@ -96,6 +96,10 @@ type Server struct {
storageInit storageInitState
// sambaEnsure is the Megosztás bring-up progress slot (v0.147.0, 4b) — same single-flight shape.
sambaEnsure sambaEnsureState
// sambaAddrFn is the connect-address seam (v0.151.0, S-2/S-5): the guest's LAN IPv4 for the
// „Csatlakozás a megosztáshoz" card, or "" when it cannot be read. nil → stackMgr.SambaLANAddress.
// Called PER RENDER and stored nowhere — the address is a DHCP lease (see sambaLANAddress).
sambaAddrFn func() string
netAgentFn func() (netAgent, error)
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
fabUpload uploadState
@@ -0,0 +1,150 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// v0.151.0 — the „Csatlakozás a megosztáshoz" card (DIAG-sharing-2026-07-20.md, S-2 + S-5).
//
// The page used to show only the configured NetBIOS name. When a network fails to resolve it the
// customer has no fallback and guesses an address; the guess that produced the diagnosis was the
// Proxmox HOST's IP, which never ran smbd. So the card must name both ways in — and the direct
// address must be re-derived on every render, because the guest holds it by DHCP.
// connectCardServer renders /sharing with sharing enabled, the given server name, and a counted
// address seam. Returns the page body and a pointer to the derivation call count.
func connectCardServer(t *testing.T, serverName, addr string) (*Server, *int) {
t.Helper()
s := testPageServer(t)
if err := s.settings.SetSMBServerName(serverName); err != nil {
t.Fatal(err)
}
if err := s.settings.SetSMBEnabled(true); err != nil {
t.Fatal(err)
}
calls := 0
s.sambaAddrFn = func() string { calls++; return addr }
return s, &calls
}
// Scenario D — both name forms and the direct address are on the page, and the address came from a
// derivation performed on THIS render.
func TestSharingConnectCard_ShowsNameAndDirectAddress(t *testing.T) {
s, calls := connectCardServer(t, "FELHOM", "192.0.2.10")
body := getPage(t, s, "/sharing").Body.String()
for _, want := range []string{
"Csatlakoz", // the card heading (ASCII-safe substring)
`\\FELHOM`, // Windows form
"smb://FELHOM", // Mac form
"smb://192.0.2.10", // the fallback that did not exist before
} {
if !strings.Contains(body, want) {
t.Errorf("page missing %q", want)
}
}
if *calls == 0 {
t.Error("address seam never called — the page cannot be showing a freshly derived address")
}
}
// S-5: derived per render, never memoized. A DHCP lease that is read once and reused is an address
// that eventually sends customers to someone else's machine.
func TestSharingConnectCard_AddressDerivedFreshEveryRender(t *testing.T) {
s, calls := connectCardServer(t, "FELHOM", "192.0.2.10")
getPage(t, s, "/sharing")
afterFirst := *calls
if afterFirst != 1 {
t.Fatalf("first render: %d derivations, want exactly 1", afterFirst)
}
getPage(t, s, "/sharing")
if *calls != 2 {
t.Errorf("second render: total derivations = %d, want 2 — the address is being cached", *calls)
}
// And a CHANGED address must reach the page, which a cache would prevent.
s.sambaAddrFn = func() string { *calls++; return "198.51.100.7" }
body := getPage(t, s, "/sharing").Body.String()
if !strings.Contains(body, "smb://198.51.100.7") {
t.Error("the new address did not render — a stale value survived the re-derivation")
}
if strings.Contains(body, "192.0.2.10") {
t.Error("the OLD address is still on the page")
}
}
// Fail-quiet: no derivable address omits the fallback lines but keeps the name lines. An
// address-less page is strictly better than a page that fails, or one that prints a wrong address.
func TestSharingConnectCard_NoAddressOmitsTheLine(t *testing.T) {
s, _ := connectCardServer(t, "FELHOM", "")
body := getPage(t, s, "/sharing").Body.String()
if !strings.Contains(body, "smb://FELHOM") {
t.Error("name lines must remain when no address can be derived")
}
if strings.Contains(body, "smb://\n") || strings.Contains(body, "smb://<") || strings.Contains(body, "smb://</span>") {
t.Error("an empty direct-address line rendered")
}
// The „változhat" caveat belongs to the address lines and must go with them.
if strings.Contains(body, "zvetlen c") { // „közvetlen cím", ASCII-safe substring
t.Error("the direct-address block rendered without an address")
}
}
// The name is CONFIGURABLE — nothing may hardcode FELHOM.
func TestSharingConnectCard_UsesConfiguredName(t *testing.T) {
s, _ := connectCardServer(t, "OTTHON", "192.0.2.10")
body := getPage(t, s, "/sharing").Body.String()
if !strings.Contains(body, "smb://OTTHON") || !strings.Contains(body, `\\OTTHON`) {
t.Error("the configured server name is not on the page")
}
if strings.Contains(body, "FELHOM") {
t.Error("FELHOM rendered while the configured name is OTTHON — a hardcoded default leaked")
}
}
// The whole card is gated on the feature being on: a box with sharing off must not be told how to
// connect to a service that is not running.
func TestSharingConnectCard_AbsentWhenSharingDisabled(t *testing.T) {
s := testPageServer(t)
if err := s.settings.SetSMBServerName("FELHOM"); err != nil {
t.Fatal(err)
}
calls := 0
s.sambaAddrFn = func() string { calls++; return "192.0.2.10" }
body := getPage(t, s, "/sharing").Body.String()
if strings.Contains(body, "Csatlakoz") {
t.Error("the connect card rendered with sharing disabled")
}
if strings.Contains(body, "smb://192.0.2.10") {
t.Error("a direct address rendered with sharing disabled")
}
if calls != 0 {
t.Errorf("address derived %d times with sharing off — a needless docker exec per render", calls)
}
}
// Guard against the settings layer being bypassed: the page data reads the EFFECTIVE name, so an
// unset name still renders the product default rather than an empty `smb://`.
func TestSharingConnectCard_UnsetNameFallsBackToEffective(t *testing.T) {
s := testPageServer(t)
if err := s.settings.SetSMBEnabled(true); err != nil {
t.Fatal(err)
}
s.sambaAddrFn = func() string { return "192.0.2.10" }
def := settings.SMBSettings{}
want := def.EffectiveServerName()
if want == "" {
t.Skip("no product default server name to assert")
}
if body := getPage(t, s, "/sharing").Body.String(); !strings.Contains(body, "smb://"+want) {
t.Errorf("effective default name %q not rendered", want)
}
}
+39 -9
View File
@@ -122,7 +122,17 @@ func (s *Server) sharingPageData() map[string]interface{} {
data["SMBEnabled"] = smb.Enabled
data["SMBServerName"] = smb.EffectiveServerName()
data["SMBUserSet"] = smb.UserSet
data["SMBRunning"] = s.stackMgr.SambaRunning()
data["SMBRunning"] = s.stackMgr != nil && s.stackMgr.SambaRunning()
// „Csatlakozás a megosztáshoz" (v0.151.0, S-2): the page has always shown the configured NAME and
// never an address, so a customer whose network does not resolve the name had nothing to fall
// back on and guessed — which is how this diagnosis started, with the Proxmox HOST's IP typed
// into Finder (DIAG-sharing-2026-07-20.md). Derived FRESH on every render and cached nowhere:
// the guest holds this address by DHCP, so a stored copy is a copy that eventually misdirects
// people (S-5). "" simply omits the line — an address-less page beats a wrong address.
if smb.Enabled {
data["SMBDirectAddress"] = s.sambaLANAddress()
}
type shareRow struct {
Name string
@@ -177,6 +187,18 @@ func (s *Server) sharingPageData() map[string]interface{} {
return data
}
// sambaLANAddress resolves the connect-address seam. Never cached at this level either — the seam
// exists so tests can supply an address without docker, not so anyone can memoize one.
func (s *Server) sambaLANAddress() string {
if s.sambaAddrFn != nil {
return s.sambaAddrFn()
}
if s.stackMgr == nil {
return ""
}
return s.stackMgr.SambaLANAddress()
}
func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
data := s.sharingPageData()
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
@@ -231,20 +253,28 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
sharingRedirect(w, r, "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…")
}
// sharingStatusHandler is the 4b poll target (GET /sharing/status). Reports the ensure job's phase
// plus the live container state, so a page loaded AFTER the job finished (or after a restart, when
// the in-memory job is gone) still shows the truth.
// sharingStatusHandler is the 4b poll target (GET /sharing/status). It carries TWO independent
// channels in one envelope, and keeping them apart is the whole point of this handler:
//
// phase — the ensure JOB. The client treats a terminal `running` as an EDGE ("the bring-up I was
// watching just succeeded") and reloads once to repaint the server-rendered badge.
// running — the service LEVEL, straight from the liveness probe. True whenever the container is up,
// with or without a job, and it is what makes a page loaded after the job finished (or
// after a controller restart, when the in-memory job is gone) still show the truth.
//
// v0.147.0 coerced `idle` → `running` here so that liveness could never be contradicted by a missing
// job. That duty belongs to — and was already discharged by — the `running` field beside it; on the
// phase channel the same value reads as a fresh terminal edge, so the client reloaded on the FIRST
// poll of every steady-state page load and the page looped at ~1.2s forever
// (felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md, S-1). The coercion is gone: no job,
// no edge. See consumeIfRunning for the other half — a REAL bring-up must be reported exactly once.
func (s *Server) sharingStatusHandler(w http.ResponseWriter, r *http.Request) {
phase := sambaPhaseIdle
errMsg := ""
if job := s.sambaEnsure.snapshot(); job != nil {
if job := s.sambaEnsure.consumeIfRunning(); job != nil {
phase, errMsg = job.Phase, job.Error
}
running := s.stackMgr != nil && s.stackMgr.SambaRunning()
// A stale `idle`/`running` job must never contradict reality: liveness wins on a fresh page.
if phase == sambaPhaseIdle && running {
phase = sambaPhaseRunning
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
"phase": phase, "error": errMsg, "running": running,
})
@@ -0,0 +1,186 @@
package web
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// v0.151.0 — the /sharing/status CONTRACT (DIAG-sharing-2026-07-20.md, S-1 + S-4 core).
//
// The endpoint carries two channels that mean different things to the client:
//
// phase — the ensure JOB, and `running` on it is an EDGE the client answers with location.reload()
// running — the service LEVEL, true whenever the container is up
//
// v0.147.0 coerced idle→running on the PHASE channel so liveness could never be contradicted. Every
// steady-state page load then reported a terminal edge on its first poll and reloaded — forever,
// ~1.2s apart, for every customer with sharing enabled. These tests pin the separation from both
// sides: a live container must NOT manufacture a phase (A), and a real bring-up must be reported
// exactly ONCE (B) while the non-reloading phases stay put (C).
// statusServer returns a Server whose liveness probe answers `running` and whose ensure slot starts
// empty — the steady state of every box that has had sharing on for more than a moment.
func statusServer(t *testing.T, running bool) *Server {
t.Helper()
s := testPageServer(t)
s.stackMgr.SetSambaRunProbe(func() bool { return running })
return s
}
func getStatus(t *testing.T, s *Server) (phase string, isRunning bool) {
t.Helper()
rec := httptest.NewRecorder()
s.sharingStatusHandler(rec, httptest.NewRequest(http.MethodGet, "/sharing/status", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status: got HTTP %d, want 200", rec.Code)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Phase string `json:"phase"`
Error string `json:"error"`
Running bool `json:"running"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
if !env.OK {
t.Fatalf("envelope not ok: %s", rec.Body.String())
}
return env.Data.Phase, env.Data.Running
}
// Scenario A — a live container alone must never produce an edge. THE loop test: with the v0.147.0
// coercion in place this fails on the very first call, which is exactly when the customer's page
// reloaded.
func TestSharingStatus_SteadyStateProducesNoEdge(t *testing.T) {
s := statusServer(t, true)
if job := s.sambaEnsure.snapshot(); job != nil {
t.Fatalf("precondition: ensure slot must be empty, got phase %q", job.Phase)
}
for i := 1; i <= 2; i++ {
phase, running := getStatus(t, s)
if phase != sambaPhaseIdle {
t.Errorf("call %d: phase = %q, want %q — a live container must not manufacture a job edge (this IS the reload loop)", i, phase, sambaPhaseIdle)
}
if !running {
t.Errorf("call %d: running = false, want true", i)
}
}
}
// The b5d78d1 REGRESSION test. The coercion was defensive, not a fix for a named repro: its comment
// read „a stale idle/running job must never contradict reality: liveness wins on a fresh page". That
// duty is real and it survives — discharged by the `running` field, which is where a LEVEL belongs.
// This is the test that would catch someone deleting the coercion's intent along with its code.
func TestSharingStatus_LivenessStillReportedWithoutAnyJob(t *testing.T) {
for _, alive := range []bool{true, false} {
s := statusServer(t, alive)
phase, running := getStatus(t, s)
if running != alive {
t.Errorf("SambaRunning()=%v: running = %v, want %v — liveness must reach the client with no job in memory", alive, running, alive)
}
if phase != sambaPhaseIdle {
t.Errorf("SambaRunning()=%v: phase = %q, want %q", alive, phase, sambaPhaseIdle)
}
}
}
// Scenario B — a REAL bring-up: one edge, then silence. Without serve-once the loop returns after
// every future image update, because the finished job outlives the reload it triggered.
func TestSharingStatus_RealBringUpServedExactlyOnce(t *testing.T) {
s := statusServer(t, true)
s.sambaEnsure.set(&sambaEnsureJob{Phase: sambaPhaseRunning})
phase, running := getStatus(t, s)
if phase != sambaPhaseRunning {
t.Fatalf("first call: phase = %q, want %q — the genuine success edge must survive", phase, sambaPhaseRunning)
}
if !running {
t.Error("first call: running = false, want true")
}
// The page the first response caused to reload polls again. It must find nothing.
phase, running = getStatus(t, s)
if phase != sambaPhaseIdle {
t.Errorf("second call: phase = %q, want %q — a re-served success re-arms the reload (post-bring-up loop)", phase, sambaPhaseIdle)
}
if !running {
t.Error("second call: running = false, want true — the LEVEL must outlive the consumed edge")
}
}
// An in-flight job must never be consumed out from under itself: the goroutine sets the terminal
// phase BEFORE its deferred release(), and eating it in that window loses the success the customer
// is waiting on.
func TestSharingStatus_RunningNotConsumedWhileInFlight(t *testing.T) {
s := statusServer(t, true)
if !s.sambaEnsure.acquire(&sambaEnsureJob{Phase: sambaPhaseRunning}) {
t.Fatal("acquire refused")
}
if phase, _ := getStatus(t, s); phase != sambaPhaseRunning {
t.Fatalf("in-flight: phase = %q, want %q", phase, sambaPhaseRunning)
}
if phase, _ := getStatus(t, s); phase != sambaPhaseRunning {
t.Errorf("in-flight second call: phase = %q, want %q — consumed while the slot was still held", phase, sambaPhaseRunning)
}
s.sambaEnsure.release()
if phase, _ := getStatus(t, s); phase != sambaPhaseRunning {
t.Errorf("after release: phase = %q, want %q — the edge must be served once, now", phase, sambaPhaseRunning)
}
if phase, _ := getStatus(t, s); phase != sambaPhaseIdle {
t.Errorf("after release, second call: phase = %q, want %q", phase, sambaPhaseIdle)
}
}
// Scenario C — serve-once applies to `running` ONLY. `failed` and `needs_password` are durable
// explanations whose client path stops the timer and shows a card with NO reload, so stickiness is
// informative and cannot loop; in-flight phases must survive being polled.
func TestSharingStatus_NonEdgePhasesStaySticky(t *testing.T) {
for _, phase := range []string{sambaPhaseFailed, sambaPhaseNeedsPassword, sambaPhasePulling, sambaPhaseStarting} {
s := statusServer(t, true)
s.sambaEnsure.set(&sambaEnsureJob{Phase: phase, Error: "boom"})
for i := 1; i <= 3; i++ {
if got, _ := getStatus(t, s); got != phase {
t.Errorf("%s call %d: phase = %q, want it to persist", phase, i, got)
}
}
}
}
// The JSON contract itself: keys and shape are what sharing.html's untouched poll reads.
func TestSharingStatus_EnvelopeShapeUnchanged(t *testing.T) {
s := statusServer(t, true)
rec := httptest.NewRecorder()
s.sharingStatusHandler(rec, httptest.NewRequest(http.MethodGet, "/sharing/status", nil))
var env struct {
OK bool `json:"ok"`
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
for _, k := range []string{"phase", "error", "running"} {
if _, ok := env.Data[k]; !ok {
t.Errorf("data.%s missing — the client contract changed", k)
}
}
if len(env.Data) != 3 {
t.Errorf("data has %d keys, want exactly 3", len(env.Data))
}
}
// A nil stack manager must not panic the poll (the handler's own liveness guard).
func TestSharingStatus_NilStackManagerIsQuiet(t *testing.T) {
s := &Server{logger: log.New(io.Discard, "", 0), cfg: &config.Config{}}
if phase, running := getStatus(t, s); phase != sambaPhaseIdle || running {
t.Errorf("nil stackMgr: phase=%q running=%v, want idle/false", phase, running)
}
}
@@ -62,6 +62,33 @@
</div>
</div>
{{if .SMBEnabled}}
<!-- v0.151.0 (S-2/S-5): the page names BOTH ways in. Until now it showed only the configured name,
so a customer whose network fails to resolve it had nothing to fall back on but a guess — and
the guess that started the diagnosis was the Proxmox HOST's address, which never ran smbd
(DIAG-sharing-2026-07-20.md). The direct address is derived per render from the samba
container's own netns and is absent whenever it cannot be read: no address beats a wrong one. -->
<div class="settings-card">
<h3>Csatlakozás a megosztáshoz</h3>
<div class="form-hint">
Windows: a Fájlkezelő címsorába: <span class="mono">\\{{.SMBServerName}}</span>
</div>
<div class="form-hint">
Mac: Finder &rarr; Ugrás &rarr; Csatlakozás a szerverhez:
<span class="mono">smb://{{.SMBServerName}}</span>
</div>
{{if .SMBDirectAddress}}
<div class="form-hint">
Ha a név nem működik, használd a közvetlen címet:
<span class="mono">smb://{{.SMBDirectAddress}}</span>
</div>
<div class="form-hint">
A közvetlen cím a hálózattól függően változhat — elsőként mindig a nevet próbáld.
</div>
{{end}}
</div>
{{end}}
<div class="settings-card">
<h3>Megosztási jelszó beállítása</h3>
<p class="settings-card-desc">