Fix offline onboarding lockout and stale candidates after rename
CI / ci (push) Successful in 2m40s
CI / ci (pull_request) Successful in 2m28s

MQ-98-04: the household settings read that OnboardingGate makes before
every page now runs with networkMode 'always', like /me. After the
browser emits an offline event it fails into the saved pages instead of
pausing on the loading screen. A test emits a real offline event before
/me fails.

MQ-98-05: renaming a dinner now clears its candidate offers and makes a
running candidate search stale, and an offer is bound to the normalized
name it was searched for, so a candidate offered before a rename cannot
ground the renamed dinner. Regression tests cover a pending dinner
renamed after candidates were offered, and a rename during a running
search.

Maestro task: 4fd737b2-fdcb-48ad-858a-ecdd95595e0c

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:36:31 -04:00
co-authored by Claude Opus 5.5
parent cb920714b0
commit 3c0a7fd398
6 changed files with 129 additions and 27 deletions
+22 -19
View File
@@ -304,10 +304,12 @@ var offerLifetime = 30 * time.Minute
var now = time.Now
// offerSet is the candidates last offered for a dinner: the page URL of
// each candidate id. They ground the dinner until deadline; expiry drops
// the set from offers once it has passed.
// each candidate id, found by searching the dinner's name, normalized as
// name. They ground the dinner until deadline, and only while it still has
// that name; expiry drops the set from offers once deadline has passed.
type offerSet struct {
urls map[string]string
name string
deadline time.Time
expiry *time.Timer
}
@@ -322,11 +324,11 @@ type offerSearch struct {
// recipe pages to ground it in, best first; the list may be empty. They
// replace the dinner's earlier candidates: one not offered again no longer
// grounds it. They ground it for offerLifetime, and not at all once it is
// grounded by hand or archived, even while this search runs. A search that
// fails wraps recipes.ErrSearchFailed.
// grounded by hand, renamed or archived, even while this search runs. A
// search that fails wraps recipes.ErrSearchFailed.
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
// Tracked before the dinner is read: grounding or archiving it after
// the read makes this search stale.
// Tracked before the dinner is read: grounding, renaming or archiving
// it after the read makes this search stale.
keep, done := s.trackOffers(id)
defer done()
row, err := s.get(ctx, id)
@@ -349,15 +351,16 @@ func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error
}
}
if row.Status != StatusArchived {
keep(urls)
keep(urls, row.NormalizedName)
}
return out, nil
}
// trackOffers registers a Candidates search for dinner id. It returns keep,
// which stores urls as the dinner's offers for offerLifetime unless
// clearOffers has run for it since, and done, to call when the search ends.
func (s *Service) trackOffers(id string) (keep func(urls map[string]string), done func()) {
// which stores urls, found for the normalized name, as the dinner's offers
// for offerLifetime unless clearOffers has run for it since, and done, to
// call when the search ends.
func (s *Service) trackOffers(id string) (keep func(urls map[string]string, name string), done func()) {
search := &offerSearch{}
s.offersMu.Lock()
if s.offerSearches[id] == nil {
@@ -365,14 +368,14 @@ func (s *Service) trackOffers(id string) (keep func(urls map[string]string), don
}
s.offerSearches[id][search] = struct{}{}
s.offersMu.Unlock()
keep = func(urls map[string]string) {
keep = func(urls map[string]string, name string) {
s.offersMu.Lock()
defer s.offersMu.Unlock()
if search.stale {
return
}
s.dropOffers(id)
set := &offerSet{urls: urls, deadline: now().Add(offerLifetime)}
set := &offerSet{urls: urls, name: name, deadline: now().Add(offerLifetime)}
// expiry waits for offersMu, so it finds set stored even when it
// fires at once, and drops set only, never offers stored later.
set.expiry = time.AfterFunc(offerLifetime, func() {
@@ -397,7 +400,7 @@ func (s *Service) trackOffers(id string) (keep func(urls map[string]string), don
// clearOffers drops dinner id's offers and makes every Candidates search
// running for it stale, so no candidate offered before grounds it. Call
// it once the dinner is grounded by hand or archived.
// it once the dinner is grounded by hand, renamed or archived.
func (s *Service) clearOffers(id string) {
s.offersMu.Lock()
defer s.offersMu.Unlock()
@@ -425,15 +428,15 @@ func (s *Service) candidateID(id, rawURL string) string {
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16])
}
// candidateURL is the page URL of candidate cid last offered for dinner id.
// candidateURL is the page URL of candidate cid last offered for dinner d.
// A cid not among the dinner's live offers, or whose page is not a valid
// recipe link, is a *ValidationError. Offers past their deadline are not
// live, even before their expiry drops them.
func (s *Service) candidateURL(id, cid string) (string, error) {
// recipe link, is a *ValidationError. Offers past their deadline, or found
// for a name d no longer has, are not live, even before they are dropped.
func (s *Service) candidateURL(d db.Dinner, cid string) (string, error) {
var raw string
var ok bool
s.offersMu.Lock()
if set := s.offers[id]; set != nil && now().Before(set.deadline) {
if set := s.offers[d.ID]; set != nil && set.name == d.NormalizedName && now().Before(set.deadline) {
raw, ok = set.urls[cid]
}
s.offersMu.Unlock()
@@ -499,7 +502,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
return Dinner{}, ErrArchived
}
if f.CandidateID != nil {
if link, err = s.candidateURL(cur.ID, *f.CandidateID); err != nil {
if link, err = s.candidateURL(cur, *f.CandidateID); err != nil {
return Dinner{}, err
}
}