Skip to content

Commit dd9b09b

Browse files
cansofgreaseclaude
andcommitted
The first speedtest runs when you start monitoring, not an hour later
Answering Quick Setup with an interval started monitoring instantly - latency, DNS, outage detection all sprang to life - while the speed panel sat empty for a full interval, because the startup test's moment had already passed. The startup run fired three seconds after boot, gated on consent the user had not given yet, and consenting later only re-derived a deadline anchored at boot. The startup run now belongs to the first moment scheduled tests are ENABLED, not to boot. On a configured install those coincide and nothing changes. On a fresh install the slot carries forward: press Start monitoring with a pace chosen and the first test fires ten seconds later, with the next one an interval after it. Choosing Manually (or closing the dialog) still runs nothing, exactly as it says. The same applies to enabling scheduled tests from Settings later. One slot per boot, and a measurement that already happened serves it: if a manual or post-outage test completed - or is still running - when the slot would fire, no second test is stacked onto a link still settling from the first. While the first test is imminent or in flight the reported next-run time is withdrawn rather than left pointing most of an interval away, and a collision with an in-flight run logs the same way a skipped scheduled slot does. Also: the first-run coachmark now tracks the header's real height on phones - the header grows as the status pills fill in and the toolbar wraps, and the card was left sitting on top of the pill rows; it now follows the header down instead of anchoring once. And the README leads with pingularity.dev and the live demo, so you can click around the dashboard before installing anything. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a682ae7 commit dd9b09b

5 files changed

Lines changed: 478 additions & 16 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Pingularity
22

