@@ -44,6 +44,7 @@ import (
4444 "path/filepath"
4545 "regexp"
4646 "strings"
47+ "time"
4748)
4849
4950// nextStepsFileName is the per-app cache written at install, read at call.
@@ -371,27 +372,160 @@ func cacheNextSteps(appDir string, g *nextStepsGraph) {
371372 if err != nil {
372373 return
373374 }
374- _ = os .WriteFile (filepath .Join (out , nextStepsFileName ), data , 0o600 ) // #nosec G304 -- confined by resolveUnder
375+ _ = os .WriteFile (filepath .Join (out , nextStepsFileName ), data , 0o600 ) // #nosec G304 G703 -- path re- confined to the install root by resolveUnder above
375376}
376377
377378// fetchNextStepsForInstall pulls the graph out of the catalogue metadata for an
378379// app id. Best-effort and quiet: an app with no catalogue entry (a sideload), no
379380// metadata_url, or an unreachable host simply gets no graph. It is called once
380381// per install, never on the call path.
381382func fetchNextStepsForInstall (appID string ) * nextStepsGraph {
383+ g , _ := fetchNextStepsAuthoritative (appID )
384+ return g
385+ }
386+
387+ // fetchNextStepsAuthoritative fetches the app's graph from the catalogue and
388+ // reports whether the answer is AUTHORITATIVE — i.e. whether the catalogue and
389+ // (if present) the metadata doc were actually reached. It is the distinction
390+ // ensureNextStepsFresh needs: a nil graph with ok=true means "this app has no
391+ // graph, stop asking for a while", whereas ok=false means "we couldn't reach the
392+ // catalogue, try again soon". Collapsing the two (as a bare *graph return does)
393+ // would either re-fetch every call forever or give up on a transient blip.
394+ func fetchNextStepsAuthoritative (appID string ) (graph * nextStepsGraph , ok bool ) {
382395 c , err := loadCatalogue ()
383396 if err != nil {
384- return nil
397+ return nil , false // could not reach/verify the catalogue
385398 }
386399 for i := range c .Apps {
387400 if c .Apps [i ].ID != appID {
388401 continue
389402 }
390403 m , err := loadAppMetadata (c .Apps [i ])
391- if err != nil || m == nil {
392- return nil
404+ if err != nil {
405+ return nil , false // entry found but the detail fetch/verify failed
393406 }
394- return m .NextSteps
407+ if m == nil {
408+ return nil , true // no metadata doc = authoritatively no graph
409+ }
410+ return m .NextSteps , true
411+ }
412+ return nil , true // not in the catalogue (e.g. a sideload) = authoritatively none
413+ }
414+
415+ // ── lazy self-heal ───────────────────────────────────────────────────────────
416+ //
417+ // The graph is cached at install, but the installed FLEET predates most graphs,
418+ // and a republished graph never touches an existing cache. Without healing, the
419+ // feature only reaches apps installed AFTER their graph shipped — a blocker.
420+ //
421+ // ensureNextStepsFresh closes that gap from the call path, off the render:
422+ // - steady state is a single stat of a freshness marker → returns immediately,
423+ // no network (the hot-path-is-local guarantee holds for all but the rare
424+ // re-check);
425+ // - when the TTL has elapsed it spends ONE bounded fetch, so an app installed
426+ // before its graph existed renders on its very next call, and a republished
427+ // graph propagates within the TTL;
428+ // - a fetch that can't reach the catalogue backs off briefly rather than
429+ // re-trying every call.
430+ const (
431+ // nextStepsFreshTTL bounds how often we re-consult the catalogue per app.
432+ // Graphs change rarely; a half-day is plenty fresh and keeps fetches rare.
433+ nextStepsFreshTTL = 12 * time .Hour
434+ // nextStepsRetryCool caps re-tries after a failed fetch so an offline host
435+ // doesn't spend the fetch budget on every call.
436+ nextStepsRetryCool = 2 * time .Minute
437+ // nextStepsFetchBudget bounds the ONE inline fetch on the call path. Generous
438+ // for a ~15 KiB metadata doc from a CDN, tight enough that a slow/hung host
439+ // never noticeably delays the app call. On a miss past this budget the app
440+ // simply renders no hint this once and the next call (past the retry cool)
441+ // tries again.
442+ nextStepsFetchBudget = 400 * time .Millisecond
443+ nsCheckedMarker = ".next-steps.checked" // mtime = last authoritative check
444+ nsRetryMarker = ".next-steps.retry" // mtime = last failed fetch
445+ )
446+
447+ // ensureNextStepsFresh is called once per `appstore call`, before the IPC call,
448+ // so the cache is current for whichever outcome renders. It never blocks longer
449+ // than nextStepsFetchBudget and never errors out of the call.
450+ func ensureNextStepsFresh (appID string ) {
451+ if nextStepsDisabled () {
452+ return
453+ }
454+ dir , err := resolveUnder (appStoreRoot (), appID )
455+ if err != nil {
456+ return
457+ }
458+ // Recent authoritative answer (a cached graph, or a confirmed absence) → done.
459+ if markerFresh (filepath .Join (dir , nsCheckedMarker ), nextStepsFreshTTL ) {
460+ return
461+ }
462+ // Recently failed → back off rather than re-fetch on every call.
463+ if markerFresh (filepath .Join (dir , nsRetryMarker ), nextStepsRetryCool ) {
464+ return
465+ }
466+ g , ok := fetchNextStepsTimed (appID , nextStepsFetchBudget )
467+ if ! ok {
468+ touchMarker (dir , nsRetryMarker ) // couldn't reach the catalogue; short cooldown
469+ return
470+ }
471+ if g != nil && len (g .Edges ) > 0 {
472+ cacheNextSteps (dir , g )
473+ } else {
474+ // The catalogue authoritatively has no graph for this app now — drop any
475+ // stale cache so we never render a graph the catalogue stopped publishing.
476+ removeUnderAppDir (dir , nextStepsFileName )
477+ }
478+ touchMarker (dir , nsCheckedMarker )
479+ removeUnderAppDir (dir , nsRetryMarker )
480+ }
481+
482+ // fetchNextStepsTimed runs the authoritative fetch under a hard deadline. On
483+ // timeout it reports ok=false (treated as a transient failure) and lets the
484+ // goroutine finish and fall off on its own — it never writes anything, so an
485+ // abandoned fetch can't race the cache.
486+ func fetchNextStepsTimed (appID string , budget time.Duration ) (* nextStepsGraph , bool ) {
487+ type res struct {
488+ g * nextStepsGraph
489+ ok bool
490+ }
491+ ch := make (chan res , 1 )
492+ go func () {
493+ g , ok := fetchNextStepsAuthoritative (appID )
494+ ch <- res {g , ok }
495+ }()
496+ select {
497+ case r := <- ch :
498+ return r .g , r .ok
499+ case <- time .After (budget ):
500+ return nil , false
501+ }
502+ }
503+
504+ // markerFresh reports whether a marker file exists and is younger than ttl.
505+ func markerFresh (path string , ttl time.Duration ) bool {
506+ fi , err := os .Stat (path )
507+ if err != nil {
508+ return false
509+ }
510+ return time .Since (fi .ModTime ()) < ttl
511+ }
512+
513+ // touchMarker writes/updates a freshness marker in the app dir (mtime = now).
514+ func touchMarker (dir , name string ) {
515+ out , err := resolveUnder (appStoreRoot (), filepath .Base (dir ))
516+ if err != nil {
517+ return
518+ }
519+ _ = os .WriteFile (filepath .Join (out , name ), []byte (time .Now ().UTC ().Format (time .RFC3339 )), 0o600 ) // #nosec G304 G703 -- path re-confined to the install root by resolveUnder above
520+ }
521+
522+ // removeUnderAppDir deletes a file inside an installed app dir, re-confining the
523+ // path to the install root (the same guard the read and write paths use) so a
524+ // crafted app id can never delete outside the tree the app store owns.
525+ func removeUnderAppDir (dir , name string ) {
526+ out , err := resolveUnder (appStoreRoot (), filepath .Base (dir ))
527+ if err != nil {
528+ return
395529 }
396- return nil
530+ _ = os . Remove ( filepath . Join ( out , name )) // #nosec G304 G703 -- path re-confined to the install root by resolveUnder above
397531}
0 commit comments