Normalize captured autorepeat for quarantined Remote
This commit is contained in:
1 parent
ee1c32f049
commit
6b3aa80138
6 files changed
+561
-3
No files matched your search
@@ -19,7 +19,7 @@ python3 -B tests/native_remote_input.py
|
||||
```
|
||||
|
||||
Requirements are Python 3, Xvfb/xvfb-run, D-Bus/dbus-run-session, Xlib, XTEST,
|
||||
xdotool, xev, Go and the reader's usual native build dependencies. Missing tools
|
||||
xdotool, xev, xset, Go and the reader's usual native build dependencies. Missing tools
|
||||
or failed checks fail the command; they do not count as skipped acceptance.
|
||||
The launcher starts a 24-bit Xvfb display before private D-Bus, with disposable
|
||||
runtime/config directories. It never connects the fixture to the desktop
|
||||
@@ -104,6 +104,38 @@ a check/inject race; it does not resolve a physical press arriving while a
|
||||
remote key is held. Do not clear all modifiers or assume the synthetic
|
||||
two-client result establishes physical-device behavior.
|
||||
|
||||
## Capture-side repeat normalization (2026-11-06)
|
||||
|
||||
`internal/input/remote_repeat.go` adds a quarantined `RemoteRepeatNormalizer`
|
||||
for a future controller capture owner. Remote's outgoing encoder requires one
|
||||
fresh down, repeated downs and one final up; the ordinary `RepeatTracker` marks
|
||||
repeats but still exposes the release of a classic X11 release/press pair.
|
||||
|
||||
The normalizer holds a nonzero-timestamp release until the next observation or
|
||||
an explicit deadline (initially 25 ms, at most 250 ms). A press of the same
|
||||
keycode with the same server timestamp becomes a repeat and the release is
|
||||
dropped. Any other observation, a zero timestamp or the deadline emits the
|
||||
release first, so ordering is preserved. Pairing is decided by event content,
|
||||
not timer accuracy; a late flush adds latency only. Detectable-autorepeat
|
||||
downs pass through as repeats. Releases for keys held before capture are
|
||||
suppressed and never treated as owned. An invalid keycode or more than 32 held
|
||||
keys stops admission; `Retire` returns one release per owned key, including a
|
||||
pending release, in reverse down order, exactly once. It performs no I/O and
|
||||
has no production caller; it grants no consent or provenance.
|
||||
|
||||
`TestNativeRemoteRepeatNormalizesHeldKey` runs in the harness above. It holds an
|
||||
XTEST key with private Xvfb autorepeat set to 200 ms/40 Hz and feeds the real
|
||||
XRecord listener's events through the normalizer with timer-driven flushes.
|
||||
Five repeated runs on 2026-11-06 each observed 22 raw downs, one raw up and
|
||||
**no** classic release/press pairs: on this Xvfb, XRecord reported server
|
||||
autorepeat as repeated presses. The normalized stream was one down, 21 repeats
|
||||
and one up, with nothing left owned. The classic-pair path is therefore covered
|
||||
only by unit tests; physical keyboards, other X servers and XGrabKeyboard-based
|
||||
capture may differ and remain unverified. The complete harness, including the
|
||||
existing internal-UI, learn-mode and injected-approval tests, passed with this
|
||||
test added. The `xset` change must follow the
|
||||
listener's connection because Xvfb resets defaults when its last client exits.
|
||||
|
||||
Still pending: Remote capture within the command owner; local escape and
|
||||
balanced bypass chords; remapping, learn/UI/lock/handoff arbitration; production
|
||||
helper parent-death and blocked-pipe tests; held-key collisions with actual
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// RemoteRepeatReleaseDelay is the initial bound for holding a release
|
||||
// while waiting for its classic X11 autorepeat press. It is a tunable
|
||||
// latency limit for a private capture owner, not a measured hardware value.
|
||||
RemoteRepeatReleaseDelay = 25 * time.Millisecond
|
||||
RemoteRepeatReleaseDelayMax = 250 * time.Millisecond
|
||||
remoteRepeatMinKeycode = 8
|
||||
)
|
||||
|
||||
var ErrRemoteRepeatDelay = errors.New("remote repeat release delay is invalid")
|
||||
|
||||
// RemoteRepeatTransition is one normalized source transition. Repeat is set
|
||||
// only on a press for a key that is already owned; no synthetic release is
|
||||
// emitted between repeats.
|
||||
type RemoteRepeatTransition struct {
|
||||
Keycode uint8
|
||||
Pressed bool
|
||||
Repeat bool
|
||||
}
|
||||
|
||||
// RemoteRepeatNormalizer converts native key observations into the stream
|
||||
// required by Remote's outgoing encoder: one fresh down, repeated downs and
|
||||
// one final up. Classic X11 autorepeat reports release/press pairs with the
|
||||
// same server timestamp; a nonzero-timestamp release is therefore held until
|
||||
// the next observation or its deadline. Detectable autorepeat already reports
|
||||
// repeated downs and passes through unchanged.
|
||||
//
|
||||
// One instance belongs to one capture lifetime and one serial native owner.
|
||||
// It owns only keys whose downs it observed: releases for keys held before
|
||||
// capture are suppressed and never reported as Remote-owned. Admission stops
|
||||
// on an invalid keycode or more than RemoteHeldKeyLimit held keys; Retire still
|
||||
// returns releases for every owned key. The normalizer performs no I/O, grants
|
||||
// no consent and does not establish physical-source provenance.
|
||||
type RemoteRepeatNormalizer struct {
|
||||
delay time.Duration
|
||||
held [256]bool
|
||||
order []uint8
|
||||
pending bool
|
||||
pendingCode uint8
|
||||
pendingStamp uint32
|
||||
pendingDeadline time.Time
|
||||
stopped bool
|
||||
retired bool
|
||||
}
|
||||
|
||||
func NewRemoteRepeatNormalizer(delay time.Duration) (*RemoteRepeatNormalizer, error) {
|
||||
if delay <= 0 || delay > RemoteRepeatReleaseDelayMax {
|
||||
return nil, ErrRemoteRepeatDelay
|
||||
}
|
||||
return &RemoteRepeatNormalizer{delay: delay, order: make([]uint8, 0, RemoteHeldKeyLimit)}, nil
|
||||
}
|
||||
|
||||
// Observe admits one native transition. A successful call can return zero,
|
||||
// one or two transitions; a returned error stops admission permanently while
|
||||
// retaining ownership for Retire. The caller must not drop returned work.
|
||||
func (normalizer *RemoteRepeatNormalizer) Observe(event Event, now time.Time) ([]RemoteRepeatTransition, error) {
|
||||
if normalizer == nil || normalizer.stopped {
|
||||
return nil, ErrRemoteKeyState
|
||||
}
|
||||
if event.Keycode < remoteRepeatMinKeycode {
|
||||
normalizer.stopped = true
|
||||
return nil, ErrRemoteKeyUnavailable
|
||||
}
|
||||
var out []RemoteRepeatTransition
|
||||
if normalizer.pending {
|
||||
if event.Pressed && event.Keycode == normalizer.pendingCode &&
|
||||
event.Timestamp == normalizer.pendingStamp {
|
||||
normalizer.pending = false
|
||||
return []RemoteRepeatTransition{{Keycode: event.Keycode, Pressed: true, Repeat: true}}, nil
|
||||
}
|
||||
out = append(out, normalizer.releasePending())
|
||||
}
|
||||
code := event.Keycode
|
||||
if event.Pressed {
|
||||
if normalizer.held[code] {
|
||||
return append(out, RemoteRepeatTransition{Keycode: code, Pressed: true, Repeat: true}), nil
|
||||
}
|
||||
if len(normalizer.order) >= RemoteHeldKeyLimit {
|
||||
normalizer.stopped = true
|
||||
return out, ErrRemoteKeyLimit
|
||||
}
|
||||
normalizer.held[code] = true
|
||||
normalizer.order = append(normalizer.order, code)
|
||||
return append(out, RemoteRepeatTransition{Keycode: code, Pressed: true}), nil
|
||||
}
|
||||
if !normalizer.held[code] {
|
||||
return out, nil
|
||||
}
|
||||
if event.Timestamp == 0 {
|
||||
// Unknown timestamps cannot prove a classic pair; release now.
|
||||
normalizer.forget(code)
|
||||
return append(out, RemoteRepeatTransition{Keycode: code}), nil
|
||||
}
|
||||
normalizer.pending = true
|
||||
normalizer.pendingCode = code
|
||||
normalizer.pendingStamp = event.Timestamp
|
||||
normalizer.pendingDeadline = now.Add(normalizer.delay)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Deadline reports when a held release must be flushed. The owner should arm
|
||||
// a timer for it rather than poll.
|
||||
func (normalizer *RemoteRepeatNormalizer) Deadline() (time.Time, bool) {
|
||||
if normalizer == nil || normalizer.retired || !normalizer.pending {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return normalizer.pendingDeadline, true
|
||||
}
|
||||
|
||||
// Flush emits a held release once its deadline has been reached. Pairing is
|
||||
// decided by event content, not by timer accuracy: a late flush only adds
|
||||
// latency, and a press arriving after a flush is a fresh down.
|
||||
func (normalizer *RemoteRepeatNormalizer) Flush(now time.Time) []RemoteRepeatTransition {
|
||||
if normalizer == nil || normalizer.retired || !normalizer.pending ||
|
||||
now.Before(normalizer.pendingDeadline) {
|
||||
return nil
|
||||
}
|
||||
return []RemoteRepeatTransition{normalizer.releasePending()}
|
||||
}
|
||||
|
||||
// Retire stops the normalizer and transfers releases for every owned key in
|
||||
// reverse down order, including a held pending release. It returns the plan
|
||||
// once; repeated retirement returns nothing.
|
||||
func (normalizer *RemoteRepeatNormalizer) Retire() []RemoteRepeatTransition {
|
||||
if normalizer == nil || normalizer.retired {
|
||||
return nil
|
||||
}
|
||||
normalizer.stopped = true
|
||||
normalizer.retired = true
|
||||
normalizer.pending = false
|
||||
out := make([]RemoteRepeatTransition, 0, len(normalizer.order))
|
||||
for index := len(normalizer.order) - 1; index >= 0; index-- {
|
||||
out = append(out, RemoteRepeatTransition{Keycode: normalizer.order[index]})
|
||||
}
|
||||
normalizer.held = [256]bool{}
|
||||
normalizer.order = normalizer.order[:0]
|
||||
return out
|
||||
}
|
||||
|
||||
func (normalizer *RemoteRepeatNormalizer) releasePending() RemoteRepeatTransition {
|
||||
code := normalizer.pendingCode
|
||||
normalizer.pending = false
|
||||
normalizer.forget(code)
|
||||
return RemoteRepeatTransition{Keycode: code}
|
||||
}
|
||||
|
||||
func (normalizer *RemoteRepeatNormalizer) forget(code uint8) {
|
||||
normalizer.held[code] = false
|
||||
for index, held := range normalizer.order {
|
||||
if held == code {
|
||||
copy(normalizer.order[index:], normalizer.order[index+1:])
|
||||
normalizer.order = normalizer.order[:len(normalizer.order)-1]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type repeatStep struct {
|
||||
code uint8
|
||||
pressed bool
|
||||
stamp uint32
|
||||
want []RemoteRepeatTransition
|
||||
}
|
||||
|
||||
func down(code uint8) RemoteRepeatTransition {
|
||||
return RemoteRepeatTransition{Keycode: code, Pressed: true}
|
||||
}
|
||||
func repeat(code uint8) RemoteRepeatTransition {
|
||||
return RemoteRepeatTransition{Keycode: code, Pressed: true, Repeat: true}
|
||||
}
|
||||
func up(code uint8) RemoteRepeatTransition { return RemoteRepeatTransition{Keycode: code} }
|
||||
|
||||
func newTestNormalizer(t *testing.T) *RemoteRepeatNormalizer {
|
||||
t.Helper()
|
||||
normalizer, err := NewRemoteRepeatNormalizer(RemoteRepeatReleaseDelay)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return normalizer
|
||||
}
|
||||
|
||||
func runRepeatSteps(t *testing.T, normalizer *RemoteRepeatNormalizer, now time.Time, steps []repeatStep) {
|
||||
t.Helper()
|
||||
for index, step := range steps {
|
||||
got, err := normalizer.Observe(Event{Keycode: step.code, Pressed: step.pressed, Timestamp: step.stamp}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("step %d: %v", index, err)
|
||||
}
|
||||
if len(got) == 0 && len(step.want) == 0 {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, step.want) {
|
||||
t.Fatalf("step %d: transitions = %+v, want %+v", index, got, step.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteRepeatNormalizesClassicPairs(t *testing.T) {
|
||||
normalizer := newTestNormalizer(t)
|
||||
now := time.Unix(100, 0)
|
||||
runRepeatSteps(t, normalizer, now, []repeatStep{
|
||||
{38, true, 100, []RemoteRepeatTransition{down(38)}},
|
||||
{38, false, 660, nil}, // Classic pair: release held.
|
||||
{38, true, 660, []RemoteRepeatTransition{repeat(38)}},
|
||||
{38, false, 700, nil},
|
||||
{38, true, 700, []RemoteRepeatTransition{repeat(38)}},
|
||||
{38, false, 750, nil}, // Real release: no matching press follows.
|
||||
{39, true, 760, []RemoteRepeatTransition{up(38), down(39)}},
|
||||
{39, false, 770, nil},
|
||||
{39, true, 780, []RemoteRepeatTransition{up(39), down(39)}}, // Different stamp is a fresh press.
|
||||
})
|
||||
if _, pending := normalizer.Deadline(); pending {
|
||||
t.Fatal("fresh press left a pending release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteRepeatPassesDetectableRepeat(t *testing.T) {
|
||||
normalizer := newTestNormalizer(t)
|
||||
runRepeatSteps(t, normalizer, time.Unix(1, 0), []repeatStep{
|
||||
{50, true, 10, []RemoteRepeatTransition{down(50)}},
|
||||
{50, true, 20, []RemoteRepeatTransition{repeat(50)}},
|
||||
{50, true, 30, []RemoteRepeatTransition{repeat(50)}},
|
||||
{38, true, 40, []RemoteRepeatTransition{down(38)}},
|
||||
{38, false, 0, []RemoteRepeatTransition{up(38)}}, // Unknown stamp releases immediately.
|
||||
{38, true, 0, []RemoteRepeatTransition{down(38)}},
|
||||
})
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteRepeatPendingReleaseFlushesAtDeadline(t *testing.T) {
|
||||
normalizer := newTestNormalizer(t)
|
||||
start := time.Unix(5, 0)
|
||||
runRepeatSteps(t, normalizer, start, []repeatStep{
|
||||
{38, true, 100, []RemoteRepeatTransition{down(38)}},
|
||||
{38, false, 200, nil},
|
||||
})
|
||||
deadline, pending := normalizer.Deadline()
|
||||
if !pending || !deadline.Equal(start.Add(RemoteRepeatReleaseDelay)) {
|
||||
t.Fatalf("deadline = %v %t", deadline, pending)
|
||||
}
|
||||
if got := normalizer.Flush(deadline.Add(-time.Nanosecond)); got != nil {
|
||||
t.Fatalf("early flush = %+v", got)
|
||||
}
|
||||
if got := normalizer.Flush(deadline); !reflect.DeepEqual(got, []RemoteRepeatTransition{up(38)}) {
|
||||
t.Fatalf("flush = %+v", got)
|
||||
}
|
||||
if got := normalizer.Flush(deadline.Add(time.Second)); got != nil {
|
||||
t.Fatalf("repeated flush = %+v", got)
|
||||
}
|
||||
// A press with the old stamp after the flush is a fresh, owned press.
|
||||
runRepeatSteps(t, normalizer, deadline, []repeatStep{
|
||||
{38, true, 200, []RemoteRepeatTransition{down(38)}},
|
||||
})
|
||||
if got := normalizer.Retire(); !reflect.DeepEqual(got, []RemoteRepeatTransition{up(38)}) {
|
||||
t.Fatalf("retire = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteRepeatSuppressesUnownedReleases(t *testing.T) {
|
||||
normalizer := newTestNormalizer(t)
|
||||
runRepeatSteps(t, normalizer, time.Unix(1, 0), []repeatStep{
|
||||
{64, false, 10, nil}, // Held before capture began.
|
||||
{64, false, 0, nil},
|
||||
{38, true, 20, []RemoteRepeatTransition{down(38)}},
|
||||
{38, false, 30, nil},
|
||||
{64, false, 31, []RemoteRepeatTransition{up(38)}}, // Pending release flushed; unowned up suppressed.
|
||||
})
|
||||
if _, pending := normalizer.Deadline(); pending {
|
||||
t.Fatal("unowned release became pending")
|
||||
}
|
||||
if got := normalizer.Retire(); len(got) != 0 {
|
||||
t.Fatalf("retire released unowned keys: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteRepeatRetireReleasesReverseOrderOnce(t *testing.T) {
|
||||
normalizer := newTestNormalizer(t)
|
||||
runRepeatSteps(t, normalizer, time.Unix(1, 0), []repeatStep{
|
||||
{50, true, 1, []RemoteRepeatTransition{down(50)}},
|
||||
{37, true, 2, []RemoteRepeatTransition{down(37)}},
|
||||
{38, true, 3, []RemoteRepeatTransition{down(38)}},
|
||||
{37, false, 4, nil}, // Pending release is still owned at retirement.
|
||||
})
|
||||
want := []RemoteRepeatTransition{up(38), up(37), up(50)}
|
||||
if got := normalizer.Retire(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("retire = %+v, want %+v", got, want)
|
||||
}
|
||||
if got := normalizer.Retire(); got != nil {
|
||||
t.Fatalf("second retire = %+v", got)
|
||||
}
|
||||
if _, pending := normalizer.Deadline(); pending {
|
||||
t.Fatal("retired normalizer kept a deadline")
|
||||
}
|
||||
if got := normalizer.Flush(time.Unix(100, 0)); got != nil {
|
||||
t.Fatalf("retired flush = %+v", got)
|
||||
}
|
||||
if _, err := normalizer.Observe(Event{Keycode: 38, Pressed: true, Timestamp: 9}, time.Unix(2, 0)); !errors.Is(err, ErrRemoteKeyState) {
|
||||
t.Fatalf("observe after retire = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteRepeatStopsOnInvalidOrExcessKeys(t *testing.T) {
|
||||
normalizer := newTestNormalizer(t)
|
||||
now := time.Unix(1, 0)
|
||||
runRepeatSteps(t, normalizer, now, []repeatStep{{38, true, 1, []RemoteRepeatTransition{down(38)}}})
|
||||
if _, err := normalizer.Observe(Event{Keycode: 7, Pressed: true, Timestamp: 2}, now); !errors.Is(err, ErrRemoteKeyUnavailable) {
|
||||
t.Fatalf("invalid keycode = %v", err)
|
||||
}
|
||||
if _, err := normalizer.Observe(Event{Keycode: 39, Pressed: true, Timestamp: 3}, now); !errors.Is(err, ErrRemoteKeyState) {
|
||||
t.Fatalf("stopped admission = %v", err)
|
||||
}
|
||||
if got := normalizer.Retire(); !reflect.DeepEqual(got, []RemoteRepeatTransition{up(38)}) {
|
||||
t.Fatalf("retire after invalid = %+v", got)
|
||||
}
|
||||
|
||||
normalizer = newTestNormalizer(t)
|
||||
for index := 0; index < RemoteHeldKeyLimit; index++ {
|
||||
code := uint8(remoteRepeatMinKeycode + index)
|
||||
runRepeatSteps(t, normalizer, now, []repeatStep{{code, true, uint32(index + 1), []RemoteRepeatTransition{down(code)}}})
|
||||
}
|
||||
// Repeats of owned keys remain admissible at the limit.
|
||||
runRepeatSteps(t, normalizer, now, []repeatStep{{8, true, 100, []RemoteRepeatTransition{repeat(8)}}})
|
||||
// A release followed by a different fresh key emits the release before the
|
||||
// limit is checked again, so the new key fits.
|
||||
runRepeatSteps(t, normalizer, now, []repeatStep{
|
||||
{8, false, 101, nil},
|
||||
{200, true, 102, []RemoteRepeatTransition{up(8), down(200)}},
|
||||
})
|
||||
if _, err := normalizer.Observe(Event{Keycode: 201, Pressed: true, Timestamp: 103}, now); !errors.Is(err, ErrRemoteKeyLimit) {
|
||||
t.Fatalf("overflow = %v", err)
|
||||
}
|
||||
got := normalizer.Retire()
|
||||
if len(got) != RemoteHeldKeyLimit || got[0] != up(200) || got[len(got)-1] != up(9) {
|
||||
t.Fatalf("retire after overflow = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteRepeatPendingDoesNotPairOtherKeysOrReleases(t *testing.T) {
|
||||
normalizer := newTestNormalizer(t)
|
||||
runRepeatSteps(t, normalizer, time.Unix(1, 0), []repeatStep{
|
||||
{38, true, 1, []RemoteRepeatTransition{down(38)}},
|
||||
{39, true, 2, []RemoteRepeatTransition{down(39)}},
|
||||
{38, false, 10, nil},
|
||||
{39, true, 10, []RemoteRepeatTransition{up(38), repeat(39)}}, // Same stamp, other key.
|
||||
{39, false, 11, nil},
|
||||
{39, false, 11, []RemoteRepeatTransition{up(39)}}, // Duplicate release is unowned once flushed.
|
||||
})
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteRepeatRejectsInvalidDelay(t *testing.T) {
|
||||
for _, delay := range []time.Duration{0, -time.Millisecond, RemoteRepeatReleaseDelayMax + time.Nanosecond} {
|
||||
if _, err := NewRemoteRepeatNormalizer(delay); !errors.Is(err, ErrRemoteRepeatDelay) {
|
||||
t.Fatalf("delay %v: %v", delay, err)
|
||||
}
|
||||
}
|
||||
var normalizer *RemoteRepeatNormalizer
|
||||
if _, err := normalizer.Observe(Event{Keycode: 38, Pressed: true}, time.Time{}); !errors.Is(err, ErrRemoteKeyState) {
|
||||
t.Fatalf("nil observe = %v", err)
|
||||
}
|
||||
if normalizer.Retire() != nil || normalizer.Flush(time.Time{}) != nil {
|
||||
t.Fatal("nil normalizer emitted transitions")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package xorg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
// A held XTEST key exercises the server's own autorepeat through the real
|
||||
// XRecord listener. The test records the raw shape and checks that the
|
||||
// quarantined normalizer yields one down, repeated downs and one final up.
|
||||
// It does not attach the normalizer to production capture or forwarding.
|
||||
// Run only through tests/native_remote_input.py.
|
||||
func TestNativeRemoteRepeatNormalizesHeldKey(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_REMOTE_INPUT") != "1" {
|
||||
t.Skip("requires private Remote input harness")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
if out, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v: %v %s", args, err, out)
|
||||
}
|
||||
}
|
||||
listener := New()
|
||||
if err := listener.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer stopCancel()
|
||||
if err := listener.Stop(stopCtx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
tick := time.NewTicker(20 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
ready:
|
||||
for {
|
||||
select {
|
||||
case <-tick.C:
|
||||
run("xdotool", "key", "F12")
|
||||
case event := <-listener.Events():
|
||||
if event.Name == "F12" && !event.Pressed {
|
||||
break ready
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("XRecord did not observe readiness marker")
|
||||
}
|
||||
}
|
||||
tick.Stop()
|
||||
// Server autorepeat is a private Xvfb setting here, never the desktop's.
|
||||
// Set it while the listener holds a connection: Xvfb resets defaults
|
||||
// when its last client disconnects.
|
||||
run("xset", "r", "on", "r", "rate", "200", "40")
|
||||
|
||||
normalizer, err := input.NewRemoteRepeatNormalizer(input.RemoteRepeatReleaseDelay)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("xdotool", "keydown", "a")
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
run("xdotool", "keyup", "a")
|
||||
|
||||
var rawDowns, rawUps, classicPairs int
|
||||
var lastUpStamp uint32
|
||||
var normalized []input.RemoteRepeatTransition
|
||||
timer := time.NewTimer(time.Second)
|
||||
defer timer.Stop()
|
||||
flush := time.NewTimer(time.Hour)
|
||||
defer flush.Stop()
|
||||
armFlush := func() {
|
||||
if deadline, pending := normalizer.Deadline(); pending {
|
||||
flush.Reset(time.Until(deadline))
|
||||
}
|
||||
}
|
||||
collect:
|
||||
for {
|
||||
select {
|
||||
case event := <-listener.Events():
|
||||
if event.Name != "a" {
|
||||
continue
|
||||
}
|
||||
if event.Pressed {
|
||||
rawDowns++
|
||||
if event.Timestamp != 0 && event.Timestamp == lastUpStamp {
|
||||
classicPairs++
|
||||
}
|
||||
} else {
|
||||
rawUps++
|
||||
lastUpStamp = event.Timestamp
|
||||
}
|
||||
got, err := normalizer.Observe(event, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalized = append(normalized, got...)
|
||||
armFlush()
|
||||
case now := <-flush.C:
|
||||
normalized = append(normalized, normalizer.Flush(now)...)
|
||||
armFlush()
|
||||
case <-timer.C:
|
||||
break collect
|
||||
case <-ctx.Done():
|
||||
t.Fatal("held key events missing")
|
||||
}
|
||||
}
|
||||
normalized = append(normalized, normalizer.Flush(time.Now().Add(time.Hour))...)
|
||||
if rawDowns < 6 {
|
||||
t.Fatalf("server produced %d downs for a held key; autorepeat did not run", rawDowns)
|
||||
}
|
||||
if len(normalized) < 3 || normalized[0] != (input.RemoteRepeatTransition{Keycode: normalized[0].Keycode, Pressed: true}) {
|
||||
t.Fatalf("normalized = %+v", normalized)
|
||||
}
|
||||
last := normalized[len(normalized)-1]
|
||||
if last.Pressed || last.Keycode != normalized[0].Keycode {
|
||||
t.Fatalf("final normalized transition = %+v", last)
|
||||
}
|
||||
for index, transition := range normalized[1 : len(normalized)-1] {
|
||||
if !transition.Pressed || !transition.Repeat {
|
||||
t.Fatalf("intermediate transition %d = %+v; normalized = %+v", index+1, transition, normalized)
|
||||
}
|
||||
}
|
||||
if retired := normalizer.Retire(); len(retired) != 0 {
|
||||
t.Fatalf("released key remained owned: %+v", retired)
|
||||
}
|
||||
if listener.Overflow() != 0 {
|
||||
t.Fatal("unexpected observer overflow")
|
||||
}
|
||||
t.Logf("raw downs=%d ups=%d classic pairs=%d; normalized %d transitions with one final up",
|
||||
rawDowns, rawUps, classicPairs, len(normalized))
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import "strings"
|
||||
|
||||
const Name = "Cthulhu"
|
||||
|
||||
const Version = "2026.11.05"
|
||||
const Version = "2026.11.06"
|
||||
const Channel = "testing"
|
||||
|
||||
var Build = Version
|
||||
|
||||
@@ -395,7 +395,7 @@ def main():
|
||||
# paths as well as the standalone recovery experiment.
|
||||
subprocess.run([
|
||||
"go", "test", "-race", "./internal/platform/xorg", "-count=1", "-timeout=60s",
|
||||
"-run", "^TestNative(InternalUICapturesAndReleases|LearnModeKeepsKeysFromFocusedApplication|RemoteInjectedKeyObservation|LocalUIRejectsInjectedApproval|SuspendedQueuedKey|XTermHandoffKeepsKeypadReleased)$",
|
||||
"-run", "^TestNative(InternalUICapturesAndReleases|LearnModeKeepsKeysFromFocusedApplication|RemoteInjectedKeyObservation|RemoteRepeatNormalizesHeldKey|LocalUIRejectsInjectedApproval|SuspendedQueuedKey|XTermHandoffKeepsKeypadReleased)$",
|
||||
"-v"], cwd=Path(__file__).resolve().parents[1], check=True,
|
||||
env=dict(os.environ, CTHULHU_NATIVE_X11="1", CTHULHU_NATIVE_REMOTE_INPUT="1"))
|
||||
else:
|
||||
|
||||
Reference in new issue
Block a user