3+
**[pingularity.dev](https://pingularity.dev)** · **[live demo](https://demo.pingularity.dev)**
4+
35
A single-binary internet connectivity monitor with a built-in web dashboard,
46
native speedtests, and a Prometheus `/metrics` endpoint. It continuously checks
57
your connection by pinging several always-on internet landmarks at once and going
@@ -8,6 +10,8 @@ by majority vote (a *quorum* across multiple *anchors*), with anti-flapping
810
latency, uptime, and speed to SQLite and shows it all in a live UI - no runtime
911
to install.
1012

13+
This is the [live demo](https://demo.pingularity.dev) - same dashboard, synthetic data:
14+
1115
![The Pingularity dashboard: top-bar status bubbles, the Connection panel (IP / ISP / DNS / internet exit), a speedtest with bufferbloat, the latency-over-time chart, and a year-long downtime heatmap](docs/dashboard.png)
1216

1317
## Quick start

internal/speedtest/scheduler.go

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,11 @@ func fptr(v *float64) any {
623623
var (
624624
startupDelay = 3 * time.Second
625625
scheduleRecheck = 30 * time.Second
626+
// The pause before the consent-triggered startup run (Loop's startupPending
627+
// latch). Longer than startupDelay on purpose: this one fires while the user
628+
// is looking at a dashboard they just opened for the first time, so the page
629+
// gets a beat to settle before the test saturates the link.
630+
firstEnableDelay = 10 * time.Second
626631
)
627632

628633
// scheduleJitter returns a bounded random offset in [0, min(interval/10, 60s))
@@ -646,13 +651,29 @@ func scheduleJitter(interval time.Duration) time.Duration {
646651
// broadcast, related or not - only re-derives the deadline, never restarts the
647652
// wait. When a run is due but the schedule window is closed, the loop re-checks
648653
// every scheduleRecheck and fires as soon as the window opens.
654+
//
655+
// The startup run belongs to the first moment the scheduler is ENABLED, not to
656+
// boot. On an already-configured install those coincide and it fires at
657+
// boot+startupDelay as it always has. On a fresh install the boot slot is
658+
// skipped - the user hasn't consented to tests yet - and without a carry-over
659+
// the consent moment inherited nothing: answering Quick Setup with "hourly"
660+
// started monitoring instantly but left the speed panel empty for a full
661+
// interval, because the wake only re-derived a deadline anchored at boot. The
662+
// startupPending latch carries the unclaimed slot forward: the first time a
663+
// wake (or deferral re-check) finds the scheduler enabled, the startup run
664+
// fires - after firstEnableDelay, so the dashboard the user just landed on has
665+
// a beat to settle - and the schedule anchors to its end. One slot per boot:
666+
// once claimed, later off/on toggles follow the normal anchor arithmetic,
667+
// which already fires promptly when more than an interval has passed and
668+
// deliberately doesn't re-test when less has.
649669
func (s *Scheduler) Loop(ctx context.Context) {
650670
select {
651671
case <-ctx.Done():
652672
return
653673
case <-time.After(startupDelay):
654674
}
655-
if s.enabled() {
675+
startupPending := !s.enabled()
676+
if !startupPending {
656677
s.RunOnce(ctx, "startup")
657678
}
658679
lastRun := time.Now()
@@ -664,6 +685,59 @@ func (s *Scheduler) Loop(ctx context.Context) {
664685
deferred := false // tracks the running→deferred edge, so the reason logs once
665686

666687
for {
688+
if startupPending && s.enabled() {
689+
// No scheduled deadline is knowable while the startup run is imminent
690+
// or in flight - the published one still says boot+interval, which the
691+
// run about to fire would predate by most of the interval. Withdraw it
692+
// (status omits the field), the same shape the boot startup run has
693+
// while the anchor is still unpublished; the post-run setAnchor below
694+
// restores it.
695+
s.anchor.Store(nil)
696+
select {
697+
case <-ctx.Done():
698+
return
699+
case <-time.After(firstEnableDelay):
700+
}
701+
// A run that completed during the pause (reconnect after a flap,
702+
// manual) IS the first measurement - the slot it was armed for is
703+
// served, and firing the latch anyway would stack a second full test
704+
// onto a link still settling from the first. The nudge only fires for
705+
// persisted runs, so a failed attempt doesn't burn the slot.
706+
select {
707+
case <-s.runWake:
708+
startupPending = false
709+
default:
710+
}
711+
// Re-checked after the pause: a toggle straight back off within it
712+
// means no consent run, and the slot stays available rather than
713+
// being burned on a run that never happened.
714+
if startupPending && s.enabled() {
715+
// An ErrBusy here is a run that STARTED during the pause and is
716+
// still in flight - it serves the slot like the completed-run
717+
// case above, but the slot going unfilled must stay traceable
718+
// (same reasoning as the scheduled collision below): if that run
719+
// fails, this log line is what a gap in the history leads back to.
720+
if _, err := s.RunOnce(ctx, "startup"); errors.Is(err, ErrBusy) {
721+
stats.Inc("speed.scheduled_skipped")
722+
s.log.Info("startup speedtest skipped: another run was already in progress")
723+
}
724+
startupPending = false
725+
lastRun = time.Now()
726+
jitter = scheduleJitter(s.curInterval())
727+
s.setAnchor(lastRun, jitter)
728+
// An overdue-while-gated stretch may have logged the deferred
729+
// edge; this run answers it, so the next real deferral logs anew
730+
// (and the scheduled path doesn't print a stale "resumed").
731+
deferred = false
732+
} else {
733+
// Declined (toggled off mid-pause) or served by a run that
734+
// completed during it: the boot-anchored deadline withdrawn
735+
// above is still the real one - republish it, or status would
736+
// omit the next run for up to a full interval.
737+
s.setAnchor(lastRun, jitter)
738+
}
739+
continue
740+
}
667741
var wake <-chan struct{}
668742
if s.WakeFn != nil {
669743
wake = s.WakeFn()
@@ -734,7 +808,13 @@ func (s *Scheduler) Loop(ctx context.Context) {
734808
// Settings changed; recompute the deadline against the same anchor.
735809
case <-s.runWake:
736810
// A run completed - possibly a reconnect/degraded/manual one on another
737-
// goroutine. If it left us in a breach, count the adaptive cadence from
811+
// goroutine. If the startup slot was still armed (gated boot, e.g. a
812+
// restart outside the schedule window), that measurement serves it:
813+
// without this, the latch would fire a duplicate full test right after
814+
// one that just finished - exactly the back-to-back double test the
815+
// scheduled path's ErrBusy handling was engineered against.
816+
startupPending = false
817+
// If it left us in a breach, count the adaptive cadence from
738818
// that run: the old anchor could otherwise sleep out hours of a base
739819
// interval before the fast cadence engages.
740820
if s.AdaptiveFn != nil && s.AdaptiveFn() && s.lastUnhealthy.Load() {

0 commit comments

Comments
 (0)