pdh: collect synchronously under mutex (#2559)

Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-08 23:30:35 +02:00
committed by GitHub
parent ad1db73b26
commit 210e98bdde
3 changed files with 119 additions and 37 deletions
+9 -34
View File
@@ -59,8 +59,7 @@ type Collector[T any] struct {
// left out of the sample, so a missing value is never reported as zero.
partialRows bool
collectCh chan *[]T
errorCh chan error
state *collectState
}
// Row holds the values of one instance collected by a collector from [NewDynamicCollector].
@@ -326,10 +325,7 @@ func newCollector[T any](logger *slog.Logger, resultType CounterType, object str
return collector, fmt.Errorf("failed to initialize collector: %w", err)
}
collector.collectCh = make(chan *[]T)
collector.errorCh = make(chan error)
go collector.collectWorker()
collector.state = &collectState{}
// Collect initial data because some counters need to be read twice to get the correct value.
var collectValues []T
@@ -400,27 +396,17 @@ func (c *Collector[T]) Collect(dst *[]T) error {
return errors.New("dst must not be nil")
}
c.mu.RLock()
defer c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.counters) == 0 || c.handle == 0 || c.collectCh == nil || c.errorCh == nil {
if len(c.counters) == 0 || c.handle == 0 || c.state == nil {
return ErrPerformanceCounterNotInitialized
}
c.collectCh <- dst
return <-c.errorCh
return c.collect(dst, c.state)
}
func (c *Collector[T]) collectWorker() {
var state collectState
for dst := range c.collectCh {
c.errorCh <- c.collect(dst, &state)
}
}
// collectState holds the buffers that the collect worker reuses between samples.
// collectState holds the buffers that the collector reuses between samples.
type collectState struct {
// buf starts empty so that the first call only queries the required size with a nil buffer.
// PdhGetRawCounterArrayW writes 8 bytes into a non-nil buffer, even if lpdwBufferSize is smaller,
@@ -690,8 +676,7 @@ func (r *rowSet[T]) finish() {
}
}
// panicError logs a panic recovered in the collect worker and returns it as an
// error. The worker goroutine has no other recovery, so a panic would end the process.
// panicError logs a panic recovered while collecting and returns it as an error.
func (c *Collector[T]) panicError(r any) error {
c.logger.Error("recovered from panic while collecting performance counters",
slog.String("object", c.object),
@@ -715,17 +700,7 @@ func (c *Collector[T]) Close() {
}
c.handle = 0
if c.collectCh != nil {
close(c.collectCh)
}
if c.errorCh != nil {
close(c.errorCh)
}
c.collectCh = nil
c.errorCh = nil
c.state = nil
}
func formatCounterPath(object, instance, counterName string) string {
+2 -3
View File
@@ -155,8 +155,7 @@ type processThreads struct {
ThreadCount float64 `perfdata:"Thread Count"`
}
// TestCollectRecoversPanic checks that a panic in the worker goroutine is
// returned as an error instead of terminating the process.
// TestCollectRecoversPanic checks that a panic while collecting is returned as an error.
func TestCollectRecoversPanic(t *testing.T) {
t.Parallel()
@@ -177,7 +176,7 @@ func TestCollectRecoversPanic(t *testing.T) {
err = c.Collect(&dst)
require.ErrorContains(t, err, "panic while collecting performance counters of Process")
// The worker survives and serves the next request.
// Collection still works after the panic.
c.mu.Lock()
c.counters[0].FieldIndexValue = 1
c.mu.Unlock()
+108
View File
@@ -19,6 +19,7 @@ package pdh_test
import (
"log/slog"
"sync"
"testing"
"time"
@@ -73,6 +74,113 @@ func TestCollector(t *testing.T) {
}
}
func TestCollectorConcurrentCollect(t *testing.T) {
t.Parallel()
for _, counterType := range []pdh.CounterType{pdh.CounterTypeRaw, pdh.CounterTypeFormatted} {
t.Run(string(counterType), func(t *testing.T) {
t.Parallel()
collector, err := pdh.NewCollector[process](slog.New(slog.DiscardHandler), counterType, "Process", pdh.InstancesAll)
require.NoError(t, err)
t.Cleanup(collector.Close)
const callers = 8
data := make([][]process, callers)
errs := make([]error, callers)
start := make(chan struct{})
var wg sync.WaitGroup
for i := range callers {
wg.Go(func() {
<-start
for range 4 {
if errs[i] = collector.Collect(&data[i]); errs[i] != nil {
return
}
}
})
}
close(start)
wg.Wait()
for i := range callers {
require.NoError(t, errs[i])
require.NotEmpty(t, data[i])
}
})
}
}
func TestCollectorConcurrentClose(t *testing.T) {
t.Parallel()
collector, err := pdh.NewCollector[process](slog.New(slog.DiscardHandler), pdh.CounterTypeRaw, "Process", pdh.InstancesAll)
require.NoError(t, err)
t.Cleanup(collector.Close)
const callers = 8
errs := make([]error, callers)
start := make(chan struct{})
var wg sync.WaitGroup
for i := range callers {
wg.Go(func() {
<-start
var data []process
errs[i] = collector.Collect(&data)
})
}
wg.Go(func() {
<-start
collector.Close()
})
close(start)
wg.Wait()
for _, err := range errs {
if err != nil {
require.ErrorIs(t, err, pdh.ErrPerformanceCounterNotInitialized)
}
}
var data []process
require.ErrorIs(t, collector.Collect(&data), pdh.ErrPerformanceCounterNotInitialized)
collector.Close()
}
func TestCollectorInitializationError(t *testing.T) {
t.Parallel()
type invalidProcess struct {
ThreadCount float64 `perfdata:"Thread Count"`
HandleCount string `perfdata:"Handle Count"`
}
collector, err := pdh.NewCollector[invalidProcess](slog.New(slog.DiscardHandler), pdh.CounterTypeRaw, "Process", pdh.InstancesAll)
require.ErrorContains(t, err, "field HandleCount must be a float64")
require.NotNil(t, collector)
t.Cleanup(collector.Close)
var data []invalidProcess
require.ErrorIs(t, collector.Collect(&data), pdh.ErrPerformanceCounterNotInitialized)
}
type processorInformation struct {
Name string
ProcessorTime float64 `perfdata:"% Processor Time"`