173 lines
4.2 KiB
Go
173 lines
4.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
mainThreadHeartbeatInterval = time.Second
|
|
mainThreadHangThreshold = 12 * time.Second
|
|
mainThreadHangCheckInterval = 2 * time.Second
|
|
mainThreadSleepSkip = 30 * time.Second
|
|
)
|
|
|
|
var (
|
|
mainThreadClockBase = time.Now()
|
|
mainThreadLastHeartbeatElapsed atomic.Int64
|
|
mainThreadLastHeartbeatWall atomic.Int64
|
|
mainThreadHangReported atomic.Bool
|
|
)
|
|
|
|
func recordMainThreadHeartbeat(t time.Time) {
|
|
elapsed := t.Sub(mainThreadClockBase)
|
|
if elapsed < 0 {
|
|
elapsed = 0
|
|
}
|
|
mainThreadLastHeartbeatElapsed.Store(int64(elapsed))
|
|
mainThreadLastHeartbeatWall.Store(t.UnixNano())
|
|
}
|
|
|
|
func mainThreadHeartbeatAge(now time.Time) (time.Duration, time.Time, bool) {
|
|
lastElapsed := time.Duration(mainThreadLastHeartbeatElapsed.Load())
|
|
lastWall := mainThreadLastHeartbeatWall.Load()
|
|
if lastWall <= 0 {
|
|
return 0, time.Time{}, false
|
|
}
|
|
age := now.Sub(mainThreadClockBase) - lastElapsed
|
|
if age < 0 {
|
|
age = 0
|
|
}
|
|
return age, time.Unix(0, lastWall), true
|
|
}
|
|
|
|
func (a *App) startMainThreadWatchdog() {
|
|
if !mainThreadWatchdogSupported() {
|
|
return
|
|
}
|
|
a.hangWatchdogMu.Lock()
|
|
if a.hangWatchdogCancel != nil {
|
|
a.hangWatchdogMu.Unlock()
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
a.hangWatchdogCancel = cancel
|
|
mainThreadHangReported.Store(false)
|
|
recordMainThreadHeartbeat(time.Now())
|
|
startNativeMainThreadHeartbeat(uint64(mainThreadHeartbeatInterval / time.Millisecond))
|
|
a.hangWatchdogMu.Unlock()
|
|
|
|
a.goSafe("mainThreadHangWatchdog", func() {
|
|
a.watchMainThreadHeartbeat(ctx)
|
|
})
|
|
}
|
|
|
|
func (a *App) stopMainThreadWatchdog() {
|
|
if !mainThreadWatchdogSupported() {
|
|
return
|
|
}
|
|
a.hangWatchdogMu.Lock()
|
|
cancel := a.hangWatchdogCancel
|
|
a.hangWatchdogCancel = nil
|
|
a.hangWatchdogMu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
stopNativeMainThreadHeartbeat()
|
|
}
|
|
|
|
func (a *App) watchMainThreadHeartbeat(ctx context.Context) {
|
|
ticker := time.NewTicker(mainThreadHangCheckInterval)
|
|
defer ticker.Stop()
|
|
lastCheck := time.Now()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case now := <-ticker.C:
|
|
if now.Sub(lastCheck) > mainThreadSleepSkip {
|
|
lastCheck = now
|
|
continue
|
|
}
|
|
lastCheck = now
|
|
age, last, ok := mainThreadHeartbeatAge(now)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if age < mainThreadHangThreshold {
|
|
continue
|
|
}
|
|
if mainThreadHangReported.CompareAndSwap(false, true) {
|
|
a.recordMainThreadHang(age, last, now)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) recordMainThreadHang(age time.Duration, lastHeartbeat, observedAt time.Time) {
|
|
report := mainThreadHangReport(age, lastHeartbeat, observedAt)
|
|
wrote := writePendingReport(report, false)
|
|
if m := a.metrics.Load(); m != nil {
|
|
m.inc("desktop_hang", "main_thread")
|
|
m.inc("desktop_hang_age", hangAgeBucket(age))
|
|
m.persist()
|
|
}
|
|
slog.Warn("desktop: mac main thread heartbeat stalled",
|
|
"age", age.Round(time.Millisecond).String(),
|
|
"lastHeartbeat", lastHeartbeat.Format(time.RFC3339),
|
|
"pendingReport", wrote,
|
|
)
|
|
}
|
|
|
|
func mainThreadHangReport(age time.Duration, lastHeartbeat, observedAt time.Time) crashReport {
|
|
age = age.Round(time.Second)
|
|
message := fmt.Sprintf(`[mac.main_thread.hang]
|
|
|
|
Reasonix detected that the macOS main-thread heartbeat stopped for %s.
|
|
|
|
--- watchdog context ---
|
|
last heartbeat: %s
|
|
observed at: %s
|
|
threshold: %s
|
|
bucket: %s
|
|
|
|
--- native runtime context ---
|
|
%s`,
|
|
age,
|
|
lastHeartbeat.UTC().Format(time.RFC3339),
|
|
observedAt.UTC().Format(time.RFC3339),
|
|
mainThreadHangThreshold,
|
|
hangAgeBucket(age),
|
|
nativeResourceContext(),
|
|
)
|
|
report := baseCrashReport("performance")
|
|
report.SchemaVersion = 2
|
|
report.Source = "native.watchdog"
|
|
report.Label = "mac.main_thread.hang"
|
|
report.ErrorType = "MacMainThreadHang"
|
|
report.ErrorMessage = sanitizeCrashText("macOS main thread heartbeat stopped; AppKit/Wails run loop may be blocked.", maxCrashFieldBytes)
|
|
report.TopFrame = "mac.main_thread.heartbeat"
|
|
report.OccurredAt = observedAt.UTC().Format(time.RFC3339)
|
|
report.Message = sanitizeCrashText(message, maxCrashDetailBytes)
|
|
return report
|
|
}
|
|
|
|
func hangAgeBucket(age time.Duration) string {
|
|
seconds := age.Seconds()
|
|
switch {
|
|
case seconds < 15:
|
|
return "s_10_15"
|
|
case seconds < 30:
|
|
return "s_15_30"
|
|
case seconds < 60:
|
|
return "s_30_60"
|
|
case seconds < 300:
|
|
return "m_1_5"
|
|
default:
|
|
return "m_5_plus"
|
|
}
|
|
}
|