eleven / messenger Public

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>
Paul Campbell 066b556ec6ef2183b5d3fc9e0aa53b0303406069 parent 8ab43ab
3 files changed, +73 −10
  • changelog/2026-07-21-link-preview-recipients.md +4 −0
  • main.go +24 −10
  • unfurl_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.