From 6b3aa801386d9e38fbec9fa9737fba9e79bda2df Mon Sep 17 00:00:00 2001 From: Chase Cichorz Date: Fri, 6 Nov 2026 14:30:43 -0500 Subject: [PATCH] Normalize captured autorepeat for quarantined Remote --- docs/testing/remote-input-feasibility.md | 34 ++- internal/input/remote_repeat.go | 167 ++++++++++++++ internal/input/remote_repeat_test.go | 218 ++++++++++++++++++ .../xorg/remote_repeat_native_test.go | 141 +++++++++++ internal/version/version.go | 2 +- tests/native_remote_input.py | 2 +- 6 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 internal/input/remote_repeat.go create mode 100644 internal/input/remote_repeat_test.go create mode 100644 internal/platform/xorg/remote_repeat_native_test.go diff --git a/docs/testing/remote-input-feasibility.md b/docs/testing/remote-input-feasibility.md index f870e94..b377dcf 100644 --- a/docs/testing/remote-input-feasibility.md +++ b/docs/testing/remote-input-feasibility.md @@ -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 diff --git a/internal/input/remote_repeat.go b/internal/input/remote_repeat.go new file mode 100644 index 0000000..3201937 --- /dev/null +++ b/internal/input/remote_repeat.go @@ -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 + } + } +} diff --git a/internal/input/remote_repeat_test.go b/internal/input/remote_repeat_test.go new file mode 100644 index 0000000..9b674dc --- /dev/null +++ b/internal/input/remote_repeat_test.go @@ -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") + } +} diff --git a/internal/platform/xorg/remote_repeat_native_test.go b/internal/platform/xorg/remote_repeat_native_test.go new file mode 100644 index 0000000..87f2ff6 --- /dev/null +++ b/internal/platform/xorg/remote_repeat_native_test.go @@ -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)) +} diff --git a/internal/version/version.go b/internal/version/version.go index bfc9f7f..570c111 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -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 diff --git a/tests/native_remote_input.py b/tests/native_remote_input.py index 69a8c4f..3d06471 100644 --- a/tests/native_remote_input.py +++ b/tests/native_remote_input.py @@ -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: