Repository: canonical/snapd
Pull request #15287: cmd/snap-bootstrap/triggerwatch: fix busy loop
Author: login: valentindavid
Labels: name: cherry-picked
Created: 2025-04-03T15:10:49Z | Merged: 2025-04-09T15:22:30Z

Thanks for helping us make a better snapd!
Have you signed the [license agreement](https://www.ubuntu.com/legal/contributors) and read the [contribution guide](https://github.com/snapcore/snapd/blob/master/CONTRIBUTING.md)?

LP: https://bugs.launchpad.net/snapd/+bug/2106121



Diff:
diff --git a/cmd/snap-bootstrap/triggerwatch/export_test.go b/cmd/snap-bootstrap/triggerwatch/export_test.go
index 07b2113c7f1..f1d626abcf0 100644
--- a/cmd/snap-bootstrap/triggerwatch/export_test.go
+++ b/cmd/snap-bootstrap/triggerwatch/export_test.go
@@ -38,7 +38,7 @@ type TriggerCapabilityFilter = triggerEventFilter
 type KeyEvent = keyEvent
 
 type mockUEventConnection struct {
-	events []netlink.UEvent
+	events chan netlink.UEvent
 }
 
 func (m *mockUEventConnection) Connect(mode netlink.Mode) error {
@@ -51,8 +51,11 @@ func (m *mockUEventConnection) Close() error {
 
 func (m *mockUEventConnection) Monitor(queue chan netlink.UEvent, errors chan error, matcher netlink.Matcher) func(time.Duration) bool {
 	go func() {
-		for _, event := range m.events {
-			queue <- event
+		for {
+			select {
+			case event := <-m.events:
+				queue <- event
+			}
 		}
 	}()
 	return func(time.Duration) bool {
@@ -60,7 +63,7 @@ func (m *mockUEventConnection) Monitor(queue chan netlink.UEvent, errors chan er
 	}
 }
 
-func MockUEvent(events []netlink.UEvent) (restore func()) {
+func MockUEventChannel(events chan netlink.UEvent) (restore func()) {
 	oldGetUEventConn := getUEventConn
 	getUEventConn = func() ueventConnection {
 		return &mockUEventConnection{events}
@@ -70,3 +73,28 @@ func MockUEvent(events []netlink.UEvent) (restore func()) {
 		getUEventConn = oldGetUEventConn
 	}
 }
+
+func MockUEvent(events []netlink.UEvent) (restore func()) {
+	oldGetUEventConn := getUEventConn
+	e := make(chan netlink.UEvent)
+	go func() {
+		for _, event := range events {
+			e <- event
+		}
+	}()
+	getUEventConn = func() ueventConnection {
+		return &mockUEventConnection{e}
+	}
+
+	return func() {
+		getUEventConn = oldGetUEventConn
+	}
+}
+
+func MockTimeAfter(f func(d time.Duration) <-chan time.Time) (restore func()) {
+	old := timeAfter
+	timeAfter = f
+	return func() {
+		f = old
+	}
+}
diff --git a/cmd/snap-bootstrap/triggerwatch/triggerwatch.go b/cmd/snap-bootstrap/triggerwatch/triggerwatch.go
index 89c1e2b4d48..2541add4949 100644
--- a/cmd/snap-bootstrap/triggerwatch/triggerwatch.go
+++ b/cmd/snap-bootstrap/triggerwatch/triggerwatch.go
@@ -31,6 +31,10 @@ import (
 	"github.com/snapcore/snapd/osutil/udev/netlink"
 )
 
+var (
+	timeAfter = time.After
+)
+
 type triggerProvider interface {
 	Open(filter triggerEventFilter, node string) (triggerDevice, error)
 	FindMatchingDevices(filter triggerEventFilter) ([]triggerDevice, error)
@@ -114,11 +118,9 @@ func Wait(timeout time.Duration, deviceTimeout time.Duration) error {
 	}
 	foundDevice := len(devices) != 0
 
-	start := time.Now()
+	timeoutEvent := timeAfter(timeout)
+	deviceTimeoutEvent := timeAfter(deviceTimeout)
 	for {
-		timePassed := time.Now().Sub(start)
-		relTimeout := timeout - timePassed
-		relDeviceTimeout := deviceTimeout - timePassed
 		select {
 		case kev := <-detectKeyCh:
 			if kev.Err != nil {
@@ -127,9 +129,9 @@ func Wait(timeout time.Duration, deviceTimeout time.Duration) error {
 			// channel got closed without an error
 			logger.Noticef("%s: + got trigger key %v", kev.Dev, triggerFilter.Key)
 			return nil
-		case <-time.After(relTimeout):
+		case <-timeoutEvent:
 			return ErrTriggerNotDetected
-		case <-time.After(relDeviceTimeout):
+		case <-deviceTimeoutEvent:
 			if !foundDevice {
 				return ErrNoMatchingInputDevices
 			}
diff --git a/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go b/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go
index ba5a8c1c57d..d7f5360bb1d 100644
--- a/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go
+++ b/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go
@@ -233,3 +233,112 @@ func (s *triggerwatchSuite) TestUdevEventNoKeyEvent(c *C) {
 		c.Assert(md.closeCalls, Equals, 1)
 	})
 }
+
+func (s *triggerwatchSuite) TestWaitMoreKeyboards(c *C) {
+	nodepath := "/dev/input/event0"
+	devpath := "/devices/SOMEBUS/input/input0/event0"
+
+	md := &mockTriggerDevice{}
+	md2 := &mockTriggerDevice{ev: &triggerwatch.KeyEvent{}}
+	mi := &mockTrigger{
+		d: md,
+		unlistedDevices: map[string]*mockTriggerDevice{
+			nodepath: md2,
+		},
+	}
+	restore := triggerwatch.MockInput(mi)
+	defer restore()
+
+	uevents := make(chan netlink.UEvent)
+	restore = triggerwatch.MockUEventChannel(uevents)
+	defer restore()
+
+	type activeTimer struct {
+		duration time.Duration
+		time     chan time.Time
+	}
+
+	start := time.Now()
+	currentNow := start
+	currentMutex := &sync.Mutex{}
+
+	var timers []*activeTimer
+	restore = triggerwatch.MockTimeAfter(func(d time.Duration) <-chan time.Time {
+		if d < 0 {
+			c.Errorf("Timer with negative duration")
+			ret := make(chan time.Time, 1)
+			currentMutex.Lock()
+			defer currentMutex.Unlock()
+			ret <- currentNow
+			return ret
+		} else {
+			currentMutex.Lock()
+			defer currentMutex.Unlock()
+			durationFromStart := currentNow.Add(d).Sub(start)
+			timer := &activeTimer{
+				duration: durationFromStart,
+				time:     make(chan time.Time, 1),
+			}
+			timers = append(timers, timer)
+			return timer.time
+		}
+	})
+	defer restore()
+
+	advanceTo := func(d time.Duration) {
+		currentMutex.Lock()
+		defer currentMutex.Unlock()
+		currentNow = start.Add(d)
+		var remaining []*activeTimer
+		for _, timer := range timers {
+			if timer.duration > d {
+				remaining = append(remaining, timer)
+			} else {
+				timer.time <- currentNow
+			}
+		}
+		timers = remaining
+	}
+
+	waitResult := make(chan error)
+	go func() {
+		err := triggerwatch.Wait(10*time.Second, 2*time.Second)
+		waitResult <- err
+	}()
+
+	// Flush go routines to block
+	<-time.After(time.Second)
+
+	// 3 seconds is after 2 seconds, but before 10. So it will
+	// trigger the device timeout.
+	advanceTo(3 * time.Second)
+
+	// Flush go routines to block
+	<-time.After(time.Second)
+
+	uevents <- netlink.UEvent{
+		Action: netlink.ADD,
+		KObj:   devpath,
+		Env: map[string]string{
+			"SUBSYSTEM": "input",
+			"DEVNAME":   nodepath,
+			"DEVPATH":   devpath,
+		},
+	}
+
+	select {
+	case err := <-waitResult:
+		c.Assert(err, IsNil)
+		c.Check(mi.findMatchingCalls, Equals, 1)
+		md.withLocked(func() {
+			c.Check(md.waitForTriggerCalls, Equals, 1)
+			c.Check(md.closeCalls, Equals, 1)
+		})
+		md2.withLocked(func() {
+			c.Check(md2.waitForTriggerCalls, Equals, 1)
+			c.Check(md2.closeCalls, Equals, 1)
+		})
+	case <-time.After(time.Second):
+		c.Errorf("Wait did not finish")
+	}
+}


Reviews:
user: login: pedronis
body: do we need unit test changes/additions?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T15:13:52Z
user: login: zyga
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T18:18:54Z
user: login: bboozzoo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T05:35:38Z
user: login: pedronis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T15:01:20Z
user: login: bboozzoo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T06:26:26Z
user: login: valentindavid
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T07:34:28Z
user: login: valentindavid
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T07:37:04Z
user: login: valentindavid
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T07:40:30Z
user: login: bboozzoo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T08:20:12Z
user: login: pedronis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T09:59:41Z
user: login: valentindavid
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T13:59:38Z
user: login: bboozzoo
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:06:44Z
user: login: pedronis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:37:02Z

Review comments:
diff_hunk: @@ -127,9 +125,9 @@ func Wait(timeout time.Duration, deviceTimeout time.Duration) error {
 			// channel got closed without an error
 			logger.Noticef("%s: + got trigger key %v", kev.Dev, triggerFilter.Key)
 			return nil
-		case <-time.After(relTimeout):
+		case <-timeoutEvent:
path: cmd/snap-bootstrap/triggerwatch/triggerwatch.go
user: login: bboozzoo
body: maybe you could use snapd/testtime for unit testing?
created_at: 2025-04-03T17:05:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,6 +31,10 @@ import (
 	"github.com/snapcore/snapd/osutil/udev/netlink"
 )
 
+var (
+	timeAfter = time.After
path: cmd/snap-bootstrap/triggerwatch/triggerwatch.go
user: login: pedronis
body: am I confused? this doesn't seem to be used in the diff
created_at: 2025-04-07T15:01:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,9 +125,9 @@ func Wait(timeout time.Duration, deviceTimeout time.Duration) error {
 			// channel got closed without an error
 			logger.Noticef("%s: + got trigger key %v", kev.Dev, triggerFilter.Key)
 			return nil
-		case <-time.After(relTimeout):
+		case <-timeoutEvent:
path: cmd/snap-bootstrap/triggerwatch/triggerwatch.go
user: login: bboozzoo
body: Here's a diff replacing time.After() with one from timeutil and using testtime to mock the timers. I don't think you need to have additional sleeps if the state is setup such that you can guide it though desired cases in select, so I dropped the real time.After/time.Sleep. Coverage shows we're hitting the right code paths.

```
diff --git a/cmd/snap-bootstrap/triggerwatch/triggerwatch.go b/cmd/snap-bootstrap/triggerwatch/triggerwatch.go
index 352024c4f7..5ec183b5d0 100644
--- a/cmd/snap-bootstrap/triggerwatch/triggerwatch.go
+++ b/cmd/snap-bootstrap/triggerwatch/triggerwatch.go
@@ -29,10 +29,13 @@ import (
 
 	"github.com/snapcore/snapd/logger"
 	"github.com/snapcore/snapd/osutil/udev/netlink"
+	"github.com/snapcore/snapd/timeutil"
 )
 
 var (
-	timeAfter = time.After
+	timeAfter = func(d time.Duration) <-chan time.Time {
+		return timeutil.After(d)
+	}
 )
 
 type triggerProvider interface {
@@ -118,8 +121,8 @@ func Wait(timeout time.Duration, deviceTimeout time.Duration) error {
 	}
 	foundDevice := len(devices) != 0
 
-	timeoutEvent := time.After(timeout)
-	deviceTimeoutEvent := time.After(deviceTimeout)
+	timeoutEvent := timeAfter(timeout)
+	deviceTimeoutEvent := timeAfter(deviceTimeout)
 	for {
 		select {
 		case kev := <-detectKeyCh:
diff --git a/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go b/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go
index 727e39e963..1ecc7150ba 100644
--- a/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go
+++ b/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go
@@ -30,6 +30,7 @@ import (
 
 	"github.com/snapcore/snapd/cmd/snap-bootstrap/triggerwatch"
 	"github.com/snapcore/snapd/osutil/udev/netlink"
+	"github.com/snapcore/snapd/testtime"
 )
 
 // Hook up check.v1 into the "go test" runner
@@ -258,39 +259,27 @@ func (s *triggerwatchSuite) TestWaitMoreKeyboards(c *C) {
 		time     chan time.Time
 	}
 
-	start := time.Now()
-	currentNow := start
+	timersReadyC := make(chan struct{})
 
-	var timers []*activeTimer
+	var timers []*testtime.TestTimer
 	restore = triggerwatch.MockTimeAfter(func(d time.Duration) <-chan time.Time {
 		if d < 0 {
-			c.Errorf("Timer with negative duration")
-			ret := make(chan time.Time, 1)
-			ret <- currentNow
-			return ret
-		} else {
-			durationFromStart := currentNow.Add(d).Sub(start)
-			timer := &activeTimer{
-				duration: durationFromStart,
-				time:     make(chan time.Time, 1),
-			}
-			timers = append(timers, timer)
-			return timer.time
+			panic("Timer with negative duration")
 		}
+
+		tm := testtime.NewTimer(d)
+		timers = append(timers, tm)
+		if len(timers) == 2 {
+			close(timersReadyC)
+		}
+		return tm.ExpiredC()
 	})
 	defer restore()
 
 	advanceTo := func(d time.Duration) {
-		currentNow = start.Add(d)
-		var remaining []*activeTimer
 		for _, timer := range timers {
-			if timer.duration > d {
-				remaining = append(remaining, timer)
-			} else {
-				timer.time <- currentNow
-			}
+			timer.Elapse(d)
 		}
-		timers = remaining
 	}
 
 	waitResult := make(chan error)
@@ -300,13 +289,12 @@ func (s *triggerwatchSuite) TestWaitMoreKeyboards(c *C) {
 	}()
 
 	// Flush go routines to block
-	<-time.After(time.Second)
+	<-timersReadyC
 
+	// advance the test timers
 	advanceTo(3 * time.Second)
 
-	// Flush go routines to block
-	<-time.After(time.Second)
-
+	// send a mock event
 	uevents <- netlink.UEvent{
 		Action: netlink.ADD,
 		KObj:   devpath,
diff --git a/timeutil/timer.go b/timeutil/timer.go
index 0332f22dcc..9c12079ced 100644
--- a/timeutil/timer.go
+++ b/timeutil/timer.go
@@ -51,6 +51,13 @@ func AfterFunc(d time.Duration, f func()) StdlibTimer {
 	return StdlibTimer{time.AfterFunc(d, f)}
 }
 
+// After waits for the duration to elapse and then closes the channel.
+//
+// See here for more information: https://pkg.go.dev/time#After
+func After(d time.Duration) <-chan time.Time {
+	return StdlibTimer{time.NewTimer(d)}.ExpiredC()
+}
+
 // NewTimer creates a new Timer that will send the current time on its channel
 // after at least duration d.
 //
```
created_at: 2025-04-08T06:26:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,6 +31,10 @@ import (
 	"github.com/snapcore/snapd/osutil/udev/netlink"
 )
 
+var (
+	timeAfter = time.After
path: cmd/snap-bootstrap/triggerwatch/triggerwatch.go
user: login: valentindavid
body: I might have broken it.
created_at: 2025-04-08T07:34:28Z
author_association: MEMBER
diff_hunk: @@ -31,6 +31,10 @@ import (
 	"github.com/snapcore/snapd/osutil/udev/netlink"
 )
 
+var (
+	timeAfter = time.After
path: cmd/snap-bootstrap/triggerwatch/triggerwatch.go
user: login: valentindavid
body: Yes. That was because I went back and forth with the path for the fix, and then I forgot to change it back to what was correct before pushing. It should be fixed now.
created_at: 2025-04-08T07:37:04Z
author_association: MEMBER
diff_hunk: @@ -127,9 +125,9 @@ func Wait(timeout time.Duration, deviceTimeout time.Duration) error {
 			// channel got closed without an error
 			logger.Noticef("%s: + got trigger key %v", kev.Dev, triggerFilter.Key)
 			return nil
-		case <-time.After(relTimeout):
+		case <-timeoutEvent:
path: cmd/snap-bootstrap/triggerwatch/triggerwatch.go
user: login: valentindavid
body: > I don't think you need to have additional sleeps

The code did not even reach the loop without sleeps.
created_at: 2025-04-08T07:40:30Z
author_association: MEMBER
diff_hunk: @@ -127,9 +125,9 @@ func Wait(timeout time.Duration, deviceTimeout time.Duration) error {
 			// channel got closed without an error
 			logger.Noticef("%s: + got trigger key %v", kev.Dev, triggerFilter.Key)
 			return nil
-		case <-time.After(relTimeout):
+		case <-timeoutEvent:
path: cmd/snap-bootstrap/triggerwatch/triggerwatch.go
user: login: bboozzoo
body: the channel is only read from in the select{} clause which is inside the for{} loop, so it must be reaching there
created_at: 2025-04-08T08:20:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -233,3 +233,110 @@ func (s *triggerwatchSuite) TestUdevEventNoKeyEvent(c *C) {
 		c.Assert(md.closeCalls, Equals, 1)
 	})
 }
+
+func (s *triggerwatchSuite) TestWaitMoreKeyboards(c *C) {
+	nodepath := "/dev/input/event0"
+	devpath := "/devices/SOMEBUS/input/input0/event0"
+
+	md := &mockTriggerDevice{}
+	md2 := &mockTriggerDevice{ev: &triggerwatch.KeyEvent{}}
+	mi := &mockTrigger{
+		d: md,
+		unlistedDevices: map[string]*mockTriggerDevice{
+			nodepath: md2,
+		},
+	}
+	restore := triggerwatch.MockInput(mi)
+	defer restore()
+
+	uevents := make(chan netlink.UEvent)
+	restore = triggerwatch.MockUEventChannel(uevents)
+	defer restore()
+
+	type activeTimer struct {
+		duration time.Duration
+		time     chan time.Time
+	}
+
+	start := time.Now()
+	currentNow := start
+	currentMutex := &sync.Mutex{}
+
+	var timers []*activeTimer
+	restore = triggerwatch.MockTimeAfter(func(d time.Duration) <-chan time.Time {
+		if d < 0 {
+			c.Errorf("Timer with negative duration")
+			ret := make(chan time.Time, 1)
+			currentMutex.Lock()
+			defer currentMutex.Unlock()
+			ret <- currentNow
+			return ret
+		} else {
+			currentMutex.Lock()
+			defer currentMutex.Unlock()
+			durationFromStart := currentNow.Add(d).Sub(start)
+			timer := &activeTimer{
+				duration: durationFromStart,
+				time:     make(chan time.Time, 1),
+			}
+			timers = append(timers, timer)
+			return timer.time
+		}
+	})
+	defer restore()
+
+	advanceTo := func(d time.Duration) {
+		currentMutex.Lock()
+		defer currentMutex.Unlock()
+		currentNow = start.Add(d)
+		var remaining []*activeTimer
+		for _, timer := range timers {
+			if timer.duration > d {
+				remaining = append(remaining, timer)
+			} else {
+				timer.time <- currentNow
+			}
+		}
+		timers = remaining
+	}
+
+	waitResult := make(chan error)
+	go func() {
+		err := triggerwatch.Wait(10*time.Second, 2*time.Second)
+		waitResult <- err
+	}()
+
+	// Flush go routines to block
+	<-time.After(time.Second)
+
+	advanceTo(3 * time.Second)
path: cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go
user: login: pedronis
body: is the idea that this will trigger the device timeout that is passed as 2s above? maybe a comment would be good if that is the case
created_at: 2025-04-08T09:59:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -233,3 +233,110 @@ func (s *triggerwatchSuite) TestUdevEventNoKeyEvent(c *C) {
 		c.Assert(md.closeCalls, Equals, 1)
 	})
 }
+
+func (s *triggerwatchSuite) TestWaitMoreKeyboards(c *C) {
+	nodepath := "/dev/input/event0"
+	devpath := "/devices/SOMEBUS/input/input0/event0"
+
+	md := &mockTriggerDevice{}
+	md2 := &mockTriggerDevice{ev: &triggerwatch.KeyEvent{}}
+	mi := &mockTrigger{
+		d: md,
+		unlistedDevices: map[string]*mockTriggerDevice{
+			nodepath: md2,
+		},
+	}
+	restore := triggerwatch.MockInput(mi)
+	defer restore()
+
+	uevents := make(chan netlink.UEvent)
+	restore = triggerwatch.MockUEventChannel(uevents)
+	defer restore()
+
+	type activeTimer struct {
+		duration time.Duration
+		time     chan time.Time
+	}
+
+	start := time.Now()
+	currentNow := start
+	currentMutex := &sync.Mutex{}
+
+	var timers []*activeTimer
+	restore = triggerwatch.MockTimeAfter(func(d time.Duration) <-chan time.Time {
+		if d < 0 {
+			c.Errorf("Timer with negative duration")
+			ret := make(chan time.Time, 1)
+			currentMutex.Lock()
+			defer currentMutex.Unlock()
+			ret <- currentNow
+			return ret
+		} else {
+			currentMutex.Lock()
+			defer currentMutex.Unlock()
+			durationFromStart := currentNow.Add(d).Sub(start)
+			timer := &activeTimer{
+				duration: durationFromStart,
+				time:     make(chan time.Time, 1),
+			}
+			timers = append(timers, timer)
+			return timer.time
+		}
+	})
+	defer restore()
+
+	advanceTo := func(d time.Duration) {
+		currentMutex.Lock()
+		defer currentMutex.Unlock()
+		currentNow = start.Add(d)
+		var remaining []*activeTimer
+		for _, timer := range timers {
+			if timer.duration > d {
+				remaining = append(remaining, timer)
+			} else {
+				timer.time <- currentNow
+			}
+		}
+		timers = remaining
+	}
+
+	waitResult := make(chan error)
+	go func() {
+		err := triggerwatch.Wait(10*time.Second, 2*time.Second)
+		waitResult <- err
+	}()
+
+	// Flush go routines to block
+	<-time.After(time.Second)
+
+	advanceTo(3 * time.Second)
path: cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go
user: login: valentindavid
body: Yes. Something more than 2 less than 10. I can add a comment.
created_at: 2025-04-08T13:59:38Z
author_association: MEMBER
diff_hunk: @@ -233,3 +233,110 @@ func (s *triggerwatchSuite) TestUdevEventNoKeyEvent(c *C) {
 		c.Assert(md.closeCalls, Equals, 1)
 	})
 }
+
+func (s *triggerwatchSuite) TestWaitMoreKeyboards(c *C) {
+	nodepath := "/dev/input/event0"
+	devpath := "/devices/SOMEBUS/input/input0/event0"
+
+	md := &mockTriggerDevice{}
+	md2 := &mockTriggerDevice{ev: &triggerwatch.KeyEvent{}}
+	mi := &mockTrigger{
+		d: md,
+		unlistedDevices: map[string]*mockTriggerDevice{
+			nodepath: md2,
+		},
+	}
+	restore := triggerwatch.MockInput(mi)
+	defer restore()
+
+	uevents := make(chan netlink.UEvent)
+	restore = triggerwatch.MockUEventChannel(uevents)
+	defer restore()
+
+	type activeTimer struct {
+		duration time.Duration
+		time     chan time.Time
+	}
+
+	start := time.Now()
+	currentNow := start
+	currentMutex := &sync.Mutex{}
+
+	var timers []*activeTimer
+	restore = triggerwatch.MockTimeAfter(func(d time.Duration) <-chan time.Time {
+		if d < 0 {
+			c.Errorf("Timer with negative duration")
+			ret := make(chan time.Time, 1)
+			currentMutex.Lock()
+			defer currentMutex.Unlock()
+			ret <- currentNow
+			return ret
+		} else {
+			currentMutex.Lock()
+			defer currentMutex.Unlock()
+			durationFromStart := currentNow.Add(d).Sub(start)
+			timer := &activeTimer{
+				duration: durationFromStart,
+				time:     make(chan time.Time, 1),
+			}
+			timers = append(timers, timer)
+			return timer.time
+		}
+	})
+	defer restore()
+
+	advanceTo := func(d time.Duration) {
+		currentMutex.Lock()
+		defer currentMutex.Unlock()
+		currentNow = start.Add(d)
+		var remaining []*activeTimer
+		for _, timer := range timers {
+			if timer.duration > d {
+				remaining = append(remaining, timer)
+			} else {
+				timer.time <- currentNow
+			}
+		}
+		timers = remaining
+	}
+
+	waitResult := make(chan error)
+	go func() {
+		err := triggerwatch.Wait(10*time.Second, 2*time.Second)
+		waitResult <- err
+	}()
+
+	// Flush go routines to block
+	<-time.After(time.Second)
+
+	advanceTo(3 * time.Second)
path: cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go
user: login: pedronis
body: thanks
created_at: 2025-04-08T14:37:02Z
author_association: CONTRIBUTOR

PR comments:
user: login: valentindavid
created_at: 2025-04-03T15:22:38Z
body: > do we need unit test changes/additions?

A bit difficult to test. When the bug is there, we still should process the keys high priorities. So it still kind of "works".
author_association: MEMBER
user: login: valentindavid
created_at: 2025-04-03T15:25:07Z
body: > A bit difficult to test. When the bug is there, we still should process the keys high priorities. So it still kind of "works".

Maybe this:
 * have at least one keyboard.
 * wait for device timeout.
 * add a keyboard.
 * press 1 in the new keyboard.

If there was at least 1 keyboard before the device timeout, we are still accepting new keyboards after the device timeout.
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-03T17:46:42Z
body: Wed Apr  9 15:12:16 UTC 2025
The following results are from: https://github.com/canonical/snapd/actions/runs/14335716938
## Failures:
### Preparing:
- openstack:fedora-40-64:tests/regression/
### Executing:
- openstack:debian-sid-64:tests/main/progress
author_association: NONE
user: login: valentindavid
created_at: 2025-04-04T10:50:39Z
body: I have added a test. But to note, the only thing that I can make fail with the previous code is verifying there is not negative timer set. The code worked. Just that it was allocating a lot of memory and running 100% CPU. I do not see how I can capture that with tests.
author_association: MEMBER
user: login: codecov[bot]
created_at: 2025-04-09T15:22:54Z
body: ## [Codecov](https://app.codecov.io/gh/canonical/snapd/pull/15287?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=canonical) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 0.00%. Comparing base [(`5e81c9d`)](https://app.codecov.io/gh/canonical/snapd/commit/5e81c9d8af4b52f0519b1a087b706dd530217497?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=canonical) to head [(`5305afb`)](https://app.codecov.io/gh/canonical/snapd/commit/5305afba4a5908d2bdce82096268fe285a89a860?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=canonical).
> Report is 18 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@       Coverage Diff       @@
##   master   #15287   +/-   ##
===============================
===============================

```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/canonical/snapd/pull/15287?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=canonical).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=canonical).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE