mscluster: reuse control buffers and cut per-property allocations (#2673)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-11 13:52:55 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a8adb6d7bc
commit 930609b0e5
11 changed files with 541 additions and 74 deletions
+6
View File
@@ -32,6 +32,12 @@
the documentation miss this. The parser validates only the structure and the documentation miss this. The parser validates only the structure and
accepts zero padding. `TestDumpPropertyLists` logs real lists as hex when accepts zero padding. `TestDumpPropertyLists` logs real lists as hex when
`WINDOWS_EXPORTER_TEST_CLUSAPI_DUMP` is set on a cluster node. `WINDOWS_EXPORTER_TEST_CLUSAPI_DUMP` is set on a cluster node.
- Each `Cluster` owns one control buffer (`controlScratch`). Like the handle,
it belongs to the running call, so calls never share it and no `sync.Pool`
is needed. `ParseProperties` returns `Property.Data` slices into it: copy
values out (`copyResourceProperties`, `readCommonProperties`) before the
next control, and never keep a parsed list past that point. `get` zeroes
the bytes, so a reused buffer reads like a new one.
- `clusapi.Open` reports an unconfigured cluster or a missing ClusAPI with - `clusapi.Open` reports an unconfigured cluster or a missing ClusAPI with
`errors.ErrUnsupported`; the shared collector test treats that as an `errors.ErrUnsupported`; the shared collector test treats that as an
unsupported role on hosts where `mscluster` is not a required fixture. unsupported role on hosts where `mscluster` is not a required fixture.
+109 -36
View File
@@ -38,6 +38,7 @@ const (
resourceGetType = 0x0100002d resourceGetType = 0x0100002d
resourceGetClassInfo = 0x0100000d resourceGetClassInfo = 0x0100000d
maxBufferSize = 64 << 20 maxBufferSize = 64 << 20
maxScratchSize = 64 << 10
stateUnknown = ^uint32(0) // ClusterResourceStateUnknown stateUnknown = ^uint32(0) // ClusterResourceStateUnknown
propertyListBufferSize = 4 << 10 propertyListBufferSize = 4 << 10
typeBufferSize = 512 typeBufferSize = 512
@@ -74,7 +75,7 @@ type nativeAPI struct {
enumName func(enum uintptr, index, enumType uint32, deadline time.Time) (objectName, error) enumName func(enum uintptr, index, enumType uint32, deadline time.Time) (objectName, error)
openResource func(cluster uintptr, name objectName) (uintptr, error) openResource func(cluster uintptr, name objectName) (uintptr, error)
closeResource func(resource uintptr) error closeResource func(resource uintptr) error
readResource func(resource uintptr, out *Resource, deadline time.Time) error readResource func(resource uintptr, out *Resource, scratch *controlScratch, deadline time.Time) error
} }
//nolint:gochecknoglobals //nolint:gochecknoglobals
@@ -158,6 +159,46 @@ type Cluster struct {
handle uintptr handle uintptr
closed bool closed bool
running bool running bool
// scratch belongs to the running call, like handle.
scratch controlScratch
}
// controlScratch is the reusable control buffer of a Cluster. Its owner runs
// one control at a time, so no pool is needed. Values parsed from a result,
// such as Property.Data, point into the buffer and are only valid until the
// next control; callers copy them out first.
type controlScratch struct {
buffer []byte
}
// get returns n zeroed bytes, the same contents as a new buffer.
func (s *controlScratch) get(n int) []byte {
if cap(s.buffer) < n {
s.buffer = make([]byte, max(n, propertyListBufferSize))
}
buffer := s.buffer[:n]
clear(buffer)
return buffer
}
// keep holds on to a buffer a control had to grow, up to maxScratchSize, so
// the next object starts with the larger size.
func (s *controlScratch) keep(data []byte) {
if cap(data) > cap(s.buffer) && cap(data) <= maxScratchSize {
s.buffer = data[:cap(data)]
}
}
// resourceControl calls ClusterResourceControl starting with n bytes of the
// scratch buffer. The result is only valid until the next control.
func (s *controlScratch) resourceControl(handle uintptr, code uint32, n int, deadline time.Time) ([]byte, error) {
data, err := resourceBufferIn(handle, code, s.get(n), deadline)
s.keep(data)
return data, err
} }
type Resource struct { type Resource struct {
@@ -391,7 +432,7 @@ func (c *Cluster) Resources(deadline time.Time) (_ []Resource, resultErr error)
return fmt.Errorf("OpenClusterResourceEx: %w", err) return fmt.Errorf("OpenClusterResourceEx: %w", err)
} }
err = api.readResource(handle, &resource, deadline) err = api.readResource(handle, &resource, &c.scratch, deadline)
if closeErr := api.closeResource(handle); closeErr != nil { if closeErr := api.closeResource(handle); closeErr != nil {
err = errors.Join(err, fmt.Errorf("CloseClusterResource: %w", closeErr)) err = errors.Join(err, fmt.Errorf("CloseClusterResource: %w", closeErr))
} }
@@ -459,13 +500,13 @@ func enumName(enum uintptr, index, enumType uint32, deadline time.Time) (objectN
// readResource fills the properties of an open resource. Failed properties are // readResource fills the properties of an open resource. Failed properties are
// omitted; the identity is only valid once the state and owner are known. // omitted; the identity is only valid once the state and owner are known.
func readResource(handle uintptr, resource *Resource, deadline time.Time) error { func readResource(handle uintptr, resource *Resource, scratch *controlScratch, deadline time.Time) error {
var resultErr error var resultErr error
hasType := false hasType := false
for _, code := range []uint32{resourceGetROCommonProperties, resourceGetCommonProperties} { for _, code := range []uint32{resourceGetROCommonProperties, resourceGetCommonProperties} {
data, err := resourceBuffer(handle, code, propertyListBufferSize, deadline) data, err := scratch.resourceControl(handle, code, propertyListBufferSize, deadline)
if err != nil { if err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("ClusterResourceControl %#x: %w", code, err)) resultErr = errors.Join(resultErr, fmt.Errorf("ClusterResourceControl %#x: %w", code, err))
@@ -479,23 +520,8 @@ func readResource(handle uintptr, resource *Resource, deadline time.Time) error
continue continue
} }
for name, values := range properties { if copyResourceProperties(resource, properties) {
if len(values) != 1 { hasType = true
continue
}
if value, err := values[0].DWORD(); err == nil {
resource.Values[name] = value
}
}
// The read-only common Type property is the same resource type name
// that CLUSCTL_RESOURCE_GET_RESOURCE_TYPE returns.
if values := properties["Type"]; len(values) == 1 {
if value, err := values[0].String(); err == nil {
resource.Type = value
hasType = true
}
} }
} }
@@ -503,7 +529,7 @@ func readResource(handle uintptr, resource *Resource, deadline time.Time) error
name string name string
code uint32 code uint32
}{{"Characteristics", resourceGetCharacteristics}, {"Flags", resourceGetFlags}} { }{{"Characteristics", resourceGetCharacteristics}, {"Flags", resourceGetFlags}} {
data, err := resourceBuffer(handle, field.code, 4, deadline) data, err := scratch.resourceControl(handle, field.code, 4, deadline)
switch { switch {
case err != nil: case err != nil:
resultErr = errors.Join(resultErr, fmt.Errorf("%s: %w", field.name, err)) resultErr = errors.Join(resultErr, fmt.Errorf("%s: %w", field.name, err))
@@ -514,7 +540,7 @@ func readResource(handle uintptr, resource *Resource, deadline time.Time) error
} }
} }
data, err := resourceBuffer(handle, resourceGetClassInfo, 8, deadline) data, err := scratch.resourceControl(handle, resourceGetClassInfo, 8, deadline)
if err != nil { if err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("class information: %w", err)) resultErr = errors.Join(resultErr, fmt.Errorf("class information: %w", err))
} else { } else {
@@ -523,7 +549,7 @@ func readResource(handle uintptr, resource *Resource, deadline time.Time) error
// The property lists normally carry the type; only ask separately without it. // The property lists normally carry the type; only ask separately without it.
if !hasType { if !hasType {
data, err = resourceBuffer(handle, resourceGetType, typeBufferSize, deadline) data, err = scratch.resourceControl(handle, resourceGetType, typeBufferSize, deadline)
if err != nil { if err != nil {
return errors.Join(resultErr, fmt.Errorf("resource type: %w", err)) return errors.Join(resultErr, fmt.Errorf("resource type: %w", err))
} }
@@ -544,6 +570,33 @@ func readResource(handle uintptr, resource *Resource, deadline time.Time) error
return resultErr return resultErr
} }
// copyResourceProperties copies the single DWORD values and the type name out
// of a parsed property list, so the list's buffer can be reused. It reports
// whether the list had the type.
func copyResourceProperties(resource *Resource, properties map[string][]Property) bool {
for name, values := range properties {
if len(values) != 1 {
continue
}
if value, ok := values[0].dword(); ok {
resource.Values[name] = value
}
}
// The read-only common Type property is the same resource type name that
// CLUSCTL_RESOURCE_GET_RESOURCE_TYPE returns.
if values := properties["Type"]; len(values) == 1 {
if value, err := values[0].String(); err == nil {
resource.Type = value
return true
}
}
return false
}
// parseResourceClassInfo decodes CLUS_RESOURCE_CLASS_INFO. Some resource DLLs, // parseResourceClassInfo decodes CLUS_RESOURCE_CLASS_INFO. Some resource DLLs,
// such as the Storage Spaces Direct Health Service, succeed but return only // such as the Storage Spaces Direct Health Service, succeed but return only
// the class DWORD without the subclass; the WMI provider reports their // the class DWORD without the subclass; the WMI provider reports their
@@ -563,8 +616,10 @@ func parseResourceClassInfo(data []byte) (uint32, uint32) {
return class, subclass return class, subclass
} }
func resourceBuffer(handle uintptr, code uint32, size int, deadline time.Time) ([]byte, error) { // resourceBufferIn calls ClusterResourceControl starting with buffer. The
return controlBuffer(deadline, size, func(buffer []byte) (uint32, error) { // result may share buffer's memory.
func resourceBufferIn(handle uintptr, code uint32, buffer []byte, deadline time.Time) ([]byte, error) {
return controlBufferIn(deadline, buffer, func(buffer []byte) (uint32, error) {
var ( var (
size uint32 size uint32
pointer *byte pointer *byte
@@ -597,7 +652,8 @@ func readResourceState(handle uintptr, deadline time.Time) (uint32, string, stri
} }
func stateBuffers(deadline time.Time, call func([]uint16, []uint16) (uint32, uint32, uint32, error)) (uint32, string, string, error) { func stateBuffers(deadline time.Time, call func([]uint16, []uint16) (uint32, uint32, uint32, error)) (uint32, string, string, error) {
node, group := make([]uint16, 256), make([]uint16, 256) units := make([]uint16, 512)
node, group := units[:256:256], units[256:]
for range 16 { for range 16 {
if err := checkDeadline(deadline); err != nil { if err := checkDeadline(deadline); err != nil {
@@ -655,15 +711,20 @@ func checkDeadline(deadline time.Time) error {
return nil return nil
} }
// controlBuffer starts with a buffer of the expected size, so most controls need // newControlBuffer returns a buffer of the expected size, so most controls
// one RPC. ERROR_MORE_DATA still grows the buffer to the reported size; size 0 // need one RPC; size 0 starts with a NULL size probe.
// starts with a NULL size probe. func newControlBuffer(size int) []byte {
func controlBuffer(deadline time.Time, size int, call func([]byte) (uint32, error)) ([]byte, error) { if size <= 0 {
var buffer []byte return nil
if size > 0 {
buffer = make([]byte, size)
} }
return make([]byte, size)
}
// controlBufferIn calls a control starting with buffer; an empty buffer is a
// NULL size probe. ERROR_MORE_DATA grows the buffer to the reported size,
// within buffer's capacity when it fits. The result may share buffer's memory.
func controlBufferIn(deadline time.Time, buffer []byte, call func([]byte) (uint32, error)) ([]byte, error) {
for range 16 { for range 16 {
if err := checkDeadline(deadline); err != nil { if err := checkDeadline(deadline); err != nil {
return nil, err return nil, err
@@ -675,7 +736,7 @@ func controlBuffer(deadline time.Time, size int, call func([]byte) (uint32, erro
return nil, errors.New("invalid ClusterResourceControl buffer size") return nil, errors.New("invalid ClusterResourceControl buffer size")
} }
buffer = make([]byte, size) buffer = growBuffer(buffer, int(size))
continue continue
} }
@@ -689,7 +750,7 @@ func controlBuffer(deadline time.Time, size int, call func([]byte) (uint32, erro
return nil, errors.New("invalid ClusterResourceControl buffer size") return nil, errors.New("invalid ClusterResourceControl buffer size")
} }
buffer = make([]byte, size) buffer = growBuffer(buffer, int(size))
continue continue
} }
@@ -703,3 +764,15 @@ func controlBuffer(deadline time.Time, size int, call func([]byte) (uint32, erro
return nil, errors.New("ClusterResourceControl buffer did not stabilize") return nil, errors.New("ClusterResourceControl buffer did not stabilize")
} }
// growBuffer returns size zeroed bytes, reusing buffer's capacity when it fits.
func growBuffer(buffer []byte, size int) []byte {
if size > cap(buffer) {
return make([]byte, size)
}
buffer = buffer[:size]
clear(buffer)
return buffer
}
+8 -8
View File
@@ -29,7 +29,7 @@ import (
func TestControlBuffer(t *testing.T) { func TestControlBuffer(t *testing.T) {
calls := 0 calls := 0
data, err := controlBuffer(time.Time{}, 0, func(buffer []byte) (uint32, error) { data, err := controlBufferIn(time.Time{}, newControlBuffer(0), func(buffer []byte) (uint32, error) {
calls++ calls++
switch calls { switch calls {
case 1: case 1:
@@ -62,7 +62,7 @@ func TestControlBuffer(t *testing.T) {
func TestControlBufferPresized(t *testing.T) { func TestControlBufferPresized(t *testing.T) {
calls := 0 calls := 0
data, err := controlBuffer(time.Time{}, 8, func(buffer []byte) (uint32, error) { data, err := controlBufferIn(time.Time{}, newControlBuffer(8), func(buffer []byte) (uint32, error) {
calls++ calls++
if len(buffer) != 8 { if len(buffer) != 8 {
@@ -79,7 +79,7 @@ func TestControlBufferPresized(t *testing.T) {
calls = 0 calls = 0
data, err = controlBuffer(time.Time{}, 4, func(buffer []byte) (uint32, error) { data, err = controlBufferIn(time.Time{}, newControlBuffer(4), func(buffer []byte) (uint32, error) {
calls++ calls++
if calls == 1 { if calls == 1 {
return 6, windows.ERROR_MORE_DATA return 6, windows.ERROR_MORE_DATA
@@ -96,7 +96,7 @@ func TestControlBufferPresized(t *testing.T) {
} }
// A pre-sized buffer that is already large enough must not be shrunk or retried. // A pre-sized buffer that is already large enough must not be shrunk or retried.
_, err = controlBuffer(time.Time{}, 8, func([]byte) (uint32, error) { return 4, windows.ERROR_MORE_DATA }) _, err = controlBufferIn(time.Time{}, newControlBuffer(8), func([]byte) (uint32, error) { return 4, windows.ERROR_MORE_DATA })
if err == nil { if err == nil {
t.Fatal("accepted ERROR_MORE_DATA without growth") t.Fatal("accepted ERROR_MORE_DATA without growth")
} }
@@ -114,7 +114,7 @@ func TestControlBufferInvalidSizes(t *testing.T) {
{"unrelated_error", 0, windows.ERROR_ACCESS_DENIED}, {"unrelated_error", 0, windows.ERROR_ACCESS_DENIED},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
_, err := controlBuffer(time.Time{}, 0, func([]byte) (uint32, error) { return tc.size, tc.err }) _, err := controlBufferIn(time.Time{}, newControlBuffer(0), func([]byte) (uint32, error) { return tc.size, tc.err })
if err == nil { if err == nil {
t.Fatal("accepted invalid native response") t.Fatal("accepted invalid native response")
} }
@@ -130,7 +130,7 @@ func TestControlBufferDeadline(t *testing.T) {
calls := 0 calls := 0
deadline := time.Now().Add(time.Millisecond) deadline := time.Now().Add(time.Millisecond)
_, err := controlBuffer(deadline, 0, func([]byte) (uint32, error) { _, err := controlBufferIn(deadline, newControlBuffer(0), func([]byte) (uint32, error) {
calls++ calls++
time.Sleep(2 * time.Millisecond) time.Sleep(2 * time.Millisecond)
@@ -141,7 +141,7 @@ func TestControlBufferDeadline(t *testing.T) {
t.Fatalf("calls = %d, err = %v", calls, err) t.Fatalf("calls = %d, err = %v", calls, err)
} }
_, err = controlBuffer(time.Now().Add(-time.Second), 0, func([]byte) (uint32, error) { _, err = controlBufferIn(time.Now().Add(-time.Second), newControlBuffer(0), func([]byte) (uint32, error) {
t.Fatal("started native call after deadline") t.Fatal("started native call after deadline")
return 0, nil return 0, nil
@@ -236,7 +236,7 @@ func TestStateBuffersInvalidNativeResponses(t *testing.T) {
func TestControlBufferSuccessfulSizeProbe(t *testing.T) { func TestControlBufferSuccessfulSizeProbe(t *testing.T) {
calls := 0 calls := 0
data, err := controlBuffer(time.Time{}, 0, func(buffer []byte) (uint32, error) { data, err := controlBufferIn(time.Time{}, newControlBuffer(0), func(buffer []byte) (uint32, error) {
calls++ calls++
if calls == 1 { if calls == 1 {
return 4, nil return 4, nil
+2 -2
View File
@@ -73,7 +73,7 @@ func (c *Cluster) Properties(deadline time.Time) (_ Object, resultErr error) {
object := Object{Name: name, Values: make(map[string]uint32)} object := Object{Name: name, Values: make(map[string]uint32)}
if err := readCommonProperties(clusterControl, c.handle, objectCluster, object.Values, deadline); err != nil { if err := readCommonProperties(clusterControl, c.handle, objectCluster, object.Values, &c.scratch, deadline); err != nil {
resultErr = errors.Join(resultErr, err) resultErr = errors.Join(resultErr, err)
if errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.DeadlineExceeded) {
@@ -157,7 +157,7 @@ func (c *Cluster) resourceClass(name string, deadline time.Time) (_ uint32, resu
} }
}() }()
data, err := resourceBuffer(handle, resourceGetClassInfo, 8, deadline) data, err := c.scratch.resourceControl(handle, resourceGetClassInfo, 8, deadline)
if err != nil { if err != nil {
return 0, fmt.Errorf("class information: %w", err) return 0, fmt.Errorf("class information: %w", err)
} }
+4 -4
View File
@@ -60,7 +60,7 @@ func TestDumpPropertyLists(t *testing.T) {
deadline := time.Now().Add(time.Minute) deadline := time.Now().Add(time.Minute)
for _, code := range []uint32{objectCluster<<24 | ctlGetROCommonProperties, objectCluster<<24 | ctlGetCommonProperties} { for _, code := range []uint32{objectCluster<<24 | ctlGetROCommonProperties, objectCluster<<24 | ctlGetCommonProperties} {
data, err := controlBuffer(deadline, 0, func(buffer []byte) (uint32, error) { data, err := controlBufferIn(deadline, newControlBuffer(0), func(buffer []byte) (uint32, error) {
var ( var (
size uint32 size uint32
pointer *byte pointer *byte
@@ -88,7 +88,7 @@ func TestDumpPropertyLists(t *testing.T) {
} }
for _, code := range []uint32{resourceTypeGetROCommonProperties, resourceTypeGetCommonProperties, resourceTypeGetPrivateProperties} { for _, code := range []uint32{resourceTypeGetROCommonProperties, resourceTypeGetCommonProperties, resourceTypeGetPrivateProperties} {
data, err := controlBuffer(deadline, 0, func(buffer []byte) (uint32, error) { data, err := controlBufferIn(deadline, newControlBuffer(0), func(buffer []byte) (uint32, error) {
var ( var (
size uint32 size uint32
pointer *byte pointer *byte
@@ -133,11 +133,11 @@ func TestDumpPropertyLists(t *testing.T) {
} }
for _, code := range []uint32{resourceGetROCommonProperties, resourceGetCommonProperties, resourceGetROPrivateProperties, resourceGetPrivateProperties} { for _, code := range []uint32{resourceGetROCommonProperties, resourceGetCommonProperties, resourceGetROPrivateProperties, resourceGetPrivateProperties} {
data, err := resourceBuffer(handle, code, 0, deadline) data, err := resourceBufferIn(handle, code, nil, deadline)
logPropertyList(t, fmt.Sprintf("resource %q %#x", name.name, code), data, err) logPropertyList(t, fmt.Sprintf("resource %q %#x", name.name, code), data, err)
} }
data, err := resourceBuffer(handle, resourceGetType, 0, deadline) data, err := resourceBufferIn(handle, resourceGetType, nil, deadline)
t.Logf("resource %q GET_RESOURCE_TYPE: %s err=%v", name.name, hex.EncodeToString(data), err) t.Logf("resource %q GET_RESOURCE_TYPE: %s err=%v", name.name, hex.EncodeToString(data), err)
closeResource.Call(handle) //nolint:errcheck closeResource.Call(handle) //nolint:errcheck
+1 -1
View File
@@ -98,7 +98,7 @@ func (f *fakeAPI) api() *nativeAPI {
return 2, nil return 2, nil
}, },
closeResource: func(uintptr) error { return nil }, closeResource: func(uintptr) error { return nil },
readResource: func(_ uintptr, resource *Resource, _ time.Time) error { readResource: func(_ uintptr, resource *Resource, _ *controlScratch, _ time.Time) error {
resource.Values["State"] = 2 resource.Values["State"] = 2
resource.IdentityValid = true resource.IdentityValid = true
+20 -9
View File
@@ -210,7 +210,7 @@ func (c *Cluster) readObject(api *objectAPI, name objectName, deadline time.Time
} }
}() }()
if err := readCommonProperties(api.control, handle, api.object, object.Values, deadline); err != nil { if err := readCommonProperties(api.control, handle, api.object, object.Values, &c.scratch, deadline); err != nil {
resultErr = errors.Join(resultErr, err) resultErr = errors.Join(resultErr, err)
if errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.DeadlineExceeded) {
@@ -222,7 +222,7 @@ func (c *Cluster) readObject(api *objectAPI, name objectName, deadline time.Time
name string name string
operation uint32 operation uint32
}{{"Characteristics", ctlGetCharacteristics}, {"Flags", ctlGetFlags}} { }{{"Characteristics", ctlGetCharacteristics}, {"Flags", ctlGetFlags}} {
data, err := objectBuffer(api.control, handle, api.object<<24|field.operation, 4, deadline) data, err := c.scratch.objectControl(api.control, handle, api.object<<24|field.operation, 4, deadline)
switch { switch {
case err != nil: case err != nil:
@@ -252,13 +252,13 @@ func (c *Cluster) readObject(api *objectAPI, name objectName, deadline time.Time
// readCommonProperties stores the single 32-bit values of the read-only and // readCommonProperties stores the single 32-bit values of the read-only and
// read/write common property lists in values. Other formats are skipped. // read/write common property lists in values. Other formats are skipped.
func readCommonProperties(control *windows.LazyProc, handle uintptr, object uint32, values map[string]uint32, deadline time.Time) error { func readCommonProperties(control *windows.LazyProc, handle uintptr, object uint32, values map[string]uint32, scratch *controlScratch, deadline time.Time) error {
var resultErr error var resultErr error
for _, operation := range []uint32{ctlGetROCommonProperties, ctlGetCommonProperties} { for _, operation := range []uint32{ctlGetROCommonProperties, ctlGetCommonProperties} {
code := object<<24 | operation code := object<<24 | operation
data, err := objectBuffer(control, handle, code, propertyListBufferSize, deadline) data, err := scratch.objectControl(control, handle, code, propertyListBufferSize, deadline)
if err != nil { if err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("%s %#x: %w", control.Name, code, err)) resultErr = errors.Join(resultErr, fmt.Errorf("%s %#x: %w", control.Name, code, err))
@@ -281,7 +281,7 @@ func readCommonProperties(control *windows.LazyProc, handle uintptr, object uint
continue continue
} }
if value, err := list[0].Value32(); err == nil { if value, ok := list[0].value32(); ok {
values[name] = value values[name] = value
} }
} }
@@ -290,10 +290,21 @@ func readCommonProperties(control *windows.LazyProc, handle uintptr, object uint
return resultErr return resultErr
} }
// objectBuffer calls a Cluster{Group,Node,Network}Control function. They share // objectControl calls a Cluster{Group,Node,Network}Control function starting
// the ClusterResourceControl signature and return a DWORD status code. // with n bytes of the scratch buffer. The result is only valid until the next
func objectBuffer(proc *windows.LazyProc, handle uintptr, code uint32, size int, deadline time.Time) ([]byte, error) { // control.
return controlBuffer(deadline, size, func(buffer []byte) (uint32, error) { func (s *controlScratch) objectControl(proc *windows.LazyProc, handle uintptr, code uint32, n int, deadline time.Time) ([]byte, error) {
data, err := objectBufferIn(proc, handle, code, s.get(n), deadline)
s.keep(data)
return data, err
}
// objectBufferIn calls a Cluster{Group,Node,Network}Control function. They
// share the ClusterResourceControl signature and return a DWORD status code.
// The result may share buffer's memory.
func objectBufferIn(proc *windows.LazyProc, handle uintptr, code uint32, buffer []byte, deadline time.Time) ([]byte, error) {
return controlBufferIn(deadline, buffer, func(buffer []byte) (uint32, error) {
var ( var (
size uint32 size uint32
pointer *byte pointer *byte
+78 -12
View File
@@ -19,6 +19,8 @@ import (
"encoding/binary" "encoding/binary"
"errors" "errors"
"fmt" "fmt"
"strings"
"unicode"
"unicode/utf16" "unicode/utf16"
) )
@@ -40,11 +42,21 @@ type Property struct {
} }
func (p Property) DWORD() (uint32, error) { func (p Property) DWORD() (uint32, error) {
if p.Format != formatDWORD || len(p.Data) != 4 { value, ok := p.dword()
if !ok {
return 0, fmt.Errorf("expected DWORD, format %d, length %d", p.Format, len(p.Data)) return 0, fmt.Errorf("expected DWORD, format %d, length %d", p.Format, len(p.Data))
} }
return binary.LittleEndian.Uint32(p.Data), nil return value, nil
}
// dword is DWORD without the error, for callers that skip other formats.
func (p Property) dword() (uint32, bool) {
if p.Format != formatDWORD || len(p.Data) != 4 {
return 0, false
}
return binary.LittleEndian.Uint32(p.Data), true
} }
// String decodes an SZ, EXPAND_SZ or EXPANDED_SZ value like WMI does: the value // String decodes an SZ, EXPAND_SZ or EXPANDED_SZ value like WMI does: the value
@@ -61,11 +73,21 @@ func (p Property) String() (string, error) {
// Value32 returns the bit pattern of a DWORD or LONG value. Callers decide the // Value32 returns the bit pattern of a DWORD or LONG value. Callers decide the
// signedness, matching the WMI type of the property they publish. // signedness, matching the WMI type of the property they publish.
func (p Property) Value32() (uint32, error) { func (p Property) Value32() (uint32, error) {
if (p.Format != formatDWORD && p.Format != formatLong) || len(p.Data) != 4 { value, ok := p.value32()
if !ok {
return 0, fmt.Errorf("expected DWORD or LONG, format %d, length %d", p.Format, len(p.Data)) return 0, fmt.Errorf("expected DWORD or LONG, format %d, length %d", p.Format, len(p.Data))
} }
return binary.LittleEndian.Uint32(p.Data), nil return value, nil
}
// value32 is Value32 without the error, for callers that skip other formats.
func (p Property) value32() (uint32, bool) {
if (p.Format != formatDWORD && p.Format != formatLong) || len(p.Data) != 4 {
return 0, false
}
return binary.LittleEndian.Uint32(p.Data), true
} }
// ParseProperties decodes CLUSPROP_LIST without casting untrusted bytes to native // ParseProperties decodes CLUSPROP_LIST without casting untrusted bytes to native
@@ -86,6 +108,9 @@ func ParseProperties(data []byte) (map[string][]Property, error) {
} }
properties := make(map[string][]Property, count) properties := make(map[string][]Property, count)
// Nearly every property has a single value, so all value lists share
// one backing array instead of one allocation per property.
all := make([]Property, 0, count)
for range count { for range count {
if len(data) < 8 || binary.LittleEndian.Uint32(data) != propertyName { if len(data) < 8 || binary.LittleEndian.Uint32(data) != propertyName {
@@ -99,7 +124,7 @@ func ParseProperties(data []byte) (map[string][]Property, error) {
name := decodeString(nameBytes) name := decodeString(nameBytes)
data = rest data = rest
values := make([]Property, 0, 1) start := len(all)
for { for {
if len(data) < 4 { if len(data) < 4 {
@@ -127,13 +152,15 @@ func ParseProperties(data []byte) (map[string][]Property, error) {
return nil, fmt.Errorf("invalid DWORD length for %q", name) return nil, fmt.Errorf("invalid DWORD length for %q", name)
} }
values = append(values, Property{Format: format, Data: payload}) all = append(all, Property{Format: format, Data: payload})
data = rest data = rest
} }
// Keep the first occurrence; a repeated name does not affect the structure. // Keep the first occurrence; a repeated name does not affect the structure.
// The full slice expression keeps appends for later properties from
// writing into this list.
if _, exists := properties[name]; !exists { if _, exists := properties[name]; !exists {
properties[name] = values properties[name] = all[start:len(all):len(all)]
} }
} }
@@ -165,16 +192,32 @@ func propertyPayload(data []byte) ([]byte, []byte, error) {
// decodeString decodes little-endian UTF-16 bytes. A trailing odd byte is ignored. // decodeString decodes little-endian UTF-16 bytes. A trailing odd byte is ignored.
func decodeString(data []byte) string { func decodeString(data []byte) string {
units := make([]uint16, len(data)/2) // Names and type strings are short; converting them on the stack leaves
for i := range units { // the result as the only allocation.
units[i] = binary.LittleEndian.Uint16(data[i*2:]) var stack [128]uint16
units := stack[:0]
if len(data)/2 > len(stack) {
units = make([]uint16, 0, len(data)/2)
}
// Stop at the first NUL: fixed-size fields such as MAX_PATH names would
// otherwise always outgrow the stack buffer.
for i := 0; i+1 < len(data); i += 2 {
unit := binary.LittleEndian.Uint16(data[i:])
if unit == 0 {
break
}
units = append(units, unit)
} }
return decodeUnits(units) return decodeUnits(units)
} }
// decodeUnits ends the string at the first NUL and replaces invalid UTF-16 with // decodeUnits ends the string at the first NUL and replaces invalid UTF-16 with
// U+FFFD, matching the strings WMI returned for the same names. // U+FFFD, matching the strings WMI returned for the same names. It decodes like
// utf16.Decode, but writes straight into the result instead of a []rune.
func decodeUnits(units []uint16) string { func decodeUnits(units []uint16) string {
for i, unit := range units { for i, unit := range units {
if unit == 0 { if unit == 0 {
@@ -184,5 +227,28 @@ func decodeUnits(units []uint16) string {
} }
} }
return string(utf16.Decode(units)) var builder strings.Builder
builder.Grow(len(units))
for i := 0; i < len(units); i++ {
r := rune(units[i])
if utf16.IsSurrogate(r) {
decoded := unicode.ReplacementChar
if i+1 < len(units) {
decoded = utf16.DecodeRune(r, rune(units[i+1]))
}
if decoded != unicode.ReplacementChar {
i++
}
r = decoded
}
builder.WriteRune(r)
}
return builder.String()
} }
+125
View File
@@ -236,3 +236,128 @@ func BenchmarkParseProperties(b *testing.B) {
} }
} }
} }
func encodeUTF16(units ...uint16) []byte {
data := make([]byte, 0, len(units)*2)
for _, unit := range units {
data = binary.LittleEndian.AppendUint16(data, unit)
}
return data
}
// referenceDecode is the utf16.Decode based decoding decodeString replaced.
func referenceDecode(data []byte) string {
units := make([]uint16, len(data)/2)
for i := range units {
units[i] = binary.LittleEndian.Uint16(data[i*2:])
}
for i, unit := range units {
if unit == 0 {
units = units[:i]
break
}
}
return string(utf16.Decode(units))
}
func TestDecodeString(t *testing.T) {
tests := map[string][]uint16{
"empty": {},
"ascii": utf16.Encode([]rune("Cluster IP Address")),
"nul": append(utf16.Encode([]rune("Type")), 0, 'x'),
"pair": utf16.Encode([]rune("disk 💾")),
"unpaired_high": {'a', 0xd83d, 'b'},
"unpaired_low": {'a', 0xdcbe, 'b'},
"high_before_nul": {'a', 0xd83d, 0, 'b'},
"high_at_end": {'a', 0xd83d},
"two_highs_then_low": {0xd83d, 0xd83d, 0xdcbe},
"replacement_char_bmp": {0xfffd},
}
for name, units := range tests {
t.Run(name, func(t *testing.T) {
data := encodeUTF16(units...)
if got, want := decodeString(data), referenceDecode(data); got != want {
t.Errorf("decodeString = %q, want %q", got, want)
}
if got, want := decodeUnits(units), referenceDecode(data); got != want {
t.Errorf("decodeUnits = %q, want %q", got, want)
}
})
}
if got := decodeString([]byte{'a', 0, 'b'}); got != "a" {
t.Errorf("odd length = %q, want %q", got, "a")
}
}
func FuzzDecodeString(f *testing.F) {
f.Add(encodeUTF16(utf16.Encode([]rune("Name 💾"))...))
f.Add(encodeUTF16('a', 0xd83d, 'b'))
f.Fuzz(func(t *testing.T, data []byte) {
if got, want := decodeString(data), referenceDecode(data); got != want {
t.Fatalf("decodeString(% x) = %q, want %q", data, got, want)
}
})
}
func appendTestProperty(data []byte, name string, values ...uint32) []byte {
encoded := encodeUTF16(append(utf16.Encode([]rune(name)), 0)...)
data = binary.LittleEndian.AppendUint32(data, propertyName)
data = binary.LittleEndian.AppendUint32(data, uint32(len(encoded)))
data = append(data, encoded...)
for len(data)%4 != 0 {
data = append(data, 0)
}
for _, value := range values {
data = binary.LittleEndian.AppendUint32(data, propertyValue|formatDWORD)
data = binary.LittleEndian.AppendUint32(data, 4)
data = binary.LittleEndian.AppendUint32(data, value)
}
return binary.LittleEndian.AppendUint32(data, 0)
}
// The value lists share one backing array; values of one property must not
// leak into another.
func TestParsePropertiesMultipleValues(t *testing.T) {
data := binary.LittleEndian.AppendUint32(nil, 3)
data = appendTestProperty(data, "First", 1, 2)
data = appendTestProperty(data, "Second", 3)
data = appendTestProperty(data, "Third", 4, 5, 6)
properties, err := ParseProperties(data)
if err != nil {
t.Fatal(err)
}
for name, want := range map[string][]uint32{"First": {1, 2}, "Second": {3}, "Third": {4, 5, 6}} {
values := properties[name]
if len(values) != len(want) {
t.Fatalf("%s: got %d values, want %d", name, len(values), len(want))
}
for i, value := range values {
if got, err := value.DWORD(); err != nil || got != want[i] {
t.Errorf("%s[%d] = %d, %v, want %d", name, i, got, err, want[i])
}
}
}
}
func BenchmarkDecodeString(b *testing.B) {
data := encodeUTF16(utf16.Encode([]rune("Microsoft Windows Server 2022 Datacenter"))...)
b.ReportAllocs()
for b.Loop() {
_ = decodeString(data)
}
}
+186
View File
@@ -0,0 +1,186 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package clusapi
import (
"bytes"
"maps"
"testing"
"time"
"golang.org/x/sys/windows"
)
// controlFromList answers a control with list, like ClusterResourceControl
// filling the caller's buffer.
func controlFromList(list []byte) func([]byte) (uint32, error) {
return func(buffer []byte) (uint32, error) {
if len(buffer) < len(list) {
return uint32(len(list)), windows.ERROR_MORE_DATA
}
return uint32(copy(buffer, list)), nil
}
}
// readListsIntoResource replays the property list part of readResource: both
// lists go through the same scratch buffer, one after the other.
func readListsIntoResource(tb testing.TB, scratch *controlScratch, resource *Resource, lists ...[]byte) bool {
tb.Helper()
hasType := false
for _, list := range lists {
data, err := controlBufferIn(time.Time{}, scratch.get(propertyListBufferSize), controlFromList(list))
if err != nil {
tb.Fatal(err)
}
scratch.keep(data)
properties, err := ParseProperties(data)
if err != nil {
tb.Fatal(err)
}
if copyResourceProperties(resource, properties) {
hasType = true
}
}
return hasType
}
func TestCopyResourceProperties(t *testing.T) {
var scratch controlScratch
resource := Resource{Values: make(map[string]uint32)}
hasType := readListsIntoResource(t, &scratch, &resource,
readPropertyFixture(t, "resource_ro_common_generic_service.hex"),
readPropertyFixture(t, "resource_common_generic_service.hex"),
)
// The second list overwrote the buffer of the first; the values must have
// been copied out before.
want := map[string]uint32{
"MonitorProcessId": 9092,
"DeadlockTimeout": 300000,
"EmbeddedFailureAction": 2,
"IsAlivePollInterval": ^uint32(0),
"LooksAlivePollInterval": ^uint32(0),
"PendingTimeout": 180000,
"PersistentState": 1,
"RestartAction": 2,
"RestartDelay": 500,
"RestartPeriod": 600000,
"RestartThreshold": 1,
"RetryPeriodOnFailure": 600000,
"SeparateMonitor": 0,
}
if !maps.Equal(resource.Values, want) {
t.Errorf("Values = %v, want %v", resource.Values, want)
}
if !hasType || resource.Type != "Generic Service" {
t.Errorf("Type = %q, hasType = %t", resource.Type, hasType)
}
clear(scratch.buffer)
if resource.Type != "Generic Service" {
t.Errorf("Type changed with the buffer: %q", resource.Type)
}
// The read-only list has no Type property.
resource = Resource{Values: make(map[string]uint32)}
if readListsIntoResource(t, &scratch, &resource, readPropertyFixture(t, "resource_ro_common_generic_service.hex")) {
t.Errorf("hasType for a list without Type, Type = %q", resource.Type)
}
}
func TestControlScratch(t *testing.T) {
var scratch controlScratch
buffer := scratch.get(8)
if len(buffer) != 8 || cap(buffer) != propertyListBufferSize {
t.Fatalf("get(8): len %d, cap %d", len(buffer), cap(buffer))
}
// A reused buffer must look like a new one: the cluster service may
// report more bytes than it wrote, and parsing relies on zero padding.
copy(buffer, "leftover")
if buffer := scratch.get(8); !bytes.Equal(buffer, make([]byte, 8)) {
t.Errorf("get after use = % x, want zeros", buffer)
}
// A buffer grown by ERROR_MORE_DATA is kept for the next object, up to
// maxScratchSize.
grown := make([]byte, 16<<10)
scratch.keep(grown[:100])
if cap(scratch.buffer) != len(grown) {
t.Errorf("keep: cap %d, want %d", cap(scratch.buffer), len(grown))
}
scratch.keep(make([]byte, maxScratchSize+1))
if cap(scratch.buffer) != len(grown) {
t.Errorf("kept a buffer above maxScratchSize: cap %d", cap(scratch.buffer))
}
}
func TestControlBufferInGrowsWithinCapacity(t *testing.T) {
var scratch controlScratch
list := bytes.Repeat([]byte{1}, 1000)
start := scratch.get(typeBufferSize)
copy(start[:cap(start)], bytes.Repeat([]byte{0xff}, cap(start)))
data, err := controlBufferIn(time.Time{}, start, controlFromList(list))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, list) {
t.Fatalf("data = % x", data[:16])
}
// ERROR_MORE_DATA reused the scratch buffer instead of allocating.
if &data[0] != &scratch.buffer[0] {
t.Error("grew into a new buffer although the capacity was enough")
}
}
// BenchmarkReadResourceProperties replays the property list work readResource
// does for one resource with the captured CI lists.
func BenchmarkReadResourceProperties(b *testing.B) {
lists := [][]byte{
readPropertyFixture(b, "resource_ro_common_generic_service.hex"),
readPropertyFixture(b, "resource_common_generic_service.hex"),
}
var scratch controlScratch
b.ReportAllocs()
for b.Loop() {
resource := Resource{Values: make(map[string]uint32)}
readListsIntoResource(b, &scratch, &resource, lists...)
}
}
+2 -2
View File
@@ -95,7 +95,7 @@ func (c *Cluster) readResourcePartitions(name objectName, deadline time.Time) (_
} }
}() }()
data, err := resourceBuffer(handle, resourceGetClassInfo, 8, deadline) data, err := c.scratch.resourceControl(handle, resourceGetClassInfo, 8, deadline)
if err != nil { if err != nil {
return nil, fmt.Errorf("class information: %w", err) return nil, fmt.Errorf("class information: %w", err)
} }
@@ -104,7 +104,7 @@ func (c *Cluster) readResourcePartitions(name objectName, deadline time.Time) (_
return nil, nil return nil, nil
} }
data, err = resourceBuffer(handle, resourceStorageGetDiskInfoEx, propertyListBufferSize, deadline) data, err = c.scratch.resourceControl(handle, resourceStorageGetDiskInfoEx, propertyListBufferSize, deadline)
if errors.Is(err, windows.ERROR_INVALID_FUNCTION) || errors.Is(err, windows.ERROR_NOT_SUPPORTED) { if errors.Is(err, windows.ERROR_INVALID_FUNCTION) || errors.Is(err, windows.ERROR_NOT_SUPPORTED) {
// Storage class resources such as storage pools have no disk layout. // Storage class resources such as storage pools have no disk layout.
return nil, nil return nil, nil