Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions server/handlers/api_debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"runtime"
"runtime/debug"
"sync"
"time"

"github.com/shirou/gopsutil/v4/process"
Expand All @@ -15,6 +16,11 @@ type Debug struct {
version string
started time.Time
proc *process.Process
// mu guards proc: Percent tracks the previous CPU-time reading on the
// *process.Process itself with no internal locking, so concurrent calls
// (e.g. multiple browser tabs each polling /api/debug on a timer) race
// on that state and can report a corrupted, wildly inflated cpu_percent.
mu sync.Mutex
}

func NewDebug(version string, started time.Time) *Debug {
Expand All @@ -35,12 +41,14 @@ func (h *Debug) Get(w http.ResponseWriter, _ *http.Request) {
cpuPct := 0.0
var rss uint64
if h.proc != nil {
h.mu.Lock()
if v, err := h.proc.Percent(0); err == nil {
cpuPct = v
}
if mi, err := h.proc.MemoryInfo(); err == nil && mi != nil {
rss = mi.RSS
}
h.mu.Unlock()
}

lastGC := ""
Expand Down
52 changes: 52 additions & 0 deletions server/handlers/api_debug_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package handlers

import (
"encoding/json"
"net/http/httptest"
"sync"
"testing"
"time"
)

// TestDebugConcurrentAccess guards against a data race in gopsutil's
// Process.Percent: it tracks the previous CPU-time reading on the *Process
// itself with no internal locking, so unsynchronized concurrent calls (e.g.
// several browser tabs each polling /api/debug on its 2s timer) race on that
// state. Run with `go test -race` to make this test meaningful — without
// the mutex in Debug, the race detector flags the read/write in
// gopsutil's PercentWithContext; a plain (non-race) run can't detect it,
// since a torn read doesn't reliably crash or produce a value outside any
// fixed bound (rapid back-to-back calls to Percent(0) legitimately produce
// large, noisy percentages on their own, since the measurement window
// between consecutive calls shrinks — that's expected behavior of an
// instantaneous-rate measurement, not corruption).
func TestDebugConcurrentAccess(t *testing.T) {
dbg := NewDebug("test", time.Now())

var wg sync.WaitGroup
for range 20 {
wg.Go(func() {
for range 20 {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/debug", nil)
dbg.Get(w, r)

var body struct {
Data struct {
Process struct {
CPUPercent float64 `json:"cpu_percent"`
} `json:"process"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Errorf("invalid JSON response: %v", err)
return
}
if body.Data.Process.CPUPercent < 0 {
t.Errorf("negative cpu_percent: %v", body.Data.Process.CPUPercent)
}
}
})
}
wg.Wait()
}