carlos / platform Public

merge: edge cache-before-wake — bots shouldn't wake hibernated instances (#10)

edge cache-before-wake: bots shouldn't wake hibernated instances
Paul Campbell 41d99f4eaa13d8171127478e7e145ae62e454f23 parent de07dacparent 620ce43
11 files changed, +3800 −9
  • docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md +1363 −0
  • docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md +270 −0
  • internal/activator/activator_test.go +190 −0
  • internal/activator/backend_exec.go +56 −4
  • internal/edge/cache.go +339 −0
  • internal/edge/cache_test.go +202 −0
  • internal/edge/edge.go +346 −4
  • internal/edge/edge_test.go +830 −0
  • internal/edge/purge.go +85 −0
  • internal/edge/purge_test.go +109 −0
  • internal/host/agent.go +10 −1
diff --git a/docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md b/docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md
new file mode 100644
index 000000000..be4598fc2
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md
@@ -0,0 +1,1363 @@
+# Edge Cache-Before-Wake (Platform) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give the CARLOS edge a response cache that serves cacheable GET/HEAD
+requests without ever invoking the hibernation activator, kept fresh by a
+purge channel each hibernating instance can call right after a write.
+
+**Architecture:** A new `internal/edge/cache.go` holds an in-memory,
+LRU+TTL, Vary-aware response cache. `Edge.ServeHTTP` (`internal/edge/edge.go`)
+checks it before calling the activator; a miss falls through to today's
+proxy/wake behavior and populates the cache from the real response on the
+way back. A new `internal/edge/purge.go` exposes a per-instance local-only
+unix-socket listener so an instance can invalidate its own cache entries.
+`internal/activator/backend_exec.go` spawns that listener alongside each
+instance process and passes it the socket path via a new `--control-socket`
+flag, mirroring the existing `--socket`/`--db` flags.
+
+**Tech Stack:** Go (matches the rest of `carlosframework/platform`), standard
+library only (`net/http`, `net`, `container/list`) — no new dependencies.
+
+## Global Constraints
+
+- Go, same module as the rest of `carlosframework/platform`; `go build ./...`
+ and `go vet ./...` must stay clean after every task.
+- Existing test conventions: table-driven where natural, `_test.go`
+ colocated with the code it tests, `go test ./...` for the full suite.
+- Several new tests create real unix sockets (`net.Listen("unix", ...)`).
+ If your shell sandboxes filesystem/socket access, run those tests with
+ sandboxing disabled — this is a known, pre-existing constraint in this
+ repo's test suite, not new to this work.
+- No new third-party dependencies for this feature — the cache, LRU, and
+ purge protocol are all small enough for the standard library.
+- Nil-means-disabled is this package's existing idiom (`Activator` is nil
+ for the pure-proxy `carlos edge` role). The new `*Cache` field on
+ `ExecBackend` follows the same rule: nil disables purge-listener startup
+ without an error.
+
+---
+
+### Task 1: Cache store
+
+**Files:**
+- Create: `internal/edge/cache.go`
+- Test: `internal/edge/cache_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ type CacheKey struct {
+ Host string // request Host header, exact match
+ Method string // "GET" or "HEAD"
+ Path string
+ RawQuery string
+ VaryValue string // concatenation of request-header values named by
+ // the response's own Vary header; "" if Vary absent
+ }
+
+ type CachedResponse struct {
+ StatusCode int
+ Header http.Header
+ Body []byte
+ StoredAt time.Time
+ TTL time.Duration
+ }
+
+ func NewCache(maxEntries, maxBodyBytes int) *Cache
+ func (c *Cache) Get(key CacheKey) (*CachedResponse, bool)
+ func (c *Cache) Put(key CacheKey, resp *CachedResponse)
+ func (c *Cache) PurgeURL(host, path, rawQuery string) int // returns count purged
+ ```
+ `Get` returns `(nil, false)` for a miss or an expired (`StoredAt+TTL <
+ now`) entry — expired entries are evicted lazily on lookup, not swept on a
+ timer. `Put` silently drops a response whose body exceeds `maxBodyBytes`
+ (never caches it, no error — a cache is allowed to say no) and evicts the
+ least-recently-used entry when at `maxEntries`. `PurgeURL` drops every
+ cached variant (every `VaryValue`) of `(host, path, rawQuery)` regardless
+ of which locale/Vary-bucket it was stored under, and returns how many
+ entries it actually removed (0 if none were cached — not an error).
+
+- [ ] **Step 1: Write the failing tests**
+
+ ```go
+ package edge
+
+ import (
+ "net/http"
+ "testing"
+ "time"
+ )
+
+ func TestCacheGetMissOnEmpty(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ if _, ok := c.Get(CacheKey{Host: "a", Method: "GET", Path: "/"}); ok {
+ t.Fatal("expected miss on empty cache")
+ }
+ }
+
+ func TestCachePutThenGetHits(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute}
+ c.Put(key, resp)
+ got, ok := c.Get(key)
+ if !ok {
+ t.Fatal("expected hit after put")
+ }
+ if string(got.Body) != "hi" {
+ t.Fatalf("body = %q, want %q", got.Body, "hi")
+ }
+ }
+
+ func TestCacheEntryExpiresAfterTTL(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now().Add(-2 * time.Minute), TTL: time.Minute}
+ c.Put(key, resp)
+ if _, ok := c.Get(key); ok {
+ t.Fatal("expected miss on expired entry")
+ }
+ }
+
+ func TestCachePutOverMaxBodyBytesIsDropped(t *testing.T) {
+ c := NewCache(10, 4) // 4-byte cap
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("way too big"), StoredAt: time.Now(), TTL: time.Minute}
+ c.Put(key, resp)
+ if _, ok := c.Get(key); ok {
+ t.Fatal("expected oversized body to never be cached")
+ }
+ }
+
+ func TestCacheEvictsLeastRecentlyUsedAtCapacity(t *testing.T) {
+ c := NewCache(2, 1<<20)
+ mk := func(p string) CacheKey { return CacheKey{Host: "a", Method: "GET", Path: p} }
+ put := func(p string) {
+ c.Put(mk(p), &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(p), StoredAt: time.Now(), TTL: time.Minute})
+ }
+ put("/one")
+ put("/two")
+ c.Get(mk("/one")) // touch /one so /two becomes the LRU victim
+ put("/three")
+ if _, ok := c.Get(mk("/two")); ok {
+ t.Fatal("expected /two to have been evicted as least-recently-used")
+ }
+ if _, ok := c.Get(mk("/one")); !ok {
+ t.Fatal("expected /one to survive (recently touched)")
+ }
+ if _, ok := c.Get(mk("/three")); !ok {
+ t.Fatal("expected /three to have been inserted")
+ }
+ }
+
+ func TestCachePurgeURLRemovesAllVaryVariants(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ en := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "en"}
+ fr := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "fr"}
+ other := CacheKey{Host: "a", Method: "GET", Path: "/other"}
+ mkResp := func(b string) *CachedResponse {
+ return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
+ }
+ c.Put(en, mkResp("en"))
+ c.Put(fr, mkResp("fr"))
+ c.Put(other, mkResp("other"))
+ n := c.PurgeURL("a", "/event", "")
+ if n != 2 {
+ t.Fatalf("purged %d entries, want 2", n)
+ }
+ if _, ok := c.Get(en); ok {
+ t.Fatal("expected en variant purged")
+ }
+ if _, ok := c.Get(fr); ok {
+ t.Fatal("expected fr variant purged")
+ }
+ if _, ok := c.Get(other); !ok {
+ t.Fatal("expected unrelated URL to survive purge")
+ }
+ }
+ ```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+ Run: `go test ./internal/edge/ -run TestCache -v`
+ Expected: FAIL — `Cache`, `CacheKey`, `CachedResponse`, `NewCache` undefined.
+
+- [ ] **Step 3: Write the implementation**
+
+ ```go
+ package edge
+
+ import (
+ "container/list"
+ "sync"
+ "time"
+ )
+
+ // Cache is an in-memory, per-host response cache for cacheable GET/HEAD
+ // responses. It exists to let the edge answer bot/crawler/repeat traffic
+ // without invoking the hibernation activator (edge.go's ServeHTTP checks
+ // it before ever reaching the activator) — see
+ // docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md.
+ // A nil *Cache disables caching entirely; callers must nil-check before use.
+ type Cache struct {
+ mu sync.Mutex
+ maxEntries int
+ maxBodyBytes int
+ order *list.List // MRU at front, LRU at back
+ entries map[cacheFullKey]*list.Element
+ byURL map[cacheURLKey]map[string]struct{} // urlKey -> set of VaryValue
+ }
+
+ type cacheURLKey struct {
+ Host, Method, Path, RawQuery string
+ }
+
+ type cacheFullKey struct {
+ cacheURLKey
+ VaryValue string
+ }
+
+ type cacheEntry struct {
+ key cacheFullKey
+ resp *CachedResponse
+ }
+
+ func NewCache(maxEntries, maxBodyBytes int) *Cache {
+ return &Cache{
+ maxEntries: maxEntries,
+ maxBodyBytes: maxBodyBytes,
+ order: list.New(),
+ entries: make(map[cacheFullKey]*list.Element),
+ byURL: make(map[cacheURLKey]map[string]struct{}),
+ }
+ }
+
+ func (k CacheKey) urlKey() cacheURLKey {
+ return cacheURLKey{Host: k.Host, Method: k.Method, Path: k.Path, RawQuery: k.RawQuery}
+ }
+
+ func (k CacheKey) fullKey() cacheFullKey {
+ return cacheFullKey{cacheURLKey: k.urlKey(), VaryValue: k.VaryValue}
+ }
+
+ func (c *Cache) Get(key CacheKey) (*CachedResponse, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ fk := key.fullKey()
+ el, ok := c.entries[fk]
+ if !ok {
+ return nil, false
+ }
+ e := el.Value.(*cacheEntry)
+ if time.Since(e.resp.StoredAt) > e.resp.TTL {
+ c.removeLocked(el)
+ return nil, false
+ }
+ c.order.MoveToFront(el)
+ return e.resp, true
+ }
+
+ func (c *Cache) Put(key CacheKey, resp *CachedResponse) {
+ if len(resp.Body) > c.maxBodyBytes {
+ return
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ fk := key.fullKey()
+ if el, ok := c.entries[fk]; ok {
+ el.Value.(*cacheEntry).resp = resp
+ c.order.MoveToFront(el)
+ return
+ }
+ el := c.order.PushFront(&cacheEntry{key: fk, resp: resp})
+ c.entries[fk] = el
+ uk := fk.cacheURLKey
+ if c.byURL[uk] == nil {
+ c.byURL[uk] = make(map[string]struct{})
+ }
+ c.byURL[uk][fk.VaryValue] = struct{}{}
+ for len(c.entries) > c.maxEntries {
+ victim := c.order.Back()
+ if victim == nil {
+ break
+ }
+ c.removeLocked(victim)
+ }
+ }
+
+ // removeLocked drops el from every index. Caller must hold c.mu.
+ func (c *Cache) removeLocked(el *list.Element) {
+ e := el.Value.(*cacheEntry)
+ c.order.Remove(el)
+ delete(c.entries, e.key)
+ if variants := c.byURL[e.key.cacheURLKey]; variants != nil {
+ delete(variants, e.key.VaryValue)
+ if len(variants) == 0 {
+ delete(c.byURL, e.key.cacheURLKey)
+ }
+ }
+ }
+
+ func (c *Cache) PurgeURL(host, path, rawQuery string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := 0
+ // GET and HEAD are purged together — a write invalidates both.
+ for _, method := range [...]string{"GET", "HEAD"} {
+ uk := cacheURLKey{Host: host, Method: method, Path: path, RawQuery: rawQuery}
+ variants := c.byURL[uk]
+ for vary := range variants {
+ fk := cacheFullKey{cacheURLKey: uk, VaryValue: vary}
+ if el, ok := c.entries[fk]; ok {
+ c.removeLocked(el)
+ n++
+ }
+ }
+ }
+ return n
+ }
+ ```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+ Run: `go test ./internal/edge/ -run TestCache -v`
+ Expected: PASS (all six).
+
+- [ ] **Step 5: Commit**
+
+ ```bash
+ git add internal/edge/cache.go internal/edge/cache_test.go
+ git commit -m "edge: add LRU+TTL response cache, Vary- and purge-aware"
+ ```
+
+---
+
+### Task 2: Cache-key computation and the ServeHTTP insertion point
+
+**Files:**
+- Modify: `internal/edge/edge.go`
+- Test: `internal/edge/edge_test.go`
+
+**Interfaces:**
+- Consumes: `Cache`, `CacheKey`, `CachedResponse`, `NewCache` (Task 1).
+- Produces:
+ ```go
+ const (
+ DefaultCacheMaxEntries = 4096
+ DefaultCacheMaxBodyBytes = 1 << 20 // 1 MiB
+ )
+
+ func (e *Edge) EnableCache(cache *Cache) // sets e.cache; nil is a valid no-op
+ ```
+ and the new field `Edge.cache *Cache` (unexported — only `ServeHTTP`
+ reads it). `RunWithActivator` gains a third parameter:
+ `func RunWithActivator(args []string, act Activator, cache *Cache)`.
+ `Run(args []string)` keeps its existing single-argument signature and
+ builds its own private cache internally, so `carlos edge`'s pure-proxy
+ role gets caching too even though it has no activator.
+
+- [ ] **Step 1: Write the failing tests**
+
+ Add to `internal/edge/edge_test.go`. This reuses the file's existing
+ fixtures verbatim: `openReg(t)` (registry against a temp DB),
+ `reg.Upsert(registry.Route{...})`, and `New(reg, act)` — the exact
+ pattern `TestHibernatingRouteGoesThroughActivator` (already in this
+ file) uses for a hibernating route, and the exact pattern
+ `testEdgeWithStore`'s doc comment recommends (`httptest.NewRecorder` +
+ `httptest.NewRequest`, no real listener) for everything else. No new
+ shared helpers are introduced by this task; `refusingActivator` below is
+ a second, deliberately different fake from the file's existing
+ `fakeActivator` because it needs to fail the test on any call, not just
+ count calls.
+
+ ```go
+ // refusingActivator fails the test immediately if ever invoked — used to
+ // prove a cache hit never reaches the activator at all.
+ type refusingActivator struct{ t *testing.T }
+
+ func (r refusingActivator) ServeHTTP(w http.ResponseWriter, req *http.Request, rt registry.Route, proxy http.Handler) bool {
+ r.t.Fatal("activator invoked on what should have been a cache hit — this defeats the whole point of caching")
+ return true
+ }
+
+ func TestServeHTTPCacheHitNeverInvokesActivator(t *testing.T) {
+ reg := openReg(t)
+ // Addr is deliberately a bogus, never-dialed address: if the cache
+ // check is ever bypassed, proxying to it fails loudly instead of
+ // silently succeeding, which would mask the bug.
+ if err := reg.Upsert(registry.Route{Host: "cached.example.com", Kind: "instance", Addr: "127.0.0.1:1", Status: "ready", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, refusingActivator{t: t})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ key := CacheKey{Host: "cached.example.com", Method: "GET", Path: "/event"}
+ e.cache.Put(key, &CachedResponse{
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": {"text/html"}},
+ Body: []byte("cached page"),
+ StoredAt: time.Now(),
+ TTL: time.Minute,
+ })
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cached.example.com/event", nil))
+
+ if rec.Code != 200 {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if rec.Body.String() != "cached page" {
+ t.Fatalf("body = %q, want %q", rec.Body.String(), "cached page")
+ }
+ }
+
+ func TestServeHTTPCacheMissPopulatesCacheFromResponse(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(200)
+ w.Write([]byte("fresh page"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "miss.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ rec1 := httptest.NewRecorder()
+ e.ServeHTTP(rec1, httptest.NewRequest("GET", "http://miss.example.com/event", nil))
+ if rec1.Body.String() != "fresh page" {
+ t.Fatalf("first response body = %q, want %q", rec1.Body.String(), "fresh page")
+ }
+
+ // Close the backend so a second real request would fail to dial —
+ // proves the second request is answered from cache, not the backend.
+ backend.Close()
+ rec2 := httptest.NewRecorder()
+ e.ServeHTTP(rec2, httptest.NewRequest("GET", "http://miss.example.com/event", nil))
+ if rec2.Body.String() != "fresh page" {
+ t.Fatalf("second response body = %q, want %q (should be served from cache)", rec2.Body.String(), "fresh page")
+ }
+ }
+
+ func TestServeHTTPNeverCachesNon2xx(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(500)
+ w.Write([]byte("boom"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "err.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://err.example.com/event", nil))
+
+ if _, ok := e.cache.Get(CacheKey{Host: "err.example.com", Method: "GET", Path: "/event"}); ok {
+ t.Fatal("a 500 response must never be cached")
+ }
+ }
+
+ func TestServeHTTPVaryAcceptLanguageProducesSeparateEntries(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.Header().Set("Vary", "Accept-Language")
+ w.WriteHeader(200)
+ w.Write([]byte("lang=" + r.Header.Get("Accept-Language")))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "i18n.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ reqEn := httptest.NewRequest("GET", "http://i18n.example.com/event", nil)
+ reqEn.Header.Set("Accept-Language", "en")
+ recEn := httptest.NewRecorder()
+ e.ServeHTTP(recEn, reqEn)
+
+ reqFr := httptest.NewRequest("GET", "http://i18n.example.com/event", nil)
+ reqFr.Header.Set("Accept-Language", "fr")
+ recFr := httptest.NewRecorder()
+ e.ServeHTTP(recFr, reqFr)
+
+ if recEn.Body.String() == recFr.Body.String() {
+ t.Fatalf("expected distinct cached responses per Accept-Language, got %q for both", recEn.Body.String())
+ }
+ }
+
+ func TestServeHTTPRefusesVaryCookie(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.Header().Set("Vary", "Cookie")
+ w.WriteHeader(200)
+ w.Write([]byte("should not be cached"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "cookie.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie.example.com/event", nil))
+
+ if _, ok := e.cache.Get(CacheKey{Host: "cookie.example.com", Method: "GET", Path: "/event"}); ok {
+ t.Fatal("a response Vary-ing on Cookie must never be cached — it would defeat per-visitor correctness")
+ }
+ }
+ ```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+ Run: `go test ./internal/edge/ -run TestServeHTTP -v`
+ Expected: FAIL — `e.cache`/`EnableCache` undefined, or (for the hit test)
+ the activator gets invoked because there's no cache check yet.
+
+- [ ] **Step 3: Implement**
+
+ Add to `edge.go` near the other package-level consts:
+
+ ```go
+ const (
+ DefaultCacheMaxEntries = 4096
+ DefaultCacheMaxBodyBytes = 1 << 20 // 1 MiB
+ )
+ ```
+
+ Add the field to the `Edge` struct (alongside the existing unexported
+ fields — find that struct definition in `edge.go`):
+
+ ```go
+ cache *Cache // nil disables caching entirely
+ ```
+
+ Add the enabler, next to `EnableStatic`:
+
+ ```go
+ // EnableCache wires a response cache into the edge. Called once at
+ // startup (RunWithActivator or Run); nil is a valid no-op, matching the
+ // package's existing nil-means-disabled convention for Activator.
+ func (e *Edge) EnableCache(cache *Cache) {
+ e.cache = cache
+ }
+ ```
+
+ Modify `ServeHTTP` — locate the existing body (from the earlier read,
+ it looks up `up` from `e.current()`, then does the hibernating check
+ before proxying). Insert the cache check right before the existing
+ `if up.hibernating { ... e.act.ServeHTTP(...) }` block, and wrap the
+ fallthrough proxy call so a cacheable response gets captured:
+
+ ```go
+ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ host := hostOnly(r.Host)
+ m := e.current()
+ up, ok := m[host]
+ if !ok {
+ e.unknownHost(w, r)
+ return
+ }
+
+ cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead)
+ var key CacheKey
+ if cacheable {
+ key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery}
+ if resp, hit := e.cache.Get(key); hit {
+ writeCached(w, resp)
+ return
+ }
+ }
+
+ if up.hibernating {
+ rec := &recordingResponseWriter{ResponseWriter: w, cacheable: cacheable, maxBody: e.cacheMaxBody()}
+ if e.act.ServeHTTP(rec, r, up.route, up.proxy) {
+ e.maybeStore(key, rec)
+ return
+ }
+ up.proxy.ServeHTTP(rec, r)
+ e.maybeStore(key, rec)
+ return
+ }
+
+ if cacheable {
+ rec := &recordingResponseWriter{ResponseWriter: w, cacheable: true, maxBody: e.cacheMaxBody()}
+ up.proxy.ServeHTTP(rec, r)
+ e.maybeStore(key, rec)
+ return
+ }
+ up.proxy.ServeHTTP(w, r)
+ }
+
+ func (e *Edge) cacheMaxBody() int {
+ if e.cache == nil {
+ return 0
+ }
+ return e.cache.maxBodyBytes
+ }
+
+ // maybeStore inspects a recorded response and caches it if it qualifies:
+ // 2xx status, a Cache-Control that names a positive max-age, and no
+ // Vary: Cookie (varying by cookie would defeat correctness per-visitor —
+ // see docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §5).
+ func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) {
+ if e.cache == nil || !rec.cacheable || rec.truncated {
+ return
+ }
+ if rec.status < 200 || rec.status >= 300 {
+ return
+ }
+ ttl, ok := parseMaxAge(rec.Header().Get("Cache-Control"))
+ if !ok || ttl <= 0 {
+ return
+ }
+ vary := rec.Header().Get("Vary")
+ if vary != "" {
+ for _, h := range strings.Split(vary, ",") {
+ if strings.EqualFold(strings.TrimSpace(h), "Cookie") {
+ log.Printf("edge: refusing to cache %s%s — Vary: Cookie would break per-visitor correctness", key.Host, key.Path)
+ return
+ }
+ }
+ key.VaryValue = varyValue(rec.request, vary)
+ }
+ e.cache.Put(key, &CachedResponse{
+ StatusCode: rec.status,
+ Header: rec.Header().Clone(),
+ Body: rec.body,
+ StoredAt: time.Now(),
+ TTL: ttl,
+ })
+ }
+
+ // varyValue concatenates r's values for each header named in the
+ // response's Vary list, in the order Vary names them — the same raw
+ // string always produces the same bucket, which is all correctness
+ // requires (standard HTTP Vary semantics; RFC 7234 §4.1).
+ func varyValue(r *http.Request, vary string) string {
+ var b strings.Builder
+ for _, h := range strings.Split(vary, ",") {
+ h = strings.TrimSpace(h)
+ b.WriteString(h)
+ b.WriteByte('=')
+ b.WriteString(r.Header.Get(h))
+ b.WriteByte(';')
+ }
+ return b.String()
+ }
+
+ // parseMaxAge extracts max-age from a Cache-Control header. Returns
+ // ok=false for "no-store", "no-cache", or a missing/malformed max-age —
+ // any of which mean "don't cache this."
+ func parseMaxAge(cc string) (time.Duration, bool) {
+ for _, part := range strings.Split(cc, ",") {
+ part = strings.TrimSpace(part)
+ if strings.EqualFold(part, "no-store") || strings.EqualFold(part, "no-cache") {
+ return 0, false
+ }
+ if v, ok := strings.CutPrefix(strings.ToLower(part), "max-age="); ok {
+ secs, err := strconv.Atoi(v)
+ if err != nil {
+ return 0, false
+ }
+ return time.Duration(secs) * time.Second, true
+ }
+ }
+ return 0, false
+ }
+
+ func writeCached(w http.ResponseWriter, resp *CachedResponse) {
+ h := w.Header()
+ for k, v := range resp.Header {
+ h[k] = v
+ }
+ w.WriteHeader(resp.StatusCode)
+ w.Write(resp.Body)
+ }
+
+ // recordingResponseWriter captures a response as it's written so it can
+ // be cached afterward, while still passing bytes through to the real
+ // client immediately (no buffering delay for the requester). Recording
+ // stops (silently, not an error) once the body exceeds maxBody — that
+ // response is simply never cached, per maybeStore's rec.truncated check.
+ type recordingResponseWriter struct {
+ http.ResponseWriter
+ request *http.Request
+ cacheable bool
+ maxBody int
+ status int
+ body []byte
+ truncated bool
+ wroteHdr bool
+ }
+
+ func (r *recordingResponseWriter) WriteHeader(status int) {
+ if !r.wroteHdr {
+ r.status = status
+ r.wroteHdr = true
+ }
+ r.ResponseWriter.WriteHeader(status)
+ }
+
+ func (r *recordingResponseWriter) Write(p []byte) (int, error) {
+ if !r.wroteHdr {
+ r.WriteHeader(http.StatusOK)
+ }
+ if r.cacheable && !r.truncated {
+ if len(r.body)+len(p) > r.maxBody {
+ r.truncated = true
+ r.body = nil
+ } else {
+ r.body = append(r.body, p...)
+ }
+ }
+ return r.ResponseWriter.Write(p)
+ }
+ ```
+
+ Add imports as needed (`strconv`, `strings`, `log` if not already
+ present in `edge.go`).
+
+ Update `Run` and `RunWithActivator`:
+
+ ```go
+ func Run(args []string) {
+ RunWithActivator(args, nil, NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ }
+
+ func RunWithActivator(args []string, act Activator, cache *Cache) {
+ // ... unchanged up through:
+ e, err := New(reg, act)
+ if err != nil {
+ log.Fatalf("edge: load routes: %v", err)
+ }
+ e.fallbackURL = *fallbackURL
+ e.EnableCache(cache)
+ // ... rest unchanged
+ }
+ ```
+
+ Update the one other call site, `main.go:27` (`edge.Run(args)`), does
+ **not** need to change — `Run`'s signature is unchanged.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+ Run: `go test ./internal/edge/... -v`
+ Expected: PASS, including all pre-existing `edge_test.go` tests
+ (unchanged behavior for non-cacheable/non-hibernating routes).
+
+- [ ] **Step 5: Run go vet and the full build**
+
+ Run: `go vet ./... && go build ./...`
+ Expected: clean.
+
+- [ ] **Step 6: Commit**
+
+ ```bash
+ git add internal/edge/edge.go internal/edge/edge_test.go
+ git commit -m "edge: check the response cache before waking a hibernating instance"
+ ```
+
+---
+
+### Task 3: Local purge listener
+
+**Files:**
+- Create: `internal/edge/purge.go`
+- Test: `internal/edge/purge_test.go`
+
+**Interfaces:**
+- Consumes: `*Cache` (Task 1), `Cache.PurgeURL`.
+- Produces:
+ ```go
+ type PurgeListener struct{ ... }
+ func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error)
+ func (p *PurgeListener) Close() error
+ ```
+ The listener binds a unix socket at `socketPath` (removing any stale
+ file left from a previous run first), mode `0600`, and serves exactly
+ one route: `POST /purge` with a JSON body `{"path":"/event","query":""}`.
+ `host` is fixed at listener-construction time — the request body never
+ names a host, so an instance can only ever purge its own route's cache
+ entries, by construction (not just by socket-file permission).
+
+- [ ] **Step 1: Write the failing tests**
+
+ ```go
+ package edge
+
+ import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+ )
+
+ func dialPurgeSocket(t *testing.T, socketPath string) *http.Client {
+ t.Helper()
+ return &http.Client{
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return net.Dial("unix", socketPath)
+ },
+ },
+ }
+ }
+
+ func TestListenPurgeRemovesCachedEntry(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ cache.Put(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}, &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
+ })
+
+ pl, err := ListenPurge(sock, "a.example", cache)
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
+ client := dialPurgeSocket(t, sock)
+ resp, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("purge POST: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("purge status = %d, want 200", resp.StatusCode)
+ }
+
+ if _, ok := cache.Get(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}); ok {
+ t.Fatal("expected entry to be purged")
+ }
+ }
+
+ func TestListenPurgeCannotAffectAnotherHost(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ cache.Put(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}, &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
+ })
+
+ pl, err := ListenPurge(sock, "a.example", cache) // bound to a.example, not other.example
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
+ client := dialPurgeSocket(t, sock)
+ resp, _ := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ resp.Body.Close()
+
+ if _, ok := cache.Get(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}); !ok {
+ t.Fatal("a purge listener bound to a.example must never affect other.example's cache entries")
+ }
+ }
+
+ func TestListenPurgeSocketFileIsOwnerOnly(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ fi, err := os.Stat(sock)
+ if err != nil {
+ t.Fatalf("stat socket: %v", err)
+ }
+ if mode := fi.Mode().Perm(); mode != 0600 {
+ t.Fatalf("socket mode = %o, want 0600", mode)
+ }
+ }
+
+ func TestListenPurgeRemovesStaleSocketFile(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ if err := os.WriteFile(sock, []byte("stale"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
+ if err != nil {
+ t.Fatalf("ListenPurge should clean up a stale socket file, got: %v", err)
+ }
+ pl.Close()
+ }
+ ```
+
+ Add `"context"` to the imports used by `dialPurgeSocket`.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+ Run: `go test ./internal/edge/ -run TestListenPurge -v`
+ Expected: FAIL — `ListenPurge` undefined.
+
+- [ ] **Step 3: Implement**
+
+ ```go
+ package edge
+
+ import (
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "os"
+ )
+
+ // PurgeListener is a per-instance, local-only channel for invalidating
+ // that instance's cache entries right after a write — see
+ // docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §3-4.
+ // It is deliberately NOT reachable over the network: an externally
+ // reachable purge endpoint would let anyone manufacture wake storms by
+ // force-purging popular pages, exactly what this feature exists to
+ // prevent.
+ type PurgeListener struct {
+ ln net.Listener
+ srv *http.Server
+ }
+
+ type purgeRequest struct {
+ Path string `json:"path"`
+ Query string `json:"query"`
+ }
+
+ // ListenPurge binds socketPath (removing any stale file left behind by a
+ // crashed previous instance) and serves purge requests scoped to host —
+ // host is fixed here, never taken from the request, so a compromised or
+ // buggy instance can only ever purge its own cache entries.
+ func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) {
+ if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ ln, err := net.Listen("unix", socketPath)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.Chmod(socketPath, 0600); err != nil {
+ ln.Close()
+ return nil, err
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /purge", func(w http.ResponseWriter, r *http.Request) {
+ var req purgeRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ cache.PurgeURL(host, req.Path, req.Query)
+ w.WriteHeader(http.StatusOK)
+ })
+
+ srv := &http.Server{Handler: mux}
+ p := &PurgeListener{ln: ln, srv: srv}
+ go srv.Serve(ln) //nolint:errcheck // Close() below triggers the expected ErrServerClosed
+ return p, nil
+ }
+
+ func (p *PurgeListener) Close() error {
+ return p.srv.Close()
+ }
+ ```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+ Run: `go test ./internal/edge/ -run TestListenPurge -v`
+ Expected: PASS (all four).
+
+- [ ] **Step 5: Commit**
+
+ ```bash
+ git add internal/edge/purge.go internal/edge/purge_test.go
+ git commit -m "edge: add local-only per-instance purge listener"
+ ```
+
+---
+
+### Task 4: Wire the purge listener into instance spawn
+
+**Files:**
+- Modify: `internal/activator/backend_exec.go`
+- Modify: `internal/host/agent.go`
+- Test: `internal/activator/backend_exec_test.go` (or wherever
+ `ExecBackend`'s existing tests live — check `backend_unit_test.go`'s
+ neighbor file for the exec-specific test file name first)
+
+**Interfaces:**
+- Consumes: `edge.Cache`, `edge.ListenPurge`, `edge.NewCache`,
+ `edge.DefaultCacheMaxEntries`, `edge.DefaultCacheMaxBodyBytes` (Tasks 1
+ and 3).
+- Produces: `ExecBackend.Cache *edge.Cache` field (nil disables
+ purge-listener startup, per this plan's Global Constraints); the
+ spawned instance process now also receives a `--control-socket <path>`
+ flag.
+
+- [ ] **Step 1: Write the failing test**
+
+ `ExecBackend`'s existing tests live in `internal/activator/activator_test.go`
+ (search for `func testExecBackend(t *testing.T) *ExecBackend` and
+ `func stageLive(t *testing.T, b *ExecBackend, host, ver string)` — both
+ already exist there and are reused below verbatim). Note there is no
+ existing test that runs `ExecBackend.Start` all the way through a
+ successful health-check — `stageLive`'s fake binary is just
+ `#!/bin/sh\n`, which never actually binds `rt.Socket`, so `Start` is
+ expected to return a health-wait error in this test too. That's fine:
+ the purge listener starts (per Step 3's placement, right after
+ `cmd.Start()` succeeds) *before* the health-wait runs, so the control
+ socket file exists by the time `Start` returns, regardless of whether
+ it returns an error. The test asserts on the control socket file
+ directly and explicitly ignores `Start`'s returned error.
+
+ ```go
+ func TestExecBackendStartPassesControlSocketFlagAndStartsPurgeListener(t *testing.T) {
+ b := testExecBackend(t)
+ b.Cache = edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+ stageLive(t, b, "control-socket.example.com", "v1")
+
+ rt := registry.Route{
+ Host: "control-socket.example.com",
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+ wantControlSocket := rt.Socket + ".control"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt) // health-wait error from the fake binary is expected and irrelevant here
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ if _, err := os.Stat(wantControlSocket); err != nil {
+ t.Fatalf("expected control socket %s to exist after Start, got: %v", wantControlSocket, err)
+ }
+ }
+
+ func TestExecBackendStartWithNilCacheSkipsPurgeListener(t *testing.T) {
+ b := testExecBackend(t) // b.Cache left nil
+ stageLive(t, b, "no-cache.example.com", "v1")
+
+ rt := registry.Route{
+ Host: "no-cache.example.com",
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ if _, err := os.Stat(rt.Socket + ".control"); err == nil {
+ t.Fatal("expected no control socket when ExecBackend.Cache is nil")
+ }
+ }
+ ```
+
+ Add `"github.com/carlosframework/platform/internal/edge"` to this test
+ file's imports if not already present.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+ Run: `go test ./internal/activator/ -run TestExecBackendStartPassesControlSocketFlag -v`
+ Run: `go test ./internal/activator/ -run TestExecBackendStartWithNilCacheSkipsPurgeListener -v`
+ Expected: FAIL — `ExecBackend.Cache` field doesn't exist yet (compile
+ error).
+
+- [ ] **Step 3: Implement**
+
+ In `internal/activator/backend_exec.go`, add the field to `ExecBackend`
+ (find its struct definition near the top of the file, alongside `Cfg`,
+ `OptDir`, `DataDir`):
+
+ ```go
+ // Cache, when non-nil, gets a purge listener started for each spawned
+ // instance so it can invalidate its own cached responses right after a
+ // write. Nil disables this — no purge listener, no error.
+ Cache *edge.Cache
+ ```
+
+ Add the import: `"github.com/carlosframework/platform/internal/edge"`
+ (match this repo's actual module path — check `go.mod`'s `module` line
+ if this isn't it verbatim).
+
+ Modify `Start` — locate the existing
+ `cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)` line
+ and the `childProc{cmd: cmd, ...}` construction just after `cmd.Start()`
+ succeeds:
+
+ ```go
+ controlSocket := rt.Socket + ".control"
+ cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath, "--control-socket", controlSocket)
+ cmd.Env = childEnv(fileEnv)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ spawnVer, _ := b.LiveVersion(host)
+ if err := cmd.Start(); err != nil {
+ return nil, "", err
+ }
+
+ p := &childProc{cmd: cmd, done: make(chan struct{})}
+ if b.Cache != nil {
+ pl, err := edge.ListenPurge(controlSocket, rt.Host, b.Cache)
+ if err != nil {
+ // Not fatal to the wake — the instance still serves requests,
+ // it just can't purge its own cache until the next restart.
+ // A stuck-stale cache self-heals via the safety-net TTL anyway.
+ log.Printf("activator: purge listener for %s: %v (continuing without it)", rt.Host, err)
+ } else {
+ p.purge = pl
+ }
+ }
+ go func() {
+ p.err = cmd.Wait()
+ close(p.done)
+ }()
+ ```
+
+ Add the field to `childProc` (find its struct definition — near
+ `func (p *childProc) Stop` seen earlier):
+
+ ```go
+ purge *edge.PurgeListener // nil if b.Cache was nil or ListenPurge failed
+ ```
+
+ Modify `childProc.Stop` to close it:
+
+ ```go
+ func (p *childProc) Stop(ctx context.Context) error {
+ if p.purge != nil {
+ p.purge.Close()
+ }
+ // ... existing body unchanged
+ }
+ ```
+
+ In `internal/host/agent.go`'s `RunAgent`, construct the cache before
+ the activator so both `ExecBackend` and `edge.RunWithActivator` share
+ the same instance:
+
+ ```go
+ cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+
+ cfg, err := activator.ConfigFromEnv()
+ if err != nil {
+ log.Fatalf("agent: %v", err)
+ }
+ var act edge.Activator
+ var svc *activator.Service
+ if cfg != nil {
+ svc = activator.New(*cfg, &activator.SplitBackend{
+ Exec: &activator.ExecBackend{
+ Cfg: *cfg,
+ OptDir: release.OptDir(),
+ DataDir: dd,
+ Cache: cache,
+ },
+ Unit: &activator.UnitBackend{OptDir: release.OptDir()},
+ }, reg)
+ svc.Start()
+ act = svc
+ log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle)
+ }
+
+ // ... adoptLoop/canaryAdoptLoop/statusLoop/domainsLoop goroutines unchanged ...
+
+ edge.RunWithActivator(args, act, cache)
+ ```
+
+ (Only the two lines constructing `cache` and passing it into both the
+ `ExecBackend` literal and the final `edge.RunWithActivator` call
+ change; everything else in `RunAgent` stays as-is.)
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+ Run: `go test ./internal/activator/... ./internal/host/... -v`
+ Expected: PASS, including all pre-existing tests in both packages
+ (`UnitBackend`'s tests are untouched by this task — only `ExecBackend`
+ changed).
+
+- [ ] **Step 5: Run the full build and vet**
+
+ Run: `go build ./... && go vet ./...`
+ Expected: clean. This is the step that catches a missed call site if
+ `RunWithActivator`'s new third parameter wasn't threaded everywhere.
+
+- [ ] **Step 6: Commit**
+
+ ```bash
+ git add internal/activator/backend_exec.go internal/host/agent.go internal/activator/backend_exec_test.go
+ git commit -m "activator: start a purge listener alongside each spawned instance"
+ ```
+
+---
+
+### Task 5: End-to-end cache-before-wake test
+
+**Files:**
+- Modify: `internal/edge/edge_test.go` (or a new
+ `internal/edge/cache_before_wake_test.go` if the existing file is
+ already large — match this repo's existing file-size norms, checked
+ during Task 2)
+
+**Interfaces:**
+- Consumes: everything from Tasks 1-4. No new production code — this
+ task is purely a test that exercises the whole path together, standing
+ in for the spec's §8 "one test that has to exist even if everything
+ else is cut for time."
+
+- [ ] **Step 1: Write the test**
+
+ Uses the same `openReg`/`reg.Upsert`/`New(reg, act)` fixtures as
+ Task 2, plus a new `activatorFunc` adapter (a plain-function `Activator`
+ for tests that want custom per-call behavior — distinct from the file's
+ existing `fakeActivator`, which always proxies straight through and
+ can't count wakes the way this test needs).
+
+ ```go
+ // activatorFunc adapts a plain function to the Activator interface, for
+ // tests that want custom wake-counting behavior without a full fake type.
+ type activatorFunc func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool
+
+ func (f activatorFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ return f(w, r, rt, proxy)
+ }
+
+ // TestCacheBeforeWakeFullCycle exercises the sequence the whole feature
+ // exists for: a cold miss wakes the instance and populates the cache; a
+ // repeat request hits the cache and does NOT wake the instance again; a
+ // write's purge call makes the next request miss and see fresh content.
+ func TestCacheBeforeWakeFullCycle(t *testing.T) {
+ body := "version 1"
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(200)
+ w.Write([]byte(body))
+ }))
+ t.Cleanup(backend.Close)
+
+ wakes := 0
+ act := activatorFunc(func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ wakes++
+ proxy.ServeHTTP(w, r)
+ return true
+ })
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "cycle.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ e.EnableCache(cache)
+
+ get := func() string {
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cycle.example.com/event", nil))
+ return rec.Body.String()
+ }
+
+ if got := get(); got != "version 1" {
+ t.Fatalf("first request body = %q, want %q", got, "version 1")
+ }
+ if wakes != 1 {
+ t.Fatalf("expected exactly 1 wake for the cold request, got %d", wakes)
+ }
+
+ if got := get(); got != "version 1" {
+ t.Fatalf("second request body = %q, want %q", got, "version 1")
+ }
+ if wakes != 1 {
+ t.Fatalf("expected the second request to hit cache without waking again, wakes = %d", wakes)
+ }
+
+ // Simulate the write + purge an instance does after a real change.
+ body = "version 2"
+ cache.PurgeURL("cycle.example.com", "/event", "")
+
+ if got := get(); got != "version 2" {
+ t.Fatalf("post-purge request body = %q, want %q", got, "version 2")
+ }
+ if wakes != 2 {
+ t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes)
+ }
+ }
+ ```
+
+- [ ] **Step 2: Run the test to verify it fails (before this task, it doesn't exist; run it right after writing to confirm the harness compiles and the assertions are meaningful)**
+
+ Run: `go test ./internal/edge/ -run TestCacheBeforeWakeFullCycle -v`
+ Expected: PASS immediately if Tasks 1-4 are correctly implemented — this
+ task adds coverage, it doesn't drive new production code. If it fails,
+ that's a real regression in one of Tasks 1-4 to fix before proceeding,
+ not a signal to weaken this test.
+
+- [ ] **Step 3: Run the full edge package test suite**
+
+ Run: `go test ./internal/edge/... -v`
+ Expected: PASS, all tests from every task in this plan plus every
+ pre-existing test in the package.
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add internal/edge/edge_test.go
+ git commit -m "edge: add end-to-end cache-before-wake test"
+ ```
+
+---
+
+## After this plan
+
+The platform side is done: any `routed` app that sets `Cache-Control` on
+its public GET responses gets cache-before-wake for free, and any
+hibernating instance can purge its own entries via
+`<its-socket-path>.control`. Nothing here is titogo-specific — titogo's
+own consumer-side changes (cache headers, the availability-fragment split,
+purge calls on writes, and the `SalesEndAt` default) are planned
+separately in `paulca/titogo`'s own
+`docs/superpowers/plans/2026-08-02-edge-cache-before-wake-titogo.md`,
+which depends on this plan's `--control-socket` flag and purge wire
+protocol (`POST /purge` with `{"path":"...","query":"..."}`) existing
+first.
diff --git a/docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md b/docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md
new file mode 100644
index 000000000..412722727
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md
@@ -0,0 +1,270 @@
+# 🤖 Edge Cache-Before-Wake — Keeping Bots From Needlessly Waking Hibernated Instances
+
+> 🤖 This document was written by an LLM (Claude), on the ideas, instruction,
+> and editing of humans, per [factor X](https://11factor.org/#x).
+
+**Status:** Concept spec, approved in brainstorming 2026-08-02.
+
+**Relationship to the platform spec:** the
+[CARLOS platform design](2026-08-01-carlos-platform-design.md) defines
+hibernation, wake, and the edge/activator split in the abstract (§4, §5).
+This document adds one capability to that picture — caching GET responses
+at the edge so a wake is never triggered for traffic a cache can already
+answer — without changing any of the platform's hibernation semantics.
+Where the two could diverge, the platform spec wins.
+
+## 1. Purpose
+
+Once an app hibernates (spec §4), any HTTP request to a parked instance
+wakes it — that's the whole point of the activator. But not all GET
+traffic represents a real visitor: search-engine crawlers, LLM/agent
+scrapers, and general bot traffic hit public pages too, and none of them
+care whether the response is five seconds or five minutes old. Left
+unmitigated, that traffic defeats hibernation's entire cost story for any
+app with public, crawlable pages — an instance that would otherwise park
+for hours gets woken every few minutes by traffic that was never going to
+buy anything.
+
+Motivating case: **Tito Go** (`paulca/titogo`, self-hosted on the Tito
+CARLOS deployment per
+[2026-08-01-tito-production-infra-design.md](2026-08-01-tito-production-infra-design.md)).
+Titogo hasn't yet adopted the platform's hibernating activator — that
+migration ("Stage 2 adoption") is still open — but every event page it
+serves is exactly this kind of public, crawlable, bot-attractive surface,
+so this is a real blocker worth solving before that migration lands, not
+after.
+
+## 2. Decisions (settled in brainstorming, 2026-08-02)
+
+1. **This is a platform capability, not an app-level workaround.** It
+ lives at the edge, general to any `routed` app, the same way static
+ serving (spec §5) is a platform capability rather than something each
+ static app reimplements. Titogo is the motivating and first consumer,
+ not the only one.
+2. **Built now, independent of titogo's Stage 2 hibernation migration.**
+ The capability is valuable even for always-on apps today (less load,
+ faster bot responses, lower bandwidth), and whichever app adopts
+ hibernation later gets the wake-prevention benefit automatically —
+ there's no reason to gate this work on Stage 2's undated completion.
+3. **Correctness over cleverness: cache freshness is enforced by
+ purge-on-write, not by tolerating staleness.** A real write (event
+ edit, ticket sale, sales open/close) always already requires the
+ instance to be awake — so having it also signal a purge afterward
+ costs nothing in extra wakes. This is what makes "cache everything
+ public" safe rather than a staleness trade-off: bots and real visitors
+ are served the identical, always-fresh cache, so no bot-detection is
+ needed anywhere in this design.
+4. **A safety-net TTL bounds the worst case.** Purge is best-effort,
+ fire-and-forget from the instance's side — it doesn't block or retry
+ the write it followed. A short fixed ceiling (exact value: an
+ implementation-plan tuning choice, not fixed here) means a lost or
+ buggy purge call self-heals within that window rather than serving
+ stale data indefinitely.
+
+## 3. Architecture
+
+`internal/edge/edge.go`'s `Edge.ServeHTTP` already has the right seam: for
+a hibernating route it calls `e.act.ServeHTTP(w, r, up.route, up.proxy)` —
+the `Activator` interface that triggers or joins a wake — and only falls
+through to the raw `proxy` once the instance is confirmed up. The cache
+check slots in **immediately before that activator call**: for GET/HEAD
+requests, look up a cached response by cache key (§5); on a hit, write it
+directly and return. The activator, and therefore any wake, is never
+reached. On a miss, today's behavior is unchanged, except that a
+cacheable response is captured on the way back through.
+
+New components, all inside the `carlos agent` process (edge and activator
+already share one process per box, per `edge.RunWithActivator`):
+
+- **`internal/edge/cache.go`** (new) — an in-memory, per-host response
+ cache, keyed as in §5. Populated only from responses the app marked
+ cacheable via the standard `Cache-Control: max-age=N` response header
+ (titogo already emits `Cache-Control: no-store` on its checkout render
+ today — this reuses the same mechanism in the positive direction). No
+ new manifest schema.
+- **A local purge channel** (new) — instances currently have no way to
+ call back into their own box's `carlos agent` process; this needs a
+ small new one (a unix socket or loopback endpoint the co-located edge
+ exposes), reachable **only locally, from the same box** — never over
+ the public network. An instance calls it right after a write to
+ invalidate the cache key(s) that write affected.
+- **A hard cache size cap with LRU eviction.** This project exists to
+ save RAM; an unbounded cache would be self-defeating. Exact
+ count/byte-size limits: an implementation-plan tuning choice.
+
+## 4. Data flow
+
+**Cache hit** (the common case for bot/crawler traffic once warm): the
+edge finds a valid entry for the request's cache key and writes it
+directly. The instance is never touched.
+
+**Cache miss** (cold cache, or first request after purge/TTL expiry): the
+edge proceeds exactly as today — the activator wakes the instance (or
+joins an in-progress wake; this existing coalescing behavior,
+`TestUnitTenantWakeServesHeldRequest`, is unchanged and the cache layer
+does not reimplement it) and proxies the request through. The edge
+inspects the real response on the way back; if it's a 2xx carrying a
+cacheable `Cache-Control`, the edge stores it before returning it to the
+client. Non-2xx responses are never cached, at any TTL — a transient
+error must not get stuck serving itself for the whole window.
+
+**Purge**: the instance is already awake (it just wrote something) and
+calls the local purge channel with the affected cache key(s) — e.g. an
+event edit purges the page shell key; a ticket sale purges the
+availability-fragment key (§6). The edge drops those entries immediately;
+the next request for them is a miss and repopulates on the next real
+response.
+
+A request in flight when a write commits could theoretically finish and
+populate the cache with pre-write data *after* the purge fires, briefly
+re-poisoning it. This is accepted, bounded by the safety-net TTL, not
+solved with stronger consistency machinery — consistent with decision 4.
+
+## 5. Cache key and locale
+
+Cache key = request URL (path + query string) **plus a normalized locale
+bucket**, negotiated the same way titogo's `publicLocale` (§6) does today
+from `Accept-Language`, clamped to the event's enabled locale set. This
+means one cache entry per event per *enabled* locale — a small, bounded
+fan-out, not one per visitor.
+
+The `titogo_lang` cookie (an explicit switcher preference, per
+`internal/instance/locale.go`) is **deliberately not part of the cache
+key**. Varying the cache by cookie was considered and rejected: it tends
+to make every visitor a cache miss (cookies differ per-visitor for
+unrelated reasons — analytics, sessions), which would defeat the point.
+The accepted consequence: a returning visitor whose cookie preference
+differs from what their browser's `Accept-Language` negotiates to sees
+the negotiated version on a cache **hit**, not their saved cookie choice.
+An explicit `?lang=xx` URL — already how the switcher persists a choice
+today — is its own distinct, independently cacheable and purgeable key,
+so a visitor who actually clicks the switcher still gets exactly what
+they asked for; only the passive cookie-remembered case is affected, and
+only on a page they haven't explicitly re-chosen the language on this
+visit.
+
+## 6. Titogo-side changes (`paulca/titogo`)
+
+None of these touch the platform's caching mechanism — they're titogo
+being a well-behaved consumer of it.
+
+1. **Cache headers on public GET routes.** `handleHome`, `handleEvent` /
+ `handleEventFlow`, and published custom pages (`pages.go`) set
+ `Cache-Control: max-age=N` (N: a plan-level tuning choice — the purge
+ path is what keeps things fresh, so this ceiling only bounds the
+ worst case of a missed purge). Every token-gated route (orders,
+ tickets, portals, checkout, quotes) stays uncached, exactly as today —
+ already effectively private by unguessable token, and caching it would
+ leak state across requests.
+2. **A cacheable ticket-availability fragment.** Today `handleEventFlow`
+ computes `onSaleOnly`/`ticketSalesOpen` (`shop.go`) and bakes
+ availability straight into the full server-rendered page — exactly the
+ part that changes on every sale. This splits: the event page shell
+ (title, description, dates, images, price display) becomes cacheable;
+ live availability becomes a small separate endpoint the page fetches
+ client-side after load. That fragment is cacheable and purgeable via
+ the same mechanism as the shell — a second cache key per event, not a
+ special case.
+3. **Purge calls on writes.** Any admin write that changes a cached
+ shell (event save) or fragment (ticket sale, inventory change, sales
+ open/close) calls the local purge channel for the affected key(s)
+ right after committing.
+
+**Also, a data-hygiene fix surfaced during this brainstorm, bundled here
+because it shrinks the set of pages that can mutate indefinitely (which
+is part of what makes long-lived caching safe):** default `SalesEndAt` to
+the event's own end date when the organizer hasn't configured one
+explicitly, instead of leaving sales open forever
+(`ticketSalesOpen := ev.SalesEndAt == 0 || now() < ev.SalesEndAt` in
+`shop.go` currently treats unset as "always open"). **Explicit
+configuration always overrides the derived default** — this is a change
+to the default only, not to what's configurable, so a deliberate future
+opt-in for keeping sales open past an event (e.g. collecting payment at
+the door from walk-ins who didn't pre-pay — `door.go`'s existing on-site
+payment flow is exactly this case) stays a simple config exposure, not a
+fight against this change. That opt-in is explicitly **out of scope for
+this spec** — noted so it isn't lost, not designed here.
+
+## 7. Error handling and edge cases
+
+- **Purge channel is local-only,** reachable exclusively by the
+ instance's own process on the same box. This is a deliberate security
+ boundary, not an oversight: an externally-reachable purge endpoint
+ would be a new DoS vector — force-purging popular pages to manufacture
+ wake storms is exactly the failure mode this whole design exists to
+ prevent.
+- **Never cache non-2xx responses,** at any TTL.
+- **Memory bounds:** hard cap + LRU eviction (§3).
+- **Wake-coalescing** is unchanged, existing activator behavior; the
+ cache sits strictly in front of it.
+- **Purge/populate race:** accepted, bounded by the safety-net TTL (§4).
+- **Host relocation/failover:** the cache is per-host and in-memory; a
+ new or recovered host starts cold. The first real request after a move
+ is one ordinary wake — consistent with how this platform already
+ treats hosts as disposable (platform spec §4).
+
+## 8. Testing
+
+- **Edge cache unit tests** (`internal/edge`): hit/miss/TTL-expiry/
+ eviction. The test that actually proves the mitigation: a cache hit
+ must never invoke the `Activator` — asserted with a fake activator that
+ fails the test if called at all.
+- **Purge channel tests:** a purge invalidates exactly the targeted
+ key(s) and nothing else; the channel is unreachable except locally (an
+ isolation test, not just a happy path, given §7's DoS concern).
+- **Locale bucketing tests:** two `Accept-Language` values negotiating to
+ the same enabled locale share one cache entry; different negotiated
+ locales get separate entries; the `titogo_lang` cookie never affects
+ the cache key.
+- **titogo-side tests:** public routes emit the expected `Cache-Control`;
+ every token-gated route never does; writes call purge for the correct
+ key(s); the default-`SalesEndAt`-from-event-date logic (extending the
+ existing `expiry_test.go` pattern), with explicit configuration still
+ winning over the derived default.
+- **End-to-end**, alongside the platform's existing `e2e` suite: cold
+ miss wakes the instance and populates the cache → repeat request hits
+ and does **not** wake it → a write plus its purge call makes the next
+ request miss again and reflect the change. This three-step sequence is
+ the whole point of the design and should survive even if everything
+ else here is cut for time.
+
+## 9. Considered and rejected
+
+- **A CDN (CloudFront) fronting `routed` apps,** extending the existing
+ static-only CDN binding (platform spec §5). Would offload bot traffic
+ even further — it would never reach the box at all — but it's
+ AWS-specific infrastructure, conflicts with this platform's explicit
+ "no load balancers, self-hostable without a cloud provider" stance, and
+ CloudFront invalidation isn't instant (seconds, not immediate), a worse
+ fit given decision 3's freshness requirement. Left as a possible future
+ accelerator layered on top of this design for deployments that want it
+ — never the default mechanism.
+- **Bot-detection / user-agent gating** instead of caching: serving
+ crawlers a degraded response while real users always wake the instance
+ reintroduces exactly the brittleness (UA sniffing, false positives
+ hurting real SEO) caching avoids, and is unnecessary once purge-on-write
+ makes the cache always-fresh for everyone regardless of who's asking.
+
+## 10. Open questions
+
+1. **Exact purge-channel transport.** Unix socket vs. an extension of an
+ existing local channel — no existing instance→host-agent control path
+ was found in `internal/activator` or `internal/host`, so this is new
+ plumbing either way. A plan-level decision.
+2. **TTL and cache-size tuning** (§3, §4, §6): concrete numbers, not
+ fixed here.
+3. **Exact shell/fragment split boundary** for titogo's event page: this
+ spec establishes that availability must be a separate fetch from the
+ cacheable shell, not the precise template/markup boundary — a
+ titogo-repo implementation detail.
+
+## 11. Provenance
+
+Brainstormed 2026-08-02, prompted directly by Paul's cost analysis of
+Tito Go's AWS footprint (see the platform-repo session's cost-scope
+discussion) surfacing that titogo hasn't yet migrated to the platform's
+hibernating activator, and that migration's cost benefit would be
+undermined by unmitigated bot/crawler traffic if solved after the fact
+rather than before. Three ideas from that discussion became this design's
+core: caching public pages, lazy-loading dynamic ticket availability, and
+defaulting ticket sales closed for events with no explicit configured end.
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index 5aee62fd7..8b48b26c0 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -20,12 +20,14 @@ import (
"net/http/httputil"
"os"
"path/filepath"
+ "slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/registry"
)
@@ -1909,3 +1911,191 @@ func TestExecBackendHasSidecar(t *testing.T) {
t.Fatal("HasSidecar false with the sidecar env file staged")
}
}
+
+// stageRecordingLive stages host's live binary as a shell script that dumps
+// its argv and its CARLOS_CONTROL_SOCKET to two files and exits — the only way
+// to see what the child was ACTUALLY handed, argv and environment both.
+// Returns the two paths.
+func stageRecordingLive(t *testing.T, b *ExecBackend, host, ver string) (argvPath, envPath string) {
+ t.Helper()
+ dir := t.TempDir()
+ argvPath = filepath.Join(dir, "argv")
+ envPath = filepath.Join(dir, "env")
+ script := "#!/bin/sh\n" +
+ "printf '%s\\n' \"$@\" > '" + argvPath + "'\n" +
+ "printf '%s\\n' \"${CARLOS_CONTROL_SOCKET-<unset>}\" > '" + envPath + "'\n"
+
+ vdir := filepath.Join(b.OptDir, "versions", ver)
+ if err := os.MkdirAll(vdir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ bin := filepath.Join(vdir, "app")
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.Remove(b.liveLink(host))
+ if err := os.Symlink(bin, b.liveLink(host)); err != nil {
+ t.Fatal(err)
+ }
+ return argvPath, envPath
+}
+
+// waitForFile polls until path exists (the child writes it concurrently with
+// Start's health wait) and returns its contents.
+func waitForFile(t *testing.T, path string, within time.Duration) string {
+ t.Helper()
+ deadline := time.Now().Add(within)
+ for time.Now().Before(deadline) {
+ if b, err := os.ReadFile(path); err == nil {
+ return string(b)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("%s never appeared within %s", path, within)
+ return ""
+}
+
+// Start hands the spawned instance its control socket through the ENVIRONMENT
+// (CARLOS_CONTROL_SOCKET), never as a CLI flag, and starts a purge listener
+// bound to it whenever ExecBackend.Cache is set.
+//
+// The argv assertion is the regression guard: a --control-socket flag makes
+// every app binary that predates this feature exit 2 at startup (Go's flag
+// package with ExitOnError, which is what app binaries use), i.e. a
+// fleet-wide outage the moment the agent upgrades. An unknown environment
+// variable is simply ignored by such a binary.
+//
+// Deviation from the task brief's literal test: the brief asserted the
+// control socket file still exists AFTER Start returns, even on the
+// health-wait error the fake binary (which never binds rt.Socket) is expected
+// to produce here. That's not actually true of the implementation the same
+// brief specifies — Start's pre-existing health-wait-failure path calls
+// p.Stop() before returning, and Step 3's own childProc.Stop change closes
+// the purge listener there, which (per Go's net.Listener.Close on a unix
+// socket the listener itself created) unlinks the socket file. So by the time
+// Start returns with an error, the file is already gone — confirmed
+// empirically, not a guess. This test instead polls for the socket WHILE
+// Start's health-wait is still running (i.e. before its internal Stop cleanup
+// fires), which is the invariant that's actually guaranteed and is what
+// "Start wires up the purge listener" means in practice.
+func TestExecBackendStartPassesControlSocketInEnvAndStartsPurgeListener(t *testing.T) {
+ b := testExecBackend(t)
+ b.Cache = edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+ argvPath, envPath := stageRecordingLive(t, b, "control-socket.example.com", "v1")
+
+ rt := registry.Route{
+ Host: "control-socket.example.com",
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+ wantControlSocket := rt.Socket + ".control"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ done := make(chan struct{})
+ var proc Proc
+ go func() {
+ proc, _, _ = b.Start(ctx, rt) // health-wait error from the fake binary is expected and irrelevant here
+ close(done)
+ }()
+
+ found := false
+ deadline := time.Now().Add(1500 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if _, err := os.Stat(wantControlSocket); err == nil {
+ found = true
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ gotArgv := strings.Fields(waitForFile(t, argvPath, time.Second))
+ gotEnv := strings.TrimSpace(waitForFile(t, envPath, time.Second))
+
+ <-done
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ wantArgv := []string{"--socket", rt.Socket, "--db", filepath.Join(b.DataDir, rt.Host+".db")}
+ if !slices.Equal(gotArgv, wantArgv) {
+ t.Fatalf("child argv = %v, want %v — a flag the app binary doesn't parse makes it exit 2 at startup", gotArgv, wantArgv)
+ }
+ if gotEnv != wantControlSocket {
+ t.Fatalf("child CARLOS_CONTROL_SOCKET = %q, want %q", gotEnv, wantControlSocket)
+ }
+ if !found {
+ t.Fatalf("expected control socket %s to exist while Start's health-wait ran", wantControlSocket)
+ }
+}
+
+// A nil ExecBackend.Cache disables the purge listener entirely — no control
+// socket, no CARLOS_CONTROL_SOCKET in the child's environment (there is
+// nothing for it to talk to), no error.
+func TestExecBackendStartWithNilCacheSkipsPurgeListener(t *testing.T) {
+ b := testExecBackend(t) // b.Cache left nil
+ _, envPath := stageRecordingLive(t, b, "no-cache.example.com", "v1")
+
+ rt := registry.Route{
+ Host: "no-cache.example.com",
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ if _, err := os.Stat(rt.Socket + ".control"); err == nil {
+ t.Fatal("expected no control socket when ExecBackend.Cache is nil")
+ }
+ if got := strings.TrimSpace(waitForFile(t, envPath, time.Second)); got != "<unset>" {
+ t.Fatalf("child CARLOS_CONTROL_SOCKET = %q, want it unset when there is no cache", got)
+ }
+}
+
+// Spawning a fresh process for a host is a deploy boundary: the code behind
+// the host just changed, so every response cached from the old process is
+// stale. Start drops them all before the new process serves anything —
+// without this, a redeploy keeps serving old HTML until the TTL expires.
+func TestExecBackendStartPurgesHostCacheOnSpawn(t *testing.T) {
+ b := testExecBackend(t)
+ cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+ b.Cache = cache
+ stageLive(t, b, "deploy.example.com", "v1")
+
+ stale := edge.CacheKey{Host: "deploy.example.com", Method: "GET", Path: "/event"}
+ otherHost := edge.CacheKey{Host: "bystander.example.com", Method: "GET", Path: "/event"}
+ for _, k := range []edge.CacheKey{stale, otherHost} {
+ cache.Put(k, "", &edge.CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("old html"),
+ StoredAt: time.Now(), TTL: time.Minute,
+ })
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ rt := registry.Route{
+ Host: "deploy.example.com",
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+ proc, _, _ := b.Start(ctx, rt) // health-wait failure is expected; the spawn still happened
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ if _, ok := cache.Get(stale); ok {
+ t.Fatal("spawning a fresh process for a host must drop that host's cached responses")
+ }
+ if _, ok := cache.Get(otherHost); !ok {
+ t.Fatal("another host's cached responses must survive a spawn")
+ }
+}
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index ffc0b128b..f04447ee9 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -19,6 +19,7 @@ import (
"syscall"
"time"
+ "github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -50,6 +51,10 @@ type ExecBackend struct {
// but a carlos app is a separate binary from the release store — there is
// nothing sensible to invent here, and the adopt pass owns the symlinks.
Materialize func(ctx context.Context, host string) error
+ // Cache, when non-nil, gets a purge listener started for each spawned
+ // instance so it can invalidate its own cached responses right after a
+ // write. Nil disables this — no purge listener, no error.
+ Cache *edge.Cache
}
var _ Backend = (*ExecBackend)(nil)
@@ -231,8 +236,21 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
return nil, "", fmt.Errorf("read env file: %w", err)
}
+ // The control socket reaches the child through its ENVIRONMENT, never as a
+ // CLI flag: every app binary built before this feature existed parses its
+ // flags with flag.ExitOnError, which exits 2 on an unrecognized flag — so
+ // passing --control-socket would refuse to start every app that hasn't
+ // also shipped support for it, a fleet-wide outage on agent upgrade. An
+ // environment variable a binary doesn't know about is simply ignored.
+ // Only set when there's actually a purge listener to talk to, so the
+ // child's environment stays honest about what exists.
+ controlSocket := rt.Socket + ".control"
+ childVars := fileEnv
+ if b.Cache != nil {
+ childVars = withVar(fileEnv, "CARLOS_CONTROL_SOCKET", controlSocket)
+ }
cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)
- cmd.Env = childEnv(fileEnv)
+ cmd.Env = childEnv(childVars)
// The child logs through the agent's own stderr/stdout (journald under
// systemd); there's no per-instance unit to own a log stream anymore.
cmd.Stdout = os.Stdout
@@ -252,6 +270,25 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
}
p := &childProc{cmd: cmd, done: make(chan struct{})}
+ if b.Cache != nil {
+ // A fresh process for this host is exactly the moment its cached
+ // content becomes suspect: a deploy/redeploy means new code and
+ // potentially new pages, and nothing else would invalidate them until
+ // their TTL ran out. Drop the whole host's entries before the new
+ // process serves its first request.
+ if n := b.Cache.PurgeHost(rt.Host); n > 0 {
+ log.Printf("activator: purged %d cached response(s) for %s on spawn", n, rt.Host)
+ }
+ pl, err := edge.ListenPurge(controlSocket, rt.Host, b.Cache)
+ if err != nil {
+ // Not fatal to the wake — the instance still serves requests, it
+ // just can't purge its own cache until the next restart. A
+ // stuck-stale cache self-heals via the safety-net TTL anyway.
+ log.Printf("activator: purge listener for %s: %v (continuing without it)", rt.Host, err)
+ } else {
+ p.purge = pl
+ }
+ }
go func() {
p.err = cmd.Wait()
close(p.done)
@@ -285,6 +322,17 @@ func childEnv(fileEnv map[string]string) []string {
return env
}
+// withVar returns a copy of env with key=value added — a copy, so the
+// caller's map (an EnvironmentFile's parsed contents) is never mutated.
+func withVar(env map[string]string, key, value string) map[string]string {
+ out := make(map[string]string, len(env)+1)
+ for k, v := range env {
+ out[k] = v
+ }
+ out[key] = value
+ return out
+}
+
// sidecarEnvPath: the host's sidecar EnvironmentFile,
// <DataDir>/<host>-sidecar.env (Eleven's <host>-bot.env, renamed).
func (b *ExecBackend) sidecarEnvPath(host string) string {
@@ -402,9 +450,10 @@ func readEnvFile(path string) (map[string]string, error) {
// childProc is a spawned child: SIGTERM starts the instance's own graceful
// drain; SIGKILL only if it overstays.
type childProc struct {
- cmd *exec.Cmd
- done chan struct{}
- err error
+ cmd *exec.Cmd
+ done chan struct{}
+ err error
+ purge *edge.PurgeListener // nil if b.Cache was nil or ListenPurge failed
}
func (p *childProc) Exited() bool {
@@ -417,6 +466,9 @@ func (p *childProc) Exited() bool {
}
func (p *childProc) Stop(ctx context.Context) error {
+ if p.purge != nil {
+ p.purge.Close()
+ }
if p.Exited() {
return nil
}
diff --git a/internal/edge/cache.go b/internal/edge/cache.go
new file mode 100644
index 000000000..ed4f07c47
--- /dev/null
+++ b/internal/edge/cache.go
@@ -0,0 +1,339 @@
+package edge
+
+import (
+ "container/list"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// Admission caps that bound how much of the cache any single URL or path can
+// occupy, independent of the global LRU. The global LRU alone does not stop
+// one URL from being pumped full of noise entries that then evict OTHER
+// hosts' useful entries — a crawler sending Accept-Language: xx-1 … xx-4096
+// (distinct Vary variants) or ?x=1 … ?x=4096 (distinct query strings, part of
+// the key because correctness requires it: different query strings can mean
+// different content) would otherwise flush the whole cache on demand.
+//
+// Two caps, because the two floods land on different key components:
+// - maxVaryVariantsPerURL bounds the Vary variants of one exact URL
+// (host+method+path+query).
+// - maxEntriesPerPath bounds everything under one path regardless of query
+// string or variant, which is what actually bounds a query-string flood.
+//
+// Both evict the oldest entry in their group (by StoredAt) when exceeded.
+const (
+ maxVaryVariantsPerURL = 8
+ maxEntriesPerPath = 32
+)
+
+// CacheKey is the full key for a cached response, including Host, Method, Path, RawQuery,
+// and VaryValue (concatenation of request-header values named by the response's Vary header).
+type CacheKey struct {
+ Host string // request Host header, exact match
+ Method string // "GET" or "HEAD"
+ Path string
+ RawQuery string
+ VaryValue string // concatenation of request-header values named by the response's own Vary header; "" if Vary absent
+}
+
+// CachedResponse holds a cached HTTP response along with TTL and storage time.
+type CachedResponse struct {
+ StatusCode int
+ Header http.Header
+ Body []byte
+ StoredAt time.Time
+ TTL time.Duration
+}
+
+// Cache is an in-memory, per-host response cache for cacheable GET/HEAD
+// responses. It exists to let the edge answer bot/crawler/repeat traffic
+// without invoking the hibernation activator (edge.go's ServeHTTP checks
+// it before ever reaching the activator) — see
+// docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md.
+// A nil *Cache disables caching entirely; callers must nil-check before use.
+type Cache struct {
+ mu sync.Mutex
+ maxEntries int
+ maxBodyBytes int
+ order *list.List // MRU at front, LRU at back
+ entries map[cacheFullKey]*list.Element
+ byURL map[cacheURLKey]map[cacheFullKey]*list.Element // urlKey -> its Vary variants
+ byPath map[cachePathKey]map[cacheFullKey]*list.Element // pathKey -> every entry under that path
+ // varyNames records, per URL, the raw Vary header value of the most recent
+ // response stored for that URL. It is what makes the two-phase lookup
+ // possible: at request time the response's Vary header isn't known yet, so
+ // GetFor replays the last one seen to compute the same VaryValue Put
+ // stored the entry under. Dropped when a URL's last entry goes away, so it
+ // can't grow without bound.
+ //
+ // "Most recent", emphatically, not "first ever". A URL whose responses
+ // vary conditionally (CORS middleware emitting Vary: Origin only for
+ // Origin-bearing requests) would otherwise be poisoned by a single
+ // request: the remembered names outlive the response that set them, later
+ // non-varying responses are stored under the empty variant, and every
+ // lookup keeps resolving against names nothing is stored under — a
+ // permanent miss on that URL, which on a hibernating route is a permanent
+ // wake per request. Put rewrites this record on every store, so a URL that
+ // stops varying stops being resolved against stale names.
+ varyNames map[cacheURLKey]string
+}
+
+type cacheURLKey struct {
+ Host, Method, Path, RawQuery string
+}
+
+type cachePathKey struct {
+ Host, Method, Path string
+}
+
+type cacheFullKey struct {
+ cacheURLKey
+ VaryValue string
+}
+
+type cacheEntry struct {
+ key cacheFullKey
+ resp *CachedResponse
+}
+
+// NewCache creates a new cache with the given capacity (max entries) and max body size.
+func NewCache(maxEntries, maxBodyBytes int) *Cache {
+ return &Cache{
+ maxEntries: maxEntries,
+ maxBodyBytes: maxBodyBytes,
+ order: list.New(),
+ entries: make(map[cacheFullKey]*list.Element),
+ byURL: make(map[cacheURLKey]map[cacheFullKey]*list.Element),
+ byPath: make(map[cachePathKey]map[cacheFullKey]*list.Element),
+ varyNames: make(map[cacheURLKey]string),
+ }
+}
+
+func (k CacheKey) urlKey() cacheURLKey {
+ return cacheURLKey{Host: k.Host, Method: k.Method, Path: k.Path, RawQuery: k.RawQuery}
+}
+
+func (k CacheKey) fullKey() cacheFullKey {
+ return cacheFullKey{cacheURLKey: k.urlKey(), VaryValue: k.VaryValue}
+}
+
+func (k cacheURLKey) pathKey() cachePathKey {
+ return cachePathKey{Host: k.Host, Method: k.Method, Path: k.Path}
+}
+
+// Get retrieves a cached response if it exists and has not expired.
+// Returns (nil, false) for a miss or an expired entry.
+// Expired entries are evicted lazily on lookup, not swept on a timer.
+//
+// Get takes the VaryValue already resolved by the caller. Request handling
+// goes through GetFor instead, which resolves it first — a request can't know
+// the response's Vary header until the response exists.
+func (c *Cache) Get(key CacheKey) (*CachedResponse, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ fk := key.fullKey()
+ el, ok := c.entries[fk]
+ if !ok {
+ return nil, false
+ }
+ e := el.Value.(*cacheEntry)
+ if time.Since(e.resp.StoredAt) > e.resp.TTL {
+ c.removeLocked(el)
+ return nil, false
+ }
+ c.order.MoveToFront(el)
+ return e.resp, true
+}
+
+// GetFor is the request-time lookup: phase one resolves which Vary variant r
+// belongs to, phase two does the ordinary Get. baseKey carries everything
+// known before the response exists (host, method, path, query) with VaryValue
+// left empty.
+//
+// The Vary header names live on the RESPONSE, so at lookup time the only
+// honest source for them is what the last response stored for this URL said
+// (c.varyNames). With none on record — no response yet, or the last one
+// carried no Vary — the lookup uses an empty VaryValue,
+// which is exactly the no-Vary case and keeps that path byte-for-byte as it
+// was. Both this and maybeStore compute the variant through varyValue, so
+// store and lookup can never drift apart — the bug this method exists to fix
+// was precisely that drift: storing under varyValue(request, vary) while
+// looking up under "", so a Vary-carrying response never hit.
+func (c *Cache) GetFor(baseKey CacheKey, r *http.Request) (*CachedResponse, bool) {
+ c.mu.Lock()
+ names := c.varyNames[baseKey.urlKey()]
+ c.mu.Unlock()
+ if names != "" {
+ baseKey.VaryValue = varyValue(r, names)
+ }
+ return c.Get(baseKey)
+}
+
+// Put stores a response in the cache. Silently drops a response whose body
+// exceeds maxBodyBytes. Evicts the least-recently-used entry when at
+// maxEntries, and enforces the per-URL and per-path admission caps so no
+// single URL or path can flood the cache (see the cap constants above).
+//
+// vary is the response's Vary field value — every line of it, comma-joined —
+// and "" when the response carried no Vary header at all. It is a separate
+// parameter rather than something read back off resp.Header because the caller
+// must have computed key.VaryValue from this exact string (edge.go's maybeStore
+// does, via varyValue), and because key.VaryValue == "" cannot stand in for
+// "no Vary": a response may vary on a header the request never sent, which
+// computes an empty value from a very real Vary header.
+//
+// Put records vary as this URL's Vary scheme so GetFor can resolve the same
+// variant on the next request, and — this is the load-bearing half — clears
+// or replaces that record when vary changes, including changing to "".
+func (c *Cache) Put(key CacheKey, vary string, resp *CachedResponse) {
+ if len(resp.Body) > c.maxBodyBytes {
+ return
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ fk := key.fullKey()
+ uk := fk.cacheURLKey
+ if vary != c.varyNames[uk] {
+ // This URL's Vary scheme changed: it stopped varying, started, or
+ // started varying by different headers. Every entry already stored for
+ // it was keyed under the OLD scheme, while GetFor will now resolve
+ // against the new one — none of them can ever be reached again, so
+ // they are pure garbage holding LRU and per-URL-cap space. Dropping
+ // them is what keeps a conditionally-varying URL from slowly filling
+ // the cache with unreachable variants.
+ //
+ // The sweep runs before the record is rewritten because removeLocked
+ // deletes varyNames[uk] once the URL's last entry goes.
+ for _, el := range elementsOf(c.byURL[uk]) {
+ c.removeLocked(el)
+ }
+ if vary == "" {
+ delete(c.varyNames, uk)
+ } else {
+ c.varyNames[uk] = vary
+ }
+ }
+ if el, ok := c.entries[fk]; ok {
+ el.Value.(*cacheEntry).resp = resp
+ c.order.MoveToFront(el)
+ return
+ }
+ el := c.order.PushFront(&cacheEntry{key: fk, resp: resp})
+ c.entries[fk] = el
+ if c.byURL[uk] == nil {
+ c.byURL[uk] = make(map[cacheFullKey]*list.Element)
+ }
+ c.byURL[uk][fk] = el
+ pk := uk.pathKey()
+ if c.byPath[pk] == nil {
+ c.byPath[pk] = make(map[cacheFullKey]*list.Element)
+ }
+ c.byPath[pk][fk] = el
+
+ c.capGroupLocked(c.byURL[uk], maxVaryVariantsPerURL, el)
+ c.capGroupLocked(c.byPath[pk], maxEntriesPerPath, el)
+ for len(c.entries) > c.maxEntries {
+ victim := c.order.Back()
+ if victim == nil {
+ break
+ }
+ c.removeLocked(victim)
+ }
+}
+
+// capGroupLocked drops the oldest entries (by StoredAt) from one admission
+// group until it holds at most max, never dropping keep (the entry just
+// inserted — evicting it would make the flood self-defeating in the wrong
+// direction, turning every request into a permanent miss AND a permanent
+// insert). Caller must hold c.mu.
+func (c *Cache) capGroupLocked(group map[cacheFullKey]*list.Element, max int, keep *list.Element) {
+ for len(group) > max {
+ var oldest *list.Element
+ for _, el := range group {
+ if el == keep {
+ continue
+ }
+ if oldest == nil || el.Value.(*cacheEntry).resp.StoredAt.Before(oldest.Value.(*cacheEntry).resp.StoredAt) {
+ oldest = el
+ }
+ }
+ if oldest == nil {
+ return
+ }
+ c.removeLocked(oldest)
+ }
+}
+
+// removeLocked drops el from every index. Caller must hold c.mu.
+func (c *Cache) removeLocked(el *list.Element) {
+ e := el.Value.(*cacheEntry)
+ c.order.Remove(el)
+ delete(c.entries, e.key)
+ uk := e.key.cacheURLKey
+ if variants := c.byURL[uk]; variants != nil {
+ delete(variants, e.key)
+ if len(variants) == 0 {
+ delete(c.byURL, uk)
+ // No entries left for this URL, so the remembered Vary names have
+ // nothing to resolve against — dropping them keeps varyNames
+ // bounded by the number of live URLs.
+ delete(c.varyNames, uk)
+ }
+ }
+ pk := uk.pathKey()
+ if group := c.byPath[pk]; group != nil {
+ delete(group, e.key)
+ if len(group) == 0 {
+ delete(c.byPath, pk)
+ }
+ }
+}
+
+// PurgeURL drops every cached variant (every VaryValue) of (host, path, rawQuery)
+// for both GET and HEAD methods and returns how many entries it actually removed.
+// Returns 0 if none were cached — not an error.
+func (c *Cache) PurgeURL(host, path, rawQuery string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := 0
+ // GET and HEAD are purged together — a write invalidates both.
+ for _, method := range [...]string{"GET", "HEAD"} {
+ uk := cacheURLKey{Host: host, Method: method, Path: path, RawQuery: rawQuery}
+ for _, el := range elementsOf(c.byURL[uk]) {
+ c.removeLocked(el)
+ n++
+ }
+ }
+ return n
+}
+
+// PurgeHost drops every cached entry for host — all paths, all query strings,
+// all Vary variants — and returns how many it removed. This is what a deploy
+// calls: a fresh process for a host means new code and potentially new
+// content, so everything cached from the old one is suspect. A whole-cache
+// scan is fine here; it happens once per spawn, not per request.
+func (c *Cache) PurgeHost(host string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ var doomed []*list.Element
+ for key, el := range c.entries {
+ if key.Host == host {
+ doomed = append(doomed, el)
+ }
+ }
+ for _, el := range doomed {
+ c.removeLocked(el)
+ }
+ return len(doomed)
+}
+
+// elementsOf snapshots a group's elements so the caller can remove them while
+// iterating (removeLocked mutates the very map being ranged over).
+func elementsOf(group map[cacheFullKey]*list.Element) []*list.Element {
+ els := make([]*list.Element, 0, len(group))
+ for _, el := range group {
+ els = append(els, el)
+ }
+ return els
+}
diff --git a/internal/edge/cache_test.go b/internal/edge/cache_test.go
new file mode 100644
index 000000000..a3b52d633
--- /dev/null
+++ b/internal/edge/cache_test.go
@@ -0,0 +1,202 @@
+package edge
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+)
+
+func TestCacheGetMissOnEmpty(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ if _, ok := c.Get(CacheKey{Host: "a", Method: "GET", Path: "/"}); ok {
+ t.Fatal("expected miss on empty cache")
+ }
+}
+
+func TestCachePutThenGetHits(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute}
+ c.Put(key, "", resp)
+ got, ok := c.Get(key)
+ if !ok {
+ t.Fatal("expected hit after put")
+ }
+ if string(got.Body) != "hi" {
+ t.Fatalf("body = %q, want %q", got.Body, "hi")
+ }
+}
+
+func TestCacheEntryExpiresAfterTTL(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now().Add(-2 * time.Minute), TTL: time.Minute}
+ c.Put(key, "", resp)
+ if _, ok := c.Get(key); ok {
+ t.Fatal("expected miss on expired entry")
+ }
+}
+
+func TestCachePutOverMaxBodyBytesIsDropped(t *testing.T) {
+ c := NewCache(10, 4) // 4-byte cap
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("way too big"), StoredAt: time.Now(), TTL: time.Minute}
+ c.Put(key, "", resp)
+ if _, ok := c.Get(key); ok {
+ t.Fatal("expected oversized body to never be cached")
+ }
+}
+
+func TestCacheEvictsLeastRecentlyUsedAtCapacity(t *testing.T) {
+ c := NewCache(2, 1<<20)
+ mk := func(p string) CacheKey { return CacheKey{Host: "a", Method: "GET", Path: p} }
+ put := func(p string) {
+ c.Put(mk(p), "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(p), StoredAt: time.Now(), TTL: time.Minute})
+ }
+ put("/one")
+ put("/two")
+ c.Get(mk("/one")) // touch /one so /two becomes the LRU victim
+ put("/three")
+ if _, ok := c.Get(mk("/two")); ok {
+ t.Fatal("expected /two to have been evicted as least-recently-used")
+ }
+ if _, ok := c.Get(mk("/one")); !ok {
+ t.Fatal("expected /one to survive (recently touched)")
+ }
+ if _, ok := c.Get(mk("/three")); !ok {
+ t.Fatal("expected /three to have been inserted")
+ }
+}
+
+// varyOf reports the Vary field value the response behind k must have carried:
+// a key only has a VaryValue because its response varied by something, and Put
+// needs to be told which something.
+func varyOf(k CacheKey) string {
+ if k.VaryValue == "" {
+ return ""
+ }
+ return "Accept-Language"
+}
+
+// Put records the Vary scheme of the LATEST response for a URL, not the first
+// one ever seen. When a URL stops varying, the old per-variant entries are
+// unreachable — GetFor resolves against the new (empty) scheme — so they must
+// go rather than sit in the LRU forever, and the remembered names must go with
+// them or every later lookup resolves against names nothing is stored under.
+func TestCachePutWithoutVaryClearsRememberedNamesAndStaleVariants(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ uk := CacheKey{Host: "a", Method: "GET", Path: "/page"}.urlKey()
+ mkResp := func(b string) *CachedResponse {
+ return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
+ }
+
+ varied := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: "Origin=https://x;"}
+ c.Put(varied, "Origin", mkResp("cors"))
+ c.mu.Lock()
+ names := c.varyNames[uk]
+ c.mu.Unlock()
+ if names != "Origin" {
+ t.Fatalf("varyNames = %q after a Vary-carrying store, want %q", names, "Origin")
+ }
+
+ plain := CacheKey{Host: "a", Method: "GET", Path: "/page"}
+ c.Put(plain, "", mkResp("plain"))
+ c.mu.Lock()
+ names, remembered := c.varyNames[uk]
+ variants := len(c.byURL[uk])
+ c.mu.Unlock()
+ if remembered {
+ t.Fatalf("varyNames still holds %q after a response with no Vary — every later lookup would resolve against names nothing is stored under", names)
+ }
+ if variants != 1 {
+ t.Fatalf("URL holds %d entries, want 1 — the old Origin variant is unreachable once the URL stops varying and must not linger in the LRU", variants)
+ }
+ if got, ok := c.Get(plain); !ok || string(got.Body) != "plain" {
+ t.Fatalf("Get(no-Vary key) = %v, %v; want the freshly stored body", got, ok)
+ }
+}
+
+// A deploy replaces the code behind a host, so every cached page for it is
+// suspect — PurgeHost drops all of them, across paths and Vary variants, and
+// nothing belonging to any other host.
+func TestCachePurgeHostRemovesEveryEntryForThatHost(t *testing.T) {
+ c := NewCache(100, 1<<20)
+ mkResp := func(b string) *CachedResponse {
+ return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
+ }
+ keys := []CacheKey{
+ {Host: "a", Method: "GET", Path: "/one"},
+ {Host: "a", Method: "GET", Path: "/two", VaryValue: "en"},
+ {Host: "a", Method: "GET", Path: "/two", VaryValue: "fr"},
+ {Host: "a", Method: "HEAD", Path: "/one"},
+ {Host: "a", Method: "GET", Path: "/one", RawQuery: "page=2"},
+ }
+ for _, k := range keys {
+ c.Put(k, varyOf(k), mkResp(k.Path))
+ }
+ other := CacheKey{Host: "b", Method: "GET", Path: "/one"}
+ c.Put(other, "", mkResp("b/one"))
+
+ if n := c.PurgeHost("a"); n != len(keys) {
+ t.Fatalf("PurgeHost removed %d entries, want %d", n, len(keys))
+ }
+ for _, k := range keys {
+ if _, ok := c.Get(k); ok {
+ t.Fatalf("entry %+v survived PurgeHost", k)
+ }
+ }
+ if _, ok := c.Get(other); !ok {
+ t.Fatal("PurgeHost(\"a\") must leave host b's entries untouched")
+ }
+ if n := c.PurgeHost("nobody"); n != 0 {
+ t.Fatalf("PurgeHost on an uncached host removed %d, want 0", n)
+ }
+}
+
+// An attacker pumping distinct query strings at one path must not be able to
+// evict the rest of the fleet's cache: the per-path cap bounds how many
+// entries a single path can occupy, independent of the global LRU.
+func TestCacheQueryStringFloodIsBoundedPerPath(t *testing.T) {
+ c := NewCache(1000, 1<<20)
+ for i := 0; i < 10*maxEntriesPerPath; i++ {
+ k := CacheKey{Host: "a", Method: "GET", Path: "/event", RawQuery: fmt.Sprintf("x=%d", i)}
+ c.Put(k, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute})
+ }
+ c.mu.Lock()
+ n := len(c.byPath[cachePathKey{Host: "a", Method: "GET", Path: "/event"}])
+ total := len(c.entries)
+ c.mu.Unlock()
+ if n > maxEntriesPerPath {
+ t.Fatalf("one path holds %d cached entries, want at most %d", n, maxEntriesPerPath)
+ }
+ if total > maxEntriesPerPath {
+ t.Fatalf("cache holds %d entries after a query-string flood, want at most %d", total, maxEntriesPerPath)
+ }
+}
+
+func TestCachePurgeURLRemovesAllVaryVariants(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ en := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "en"}
+ fr := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "fr"}
+ other := CacheKey{Host: "a", Method: "GET", Path: "/other"}
+ mkResp := func(b string) *CachedResponse {
+ return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
+ }
+ c.Put(en, "Accept-Language", mkResp("en"))
+ c.Put(fr, "Accept-Language", mkResp("fr"))
+ c.Put(other, "", mkResp("other"))
+ n := c.PurgeURL("a", "/event", "")
+ if n != 2 {
+ t.Fatalf("purged %d entries, want 2", n)
+ }
+ if _, ok := c.Get(en); ok {
+ t.Fatal("expected en variant purged")
+ }
+ if _, ok := c.Get(fr); ok {
+ t.Fatal("expected fr variant purged")
+ }
+ if _, ok := c.Get(other); !ok {
+ t.Fatal("expected unrelated URL to survive purge")
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 78be14db8..ed9ec5a03 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -16,6 +16,7 @@
package edge
import (
+ "bufio"
"context"
"crypto/ed25519"
"errors"
@@ -28,6 +29,8 @@ import (
"os"
"os/signal"
"path/filepath"
+ "strconv"
+ "strings"
"sync/atomic"
"syscall"
"time"
@@ -38,6 +41,24 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// Default cache sizing for EnableCache: 4096 entries is comfortably above a
+// single box's route × Vary-variant count, and 1 MiB caps a single cached
+// body well above a typical HTML page while still bounding worst-case memory
+// (4096 * 1MiB = 4GiB ceiling, never actually reached in practice since most
+// bodies are far smaller).
+const (
+ DefaultCacheMaxEntries = 4096
+ DefaultCacheMaxBodyBytes = 1 << 20 // 1 MiB
+)
+
+// DefaultCacheMaxTTL is the ceiling every cached entry's TTL is clamped to,
+// however long the app's own max-age asks for. It is what makes spec decision
+// 4 true — "a lost purge self-heals within a bounded window": without a
+// ceiling, an app setting max-age=31536000 turns one dropped purge call into a
+// permanently wrong page. Five minutes is short enough that a missed purge is
+// a blip and long enough that a crawler sweep still rides the cache.
+const DefaultCacheMaxTTL = 5 * time.Minute
+
// Activator is the hibernation seam (implemented by the activator package):
// given a hibernate-flagged instance route, it wakes (or joins an in-progress
// wake of) the backing process before letting the request through, and
@@ -75,6 +96,10 @@ type Edge struct {
staticPub ed25519.PublicKey
staticCacheDir string
staticRecheck time.Duration
+ // cache, when non-nil, answers cacheable GET/HEAD requests without ever
+ // invoking the hibernation activator or the backend — the mitigation
+ // this whole feature exists for. nil disables caching entirely.
+ cache *Cache
}
// upstream is one served host: the handler for its backend, plus — when the
@@ -117,6 +142,13 @@ func (e *Edge) EnableStatic(store release.Store, pub ed25519.PublicKey, cacheDir
e.staticRecheck = staticRecheck
}
+// EnableCache wires a response cache into the edge. Called once at startup
+// (RunWithActivator or Run); nil is a valid no-op, matching the package's
+// existing nil-means-disabled convention for Activator.
+func (e *Edge) EnableCache(cache *Cache) {
+ e.cache = cache
+}
+
// SetStaticRecheck tunes how long a static route trusts a resolved channel
// pointer before re-reading it (default 60s; 0 = every request). Call after
// EnableStatic and before Reload; handlers built on the next reload use it.
@@ -231,17 +263,323 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
e.unknownHost(w, r)
return
}
+
+ // A Connection: Upgrade request (a WebSocket handshake, typically GET)
+ // must reach the activator/proxy with the real ResponseWriter, completely
+ // untouched by caching: the stdlib reverse proxy's upgrade path type-
+ // asserts the ResponseWriter to http.Hijacker to take over the raw
+ // connection, and once upgraded, the open WebSocket is also what keeps a
+ // hibernating instance's idle clock from firing (the activator's inflight
+ // counting — see internal/activator/activator.go's actEntry.inflight doc
+ // comment). So this path skips caching entirely and stays byte-for-byte
+ // identical to pre-cache behavior, rather than relying on
+ // recordingResponseWriter's Hijack forwarding below.
+ if isUpgradeRequest(r) {
+ if up.hibernating {
+ if e.act.ServeHTTP(w, r, up.route, up.proxy) {
+ return
+ }
+ }
+ up.proxy.ServeHTTP(w, r)
+ return
+ }
+
+ // Cache check happens before the hibernation branch below — a hit must
+ // never reach the activator, since avoiding pointless wakes for
+ // bot/crawler/repeat traffic is the entire point of this cache.
+ cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead)
+ var key CacheKey
+ if cacheable {
+ key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery}
+ // GetFor, not Get: the variant this request belongs to depends on the
+ // response's Vary header, which doesn't exist yet — GetFor resolves it
+ // from the Vary names the last response for this URL recorded, so the
+ // lookup key matches the key maybeStore stored under.
+ if resp, hit := e.cache.GetFor(key, r); hit {
+ writeCached(w, resp)
+ return
+ }
+ }
+
if up.hibernating {
// Wake (or join an in-progress wake) before proxying; the activator
// also stamps this request's activity for the idle clock. Note a bare
// TLS handshake never wakes anything — only a proxied request does.
- if e.act.ServeHTTP(w, r, up.route, up.proxy) {
+ // The response is always recorded (even when not cacheable, in which
+ // case maybeStore is a no-op on rec.cacheable) so this branch stays a
+ // single shape regardless of method.
+ rec := &recordingResponseWriter{ResponseWriter: w, request: r, cacheable: cacheable, maxBody: e.cacheMaxBody()}
+ if e.act.ServeHTTP(rec, r, up.route, up.proxy) {
+ e.maybeStore(key, rec)
return
}
+ up.proxy.ServeHTTP(rec, r)
+ e.maybeStore(key, rec)
+ return
+ }
+
+ if cacheable {
+ rec := &recordingResponseWriter{ResponseWriter: w, request: r, cacheable: true, maxBody: e.cacheMaxBody()}
+ up.proxy.ServeHTTP(rec, r)
+ e.maybeStore(key, rec)
+ return
}
up.proxy.ServeHTTP(w, r)
}
+// isUpgradeRequest reports whether r is a protocol-upgrade request (e.g. a
+// WebSocket handshake) per the Connection header's token list (RFC 7230
+// §6.7: comma-separated, case-insensitive — "Upgrade", "keep-alive, Upgrade",
+// etc. all qualify).
+func isUpgradeRequest(r *http.Request) bool {
+ for _, token := range strings.Split(r.Header.Get("Connection"), ",") {
+ if strings.EqualFold(strings.TrimSpace(token), "Upgrade") {
+ return true
+ }
+ }
+ return false
+}
+
+// cacheMaxBody reports the body-size ceiling recordingResponseWriter should
+// enforce, or 0 when caching is disabled (recordingResponseWriter is never
+// constructed with cacheable=true in that case, so the value is unused).
+func (e *Edge) cacheMaxBody() int {
+ if e.cache == nil {
+ return 0
+ }
+ return e.cache.maxBodyBytes
+}
+
+// maybeStore inspects a recorded response and caches it if it qualifies:
+// 2xx status, a Cache-Control that names a positive max-age, and none of the
+// per-visitor markers below. This cache is shared across every visitor of a
+// host and caching is unconditional per route (not opt-in per app), so each
+// refusal here is the only thing standing between one visitor's response and
+// everyone else's:
+//
+// - Cache-Control: private — the app itself saying "one visitor only".
+// - Set-Cookie — the response carries per-visitor state (a session id, a
+// CSRF token); replaying it would hand that cookie to later visitors.
+// - an Authorization header on the REQUEST — the response is presumed
+// private per RFC 9111 §3.5, unless Cache-Control explicitly says public,
+// which is the app asserting the response is safe to share.
+// - Vary: Cookie — varying by cookie would defeat correctness per-visitor.
+// - Vary: * — RFC 9111 §4.1 / RFC 9110 §12.5.5: the response depends on
+// something request headers can't capture, so it is never reusable from
+// a shared cache.
+//
+// The stored TTL is clamped to DefaultCacheMaxTTL so a lost purge always
+// self-heals within a bounded window.
+func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) {
+ if e.cache == nil || !rec.cacheable || rec.truncated {
+ return
+ }
+ if rec.status < 200 || rec.status >= 300 {
+ return
+ }
+ cc := joinHeader(rec.Header(), "Cache-Control")
+ ttl, ok := parseMaxAge(cc)
+ if !ok || ttl <= 0 {
+ return
+ }
+ if len(rec.Header().Values("Set-Cookie")) > 0 {
+ return
+ }
+ if len(rec.request.Header.Values("Authorization")) > 0 && !hasCCDirective(cc, "public") {
+ return
+ }
+ if ttl > DefaultCacheMaxTTL {
+ ttl = DefaultCacheMaxTTL
+ }
+ vary := joinHeader(rec.Header(), "Vary")
+ if vary != "" {
+ for _, h := range strings.Split(vary, ",") {
+ h = strings.TrimSpace(h)
+ if h == "*" || strings.EqualFold(h, "Cookie") {
+ return
+ }
+ }
+ // The same computation GetFor replays at lookup time — both call
+ // varyValue so the store key and the lookup key can never drift.
+ key.VaryValue = varyValue(rec.request, vary)
+ }
+ // vary is handed to Put as well as consumed here: it is what Put records
+ // (or, when empty, clears) as this URL's Vary scheme, so the next request's
+ // GetFor resolves the very variant this store used.
+ e.cache.Put(key, vary, &CachedResponse{
+ StatusCode: rec.status,
+ Header: rec.Header().Clone(),
+ Body: rec.body,
+ StoredAt: time.Now(),
+ TTL: ttl,
+ })
+}
+
+// joinHeader returns every line of one header field as a single comma-joined
+// value. RFC 9110 §5.3: a field may legally appear on multiple lines and that
+// is identical in meaning to one comma-joined line — which is exactly what
+// http.Header.Add produces, and exactly how layered middleware emits
+// list-valued fields (compression adds Vary: Accept-Encoding, the session
+// layer separately adds Vary: Cookie; one handler sets Cache-Control:
+// max-age=600 and an auth wrapper adds Cache-Control: private).
+//
+// Every Cache-Control and Vary question this file asks goes through here
+// because http.Header.Get returns ONLY the first line: reading either field
+// with Get made every later line invisible, so a second line saying "private"
+// or "Cookie" silently failed to refuse the response. Joining with ", " keeps
+// the result a well-formed field value for the comma-splitting parsers below.
+func joinHeader(h http.Header, name string) string {
+ return strings.Join(h.Values(name), ", ")
+}
+
+// varyValue concatenates r's values for each header named in the response's
+// Vary list, in the order Vary names them — the same raw string always
+// produces the same bucket, which is all correctness requires (standard HTTP
+// Vary semantics; RFC 7234 §4.1).
+//
+// vary itself may be a comma-joined multi-line field (see joinHeader); the
+// split-and-trim below handles that form identically to a single line. The
+// request side reads all of a header's lines too, so two requests that differ
+// only in how they split a varied-on header still land in different buckets.
+func varyValue(r *http.Request, vary string) string {
+ var b strings.Builder
+ for _, h := range strings.Split(vary, ",") {
+ h = strings.TrimSpace(h)
+ b.WriteString(h)
+ b.WriteByte('=')
+ b.WriteString(joinHeader(r.Header, h))
+ b.WriteByte(';')
+ }
+ return b.String()
+}
+
+// parseMaxAge extracts max-age from a Cache-Control header. Returns
+// ok=false for "no-store", "no-cache", "private", or a missing/malformed
+// max-age — any of which mean "don't cache this in a shared cache."
+//
+// The whole header is scanned before the max-age is honored, so a
+// "max-age=600, private" (directives in either order, and "private" in its
+// field-name form private="Set-Cookie") is refused rather than cached because
+// max-age happened to come first.
+func parseMaxAge(cc string) (time.Duration, bool) {
+ var ttl time.Duration
+ found := false
+ for name, arg := range ccDirectives(cc) {
+ switch name {
+ case "no-store", "no-cache", "private":
+ return 0, false
+ case "max-age":
+ secs, err := strconv.Atoi(strings.TrimSpace(arg))
+ if err != nil {
+ return 0, false
+ }
+ ttl, found = time.Duration(secs)*time.Second, true
+ }
+ }
+ if !found {
+ return 0, false
+ }
+ return ttl, true
+}
+
+// hasCCDirective reports whether cc carries the named directive.
+func hasCCDirective(cc, want string) bool {
+ for name := range ccDirectives(cc) {
+ if name == want {
+ return true
+ }
+ }
+ return false
+}
+
+// ccDirectives iterates a Cache-Control header as (lowercased name, argument)
+// pairs; the argument is "" for a bare directive. One parse shape for every
+// directive question this file asks.
+func ccDirectives(cc string) func(func(string, string) bool) {
+ return func(yield func(string, string) bool) {
+ for _, part := range strings.Split(cc, ",") {
+ name, arg, _ := strings.Cut(strings.TrimSpace(part), "=")
+ if !yield(strings.ToLower(strings.TrimSpace(name)), arg) {
+ return
+ }
+ }
+ }
+}
+
+// writeCached replays a cached response verbatim onto w.
+func writeCached(w http.ResponseWriter, resp *CachedResponse) {
+ h := w.Header()
+ for k, v := range resp.Header {
+ h[k] = v
+ }
+ w.WriteHeader(resp.StatusCode)
+ w.Write(resp.Body)
+}
+
+// recordingResponseWriter captures a response as it's written so it can be
+// cached afterward, while still passing bytes through to the real client
+// immediately (no buffering delay for the requester). Recording stops
+// (silently, not an error) once the body exceeds maxBody — that response is
+// simply never cached, per maybeStore's rec.truncated check.
+type recordingResponseWriter struct {
+ http.ResponseWriter
+ request *http.Request
+ cacheable bool
+ maxBody int
+ status int
+ body []byte
+ truncated bool
+ wroteHdr bool
+}
+
+func (r *recordingResponseWriter) WriteHeader(status int) {
+ if !r.wroteHdr {
+ r.status = status
+ r.wroteHdr = true
+ }
+ r.ResponseWriter.WriteHeader(status)
+}
+
+func (r *recordingResponseWriter) Write(p []byte) (int, error) {
+ if !r.wroteHdr {
+ r.WriteHeader(http.StatusOK)
+ }
+ if r.cacheable && !r.truncated {
+ if len(r.body)+len(p) > r.maxBody {
+ r.truncated = true
+ r.body = nil
+ } else {
+ r.body = append(r.body, p...)
+ }
+ }
+ return r.ResponseWriter.Write(p)
+}
+
+// Hijack forwards to the underlying ResponseWriter's http.Hijacker, when it
+// implements one — the same optional-interface pattern the stdlib itself
+// uses. ServeHTTP special-cases upgrade requests before ever constructing a
+// recordingResponseWriter (see isUpgradeRequest), so this method is not
+// expected to be called on the hot path today; it exists so the wrapper type
+// is never itself the reason a hijack fails, matching http.ResponseWriter
+// wrapper conventions generally.
+func (r *recordingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+ hj, ok := r.ResponseWriter.(http.Hijacker)
+ if !ok {
+ return nil, nil, fmt.Errorf("edge: underlying ResponseWriter does not support http.Hijacker")
+ }
+ return hj.Hijack()
+}
+
+// Flush forwards to the underlying ResponseWriter's http.Flusher, when it
+// implements one — needed so a recorded (non-upgrade) response can still
+// stream incrementally to the real client, e.g. Server-Sent Events, instead
+// of buffering until the handler returns.
+func (r *recordingResponseWriter) Flush() {
+ if f, ok := r.ResponseWriter.(http.Flusher); ok {
+ f.Flush()
+ }
+}
+
// unknownHost answers a request for a host we don't serve: a redirect to the
// fallback (302 — a typo'd host must not be cached as permanently moved), or
// the classic 404 when no fallback is configured.
@@ -375,12 +713,15 @@ func isRetryableDialErr(err error) bool {
// Run is the `carlos edge` verb: the shared front door for every instance,
// sibling service, and static site on the box.
-func Run(args []string) { RunWithActivator(args, nil) }
+func Run(args []string) {
+ RunWithActivator(args, nil, NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+}
// RunWithActivator is Run with an injected hibernation activator — the seam
// `carlos agent` (internal/host) uses to compose edge + activator in one
-// process. act may be nil: pure proxy, exactly Run's behavior.
-func RunWithActivator(args []string, act Activator) {
+// process. act may be nil: pure proxy, exactly Run's behavior. cache may
+// also be nil, disabling response caching entirely.
+func RunWithActivator(args []string, act Activator, cache *Cache) {
dataDir := envOr("CARLOS_DATA_DIR", "/data")
fs := flag.NewFlagSet("edge", flag.ExitOnError)
registryPath := fs.String("registry", filepath.Join(dataDir, "registry.db"), "registry SQLite path (default $CARLOS_DATA_DIR/registry.db)")
@@ -403,6 +744,7 @@ func RunWithActivator(args []string, act Activator) {
log.Fatalf("edge: load routes: %v", err)
}
e.fallbackURL = *fallbackURL
+ e.EnableCache(cache)
// Static serving lights up when the environment carries a deployment
// store and the release pubkey; otherwise static routes 404 until it does.
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go
index 88477efd1..492479c7e 100644
--- a/internal/edge/edge_test.go
+++ b/internal/edge/edge_test.go
@@ -1,8 +1,12 @@
package edge
import (
+ "bufio"
+ "bytes"
"context"
+ "encoding/json"
"errors"
+ "fmt"
"io"
"net"
"net/http"
@@ -10,6 +14,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"syscall"
"testing"
"time"
@@ -472,3 +477,828 @@ func TestHibernatingRouteGoesThroughActivator(t *testing.T) {
t.Fatalf("RetireExcept keep-set %v missing hib.example.com", last)
}
}
+
+// refusingActivator fails the test immediately if ever invoked — used to
+// prove a cache hit never reaches the activator at all.
+type refusingActivator struct{ t *testing.T }
+
+func (r refusingActivator) ServeHTTP(w http.ResponseWriter, req *http.Request, rt registry.Route, proxy http.Handler) bool {
+ r.t.Fatal("activator invoked on what should have been a cache hit — this defeats the whole point of caching")
+ return true
+}
+
+func (r refusingActivator) RetireExcept(keep map[string]bool) {}
+
+func TestServeHTTPCacheHitNeverInvokesActivator(t *testing.T) {
+ reg := openReg(t)
+ // Addr is deliberately a bogus, never-dialed address: if the cache
+ // check is ever bypassed, proxying to it fails loudly instead of
+ // silently succeeding, which would mask the bug.
+ if err := reg.Upsert(registry.Route{Host: "cached.example.com", Kind: "instance", Addr: "127.0.0.1:1", Status: "ready", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, refusingActivator{t: t})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ key := CacheKey{Host: "cached.example.com", Method: "GET", Path: "/event"}
+ e.cache.Put(key, "", &CachedResponse{
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": {"text/html"}},
+ Body: []byte("cached page"),
+ StoredAt: time.Now(),
+ TTL: time.Minute,
+ })
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cached.example.com/event", nil))
+
+ if rec.Code != 200 {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if rec.Body.String() != "cached page" {
+ t.Fatalf("body = %q, want %q", rec.Body.String(), "cached page")
+ }
+}
+
+func TestServeHTTPCacheMissPopulatesCacheFromResponse(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(200)
+ w.Write([]byte("fresh page"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "miss.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ rec1 := httptest.NewRecorder()
+ e.ServeHTTP(rec1, httptest.NewRequest("GET", "http://miss.example.com/event", nil))
+ if rec1.Body.String() != "fresh page" {
+ t.Fatalf("first response body = %q, want %q", rec1.Body.String(), "fresh page")
+ }
+
+ // Close the backend so a second real request would fail to dial —
+ // proves the second request is answered from cache, not the backend.
+ backend.Close()
+ rec2 := httptest.NewRecorder()
+ e.ServeHTTP(rec2, httptest.NewRequest("GET", "http://miss.example.com/event", nil))
+ if rec2.Body.String() != "fresh page" {
+ t.Fatalf("second response body = %q, want %q (should be served from cache)", rec2.Body.String(), "fresh page")
+ }
+}
+
+func TestServeHTTPNeverCachesNon2xx(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(500)
+ w.Write([]byte("boom"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "err.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://err.example.com/event", nil))
+
+ if _, ok := e.cache.Get(CacheKey{Host: "err.example.com", Method: "GET", Path: "/event"}); ok {
+ t.Fatal("a 500 response must never be cached")
+ }
+}
+
+// varyBackendEdge wires an Edge in front of a backend that echoes the request's
+// Accept-Language and counts its own invocations — the counter is what proves a
+// second request was answered from cache rather than re-proxied, which asserting
+// on response bodies alone cannot do (a miss echoes the same body a hit would).
+func varyBackendEdge(t *testing.T, host string, hits *atomic.Int64) *Edge {
+ t.Helper()
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.Header().Set("Vary", "Accept-Language")
+ w.WriteHeader(200)
+ w.Write([]byte("lang=" + r.Header.Get("Accept-Language")))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ return e
+}
+
+// getLang issues a GET carrying Accept-Language: lang (omitted entirely when
+// lang is "") and returns the body.
+func getLang(t *testing.T, e *Edge, url, lang string) string {
+ t.Helper()
+ req := httptest.NewRequest("GET", url, nil)
+ if lang != "" {
+ req.Header.Set("Accept-Language", lang)
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec.Body.String()
+}
+
+// A Vary-carrying response must be both separated per variant AND actually
+// re-served from cache on a repeat request. The backend-invocation count is
+// the load-bearing assertion: pre-fix, ServeHTTP looked entries up with an
+// empty VaryValue while maybeStore stored them under varyValue(request, vary),
+// so every request was a miss — 4 backend hits, not 2 — while the bodies still
+// differed per language, which is why the old body-comparison assertion passed
+// with Vary support entirely broken.
+func TestServeHTTPVaryAcceptLanguageProducesSeparateEntries(t *testing.T) {
+ var hits atomic.Int64
+ e := varyBackendEdge(t, "i18n.example.com", &hits)
+ const url = "http://i18n.example.com/event"
+
+ for _, want := range []struct{ lang, body string }{
+ {"en", "lang=en"}, {"en", "lang=en"}, {"fr", "lang=fr"}, {"fr", "lang=fr"},
+ } {
+ if got := getLang(t, e, url, want.lang); got != want.body {
+ t.Fatalf("Accept-Language %q: body = %q, want %q", want.lang, got, want.body)
+ }
+ }
+
+ if n := hits.Load(); n != 2 {
+ t.Fatalf("backend invoked %d times across 4 requests (en, en, fr, fr), want 2 — the repeat of each language must be served from cache", n)
+ }
+}
+
+// The core regression: a repeat of an identical request against a
+// Vary-carrying response must hit. Here no Accept-Language is sent at all, so
+// both requests resolve to the same (empty-valued) variant.
+func TestServeHTTPVaryRepeatIdenticalRequestHitsCache(t *testing.T) {
+ var hits atomic.Int64
+ e := varyBackendEdge(t, "repeat.example.com", &hits)
+ const url = "http://repeat.example.com/event"
+
+ if got := getLang(t, e, url, ""); got != "lang=" {
+ t.Fatalf("first body = %q, want %q", got, "lang=")
+ }
+ if got := getLang(t, e, url, ""); got != "lang=" {
+ t.Fatalf("second body = %q, want %q", got, "lang=")
+ }
+ if n := hits.Load(); n != 1 {
+ t.Fatalf("backend invoked %d times across 2 identical requests, want 1 — the second must be a cache hit", n)
+ }
+}
+
+// A crawler pumping distinct Vary values at one URL must not be able to fill
+// the cache with unreachable variants: the per-URL cap bounds how many a
+// single URL can occupy, evicting the oldest as new ones arrive.
+func TestServeHTTPVaryVariantCapBoundsEntriesPerURL(t *testing.T) {
+ var hits atomic.Int64
+ e := varyBackendEdge(t, "flood.example.com", &hits)
+ const url = "http://flood.example.com/event"
+
+ langs := make([]string, 4*maxVaryVariantsPerURL)
+ for i := range langs {
+ langs[i] = fmt.Sprintf("xx-%d", i)
+ getLang(t, e, url, langs[i])
+ }
+
+ uk := CacheKey{Host: "flood.example.com", Method: "GET", Path: "/event"}.urlKey()
+ e.cache.mu.Lock()
+ n := len(e.cache.byURL[uk])
+ e.cache.mu.Unlock()
+ if n > maxVaryVariantsPerURL {
+ t.Fatalf("one URL holds %d cached variants, want at most %d", n, maxVaryVariantsPerURL)
+ }
+
+ // The newest variant survives; the oldest was evicted to make room.
+ before := hits.Load()
+ getLang(t, e, url, langs[len(langs)-1])
+ if hits.Load() != before {
+ t.Fatal("the most recent Vary variant should still be cached")
+ }
+ getLang(t, e, url, langs[0])
+ if hits.Load() != before+1 {
+ t.Fatal("the oldest Vary variant should have been evicted by the per-URL cap")
+ }
+}
+
+// cacheProbeEdge wires an Edge whose backend answers with headers chosen per
+// path by hdr — the shape every "must not be cached" rule test needs.
+func cacheProbeEdge(t *testing.T, host string, hdr func(path string, h http.Header)) *Edge {
+ t.Helper()
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hdr(r.URL.Path, w.Header())
+ w.WriteHeader(200)
+ w.Write([]byte("body for " + r.URL.Path))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ return e
+}
+
+func cachedAt(e *Edge, host, path string) bool {
+ _, ok := e.cache.Get(CacheKey{Host: host, Method: "GET", Path: path})
+ return ok
+}
+
+// Cache-Control: private means "for one visitor only" — caching it at the
+// shared edge and replaying it to everyone else is a cross-visitor leak.
+func TestServeHTTPRefusesCacheControlPrivate(t *testing.T) {
+ e := cacheProbeEdge(t, "priv.example.com", func(path string, h http.Header) {
+ if path == "/private" {
+ h.Set("Cache-Control", "private, max-age=600")
+ return
+ }
+ h.Set("Cache-Control", "max-age=600")
+ })
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://priv.example.com/private", nil))
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://priv.example.com/shared", nil))
+
+ if cachedAt(e, "priv.example.com", "/private") {
+ t.Fatal("a Cache-Control: private response must never be cached at the shared edge")
+ }
+ if !cachedAt(e, "priv.example.com", "/shared") {
+ t.Fatal("an ordinary max-age response must still be cached")
+ }
+}
+
+// A Set-Cookie response carries per-visitor state (a session, a CSRF token);
+// caching it would hand that cookie to every later visitor.
+func TestServeHTTPRefusesResponseWithSetCookie(t *testing.T) {
+ e := cacheProbeEdge(t, "cookie2.example.com", func(path string, h http.Header) {
+ h.Set("Cache-Control", "max-age=600")
+ if path == "/session" {
+ h.Set("Set-Cookie", "sid=abc123; Path=/; HttpOnly")
+ }
+ })
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie2.example.com/session", nil))
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie2.example.com/plain", nil))
+
+ if cachedAt(e, "cookie2.example.com", "/session") {
+ t.Fatal("a response carrying Set-Cookie must never be cached — the cookie would be replayed to other visitors")
+ }
+ if !cachedAt(e, "cookie2.example.com", "/plain") {
+ t.Fatal("a response without Set-Cookie must still be cached")
+ }
+}
+
+// A response to an Authorization-bearing request is presumed private (RFC 9111
+// §3.5) unless the app explicitly marks it public.
+func TestServeHTTPRefusesAuthorizedRequestUnlessPublic(t *testing.T) {
+ e := cacheProbeEdge(t, "auth.example.com", func(path string, h http.Header) {
+ if path == "/public" {
+ h.Set("Cache-Control", "public, max-age=600")
+ return
+ }
+ h.Set("Cache-Control", "max-age=600")
+ })
+ authGet := func(path string) {
+ req := httptest.NewRequest("GET", "http://auth.example.com"+path, nil)
+ req.Header.Set("Authorization", "Bearer secret-token")
+ e.ServeHTTP(httptest.NewRecorder(), req)
+ }
+ authGet("/account")
+ authGet("/public")
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://auth.example.com/anon", nil))
+
+ if cachedAt(e, "auth.example.com", "/account") {
+ t.Fatal("a response to an Authorization-bearing request must not be cached without an explicit Cache-Control: public")
+ }
+ if !cachedAt(e, "auth.example.com", "/public") {
+ t.Fatal("Cache-Control: public overrides the Authorization concern (RFC 9111 §3.5) — this response should be cached")
+ }
+ if !cachedAt(e, "auth.example.com", "/anon") {
+ t.Fatal("an unauthenticated request's ordinary max-age response must still be cached")
+ }
+}
+
+// An app asking for a year of freshness would make a single lost purge
+// effectively permanent; the edge clamps every TTL to its own ceiling so a
+// missed purge always self-heals within a bounded window (spec decision 4).
+func TestServeHTTPClampsMaxAgeToCeiling(t *testing.T) {
+ e := cacheProbeEdge(t, "ttl.example.com", func(path string, h http.Header) {
+ h.Set("Cache-Control", "max-age=31536000")
+ })
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://ttl.example.com/event", nil))
+
+ resp, ok := e.cache.Get(CacheKey{Host: "ttl.example.com", Method: "GET", Path: "/event"})
+ if !ok {
+ t.Fatal("expected the response to be cached")
+ }
+ if resp.TTL != DefaultCacheMaxTTL {
+ t.Fatalf("cached TTL = %s, want it clamped to %s", resp.TTL, DefaultCacheMaxTTL)
+ }
+}
+
+func TestServeHTTPRefusesVaryCookie(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.Header().Set("Vary", "Cookie")
+ w.WriteHeader(200)
+ w.Write([]byte("should not be cached"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "cookie.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie.example.com/event", nil))
+
+ if _, ok := e.cache.Get(CacheKey{Host: "cookie.example.com", Method: "GET", Path: "/event"}); ok {
+ t.Fatal("a response Vary-ing on Cookie must never be cached — it would defeat per-visitor correctness")
+ }
+}
+
+// cachedVariants counts every cached variant of one URL, whatever VaryValue
+// each was stored under. A "must not be cached" assertion has to ask this
+// question rather than Get with an empty VaryValue: a response that varies is
+// stored under a computed variant key, so the empty-key Get misses and the
+// assertion passes whether or not the refusal actually fired.
+func cachedVariants(e *Edge, host, path string) int {
+ uk := CacheKey{Host: host, Method: "GET", Path: path}.urlKey()
+ e.cache.mu.Lock()
+ defer e.cache.mu.Unlock()
+ return len(e.cache.byURL[uk])
+}
+
+// getWith issues a GET carrying one extra header and returns the recorder.
+func getWith(t *testing.T, e *Edge, url, name, value string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest("GET", url, nil)
+ if name != "" {
+ req.Header.Set(name, value)
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+// RFC 9110 §5.3: one header field may arrive as several lines, and that is
+// semantically identical to a single comma-joined line — which is exactly what
+// http.Header.Add produces and what layered middleware emits in practice
+// (compression adds Vary: Accept-Encoding, the session layer separately adds
+// Vary: Cookie). http.Header.Get returns only the FIRST line, so reading Vary
+// that way made everything after it invisible: the Vary: Cookie refusal never
+// fired and the entry was keyed on Accept-Encoding alone, so one visitor's
+// cookie-scoped response was replayed to the next visitor.
+func TestServeHTTPRefusesVaryCookieSplitAcrossHeaderLines(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ // Two separate Add calls, the standard shape for a list-valued header.
+ w.Header().Add("Vary", "Accept-Encoding")
+ w.Header().Add("Vary", "Cookie")
+ w.WriteHeader(200)
+ w.Write([]byte("dashboard for " + r.Header.Get("Cookie")))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "split.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ const url = "http://split.example.com/dashboard"
+
+ alice := getWith(t, e, url, "Cookie", "sid=alice").Body.String()
+ if alice != "dashboard for sid=alice" {
+ t.Fatalf("alice body = %q, want %q", alice, "dashboard for sid=alice")
+ }
+ if n := cachedVariants(e, "split.example.com", "/dashboard"); n != 0 {
+ // Errorf, not Fatalf: the replay assertion below is the live
+ // consequence and is worth reporting in the same run.
+ t.Errorf("%d variants cached for a response that varies on Cookie, want 0 — a multi-line Vary is still a Vary", n)
+ }
+
+ bob := getWith(t, e, url, "Cookie", "sid=bob").Body.String()
+ if bob != "dashboard for sid=bob" {
+ t.Fatalf("bob was served %q — one visitor's cookie-scoped response was replayed to another", bob)
+ }
+}
+
+// The same root cause on Cache-Control: with max-age=600 on one line and
+// private on the next, Header.Get saw only max-age and the private response
+// was cached at the shared edge — the cross-visitor leak the private rule
+// exists to prevent.
+func TestServeHTTPRefusesCacheControlPrivateSplitAcrossHeaderLines(t *testing.T) {
+ e := cacheProbeEdge(t, "splitcc.example.com", func(path string, h http.Header) {
+ if path == "/private" {
+ h.Add("Cache-Control", "max-age=600")
+ h.Add("Cache-Control", "private")
+ return
+ }
+ h.Set("Cache-Control", "max-age=600")
+ })
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://splitcc.example.com/private", nil))
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://splitcc.example.com/shared", nil))
+
+ if n := cachedVariants(e, "splitcc.example.com", "/private"); n != 0 {
+ t.Fatalf("%d entries cached for a response whose second Cache-Control line says private, want 0", n)
+ }
+ if !cachedAt(e, "splitcc.example.com", "/shared") {
+ t.Fatal("an ordinary max-age response must still be cached")
+ }
+}
+
+// The same root cause once more, on the Authorization rule: a response to an
+// Authorization-bearing request is private per RFC 9111 §3.5 unless
+// Cache-Control says public — and "public" on a second header line counts.
+func TestServeHTTPAuthorizedPublicSplitAcrossHeaderLines(t *testing.T) {
+ e := cacheProbeEdge(t, "splitauth.example.com", func(path string, h http.Header) {
+ h.Add("Cache-Control", "max-age=600")
+ h.Add("Cache-Control", "public")
+ })
+ req := httptest.NewRequest("GET", "http://splitauth.example.com/public", nil)
+ req.Header.Set("Authorization", "Bearer secret-token")
+ e.ServeHTTP(httptest.NewRecorder(), req)
+
+ if !cachedAt(e, "splitauth.example.com", "/public") {
+ t.Fatal("Cache-Control: public on a second header line still overrides the Authorization concern")
+ }
+}
+
+// An app that emits Vary only for some requests — CORS middleware adding
+// Vary: Origin only when the request carries Origin, content negotiation
+// adding Vary: Accept only when it must — used to poison its own URL
+// permanently: the first Vary-carrying response recorded the Vary names for
+// that URL and nothing ever cleared them, so later non-Vary responses were
+// stored under the empty variant while every lookup kept resolving against the
+// remembered names. Store and lookup drifted apart, every ordinary request
+// missed, and on a hibernating route every miss is a wake — a remote,
+// unauthenticated wake-storm from one request.
+func TestServeHTTPConditionalVaryDoesNotPoisonURL(t *testing.T) {
+ var hits atomic.Int64
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ w.Header().Set("Cache-Control", "max-age=60")
+ if origin := r.Header.Get("Origin"); origin != "" {
+ w.Header().Set("Vary", "Origin")
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ }
+ w.WriteHeader(200)
+ w.Write([]byte("page"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "cors.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ const url = "http://cors.example.com/page"
+
+ // One unauthenticated request on a cold URL, eliciting the Vary variant.
+ getWith(t, e, url, "Origin", "https://crawler.example")
+
+ // Then ordinary traffic: the first is a legitimate miss, every one after
+ // it must be served from cache.
+ var last *httptest.ResponseRecorder
+ for i := 0; i < 10; i++ {
+ last = getWith(t, e, url, "", "")
+ if got := last.Body.String(); got != "page" {
+ t.Fatalf("ordinary request %d body = %q, want %q", i, got, "page")
+ }
+ }
+ if n := hits.Load(); n != 2 {
+ t.Fatalf("backend invoked %d times for 1 Origin request + 10 ordinary ones, want 2 — after the first ordinary miss every ordinary request must hit the cache", n)
+ }
+ if acao := last.Header().Get("Access-Control-Allow-Origin"); acao != "" {
+ t.Fatalf("ordinary request served the Origin variant (Access-Control-Allow-Origin: %s)", acao)
+ }
+}
+
+// The same root cause on the Authorization rule, but in the read of the
+// REQUEST'S Authorization header rather than the response's Cache-Control:
+// http.Header.Get returns only the first line, so a request that splits
+// Authorization across two lines (an empty first line, the real credential on
+// the second — Go's HTTP server accepts exactly this from a client) made Get
+// see "" and the request looked unauthenticated. The RFC 9111 §3.5 refusal
+// never fired and a credentialed response got cached at the shared edge for
+// everyone.
+func TestServeHTTPRefusesAuthorizationSplitAcrossHeaderLines(t *testing.T) {
+ e := cacheProbeEdge(t, "splitauthreq.example.com", func(path string, h http.Header) {
+ if path == "/public" {
+ h.Set("Cache-Control", "max-age=60, public")
+ return
+ }
+ h.Set("Cache-Control", "max-age=60")
+ })
+
+ req := httptest.NewRequest("GET", "http://splitauthreq.example.com/private", nil)
+ req.Header.Add("Authorization", "")
+ req.Header.Add("Authorization", "Bearer secret-token")
+ e.ServeHTTP(httptest.NewRecorder(), req)
+
+ if n := cachedVariants(e, "splitauthreq.example.com", "/private"); n != 0 {
+ t.Fatalf("%d variants cached for a request whose Authorization arrived on a second header line, want 0 — a multi-line Authorization is still an Authorization", n)
+ }
+
+ // Positive control: the same split-Authorization request, but the
+ // response says public — proving the refusal isn't just breaking caching
+ // wholesale.
+ pubReq := httptest.NewRequest("GET", "http://splitauthreq.example.com/public", nil)
+ pubReq.Header.Add("Authorization", "")
+ pubReq.Header.Add("Authorization", "Bearer secret-token")
+ e.ServeHTTP(httptest.NewRecorder(), pubReq)
+
+ if !cachedAt(e, "splitauthreq.example.com", "/public") {
+ t.Fatal("Cache-Control: public must still be cached even with a split-line Authorization present")
+ }
+}
+
+// RFC 9111 §4.1 / RFC 9110 §12.5.5: Vary: * means the response is NEVER
+// reusable from a shared cache — it signals that the response depends on
+// something not captured by request headers at all. The token-scanning loop
+// treated "*" as if it were an ordinary header name: varyValue computed the
+// constant string "*=;" for every request regardless of who they were, so one
+// variant was stored and replayed to everyone.
+func TestServeHTTPRefusesVaryStar(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.Header().Set("Vary", "*")
+ w.WriteHeader(200)
+ w.Write([]byte("dashboard for " + r.Header.Get("Cookie")))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "varystar.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ const url = "http://varystar.example.com/dashboard"
+
+ alice := getWith(t, e, url, "Cookie", "sid=alice").Body.String()
+ if alice != "dashboard for sid=alice" {
+ t.Fatalf("alice body = %q, want %q", alice, "dashboard for sid=alice")
+ }
+ if n := cachedVariants(e, "varystar.example.com", "/dashboard"); n != 0 {
+ // Errorf, not Fatalf: the replay assertion below is the live
+ // consequence and is worth reporting in the same run.
+ t.Errorf("%d variants cached for a response carrying Vary: *, want 0 — Vary: * means never reusable from a shared cache", n)
+ }
+
+ bob := getWith(t, e, url, "Cookie", "sid=bob").Body.String()
+ if bob != "dashboard for sid=bob" {
+ t.Fatalf("bob was served %q — one visitor's response was replayed to another despite Vary: *", bob)
+ }
+}
+
+// hijackableRecorder embeds httptest.ResponseRecorder (which deliberately
+// does NOT implement http.Hijacker) and adds a no-op Hijack, standing in for
+// a real connection's ResponseWriter — the minimum shape needed to prove
+// ServeHTTP passes an Upgrade request's ResponseWriter through untouched
+// instead of wrapping it in recordingResponseWriter (which pre-fix had no
+// Hijack, breaking the stdlib reverse proxy's WebSocket-upgrade path).
+type hijackableRecorder struct {
+ *httptest.ResponseRecorder
+}
+
+func (h *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+ return nil, nil, nil
+}
+
+// hijackCheckingActivator fails the test if the ResponseWriter it's handed
+// doesn't satisfy http.Hijacker — proving ServeHTTP special-cased the
+// Upgrade request instead of wrapping it in recordingResponseWriter.
+type hijackCheckingActivator struct{ t *testing.T }
+
+func (h hijackCheckingActivator) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ if _, ok := w.(http.Hijacker); !ok {
+ h.t.Fatal("ResponseWriter passed to activator for an Upgrade request does not implement http.Hijacker — recordingResponseWriter must not wrap upgrade requests")
+ }
+ return true
+}
+
+func (h hijackCheckingActivator) RetireExcept(keep map[string]bool) {}
+
+// TestServeHTTPUpgradeRequestBypassesRecording proves a Connection: Upgrade
+// request (a WebSocket handshake) to a hibernating route reaches the
+// activator with a still-Hijacker-capable ResponseWriter — i.e. ServeHTTP
+// never wraps it in recordingResponseWriter, which would break the stdlib
+// reverse proxy's hijack-based upgrade path and, with it, the open WebSocket
+// the activator relies on to keep the instance's idle clock from firing.
+func TestServeHTTPUpgradeRequestBypassesRecording(t *testing.T) {
+ reg := openReg(t)
+ // Addr is a bogus, never-dialed address — this test only needs to reach
+ // the activator, never the real backend.
+ if err := reg.Upsert(registry.Route{Host: "ws.example.com", Kind: "instance", Addr: "127.0.0.1:1", Status: "ready", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, hijackCheckingActivator{t: t})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ req := httptest.NewRequest("GET", "http://ws.example.com/socket", nil)
+ req.Header.Set("Connection", "Upgrade")
+ req.Header.Set("Upgrade", "websocket")
+ rec := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()}
+
+ e.ServeHTTP(rec, req)
+}
+
+// activatorFunc adapts a plain function to the Activator interface, for
+// tests that want custom wake-counting behavior without a full fake type.
+type activatorFunc func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool
+
+func (f activatorFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ return f(w, r, rt, proxy)
+}
+
+func (f activatorFunc) RetireExcept(keep map[string]bool) {
+ // No-op for tests that don't need retirement tracking.
+}
+
+// TestCacheBeforeWakeFullCycleOverPurgeSocket is the full cycle with the REAL
+// purge channel in it: the same cold-miss → cached-repeat → purge → fresh
+// sequence as TestCacheBeforeWakeFullCycle, except the purge arrives the way a
+// spawned instance actually sends it — a JSON POST /purge over the unix
+// control socket ListenPurge binds (the socket whose path the activator hands
+// the child as CARLOS_CONTROL_SOCKET). The sibling test below drives the
+// in-process cache API, which cannot catch a broken socket, encoding, or
+// host-scoping; this one proves the whole channel from "instance calls purge"
+// to "edge cache entry is gone."
+func TestCacheBeforeWakeFullCycleOverPurgeSocket(t *testing.T) {
+ body := "version 1"
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(200)
+ w.Write([]byte(body))
+ }))
+ t.Cleanup(backend.Close)
+
+ wakes := 0
+ act := activatorFunc(func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ wakes++
+ proxy.ServeHTTP(w, r)
+ return true
+ })
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "sock-cycle.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ e.EnableCache(cache)
+
+ // The control socket the activator would create for this instance and
+ // hand it as CARLOS_CONTROL_SOCKET.
+ controlSocket := filepath.Join(t.TempDir(), "instance.sock.control")
+ pl, err := ListenPurge(controlSocket, "sock-cycle.example.com", cache)
+ if err != nil {
+ if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") {
+ t.Skipf("unix-socket listeners not permitted in this environment: %v", err)
+ }
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ get := func() string {
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://sock-cycle.example.com/event", nil))
+ return rec.Body.String()
+ }
+
+ if got := get(); got != "version 1" {
+ t.Fatalf("first request body = %q, want %q", got, "version 1")
+ }
+ if wakes != 1 {
+ t.Fatalf("expected exactly 1 wake for the cold request, got %d", wakes)
+ }
+ if got := get(); got != "version 1" || wakes != 1 {
+ t.Fatalf("second request body = %q wakes = %d, want %q and no second wake", got, wakes, "version 1")
+ }
+
+ // The write happens, and the instance purges its own page over the control
+ // socket — exactly the request a real instance makes.
+ body = "version 2"
+ payload, _ := json.Marshal(purgeRequest{Path: "/event", Query: ""})
+ resp, err := dialPurgeSocket(t, controlSocket).Post("http://unix/purge", "application/json", bytes.NewReader(payload))
+ if err != nil {
+ t.Fatalf("purge POST over control socket: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("purge status = %d, want 200", resp.StatusCode)
+ }
+
+ if got := get(); got != "version 2" {
+ t.Fatalf("post-purge request body = %q, want %q — the purge never reached the cache through the socket", got, "version 2")
+ }
+ if wakes != 2 {
+ t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes)
+ }
+}
+
+// TestCacheBeforeWakeFullCycle exercises the sequence the whole feature
+// exists for: a cold miss wakes the instance and populates the cache; a
+// repeat request hits the cache and does NOT wake the instance again; a
+// write's purge call makes the next request miss and see fresh content.
+func TestCacheBeforeWakeFullCycle(t *testing.T) {
+ body := "version 1"
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(200)
+ w.Write([]byte(body))
+ }))
+ t.Cleanup(backend.Close)
+
+ wakes := 0
+ act := activatorFunc(func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ wakes++
+ proxy.ServeHTTP(w, r)
+ return true
+ })
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "cycle.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ e.EnableCache(cache)
+
+ get := func() string {
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cycle.example.com/event", nil))
+ return rec.Body.String()
+ }
+
+ if got := get(); got != "version 1" {
+ t.Fatalf("first request body = %q, want %q", got, "version 1")
+ }
+ if wakes != 1 {
+ t.Fatalf("expected exactly 1 wake for the cold request, got %d", wakes)
+ }
+
+ if got := get(); got != "version 1" {
+ t.Fatalf("second request body = %q, want %q", got, "version 1")
+ }
+ if wakes != 1 {
+ t.Fatalf("expected the second request to hit cache without waking again, wakes = %d", wakes)
+ }
+
+ // Simulate the write + purge an instance does after a real change.
+ body = "version 2"
+ cache.PurgeURL("cycle.example.com", "/event", "")
+
+ if got := get(); got != "version 2" {
+ t.Fatalf("post-purge request body = %q, want %q", got, "version 2")
+ }
+ if wakes != 2 {
+ t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes)
+ }
+}
diff --git a/internal/edge/purge.go b/internal/edge/purge.go
new file mode 100644
index 000000000..2f931ef79
--- /dev/null
+++ b/internal/edge/purge.go
@@ -0,0 +1,85 @@
+package edge
+
+import (
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "os"
+ "sync"
+ "syscall"
+)
+
+// PurgeListener is a per-instance, local-only channel for invalidating
+// that instance's cache entries right after a write — see
+// docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §3-4.
+// It is deliberately NOT reachable over the network: an externally
+// reachable purge endpoint would let anyone manufacture wake storms by
+// force-purging popular pages, exactly what this feature exists to
+// prevent.
+type PurgeListener struct {
+ ln net.Listener
+ srv *http.Server
+}
+
+type purgeRequest struct {
+ Path string `json:"path"`
+ Query string `json:"query"`
+}
+
+// listenMu guards the umask-wrapped net.Listen call in ListenPurge. Since
+// syscall.Umask is process-wide and not per-goroutine, concurrent ListenPurge
+// calls (e.g., in carlos agent spawning multiple instances) must serialize
+// around the umask change to avoid race windows where the socket is created
+// world-connectable before being chmod'd to 0600.
+var listenMu sync.Mutex
+
+// ListenPurge binds socketPath (removing any stale file left behind by a
+// crashed previous instance) and serves purge requests scoped to host —
+// host is fixed here, never taken from the request, so a compromised or
+// buggy instance can only ever purge its own cache entries.
+func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) {
+ if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+
+ // Create the socket with umask 0177 to prevent a world-connectable race window.
+ // syscall.Umask is process-wide, so we serialize around it with listenMu
+ // to protect against concurrent ListenPurge calls (e.g., in carlos agent
+ // spawning multiple instances).
+ listenMu.Lock()
+ prevUmask := syscall.Umask(0177)
+ ln, err := net.Listen("unix", socketPath)
+ syscall.Umask(prevUmask)
+ listenMu.Unlock()
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Defensive chmod as backstop (no longer load-bearing, but kept for defense-in-depth).
+ if err := os.Chmod(socketPath, 0600); err != nil {
+ ln.Close()
+ return nil, err
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /purge", func(w http.ResponseWriter, r *http.Request) {
+ var req purgeRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ cache.PurgeURL(host, req.Path, req.Query)
+ w.WriteHeader(http.StatusOK)
+ })
+
+ srv := &http.Server{Handler: mux}
+ p := &PurgeListener{ln: ln, srv: srv}
+ go srv.Serve(ln) //nolint:errcheck // Close() below triggers the expected ErrServerClosed
+ return p, nil
+}
+
+func (p *PurgeListener) Close() error {
+ return p.srv.Close()
+}
diff --git a/internal/edge/purge_test.go b/internal/edge/purge_test.go
new file mode 100644
index 000000000..6d126fa2f
--- /dev/null
+++ b/internal/edge/purge_test.go
@@ -0,0 +1,109 @@
+package edge
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func dialPurgeSocket(t *testing.T, socketPath string) *http.Client {
+ t.Helper()
+ return &http.Client{
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return net.Dial("unix", socketPath)
+ },
+ },
+ }
+}
+
+func TestListenPurgeRemovesCachedEntry(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ cache.Put(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}, "", &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
+ })
+
+ pl, err := ListenPurge(sock, "a.example", cache)
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
+ client := dialPurgeSocket(t, sock)
+ resp, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("purge POST: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("purge status = %d, want 200", resp.StatusCode)
+ }
+
+ if _, ok := cache.Get(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}); ok {
+ t.Fatal("expected entry to be purged")
+ }
+}
+
+func TestListenPurgeCannotAffectAnotherHost(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ cache.Put(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}, "", &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
+ })
+
+ pl, err := ListenPurge(sock, "a.example", cache) // bound to a.example, not other.example
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
+ client := dialPurgeSocket(t, sock)
+ resp, _ := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ resp.Body.Close()
+
+ if _, ok := cache.Get(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}); !ok {
+ t.Fatal("a purge listener bound to a.example must never affect other.example's cache entries")
+ }
+}
+
+func TestListenPurgeSocketFileIsOwnerOnly(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ fi, err := os.Stat(sock)
+ if err != nil {
+ t.Fatalf("stat socket: %v", err)
+ }
+ if mode := fi.Mode().Perm(); mode != 0600 {
+ t.Fatalf("socket mode = %o, want 0600", mode)
+ }
+}
+
+func TestListenPurgeRemovesStaleSocketFile(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ if err := os.WriteFile(sock, []byte("stale"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
+ if err != nil {
+ t.Fatalf("ListenPurge should clean up a stale socket file, got: %v", err)
+ }
+ pl.Close()
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 0290fa70d..07a1d6f55 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -111,6 +111,12 @@ func RunAgent(args []string) {
log.Fatalf("agent: open registry %s: %v", regPath, err)
}
+ // Constructed once here so the activator's ExecBackend (purges) and the
+ // edge (reads + serves cached hits) share the very same store — see the
+ // package doc comment above about caching applying before the
+ // hibernation branch.
+ cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+
cfg, err := activator.ConfigFromEnv()
if err != nil {
log.Fatalf("agent: %v", err)
@@ -127,6 +133,7 @@ func RunAgent(args []string) {
Cfg: *cfg,
OptDir: release.OptDir(),
DataDir: dd,
+ Cache: cache,
},
Unit: &activator.UnitBackend{OptDir: release.OptDir()},
}, reg) // the registry satisfies activator.DueStore directly
@@ -141,7 +148,9 @@ func RunAgent(args []string) {
go domainsLoop(reg, peekAcmeCache(args))
// The same serving loop as `carlos edge`, with the activator injected.
- edge.RunWithActivator(args, act)
+ // Caching applies here too: cached hits still avoid the activator, which
+ // is the entire point of checking the cache before the hibernation branch.
+ edge.RunWithActivator(args, act, cache)
// The edge has drained; take the children down too (they're this process's
// children — orphaning them would strand their leases until TTL expiry).