Repository: kubecost/events
Pull request #7: Events Upgrades, Fixes, and Improvements
Author: login: mbolt35
Created: 2025-05-05T22:22:53Z | Merged: 2025-05-06T19:22:42Z

* Fixed a small issue with `atomic.Bool` on a sync event that would copy a `noCopy`
* Upgraded to go 1.24.2
* Optimized the removal and filtering methods to operate in a single loop. 
* Added more test coverage


Diff:
diff --git a/dispatcher.go b/dispatcher.go
index 5596776..83d930c 100644
--- a/dispatcher.go
+++ b/dispatcher.go
@@ -96,7 +96,7 @@ type Dispatcher[T any] interface {
 // SyncEvent[T] is an event wrapper which contains the event T payload and a channel to
 // signal when the event is processed.
 type SyncEvent[T any] struct {
-	closed atomic.Bool
+	closed *atomic.Bool
 	done   chan struct{}
 
 	// Event contains the T event payload that was dispatched.
@@ -121,8 +121,9 @@ func (se *SyncEvent[T]) Done() {
 // creates a new synchronous event wrapper to pass to the synchronous event stream.
 func newSyncEvent[T any](event T) SyncEvent[T] {
 	return SyncEvent[T]{
-		done:  make(chan struct{}),
-		Event: event,
+		closed: new(atomic.Bool),
+		done:   make(chan struct{}),
+		Event:  event,
 	}
 }
 
@@ -130,8 +131,9 @@ func newSyncEvent[T any](event T) SyncEvent[T] {
 // an event dispatched asynchronously
 func newFauxSyncEvent[T any](event T) SyncEvent[T] {
 	return SyncEvent[T]{
-		done:  nil,
-		Event: event,
+		closed: new(atomic.Bool),
+		done:   nil,
+		Event:  event,
 	}
 }
 
@@ -472,15 +474,16 @@ func (md *multicastDispatcher[T]) getFilteredEventStreams(event T) []*asyncEvent
 		return nil
 	}
 
-	// ensure that we remove all streams that are closed
-	md.streams.RemoveOn(func(stream *asyncEventStream[T]) bool {
-		return stream == nil || stream.IsClosed()
-	})
-
-	// return a slice containing the streams to dispatch to
-	return md.streams.Filtered(func(stream *asyncEventStream[T]) bool {
-		return stream.condition == nil || stream.condition(event)
-	})
+	// ensure that we remove all streams that are closed,
+	// and filter out any streams that do not match the event conditions
+	return md.streams.RemoveAndFilter(
+		func(stream *asyncEventStream[T]) bool {
+			return stream == nil || stream.IsClosed()
+		},
+		func(stream *asyncEventStream[T]) bool {
+			return stream.condition == nil || stream.condition(event)
+		},
+	)
 }
 
 // getHandlerClose is a helper function which allows tests to access the onClose channel for
@@ -606,9 +609,9 @@ func (md *multicastDispatcher[T]) executeHandler(handler EventHandler[T], event
 			if e, ok := r.(error); ok {
 				err = e
 			} else if s, ok := r.(string); ok {
-				err = fmt.Errorf("Unexpected panic: %s", s)
+				err = fmt.Errorf("unexpected panic: %s", s)
 			} else {
-				err = fmt.Errorf("Unexpected panic: %+v", r)
+				err = fmt.Errorf("unexpected panic: %+v", r)
 			}
 		}
 	}()
diff --git a/events.go b/events.go
index e92f6b0..251cfe9 100644
--- a/events.go
+++ b/events.go
@@ -8,9 +8,8 @@ import (
 )
 
 var (
-	lock         sync.Mutex
-	dispatchers  map[string]any
-	dispatchers2 map[reflect.Type]any
+	lock        sync.Mutex
+	dispatchers map[string]any
 )
 
 func init() {
diff --git a/events_test.go b/events_test.go
index 4c56129..2c51895 100644
--- a/events_test.go
+++ b/events_test.go
@@ -2,10 +2,14 @@ package events
 
 import (
 	"fmt"
+	"math/rand"
+	"runtime"
+	"slices"
 	"sync"
 	"sync/atomic"
 	"testing"
 	"time"
+	"weak"
 )
 
 //--------------------------------------------------------------------------
@@ -260,6 +264,36 @@ func TestAddRemoveHandlersMidStreamSync(t *testing.T) {
 	}
 }
 
+func TestPanicOnListener(t *testing.T) {
+	type Panic struct {
+		Message string
+	}
+
+	d := NewDispatcher[TestEvent]()
+
+	d.AddEventHandler(func(event TestEvent) {
+		t.Logf("Handler One: [Event: %s]\n", event.Message)
+		panic("Panic on listener")
+	})
+
+	d.AddEventHandler(func(event TestEvent) {
+		t.Logf("Handler Two: [Event: %s]\n", event.Message)
+		panic(fmt.Errorf("Panic error!"))
+	})
+
+	d.AddEventHandler(func(event TestEvent) {
+		t.Logf("Handler Three: [Event: %s]\n", event.Message)
+		panic(Panic{"Panic struct"})
+	})
+
+	time.Sleep(100 * time.Millisecond)
+
+	d.Dispatch(TestEvent{"Test"})
+
+	time.Sleep(500 * time.Millisecond)
+	d.CloseEventStreams()
+}
+
 func TestFilteredEventStreams(t *testing.T) {
 	d := NewDispatcher[TypedEvent]()
 
@@ -635,7 +669,7 @@ func TestBlockingSyncDispatch(t *testing.T) {
 	case <-time.After(3 * time.Second):
 	}
 
-	delta := time.Now().Sub(start)
+	delta := time.Since(start)
 	if delta < (2 * time.Second) {
 		t.Errorf("Test failed. Blocked for less than 2 seconds: %dms\n", delta.Milliseconds())
 	}
@@ -653,6 +687,218 @@ func TestBlockingSyncDispatch(t *testing.T) {
 
 }
 
+func TestBlockingSyncMultiDispatch(t *testing.T) {
+	const listeners = 30
+
+	var waitTimeLock sync.Mutex
+	waitTimes := []time.Duration{}
+
+	d := NewDispatcher[TestEvent]()
+	defer d.CloseEventStreams()
+
+	var wg sync.WaitGroup
+	wg.Add(listeners)
+
+	// Create multiple stream listeners
+	for i := range listeners {
+		go func(id int) {
+			es := d.NewEventStream()
+			ss := es.SyncStream()
+
+			// notify waitgroup that the go routine has started
+			wg.Done()
+
+			for syncEvent := range ss {
+				func() {
+					defer syncEvent.Done()
+
+					// simulate some work
+					dur := time.Duration(100+rand.Intn(1500)) * time.Millisecond
+
+					waitTimeLock.Lock()
+					waitTimes = append(waitTimes, dur)
+					waitTimeLock.Unlock()
+
+					time.Sleep(dur)
+
+					t.Logf("[%d] Handled: %s\n", id, syncEvent.Event.Message)
+				}()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+	time.Sleep(200 * time.Millisecond)
+
+	// dispatch single event, should block until all listeners are done
+	now := time.Now().UTC()
+	d.DispatchSyncWithTimeout(TestEvent{Message: "TestEvent"}, 5*time.Second)
+	totalDuration := time.Since(now)
+
+	// we expect ALL handlers be done, so access to waiTimes _should_ be safe. If it's not, we
+	// have a bug :)
+	maxWaitTime := slices.Max(waitTimes)
+
+	t.Logf("Max Wait Time: %dms, Total Block Duration: %dms\n", maxWaitTime.Milliseconds(), totalDuration.Milliseconds())
+
+	if totalDuration < maxWaitTime {
+		t.Errorf("DispatchSync returned before all handlers were done. Total: %dms, Max Wait: %dms\n", totalDuration.Milliseconds(), maxWaitTime.Milliseconds())
+	}
+}
+
+func TestMultiEventBlockingSyncMultiDispatch(t *testing.T) {
+	const listeners = 30
+	const dispatches = 10
+
+	var countLock sync.Mutex
+	counts := make(map[int]*atomic.Uint64)
+
+	var waitTimeLock sync.Mutex
+	waitTimes := []time.Duration{}
+
+	d := NewDispatcher[TestEvent]()
+	defer d.CloseEventStreams()
+
+	var wg sync.WaitGroup
+	wg.Add(listeners)
+
+	// Create multiple stream listeners
+	for i := range listeners {
+		go func(id int) {
+			es := d.NewEventStream()
+			ss := es.SyncStream()
+
+			// notify waitgroup that the go routine has started
+			wg.Done()
+
+			for syncEvent := range ss {
+				func() {
+					defer syncEvent.Done()
+
+					// simulate some work
+					dur := time.Duration(200+rand.Intn(300)) * time.Millisecond
+
+					waitTimeLock.Lock()
+					waitTimes = append(waitTimes, dur)
+					waitTimeLock.Unlock()
+
+					countLock.Lock()
+					if _, ok := counts[id]; !ok {
+						counts[id] = new(atomic.Uint64)
+					}
+					counts[id].Add(1)
+					countLock.Unlock()
+
+					time.Sleep(dur)
+
+					t.Logf("[%d] Handled: %s\n", id, syncEvent.Event.Message)
+				}()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+	time.Sleep(200 * time.Millisecond)
+
+	for i := range dispatches {
+		now := time.Now().UTC()
+		d.DispatchSync(TestEvent{Message: fmt.Sprintf("TestEvent: %d", i)})
+		totalDuration := time.Since(now)
+
+		maxWaitTime := slices.Max(waitTimes)
+		t.Logf("Max Wait Time: %dms, Total Block Duration: %dms\n", maxWaitTime.Milliseconds(), totalDuration.Milliseconds())
+		if totalDuration < maxWaitTime {
+			t.Errorf("DispatchSync returned before all handlers were done. Total: %dms, Max Wait: %dms\n", totalDuration.Milliseconds(), maxWaitTime.Milliseconds())
+		}
+
+		waitTimes = waitTimes[:0]
+	}
+
+	for k, v := range counts {
+		count := v.Load()
+		t.Logf("Listener: %d, Count: %d", k, count)
+
+		if count != dispatches {
+			t.Errorf("Listener: %d, Count != %d. Got: %d\n", k, dispatches, count)
+		}
+	}
+}
+
+func TestMultiEventBlockingAsyncMultiDispatch(t *testing.T) {
+	const listeners = 30
+	const dispatches = 10
+
+	type TEvent struct {
+		ID      int
+		Message string
+	}
+
+	var countLock sync.Mutex
+	counts := make(map[int]*atomic.Uint64)
+
+	d := NewDispatcher[TEvent]()
+	defer d.CloseEventStreams()
+
+	var wg sync.WaitGroup
+	wg.Add(listeners)
+
+	// Create multiple stream listeners
+	for i := range listeners {
+		go func(id int) {
+			es := d.NewEventStream()
+			ss := es.SyncStream()
+
+			// notify waitgroup that the go routine has started
+			wg.Done()
+
+			for syncEvent := range ss {
+				func() {
+					defer syncEvent.Done()
+
+					// simulate some work
+					dur := time.Duration(200+rand.Intn(300)) * time.Millisecond
+
+					countLock.Lock()
+					if _, ok := counts[id]; !ok {
+						counts[id] = new(atomic.Uint64)
+					}
+					counts[id].Add(1)
+					countLock.Unlock()
+
+					time.Sleep(dur)
+
+					//t.Logf("[%d] Handled: %s\n", id, syncEvent.Event.Message)
+				}()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+	time.Sleep(200 * time.Millisecond)
+
+	var allDispatches sync.WaitGroup
+	allDispatches.Add(dispatches)
+
+	for i := range dispatches {
+		go func(id int) {
+			defer allDispatches.Done()
+
+			d.DispatchSync(TEvent{ID: id, Message: fmt.Sprintf("TestEvent: %d", id)})
+		}(i)
+	}
+
+	allDispatches.Wait()
+
+	for k, v := range counts {
+		count := v.Load()
+		t.Logf("Listener: %d, Count: %d", k, count)
+
+		if count != dispatches {
+			t.Errorf("Listener: %d, Count != %d. Got: %d\n", k, dispatches, count)
+		}
+	}
+}
+
 func TestStreamAccessSwitching(t *testing.T) {
 	var wg sync.WaitGroup
 	wg.Add(1)
@@ -697,6 +943,104 @@ func TestStreamAccessSwitching(t *testing.T) {
 	}
 }
 
+func TestEventsWithoutListeners(t *testing.T) {
+	// this test just ensures that we can dispatch events without listeners and
+	// they fall into the void.
+	type TEvent struct {
+		ID      int
+		Message string
+	}
+	weakEvents := []weak.Pointer[TEvent]{}
+
+	d := NewDispatcher[*TEvent]()
+
+	// signal to dispatcher to start listening
+	startListener := make(chan struct{})
+	resumeEvents := make(chan struct{})
+
+	go func() {
+		<-startListener
+
+		d.AddEventHandler(func(te *TEvent) {
+			t.Logf("Received: %s\n", te.Message)
+		})
+		close(resumeEvents)
+	}()
+
+	for i := 0; i < 50; i++ {
+		if i == 30 {
+			close(startListener)
+			<-resumeEvents
+		}
+
+		testEvent := &TEvent{
+			ID:      i,
+			Message: fmt.Sprintf("Test:%d", i),
+		}
+
+		// add a weak ptr to the event
+		weakEvents = append(weakEvents, weak.Make(testEvent))
+
+		// dispatch the event
+		d.Dispatch(testEvent)
+	}
+
+	time.Sleep(500 * time.Millisecond)
+	runtime.GC()
+	time.Sleep(time.Second)
+
+	for _, weakEvent := range weakEvents {
+		val := weakEvent.Value()
+		if val != nil {
+			// check that only events with ID < 30 are dead
+			if val.ID < 30 {
+				t.Errorf("Weak event was not garbage collected: %s\n", weakEvent.Value().Message)
+			}
+		}
+	}
+
+}
+
+func TestPointerEvent(t *testing.T) {
+	const listeners = 20
+
+	type TEvent struct {
+		ID      int
+		Message string
+		Count   atomic.Uint64
+	}
+
+	d := NewDispatcher[*TEvent]()
+	defer d.CloseEventStreams()
+
+	var wg sync.WaitGroup
+	wg.Add(listeners)
+
+	listener := func(te *TEvent) {
+		defer wg.Done()
+
+		t.Logf("Received: %s\n", te.Message)
+		te.Count.Add(1)
+	}
+
+	for range listeners {
+		d.AddEventHandler(listener)
+	}
+
+	testEvent := &TEvent{
+		ID:      1,
+		Message: "Test",
+	}
+
+	d.Dispatch(testEvent)
+
+	wg.Wait()
+
+	if testEvent.Count.Load() != uint64(listeners) {
+		t.Errorf("Count != %d. Got: %d\n", listeners, testEvent.Count.Load())
+	}
+}
+
 //--------------------------------------------------------------------------
 //  Benchmarks
 //--------------------------------------------------------------------------
diff --git a/go.mod b/go.mod
index b3eeadd..94f2aa2 100644
--- a/go.mod
+++ b/go.mod
@@ -1,5 +1,5 @@
 module github.com/kubecost/events
 
-go 1.19
+go 1.24.2
 
-require github.com/google/uuid v1.3.0
+require github.com/google/uuid v1.6.0
diff --git a/go.sum b/go.sum
index 3dfe1c9..7790d7c 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,2 @@
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
-github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
diff --git a/set.go b/set.go
index baef9af..ba3c919 100644
--- a/set.go
+++ b/set.go
@@ -28,6 +28,10 @@ type set[T comparable] interface {
 	// Filtered returns a slice of items that match the predicate.
 	Filtered(func(T) bool) []T
 
+	// RemoveAndFilter accepts two predicates, the first is used to remove items from the set,
+	// while the second is used to filter the items in the remaining set.
+	RemoveAndFilter(removeOn func(T) bool, filterOn func(T) bool) []T
+
 	// Length returns the total number of items in the set.
 	Length() int
 
@@ -116,6 +120,25 @@ func (s *lockingSet[T]) Length() int {
 	return len(s.m)
 }
 
+func (s *lockingSet[T]) RemoveAndFilter(removeOn func(T) bool, filterOn func(T) bool) []T {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	var slice []T
+	for k := range s.m {
+		if removeOn(k) {
+			delete(s.m, k)
+			continue
+		}
+
+		if filterOn(k) {
+			slice = append(slice, k)
+		}
+	}
+
+	return slice
+}
+
 // ToSlice creates a new slice of size Length(), copies the set elements into the slice, and
 // returns it.
 func (s *lockingSet[T]) ToSlice() []T {
@@ -166,7 +189,7 @@ func (s *lockingSet[T]) CopyTo(destination []T) error {
 	}
 
 	if len(destination) > l {
-		return fmt.Errorf("Destination length(%d) < source length (%d)", len(destination), l)
+		return fmt.Errorf("destination length(%d) < source length (%d)", len(destination), l)
 	}
 
 	index := 0


Reviews:
user: login: nik-kc
state: APPROVED
author_association: NONE
submitted_at: 2025-05-06T01:55:23Z
user: login: avrodrigues5
body: `Fixed a small issue with atomic.Bool on a sync event that would copy a noCopy`

Would you be ok to explain what this means?
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-06T15:36:00Z
user: login: mbolt35
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T17:51:14Z
user: login: avrodrigues5
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-06T17:55:36Z
user: login: avrodrigues5
state: APPROVED
author_association: NONE
submitted_at: 2025-05-06T17:55:56Z

Review comments:
diff_hunk: @@ -260,6 +264,36 @@ func TestAddRemoveHandlersMidStreamSync(t *testing.T) {
 	}
 }
 
+func TestPanicOnListener(t *testing.T) {
+	type Panic struct {
+		Message string
+	}
+
+	d := NewDispatcher[TestEvent]()
+
+	d.AddEventHandler(func(event TestEvent) {
+		t.Logf("Handler One: [Event: %s]\n", event.Message)
path: events_test.go
user: login: avrodrigues5
body: Is the intend of the test case to panic for all three event handler? 

Asking the question for my own learning. 
created_at: 2025-05-06T15:34:31Z
author_association: NONE
diff_hunk: @@ -260,6 +264,36 @@ func TestAddRemoveHandlersMidStreamSync(t *testing.T) {
 	}
 }
 
+func TestPanicOnListener(t *testing.T) {
+	type Panic struct {
+		Message string
+	}
+
+	d := NewDispatcher[TestEvent]()
+
+	d.AddEventHandler(func(event TestEvent) {
+		t.Logf("Handler One: [Event: %s]\n", event.Message)
path: events_test.go
user: login: mbolt35
body: @avrodrigues5 
Yes, this tests panic within the event handler and tests the dispatcher recovery. Because you can pass `panic` anything, we need a test to verify that our recovery code is resilient. For reference, here's the recovery code for the panic:

https://github.com/kubecost/events/blob/5d085fc7be3c7c10fb4baae781be835a898519df/dispatcher.go#L606-L621
created_at: 2025-05-06T17:51:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -260,6 +264,36 @@ func TestAddRemoveHandlersMidStreamSync(t *testing.T) {
 	}
 }
 
+func TestPanicOnListener(t *testing.T) {
+	type Panic struct {
+		Message string
+	}
+
+	d := NewDispatcher[TestEvent]()
+
+	d.AddEventHandler(func(event TestEvent) {
+		t.Logf("Handler One: [Event: %s]\n", event.Message)
path: events_test.go
user: login: avrodrigues5
body: nice. Makes sense! TYVM!
created_at: 2025-05-06T17:55:36Z
author_association: NONE

PR comments:
user: login: mbolt35
created_at: 2025-05-06T17:45:34Z
body: > `Fixed a small issue with atomic.Bool on a sync event that would copy a noCopy`
> 
> Would you be ok to explain what this means?

@avrodrigues5 
Yeah, so `go` uses this weird idiom to prevent copy by value on certain types (this is mainly `sync` package types like `sync.Mutex` `atomic.*` etc...)

There is a rule in `go vet` that will yell whenever there is code that attempts to copy a `sync.Locker` implementation by value.  Let's say you have a type that you never want a dev to copy by value (keep it exclusively a pointer). In order to do this, you can first create a type `noCopy` (the type name used in the go std library) that implements `sync.Locker`:

```go
type noCopy struct{}
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}
```

Then, in your type, you would just include a field with the type `noCopy` like this:

```go
type NonCopyable struct {
	Id int

	// since noCopy implements sync.Locker, the NonCopyable type cannot be
	// copied by value
	_ noCopy
}
```

Copying a pointer to a `NonCopyable` is perfectly fine, but trying to do a dereference copy is not:

```go
func main() {
        nonCopyablePtr := &NonCopyable{Id: 10}

	// this is fine
	anotherPtr := nonCopyablePtr

	// this is not
	anotherValue := *anotherPtr

	fmt.Printf("Id: %d\n", anotherValue.Id)
}
```

Gives me the error: 
```
assignment copies lock value to anotherValue: main.NonCopyable contains main.noCopy
```

Dumped this in the go playground to demonstrate a workable example:
https://go.dev/play/p/Wo4s0OyQvHd


### _Why this matters here_
For sync events, we returned a `SyncEvent[T]` from the event stream that looked like this:

```go
type SyncEvent[T any] struct {
	closed atomic.Bool
	done   chan struct{}

	// Event contains the T event payload that was dispatched.
	Event T
}
```

Note that the `closed` field is an `atomic.Bool` which looks like this in the go standard library: 

```go
// A Bool is an atomic boolean value.
// The zero value is false.
type Bool struct {
	_ noCopy
	v uint32
}
```

So the `atomic.Bool` type is `noCopy`, but when the event stream returned a `SyncEvent[T]` it was a copy by value, which tries to copy the fields, and `go vet` wasn't happy. 

In general, a good rule of thumb is not to "fix" this by just updating the `atomic.Bool` to a pointer (like I did), but in the context of this library, it is 100% fine, as the state of the `SyncEvent[T]` needs to be preserved no matter how many value copies are made. The more reliable solution would be to update the Stream to return a `*SyncEvent[T]` rather than the value type. However, this would change the public facing contract, and I did not want to break compatibility.
author_association: CONTRIBUTOR
user: login: avrodrigues5
created_at: 2025-05-06T17:54:35Z
body: > > `Fixed a small issue with atomic.Bool on a sync event that would copy a noCopy`
> > Would you be ok to explain what this means?
> 
> @avrodrigues5 Yeah, so `go` uses this weird idiom to prevent copy by value on certain types (this is mainly `sync` package types like `sync.Mutex` `atomic.*` etc...)
> 
> There is a rule in `go vet` that will yell whenever there is code that attempts to copy a `sync.Locker` implementation by value. Let's say you have a type that you never want a dev to copy by value (keep it exclusively a pointer). In order to do this, you can first create a type `noCopy` (the type name used in the go std library) that implements `sync.Locker`:
> 
> ```go
> type noCopy struct{}
> func (*noCopy) Lock()   {}
> func (*noCopy) Unlock() {}
> ```
> 
> Then, in your type, you would just include a field with the type `noCopy` like this:
> 
> ```go
> type NonCopyable struct {
> 	Id int
> 
> 	// since noCopy implements sync.Locker, the NonCopyable type cannot be
> 	// copied by value
> 	_ noCopy
> }
> ```
> 
> Copying a pointer to a `NonCopyable` is perfectly fine, but trying to do a dereference copy is not:
> 
> ```go
> func main() {
>         nonCopyablePtr := &NonCopyable{Id: 10}
> 
> 	// this is fine
> 	anotherPtr := nonCopyablePtr
> 
> 	// this is not
> 	anotherValue := *anotherPtr
> 
> 	fmt.Printf("Id: %d\n", anotherValue.Id)
> }
> ```
> 
> Gives me the error:
> 
> ```
> assignment copies lock value to anotherValue: main.NonCopyable contains main.noCopy
> ```
> 
> Dumped this in the go playground to demonstrate a workable example: https://go.dev/play/p/Wo4s0OyQvHd
> 
> ### _Why this matters here_
> For sync events, we returned a `SyncEvent[T]` from the event stream that looked like this:
> 
> ```go
> type SyncEvent[T any] struct {
> 	closed atomic.Bool
> 	done   chan struct{}
> 
> 	// Event contains the T event payload that was dispatched.
> 	Event T
> }
> ```
> 
> Note that the `closed` field is an `atomic.Bool` which looks like this in the go standard library:
> 
> ```go
> // A Bool is an atomic boolean value.
> // The zero value is false.
> type Bool struct {
> 	_ noCopy
> 	v uint32
> }
> ```
> 
> So the `atomic.Bool` type is `noCopy`, but when the event stream returned a `SyncEvent[T]` it was a copy by value, which tries to copy the fields, and `go vet` wasn't happy.
> 
> In general, a good rule of thumb is not to "fix" this by just updating the `atomic.Bool` to a pointer (like I did), but in the context of this library, it is 100% fine, as the state of the `SyncEvent[T]` needs to be preserved no matter how many value copies are made. The more reliable solution would be to update the Stream to return a `*SyncEvent[T]` rather than the value type. However, this would change the public facing contract, and I did not want to break compatibility.

Thank you Bolt. 
author_association: NONE