feat: Add HID keyboard auto-release and async callbacks

Co-authored-by: adam <adam@buildjet.com>
This commit is contained in:
Cursor Agent 2025-09-13 00:08:48 +00:00
parent 1717549578
commit 20783e6118
4 changed files with 104 additions and 20 deletions

View File

@ -4,4 +4,13 @@ import "time"
const dwc3Path = "/sys/bus/platform/drivers/dwc3"
const hidWriteTimeout = 10 * time.Millisecond
// Timeout for HID writes to gadget endpoints. Some hosts poll at ~10ms, but
// under load this can be longer. Use a slightly higher value to reduce
// spurious timeouts while still preventing indefinite blocking.
const hidWriteTimeout = 50 * time.Millisecond
// Auto-release configuration. When keys are down and no successful HID write
// has happened for this duration, clear the keyboard state to prevent stuck
// keys after network hiccups.
const keyAutoReleaseAfter = 2 * time.Second
const keyAutoReleaseCheckInterval = 200 * time.Millisecond

View File

@ -163,9 +163,16 @@ func (u *UsbGadget) updateKeyDownState(state KeysDownState) {
}
if u.onKeysDownChange != nil {
u.log.Trace().Interface("state", state).Msg("calling onKeysDownChange")
(*u.onKeysDownChange)(state)
u.log.Trace().Interface("state", state).Msg("onKeysDownChange called")
// Call the callback asynchronously to avoid blocking the HID RPC handler path
// and to prevent timeouts when the network is slow or the channel back-pressures.
st := state
// make a copy of the slice to avoid data races
st.Keys = append([]byte(nil), state.Keys...)
go func(s KeysDownState) {
u.log.Trace().Interface("state", s).Msg("calling onKeysDownChange")
(*u.onKeysDownChange)(s)
u.log.Trace().Interface("state", s).Msg("onKeysDownChange called")
}(st)
}
}
@ -234,6 +241,8 @@ func (u *UsbGadget) openKeyboardHidFile() error {
u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background())
u.listenKeyboardEvents()
// Start auto-release monitor to clear stuck keys after inactivity
u.listenKeyboardAutoRelease()
return nil
}
@ -400,3 +409,59 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error)
return u.UpdateKeysDown(modifier, keys), err
}
// listenKeyboardAutoRelease starts a background monitor that automatically
// releases all keys if no input has been successfully written to the HID device
// for a period of time. This acts as a safety net to avoid stuck keys when
// key-up events are lost due to network issues.
func (u *UsbGadget) listenKeyboardAutoRelease() {
var path string
if u.keyboardHidFile != nil {
path = u.keyboardHidFile.Name()
}
l := u.log.With().Str("listener", "keyboardAutoRelease").Str("path", path).Logger()
l.Trace().Msg("starting")
go func() {
ticker := time.NewTicker(keyAutoReleaseCheckInterval)
defer ticker.Stop()
for {
select {
case <-u.keyboardStateCtx.Done():
l.Info().Msg("context done")
return
case <-ticker.C:
// Fast path: if nothing is currently down, skip work
u.keyboardStateLock.Lock()
modifier := u.keysDownState.Modifier
keys := append([]byte(nil), u.keysDownState.Keys...)
u.keyboardStateLock.Unlock()
allZero := modifier == 0
if allZero {
for _, k := range keys {
if k != 0 {
allZero = false
break
}
}
}
if allZero {
continue
}
// If we have any key/modifier down and we've been idle long enough, clear state
if time.Since(u.GetLastUserInputTime()) >= keyAutoReleaseAfter {
l.Debug().Dur("idle", time.Since(u.GetLastUserInputTime())).Msg("auto-releasing stuck keys")
zeros := make([]byte, hidKeyBufferSize)
_, err := u.KeyboardReport(0x00, zeros)
if err != nil {
u.logWithSuppression("keyboardAutoReleaseWrite", 100, &l, err, "failed to auto-release keys")
} else {
u.resetLogSuppressionCounter("keyboardAutoReleaseWrite")
}
}
}
}
}()
}

View File

@ -116,23 +116,22 @@ func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err err
}
n, err = file.Write(data)
if err == nil {
return
if err != nil {
if errors.Is(err, os.ErrDeadlineExceeded) {
// Promote visibility of timeouts and allow callers to recover by
// reopening the file. Do not swallow the error.
u.logWithSuppression(
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
100,
u.log,
err,
"write timed out: %s",
file.Name(),
)
}
return n, err
}
if errors.Is(err, os.ErrDeadlineExceeded) {
u.logWithSuppression(
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
1000,
u.log,
err,
"write timed out: %s",
file.Name(),
)
err = nil
}
return
return n, nil
}
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...any) {

View File

@ -274,6 +274,17 @@ func newSession(config SessionConfig) (*Session, error) {
session.rpcQueue = nil
}
// Best-effort: clear any potentially stuck keys on channel close
if gadget != nil {
go func() {
zeros := make([]byte, 6)
_, err := rpcKeyboardReport(0x00, zeros)
if err != nil {
scopedLogger.Debug().Err(err).Msg("failed to auto-release keys on channel close")
}
}()
}
// Stop HID RPC processor
for i := 0; i < len(session.hidQueue); i++ {
close(session.hidQueue[i])