Skip to content

Commit 4f7ffe0

Browse files
cansofgreaseclaude
andcommitted
Fix the tooltips, the speedtest engine's bookkeeping, and the recovery paths
The settings tooltips said several things the daemon does not do - a wrong retry floor, a power button that supposedly stopped manual actions, colour and logging switches described backwards - and the overgrown ones buried what decides a toggle. All corrected and cut to size: the Best of 3 tooltip was a forty-line essay in a 250-pixel bubble, most of it a scoring formula the README already documents. Warnings were added where decisions get made (retries at 0 also disable the slow-uplink rescue; the packet-loss probe's traffic is not counted in the data-used figure), and unbacked advice was replaced with measured numbers. The server picker judged health with a cheap read check a server can pass while refusing every actual upload; our fleet survey found about one server in seven refuses them, and when such a server is nearest it won every selection and the speed history sat empty forever. A finished run's own evidence now feeds back: a run where the server answered every upload and accepted none marks it unusable for 12 hours. What does not count is deliberate - a starved slow uplink, a redirect, overload answers (429, 502, 503, 504), any accepted upload, and any run we cancelled ourselves. The verdict holds its ground: a health probe already in flight when the run convicted the server cannot land late and quietly overwrite it - expiry is the only readmission - and when several DIFFERENT servers get convicted in quick succession, the log says what that pattern means: a firewall or proxy on the operator's own network is probably filtering upload POSTs. Uploads themselves are recognized by what they are - the engine's only POSTs - so no server's choice of endpoint path can blind that evidence chain. And a server answering the endpoint probe with a relative redirect (the ordinary nginx shape) is followed to its working endpoint instead of blacklisted for a perfectly legal header. A speedtest that measured its download and lost only its upload used to be thrown away whole - and on uplinks under roughly 3 Mbps the upload always fails, so those users had a permanently empty chart. Such a run is now stored as a partial, like the iperf3 engine has always done: download, ping and jitter land on the chart, the upload shows as unmeasured rather than a fake zero, and the failed upload's traffic is still billed through the accounting channel that cannot masquerade as a measurement. The boundaries hold everywhere: an upload-only test still fails outright, a cancelled run is recorded as cancelled, the packet-loss probe is skipped (it would measure through the failed upload's leftovers), and in a best-of round a partial winner's blank upload stays blank instead of inheriting other servers' bytes. And a direction the run tried and LOST now counts as a breach of any minimum configured on it, in both engines - the alternative judged such runs healthy, silencing the upload alert with the very failure it watches and wiping an in-progress alert streak at the moment the uplink fell off the cliff. A speedtest row's timestamp is its identity everywhere - deleting a run, merging a backup, linking chart to table - but the accounting rows could land on an occupied second, where deleting one row silently un-billed another and a restore quietly dropped one of the two. Every insert now takes the first free second, in one atomic statement protected by the same busy-wait as every ordinary write: the first version used a read-then-write transaction, the exact shape this database already broke on once, and under the daemon's own probe traffic it dropped a COMPLETED measurement within a handful of inserts on a real file-backed database (the in-memory test databases run a single connection and could never see it; a file-backed test now hammers exactly that). The spend row is also written only after the measurement it references, so a crash can no longer leave a permanent orphan billing bytes for a run that never landed. The outage repair could corrupt a neighbouring outage's record. It paired outages only two minutes into the future while its own design notes work to a 48-hour horizon, and its correction grabbed the first recovery row after an outage began without checking whether a different outage started in between - given a complete future pair from a fast clock, it rewrote that pair's recovery as the old outage's and left a permanently open outage that never happened. The pairing now reaches the full 48 hours, corrections are made by row identity so nothing can be seized, and a future-dated recovery whose proving samples are pruned in the same pass is moved back to the proven second - while one whose samples survive stays exactly where the operator's history put it. The documented lockout recovery for containers - restart with PINGULARITY_ACCESS=network - only worked when the boot's first settings read succeeded. The settings controller now runs the access sequence after every successful load through a single hook, so the retry loop after a transient database fault, the reload signal, and a settings import or backup restore all apply the operator's explicit choice; the reload signal no longer bails on the harmless legacy-password warning, one shared rule defines a usable load for every path, and the first-load flag is read under the writer lock so two racing first loads cannot both claim to be first. Nothing changes for installs without the explicit flag. And whether Quick Setup has been answered is decided from the loaded settings before any database read: a passing disk fault at boot used to put a long-since-answered install back on the first-run hold, pausing monitoring and announcing a first run to an operator who answered it months ago. Answered installs also stop paying a database read on every probe round's hold check. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e3778ad commit 4f7ffe0

24 files changed

Lines changed: 2209 additions & 186 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -677,8 +677,10 @@ switching engines switches which pair is in force. On Ookla, retries are also wh
677677
let a very slow uplink finish at all: when parallel upload streams are too slow for
678678
any of them to complete inside the capture window, the retry falls back to a single
679679
stream. Set Ookla's retries to `0` and that fallback cannot run, so on a link that
680-
slow the upload - and with it the whole run - fails. The error says so and names the
681-
setting.
680+
slow the upload always fails and records nothing. The run's download half is kept
681+
either way - a "both" run that loses only its upload stores its download, ping and
682+
jitter as a partial result, with the upload shown as unmeasured (the same contract
683+
iperf3 has always had) - and the warning in the log says why and names the setting.
682684
683685
That UDP pass needs the iperf3 port open for **UDP as well as TCP** - the same
684686
port, both protocols (`ufw allow 5201/tcp` and `ufw allow 5201/udp`, or the

internal/settings/quicksetup_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"errors"
66
"strconv"
7+
"sync"
8+
"sync/atomic"
79
"testing"
810
"time"
911

@@ -499,3 +501,41 @@ func TestQuickSetupOfferSinceErr(t *testing.T) {
499501
t.Error("a store read error must be surfaced, not masked as 0 (the hold must fail-safe)")
500502
}
501503
}
504+
505+
// TestConcurrentFirstLoadsFireOneFirstLoadHook: wasLoaded is read under wmu
506+
// inside reload, so of N concurrent Reloads racing an unloaded controller,
507+
// exactly ONE observes the unloaded->loaded transition. Sampled outside the
508+
// lock, several could - each firing the boot-shaped ambiguity warning and a
509+
// store-wide read the firstLoad gate exists to avoid.
510+
func TestConcurrentFirstLoadsFireOneFirstLoadHook(t *testing.T) {
511+
ctx := context.Background()
512+
st, err0 := store.Open(":memory:")
513+
if err0 != nil {
514+
t.Fatal(err0)
515+
}
516+
t.Cleanup(func() { st.Close() })
517+
dead, cancel := context.WithCancel(ctx)
518+
cancel()
519+
c, err := New(dead, st, Values{Latency: 5 * time.Second, Speed: time.Hour, Timeout: 2 * time.Second, DownAfter: 3, UpAfter: 2})
520+
if err == nil {
521+
t.Fatal("fixture: initial load must fail")
522+
}
523+
var firstLoads atomic.Int64
524+
c.OnLoaded(func(firstLoad bool) {
525+
if firstLoad {
526+
firstLoads.Add(1)
527+
}
528+
})
529+
var wg sync.WaitGroup
530+
for i := 0; i < 8; i++ {
531+
wg.Add(1)
532+
go func() {
533+
defer wg.Done()
534+
_ = c.Reload(ctx)
535+
}()
536+
}
537+
wg.Wait()
538+
if got := firstLoads.Load(); got != 1 {
539+
t.Fatalf("%d hooks fired with firstLoad=true across 8 concurrent reloads, want exactly 1", got)
540+
}
541+
}

internal/settings/settings.go

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,15 @@ type Controller struct {
385385
// persisting the blank, so an unrelated form save can't erase a still-recoverable
386386
// password. Rebuilt each load; set under wmu in New/Reload, read under wmu in mutate.
387387
sealedByAddr map[string]string
388+
// onLoaded, when set, is called after every load that leaves the controller
389+
// usable (Reload returning nil or ErrLegacyReseal), OUTSIDE the controller's
390+
// locks, with firstLoad reporting whether this load took the controller from
391+
// unloaded to loaded. It exists so the process can hang behavior off "settings
392+
// (re)loaded" without every call site of Reload - the boot retry loop, the
393+
// reload signal, the web import - having to remember to invoke it: a forgotten
394+
// site is how an explicit access override once silently stopped applying.
395+
// Set once, before any concurrent Reload can fire.
396+
onLoaded atomic.Pointer[func(firstLoad bool)]
388397
// initErr records that the initial settings read failed, so the controller is
389398
// running on defaults rather than stored config. Writes are refused (see
390399
// mutate) to avoid clobbering the stored config with defaults, and the web
@@ -671,6 +680,17 @@ func New(ctx context.Context, st *store.Store, def Values, opts ...Option) (*Con
671680
return c, nil
672681
}
673682

683+
// LoadedOK is the ONE definition of "this load left the controller usable":
684+
// a clean load, or ErrLegacyReseal - settings fully live, only the legacy
685+
// iperf3 password re-encryption failed. The boot path, the retry loop, the
686+
// reload signal, the web import and the controller's own post-load hook all
687+
// judge loads with it; scattered inline copies once disagreed, and the reload
688+
// signal silently skipped the whole post-load sequence on exactly the
689+
// installs where nothing else would run it.
690+
func LoadedOK(err error) bool {
691+
return err == nil || errors.Is(err, ErrLegacyReseal)
692+
}
693+
674694
// ErrLegacyReseal marks a settings load that SUCCEEDED but could not rewrite
675695
// legacy plaintext iperf3 passwords in their sealed form. Settings are in
676696
// effect; only the at-rest re-encryption is pending.
@@ -897,11 +917,33 @@ func overlay(v Values, m map[string]string) Values {
897917
// writer lock so a concurrent setter can't slip between the DB read and the
898918
// broadcast and have its change stomped by stale data.
899919
func (c *Controller) Reload(ctx context.Context) error {
920+
// wasLoaded comes from reload itself, read under wmu: sampled out here,
921+
// two concurrent first loads (the boot retry loop racing a SIGHUP or a
922+
// web import) both saw unloaded and both fired the hook with
923+
// firstLoad=true - a duplicate boot-shaped warning and a duplicate
924+
// store-wide read, the exact cost gating on firstLoad exists to avoid.
925+
wasLoaded, err := c.reload(ctx)
926+
if LoadedOK(err) {
927+
// Fire the post-load hook with every lock released: the hook may write
928+
// settings (the access override does), and a write takes wmu.
929+
if fn := c.onLoaded.Load(); fn != nil {
930+
(*fn)(!wasLoaded)
931+
}
932+
}
933+
return err
934+
}
935+
936+
// OnLoaded registers the post-load hook (see the field). Call before any
937+
// concurrent Reload is possible.
938+
func (c *Controller) OnLoaded(fn func(firstLoad bool)) { c.onLoaded.Store(&fn) }
939+
940+
func (c *Controller) reload(ctx context.Context) (wasLoaded bool, err error) {
900941
c.wmu.Lock()
901942
defer c.wmu.Unlock()
943+
wasLoaded = c.Loaded() // under wmu: serialized against every other load
902944
m, err := c.store.AllSettings(ctx)
903945
if err != nil {
904-
return err
946+
return wasLoaded, err
905947
}
906948
// Reload is the recovery path's first successful read when New's failed (the
907949
// settings-retry loop, SIGHUP): the fresh install it just recovered starts
@@ -929,15 +971,15 @@ func (c *Controller) Reload(ctx context.Context) error {
929971
// Sealing failed: don't rewrite the imported passwords in the clear. The
930972
// config is already live in memory (broadcast above); report the same
931973
// distinguishable ErrLegacyReseal a failed write would.
932-
return fmt.Errorf("%w: %v", ErrLegacyReseal, err)
974+
return wasLoaded, fmt.Errorf("%w: %v", ErrLegacyReseal, err)
933975
}
934976
if _, err := c.store.SetSettingsDiff(ctx, map[string]string{
935977
keyIperfServers: sealed,
936978
}); err != nil {
937-
return fmt.Errorf("%w: %v", ErrLegacyReseal, err)
979+
return wasLoaded, fmt.Errorf("%w: %v", ErrLegacyReseal, err)
938980
}
939981
}
940-
return nil
982+
return wasLoaded, nil
941983
}
942984

943985
// Getters (each safe for concurrent use).

internal/speedtest/iperf.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,11 @@ func (i *Iperf) Run(ctx context.Context) (Result, error) {
691691
// rate instead of being invisible.
692692
if dir == "both" && (dnErr != nil || upErr != nil) {
693693
stats.Inc("speed.iperf_partial")
694+
// Recorded on the Result so a configured threshold on the FAILED
695+
// direction reads as a breach instead of being silenced by the
696+
// very failure it watches (see Result.UploadFailed).
697+
res.DownloadFailed = dnErr != nil
698+
res.UploadFailed = upErr != nil
694699
if i.Log != nil {
695700
if dnErr != nil {
696701
i.Log.Warn("iperf3 direction failed, partial result kept", "direction", "down", "err", i.withEnvHint(dnErr))

0 commit comments

Comments
 (0)