Skip to content

Commit 3922060

Browse files
authored
catalogue: refresh pins on a miss (newly-pinned apps spawn without a daemon restart) (#328)
1 parent 17d773f commit 3922060

2 files changed

Lines changed: 122 additions & 3 deletions

File tree

internal/catalogue/catalogue.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,27 +146,69 @@ type Provider struct {
146146

147147
mu sync.RWMutex
148148
pins map[string]string
149+
150+
// refresh-on-miss: an installed app with no pin is likely one that was
151+
// pinned in the catalogue after our last periodic refresh. Rather than make
152+
// it wait for the 10-minute tick (and be refused meanwhile), a pin miss
153+
// triggers a rate-limited background refresh so the next supervisor scan
154+
// can spawn it.
155+
refreshMu sync.Mutex
156+
lastRefresh time.Time
157+
refreshInFlight bool
158+
minRefreshGap time.Duration
149159
}
150160

161+
// missRefreshGap bounds how often a pin miss may trigger a catalogue refetch, so
162+
// a genuinely unpinned app can't cause a refresh storm.
163+
const missRefreshGap = 30 * time.Second
164+
151165
// NewProvider builds a Provider for the catalogue at url, caching the last
152166
// verified pin set at cachePath (empty disables the cache).
153167
func NewProvider(url, cachePath string) *Provider {
154-
return &Provider{url: url, cachePath: cachePath, pins: map[string]string{}}
168+
return &Provider{url: url, cachePath: cachePath, pins: map[string]string{}, minRefreshGap: missRefreshGap}
155169
}
156170

157171
// Publisher implements appstore.Config.CataloguePublisher: it returns the
158-
// catalogue-pinned publisher for appID and whether appID is pinned.
172+
// catalogue-pinned publisher for appID and whether appID is pinned. On a miss it
173+
// kicks off a rate-limited background refresh (see refreshOnMiss), so an app
174+
// that was pinned since the last periodic refresh becomes spawnable within a
175+
// scan cycle instead of after the next 10-minute tick.
159176
func (p *Provider) Publisher(appID string) (string, bool) {
160177
p.mu.RLock()
161-
defer p.mu.RUnlock()
162178
pub, ok := p.pins[appID]
179+
p.mu.RUnlock()
180+
if !ok {
181+
p.refreshOnMiss()
182+
}
163183
return pub, ok
164184
}
165185

186+
// refreshOnMiss launches a single background Refresh if one isn't already
187+
// running and the last refresh is older than minRefreshGap. Non-blocking: the
188+
// caller (a supervisor scan) never waits on the network.
189+
func (p *Provider) refreshOnMiss() {
190+
p.refreshMu.Lock()
191+
if p.refreshInFlight || time.Since(p.lastRefresh) < p.minRefreshGap {
192+
p.refreshMu.Unlock()
193+
return
194+
}
195+
p.refreshInFlight = true
196+
p.refreshMu.Unlock()
197+
go func() {
198+
_ = p.Refresh()
199+
p.refreshMu.Lock()
200+
p.refreshInFlight = false
201+
p.refreshMu.Unlock()
202+
}()
203+
}
204+
166205
// Refresh fetches + verifies the catalogue and atomically swaps in the new pin
167206
// set. On success it also writes the disk cache. On failure the previous pins
168207
// are kept (so a transient outage doesn't suddenly fail-close running apps).
169208
func (p *Provider) Refresh() error {
209+
p.refreshMu.Lock()
210+
p.lastRefresh = time.Now()
211+
p.refreshMu.Unlock()
170212
pins, err := LoadPublishers(p.url)
171213
if err != nil {
172214
return err

internal/catalogue/catalogue_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"path/filepath"
77
"testing"
8+
"time"
89

910
"github.com/pilot-protocol/pilotprotocol/internal/catalogtrust"
1011
)
@@ -113,6 +114,82 @@ func TestProvider_RefreshPublisherAndCache(t *testing.T) {
113114
}
114115
}
115116

117+
func TestProvider_RefreshOnMissPicksUpNewlyPinnedApp(t *testing.T) {
118+
dir := t.TempDir()
119+
cat := filepath.Join(dir, "catalogue.json")
120+
url := "file://" + cat
121+
122+
// writeSigned writes the catalogue body + a fresh valid .sig and returns a
123+
// restore for the ephemeral key swap.
124+
writeSigned := func(body string) func() {
125+
if err := os.WriteFile(cat, []byte(body), 0o600); err != nil {
126+
t.Fatal(err)
127+
}
128+
sig, restore := catalogtrust.SignWithEphemeralKey([]byte(body))
129+
if err := os.WriteFile(cat+".sig", []byte(base64.StdEncoding.EncodeToString(sig)), 0o600); err != nil {
130+
t.Fatal(err)
131+
}
132+
return restore
133+
}
134+
135+
const onlyA = `{"version":2,"apps":[{"id":"io.test.a","publisher":"ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI="}]}`
136+
const aAndB = `{"version":2,"apps":[
137+
{"id":"io.test.a","publisher":"ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI="},
138+
{"id":"io.test.b","publisher":"ed25519:VF8fdEP/Oe2aWN3ozQ7Ar22137tHb7dkSw0hlzlk/os="}]}`
139+
140+
restore1 := writeSigned(onlyA)
141+
142+
p := NewProvider(url, "")
143+
p.minRefreshGap = 0 // allow a miss to refresh immediately (no cooldown in the test)
144+
if err := p.Refresh(); err != nil {
145+
t.Fatalf("initial Refresh: %v", err)
146+
}
147+
// Assert B is not pinned yet by reading the map directly — using Publisher here
148+
// would kick off a background refresh that races the re-sign below (the test's
149+
// signing key is process-global; production never swaps it at runtime).
150+
p.mu.RLock()
151+
_, hasB := p.pins["io.test.b"]
152+
p.mu.RUnlock()
153+
if hasB {
154+
t.Fatal("io.test.b must not be pinned before it is added to the catalogue")
155+
}
156+
restore1() // no refresh in flight here; safe to restore the key
157+
158+
// Pin B in the catalogue (re-signed). A running daemon would not see it until
159+
// the next periodic refresh — but a Publisher miss should refetch. From here on
160+
// this is the only signing key live, so background refreshes don't race it.
161+
restore2 := writeSigned(aAndB)
162+
defer restore2()
163+
164+
p.Publisher("io.test.b") // miss → triggers background refresh
165+
got := false
166+
for i := 0; i < 200; i++ {
167+
if _, ok := p.Publisher("io.test.b"); ok {
168+
got = true
169+
break
170+
}
171+
time.Sleep(5 * time.Millisecond)
172+
}
173+
if !got {
174+
t.Fatal("refresh-on-miss did not pick up the newly-pinned app within 1s")
175+
}
176+
177+
// Drain: stop new refreshes and wait for any in-flight one to finish before the
178+
// deferred restore swaps the global signing key (else the swap races the read).
179+
p.refreshMu.Lock()
180+
p.minRefreshGap = time.Hour
181+
p.refreshMu.Unlock()
182+
for i := 0; i < 200; i++ {
183+
p.refreshMu.Lock()
184+
inFlight := p.refreshInFlight
185+
p.refreshMu.Unlock()
186+
if !inFlight {
187+
break
188+
}
189+
time.Sleep(5 * time.Millisecond)
190+
}
191+
}
192+
116193
func TestProvider_NilCacheAndFailClosed(t *testing.T) {
117194
// A provider that has never loaded anything reports nothing pinned.
118195
p := NewProvider("file:///nonexistent", "")

0 commit comments

Comments
 (0)