11package main
22
33import (
4+ "bytes"
45 "encoding/json"
6+ "io"
57 "net/url"
68 "os"
79 "path/filepath"
@@ -13,6 +15,22 @@ import (
1315
1416var diagnosticThrottle sync.Map
1517
18+ const (
19+ diagnosticLogMaxBytes = 32 << 20
20+ diagnosticLogReadChunk = 64 << 10
21+ diagnosticLogMaxTailBytes = 4 << 20
22+ diagnosticLogMaxReadLines = 5000
23+ )
24+
25+ var diagnosticLogWriteMu sync.Mutex
26+ var diagnosticLogStates = map [string ]* diagnosticLogState {}
27+
28+ type diagnosticLogState struct {
29+ size int64
30+ writes uint64
31+ initialized bool
32+ }
33+
1634func appendDiagnosticLog (event string , detail map [string ]any ) {
1735 if detail == nil {
1836 detail = map [string ]any {}
@@ -35,12 +53,60 @@ func appendDiagnosticLog(event string, detail map[string]any) {
3553 if err := os .MkdirAll (filepath .Dir (path ), 0o755 ); err != nil {
3654 return
3755 }
56+
57+ diagnosticLogWriteMu .Lock ()
58+ defer diagnosticLogWriteMu .Unlock ()
59+ state := diagnosticLogStates [path ]
60+ if state == nil {
61+ state = & diagnosticLogState {}
62+ diagnosticLogStates [path ] = state
63+ }
64+ if ! state .initialized {
65+ if info , statErr := os .Stat (path ); statErr == nil {
66+ state .size = info .Size ()
67+ }
68+ state .initialized = true
69+ }
70+ if state .size + int64 (len (data ))+ 1 > diagnosticLogMaxBytes {
71+ state .size = rotateDiagnosticLog (path , state .size )
72+ }
3873 file , err := os .OpenFile (path , os .O_CREATE | os .O_WRONLY | os .O_APPEND , 0o644 )
3974 if err != nil {
4075 return
4176 }
4277 defer file .Close ()
43- _ , _ = file .Write (append (data , '\n' ))
78+ n , _ := file .Write (append (data , '\n' ))
79+ state .size += int64 (n )
80+ state .writes ++
81+ // The manager and launcher are separate processes and share this file.
82+ // Re-check occasionally so one process notices writes made by the other.
83+ if state .writes % 64 == 0 {
84+ if info , statErr := file .Stat (); statErr == nil {
85+ state .size = info .Size ()
86+ }
87+ }
88+ }
89+
90+ func rotateDiagnosticLog (path string , currentSize int64 ) int64 {
91+ // Do not preserve legacy runaway logs: keeping a multi-gigabyte backup
92+ // would defeat rotation and continue consuming the user's disk.
93+ if currentSize > 2 * diagnosticLogMaxBytes {
94+ if err := os .Truncate (path , 0 ); err == nil {
95+ return 0
96+ }
97+ }
98+ backup := path + ".1"
99+ _ = os .Remove (backup )
100+ if err := os .Rename (path , backup ); err == nil || os .IsNotExist (err ) {
101+ return 0
102+ }
103+ if err := os .Truncate (path , 0 ); err == nil {
104+ return 0
105+ }
106+ if info , err := os .Stat (path ); err == nil {
107+ return info .Size ()
108+ }
109+ return 0
44110}
45111
46112func shouldThrottleDiagnosticLog (event string , detail map [string ]any , now time.Time ) bool {
@@ -182,6 +248,29 @@ func diagnosticSettingsValue(settings backendSettings) any {
182248 }
183249}
184250
251+ func runtimeDiagnosticsValue () map [string ]any {
252+ var memory runtime.MemStats
253+ runtime .ReadMemStats (& memory )
254+ logBytes := int64 (0 )
255+ if info , err := os .Stat (diagnosticLogPath ()); err == nil {
256+ logBytes = info .Size ()
257+ }
258+ return map [string ]any {
259+ "status" : "ok" ,
260+ "version" : version ,
261+ "heapAllocBytes" : memory .HeapAlloc ,
262+ "heapInuseBytes" : memory .HeapInuse ,
263+ "heapSysBytes" : memory .HeapSys ,
264+ "stackInuseBytes" : memory .StackInuse ,
265+ "sysBytes" : memory .Sys ,
266+ "nextGCBytes" : memory .NextGC ,
267+ "numGC" : memory .NumGC ,
268+ "goroutines" : runtime .NumGoroutine (),
269+ "diagnosticLogBytes" : logBytes ,
270+ "diagnosticLogMaxBytes" : diagnosticLogMaxBytes ,
271+ }
272+ }
273+
185274func diagnosticAppReference (path string ) string {
186275 trimmed := strings .TrimSpace (path )
187276 if trimmed == "" {
@@ -369,12 +458,58 @@ func (s *server) readLatestLogs(args map[string]any) commandResult {
369458}
370459
371460func tailFile (path string , maxLines int ) (string , error ) {
372- data , err := os .ReadFile (path )
461+ if maxLines <= 0 || maxLines > diagnosticLogMaxReadLines {
462+ maxLines = diagnosticLogMaxReadLines
463+ }
464+ file , err := os .Open (path )
373465 if err != nil {
374466 return "" , err
375467 }
468+ defer file .Close ()
469+ info , err := file .Stat ()
470+ if err != nil {
471+ return "" , err
472+ }
473+ if info .Size () == 0 {
474+ return "" , nil
475+ }
476+
477+ // Read backwards in bounded chunks. Diagnostics can be large, and the UI
478+ // only needs the most recent lines; loading the entire file caused avoidable
479+ // multi-hundred-megabyte allocations when the log had grown unchecked.
480+ var chunks [][]byte
481+ readBytes := 0
482+ newlines := 0
483+ offset := info .Size ()
484+ for offset > 0 && readBytes < diagnosticLogMaxTailBytes && newlines <= maxLines {
485+ start := offset - diagnosticLogReadChunk
486+ if start < 0 {
487+ start = 0
488+ }
489+ if int64 (readBytes )+ (offset - start ) > diagnosticLogMaxTailBytes {
490+ start = offset - int64 (diagnosticLogMaxTailBytes - readBytes )
491+ }
492+ chunk := make ([]byte , offset - start )
493+ if _ , readErr := file .ReadAt (chunk , start ); readErr != nil && readErr != io .EOF {
494+ return "" , readErr
495+ }
496+ chunks = append (chunks , chunk )
497+ readBytes += len (chunk )
498+ newlines += bytes .Count (chunk , []byte {'\n' })
499+ offset = start
500+ }
501+ data := make ([]byte , readBytes )
502+ cursor := 0
503+ for index := len (chunks ) - 1 ; index >= 0 ; index -- {
504+ cursor += copy (data [cursor :], chunks [index ])
505+ }
506+ if offset > 0 {
507+ if newline := bytes .IndexByte (data , '\n' ); newline >= 0 {
508+ data = data [newline + 1 :]
509+ }
510+ }
376511 lines := strings .Split (strings .ReplaceAll (string (data ), "\r \n " , "\n " ), "\n " )
377- if maxLines > 0 && len (lines ) > maxLines {
512+ if len (lines ) > maxLines {
378513 lines = lines [len (lines )- maxLines :]
379514 }
380515 return strings .Join (lines , "\n " ), nil
0 commit comments