Skip to content

Fix ui send-keys --via send-input silently dropping characters on long text (#657)#667

Merged
nmetulev merged 8 commits into
mainfrom
nmetulev-fix-send-input-drops-characters
Jul 17, 2026
Merged

Fix ui send-keys --via send-input silently dropping characters on long text (#657)#667
nmetulev merged 8 commits into
mainfrom
nmetulev-fix-send-input-drops-characters

Conversation

@nmetulev

Copy link
Copy Markdown
Member

Problem

winapp ui send-keys --via send-input built one SendInput array for the entire payload and injected it in a single call. Long text (e.g. "X" * 1000) expands to thousands of key events; injected all at once it overruns the target thread's input queue, which silently drops the excess. Because SendInput still returns the full count, the command reported success (✅ Sent 1 key action(s), exit 0) while only part of the text landed (issue #657 reported ~434 of 1000).

Fix (fully automatic — no new CLI flags)

Chunk + throttle the send-input injection:

  • Flatten the payload into small self-contained segments — each key chord stays atomic (one segment); literal text is split into fixed-size character chunks.
  • Inject one SendInput per segment, with a brief Thread.Sleep between segments so the target can pump and drain its input queue before the next burst.
  • Sleeping only between segments means short inputs (a chord, a word, a shortcut) add zero latency — a single segment sends exactly as before.
  • Each segment is balanced (its key-downs/key-ups are contained), so a short write can only strand its own keys — release just that segment and surface the same precise error messages as before.

Defaults (16 chars / 15 ms) were tuned empirically and chosen for a comfortable queue-drain margin that's robust across both coarse (~15.6 ms) and fine (~1 ms) Windows timer resolutions.

Verification

  • Deterministic Win32 EDIT target: un-throttled dropped 3000 → 2500 chars (4/4 runs); throttled delivered 3000/3000 (8/8 runs).
  • Real Windows 11 Notepad at the issue's exact case: 1000/1000 (5/5 runs).
  • Unit tests: 6 new tests in KeyboardInputTests.cs cover segmentation, chord atomicity, sleep-between-not-after, no-latency-for-short-text, and per-segment release on short write. Existing keyboard tests unchanged and green (19/19). Full suite passes.
  • No change to cli-schema.json (no new surface); docs skill fragment updated and regenerated.

Docs

Added a note to the ui-automation skill fragment that long send-input text is auto-throttled for reliable delivery, and that set-value remains preferred for large bulk text (regenerated .github/plugin + .claude skills follow).

Closes #657.

`ui send-keys --via send-input` injected the entire payload in one
SendInput call. Long text (e.g. 1000+ chars) overran the target
thread's input queue, which silently drops events even though
SendInput returns the full count — so the command reported success
while only part of the text landed.

Split the payload into small self-contained segments (each chord stays
atomic; literal text is chunked) and inject one SendInput per segment,
pausing briefly between segments so the target can drain its queue.
Sleeping only *between* segments means short inputs (a chord or a word)
add zero latency. Each segment is balanced, so a short write can only
strand its own keys — release just that segment and surface the same
precise error. Defaults (16 chars / 15 ms) were tuned empirically:
un-throttled dropped 3000→2500 chars on a deterministic Win32 EDIT,
while throttled delivered 3000/3000; real Notepad lands 1000/1000.

No new CLI flags — throttling is fully automatic.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 16, 2026 21:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds chunked, throttled SendInput delivery to prevent long text payloads from dropping characters.

Changes:

  • Splits text into paced segments while keeping chords atomic.
  • Adds segmentation, throttling, and failure-path tests.
  • Documents automatic throttling and regenerates skill files.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
KeyboardInput.cs Implements segmented, throttled input.
KeyboardInputTests.cs Tests chunking and failure handling.
ui-automation.md Documents throttling behavior.
.github/.../SKILL.md Regenerates Copilot skill documentation.
.claude/.../SKILL.md Synchronizes Claude skill documentation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/winapp-CLI/WinApp.Cli/Helpers/KeyboardInput.cs Outdated
Comment thread docs/fragments/skills/winapp-cli/ui-automation.md Outdated
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Build Metrics Report

Binary Sizes

Artifact Baseline Current Delta
CLI (ARM64) 32.42 MB 32.43 MB 📈 +12.0 KB (+0.04%)
CLI (x64) 32.72 MB 32.73 MB 📈 +12.0 KB (+0.04%)
MSIX (ARM64) 13.57 MB 13.58 MB 📈 +1.5 KB (+0.01%)
MSIX (x64) 14.43 MB 14.43 MB 📈 +1.4 KB (+0.01%)
NPM Package 28.33 MB 28.34 MB 📈 +13.8 KB (+0.05%)
NuGet Package 28.35 MB 28.36 MB 📈 +10.4 KB (+0.04%)

Test Results

3582 passed, 4 skipped out of 3586 tests in 561.4s (+27 tests, -65.3s vs. baseline)

Test Coverage

95.2% line coverage, 89.3% branch coverage · ✅ no change vs. baseline

CLI Startup Time

47ms median (x64, winapp --version) · ✅ no change vs. baseline


Updated 2026-07-17 18:11:30 UTC · commit e0b5f5f · workflow run

@zateutsch zateutsch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

M1 might be worth a follow up.

PR Review — nmetulev-fix-send-input-drops-characters vs origin/main (1 commit, 5 files, +248/-31 lines)

Summary
Critical: 0 High: 0 Medium: 2 Low: 1

Coverage
security ⚠ 1 finding
correctness ⚠ 1 finding
cli-ux ⚠ 1 finding (merged into M2)
alternative-solution ⚠ 1 finding
necessity-simplicity n/a (no new surface — internal bug fix)
test-coverage ✓ clean
docs-and-samples ✓ clean
packaging ✓ clean
multi-model ⚠ 1 new (High, disputed) · 4/4 specialist findings reconciled · model: gemini-3.1-pro (self-reported "gpt")
regression ⚠ 1 behavior change (loss of single-call atomicity — deliberate tradeoff)
validation ✓ build clean (0 warnings) · 19/19 KeyboardInput tests pass · L1 & message-logic confirmed statically; wrong-window delivery static-only

Findings
M1 src/winapp-CLI/WinApp.Cli/Helpers/KeyboardInput.cs:201-232 security,correctness Paced multi-segment injection drops SendInput atomicity; foreground checked once → focus steal or physical input mid-stream can misdirect/corrupt remaining text
M2 src/winapp-CLI/WinApp.Cli/Helpers/KeyboardInput.cs:224-249 correctness,cli-ux Failure after earlier chunks reports "refused"/"retry" without noting input was already partially applied → misleading, can duplicate text
L1 src/winapp-CLI/WinApp.Cli/Helpers/KeyboardInput.cs:300-321 alternative-solution BuildSendInputBatch is now production-dead, retained only for its own tests

Details

M1 src/winapp-CLI/WinApp.Cli/Helpers/KeyboardInput.cs:201-232

  • Severity: medium
  • Confidence: medium
  • Validation: static-only (needs runtime confirmation) — single foreground check confirmed in code; wrong-window delivery not reproduced
  • Domain: security, correctness
  • Multi-model: upgrade (Gemini cross-check argues High)
  • Finding: The fix trades one atomic SendInput for N paced SendInput calls with 15 ms sleeps between them. Windows guarantees no physical input interleaves within one SendInput call, but not across calls. During a long payload (~1000 chars/sec) a focus steal (notification, popup, accidental click) or a physical keypress can land remaining chunks in the wrong window or corrupt the stream (e.g. a real Shift/Ctrl held mid-stream). UiSendKeysCommand validates the target foreground only once (UiSendKeysCommand.cs:206) before KeyboardInput.Send.
  • Evidence: SendViaSendInput loops s_sendInput(segment) then s_sleep(s_chunkDelayMs) between segments (KeyboardInput.cs:217-231) with no re-check of the foreground window; foregroundGuard.TryEnsureForeground(...) is called exactly once at UiSendKeysCommand.cs:206.
  • Note: This is a deliberate tradeoff — the prior single burst silently dropped characters (#657), which is worse. The exposure is real but low-probability.
  • Recommendation: Re-assert the target is still foreground before each post-sleep segment (abort with the existing foreground_not_target error on change). That closes the sensitive-text-to-wrong-window case at low cost.

M2 src/winapp-CLI/WinApp.Cli/Helpers/KeyboardInput.cs:224-249

  • Severity: medium
  • Confidence: high
  • Validation: validated (message logic confirmed by reading BuildSendInputFailure); user-visible duplication is static-only
  • Domain: correctness, cli-ux
  • Multi-model: confirmed
  • Finding: On a failure in a later segment, BuildSendInputFailure(sent, segment.Length) only sees the current segment. A zero-write after earlier chunks succeeded emits "SendInput failed — no interactive desktop / UIPI…" (implying nothing was typed), and the short-write path says "retry the gesture" — yet earlier chunks already landed, so a literal retry duplicates text (e.g. "passpass…word").
  • Evidence: The throw at KeyboardInput.cs:227 has no knowledge of i > 0; BuildSendInputFailure (238-249) branches solely on the current segment's sent.
  • Recommendation: Track whether any prior segment was delivered; when i > 0, prepend "some input was already applied — verify/clear the target before retrying" to both the zero- and short-write messages.

L1 src/winapp-CLI/WinApp.Cli/Helpers/KeyboardInput.cs:300-321

  • Severity: low
  • Confidence: high
  • Validation: validated — grep confirms no production caller
  • Domain: alternative-solution
  • Multi-model: confirmed
  • Finding: BuildSendInputBatch is no longer called in production (SendViaSendInput uses BuildSendInputSegments); only two KeyboardInputTests methods and one doc-cref reference it. It's dead production code kept alive by tests that only test itself.
  • Evidence: rg BuildSendInputBatch → definition + XML-doc cref + KeyboardInputTests.cs:43,67 only. Build produced 0 warnings, so the compiler doesn't flag it.
  • Recommendation: Remove BuildSendInputBatch and port its two tests to assert over the flattened output of BuildSendInputSegments (a single-chord input yields one segment; text yields chunk segments).

Multi-model independent finding (disputed)
Gemini raised a High: "loss of SendInput atomicity allows physical input interleaving." Root cause is the same as M1. I keep it at Medium, not High: the pre-fix single-call path already lost characters to queue overrun, so the fix is a net improvement, and the interleave window requires the user to actively type/steal focus during automation. Surfaced under M1; the per-segment foreground re-check is the concrete mitigation. Note: the cross-check self-reported "gpt (gpt-4o)" but was dispatched on gemini-3.1-pro-preview — model self-reports are unreliable.

Coverage notes
test-coverage: New tests map to every new branch (chunk split, sleep-between-only, chord atomicity, short-write releases only the stranded segment, short-text no-sleep). sent==0 path covered by pre-existing Send_SendInput_ZeroWriteReportsPreciseReason. Reset restores s_textChunkChars/s_chunkDelayMs. Only untested gap: empty-string TextInput (harmless). Clean.
docs-and-samples: The one throttling bullet is byte-identical across the hand-written fragment and both auto-generated SKILL.md mirrors; docs/ui-automation.md, usage.md, winapp.agent.md already cover send-input + set-value guidance. No schema change (no Commands/ edit). Clean.
packaging: No Commands/, npm wrapper, csproj, NuGet, manifest, or version.json change; generated skills in sync; no Release warnings. Clean.
validation: Built src\winapp-CLI\WinApp.Cli Debug → succeeded, 0 warnings. Ran WinApp.Cli.Tests → 19/19 pass. Could not stage a runtime focus-steal to reproduce M1's wrong-window delivery (needs a second app + timed focus change).

nmetulev and others added 7 commits July 16, 2026 16:26
…#657)

Follow-up to PR #667 (pr-review). Addresses one HIGH plus four polish items on
the issue #657 chunked/throttled send-input fix:

- [H1] Re-verify the target owns the foreground (GA_ROOT) before EACH paced
  SendInput segment and abort on drift, so a focus change mid-injection cannot
  spray the remaining keystrokes into whatever window took focus. Surfaced as
  ForegroundLostException and mapped by the command to the existing
  foreground_not_target error code. No releases are injected on drift: every
  completed segment is self-balanced (nothing held), and any SendInput after
  drift would land in the wrong window -- the exact hazard being prevented. The
  short-write path still releases its one in-flight segment.
- [M1] Emit an informational warning when a send-input payload exceeds one
  chunk, noting the throttling is intentional and that 'ui set-value' is faster
  for bulk text.
- [L1] Consolidate the test-only BuildSendInputBatch onto BuildSendInputSegments
  so one builder owns the action->INPUT contract.
- [M2] Pin the shipping throttle defaults (16 chars / 15 ms) behaviorally so a
  future default change cannot silently reintroduce #657.
- [M3] Document the per-chunk foreground re-check and auto-throttle behavior in
  docs/ui-automation.md.

Tests: +3 helper tests (focus-drift abort, held-throughout happy path, default
pinning) and +3 command tests (foreground_not_target mapping, throttle warning,
warning boundary). Full suite green (3329 passed, 0 failed).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Address two copilot-pull-request-reviewer threads on PR #667: the
inter-segment pacing delay and the mid-injection foreground re-check
were applied between EVERY SendInput segment, so a short chord sequence
like `ctrl+a delete` gained an inter-chord pause and a chord that
intentionally moves focus (alt+tab, win+d) could be misread as drift and
abort the send.

Tag each SendInput segment with ThrottleBefore and pace/re-check only the
2nd..Nth chunk of a single split literal-text run -- the sole place the
issue #657 queue overrun occurs. Chords and the first chunk of any text
run now inject immediately: no added latency for short input, and a
focus-changing chord is never treated as foreground drift. The H1
wrong-window guard is preserved for the long-text scenario it targets,
and the sleep->recheck->inject ordering tightens the TOCTOU window.

- KeyboardInput: add SendInputSegment(Events, ThrottleBefore); gate delay
  and foreground re-check on ThrottleBefore; BuildSendInputBatch flattens
  .Events.
- Tests: assert ThrottleBefore tagging; chord->text boundary adds no
  sleep; drift test re-checks only continuation chunks.
- Docs: fragment + ui-automation.md clarify pacing is scoped to one long
  run (chord sequences add no delay) and note the per-continuation-chunk
  foreground_not_target abort with the chord exemption. Regenerated
  plugin + Claude ui-automation skills.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Address zateutsch's PR #667 review: after send-input was split into
paced chunks, a failure in a LATER segment built its message only from
the current segment. A zero write after earlier chunks already landed
emitted the "no interactive desktop / UIPI" wording (implying nothing
was typed), and the short-write path said "retry the gesture" — but a
literal retry re-types the text that already went in (e.g. a password
becomes "passpass...word").

Track whether any earlier segment was delivered and thread it into
BuildSendInputFailure. When prior input landed, lead both the zero-write
and short-write messages with a caveat to verify or clear the target
before retrying, and drop the bare "retry the gesture" hint that would
invite duplication. Injection path is unchanged — this is a message/UX
correctness fix.

Tests: extend the later-chunk short-write test to assert the caveat and
the dropped retry hint; add a zero-write-after-earlier-chunk test; pin
the scoping with negative assertions on first-segment failures.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Resolves conflicts after #664/#665/#666/#668 merged to main.

- KeyboardInput.cs: keep #657 chunked/throttled SendViaSendInput +
  per-segment foreground re-check + partial-apply messaging, and apply
  #664 NormalizeNewlines to the chunking path (normalize once, then chunk).
- KeyboardInputTests.cs: keep both #657 segment/throttle/partial-apply
  tests and #664 newline tests.
- UiSendKeysCommand.cs: keep #657 throttle warning; deliver via #666
  effectiveHwnd (post-message focused-child retarget; equals target for
  send-input).
- ui-automation skill fragment + generated SKILL.md (.github + .claude):
  keep #666 focused-child/windowless post-message note and the #657
  auto-throttle / foreground-recheck / set-value-for-bulk note.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@nmetulev
nmetulev merged commit 075472e into main Jul 17, 2026
22 checks passed
@nmetulev
nmetulev deleted the nmetulev-fix-send-input-drops-characters branch July 17, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: winapp ui send-keys --via send-input silently drops characters when sending long text

3 participants