Compare commits

..

10 Commits

Author SHA1 Message Date
linus 8eb31143e5 cache: Key by depot path across CDN host aliases (#48)
CI / vulncheck (push) Successful in 14s
CI / check-and-test (push) Successful in 41s
Release Tag / release (push) Successful in 15s
Cache key from Path only; Host aliases share one entry. Closes #39.
2026-09-07 15:18:26 -05:00
moss 337741e06e cache: Key by depot path across CDN host aliases
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 41s
2026-09-07 20:14:30 +00:00
linus 2c8ec276e7 ops: Widen validate-check for upstream/write/rate failures (#47)
CI / vulncheck (push) Successful in 15s
CI / check-and-test (push) Successful in 41s
Highlight upstream_errors/cache_write_failures/rate_limited. Closes #34.
2026-09-07 14:53:37 -05:00
pike 144a68dcd7 ops: Widen validate-check for upstream/write/rate failures
CI / vulncheck (pull_request) Successful in 15s
CI / check-and-test (pull_request) Successful in 41s
Surface upstream_errors, cache_write_failures, and rate_limited in
make validate-check highlights and Quick check docs so a hot failure
path cannot look fine when hits exist.

Closes #34
2026-09-07 19:51:50 +00:00
linus 8cebc1f96c ops: Signal disk-tier attach pending vs ready (#45)
CI / vulncheck (push) Successful in 15s
CI / check-and-test (push) Successful in 41s
Release Tag / release (push) Successful in 15s
disk_tier_ready + X-SteamCache-Disk-Tier. Closes #33.
2026-09-07 12:02:10 -05:00
pike 12ea3ee4f6 ops: Signal disk-tier attach pending vs ready
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 40s
Large disk caches can look memory-only/broken during async DiskFS attach.
Expose disk_tier_ready in /metrics, pending/ready logs for disk-only and
mixed modes, and X-SteamCache-Disk-Tier on /lancache-heartbeat. GetMetrics
skips blocking disk.Size() while attach is pending so /metrics stays usable.

Closes #33
2026-09-07 16:58:13 +00:00
linus a500f51f17 cache: Serve Range GET from disk when present (#44)
CI / vulncheck (push) Successful in 14s
CI / check-and-test (push) Successful in 41s
Range HIT/MISS as 206; range_cache/range_upstream metrics. Closes #38.
2026-09-07 11:37:06 -05:00
moss ac2d36f1ad cache: Serve Range GET from disk when present
CI / vulncheck (pull_request) Successful in 15s
CI / check-and-test (pull_request) Successful in 41s
Steam clients lean on Range requests. Hit/miss looked fine while downloads
still felt cold when a Range miss returned the full upstream body as 200.

Add range_cache / range_upstream metrics. HIT path already sliced 206 from
the cached full object; count range_cache there. On MISS, keep stripping
Range for the upstream fetch (full object still cached) but serve the
client's requested slice as 206 and count range_upstream.

Tests in steamcache/range_test.go cover Range HIT (local, no upstream),
Range MISS (206 + full object cached), and /metrics emission.
2026-09-07 16:32:36 +00:00
linus c43bfba568 Merge pull request 'docs: Real DNS + empty upstream Quick Start' (#43) from docs/quick-start-real-dns into main
docs: Real DNS + empty upstream Quick Start

Fixes #32
2026-09-07 10:00:58 -05:00
eva 3411defd51 docs: Real DNS + empty upstream Quick Start
CI / vulncheck (pull_request) Successful in 15s
CI / check-and-test (pull_request) Successful in 40s
2026-09-07 14:58:06 +00:00
8 changed files with 760 additions and 27 deletions
+4 -4
View File
@@ -62,7 +62,7 @@ validate run-validation: build clean-disk ## Start steamcache2 on :80 with small
fi; \
exec "$$BINARY" --config docs/examples/validate-config.yaml --log-level info
validate-check: ## Curl local /metrics (full dump + hit/miss fields) and /lancache-heartbeat (default :80)
validate-check: ## Curl local /metrics (full dump + hit/miss + upstream/write/rate fields) and /lancache-heartbeat (default :80)
@echo "=== http://localhost/metrics ==="
@metrics=$$(curl -sf --max-time 5 http://localhost/metrics) || { \
echo "ERROR: could not fetch http://localhost/metrics"; \
@@ -71,8 +71,8 @@ validate-check: ## Curl local /metrics (full dump + hit/miss fields) and /lancac
}; \
printf '%s\n' "$$metrics"; \
echo ""; \
echo "=== hit/miss fields ==="; \
printf '%s\n' "$$metrics" | grep -E '^(total_requests|cache_hits|cache_misses|hit_rate|memory_cache_hits|disk_cache_hits|errors) ' || true; \
echo "=== hit/miss + upstream/write/rate fields ==="; \
printf '%s\n' "$$metrics" | grep -E '^(total_requests|cache_hits|cache_misses|hit_rate|memory_cache_hits|disk_cache_hits|errors|upstream_errors|cache_write_failures|rate_limited) ' || true; \
echo ""; \
echo "=== http://localhost/lancache-heartbeat (GET; expect 204 + X-LanCache-Processed-By: SteamCache2) ==="; \
hb=$$(curl -sD - -o /dev/null --max-time 5 http://localhost/lancache-heartbeat) || { \
@@ -133,7 +133,7 @@ help: ## Show this help message
@echo " clean-disk Remove disk cache"
@echo " bench Run low-level VFS microbenchmarks"
@echo " validate / run-validation Start server on :80 (builds, auto-setcaps fresh binary, then runs as normal user, cleans disk cache first)"
@echo " validate-check Curl local /metrics (full dump + hit/miss fields) and /lancache-heartbeat (default :80)"
@echo " validate-check Curl local /metrics (full dump + hit/miss + upstream/write/rate fields) and /lancache-heartbeat (default :80)"
@echo " setcap Explicitly set cap on current build (for port 80 use outside validate)"
@echo " validate-kill Kill leftover steamcache2 processes (safer)"
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)"
+35 -10
View File
@@ -10,8 +10,8 @@ SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandw
- Reduces bandwidth usage
- Easy to set up and configure aside from dns stuff to trick Steam into using it
- Supports multiple clients
- **NEW:** YAML configuration system with automatic config generation
- **NEW:** Simple Makefile for development workflow
- YAML configuration with automatic config generation on first run
- Makefile for development and validation workflows
- Cross-platform builds (Linux, macOS, Windows)
## Quick Start
@@ -34,7 +34,10 @@ SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandw
The application will automatically create a `config.yaml` file with default settings and exit, allowing you to customize it.
3. **Edit the configuration** (`config.yaml`):
3. **Edit the configuration** (`config.yaml`) for a real Steam front-door:
Leave `upstream` empty. With an empty upstream, steamcache2 fetches from the request `Host` (the same pattern SteamPrefill / real Steam clients use when DNS points them at your cache). Do **not** paste a fake host like `https://steam.cdn.com` — that is not a Steam CDN and will not put you in front of Steam.
```yaml
listen_address: :80
cache:
@@ -45,10 +48,19 @@ SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandw
size: 10GB
path: ./disk
gc_algorithm: hybrid
upstream: "https://steam.cdn.com" # Set your upstream server
# Empty upstream = use the client Host as the origin (Steam CDN names only).
upstream: ""
```
4. **Run the application again:**
4. **Point Steam (or SteamPrefill) at this cache** before you expect hits:
- **LAN DNS:** resolve `lancache.steamcontent.com` (and other Steam content names your clients use) to this server's LAN IP.
- **Single Windows PC:** add a hosts override — see [Windows Hosts File Override](#windows-hosts-file-override) (`<cache-ip> lancache.steamcontent.com`).
- Restart Steam (or your prefill tool) after DNS/hosts changes.
Empty-upstream direct fetch only allows Steam CDN host suffixes (`steamcontent.com`, `steampowered.com`, `steamstatic.com`). Literal IPs and unrelated hosts are rejected.
5. **Run the application again:**
```bash
make run # or ./steamcache2
```
@@ -64,16 +76,23 @@ curl -s http://localhost/metrics
curl -s -i http://localhost/lancache-heartbeat
```
`make validate-check` prints the full `/metrics` dump, highlights hit/miss fields, and curls `/lancache-heartbeat`. Read these fields:
`make validate-check` prints the full `/metrics` dump, highlights hit/miss plus `upstream_errors` / `cache_write_failures` / `rate_limited`, and curls `/lancache-heartbeat`. Read these fields:
| Field | Meaning |
| --- | --- |
| `cache_hits` / `cache_misses` / `hit_rate` | Whether later requests were served from cache |
| `range_cache` / `range_upstream` | Range GETs served as 206 from a cached object vs after a full upstream fetch |
| `memory_cache_hits` / `disk_cache_hits` | Which tier served the hits |
| `total_requests` / `errors` | Volume and failures |
| `upstream_errors` / `cache_write_failures` / `rate_limited` | Upstream pipe, cache write, and rate-limit pressure (Quick check highlights these next to hit/miss) |
| `disk_tier_ready` | `0` while disk slow-tier attach pending; `1` when attached, or when no disk configured (N/A — not waiting) |
A first pass through new content is mostly misses (`hit_rate` near 0). Repeat the same content and `cache_hits` / `hit_rate` should rise.
Cache entries are keyed by depot object path (not the CDN `Host` header), so when Steam rotates CDN hostnames for the same depot path, hits still climb across the aliases.
Steam clients lean on Range requests. When an object is already cached, a Range GET is served locally as 206 from that full object (`range_cache`). On a Range miss the cache still fetches and stores the full upstream body, then returns the requested byte range as 206 (`range_upstream`).
To confirm the process is up (HTTP 204 and `X-LanCache-Processed-By: SteamCache2`):
```bash
@@ -82,6 +101,8 @@ curl -s -i http://localhost/lancache-heartbeat
Use GET (`curl -i`), not HEAD (`curl -I`): the server only accepts GET.
Heartbeat also returns `X-SteamCache-Disk-Tier: pending|ready|disabled` (`disabled` = memory-only / no disk; `pending`/`ready` = disk configured attach state).
These are the cache process's own `/metrics` and `/lancache-heartbeat` endpoints. There is no separate metrics daemon.
If you changed `listen_address`, point curl at that host:port instead. For a full SteamPrefill validation workflow (small caches, coalescing, GC), see [Validating Full Functionality](#validating-full-functionality-with-external-tools).
@@ -154,7 +175,7 @@ curl -s -i http://localhost/lancache-heartbeat
`make validate-check` prints the full `/metrics` dump, highlights hit/miss fields, and curls `/lancache-heartbeat`. Look for:
- High cache hit rate after the warmup pass (`cache_hits`, `hit_rate`, plus `memory_cache_hits` / `disk_cache_hits`)
- Non-zero `coalesced` and `disk` activity
- Zero unexpected `errors`
- Zero unexpected `errors`, and quiet `upstream_errors` / `cache_write_failures` / `rate_limited`
Heartbeat should be HTTP 204 with `X-LanCache-Processed-By: SteamCache2`. Use GET (`curl -i`), not HEAD (`curl -I`).
@@ -167,7 +188,7 @@ The recommended validation config is at [docs/examples/validate-config.yaml](doc
Running a realistic SteamPrefill benchmark workload through a built steamcache2 exercises the complete public surface that matters for production use:
- Steam User-Agent detection and depot/manifest/chunk URL patterns
- Full MISS → cache write → HIT (and HIT-COALESCED) paths
- Range request handling from cached full responses
- Range request handling from cached full responses (local 206 on HIT via `range_cache`; MISS fetches the full object then serves the requested slice as 206 via `range_upstream`)
- Request coalescing under concurrent load
- Memory tier + disk tier interaction (including async disk attach)
- Garbage collection and eviction under pressure
@@ -238,8 +259,9 @@ cache:
gc_algorithm: hybrid
# Upstream server configuration
# The upstream server to proxy requests to
upstream: "https://steam.cdn.com"
# Leave empty to fetch from the request Host (Steam CDN names only).
# Set only when chaining caches (table RAM cache -> room disk cache).
upstream: ""
```
#### Startup Validation
@@ -274,6 +296,9 @@ See `config.Validate()` and `steamcache.New` error paths. This ensures the LAN a
- The explicit startup guard (reduce size if pre-existing on-disk > cap) runs as the literal last step of bg init, before the barrier opens.
- Add a note for operators: very large disk caches (tens/hundreds GB with millions files) may show extended "memory-only or no-cache" behavior at startup (seconds to minutes depending on storage speed); this is by design for responsiveness.
- Godoc on `disk.New` and `DiskFS.Size` expanded with the barrier/attach behavior.
- Startup logs: Info "Disk slow tier attach pending..." then later "Disk slow tier attached (...)" for disk-only and mixed modes.
- `/metrics` exposes `disk_tier_ready` 0/1 and stays responsive during attach (GetMetrics does not block on Size while pending).
- `/lancache-heartbeat` header `X-SteamCache-Disk-Tier` mirrors that state.
#### Garbage Collection Algorithms
+7
View File
@@ -262,6 +262,13 @@ func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Reques
// Send the range data
_, _ = w.Write(rangeData) // client write error ignored (disconnect during range body send is not actionable)
// Range served from cache: count the range-specific metric and the range
// bytes actually written (handleCacheHit skips full-blob byte counting for
// Range requests so these are not double-counted).
sc.metrics.IncrementRangeCache()
sc.metrics.AddBytesServed(int64(len(rangeData)))
sc.metrics.AddBytesSaved(int64(len(rangeData)))
logger.Logger.Info().
Str("cache_key", cacheKey).
Str("url", r.URL.String()).
+53 -9
View File
@@ -56,6 +56,15 @@ func (sc *SteamCache) handleSpecialEndpoints(w http.ResponseWriter, r *http.Requ
logger.Logger.Debug().
Str("client_ip", clientIP).
Msg("LanCache heartbeat request")
diskTier := "disabled"
if sc.disk != nil {
if sc.metrics.GetDiskTierReady() == 1 {
diskTier = "ready"
} else {
diskTier = "pending"
}
}
w.Header().Add("X-SteamCache-Disk-Tier", diskTier)
w.Header().Add("X-LanCache-Processed-By", "SteamCache2")
w.WriteHeader(http.StatusNoContent)
_, _ = w.Write(nil) // client write error ignored (heartbeat path; nil write is no-op)
@@ -109,8 +118,14 @@ func (sc *SteamCache) handleCacheHit(w http.ResponseWriter, r *http.Request, cac
// Track cache hit metrics
sc.metrics.IncrementCacheHits()
sc.metrics.AddResponseTime(time.Since(tstart))
sc.metrics.AddBytesServed(int64(len(cachedData)))
sc.metrics.AddBytesSaved(int64(len(cachedData)))
if r.Header.Get("Range") == "" {
// Full-object HIT: count the cached blob served. Range HITs skip
// this here — streamCachedResponse counts the range bytes actually
// written to the client instead, so BytesServed/Saved reflect the
// partial body and are not double-counted.
sc.metrics.AddBytesServed(int64(len(cachedData)))
sc.metrics.AddBytesSaved(int64(len(cachedData)))
}
sc.metrics.IncrementServiceRequests(service.Name)
logger.Logger.Debug().
@@ -264,9 +279,12 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check if this is a request from a supported service
if service, isSupported := sc.detectService(r); isSupported {
// trim the query parameters from the URL path
// this is necessary because the cache key should not include query parameters
urlPath := strings.SplitN(r.URL.String(), "?", 2)[0] // trim query for cache key (SplitN makes intent explicit vs Cut + ignored bool)
// Cache key is the path only, never the Host: Steam rotates CDN hostnames
// for the same depot object, so different Host headers (or absolute-form
// request targets) for the same path must share one cache entry. r.URL.Path
// is the decoded path (query is never part of it); validateURLPath checks
// this decoded form and url.JoinPath re-escapes it for the upstream join.
urlPath := r.URL.Path
// Validate URL path for security
if err := validateURLPath(urlPath); err != nil {
@@ -549,14 +567,40 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-LanCache-Status", "MISS")
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
// Stream the response body to client
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(bodyData) // client write error ignored (disconnect during MISS body send is not actionable)
// Stream the response body to client.
// Range miss: the Range header was stripped for the upstream fetch (so the
// FULL object is cached below); serve the client's requested slice from the
// full body as 206, matching the HIT Range path.
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
start, end, totalSize, rangeValid := parseRangeHeader(rangeHeader, int64(len(bodyData)))
if !rangeValid {
// Invalid range — 416 (consistent with the HIT Range path). Drop the
// upstream Content-Length: it describes the full body, which 416 does
// not send (a stale CL would hang clients waiting for a body).
w.Header().Del("Content-Length")
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", len(bodyData)))
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
} else {
rangeData := bodyData[start : end+1]
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(rangeData)))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write(rangeData) // client write error ignored (disconnect during MISS range body send is not actionable)
// Range required an upstream fetch (full object) then served as 206
sc.metrics.IncrementRangeUpstream()
sc.metrics.AddBytesServed(int64(len(rangeData))) // range bytes only, not the full cached object
}
} else {
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(bodyData) // client write error ignored (disconnect during MISS body send is not actionable)
sc.metrics.AddBytesServed(int64(len(bodyData)))
}
// Track cache miss metrics
sc.metrics.IncrementCacheMisses()
sc.metrics.AddResponseTime(time.Since(tstart))
sc.metrics.AddBytesServed(int64(len(bodyData)))
sc.metrics.IncrementServiceRequests(service.Name)
// Verify we received the complete file by checking Content-Length
+36
View File
@@ -16,6 +16,8 @@ type Metrics struct {
CacheHits int64
CacheMisses int64
CacheCoalesced int64
RangeCache int64 // Range requests served as 206 from an already-cached object (HIT)
RangeUpstream int64 // Range requests that required an upstream fetch (full object), served as 206
Errors int64
RateLimited int64
@@ -31,6 +33,7 @@ type Metrics struct {
DiskCacheHits int64
Promotions int64
Evictions int64
DiskTierReady int64 // 0=pending (or unset), 1=ready or no-disk (N/A)
// Expanded observability (upstream breakdowns, cache write failures, per-service errors)
UpstreamErrors int64
@@ -78,6 +81,17 @@ func (m *Metrics) IncrementCacheCoalesced() {
atomic.AddInt64(&m.CacheCoalesced, 1)
}
// IncrementRangeCache increments the Range-from-cache counter (HIT served as 206)
func (m *Metrics) IncrementRangeCache() {
atomic.AddInt64(&m.RangeCache, 1)
}
// IncrementRangeUpstream increments the Range-from-upstream counter (full object
// fetched upstream, requested slice served as 206)
func (m *Metrics) IncrementRangeUpstream() {
atomic.AddInt64(&m.RangeUpstream, 1)
}
// IncrementErrors increments the error counter
func (m *Metrics) IncrementErrors() {
atomic.AddInt64(&m.Errors, 1)
@@ -114,6 +128,17 @@ func (m *Metrics) SetDiskCacheSize(size int64) {
atomic.StoreInt64(&m.DiskCacheSize, size)
}
// SetDiskTierReady sets whether the disk slow tier is attached (1) or still pending (0).
// Memory-only (no disk) also uses 1 — meaning "not waiting on disk attach". Reset does not clear this.
func (m *Metrics) SetDiskTierReady(ready int64) {
atomic.StoreInt64(&m.DiskTierReady, ready)
}
// GetDiskTierReady returns 1 if disk tier is ready (or no disk configured), else 0 while attach pending.
func (m *Metrics) GetDiskTierReady() int64 {
return atomic.LoadInt64(&m.DiskTierReady)
}
// IncrementMemoryCacheHits increments memory cache hits
func (m *Metrics) IncrementMemoryCacheHits() {
atomic.AddInt64(&m.MemoryCacheHits, 1)
@@ -188,6 +213,8 @@ func (m *Metrics) GetStats() *Stats {
CacheHits: cacheHits,
CacheMisses: cacheMisses,
CacheCoalesced: atomic.LoadInt64(&m.CacheCoalesced),
RangeCache: atomic.LoadInt64(&m.RangeCache),
RangeUpstream: atomic.LoadInt64(&m.RangeUpstream),
Errors: atomic.LoadInt64(&m.Errors),
RateLimited: atomic.LoadInt64(&m.RateLimited),
HitRate: hitRate,
@@ -196,6 +223,7 @@ func (m *Metrics) GetStats() *Stats {
TotalBytesSaved: atomic.LoadInt64(&m.TotalBytesSaved),
MemoryCacheSize: atomic.LoadInt64(&m.MemoryCacheSize),
DiskCacheSize: atomic.LoadInt64(&m.DiskCacheSize),
DiskTierReady: atomic.LoadInt64(&m.DiskTierReady),
MemoryCacheHits: atomic.LoadInt64(&m.MemoryCacheHits),
DiskCacheHits: atomic.LoadInt64(&m.DiskCacheHits),
Promotions: atomic.LoadInt64(&m.Promotions),
@@ -215,6 +243,8 @@ func (m *Metrics) Reset() {
atomic.StoreInt64(&m.CacheHits, 0)
atomic.StoreInt64(&m.CacheMisses, 0)
atomic.StoreInt64(&m.CacheCoalesced, 0)
atomic.StoreInt64(&m.RangeCache, 0)
atomic.StoreInt64(&m.RangeUpstream, 0)
atomic.StoreInt64(&m.Errors, 0)
atomic.StoreInt64(&m.RateLimited, 0)
atomic.StoreInt64(&m.TotalResponseTime, 0)
@@ -244,6 +274,8 @@ type Stats struct {
CacheHits int64
CacheMisses int64
CacheCoalesced int64
RangeCache int64
RangeUpstream int64
Errors int64
RateLimited int64
HitRate float64
@@ -253,6 +285,7 @@ type Stats struct {
MemoryCacheSize int64
DiskCacheSize int64
DiskTierReady int64
MemoryCacheHits int64
DiskCacheHits int64
Promotions int64
@@ -276,6 +309,8 @@ func WriteText(w http.ResponseWriter, stats *Stats) {
_, _ = fmt.Fprintf(w, "cache_hits %d\n", stats.CacheHits)
_, _ = fmt.Fprintf(w, "cache_misses %d\n", stats.CacheMisses)
_, _ = fmt.Fprintf(w, "cache_coalesced %d\n", stats.CacheCoalesced)
_, _ = fmt.Fprintf(w, "range_cache %d\n", stats.RangeCache)
_, _ = fmt.Fprintf(w, "range_upstream %d\n", stats.RangeUpstream)
_, _ = fmt.Fprintf(w, "errors %d\n", stats.Errors)
_, _ = fmt.Fprintf(w, "rate_limited %d\n", stats.RateLimited)
_, _ = fmt.Fprintf(w, "upstream_errors %d\n", stats.UpstreamErrors)
@@ -297,5 +332,6 @@ func WriteText(w http.ResponseWriter, stats *Stats) {
_, _ = fmt.Fprintf(w, "memory_cache_size %d\n", stats.MemoryCacheSize)
_, _ = fmt.Fprintf(w, "disk_cache_size %d\n", stats.DiskCacheSize)
_, _ = fmt.Fprintf(w, "disk_tier_ready %d\n", stats.DiskTierReady)
_, _ = fmt.Fprintf(w, "uptime_seconds %.2f\n", stats.Uptime.Seconds())
}
+386
View File
@@ -0,0 +1,386 @@
// steamcache/range_test.go
package steamcache
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestRangeHitServedLocally verifies that a Range GET against an already-cached
// object is served locally as 206 (no upstream re-fetch) and increments range_cache.
func TestRangeHitServedLocally(t *testing.T) {
body := []byte("0123456789abcdef") // 16 bytes
var upstreamCalls atomic.Int64
f := func(w http.ResponseWriter, r *http.Request) {
upstreamCalls.Add(1)
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(body)
}
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
srv := newCacheServer(t, sc)
c := &http.Client{Timeout: 5 * time.Second}
// 1) Populate the cache with a full (non-Range) MISS.
req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
resp, err := c.Do(req)
if err != nil {
t.Fatalf("miss GET: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("miss GET: expected 200, got %d", resp.StatusCode)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if got := sc.GetMetrics().CacheMisses; got < 1 {
t.Fatalf("expected CacheMisses >= 1 after first GET, got %d", got)
}
// 2) Range GET against the same URL — must be a local 206 HIT.
req2, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req2.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
req2.Header.Set("Range", "bytes=4-7")
resp2, err := c.Do(req2)
if err != nil {
t.Fatalf("range GET: %v", err)
}
data, err := io.ReadAll(resp2.Body)
_ = resp2.Body.Close()
if err != nil {
t.Fatalf("read range body: %v", err)
}
if resp2.StatusCode != http.StatusPartialContent {
t.Fatalf("range GET: expected 206, got %d", resp2.StatusCode)
}
if string(data) != "4567" {
t.Errorf("range GET: expected body %q, got %q", "4567", data)
}
if got := resp2.Header.Get("X-LanCache-Status"); got != "HIT" {
t.Errorf("range GET: expected X-LanCache-Status HIT, got %q", got)
}
if got := resp2.Header.Get("Content-Range"); got != "bytes 4-7/16" {
t.Errorf("range GET: expected Content-Range bytes 4-7/16, got %q", got)
}
if got := resp2.Header.Get("Accept-Ranges"); got != "bytes" {
t.Errorf("range GET: expected Accept-Ranges bytes, got %q", got)
}
// Upstream must NOT have been hit again.
if got := upstreamCalls.Load(); got != 1 {
t.Errorf("upstream hit %d times, want exactly 1 (range HIT must be local)", got)
}
// range_cache incremented, range_upstream untouched.
stats := sc.GetMetrics()
if stats.RangeCache != 1 {
t.Errorf("expected RangeCache == 1 after range HIT, got %d", stats.RangeCache)
}
if stats.RangeUpstream != 0 {
t.Errorf("expected RangeUpstream == 0 (no range miss yet), got %d", stats.RangeUpstream)
}
if stats.CacheHits < 1 {
t.Errorf("expected CacheHits >= 1 after range HIT, got %d", stats.CacheHits)
}
// BytesServed: 16 (full miss body) + 4 (range bytes) = 20.
if stats.TotalBytesServed != 20 {
t.Errorf("expected TotalBytesServed == 20 (16 + range 4), got %d", stats.TotalBytesServed)
}
}
// TestRangeMissServes206FromFullFetch verifies that a Range GET on a cold key fetches
// the FULL object from upstream (Range stripped), caches it, and serves the requested
// slice as 206 with range_upstream incremented.
func TestRangeMissServes206FromFullFetch(t *testing.T) {
body := []byte("0123456789abcdef") // 16 bytes
var upstreamCalls atomic.Int64
var upstreamSawRange atomic.Bool
f := func(w http.ResponseWriter, r *http.Request) {
upstreamCalls.Add(1)
if r.Header.Get("Range") != "" {
upstreamSawRange.Store(true)
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(body)
}
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
srv := newCacheServer(t, sc)
c := &http.Client{Timeout: 5 * time.Second}
// Range GET on a cold key.
req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk2", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
req.Header.Set("Range", "bytes=0-3")
resp, err := c.Do(req)
if err != nil {
t.Fatalf("range miss GET: %v", err)
}
data, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
t.Fatalf("read body: %v", err)
}
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("range miss GET: expected 206, got %d", resp.StatusCode)
}
if string(data) != "0123" {
t.Errorf("range miss GET: expected body %q, got %q", "0123", data)
}
if got := resp.Header.Get("X-LanCache-Status"); got != "MISS" {
t.Errorf("range miss GET: expected X-LanCache-Status MISS, got %q", got)
}
if got := resp.Header.Get("Content-Range"); got != "bytes 0-3/16" {
t.Errorf("range miss GET: expected Content-Range bytes 0-3/16, got %q", got)
}
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
t.Errorf("range miss GET: expected Accept-Ranges bytes, got %q", got)
}
// Range must have been stripped for the upstream fetch (full file cached).
if upstreamSawRange.Load() {
t.Error("upstream received a Range header; Range must be stripped so the full object is cached")
}
if got := upstreamCalls.Load(); got != 1 {
t.Errorf("upstream hit %d times, want exactly 1", got)
}
// range_upstream incremented, range_cache untouched.
stats := sc.GetMetrics()
if stats.RangeUpstream != 1 {
t.Errorf("expected RangeUpstream == 1 after range MISS, got %d", stats.RangeUpstream)
}
if stats.RangeCache != 0 {
t.Errorf("expected RangeCache == 0 (no range hit yet), got %d", stats.RangeCache)
}
if stats.CacheMisses < 1 {
t.Errorf("expected CacheMisses >= 1, got %d", stats.CacheMisses)
}
// BytesServed for the range miss: only the 4 range bytes, not the 16-byte fetch.
if stats.TotalBytesServed != 4 {
t.Errorf("expected TotalBytesServed == 4 (range bytes only), got %d", stats.TotalBytesServed)
}
// The FULL object must have been cached: a subsequent full GET is a HIT with the
// complete 16-byte body.
req3, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk2", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req3.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
resp3, err := c.Do(req3)
if err != nil {
t.Fatalf("full GET after range miss: %v", err)
}
fullData, err := io.ReadAll(resp3.Body)
_ = resp3.Body.Close()
if err != nil {
t.Fatalf("read full body: %v", err)
}
if resp3.StatusCode != http.StatusOK {
t.Fatalf("full GET after range miss: expected 200, got %d", resp3.StatusCode)
}
if got := resp3.Header.Get("X-LanCache-Status"); got != "HIT" {
t.Errorf("full GET after range miss: expected X-LanCache-Status HIT, got %q", got)
}
if len(fullData) != len(body) || string(fullData) != string(body) {
t.Errorf("full GET after range miss: expected full %d-byte body, got %d bytes", len(body), len(fullData))
}
if got := upstreamCalls.Load(); got != 1 {
t.Errorf("upstream hit %d times after HIT, want still 1", got)
}
}
// TestRangeMissInvalidRange416 verifies that an unsatisfiable Range on a cold key
// fetches upstream, returns 416 (as on the HIT path), and does not count range_upstream.
func TestRangeMissInvalidRange416(t *testing.T) {
body := []byte("0123456789abcdef") // 16 bytes
var upstreamCalls atomic.Int64
f := func(w http.ResponseWriter, r *http.Request) {
upstreamCalls.Add(1)
_, _ = w.Write(body)
}
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
srv := newCacheServer(t, sc)
c := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk3", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
req.Header.Set("Range", "bytes=100-200")
resp, err := c.Do(req)
if err != nil {
t.Fatalf("invalid range GET: %v", err)
}
data, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
t.Fatalf("read body: %v", err)
}
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf("invalid range GET: expected 416, got %d", resp.StatusCode)
}
if len(data) != 0 {
t.Errorf("invalid range GET: expected empty body, got %d bytes", len(data))
}
if got := resp.Header.Get("Content-Range"); got != "bytes */16" {
t.Errorf("invalid range GET: expected Content-Range bytes */16, got %q", got)
}
if got := upstreamCalls.Load(); got != 1 {
t.Errorf("upstream hit %d times, want exactly 1 (fetch happens, then 416 to client)", got)
}
stats := sc.GetMetrics()
if stats.RangeUpstream != 0 {
t.Errorf("expected RangeUpstream == 0 for unsatisfiable range, got %d", stats.RangeUpstream)
}
if stats.RangeCache != 0 {
t.Errorf("expected RangeCache == 0, got %d", stats.RangeCache)
}
}
// TestRangeMetricsWriteText verifies /metrics emits the range_cache and range_upstream
// lines with the expected values after a range HIT and a range MISS.
func TestRangeMetricsWriteText(t *testing.T) {
body := []byte("0123456789abcdef")
f := func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
srv := newCacheServer(t, sc)
c := &http.Client{Timeout: 5 * time.Second}
get := func(path, rangeHeader string) int {
t.Helper()
req, err := http.NewRequest("GET", srv.URL+path, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := c.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return resp.StatusCode
}
// Range MISS on cold key -> range_upstream; warm it; range HIT -> range_cache.
if code := get("/depot/rangetest/wt/1", "bytes=0-3"); code != http.StatusPartialContent {
t.Fatalf("range miss: expected 206, got %d", code)
}
if code := get("/depot/rangetest/wt/2", ""); code != http.StatusOK {
t.Fatalf("warm miss: expected 200, got %d", code)
}
if code := get("/depot/rangetest/wt/2", "bytes=8-11"); code != http.StatusPartialContent {
t.Fatalf("range hit: expected 206, got %d", code)
}
req, err := http.NewRequest("GET", srv.URL+"/metrics", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
resp, err := c.Do(req)
if err != nil {
t.Fatalf("GET /metrics: %v", err)
}
out, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
t.Fatalf("read /metrics: %v", err)
}
text := string(out)
if !strings.Contains(text, "range_cache 1\n") {
t.Errorf("/metrics missing 'range_cache 1' line:\n%s", text)
}
if !strings.Contains(text, "range_upstream 1\n") {
t.Errorf("/metrics missing 'range_upstream 1' line:\n%s", text)
}
}
// TestStreamCachedResponseRange206 is a focused unit test for streamCachedResponse:
// valid Range yields 206 with the exact slice + metrics; invalid Range yields 416
// with no range metrics.
func TestStreamCachedResponseRange206(t *testing.T) {
body := []byte("0123456789abcdef") // 16 bytes
raw := append([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n"), body...)
serialized, err := serializeRawResponse(raw)
if err != nil {
t.Fatalf("serialize cache file: %v", err)
}
cf, err := deserializeCacheFile(serialized)
if err != nil {
t.Fatalf("build cache file: %v", err)
}
sc, _ := newTestCacheWithFakeUpstream(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("x"))
}, "1MB", "0")
sc.ResetMetrics()
// Valid range -> 206 + slice + metrics.
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/depot/rangetest/chunk", nil)
req.Header.Set("Range", "bytes=4-7")
sc.streamCachedResponse(rec, req, cf, "steam/testkey", "127.0.0.1", time.Now())
if rec.Code != http.StatusPartialContent {
t.Fatalf("expected 206, got %d", rec.Code)
}
if rec.Body.String() != "4567" {
t.Errorf("expected body %q, got %q", "4567", rec.Body.String())
}
if got := rec.Header().Get("Content-Range"); got != "bytes 4-7/16" {
t.Errorf("expected Content-Range bytes 4-7/16, got %q", got)
}
if got := rec.Header().Get("X-LanCache-Status"); got != "HIT" {
t.Errorf("expected X-LanCache-Status HIT, got %q", got)
}
stats := sc.GetMetrics()
if stats.RangeCache != 1 {
t.Errorf("expected RangeCache == 1, got %d", stats.RangeCache)
}
if stats.TotalBytesServed != 4 {
t.Errorf("expected TotalBytesServed == 4 (range bytes), got %d", stats.TotalBytesServed)
}
if stats.TotalBytesSaved != 4 {
t.Errorf("expected TotalBytesSaved == 4 (range bytes), got %d", stats.TotalBytesSaved)
}
// Invalid range -> 416, no range metrics, no bytes served.
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest("GET", "/depot/rangetest/chunk", nil)
req2.Header.Set("Range", "bytes=100-200")
sc.streamCachedResponse(rec2, req2, cf, "steam/testkey", "127.0.0.1", time.Now())
if rec2.Code != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf("expected 416, got %d", rec2.Code)
}
if got := rec2.Header().Get("Content-Range"); got != "bytes */16" {
t.Errorf("expected Content-Range bytes */16, got %q", got)
}
stats = sc.GetMetrics()
if stats.RangeCache != 1 {
t.Errorf("RangeCache must stay 1 after unsatisfiable range, got %d", stats.RangeCache)
}
if stats.TotalBytesServed != 4 {
t.Errorf("TotalBytesServed must stay 4 after 416, got %d", stats.TotalBytesServed)
}
}
+13 -4
View File
@@ -200,23 +200,33 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
if disksize == 0 && memorysize != 0 {
// memory only mode - no disk
sc.metrics.SetDiskTierReady(1) // no disk — N/A / not pending
c.SetSlow(mgc)
} else if disksize != 0 && memorysize == 0 {
// disk only mode: delay attach until disk ready (pure-proxy during scan; Create returns ErrNotFound until slow tier Set)
sc.metrics.SetDiskTierReady(0)
logger.Logger.Info().Msg("Disk slow tier attach pending; Size barrier in progress")
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
t0 := time.Now()
_ = d.Size() // block on barrier per design (all Size callers during window do this; documented)
select {
case <-sc.shutdownCh:
return // Shutdown raced; do not attach or SetSlow after stop
default:
c.SetSlow(dgc)
sc.metrics.SetDiskTierReady(1)
logger.Logger.Info().
Dur("attach_delay", time.Since(t0)).
Msg("Disk slow tier attached (disk-only mode); prior traffic had no disk tier")
}
}()
} else if disksize != 0 && memorysize != 0 {
// memory and disk mode: fast mem immediate, disk delayed (mem-only during scan)
c.SetFast(mgc)
sc.metrics.SetDiskTierReady(0)
logger.Logger.Info().Msg("Disk slow tier attach pending; Size barrier in progress")
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
@@ -227,6 +237,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
return
default:
c.SetSlow(dgc)
sc.metrics.SetDiskTierReady(1)
logger.Logger.Info().
Dur("attach_delay", time.Since(t0)).
Msg("Disk slow tier attached (mixed mode); prior traffic was memory-only")
@@ -326,15 +337,13 @@ func (sc *SteamCache) Shutdown() {
// GetMetrics returns current metrics
func (sc *SteamCache) GetMetrics() *metrics.Stats {
// Update cache sizes
if sc.memory != nil {
sc.metrics.SetMemoryCacheSize(sc.memory.Size())
}
if sc.disk != nil {
// Note: blocks on initDone (post-eviction state) for accurate post-attach size during long disk init window.
// Skip disk.Size() while attach pending — Size() blocks on initDone and would hang /metrics.
if sc.disk != nil && sc.metrics.GetDiskTierReady() == 1 {
sc.metrics.SetDiskCacheSize(sc.disk.Size())
}
return sc.metrics.GetStats()
}
+226
View File
@@ -2,10 +2,12 @@
package steamcache
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -17,6 +19,7 @@ import (
"s1d3sw1ped/steamcache2/vfs/vfserror"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
@@ -1057,6 +1060,12 @@ func TestDiskOnlyDelayedAttach(t *testing.T) {
t.Errorf("during init window, expected ErrNotFound from disk-only tiered Create (no slow), got %v", err)
}
// Disk tier is pending while the attach goroutine is in the Size barrier.
// GetMetrics must return quickly (it skips disk.Size() while pending) and report 0.
if got := sc.GetMetrics().DiskTierReady; got != 0 {
t.Errorf("during pending attach, DiskTierReady=%d, want 0", got)
}
// Wait the barrier (exercises the attach go's Size wait)
_ = sc.disk.Size()
@@ -1083,6 +1092,91 @@ func TestDiskOnlyDelayedAttach(t *testing.T) {
} else {
rc.Close()
}
// After attach, the disk tier must be marked ready (1)
if got := sc.GetMetrics().DiskTierReady; got != 1 {
t.Errorf("post-attach DiskTierReady=%d, want 1 (ready)", got)
}
// /metrics text output includes the disk_tier_ready line
rec := httptest.NewRecorder()
metrics.WriteText(rec, sc.GetMetrics())
if !bytes.Contains(rec.Body.Bytes(), []byte("disk_tier_ready 1")) {
t.Errorf("WriteText output missing \"disk_tier_ready 1\": %q", rec.Body.String())
}
}
// TestDiskTierSignalMemoryOnly covers memory-only mode: DiskTierReady=1 (N/A, not
// waiting on disk attach) and heartbeat header X-SteamCache-Disk-Tier: disabled.
func TestDiskTierSignalMemoryOnly(t *testing.T) {
sc, err := New("127.0.0.1:0", "1MB", "0", t.TempDir(), "", "lru", "lru", 10, 5, "0", nil)
if err != nil {
t.Fatalf("New memory-only: %v", err)
}
t.Cleanup(func() { sc.Shutdown() })
if got := sc.GetMetrics().DiskTierReady; got != 1 {
t.Errorf("DiskTierReady=%d, want 1 (memory-only = N/A/not pending)", got)
}
req := httptest.NewRequest("GET", "/lancache-heartbeat", nil)
rec := httptest.NewRecorder()
sc.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("heartbeat status=%d, want 204", rec.Code)
}
if got := rec.Header().Get("X-SteamCache-Disk-Tier"); got != "disabled" {
t.Errorf("X-SteamCache-Disk-Tier=%q, want disabled", got)
}
if got := rec.Header().Get("X-LanCache-Processed-By"); got != "SteamCache2" {
t.Errorf("X-LanCache-Processed-By=%q, want SteamCache2", got)
}
}
// TestDiskTierSignalMixedPendingReady covers mixed mode: DiskTierReady=0 (header
// pending) while the disk attach is in the Size barrier, then DiskTierReady=1
// (header ready) after the barrier opens and the attach goroutine sets SetSlow.
func TestDiskTierSignalMixedPendingReady(t *testing.T) {
td := t.TempDir()
diskPath := filepath.Join(td, "disk")
if err := os.MkdirAll(diskPath, 0755); err != nil {
t.Fatal(err)
}
sc, err := New("127.0.0.1:0", "1MB", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil)
if err != nil {
t.Fatalf("New mixed: %v", err)
}
t.Cleanup(func() { sc.Shutdown() })
// Immediately in the pending window
if got := sc.GetMetrics().DiskTierReady; got != 0 {
t.Errorf("immediate DiskTierReady=%d, want 0 (pending)", got)
}
req := httptest.NewRequest("GET", "/lancache-heartbeat", nil)
rec := httptest.NewRecorder()
sc.ServeHTTP(rec, req)
if got := rec.Header().Get("X-SteamCache-Disk-Tier"); got != "pending" {
t.Errorf("heartbeat header=%q, want pending", got)
}
// Wait the barrier, then retry until the attach goroutine flips the flag
_ = sc.disk.Size()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if sc.GetMetrics().DiskTierReady == 1 {
break
}
time.Sleep(1 * time.Millisecond)
}
if got := sc.GetMetrics().DiskTierReady; got != 1 {
t.Fatalf("DiskTierReady=%d after barrier, want 1 (ready)", got)
}
rec2 := httptest.NewRecorder()
sc.ServeHTTP(rec2, httptest.NewRequest("GET", "/lancache-heartbeat", nil))
if got := rec2.Header().Get("X-SteamCache-Disk-Tier"); got != "ready" {
t.Errorf("heartbeat header=%q, want ready", got)
}
}
// --- Phase 2: narrow black-box tests for the new wrapper types ---
@@ -1223,3 +1317,135 @@ func TestDirectFetchRejectsNonSteamHost(t *testing.T) {
t.Errorf("non-CDN Host: expected 400, got %d", rec2.Code)
}
}
// TestCacheKeySharedAcrossCDNHostAliases verifies that one cache entry serves
// the same depot object across different Steam CDN host aliases: the key uses
// only the request path (never the Host header or an absolute-form target
// host), so hits climb instead of re-stamping upstream per host rotation.
func TestCacheKeySharedAcrossCDNHostAliases(t *testing.T) {
body := []byte("depot chunk body for host-alias keying")
var upstreamCalls atomic.Int64
f := func(w http.ResponseWriter, r *http.Request) {
upstreamCalls.Add(1)
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(body)
}
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
srv := newCacheServer(t, sc)
const depotPath = "/depot/1684171/chunk/abc123"
const ua = "Valve/Steam HTTP Client 1.0"
c := &http.Client{Timeout: 5 * time.Second}
// 1) MISS under the first CDN alias (origin-form target, Host: cdn1).
req1, err := http.NewRequest("GET", srv.URL+depotPath, nil)
if err != nil {
t.Fatal(err)
}
req1.Host = "cdn1.steamcontent.com"
req1.Header.Set("User-Agent", ua)
resp1, err := c.Do(req1)
if err != nil {
t.Fatalf("host-alias MISS request: %v", err)
}
data1, err := io.ReadAll(resp1.Body)
resp1.Body.Close()
if err != nil {
t.Fatal(err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("host-alias MISS: expected 200, got %d", resp1.StatusCode)
}
if got := resp1.Header.Get("X-LanCache-Status"); got != "MISS" {
t.Fatalf("host-alias MISS: expected X-LanCache-Status MISS, got %q", got)
}
if !bytes.Equal(data1, body) {
t.Fatalf("host-alias MISS: body mismatch: got %q", data1)
}
// Bounded wait for the entry to be visible before hitting the next alias
// (the MISS handler streams the body to the client before the VFS write).
key, err := generateServiceCacheKey(depotPath, "steam")
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for {
if rc, e := sc.vfs.Open(key); e == nil {
_ = rc.Close()
break
}
if time.Now().After(deadline) {
t.Fatalf("cache entry %q not visible after MISS", key)
}
time.Sleep(5 * time.Millisecond)
}
// 2) Same depot path under the second CDN alias (Host header only) -> HIT.
req2, err := http.NewRequest("GET", srv.URL+depotPath, nil)
if err != nil {
t.Fatal(err)
}
req2.Host = "cdn2.steamcontent.com"
req2.Header.Set("User-Agent", ua)
resp2, err := c.Do(req2)
if err != nil {
t.Fatalf("second host-alias request: %v", err)
}
data2, err := io.ReadAll(resp2.Body)
resp2.Body.Close()
if err != nil {
t.Fatal(err)
}
if resp2.StatusCode != http.StatusOK {
t.Fatalf("second host-alias: expected 200, got %d", resp2.StatusCode)
}
if got := resp2.Header.Get("X-LanCache-Status"); got != "HIT" {
t.Fatalf("second host-alias: expected X-LanCache-Status HIT, got %q", got)
}
if !bytes.Equal(data2, body) {
t.Fatalf("second host-alias: body mismatch: got %q", data2)
}
// 3) Same depot path with an absolute-form target embedding a third CDN
// hostname in the URL itself -> still a HIT on the same entry.
// (Go's http client always sends origin-form targets, so use raw HTTP.)
conn, err := net.Dial("tcp", srv.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
rawRequest := "GET http://cdn3.steamcontent.com" + depotPath + " HTTP/1.1\r\n" +
"Host: cdn3.steamcontent.com\r\n" +
"User-Agent: " + ua + "\r\n" +
"Connection: close\r\n\r\n"
if _, err := conn.Write([]byte(rawRequest)); err != nil {
t.Fatal(err)
}
rawReq, err := http.NewRequest("GET", "http://cdn3.steamcontent.com"+depotPath, nil)
if err != nil {
t.Fatal(err)
}
resp3, err := http.ReadResponse(bufio.NewReader(conn), rawReq)
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
data3, err := io.ReadAll(resp3.Body)
if err != nil {
t.Fatal(err)
}
if resp3.StatusCode != http.StatusOK {
t.Fatalf("absolute-form host-alias: expected 200, got %d", resp3.StatusCode)
}
if got := resp3.Header.Get("X-LanCache-Status"); got != "HIT" {
t.Fatalf("absolute-form host-alias: expected X-LanCache-Status HIT, got %q", got)
}
if !bytes.Equal(data3, body) {
t.Fatalf("absolute-form host-alias: body mismatch: got %q", data3)
}
// All three aliases must have shared one upstream fetch.
if got := upstreamCalls.Load(); got != 1 {
t.Errorf("upstream fetched %d times across host aliases, want 1", got)
}
}