From fe9266f53fa7cac029ac317a8d892d464620f8d3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 16 Jul 2026 19:31:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(report):=20v0.139.0=20=E2=80=94=20immediat?= =?UTF-8?q?e=20out-of-cycle=20hub=20report=20on=20user=20actions=20(Direct?= =?UTF-8?q?ion=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New report.Trigger (buffered-1 chan + worker; quiet 2s, min spacing 15s, trailing-edge coalescing) generalizes the v0.70.0 geo out-of-band push. One canonical fire closure in main.go; wired: geo save/sync + app deploy/remove/delete (api reportPushNow), escrow recovery-code claim, notification-prefs save, app-email toggle, offsite config + per-app toggle, customer claim (web SetReportTrigger seam, nil-safe, fired only after a successful local commit). 15-min hub-report cycle untouched as the reconciliation backbone; hub.enabled=false stays a strict no-op. Tests: trigger engine (2 red-proofs), seam fires-after-commit-only, nil-seam no-ops. --- CHANGELOG.md | 33 ++++ CONTEXT.md | 20 +- REUSE.md | 4 +- controller/README.md | 2 +- controller/cmd/controller/main.go | 38 ++-- .../api/report_trigger_nilsafe_test.go | 11 ++ controller/internal/api/router.go | 9 + controller/internal/report/trigger.go | 124 ++++++++++++ controller/internal/report/trigger_test.go | 182 ++++++++++++++++++ controller/internal/web/claim.go | 3 + controller/internal/web/escrow_handlers.go | 4 + controller/internal/web/handlers.go | 4 + controller/internal/web/offbox_handlers.go | 2 + .../internal/web/report_trigger_seam_test.go | 56 ++++++ controller/internal/web/server.go | 23 +++ 15 files changed, 501 insertions(+), 14 deletions(-) create mode 100644 controller/internal/api/report_trigger_nilsafe_test.go create mode 100644 controller/internal/report/trigger.go create mode 100644 controller/internal/report/trigger_test.go create mode 100644 controller/internal/web/report_trigger_seam_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a4f472..3c2af9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ ## Changelog +### v0.139.0 — immediate out-of-cycle hub report on user actions (Direction 1) (2026-07-16) + +Viktor's ruling: a user action with hub-side effects must round-trip in seconds, not minutes. One +generic, debounced out-of-cycle report trigger now sits on the proven outbound push channel; the +15-min `hub-report` cycle is untouched and stays the reconciliation backbone. Headline UX win: the +v0.138.0 escrow "megerősítésre vár" card collapses from ~14 min to seconds (the blob is already +uploaded at claim time — the immediate report's ACK hash-match flips `pending→escrowed` via the +unchanged `EscrowAutoConfirmer`). No hub change; no UI copy change ("legfeljebb 15 perc" stays the +honest worst case for a failed immediate push). + +- **`internal/report/trigger.go` (new) `report.Trigger`:** buffered-1 signal channel + single + worker (shape: hub `wgsync/reconciler.go`). Non-blocking `Fire()`; worker = quiet window 2 s + (burst coalescing) → drain → min spacing 15 s → ONE full-report fire. Coalesce-and-eventually- + fire (trailing edge): a burst yields ≤ 1 + ceil(burst/15 s) pushes and the last state always + reaches the hub — deliberately NOT the `internal/sync` refuse-debounce (a refused fire would + lose the update until the next cycle). No retries of its own (the Pusher owns 3×5 s); a fire + error logs one WARN and degrades to the cycle. Exits on context cancel. +- **`cmd/controller/main.go`:** ONE canonical fire closure (`BuildReport` + `Claimed` + + `hubPusher.Push`), constructed only when `hubPusher != nil && cfg.Hub.Enabled`; replaces the + raw per-fire goroutine behind `apiRouter.SetReportPushTrigger` (the v0.70.0 geo seam — kept, + now debounced) and feeds the new `webServer.SetReportTrigger`. +- **`internal/api/router.go`:** deploy / remove / delete endpoints now call the existing + `reportPushNow()` after success (geo save/sync already did) — all backed by the trigger. +- **`internal/web` seam + call sites (`server.go` `SetReportTrigger`/`reportTriggerNow`, + nil-safe, fired only AFTER a successful local commit):** escrow recovery-code claim + (`escrow_handlers.go`), notification-prefs save + app-email toggle (`handlers.go`), offsite + target config + per-app offsite toggle (`offbox_handlers.go`), customer claim completion + (`claim.go`). `hub.enabled: false` → seams stay nil → strict no-op. +- Tests: `internal/report/trigger_test.go` (single-fire exactly-once, Fire() non-blocking, + burst-coalescing ceiling + trailing edge with red-proof, fire-error isolation with red-proof, + prompt cancel exit), `web/report_trigger_seam_test.go` (fires-after-commit-only through the + real offbox toggle handler + nil-seam no-op), `api/report_trigger_nilsafe_test.go`. + ### v0.138.0 — escrow "awaiting hub confirmation" waiting state (2026-07-16) Closes the customer-zero (N100) UX gap: after a completed escrow ceremony the Távoli mentés page kept diff --git a/CONTEXT.md b/CONTEXT.md index f2ae825..e864dbc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,25 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-16 (v0.138.0 — escrow "awaiting hub confirmation" waiting state) +Last updated: 2026-07-16 (v0.139.0 — immediate out-of-cycle hub report, Direction 1) + +> **2026-07-16 — v0.139.0: immediate out-of-cycle hub report on user actions (Direction 1 +> SHIPPED; Direction 2 pending SPIKE-immediate-sync-transport).** Viktor's ruling: user actions +> with hub-side effects round-trip in seconds. New `report.Trigger` (`internal/report/trigger.go`): +> buffered-1 chan + worker, quiet 2 s → drain → min-interval 15 s → ONE full BuildReport+Claimed+ +> Push; trailing-edge coalescing (burst ≤ 1+ceil(burst/15 s) pushes, last state always lands), +> no own retries, failures degrade to the UNTOUCHED 15-min cycle. Generalizes the v0.70.0 geo +> `reportPushNow` seam (raw goroutine in main.go replaced by the debounced trigger). Wired: geo +> save/sync + app deploy/remove/delete (api), and via `web.SetReportTrigger`/`reportTriggerNow` +> (nil-safe, AFTER successful local commit only): escrow recovery-code claim (headline — the +> v0.138.0 "megerősítésre vár" card now collapses in seconds via the unchanged EscrowAutoConfirmer +> ACK hash-match), notification-prefs save, app-email toggle, offsite config + per-app toggle, +> customer claim. `hub.enabled:false` → seams nil → strict no-op. NO hub change, NO UI copy change +> ("legfeljebb 15 perc" stays the honest worst case; post-live-proof a soften to "általában néhány +> másodperc" is a later one-liner). Tests: trigger_test.go (2 red-proofs recorded), +> report_trigger_seam_test.go, report_trigger_nilsafe_test.go. Known pre-existing Windows-only +> test failures (appexport df=0, stacks paperless, web fab pipelines) verified failing on base +> 8f3564c too. > **2026-07-16 — v0.138.0: escrow "awaiting hub confirmation" waiting state.** Fixes the customer-zero > (N100) UX gap: after a completed escrow ceremony the Távoli mentés page kept showing the yellow diff --git a/REUSE.md b/REUSE.md index 677aee2..2083f1b 100644 --- a/REUSE.md +++ b/REUSE.md @@ -127,6 +127,7 @@ | `Notifier.PushEvent` | controller/internal/notify/notifier.go | `(eventType, severity, message, details)` | Hub events | Async goroutine, 3 attempts/3s backoff. NEW event types MUST be added to hub `allowedEventTypes` or POST /event 400s; hub only emails `warning`/`error` from this path | | `Notifier.Notify*` convenience methods | controller/internal/notify/notifier.go | typed wrappers (backup/DB/storage/channel/DR…) | Standard events | Add a typed wrapper rather than raw PushEvent calls | | `report.BuildReport` / `Pusher.Push` | controller/internal/report/builder.go + pusher.go | periodic hub report | Box→hub reporting | ACK carries `config_version` → `ConfigRefresher.Reconcile` | +| `report.Trigger` (`NewTrigger`/`Fire`/`Run`) | controller/internal/report/trigger.go | `Fire()` after a hub-relevant user action | THE out-of-cycle report push (v0.139.0) — fire via `api.Router.reportPushNow` / `web.Server.reportTriggerNow`, both nil-safe | Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the `internal/sync` REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit | | `report.SetPendingLogTails` + `buildLogTailsSection` | controller/internal/report/logtail.go | ACK `log_tail_requests` → next report `log_tails` | THE pull-based ACK-flag pattern (hub asks, controller pushes next cycle) — copy for any new hub→box request | Consume-once drain at BuildReport; failed push re-arms from the hub's still-pending request; NEVER add a hub→controller push channel | | `metrics.FetchContainerLogTail` | controller/internal/metrics/logscanner.go | `(name, tailLines) (string, error)` | Raw per-container `docker logs --tail=N` | 15s timeout; caller caps/redacts (capTailLines) | | `ConfigRefresher.Reconcile` | controller/internal/report/config_refresh.go | `(ackVersion int)` | Pull-based config refresh | Re-pulls controller.yaml (re-merging local_api), then graceful self-restart; first-run = baseline, no restart | @@ -161,7 +162,8 @@ | Settings mutator | controller/internal/settings/settings.go (any Set*/Add*) | Lock → mutate → `s.save()`; getters return copies; never expose internal slices | | Channel-health checker w/ born-down alerting | controller/internal/channelhealth/checker.go | classify → debounce N≥2 → `alerted` flag re-armed on reason change (F2) | | Platform split | controller/internal/system/mounts_linux.go + mounts_other.go | `_linux.go`/`_other.go` twins; other = permissive no-op stubs for dev on Windows | -| Debounced trigger + status | controller/internal/sync/sync.go | `TriggerSync` 30s debounce, `Status()` snapshot struct, post-sync hook fan-out | +| Debounced trigger + status (REFUSE-style — a too-soon fire is refused/lost) | controller/internal/sync/sync.go | `TriggerSync` 30s debounce, `Status()` snapshot struct, post-sync hook fan-out | +| Coalescing trigger (trailing edge — a burst collapses but the LAST state always fires) | controller/internal/report/trigger.go | buffered-1 chan + non-blocking `Fire()` + single worker (quiet window → drain → min-interval → fire once); shape from hub `wgsync/reconciler.go` | | Post-start async verification | controller/internal/stacks/manager.go `logPostStartStatus` | goroutine + sleep, INFO log, never blocks/fails the operation | | Startup wiring order | controller/cmd/controller/main.go | init-only setters (`SetStackProvider` M2 contract: exactly once, before scheduler/HTTP), scheduler registration block | diff --git a/controller/README.md b/controller/README.md index 6d7bd92..55ac2ad 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1778,7 +1778,7 @@ Periodic JSON push (default every 15 min) to the central felhom-hub service: Bearer token authentication, 3-attempt retry with 5-second backoff. Push status tracked via `PushStatus` struct (LastAttempt, LastSuccess, LastError, consecutive failures) — used by the monitoring page and alert system to show Hub connection health. -**Immediate report push on geo change (v0.70.0):** besides the periodic cycle, a successful geo settings save and a successful manual geo sync fire an **out-of-band, non-blocking** report push (`Router.reportPushNow`, wired in `main.go` to `BuildReport`+`Push` in a goroutine), so the Hub reflects the new geo state / clears a stale `last_sync_error` within seconds instead of after the next ~15-min cycle. Currently scoped to the geo handlers; the same seam can be reused for other settings later. +**Immediate out-of-cycle report on user actions (v0.139.0, generalizing the v0.70.0 geo push):** besides the periodic cycle, user actions with hub-side effects fire a **debounced, coalescing out-of-cycle report push** (`report.Trigger` in `internal/report/trigger.go`: buffered-1 signal channel + single worker; quiet window 2 s, min spacing 15 s, trailing-edge — a burst coalesces to ≤ 1 + ceil(burst/15 s) pushes and the LAST state always reaches the Hub). One canonical fire closure in `main.go` does the full `BuildReport`+`Claimed`+`Push`; the trigger adds NO retry of its own (the Pusher owns retries) and every failure degrades to the 15-min cycle, which stays the reconciliation backbone. Wired call sites: geo settings save/manual sync + app deploy/remove/delete (`api.Router.reportPushNow`), and via the `web.Server.SetReportTrigger` seam (`reportTriggerNow`, fired only AFTER a successful local commit): escrow recovery-code claim (the ACK hash-match flips pending→escrowed in seconds), notification-prefs save, app-email toggle, offsite target config + per-app offsite toggle, customer claim completion. `hub.enabled: false` → the seams stay nil (strict no-op). #### Config apply + self-restart (`internal/api/router.go`, `internal/api/selfrestart.go`) diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 3bf9481..dfaae73 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -795,20 +795,30 @@ func main() { alertMgr.Refresh(report, cfg, backupMgr, false, "") }() + // --- Out-of-cycle report trigger (v0.139.0, Direction 1) --- + // ONE canonical fire closure (full BuildReport + Claimed + Push) behind a debounced, + // coalescing trigger — user actions with hub-side effects (geo, escrow claim, settings + // save, offsite toggle, app deploy/remove, customer claim) round-trip in seconds instead + // of the next ~15-min cycle. The scheduled hub-report job above stays the reconciliation + // backbone; the trigger is best-effort on top (its failures degrade to the cycle). + // nil when hub reporting is off → the api/web seams stay unset (strict no-op). + var reportTrigger *report.Trigger + if hubPusher != nil && cfg.Hub.Enabled { + fireReport := func() error { + rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) + rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub + return hubPusher.Push(rep) + } + reportTrigger = report.NewTrigger(fireReport, logger) + go reportTrigger.Run(ctx) + } + // --- Initialize API router --- apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger) - if hubPusher != nil { - // Out-of-band, non-blocking hub report push (e.g. after a geo settings change) so + if reportTrigger != nil { + // Out-of-cycle, non-blocking hub report push (geo changes + app deploy/remove) so // the hub reflects the new state immediately instead of after the next ~15-min cycle. - apiRouter.SetReportPushTrigger(func() { - go func() { - rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) - rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub - if err := hubPusher.Push(rep); err != nil { - logger.Printf("[WARN] [report] Out-of-band geo report push failed: %v", err) - } - }() - }) + apiRouter.SetReportPushTrigger(reportTrigger.Fire) } if assetsSyncer != nil { apiRouter.SetAssetsSyncer(assetsSyncer) @@ -883,6 +893,12 @@ func main() { webServer.SetEscrowStale(escrowConfirmer.StaleBlob) } webServer.SetIntegrationManager(integrationMgr) + if reportTrigger != nil { + // Out-of-cycle report push after hub-relevant user actions (escrow claim, settings + // save, offsite config/toggle, customer claim) — same debounced trigger as the api + // router's; nil (hub reporting off) leaves the seam a strict no-op. + webServer.SetReportTrigger(reportTrigger.Fire) + } if quiesceLoop != nil { webServer.SetBackupTrigger(quiesceLoop) // "Mentés most" → app-consistent backup via the quiesce loop } diff --git a/controller/internal/api/report_trigger_nilsafe_test.go b/controller/internal/api/report_trigger_nilsafe_test.go new file mode 100644 index 0000000..d0bdbd7 --- /dev/null +++ b/controller/internal/api/report_trigger_nilsafe_test.go @@ -0,0 +1,11 @@ +package api + +import "testing" + +// Group D (Scenario D, v0.139.0): with the seam unset (hub reporting disabled → main.go +// never calls SetReportPushTrigger), reportPushNow is a strict nil-safe no-op — the +// deploy/remove/geo handlers calling it must never panic. +func TestReportPushNow_NilSeamIsNoOp(t *testing.T) { + r := &Router{} + r.reportPushNow() // must not panic with triggerReportPush == nil +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 3e4544a..ddb30b7 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -476,6 +476,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri go r.OnGeoRelevantChange() } + // v0.139.0: the hub sees the deploy in seconds (debounced trigger, not per-request) + r.reportPushNow() + // Re-apply integrations that target this newly deployed stack if r.integrationMgr != nil { go r.integrationMgr.OnStackStart(context.Background(), name) @@ -774,6 +777,9 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri if r.OnGeoRelevantChange != nil { go r.OnGeoRelevantChange() } + + // v0.139.0: the hub sees the removal in seconds (debounced trigger, not per-request) + r.reportPushNow() } func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) { @@ -805,6 +811,9 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"}) + + // v0.139.0: the hub sees the delete in seconds (debounced trigger, not per-request) + r.reportPushNow() } func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) { diff --git a/controller/internal/report/trigger.go b/controller/internal/report/trigger.go new file mode 100644 index 0000000..724a63e --- /dev/null +++ b/controller/internal/report/trigger.go @@ -0,0 +1,124 @@ +// trigger.go — the generic, debounced out-of-cycle report trigger (v0.139.0, Direction 1). +// +// User actions with hub-side effects (escrow claim, settings save, offsite toggle, app +// deploy/remove, customer claim) fire this trigger so the hub sees the new state in seconds +// instead of after the next ~15-min hub-report cycle. The scheduled cycle stays the +// reconciliation backbone — the trigger is best-effort on top, and every failure degrades +// to the cycle (no retry loop of its own; the Pusher owns retries). +// +// Semantics: COALESCE AND EVENTUALLY FIRE (trailing edge). A burst of fires collapses into +// at most 1 + ceil(burst/minInterval) pushes, and the LAST push always happens after the +// last fire — never a refused/lost update (the internal/sync refuse-debounce is the wrong +// shape here on purpose). Shape copied from felhom.eu/hub/internal/wgsync/reconciler.go +// (buffered-1 trigger channel + worker loop). +package report + +import ( + "context" + "log" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/logx" +) + +const ( + // triggerQuietWindow is how long the worker waits after the first fire before pushing, + // so a burst of related saves (multi-field form, wizard steps) coalesces into one report. + triggerQuietWindow = 2 * time.Second + // triggerMinInterval is the minimum spacing between out-of-cycle pushes — the hard + // ceiling against report storms (pushes <= 1 + ceil(burst/minInterval) per burst). + triggerMinInterval = 15 * time.Second +) + +// Trigger coalesces "push a report now" requests into paced calls of an opaque fire func. +// Fire() is safe from any goroutine and never blocks (HTTP handlers call it); the single +// worker (Run) does the waiting and the pushing. Report building stays with the caller — +// the fire closure in main.go does BuildReport + Pusher.Push. +type Trigger struct { + fire func() error + quiet time.Duration + minInterval time.Duration + signal chan struct{} + logger *log.Logger +} + +// NewTrigger builds a Trigger with the production pacing. fire is the full report +// build+push closure; it must be safe to call repeatedly from one goroutine. +func NewTrigger(fire func() error, logger *log.Logger) *Trigger { + return newTriggerWithPacing(fire, triggerQuietWindow, triggerMinInterval, logger) +} + +// newTriggerWithPacing is the test constructor — tests shrink the windows to keep runs fast. +func newTriggerWithPacing(fire func() error, quiet, minInterval time.Duration, logger *log.Logger) *Trigger { + return &Trigger{ + fire: fire, + quiet: quiet, + minInterval: minInterval, + signal: make(chan struct{}, 1), + logger: logger, + } +} + +// Fire requests an out-of-cycle report push. Non-blocking: a pending signal already covers +// this request (the eventual push carries the FULL current state either way). +func (t *Trigger) Fire() { + select { + case t.signal <- struct{}{}: + default: + } +} + +// Run is the worker loop; main.go starts it under the process context. Per signal: +// wait the quiet window (coalescing the burst), drain, enforce minInterval spacing since +// the last push, drain again, then fire ONCE. A fire error is logged and the loop +// continues — the scheduled cycle reconciles. Exits on context cancel (a pending fire may +// be dropped then; the cycle covers it). +func (t *Trigger) Run(ctx context.Context) { + var lastPush time.Time + for { + select { + case <-ctx.Done(): + return + case <-t.signal: + } + // Quiet window: let the burst finish, then collapse it into one push. + if !t.sleep(ctx, t.quiet) { + return + } + t.drain() + // Pacing: never push more often than minInterval. time.Since(zero) is huge, + // so the first push after startup is never delayed. + if remaining := t.minInterval - time.Since(lastPush); remaining > 0 { + if !t.sleep(ctx, remaining) { + return + } + t.drain() + } + if err := t.fire(); err != nil { + logx.Warnf(t.logger, "[report] out-of-cycle push failed: %v — next cycle reconciles", err) + } + lastPush = time.Now() + } +} + +// drain clears a pending signal that arrived during a wait — those requests are covered +// by the push about to happen (full state, not deltas). +func (t *Trigger) drain() { + select { + case <-t.signal: + default: + } +} + +// sleep waits d or until ctx is cancelled; false = cancelled (caller returns promptly, +// never blocking shutdown on a pending wait). +func (t *Trigger) sleep(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/controller/internal/report/trigger_test.go b/controller/internal/report/trigger_test.go new file mode 100644 index 0000000..bea5b0a --- /dev/null +++ b/controller/internal/report/trigger_test.go @@ -0,0 +1,182 @@ +package report + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +// fireRecorder is the fake fire func: counts calls, records their times, and returns a +// settable error — the trigger tests never touch HTTP (felhom-testing doctrine). +type fireRecorder struct { + mu sync.Mutex + calls []time.Time + err error +} + +func (f *fireRecorder) fire() error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, time.Now()) + return f.err +} + +func (f *fireRecorder) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.calls) +} + +func (f *fireRecorder) lastCall() time.Time { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.calls) == 0 { + return time.Time{} + } + return f.calls[len(f.calls)-1] +} + +func (f *fireRecorder) setErr(err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.err = err +} + +// startTrigger runs tr.Run under a test-scoped context and returns a done channel that +// closes when the worker exits. +func startTrigger(t *testing.T, tr *Trigger) chan struct{} { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + done := make(chan struct{}) + go func() { + tr.Run(ctx) + close(done) + }() + return done +} + +// waitForCount polls until the recorder reaches want calls or the deadline passes. +func waitForCount(t *testing.T, rec *fireRecorder, want int, within time.Duration) { + t.Helper() + deadline := time.Now().Add(within) + for time.Now().Before(deadline) { + if rec.count() >= want { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("fire count = %d, want >= %d within %s", rec.count(), want, within) +} + +// Group A (Scenario A): a single fire produces exactly ONE push, within quiet + ε. +func TestTrigger_SingleFireExactlyOnePush(t *testing.T) { + rec := &fireRecorder{} + tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil) + startTrigger(t, tr) + + tr.Fire() + waitForCount(t, rec, 1, 2*time.Second) + + // Exactly one: no second push may appear (a duplicate would double-report for nothing). + time.Sleep(600 * time.Millisecond) // > quiet + minInterval + if got := rec.count(); got != 1 { + t.Fatalf("single Fire produced %d pushes, want exactly 1", got) + } +} + +// Group A: Fire() is non-blocking even when nothing consumes the signal (the worker is +// mid-sleep in production) — the buffered-1 channel's default branch returns immediately. +func TestTrigger_FireNonBlocking(t *testing.T) { + rec := &fireRecorder{} + tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil) + // Deliberately NOT started: the signal buffer fills after one Fire, so every + // subsequent call exercises the "worker not listening" path. + for i := 0; i < 100; i++ { + start := time.Now() + tr.Fire() + if elapsed := time.Since(start); elapsed > time.Millisecond { + t.Fatalf("Fire() call %d took %s, want < 1ms (must never block a handler)", i, elapsed) + } + } + if rec.count() != 0 { + t.Fatalf("Fire without a running worker pushed %d times, want 0", rec.count()) + } +} + +// Group B (Scenario B): a 10-fire burst coalesces under the hard ceiling +// (1 + ceil(burst/minInterval) = 2 for this pacing) and the LAST push happens after the +// last fire (trailing edge — the final action's state reaches the hub, never lost). +// RED-PROOF (recorded in REPORT.md): deliver every signal straight to fire (naive +// `for { <-signal; fire() }` loop) → this test fails with ~10 calls. +func TestTrigger_BurstCoalescesTrailingEdge(t *testing.T) { + rec := &fireRecorder{} + // quiet 30ms, minInterval 400ms; burst spans ~45ms → ceiling = 1 + ceil(45/400) = 2. + tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil) + startTrigger(t, tr) + + var lastFire time.Time + for i := 0; i < 10; i++ { + tr.Fire() + lastFire = time.Now() + time.Sleep(5 * time.Millisecond) + } + + // Let the burst fully settle: quiet + minInterval + generous margin. + waitForCount(t, rec, 1, 2*time.Second) + time.Sleep(700 * time.Millisecond) + + got := rec.count() + if got < 1 || got > 2 { + t.Fatalf("10-fire burst produced %d pushes, want 1..2 (ceiling = 1 + ceil(burst/minInterval))", got) + } + if last := rec.lastCall(); !last.After(lastFire) { + t.Fatalf("last push at %s is not after the last fire at %s — trailing edge lost (10th action's state would wait for the 15-min cycle)", last.Format(time.RFC3339Nano), lastFire.Format(time.RFC3339Nano)) + } +} + +// Group C (Scenario C): a fire error is isolated — the worker keeps running and a +// SUBSEQUENT fire still pushes (the failed state is reconciled by the next cycle, the +// trigger itself adds no retry). +// RED-PROOF (recorded in REPORT.md): make Run return on fire error → the second +// waitForCount here fails (no push ever comes). +func TestTrigger_FireErrorWorkerContinues(t *testing.T) { + rec := &fireRecorder{err: errors.New("hub unreachable")} + tr := newTriggerWithPacing(rec.fire, 20*time.Millisecond, 50*time.Millisecond, nil) + startTrigger(t, tr) + + tr.Fire() + waitForCount(t, rec, 1, 2*time.Second) // the failing push was attempted + + rec.setErr(nil) // "hub back up" + tr.Fire() + waitForCount(t, rec, 2, 2*time.Second) // the worker survived the error and pushed again +} + +// §8: the worker exits promptly on context cancel, even while mid-wait (a pending fire +// may be dropped — the scheduled cycle covers it; shutdown never hangs on the trigger). +func TestTrigger_CancelDuringWaitExitsPromptly(t *testing.T) { + rec := &fireRecorder{} + tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil) // waits would block ~forever + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + tr.Run(ctx) + close(done) + }() + + tr.Fire() // worker enters the hour-long quiet sleep + time.Sleep(20 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Run did not exit within 1s of context cancel") + } + if rec.count() != 0 { + t.Fatalf("cancelled mid-quiet-window but fired %d times, want 0", rec.count()) + } +} diff --git a/controller/internal/web/claim.go b/controller/internal/web/claim.go index 032fe08..d4ac4c7 100644 --- a/controller/internal/web/claim.go +++ b/controller/internal/web/claim.go @@ -349,6 +349,9 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) { if err := s.settings.SetClaimed(); err != nil { s.logger.Printf("[WARN] [web] claim: marking claimed failed: %v", err) } + // v0.139.0: report out-of-cycle so the hub's Claimed flag (and claim-code consumption) + // flips in seconds — the operator sees the customer claim land immediately. + s.reportTriggerNow() s.claimClearFailures(ip) s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist diff --git a/controller/internal/web/escrow_handlers.go b/controller/internal/web/escrow_handlers.go index 6516b16..585d1fc 100644 --- a/controller/internal/web/escrow_handlers.go +++ b/controller/internal/web/escrow_handlers.go @@ -284,6 +284,10 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) { }); err != nil { s.logger.Printf("[WARN] [web] escrow claim: ceremony timestamp not persisted: %v", err) } + // v0.139.0: the blob is already uploaded at claim time — an immediate report lets the + // hub's ACK hash-match flip pending→escrowed in seconds (EscrowAutoConfirmer, unchanged) + // instead of after the next ~15-min cycle. Non-blocking; a failed push degrades to the cycle. + s.reportTriggerNow() escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "") code = "" // drop the reference promptly (GC caveat: best-effort) _ = code diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index bdfb847..f76e457 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1491,6 +1491,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req } s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents) + s.reportTriggerNow() // v0.139.0: hub reflects the saved prefs in seconds, not next cycle // Sync preferences to hub data := s.notificationsPageData() @@ -1521,6 +1522,9 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request) s.executeTemplate(w, r, "settings_notifications", data) return } + // v0.139.0: the toggle is committed (the shim reconcile below is runtime state, not the + // setting) — report out-of-cycle so the hub sees it in seconds. + s.reportTriggerNow() // Reconcile the shim's running state with the new toggle. if s.mailShim != nil { if err := s.mailShim.Apply(enabled); err != nil { diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index ca4f720..7e8b17f 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -112,6 +112,7 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) { return } s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState) + s.reportTriggerNow() // v0.139.0: offsite enable/disable reaches the hub in seconds offboxRedirect(w, r, "A távoli mentési cél elmentve."+stageErr, stageErr != "") } @@ -193,6 +194,7 @@ func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) { offboxRedirect(w, r, "A beállítás mentése sikertelen.", true) return } + s.reportTriggerNow() // v0.139.0: per-app offsite toggle reaches the hub in seconds offboxRedirect(w, r, "A távoli mentés beállítása frissítve.", false) } diff --git a/controller/internal/web/report_trigger_seam_test.go b/controller/internal/web/report_trigger_seam_test.go new file mode 100644 index 0000000..564d0a1 --- /dev/null +++ b/controller/internal/web/report_trigger_seam_test.go @@ -0,0 +1,56 @@ +package web + +import ( + "io" + "log" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Group D (Scenario D): with the seam unset (hub reporting off / nil trigger), the helper +// is a strict nil-safe no-op — a handler calling it must never panic. +func TestReportTriggerNow_NilSeamIsNoOp(t *testing.T) { + s := &Server{} + s.reportTriggerNow() // must not panic with reportTriggerFn == nil +} + +// The wired seam fires on a SUCCESSFUL local commit and does NOT fire on the error path +// (§Part 3: after the successful local commit — never before, never on error). Exercised +// through a real handler (offbox per-app toggle) so deleting the call site fails this test. +func TestReportTriggerNow_FiresAfterSuccessfulCommitOnly(t *testing.T) { + tmp := t.TempDir() + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + fired := 0 + s := &Server{settings: sett, logger: lg} + s.SetReportTrigger(func() { fired++ }) + + // Error path: missing app param → refused, trigger must NOT fire. + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/backup/offbox/toggle", strings.NewReader(url.Values{"enabled": {"on"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.offboxToggleHandler(w, req) + if fired != 0 { + t.Fatalf("trigger fired %d time(s) on the refused save, want 0 (never on the error path)", fired) + } + + // Success path: the setting commits → the trigger fires exactly once. + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/backup/offbox/toggle", strings.NewReader(url.Values{"app": {"immich"}, "enabled": {"on"}}.Encode())) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.offboxToggleHandler(w2, req2) + if !sett.IsAppOffbox("immich") { + t.Fatal("toggle did not commit — precondition for the trigger assertion") + } + if fired != 1 { + t.Fatalf("trigger fired %d time(s) after the successful save, want exactly 1", fired) + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index e934ef9..73667ea 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -71,6 +71,13 @@ type Server struct { // Hub push status callback — set via SetHubPushStatus for monitoring page hubPushStatusFn func() HubPushStatusData + // Out-of-cycle hub report trigger (v0.139.0, Direction 1) — set via SetReportTrigger to + // report.Trigger.Fire. Fired via reportTriggerNow() AFTER a successful hub-relevant local + // commit (escrow claim, notification/app-email save, offsite config/toggle, customer + // claim) so the hub reflects the new state in seconds instead of the next ~15-min cycle. + // nil (hub reporting off / tests) = strict no-op. + reportTriggerFn func() + // Fork-4 hygiene seam: wipes the agent-staged offsite repo password when EscrowState flips to // escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject. wipeStagedEscrowFn func(ctx context.Context) error @@ -235,6 +242,22 @@ func (s *Server) SetAssetsSyncer(as *assets.Syncer) { s.assetsSyncer = as } +// SetReportTrigger wires the out-of-cycle hub report trigger (report.Trigger.Fire). The +// provided func MUST be non-blocking — it is called from request handlers (the trigger's +// worker does the waiting/pushing). Init-time only, like every Set* here. +func (s *Server) SetReportTrigger(fn func()) { + s.reportTriggerFn = fn +} + +// reportTriggerNow fires the out-of-cycle report trigger if wired (mirrors +// api.Router.reportPushNow). Call AFTER a successful hub-relevant local commit — never +// before it, never on an error path. Nil-safe no-op when hub reporting is off. +func (s *Server) reportTriggerNow() { + if s.reportTriggerFn != nil { + s.reportTriggerFn() + } +} + // SetIntegrationManager sets the app-to-app integration manager. func (s *Server) SetIntegrationManager(mgr *integrations.Manager) { s.integrationMgr.Store(mgr)