1
0
forked from wrenn/wrenn
Files
wrenn-releases/envd/internal/port/subsystem.go
pptx704 3deecbff89 fix: prevent Go runtime memory corruption and sandbox halt after snapshot restore
Three root causes addressed:

1. Go page allocator corruption: allocations between the pre-snapshot GC
   and VM freeze leave the summary tree inconsistent. After restore, GC
   reads corrupted metadata — either panicking (killing PID 1 → kernel
   panic) or silently failing to collect, causing unbounded heap growth
   until OOM. Fix: move GC to after all HTTP allocations in
   PostSnapshotPrepare, then set GOMAXPROCS(1) so any remaining
   allocations run sequentially with no concurrent page allocator access.
   GOMAXPROCS is restored on first health check after restore.

2. PostInit timeout starvation: WaitUntilReady and PostInit shared a
   single 30s context. If WaitUntilReady consumed most of it, PostInit
   failed — RestoreAfterSnapshot never ran, leaving envd with keep-alives
   disabled and zombie connections. Fix: separate timeout contexts.

3. CP HTTP server missing timeouts: no ReadHeaderTimeout or IdleTimeout
   caused goroutine leaks from hung proxy connections. Fix: add both,
   matching host agent values.

Also adds UFFD prefetch to proactively load all guest pages after restore,
eliminating on-demand page fault latency for subsequent RPC calls.
2026-05-02 17:22:51 +06:00

102 lines
2.4 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Modifications by M/S Omukk
package port
import (
"context"
"sync"
"time"
"github.com/rs/zerolog"
"git.omukk.dev/wrenn/sandbox/envd/internal/services/cgroups"
)
// PortSubsystem owns the port scanner and forwarder lifecycle.
// It supports stop/restart across Firecracker snapshot/restore cycles.
type PortSubsystem struct {
logger *zerolog.Logger
cgroupManager cgroups.Manager
period time.Duration
mu sync.Mutex
cancel context.CancelFunc
wg *sync.WaitGroup // per-cycle WaitGroup; nil when not running
running bool
}
// NewPortSubsystem creates a new PortSubsystem. Call Start() to begin scanning.
func NewPortSubsystem(logger *zerolog.Logger, cgroupManager cgroups.Manager, period time.Duration) *PortSubsystem {
return &PortSubsystem{
logger: logger,
cgroupManager: cgroupManager,
period: period,
}
}
// Start creates a fresh scanner and forwarder, launching their goroutines.
// Safe to call multiple times; does nothing if already running.
func (p *PortSubsystem) Start(parentCtx context.Context) {
p.mu.Lock()
defer p.mu.Unlock()
if p.running {
return
}
ctx, cancel := context.WithCancel(parentCtx)
p.cancel = cancel
p.running = true
// Allocate a fresh WaitGroup for this lifecycle so a concurrent Stop
// on the previous cycle's WaitGroup cannot interfere.
wg := &sync.WaitGroup{}
p.wg = wg
scanner := NewScanner(p.period)
forwarder := NewForwarder(p.logger, scanner, p.cgroupManager)
wg.Add(2)
go func() {
defer wg.Done()
forwarder.StartForwarding(ctx)
}()
go func() {
defer wg.Done()
scanner.ScanAndBroadcast(ctx)
}()
}
// Stop quiesces the scanner and forwarder goroutines.
// Blocks until both goroutines have exited. Safe to call if already stopped.
//
// GC is NOT run here — it is deferred to PostSnapshotPrepare so that the
// GC happens after all allocations (connection cleanup, HTTP response) are
// complete, minimizing the window where page allocator corruption can occur.
func (p *PortSubsystem) Stop() {
p.mu.Lock()
if !p.running {
p.mu.Unlock()
return
}
cancelFn := p.cancel
wg := p.wg
p.cancel = nil
p.wg = nil
p.running = false
p.mu.Unlock()
cancelFn()
wg.Wait()
}
// Restart stops the subsystem (if running) and starts it again with a fresh
// scanner and forwarder. Used after snapshot restore via PostInit.
func (p *PortSubsystem) Restart(parentCtx context.Context) {
p.Stop()
p.Start(parentCtx)
}