v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
gates / gates (push) Successful in 23s

The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 14:19:31 +02:00
parent eb6aa58aa7
commit 48f3336956
142 changed files with 2199 additions and 318 deletions
+29 -19
View File
@@ -93,7 +93,7 @@ var ErrOffsiteQuota = errors.New("offsite quota exceeded")
// of showing the previous run's verdict under a „started" message.
// offboxWholeUnitGap is the pseudo-path used to report a WHOLE-unit gap through the mandatory-gap
// notification, so a skipped app and a skipped directory reach the operator in one vocabulary.
const offboxWholeUnitGap = "(a teljes alkalmazás — nincs helyi mentési egysége)"
const offboxWholeUnitGapKey = "note.offsite.whole_unit_gap"
var ErrOffboxRunInFlight = fmt.Errorf("an off-box backup is already running; this request did not start a new one")
@@ -226,20 +226,24 @@ func (m *Manager) OffsiteFailureMessage(err error, dur time.Duration) string {
if m != nil && m.settings != nil {
t = m.settings.GetOffboxTarget()
}
return offsiteFailureMessage(t, err, dur)
return offsiteFailureMessage(t, err, dur, m.boxLang())
}
func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration) string {
// offsiteFailureMessage builds the note that is SAVED in OffboxTarget.LastError and read on the page
// days later, so it is written in the box's language at write time (release C, R-557 option 1).
// The failure CLASS is still decided by ClassifyOffsiteFailure from a kind, never from these words
// (R-553) — which is why translating them is safe.
func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration, lang string) string {
head := map[OffsiteFailureClass]string{
OffsiteFailQuota: "A távoli mentés nem fért el a tárhelykereten belül",
OffsiteFailOrphaned: "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült",
OffsiteFailNoRepo: "A távoli tárhelyen nincs mentési adattár",
OffsiteFailNoUnits: "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése",
OffsiteFailTransport: "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)",
OffsiteFailUnknown: "A távoli mentés ismeretlen okból nem sikerült",
OffsiteFailQuota: util.Text(lang, "note.offsite.fail_quota"),
OffsiteFailOrphaned: util.Text(lang, "note.offsite.fail_orphaned"),
OffsiteFailNoRepo: util.Text(lang, "note.offsite.fail_no_repo"),
OffsiteFailNoUnits: util.Text(lang, "note.offsite.fail_no_units"),
OffsiteFailTransport: util.Text(lang, "note.offsite.fail_transport"),
OffsiteFailUnknown: util.Text(lang, "note.offsite.fail_unknown"),
}[ClassifyOffsiteFailure(err)]
if head == "" {
head = "A távoli mentés nem sikerült"
head = util.Text(lang, "note.offsite.fail_head")
}
return fmt.Sprintf("%s (%s): %s", head, dur.Round(time.Second), sanitiseOffsiteErrorFor(t, err))
}
@@ -988,6 +992,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
if runErr == nil {
snapshots = m.offboxRecordStats(ctx, base, env)
}
// THE LANGUAGE IS RESOLVED HERE, OUTSIDE THE CALLBACK, AND IT IS NOT A STYLE CHOICE.
//
// UpdateOffboxStatus holds the settings WRITE lock while it runs `fn`, and boxLang() reads the
// language through the settings READ lock. sync.RWMutex is not reentrant, so calling m.note()
// inside this callback DEADLOCKS — and it deadlocks holding the settings lock, which then wedges
// everything else that touches settings.json on that box. Written this way once and caught by the
// suite timing out at 25 minutes; TestNoteHelpersAreNotCalledUnderTheSettingsLock keeps it fixed.
lang := m.boxLang()
if perr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.LastRun = time.Now().UTC().Format(time.RFC3339)
// R-100: LastRun above records the ATTEMPT; this records the RESULT. The hub's staleness
@@ -1047,7 +1059,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
notify = cp
}
for _, a := range runResult.missingUnprotected {
notify[a] = append(notify[a], offboxWholeUnitGap)
notify[a] = append(notify[a], util.Text(lang, offboxWholeUnitGapKey))
}
}
m.offboxGapNotify(notify)
@@ -1074,13 +1086,11 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
// available backup and were left out", which names a problem with no next step and reads
// the same whether the customer must act or simply wait.
if len(runResult.missingUnprotected) > 0 {
warns = append(warns, fmt.Sprintf(
"Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.",
warns = append(warns, util.Text(lang, "note.offsite.no_local_unit",
strings.Join(runResult.missingUnprotected, ", ")))
}
if len(runResult.missingNotDeployed) > 0 {
warns = append(warns, fmt.Sprintf(
"Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.",
warns = append(warns, util.Text(lang, "note.offsite.not_installed",
strings.Join(runResult.missingNotDeployed, ", ")))
}
// 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed).
@@ -1100,14 +1110,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
blockedApps = append(blockedApps, n)
}
if len(blockedApps) > 0 {
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.",
warns = append(warns, util.Text(lang, "note.offsite.quota_partial",
len(blockedApps), strings.Join(blockedApps, ", ")))
}
if sharesBlocked {
warns = append(warns, sharesBlockedWarning())
}
// SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run.
if qw := offboxQuotaWarning(o); qw != "" {
if qw := offboxQuotaWarning(o, lang); qw != "" {
warns = append(warns, qw)
}
o.LastWarning = strings.Join(warns, " ")
@@ -1739,7 +1749,7 @@ func offboxQuotaState(t *settings.OffboxTarget) (usedGB, quotaGB int, over bool)
// offboxQuotaWarning returns the Hungarian ≥80% (<100%) usage notice, or "" (quota unset / usage fine /
// already over — over-quota is the run-refusal error, not a warning).
func offboxQuotaWarning(t *settings.OffboxTarget) string {
func offboxQuotaWarning(t *settings.OffboxTarget, lang string) string {
if t == nil || t.QuotaGB <= 0 || t.RepoSizeBytes <= 0 {
return ""
}
@@ -1748,7 +1758,7 @@ func offboxQuotaWarning(t *settings.OffboxTarget) string {
if pct < 80 || t.RepoSizeBytes >= limit {
return ""
}
return fmt.Sprintf("A távoli mentés a keret %d%%-át használja (%d/%d GB).", pct, t.RepoSizeBytes/offboxGiB, t.QuotaGB)
return util.Text(lang, "note.offsite.quota_usage", int(pct), int(t.RepoSizeBytes/offboxGiB), t.QuotaGB)
}
// OffboxQuotaPercent returns the usage percentage for the /backups usage bar (0 when no quota/size).