Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300package tui
import ( "albedo/cli/internal/config" "albedo/cli/internal/daemon" "encoding/json" "fmt" tea "github.com/charmbracelet/bubbletea" "os" "os/exec" "path/filepath" "runtime" "runtime/debug" "runtime/pprof" "strconv" "strings" "testing" "time")
const profileDir = "/tmp/albedo-memory-profile"
type ProcessOSMemory struct { PID int `json:"pid"` RSSBytes uint64 `json:"rss_bytes"` RSSFormatted string `json:"rss_formatted"` MaxRSSBytes uint64 `json:"max_rss_bytes"` VMMapFootprintMB float64 `json:"vmmap_footprint_mb"` VMMapRawSummary string `json:"vmmap_raw_summary,omitempty"`}
func readOSMemory() ProcessOSMemory { pid := os.Getpid() var rusage runtime.MemStats runtime.ReadMemStats(&rusage)
mem := ProcessOSMemory{ PID: pid, }
// Read RSS via ps out, err := exec.Command("ps", "-o", "rss=", "-p", strconv.Itoa(pid)).Output() if err == nil { rssKb, err := strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64) if err == nil { mem.RSSBytes = rssKb * 1024 mem.RSSFormatted = fmt.Sprintf("%.2f MB", float64(mem.RSSBytes)/(1024*1024)) } }
// Read macOS vmmap summary if available vmOut, err := exec.Command("vmmap", "-summary", strconv.Itoa(pid)).Output() if err == nil { lines := strings.Split(string(vmOut), "\n") for _, line := range lines { if strings.HasPrefix(line, "Physical footprint:") { parts := strings.Fields(line) if len(parts) >= 3 { valStr := parts[2] if strings.HasSuffix(valStr, "K") { if f, err := strconv.ParseFloat(strings.TrimSuffix(valStr, "K"), 64); err == nil { mem.VMMapFootprintMB = f / 1024.0 } } else if strings.HasSuffix(valStr, "M") { if f, err := strconv.ParseFloat(strings.TrimSuffix(valStr, "M"), 64); err == nil { mem.VMMapFootprintMB = f } } else if strings.HasSuffix(valStr, "G") { if f, err := strconv.ParseFloat(strings.TrimSuffix(valStr, "G"), 64); err == nil { mem.VMMapFootprintMB = f * 1024.0 } } } break } } }
return mem}
type PhaseMetrics struct { PhaseName string `json:"phase_name"` PhaseDescription string `json:"phase_description"` Timestamp string `json:"timestamp"` OSMemory ProcessOSMemory `json:"os_memory"` HeapAllocBytes uint64 `json:"heap_alloc_bytes"` HeapInuseBytes uint64 `json:"heap_inuse_bytes"` HeapIdleBytes uint64 `json:"heap_idle_bytes"` HeapReleasedBytes uint64 `json:"heap_released_bytes"` StackInuseBytes uint64 `json:"stack_inuse_bytes"` MSpanInuseBytes uint64 `json:"mspan_inuse_bytes"` MCacheInuseBytes uint64 `json:"mcache_inuse_bytes"` OtherSysBytes uint64 `json:"other_sys_bytes"` SysBytes uint64 `json:"sys_bytes"` NumGC uint32 `json:"num_gc"` HeapProfilePath string `json:"heap_profile_path"` ModelRetainedEntries int `json:"model_retained_entries,omitempty"` ModelRetainedBytes int64 `json:"model_retained_bytes,omitempty"` ModelDroppedLines int `json:"model_dropped_lines,omitempty"`}
func capturePhaseMetrics(phaseName, description, pprofFilename string) (PhaseMetrics, error) { _ = os.MkdirAll(profileDir, 0755) pprofPath := filepath.Join(profileDir, pprofFilename)
// CRITICAL: Capture runtime.MemStats and OS memory BEFORE writing the heap profile. // Writing a gzip-compressed heap profile allocates runtime buffers and formatting // memory which would contaminate HeapAlloc and HeapInuse if measured after. var m runtime.MemStats runtime.ReadMemStats(&m) osMem := readOSMemory()
// Write heap profile snapshot for call-site attribution f, err := os.Create(pprofPath) if err != nil { return PhaseMetrics{}, fmt.Errorf("failed to create pprof file: %w", err) } defer f.Close()
if err := pprof.WriteHeapProfile(f); err != nil { return PhaseMetrics{}, fmt.Errorf("failed to write heap profile: %w", err) }
return PhaseMetrics{ PhaseName: phaseName, PhaseDescription: description, Timestamp: time.Now().UTC().Format(time.RFC3339), OSMemory: osMem, HeapAllocBytes: m.HeapAlloc, HeapInuseBytes: m.HeapInuse, HeapIdleBytes: m.HeapIdle, HeapReleasedBytes: m.HeapReleased, StackInuseBytes: m.StackInuse, MSpanInuseBytes: m.MSpanInuse, MCacheInuseBytes: m.MCacheInuse, OtherSysBytes: m.OtherSys, SysBytes: m.Sys, NumGC: m.NumGC, HeapProfilePath: pprofPath, }, nil}
// TestMemoryProfile_FreshPicker captures Phase 1: Fresh App Picker Idle.func TestMemoryProfile_FreshPicker(t *testing.T) { if os.Getenv("ALBEDO_MEM_PROFILE") != "1" { t.Skip("skipping memory profiling; set ALBEDO_MEM_PROFILE=1 to run") }
// Fresh AppModel with no active session => Session Picker state app := NewAppModel(nil, config.Profiles{}, nil, "/workspace/fresh", false) newApp, _ := app.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) app = newApp.(AppModel) _ = app.View()
metrics, err := capturePhaseMetrics( "fresh_picker_idle", "Fresh AppModel in Session Picker state before loading any chat session", "fresh_picker_heap.pb.gz", ) if err != nil { t.Fatalf("failed to capture fresh picker metrics: %v", err) }
// Retain app alive strictly AFTER capture runtime.KeepAlive(&app)
data, _ := json.MarshalIndent(metrics, "", " ") _ = os.WriteFile(filepath.Join(profileDir, "fresh_picker.json"), data, 0644) t.Logf("=== PHASE 1: FRESH PICKER IDLE ===") t.Logf(" RSS: %s (vmmap footprint: %.1f MB)", metrics.OSMemory.RSSFormatted, metrics.OSMemory.VMMapFootprintMB) t.Logf(" HeapAlloc: %d B (%.2f MB), HeapInuse: %d B (%.2f MB), Sys: %d B (%.2f MB)", metrics.HeapAllocBytes, float64(metrics.HeapAllocBytes)/(1024*1024), metrics.HeapInuseBytes, float64(metrics.HeapInuseBytes)/(1024*1024), metrics.SysBytes, float64(metrics.SysBytes)/(1024*1024))}
// TestMemoryProfile_PostStreamStress captures Phase 2 (Natural Idle) and Phase 3 (Diagnostic Forced-GC Idle)// under an uninterrupted 300KB+ SSE stress transcript.func TestMemoryProfile_PostStreamStress(t *testing.T) { if os.Getenv("ALBEDO_MEM_PROFILE") != "1" { t.Skip("skipping memory profiling; set ALBEDO_MEM_PROFILE=1 to run") }
session := &daemon.Session{ID: "sess-mem-stress", Workspace: "/workspace/stress"} app := NewAppModel(nil, config.Profiles{}, session, "/workspace/stress", false) newApp, _ := app.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) app = newApp.(AppModel)
// Stream 1,200 chunks of ~270 bytes each = ~325 KB (matching canonical SSE stress flow) chunkTemplate := "assistant token chunk with code snippet `let mut ring = RingBuffer::new(4096);` and multiline buffer text for stress testing memory retention, history truncation bounds, and viewport rendering performance invariants across large scrollback buffers\n" totalChunks := 1200 totalStreamedBytes := 0
var fullStreamBuilder strings.Builder for i := 0; i < totalChunks; i++ { chunkText := fmt.Sprintf("[%04d] %s", i, chunkTemplate) totalStreamedBytes += len(chunkText) fullStreamBuilder.WriteString(chunkText)
msg := ChatStreamEventMsg{ SessionID: app.Chat.SessionID, Generation: app.Chat.Generation, Event: daemon.StreamEvent{ Type: daemon.EventText, Text: chunkText, }, } newApp, _ := app.Update(msg) app = newApp.(AppModel) }
// Settle the stream with canonical EventMessage containing the full original stream content canonicalFullText := fullStreamBuilder.String() settleMsg := ChatStreamEventMsg{ SessionID: app.Chat.SessionID, Generation: app.Chat.Generation, Event: daemon.StreamEvent{ Type: daemon.EventMessage, Text: canonicalFullText, }, } newApp, _ = app.Update(settleMsg) app = newApp.(AppModel)
// Explicitly release fixture memory references before taking snapshots canonicalFullText = "" fullStreamBuilder.Reset()
// Populate rendered viewport cache _ = app.View()
// Verify transcript invariants if app.Chat.activeKind != StreamKindNone { t.Errorf("expected activeKind to be None after settle, got %v", app.Chat.activeKind) } if len(app.Chat.pendingUsers) != 0 { t.Errorf("expected pendingUsers to be empty, got %d", len(app.Chat.pendingUsers)) }
// Let runtime settle naturally time.Sleep(50 * time.Millisecond)
// Phase 2: Natural Post-Stream Idle (NO forced GC; represents real runtime idle with floating garbage) naturalMetrics, err := capturePhaseMetrics( "post_stream_natural_idle", "Post-stream settled state under natural Go GC (canonical 325KB stream settled, rendered cache populated, pending zero)", "post_stream_natural_heap.pb.gz", ) if err != nil { t.Fatalf("failed to capture natural post-stream metrics: %v", err) } naturalMetrics.ModelRetainedEntries = app.Chat.History.Len() naturalMetrics.ModelRetainedBytes = app.Chat.History.TotalBytes() naturalMetrics.ModelDroppedLines = app.Chat.droppedSettledLines
// Phase 3: Diagnostic Forced-GC + Explicit FreeOSMemory Idle // Explicitly forces full GC sweep and OS madvise return to distinguish live objects from allocator spans. runtime.GC() debug.FreeOSMemory() time.Sleep(20 * time.Millisecond)
diagnosticMetrics, err := capturePhaseMetrics( "post_stream_diagnostic_gc_idle", "Post-stream settled state after diagnostic runtime.GC() + debug.FreeOSMemory() to isolate true live retained objects", "post_stream_diagnostic_gc_heap.pb.gz", ) if err != nil { t.Fatalf("failed to capture diagnostic GC metrics: %v", err) } diagnosticMetrics.ModelRetainedEntries = app.Chat.History.Len() diagnosticMetrics.ModelRetainedBytes = app.Chat.History.TotalBytes() diagnosticMetrics.ModelDroppedLines = app.Chat.droppedSettledLines
// Retain app alive strictly AFTER all captures runtime.KeepAlive(&app)
dataNat, _ := json.MarshalIndent(naturalMetrics, "", " ") _ = os.WriteFile(filepath.Join(profileDir, "post_stream_natural.json"), dataNat, 0644)
dataDiag, _ := json.MarshalIndent(diagnosticMetrics, "", " ") _ = os.WriteFile(filepath.Join(profileDir, "post_stream_diagnostic.json"), dataDiag, 0644)
t.Logf("=== PHASE 2: POST-STREAM NATURAL IDLE ===") t.Logf(" Streamed: %d bytes across %d chunks", totalStreamedBytes, totalChunks) t.Logf(" Model: %d entries (%d B), %d dropped lines", naturalMetrics.ModelRetainedEntries, naturalMetrics.ModelRetainedBytes, naturalMetrics.ModelDroppedLines) t.Logf(" RSS: %s (vmmap footprint: %.1f MB)", naturalMetrics.OSMemory.RSSFormatted, naturalMetrics.OSMemory.VMMapFootprintMB) t.Logf(" Allocated Heap (pre-GC): %d B (%.2f MB), HeapInuse: %d B (%.2f MB), Sys: %d B (%.2f MB)", naturalMetrics.HeapAllocBytes, float64(naturalMetrics.HeapAllocBytes)/(1024*1024), naturalMetrics.HeapInuseBytes, float64(naturalMetrics.HeapInuseBytes)/(1024*1024), naturalMetrics.SysBytes, float64(naturalMetrics.SysBytes)/(1024*1024))
t.Logf("=== PHASE 3: POST-STREAM DIAGNOSTIC GC IDLE ===") t.Logf(" RSS: %s (vmmap footprint: %.1f MB)", diagnosticMetrics.OSMemory.RSSFormatted, diagnosticMetrics.OSMemory.VMMapFootprintMB) t.Logf(" Live Retained Heap (post-GC): %d B (%.2f MB), HeapInuse: %d B (%.2f MB), HeapReleased: %d B (%.2f MB)", diagnosticMetrics.HeapAllocBytes, float64(diagnosticMetrics.HeapAllocBytes)/(1024*1024), diagnosticMetrics.HeapInuseBytes, float64(diagnosticMetrics.HeapInuseBytes)/(1024*1024), diagnosticMetrics.HeapReleasedBytes, float64(diagnosticMetrics.HeapReleasedBytes)/(1024*1024))}