server: unfurl requests mid-fetch wait for the result instead of getting an empty stub (#1240)
The in-flight de-dup in fetchAndCachePreview answered concurrent callers
with LinkPreview{URL} when nothing was cached yet — exactly what the
recipient of a just-posted link hit, since their render races the poster's
synchronous fetch by milliseconds. The poster saw the card; the recipient
got the stub, the web client only retries on a re-render, and the native
model caches the nil for its lifetime — matching the 'only the poster sees
unfurls' reports.
The in-flight entry is now a previewFlight (done chan + result): one fetch
runs per URL+language, and every concurrent caller waits on it (bounded by
fetchOG's 6s timeout) and returns the shared card.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+73
−10
changelog/2026-07-21-link-preview-recipients.md+4 −0main.go+24 −10unfurl_test.go+45 −0
diff --git a/changelog/2026-07-21-link-preview-recipients.md b/changelog/2026-07-21-link-preview-recipients.md| new file mode 100644 |
| index 0000000..c8208ef |
| --- /dev/null |
| +++ b/changelog/2026-07-21-link-preview-recipients.md |
| @@ -0,0 +1,4 @@ |
| +🤖 Link previews now show up for everyone in the chat. Before, the person who |
| +posted a link would often see the preview card while everyone else saw only |
| +the bare link — the fix means the card appears on both ends of the |
| +conversation. |
diff --git a/main.go b/main.go| index 83d0de5..93820c8 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -121,7 +121,8 @@ type Server struct { |
| // worst re-notifies once, and the queue itself is the durable record. |
| reportPushMu sync.Mutex |
| lastReportPush time.Time |
| - // inflight de-duplicates concurrent unfurls of the same URL. |
| + // inflight coalesces concurrent unfurls of the same URL+language: one |
| + // fetch runs, the rest wait on its *previewFlight for the shared result. |
| inflight sync.Map |
| // active tracks the last time each device token reported being open + focused |
| // (token -> unix seconds); activeUsers rolls that up per owner (user id -> unix |
| @@ -3089,18 +3090,30 @@ func (s *Server) unfurl(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, s.fetchAndCachePreview(norm, lang)) |
| } |
| +// previewFlight is one in-progress unfurl; concurrent requests for the same |
| +// URL+language wait on done and share pv rather than fetching again. |
| +type previewFlight struct { |
| + done chan struct{} |
| + pv LinkPreview |
| +} |
| + |
| // fetchAndCachePreview fetches+stores a preview, ensuring only one fetch per |
| -// URL+language runs at a time. Concurrent callers get whatever is currently |
| -// cached. |
| +// URL+language runs at a time. Concurrent callers WAIT for that fetch and get |
| +// its result — answering them with an empty stub instead is why a recipient |
| +// (whose render of a just-posted link races the poster's synchronous fetch) |
| +// never saw a card the poster did. The wait is bounded by fetchOG's timeout. |
| func (s *Server) fetchAndCachePreview(norm, lang string) LinkPreview { |
| flightKey := previewCacheKey(lang, norm) |
| - if _, busy := s.inflight.LoadOrStore(flightKey, true); busy { |
| - if pv, ok, _ := s.db.getLinkPreview(norm, lang); ok { |
| - return pv |
| - } |
| - return LinkPreview{URL: norm} |
| - } |
| - defer s.inflight.Delete(flightKey) |
| + f := &previewFlight{done: make(chan struct{})} |
| + if cur, busy := s.inflight.LoadOrStore(flightKey, f); busy { |
| + flight := cur.(*previewFlight) |
| + <-flight.done |
| + return flight.pv |
| + } |
| + defer func() { |
| + s.inflight.Delete(flightKey) |
| + close(f.done) |
| + }() |
| pv, err := fetchOG(norm, lang) |
| if err != nil { |
| @@ -3109,6 +3122,7 @@ func (s *Server) fetchAndCachePreview(norm, lang string) LinkPreview { |
| pv.URL = norm |
| pv.FetchedAt = time.Now().Unix() |
| _ = s.db.putLinkPreview(pv, lang) |
| + f.pv = pv |
| return pv |
| } |
diff --git a/unfurl_test.go b/unfurl_test.go| index 875c54a..85150e3 100644 |
| --- a/unfurl_test.go |
| +++ b/unfurl_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "net/http" |
| "net/http/httptest" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| ) |
| @@ -100,6 +101,50 @@ func TestUnfurlEndpoint(t *testing.T) { |
| } |
| } |
| +// A request that arrives while the same URL's fetch is in flight waits for it |
| +// and gets the real card — not an empty stub. This is the poster/recipient |
| +// race: the recipient renders a just-posted link milliseconds after the |
| +// poster, lands mid-fetch, and used to be the one member with no preview. |
| +func TestUnfurlConcurrentRequestsShareFetch(t *testing.T) { |
| + _, srv := newTestServer(t) |
| + |
| + allowPrivatePreviewHosts = true |
| + t.Cleanup(func() { allowPrivatePreviewHosts = false }) |
| + |
| + // The page blocks until released, holding the first fetch in flight; a |
| + // second fetch would panic the double-close, so one fetch is also asserted. |
| + var started sync.Once |
| + firstFetch := make(chan struct{}) |
| + release := make(chan struct{}) |
| + page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + started.Do(func() { close(firstFetch) }) |
| + <-release |
| + w.Header().Set("Content-Type", "text/html") |
| + w.Write([]byte(`<head><meta property="og:title" content="Raced"></head>`)) |
| + })) |
| + t.Cleanup(page.Close) |
| + |
| + var poster, recipient LinkPreview |
| + var wg sync.WaitGroup |
| + wg.Add(2) |
| + go func() { defer wg.Done(); poster = srv.fetchAndCachePreview(page.URL, "") }() |
| + <-firstFetch // the poster's fetch is now in flight |
| + go func() { defer wg.Done(); recipient = srv.fetchAndCachePreview(page.URL, "") }() |
| + // Give the recipient's call time to reach the in-flight path, then let the |
| + // page respond. (If it instead arrives after the fetch completes, it reads |
| + // the cache — either way the assertion below is what members experience.) |
| + time.Sleep(50 * time.Millisecond) |
| + close(release) |
| + wg.Wait() |
| + |
| + if !poster.OK || poster.Title != "Raced" { |
| + t.Fatalf("poster preview = %+v", poster) |
| + } |
| + if !recipient.OK || recipient.Title != "Raced" { |
| + t.Fatalf("recipient preview = %+v (the mid-fetch stub bug)", recipient) |
| + } |
| +} |
| + |
| // The member's Accept-Language rides along to the unfurled page (so a box in |
| // Finland doesn't produce Finnish cards for Irish members), and cards fetched |
| // under different languages cache as separate rows. |