Review round 2: a media restart no longer starts an app the household stopped (R-721); the file manager's header name file appears whole (R-1100 race, 44/300 -> 0/300); both red-proved; unreleased
gates / gates (push) Successful in 1m22s

This commit is contained in:
2026-10-24 15:26:56 +02:00
parent 321df1f87a
commit b01bb4b88d
8 changed files with 163 additions and 31 deletions
+24 -10
View File
@@ -64,22 +64,36 @@ func LoadOrCreateFileBrowserSSOHeader(dataDir string) (string, error) {
return "", err
}
h := "X-Felhom-User-" + hex.EncodeToString(rb)
f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
// The name file appears WHOLE or not at all (review round 2, 2026-10-24): it is written to a private temp file
// first and then hard-linked into place — the link is the exclusive create, so a second writer never reads an
// empty, half-written file (measured: 44 of 300 runs of the parallel-writers check read "" with the old
// create-then-write; pinned by TestFileBrowserSSOHeader_R1100_ABoxSecret).
tmp, err := os.CreateTemp(dataDir, fileBrowserSSOHeaderFile+".tmp-*")
if err != nil {
return "", err
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return "", err
}
if _, err := tmp.WriteString(h + "\n"); err != nil {
tmp.Close()
return "", err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return "", err
}
if err := tmp.Close(); err != nil {
return "", err
}
if err := os.Link(tmp.Name(), p); err != nil {
if os.IsExist(err) {
return LoadOrCreateFileBrowserSSOHeader(dataDir)
}
return "", err
}
if _, err := f.WriteString(h + "\n"); err != nil {
f.Close()
_ = os.Remove(p)
return "", err
}
if err := f.Close(); err != nil {
_ = os.Remove(p)
return "", err
}
return h, nil
}
@@ -283,6 +283,21 @@ func (m *Manager) MediaApps() []string {
return out
}
// MediaAppsToRecreate is MediaApps without the apps the household stopped (R-721, „a Stop holds"): the media restart
// and the safe removal recreate with `up -d --force-recreate` (RestartStack), which would START a stopped app. Their
// override is already written for the drives present now, so their next start sees them. `stopped` is returned for the
// caller's log. Found by review round 2 (2026-10-24); pinned by TestMediaAppsToRecreate_SkipsAppsTheHouseholdStopped.
func (m *Manager) MediaAppsToRecreate() (recreate, stopped []string) {
for _, name := range m.MediaApps() {
if m.WantsStopped(name) {
stopped = append(stopped, name)
continue
}
recreate = append(recreate, name)
}
return recreate, stopped
}
// MediaAppsNeedingRestart renders every media app's override for the drives present NOW and returns the apps whose
// binds changed — the apps that must restart to see a drive that was added, removed or came back (the notice of
// decision 238). It writes the files; the restart itself is the household's „restart now" (RestartStack).
@@ -187,3 +187,30 @@ func TestAfterStartField_NotAskedAtInstall(t *testing.T) {
t.Fatalf("the template shape must parse as required + after_start: %+v %v", meta.DeployFields, err)
}
}
// Review round 2 (2026-10-24, DOC-D2 U2): the media restart („Újraindítás most", „Később") and the safe removal
// recreate every media app with `up -d --force-recreate`, which STARTED an app the household had stopped — against
// R-721 („a Stop holds"). COMPANION RED-PROOF: return every MediaApps() entry from MediaAppsToRecreate → the stopped app
// is in `recreate` and this test fails. Restored.
func TestMediaAppsToRecreate_SkipsAppsTheHouseholdStopped(t *testing.T) {
for _, tc := range []struct {
desired string
wantRecreate bool
}{{DesiredStateStopped, false}, {DesiredStateRunning, true}, {"", true}} {
m, _ := newInstalledManager(t, sonarrLike, "deployed: true\nenv: {}\ndesired_state: "+tc.desired+"\n")
dir := filepath.Dir(m.stacks["bookstack"].ComposePath)
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte("media_library:\n read_only: true\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := m.MediaApps(); len(got) != 1 {
t.Fatalf("fixture: want one media app, got %v", got)
}
recreate, stopped := m.MediaAppsToRecreate()
if tc.wantRecreate && (len(recreate) != 1 || len(stopped) != 0) {
t.Fatalf("desired %q: want recreated, got recreate=%v stopped=%v", tc.desired, recreate, stopped)
}
if !tc.wantRecreate && (len(recreate) != 0 || len(stopped) != 1) {
t.Fatalf("desired %q: a stopped app must NOT be recreated (it would start), got recreate=%v stopped=%v", tc.desired, recreate, stopped)
}
}
}
+15 -2
View File
@@ -50,6 +50,19 @@ func (s *Server) mediaApps() []string {
return s.stackMgr.MediaApps()
}
// mediaAppsToRecreate is the list the media restart and the safe removal recreate: every media app except the ones the
// household stopped (R-721) — a recreate would start them; their next start reads the override already written.
func (s *Server) mediaAppsToRecreate(why string) []string {
if s.stackMgr == nil {
return nil
}
recreate, stopped := s.stackMgr.MediaAppsToRecreate()
for _, name := range stopped {
s.logger.Printf("[INFO] [media] %s: NOT recreating %s — the household stopped it; it sees the drives at its next start", why, name)
}
return recreate
}
func (s *Server) presentMediaDrives() []mediawire.Drive {
if s.stackMgr == nil {
return nil
@@ -195,7 +208,7 @@ func (s *Server) MediaNotice(checkStale bool) MediaDriveNotice {
// recreates the file browser, records the restart on every drive, then wires the present drives in.
func (s *Server) restartMediaApps(reason string) []string {
var failed []string
for _, name := range s.mediaApps() {
for _, name := range s.mediaAppsToRecreate("restart (" + reason + ")") {
if s.appHeld(name) {
continue
}
@@ -304,7 +317,7 @@ func (s *Server) mediaSafeRemoval(path, durableID string) []string {
if err := s.settings.SetEjected(path, durableID, paused); err != nil {
s.logger.Printf("[WARN] [media] safe removal %s: recording it: %v", path, err)
}
for _, name := range s.mediaApps() {
for _, name := range s.mediaAppsToRecreate("safe removal " + path) {
if s.appHeld(name) {
continue
}
@@ -0,0 +1,49 @@
package web
import (
"go/ast"
"go/parser"
"go/token"
"os"
"strings"
"testing"
)
// Review round 2 (2026-10-24, DOC-D2 U2): both web paths that recreate the media apps must take their list from
// mediaAppsToRecreate (which leaves out the apps the household stopped, R-721), never from mediaApps(). A source pin:
// the decision itself is tested in stacks (TestMediaAppsToRecreate_SkipsAppsTheHouseholdStopped); this pins the
// WIRING, the half a seam test cannot see. COMPANION RED-PROOF: put `s.mediaApps()` back in either loop → this fails.
func TestR2_MediaRecreateLoopsSkipStoppedApps(t *testing.T) {
src, err := os.ReadFile("media_events.go")
if err != nil {
t.Fatal(err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "media_events.go", src, 0)
if err != nil {
t.Fatal(err)
}
want := map[string]bool{"restartMediaApps": false, "mediaSafeRemoval": false}
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok {
continue
}
if _, tracked := want[fn.Name.Name]; !tracked {
continue
}
body := string(src[fset.Position(fn.Body.Pos()).Offset:fset.Position(fn.Body.End()).Offset])
if strings.Contains(body, "range s.mediaApps()") {
t.Errorf("%s ranges over s.mediaApps(): it would recreate (start) an app the household stopped", fn.Name.Name)
}
if !strings.Contains(body, "s.mediaAppsToRecreate(") || !strings.Contains(body, "RestartStack(") {
t.Errorf("%s no longer recreates from s.mediaAppsToRecreate", fn.Name.Name)
}
want[fn.Name.Name] = true
}
for name, seen := range want {
if !seen {
t.Errorf("function %s not found in media_events.go — the pin is stale", name)
}
}
}