// steamcache/steamcache.go package steamcache import ( "context" "fmt" "net" "net/http" "sync" "time" "github.com/docker/go-units" "golang.org/x/sync/semaphore" "s1d3sw1ped/steamcache2/steamcache/logger" "s1d3sw1ped/steamcache2/steamcache/metrics" "s1d3sw1ped/steamcache2/vfs" "s1d3sw1ped/steamcache2/vfs/cache" "s1d3sw1ped/steamcache2/vfs/disk" "s1d3sw1ped/steamcache2/vfs/gc" "s1d3sw1ped/steamcache2/vfs/memory" ) type SteamCache struct { address string upstream string vfs vfs.VFS memory *memory.MemoryFS disk *disk.DiskFS memorygc *gc.AsyncGCFS diskgc *gc.AsyncGCFS server *http.Server client *http.Client cancel context.CancelFunc wg sync.WaitGroup // Shutdown safety (Once hardening per existing patterns) shutdownOnce sync.Once // shutdownCh closed during Shutdown() to allow delayed disk-attach goroutines (the Size barrier waits) to skip late SetSlow and avoid post-shutdown side effects. Also enables wg tracking for hygiene (reaps on wg.Wait). shutdownCh chan struct{} // Request coalescing (owned by coalescer wrapper; see coalescing.go) coalescer *coalescer // Concurrency control maxConcurrentRequests int64 requestSemaphore *semaphore.Weighted // Per-client rate limiting (owned by clientRateLimiter wrapper; see ratelimit.go) clientRateLimiter *clientRateLimiter maxRequestsPerClient int64 // Per-client uplink bandwidth shaping (see bandwidth.go); nil/disabled = unlimited bandwidth *clientBandwidthLimiter // Hardening config fields (plumbed) maxObjectSize int64 trustedProxies []string // Negative TTL for 404/410 depot objects stored in the same VFS cache. // Zero disables storing negatives (client still receives the upstream status). negativeTTL time.Duration // Service management serviceManager *ServiceManager // Metrics metrics *metrics.Metrics // processor (Phase 3 foundation) + 4 narrow interfaces + ctor injection in New. // Bulk logic preserved on SteamCache (intentionally; see handler.go deferral block). // SteamCache remains thin coordinator + lifecycle owner. Enables future fakes. processor *requestProcessor } // DefaultNegativeTTL is used when cache.negative_ttl / Options.NegativeTTL is empty. const DefaultNegativeTTL = 5 * time.Minute // New creates a new SteamCache instance. // Returns an error (instead of panicking) on invalid memorySize or diskSize strings. // Also validates maxObjectSize and accepts trustedProxies for X-Forwarded-For handling. // Empty maxObjectSize or nil trustedProxies are normalized to safe defaults before parsing. // negativeTTL is a Go duration string for 404/410 negative cache entries; empty means 5m. // Callers must check the returned error. // Prefer NewWithOptions (or config file) for forward compatibility. See README migration notes. func New(address string, memorySize string, diskSize string, diskPath, upstream, memoryGC, diskGC string, maxConcurrentRequests int64, maxRequestsPerClient int64, maxObjectSize string, trustedProxies []string, negativeTTL string, uplinkBandwidth string, maxBytesPerClientPerSec int64) (*SteamCache, error) { memorysize, err := units.FromHumanSize(memorySize) if err != nil { return nil, fmt.Errorf("invalid memory size: %w", err) } disksize, err := units.FromHumanSize(diskSize) if err != nil { return nil, fmt.Errorf("invalid disk size: %w", err) } // Apply safe defaults before parsing user-provided sizes (handles zero-value Options) if maxObjectSize == "" { maxObjectSize = "0" } if trustedProxies == nil { trustedProxies = []string{} } maxObjBytes, err := units.FromHumanSize(maxObjectSize) if err != nil { return nil, fmt.Errorf("invalid max object size: %w", err) } negTTL, err := parseNegativeTTL(negativeTTL) if err != nil { return nil, err } var uplinkBytes int64 if uplinkBandwidth != "" && uplinkBandwidth != "0" { uplinkBytes, err = units.FromHumanSize(uplinkBandwidth) if err != nil { return nil, fmt.Errorf("invalid uplink bandwidth: %w", err) } } if maxBytesPerClientPerSec < 0 { return nil, fmt.Errorf("negative max_bytes_per_client_per_sec not allowed") } c := cache.New() var m *memory.MemoryFS var mgc *gc.AsyncGCFS if memorysize > 0 { var err error m, err = memory.New(memorysize) if err != nil { return nil, err } memoryGCAlgo := gc.GCAlgorithm(memoryGC) if memoryGCAlgo == "" { memoryGCAlgo = gc.LRU // default to LRU } // Use hybrid async GC with thresholds: 80% async, 95% sync, 100% hard limit mgc = gc.NewAsync(m, memoryGCAlgo, true, 0.8, 0.95, 1.0) } var d *disk.DiskFS var dgc *gc.AsyncGCFS // for disk cases, created inside delayed attach goroutine (just before SetSlow) so preemptive ticker does not run during proxy/init window (delayed attach goroutine hygiene for shutdown races during disk Size() barrier) if disksize > 0 { diskGCAlgo := gc.GCAlgorithm(diskGC) if diskGCAlgo == "" { diskGCAlgo = gc.LRU // default to LRU } evictFn := gc.GetGCAlgorithm(diskGCAlgo) var err error d, err = disk.New(diskPath, disksize, evictFn) if err != nil { return nil, err } // Use hybrid async GC with thresholds: 80% async, 95% sync, 100% hard limit dgc = gc.NewAsync(d, diskGCAlgo, true, 0.8, 0.95, 1.0) } // NOTE: cache tier attach (SetFast/SetSlow + delayed disk attach gos) moved *after* sc construction (see below). // This enables wg tracking on sc.wg + shutdownCh signaling for attach goroutines (goroutine hygiene + robust Shutdown during pending init). // (Early return for invalid no-mem/no-disk still needed before heavy setup.) if disksize == 0 && memorysize == 0 { return nil, fmt.Errorf("no memory or disk cache configured") } transport := newHTTPTransport() client := newHTTPClient(transport) server := newHTTPServer(address) sc := &SteamCache{ upstream: upstream, address: address, vfs: c, memory: m, disk: d, memorygc: mgc, diskgc: dgc, client: client, server: server, // Initialize concurrency control fields (wrappers own maps + internal stop chans for cleanup goroutine) coalescer: newCoalescer(), maxConcurrentRequests: maxConcurrentRequests, requestSemaphore: semaphore.NewWeighted(maxConcurrentRequests), clientRateLimiter: newClientRateLimiter(maxRequestsPerClient), maxRequestsPerClient: maxRequestsPerClient, bandwidth: newClientBandwidthLimiter(uplinkBytes, maxBytesPerClientPerSec), shutdownCh: make(chan struct{}), // Hardening config plumbed maxObjectSize: maxObjBytes, trustedProxies: trustedProxies, negativeTTL: negTTL, // Initialize service management serviceManager: NewServiceManager(), // Initialize metrics metrics: metrics.NewMetrics(), } // Wire metrics into TieredCache so promotions are counted. c.SetMetrics(sc.metrics) // Wire metrics into the concrete storage tiers (MemoryFS / DiskFS) so per-tier hits and evictions are counted. if m != nil { m.SetMetrics(sc.metrics) } if d != nil { d.SetMetrics(sc.metrics) } // Wire the request processor (constructor injection of interfaces for the owned wrappers + vfs + client + scalars). // Done after all fields including wrappers are set; before attach goroutines (no impact on lifecycle paths). sc.processor = newRequestProcessor(sc) // configure the cache to match the specified mode (memory only, disk only, or memory and disk) based on the provided sizes. // Disk tier SetSlow delayed until after disk init+eviction (evict func passed at disk.New for race-free guard; via Size barrier); mem immediate. // Attach goroutines now tracked on sc.wg (Add before go + Done), guarded by shutdownCh (skip SetSlow if Shutdown racing) for goroutine hygiene. (GC wrapper preemption addressed via analysis + wontfix on literal defer-creation.) 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() t0 := time.Now() _ = d.Size() select { case <-sc.shutdownCh: 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") } }() } // Log GC algorithm configuration if m != nil { logger.Logger.Info().Str("memory_gc", memoryGC).Msg("Memory cache GC algorithm configured") } if d != nil { logger.Logger.Info().Str("disk_gc", diskGC).Msg("Disk cache GC algorithm configured") } return sc, nil } func (sc *SteamCache) Run() error { if sc.upstream != "" { resp, err := sc.client.Get(sc.upstream) if err != nil { if resp != nil { _ = resp.Body.Close() // best-effort body close on connectivity error path; primary err returned } logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed upstream connectivity check") return err } if resp.StatusCode != http.StatusOK { _ = resp.Body.Close() // best-effort body close on non-OK status; primary error returned logger.Logger.Error().Int("status_code", resp.StatusCode).Str("upstream", sc.upstream).Msg("Upstream connectivity check returned non-OK status") return fmt.Errorf("upstream returned status %d", resp.StatusCode) } _ = resp.Body.Close() // best-effort; success path close before ListenAndServe } sc.server.Handler = sc ctx, cancel := context.WithCancel(context.Background()) sc.cancel = cancel // Start cleanup goroutine for old client limiters sc.wg.Add(1) go func() { defer sc.wg.Done() sc.cleanupOldClientLimiters() }() sc.wg.Add(1) go func() { defer sc.wg.Done() err := sc.server.ListenAndServe() if err != nil && err != http.ErrServerClosed { logger.Logger.Error().Err(err).Msg("Failed to start SteamCache2") return // goroutine cannot return err to caller; logged; shutdown path handles } }() <-ctx.Done() _ = sc.server.Shutdown(ctx) // shutdown error secondary (ListenAndServe already terminated or context done); logged elsewhere if fatal sc.wg.Wait() return nil } func (sc *SteamCache) Shutdown() { if sc == nil { return } sc.shutdownOnce.Do(func() { if sc.cancel != nil { sc.cancel() } // Stop all background managers started in New() (critical for test hygiene + no leaks when using httptest direct ServeHTTP paths that never call Run()). if sc.memorygc != nil { sc.memorygc.Stop() } if sc.diskgc != nil { sc.diskgc.Stop() } // Signal cleanup goroutine to exit so wg.Wait below does not hang indefinitely. // (stop chan owned by clientRateLimiter wrapper; signaling delegated for full lifecycle encapsulation) if sc.clientRateLimiter != nil { sc.clientRateLimiter.stop() } // Signal delayed attach goroutines (attach goroutine shutdown safety) to skip SetSlow if still waiting Size() or just finished. if sc.shutdownCh != nil { select { case <-sc.shutdownCh: default: close(sc.shutdownCh) } } sc.wg.Wait() // Brief reap window after stopping workers (helps goroutine delta checks see low counts quickly). time.Sleep(10 * time.Millisecond) }) } // GetMetrics returns current metrics func (sc *SteamCache) GetMetrics() *metrics.Stats { if sc.memory != nil { sc.metrics.SetMemoryCacheSize(sc.memory.Size()) sc.metrics.SetMemoryCacheCapacity(sc.memory.Capacity()) } if sc.disk != nil { // Capacity() is a plain config field — safe to read even while disk attach is pending. sc.metrics.SetDiskCacheCapacity(sc.disk.Capacity()) } // 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() } // ResetMetrics resets all metrics to zero func (sc *SteamCache) ResetMetrics() { sc.metrics.Reset() } // parseNegativeTTL parses a Go duration string for 404/410 negative cache entries. // Empty means DefaultNegativeTTL. Zero disables storing negatives. func parseNegativeTTL(s string) (time.Duration, error) { if s == "" { return DefaultNegativeTTL, nil } d, err := time.ParseDuration(s) if err != nil { return 0, fmt.Errorf("invalid negative ttl: %w", err) } if d < 0 { return 0, fmt.Errorf("invalid negative ttl: negative duration") } return d, nil } func isDefinitiveGone(statusCode int) bool { return statusCode == http.StatusNotFound || statusCode == http.StatusGone } // newHTTPTransport returns a tuned http.Transport for upstream fetches. // Extracted to shrink New (Phase 3). func newHTTPTransport() *http.Transport { return &http.Transport{ // Connection pooling optimizations MaxIdleConns: 500, // Increased for high concurrency MaxIdleConnsPerHost: 100, // Increased for better connection reuse MaxConnsPerHost: 200, // Limit connections per host IdleConnTimeout: 300 * time.Second, // Longer idle timeout for better reuse // Dial optimizations DialContext: (&net.Dialer{ Timeout: 10 * time.Second, // Faster connection timeout KeepAlive: 60 * time.Second, // Longer keep-alive // Dual-stack Happy Eyeballs is the default since Go 1.12 (DualStack is deprecated). }).DialContext, // Timeout optimizations TLSHandshakeTimeout: 5 * time.Second, // Faster TLS handshake ResponseHeaderTimeout: 15 * time.Second, // Faster header timeout ExpectContinueTimeout: 1 * time.Second, // Faster expect-continue // Performance optimizations DisableCompression: true, // Steam doesn't use compression ForceAttemptHTTP2: true, // Enable HTTP/2 if available DisableKeepAlives: false, // Enable keep-alives // Buffer optimizations WriteBufferSize: 64 * 1024, // 64KB write buffer ReadBufferSize: 64 * 1024, // 64KB read buffer // Connection reuse optimizations MaxResponseHeaderBytes: 1 << 20, // 1MB max header size } } // newHTTPClient returns a tuned http.Client wrapping the provided transport. // Extracted to shrink New (Phase 3). func newHTTPClient(transport *http.Transport) *http.Client { return &http.Client{ Transport: transport, Timeout: 60 * time.Second, // Optimized timeout for better responsiveness // Add redirect policy for better performance CheckRedirect: func(req *http.Request, via []*http.Request) error { // Do not follow redirects. Steam CDN chunk/manifest fetches are // expected to be 200; following Location would let an origin send // the cache at an arbitrary internal URL. return http.ErrUseLastResponse }, } } // newHTTPServer returns a tuned http.Server for the given address. // Extracted to shrink New (Phase 3). func newHTTPServer(address string) *http.Server { return &http.Server{ Addr: address, ReadTimeout: 15 * time.Second, // Optimized for faster response WriteTimeout: 30 * time.Second, // Optimized for faster response IdleTimeout: 300 * time.Second, // Longer idle timeout for better connection reuse ReadHeaderTimeout: 5 * time.Second, // Faster header timeout MaxHeaderBytes: 1 << 20, // 1MB max header size // Connection optimizations will be handled in ServeHTTP method } }