From 8a58f77ee6e406345d303afb1001e7cb2266cdd0 Mon Sep 17 00:00:00 2001 From: skulitom Date: Tue, 6 Oct 2026 23:25:39 +0100 Subject: [PATCH 1/3] Answer typing_stopped with the count when Windows refuses input partway TextTyping.Type called typist.Send with no catch. InputInjector.Send throws when the seat's input is blocked or SendInput accepts fewer events than it was given, for example once a typed Enter opened a UAC prompt or an elevated window. The exception left Type, and the pipe handler turned it into a bare "{type}: {message}" reply with no typed, total or nextIndex, so an agent could not tell how much of the text had reached the field before it retried. A refusal now ends the call like the other stops, with the exact count, errorCode typing_stopped and partlySent: true, since the refused character (an Enter pressed but not released, say) may have arrived in part. Other exceptions still surface as before. The docs no longer say a stopping seat ends typing with typing_stopped (B-020): the daemon's shutdown waits behind the running input.text on the shared seat connection, so the caller sees the connection close, and the sign-out closes the program anyway. Reported in Compass's Anode inbox as B-021 and B-020 (the second review of #18). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- docs/PROTOCOL.md | 7 +++--- docs/TROUBLESHOOTING.md | 10 +++++--- src/Anode/Cli/InputChecks.cs | 38 +++++++++++++++++++++++++++++- src/Anode/Core/Input/TextTyping.cs | 22 +++++++++++++---- src/Anode/Seat/SeatHost.cs | 3 ++- 6 files changed, 69 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91dfedd..35124ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ them while the call still reported success. Characters now go out 15 ms apart by default, timed precisely, and typing pauses while the focused program is busy; `perCharMs` sets the pace. Text that can't be typed within the request's time is refused before typing, and a program that stops - responding, a slow one that runs out the request's time, or a stopping seat ends the call with the - exact count typed. + responding, a slow one that runs out the request's time, or input Windows refuses partway, for + example once a typed Enter opens a UAC prompt, ends the call with the exact count typed. - **The sign-in window explains itself.** `anode browser --sign-in` opened a blank window when given no address, with its guidance in the terminal behind it. Its first tab now says what the window is for and that it must be closed afterwards, and up to 8 addresses open in tabs of their own. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 7565b52..e441e17 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -252,9 +252,10 @@ schema, so its row lists every argument it accepts. keyboard focus is busy, and answers with `typed`, `perCharMs` and `elapsedMs`. Text that can't be typed in 45 seconds at that pace (three quarters of a shorter `timeoutMs`) is refused before anything is typed. If the program stops responding for 5 seconds, typing would pass 52 seconds (seven eighths of a -shorter `timeoutMs`), or the request is cancelled, it fails with `errorCode` `typing_stopped`, `typed` -and `total` (a newline or a surrogate pair counts as one character) and `nextIndex`, the UTF-16 index -where the untyped rest starts. +shorter `timeoutMs`), the request is cancelled, or Windows refuses the input partway, it fails with +`errorCode` `typing_stopped`, `typed` and `total` (a newline or a surrogate pair counts as one +character) and `nextIndex`, the UTF-16 index where the untyped rest starts. After a refusal, +`partlySent` is `true`: the character at `nextIndex` may have been partly sent. Buttons: `left`, `right`, `middle`, `x1`, `x2`. Key names: letters, digits, `f1`–`f24`, `num0`–`num9`, `enter`, `esc`, `space`, `tab`, `backspace`, `del`, `ins`, `home`, `end`, `pgup`, `pgdn`, arrows, diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2fe4ea6..9f4a7db 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -441,9 +441,13 @@ If text still arrives wrong: - **Skip the keyboard.** For a form field, `seat_element` `set_value` on the observed control sets the whole value at once. - **`typing_stopped`.** The focused program stopped responding for 5 seconds, typing ran out of the - call's time, or the seat stopped. The error gives `typed` and `total` (a newline or an emoji counts as one character) - and `nextIndex`, the UTF-16 index where the untyped rest starts. Check the field before you send the - rest; Anode never sends it again by itself. + call's time, or Windows refused the input partway, for example once a typed Enter opened a UAC prompt + or an elevated window. The error gives `typed` and `total` (a newline or an emoji counts as one character) + and `nextIndex`, the UTF-16 index where the untyped rest starts. After a refusal, `partlySent` is + `true`: the character at `nextIndex` may have arrived in part, such as an Enter pressed but not + released. Check the field before you send the rest; Anode never sends it again by itself. A seat that + stops during a call ends it with a lost connection instead: stopping signs the seat out, which closes + the program you were typing into. ### A display change fails or looks wrong diff --git a/src/Anode/Cli/InputChecks.cs b/src/Anode/Cli/InputChecks.cs index f0a9190..bcea211 100644 --- a/src/Anode/Cli/InputChecks.cs +++ b/src/Anode/Cli/InputChecks.cs @@ -149,10 +149,46 @@ public static string Pacing() "typing went on after its request was cancelled: " + halted.ToJsonString()); } + // Windows refusing the input partway, as it does once a prompt or an elevated window takes the focus, still + // ends the call with the count, and says the refused character may have been partly sent. + Require(stopped["partlySent"] is null, "a stop before a character went out said it may have been partly sent: " + stopped.ToJsonString()); + foreach (Exception refusal in new Exception[] + { + new InvalidOperationException("The foreground helper is blocking input in this seat."), + new System.ComponentModel.Win32Exception(5, "SendInput accepted 0 of 2 events (Win32 error 5: Access is denied.)") + }) + { + var blocked = new Recorder(); + var typist = blocked.Typist(_ => true); + var partway = TextTyping.Type(Sample, null, new TextTyping.Typist + { + Send = unit => { if (blocked.Sent.Count == 3) throw refusal; typist.Send(unit); }, + Responsive = typist.Responsive, Wait = typist.Wait, Now = typist.Now + }); + string why = partway.Str("error") ?? ""; + Require(partway.Bool("ok") == false && partway.Str("errorCode") == "typing_stopped" && partway.Int("typed") == 3 + && partway.Int("total") == 7 && partway.Int("nextIndex") == 4 && partway.Bool("partlySent") == true && blocked.Sent.Count == 3 + && why.Contains(refusal.Message.TrimEnd('.'), StringComparison.Ordinal) + && why.Contains("Character 4 may have been partly sent; nothing after it was.", StringComparison.Ordinal), + $"input Windows refused partway ({refusal.GetType().Name}) did not end typing with the exact count: " + partway.ToJsonString()); + } + // A fault in Anode itself still fails loudly instead of passing for a refusal. + var faulty = new Recorder().Typist(_ => true); + bool surfaced = false; + try + { + TextTyping.Type("ab", null, new TextTyping.Typist + { + Send = _ => throw new ArgumentException("not a refusal"), Responsive = faulty.Responsive, Wait = faulty.Wait, Now = faulty.Now + }); + } + catch (ArgumentException) { surfaced = true; } + Require(surfaced, "typing turned a fault in Anode's own code into a typing_stopped reply"); + var refused = new Recorder(); Require(TextTyping.Type(new string('x', 3002), null, refused.Typist(_ => true)).Bool("ok") == false && refused.Sent.Count == 0, "text too long for one call was partly typed before being refused"); - return "15 ms apart by default, pauses for a busy program, stops with an exact count for a hung one, a deadline or a cancel, keeps Enter, Tab and emoji whole"; + return "15 ms apart by default, pauses for a busy program, stops with an exact count for a hung one, a deadline, a cancel or input Windows refuses partway, keeps Enter, Tab and emoji whole"; } public static string Limits() diff --git a/src/Anode/Core/Input/TextTyping.cs b/src/Anode/Core/Input/TextTyping.cs index 2301561..5a36a37 100644 --- a/src/Anode/Core/Input/TextTyping.cs +++ b/src/Anode/Core/Input/TextTyping.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Globalization; using System.Runtime.InteropServices; using System.Text.Json.Nodes; @@ -143,7 +144,13 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti return Stopped(units, i, cancelled); if (typist.Now() - start + (units[i].Key is null ? 0 : KeyHoldMs) > stop) return Stopped(units, i, ranOut); previous = typist.Now(); - typist.Send(units[i]); + try { typist.Send(units[i]); } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) + { + // Windows refused the input, for example once an Enter typed earlier opened a prompt or an elevated + // window. The count still matters: the caller mustn't send again what already arrived. + return Stopped(units, i, $"Windows refused the input ({ex.Message.TrimEnd('.')})", partlySent: true); + } } double elapsed = typist.Now() - start; string how = pace > 0 ? $"{pace} ms apart" : "without a pause"; @@ -159,16 +166,21 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti /// /// Ends a call partway, saying exactly how far it got. The reply gives counts only and never repeats the text, - /// which may be a secret. + /// which may be a secret. means Windows refused character + /// partway through sending it, so part of it, such as the press of an Enter, may have arrived. /// - private static JsonObject Stopped(List units, int typed, string reason) + private static JsonObject Stopped(List units, int typed, string reason, bool partlySent = false) { - var failure = JsonLine.Fail($"Typed the first {typed} of {units.Count} characters, then stopped: {reason}. Nothing after " - + $"character {typed} was sent. Check what reached the field before you send the rest (from UTF-16 index {units[typed].Index})."); + string sent = partlySent + ? $"Character {typed + 1} may have been partly sent; nothing after it was." + : $"Nothing after character {typed} was sent."; + var failure = JsonLine.Fail($"Typed the first {typed} of {units.Count} characters, then stopped: {reason}. {sent} " + + $"Check what reached the field before you send the rest (from UTF-16 index {units[typed].Index})."); failure["errorCode"] = "typing_stopped"; failure["typed"] = typed; failure["total"] = units.Count; failure["nextIndex"] = units[typed].Index; + if (partlySent) failure["partlySent"] = true; return failure; } diff --git a/src/Anode/Seat/SeatHost.cs b/src/Anode/Seat/SeatHost.cs index d751d01..f2fe29f 100644 --- a/src/Anode/Seat/SeatHost.cs +++ b/src/Anode/Seat/SeatHost.cs @@ -177,7 +177,8 @@ private static async Task HandleOwnedAsync(JsonObject request, Cance bool changesUi = op.StartsWith("input.", StringComparison.Ordinal) || op is "run" or "steam.launch" or "ps.kill" or "android.studio" or "browser.open"; if (!desktop && !changesUi) return Dispatch(op, request); if (changesUi) Desktop.InvalidateObservations(); - // Typing can take most of a request's time, so it stops for the lease's deadline and for a stopping seat. + // Typing can take most of a request's time, so it stops at the lease's deadline, or at a shutdown sent on + // another connection. The daemon's own shutdown waits behind it, and the sign-out that follows ends it. if (op == "input.text") return InputInjector.TypeText(request.Str("text") ?? throw new ArgumentException("text needs 'text'."), request.Int("perCharMs"), request.Int("timeoutMs") ?? 60_000, cancel); From 371437c50701706b2048d10d680503ae538085d5 Mon Sep 17 00:00:00 2001 From: skulitom Date: Tue, 6 Oct 2026 23:55:55 +0100 Subject: [PATCH 2/3] Report partlySent only when part of the refused character went out Review follow-up. Typing catches only TextTyping.Refused now, which the typing path in InputInjector throws: TypingStep sends one step, and a block or a short SendInput count becomes Refused, with PartlySent when an earlier part of the step (an Enter's press) or some of its events went out. Any other exception, ObjectDisposedException included, still surfaces as a fault. The docs no longer name an elevated window as a refusal: Windows drops input an elevated window doesn't take without reporting it, which TROUBLESHOOTING and PROTOCOL now say. A seat that stops during a call may answer with a lost connection or typing_stopped; either way the program is closed. The cancel reason names the request's time, which is what cancels typing in practice. The check covers refusals at the first character, at an ordinary one and at a key whose release was refused, and that the reply never repeats the text. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +-- docs/PROTOCOL.md | 6 ++-- docs/TROUBLESHOOTING.md | 17 +++++---- src/Anode/Cli/InputChecks.cs | 50 +++++++++++++++------------ src/Anode/Core/Input/InputInjector.cs | 31 ++++++++++++++--- src/Anode/Core/Input/TextTyping.cs | 26 +++++++++----- src/Anode/Seat/SeatHost.cs | 2 +- 7 files changed, 87 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35124ae..15b5211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ them while the call still reported success. Characters now go out 15 ms apart by default, timed precisely, and typing pauses while the focused program is busy; `perCharMs` sets the pace. Text that can't be typed within the request's time is refused before typing, and a program that stops - responding, a slow one that runs out the request's time, or input Windows refuses partway, for - example once a typed Enter opens a UAC prompt, ends the call with the exact count typed. + responding, a slow one that runs out the request's time, or input Windows refuses partway ends the + call with the exact count typed. - **The sign-in window explains itself.** `anode browser --sign-in` opened a blank window when given no address, with its guidance in the terminal behind it. Its first tab now says what the window is for and that it must be closed afterwards, and up to 8 addresses open in tabs of their own. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index e441e17..8b91586 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -254,8 +254,10 @@ in 45 seconds at that pace (three quarters of a shorter `timeoutMs`) is refused typed. If the program stops responding for 5 seconds, typing would pass 52 seconds (seven eighths of a shorter `timeoutMs`), the request is cancelled, or Windows refuses the input partway, it fails with `errorCode` `typing_stopped`, `typed` and `total` (a newline or a surrogate pair counts as one -character) and `nextIndex`, the UTF-16 index where the untyped rest starts. After a refusal, -`partlySent` is `true`: the character at `nextIndex` may have been partly sent. +character) and `nextIndex`, the UTF-16 index where the untyped rest starts. `partlySent` is `true` +when part of the character at `nextIndex` went out before a refusal, such as an Enter pressed but not +released. Windows doesn't report every refusal: input an elevated window doesn't accept is dropped +silently. Buttons: `left`, `right`, `middle`, `x1`, `x2`. Key names: letters, digits, `f1`–`f24`, `num0`–`num9`, `enter`, `esc`, `space`, `tab`, `backspace`, `del`, `ins`, `home`, `end`, `pgup`, `pgdn`, arrows, diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 9f4a7db..2097b2b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -441,13 +441,16 @@ If text still arrives wrong: - **Skip the keyboard.** For a form field, `seat_element` `set_value` on the observed control sets the whole value at once. - **`typing_stopped`.** The focused program stopped responding for 5 seconds, typing ran out of the - call's time, or Windows refused the input partway, for example once a typed Enter opened a UAC prompt - or an elevated window. The error gives `typed` and `total` (a newline or an emoji counts as one character) - and `nextIndex`, the UTF-16 index where the untyped rest starts. After a refusal, `partlySent` is - `true`: the character at `nextIndex` may have arrived in part, such as an Enter pressed but not - released. Check the field before you send the rest; Anode never sends it again by itself. A seat that - stops during a call ends it with a lost connection instead: stopping signs the seat out, which closes - the program you were typing into. + call's time, or Windows refused the input partway. The error gives `typed` and `total` (a newline or an + emoji counts as one character) and `nextIndex`, the UTF-16 index where the untyped rest starts. When + `partlySent` is `true`, part of the character at `nextIndex` went out before the refusal: an Enter may + have been pressed and not released, so it may already have taken effect, and Anode releases the key + when the lease ends. Check the field before you send the rest; Anode never sends it again by itself. +- **Windows doesn't report every refusal.** Input an elevated window doesn't accept is dropped without + an error, so check the field after typing into one. +- **The seat stopped during a call.** The call ends with a lost connection or `typing_stopped`. Either + way, stopping signs the seat out, which closes the program you were typing into, so don't send the + rest. ### A display change fails or looks wrong diff --git a/src/Anode/Cli/InputChecks.cs b/src/Anode/Cli/InputChecks.cs index bcea211..7d32f5a 100644 --- a/src/Anode/Cli/InputChecks.cs +++ b/src/Anode/Cli/InputChecks.cs @@ -149,41 +149,45 @@ public static string Pacing() "typing went on after its request was cancelled: " + halted.ToJsonString()); } - // Windows refusing the input partway, as it does once a prompt or an elevated window takes the focus, still - // ends the call with the count, and says the refused character may have been partly sent. - Require(stopped["partlySent"] is null, "a stop before a character went out said it may have been partly sent: " + stopped.ToJsonString()); - foreach (Exception refusal in new Exception[] - { - new InvalidOperationException("The foreground helper is blocking input in this seat."), - new System.ComponentModel.Win32Exception(5, "SendInput accepted 0 of 2 events (Win32 error 5: Access is denied.)") - }) + // Windows refusing a character still ends the call with the exact count. partlySent says part of that character + // went out first, as when an Enter's press went through and its release was refused. + foreach (var (at, partly) in new[] { (0, false), (3, true), (4, false) }) { var blocked = new Recorder(); var typist = blocked.Typist(_ => true); + var refusal = new TextTyping.Refused("stand-in refusal: SendInput accepted 0 of 2 events.", partly); var partway = TextTyping.Type(Sample, null, new TextTyping.Typist { - Send = unit => { if (blocked.Sent.Count == 3) throw refusal; typist.Send(unit); }, + Send = unit => { if (blocked.Sent.Count == at) throw refusal; typist.Send(unit); }, Responsive = typist.Responsive, Wait = typist.Wait, Now = typist.Now }); string why = partway.Str("error") ?? ""; - Require(partway.Bool("ok") == false && partway.Str("errorCode") == "typing_stopped" && partway.Int("typed") == 3 - && partway.Int("total") == 7 && partway.Int("nextIndex") == 4 && partway.Bool("partlySent") == true && blocked.Sent.Count == 3 - && why.Contains(refusal.Message.TrimEnd('.'), StringComparison.Ordinal) - && why.Contains("Character 4 may have been partly sent; nothing after it was.", StringComparison.Ordinal), - $"input Windows refused partway ({refusal.GetType().Name}) did not end typing with the exact count: " + partway.ToJsonString()); + int next = TextTyping.Units(Sample)[at].Index; + Require(partway.Bool("ok") == false && partway.Str("errorCode") == "typing_stopped" && partway.Int("typed") == at + && partway.Int("total") == 7 && partway.Int("nextIndex") == next && blocked.Sent.Count == at + && (partway["partlySent"] is null) != partly + && why.Contains("Windows refused the input (stand-in refusal: SendInput accepted 0 of 2 events)", StringComparison.Ordinal) + && why.Contains(partly ? $"Part of character {at + 1} went out first" : $"Nothing after character {at} was sent.", StringComparison.Ordinal) + && !why.Contains("ab\n", StringComparison.Ordinal) && !why.Contains("\U0001F600", StringComparison.Ordinal), + $"a refusal at character {at + 1} (partly sent: {partly}) did not end typing with the exact count: " + partway.ToJsonString()); } - // A fault in Anode itself still fails loudly instead of passing for a refusal. - var faulty = new Recorder().Typist(_ => true); - bool surfaced = false; - try + Require(stopped["partlySent"] is null && late["partlySent"] is null, + "a stop before a character went out said part of it was sent: " + stopped.ToJsonString()); + // Any other exception is a fault in Anode itself, and fails loudly instead of passing for a refusal. + foreach (Exception fault in new Exception[] { new InvalidOperationException("stand-in fault"), new ObjectDisposedException("stand-in") }) { - TextTyping.Type("ab", null, new TextTyping.Typist + var faulty = new Recorder().Typist(_ => true); + bool surfaced = false; + try { - Send = _ => throw new ArgumentException("not a refusal"), Responsive = faulty.Responsive, Wait = faulty.Wait, Now = faulty.Now - }); + TextTyping.Type("ab", null, new TextTyping.Typist + { + Send = _ => throw fault, Responsive = faulty.Responsive, Wait = faulty.Wait, Now = faulty.Now + }); + } + catch (Exception caught) when (ReferenceEquals(caught, fault)) { surfaced = true; } + Require(surfaced, $"typing turned a fault in Anode's own code ({fault.GetType().Name}) into a typing_stopped reply"); } - catch (ArgumentException) { surfaced = true; } - Require(surfaced, "typing turned a fault in Anode's own code into a typing_stopped reply"); var refused = new Recorder(); Require(TextTyping.Type(new string('x', 3002), null, refused.Typist(_ => true)).Bool("ok") == false && refused.Sent.Count == 0, diff --git a/src/Anode/Core/Input/InputInjector.cs b/src/Anode/Core/Input/InputInjector.cs index db98f83..8503103 100644 --- a/src/Anode/Core/Input/InputInjector.cs +++ b/src/Anode/Core/Input/InputInjector.cs @@ -115,12 +115,32 @@ public static System.Drawing.Point CursorPosition() => private static void Send(params Input[] inputs) { - if (Desktop.WindowAccess.InputBlockReason() is { } blocked) throw new InvalidOperationException(blocked); - uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf()); + uint sent = SendCounted(inputs, out string? blocked); + if (blocked is not null) throw new InvalidOperationException(blocked); if (sent != inputs.Length) throw Native.Native.LastError($"SendInput accepted {sent} of {inputs.Length} events"); } + /// Sends the events unless the seat's input is blocked, and returns how many Windows accepted. + private static uint SendCounted(Input[] inputs, out string? blocked) + { + blocked = Desktop.WindowAccess.InputBlockReason(); + return blocked is null ? SendInput((uint)inputs.Length, inputs, Marshal.SizeOf()) : 0; + } + + /// + /// Sends one step of typing. A refusal says whether part of the step went out: an earlier part, or some of these + /// events. Windows doesn't report every refusal, though: input an elevated window doesn't take is dropped silently. + /// + private static void TypingStep(Input[] events, bool earlierPartSent) + { + uint sent = SendCounted(events, out string? blocked); + if (blocked is not null) throw new TextTyping.Refused(blocked, earlierPartSent); + if (sent != events.Length) + throw new TextTyping.Refused(Native.Native.LastError($"SendInput accepted {sent} of {events.Length} events").Message, + earlierPartSent || sent > 0); + } + private static Input AbsoluteMove(int x, int y) { var size = ScreenSize(); @@ -312,10 +332,11 @@ private static void TypeUnit(TextTyping.Unit unit, Action wait) // As Press does, with the hold timed precisely. ushort vk = KeyCodes.Resolve(key); HeldKeys.Add((vk, true)); - try { Send(KeyEvent(vk, false, true)); wait(TextTyping.KeyHoldMs); } + bool pressed = false; + try { TypingStep([KeyEvent(vk, false, true)], false); pressed = true; wait(TextTyping.KeyHoldMs); } finally { - Send(KeyEvent(vk, true, true)); + TypingStep([KeyEvent(vk, true, true)], pressed); HeldKeys.Remove((vk, true)); } return; @@ -327,7 +348,7 @@ private static void TypeUnit(TextTyping.Unit unit, Action wait) events.Add(new Input { Type = InputKeyboard, Union = { Keyboard = new KeyboardInput { Scan = character, Flags = KeyEventUnicode } } }); events.Add(new Input { Type = InputKeyboard, Union = { Keyboard = new KeyboardInput { Scan = character, Flags = KeyEventUnicode | KeyEventKeyUp } } }); } - Send(events.ToArray()); + TypingStep(events.ToArray(), false); } private static bool IsExtendedKey(ushort vk) => vk switch diff --git a/src/Anode/Core/Input/TextTyping.cs b/src/Anode/Core/Input/TextTyping.cs index 5a36a37..942342e 100644 --- a/src/Anode/Core/Input/TextTyping.cs +++ b/src/Anode/Core/Input/TextTyping.cs @@ -1,4 +1,3 @@ -using System.ComponentModel; using System.Globalization; using System.Runtime.InteropServices; using System.Text.Json.Nodes; @@ -69,6 +68,15 @@ internal sealed class Typist public CancellationToken Cancel { get; init; } } + /// + /// Windows refused a step of typing. when part of the step went out before the refusal, + /// such as the press of an Enter whose release was refused. + /// + internal sealed class Refused(string message, bool partlySent) : Exception(message) + { + public bool PartlySent { get; } = partlySent; + } + internal static List Units(string text) { var units = new List(text.Length); @@ -120,7 +128,7 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti var units = Units(text); double start = typist.Now(), previous = start; string ranOut = string.Create(CultureInfo.InvariantCulture, $"typing would have run past {stop / 1000.0:0.#} seconds, the time this call has"); - const string cancelled = "the request was cancelled, because the seat is stopping or its time ran out"; + const string cancelled = "the request was cancelled because its time ran out (time spent waiting for the desktop counts)"; for (int i = 0; i < units.Count; i++) { if (i > 0) @@ -145,11 +153,10 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti if (typist.Now() - start + (units[i].Key is null ? 0 : KeyHoldMs) > stop) return Stopped(units, i, ranOut); previous = typist.Now(); try { typist.Send(units[i]); } - catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) + catch (Refused refused) { - // Windows refused the input, for example once an Enter typed earlier opened a prompt or an elevated - // window. The count still matters: the caller mustn't send again what already arrived. - return Stopped(units, i, $"Windows refused the input ({ex.Message.TrimEnd('.')})", partlySent: true); + // The count still matters: the caller mustn't send again what already arrived. + return Stopped(units, i, $"Windows refused the input ({refused.Message.TrimEnd('.')})", refused.PartlySent); } } double elapsed = typist.Now() - start; @@ -166,13 +173,14 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti /// /// Ends a call partway, saying exactly how far it got. The reply gives counts only and never repeats the text, - /// which may be a secret. means Windows refused character - /// partway through sending it, so part of it, such as the press of an Enter, may have arrived. + /// which may be a secret. means part of the next character, the one at index + /// of , went out before Windows refused the rest. /// private static JsonObject Stopped(List units, int typed, string reason, bool partlySent = false) { string sent = partlySent - ? $"Character {typed + 1} may have been partly sent; nothing after it was." + ? $"Part of character {typed + 1} went out first, so it may already have taken effect, as a pressed Enter does; " + + "nothing after it was sent." : $"Nothing after character {typed} was sent."; var failure = JsonLine.Fail($"Typed the first {typed} of {units.Count} characters, then stopped: {reason}. {sent} " + $"Check what reached the field before you send the rest (from UTF-16 index {units[typed].Index})."); diff --git a/src/Anode/Seat/SeatHost.cs b/src/Anode/Seat/SeatHost.cs index f2fe29f..37acb1b 100644 --- a/src/Anode/Seat/SeatHost.cs +++ b/src/Anode/Seat/SeatHost.cs @@ -178,7 +178,7 @@ private static async Task HandleOwnedAsync(JsonObject request, Cance if (!desktop && !changesUi) return Dispatch(op, request); if (changesUi) Desktop.InvalidateObservations(); // Typing can take most of a request's time, so it stops at the lease's deadline, or at a shutdown sent on - // another connection. The daemon's own shutdown waits behind it, and the sign-out that follows ends it. + // another connection. The daemon's own shutdown waits behind it; the sign-out that follows ends it. if (op == "input.text") return InputInjector.TypeText(request.Str("text") ?? throw new ArgumentException("text needs 'text'."), request.Int("perCharMs"), request.Int("timeoutMs") ?? 60_000, cancel); From fd2356cfa903df24c39f56a08d573b9ce6c48d0b Mon Sep 17 00:00:00 2001 From: skulitom Date: Wed, 7 Oct 2026 00:10:20 +0100 Subject: [PATCH 3/3] Hedge the elevated-window note, and name both causes of a cancel Second review follow-up (P3s): Windows may drop input to an elevated window without an error, rather than always does; the cancel reason names the request's time or a stopping seat, since a shutdown on another connection cancels the same token; a stop at the first character says "Nothing was sent."; and the SeatHost comment names the request's deadline, not the lease's. Co-Authored-By: Claude Opus 5.5 --- docs/PROTOCOL.md | 4 ++-- docs/TROUBLESHOOTING.md | 4 ++-- src/Anode/Cli/InputChecks.cs | 3 ++- src/Anode/Core/Input/InputInjector.cs | 3 ++- src/Anode/Core/Input/TextTyping.cs | 4 ++-- src/Anode/Seat/SeatHost.cs | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 8b91586..95df730 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -256,8 +256,8 @@ shorter `timeoutMs`), the request is cancelled, or Windows refuses the input par `errorCode` `typing_stopped`, `typed` and `total` (a newline or a surrogate pair counts as one character) and `nextIndex`, the UTF-16 index where the untyped rest starts. `partlySent` is `true` when part of the character at `nextIndex` went out before a refusal, such as an Enter pressed but not -released. Windows doesn't report every refusal: input an elevated window doesn't accept is dropped -silently. +released. Windows may not report every refusal: input an elevated window doesn't accept may be +dropped without an error. Buttons: `left`, `right`, `middle`, `x1`, `x2`. Key names: letters, digits, `f1`–`f24`, `num0`–`num9`, `enter`, `esc`, `space`, `tab`, `backspace`, `del`, `ins`, `home`, `end`, `pgup`, `pgdn`, arrows, diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2097b2b..5dddd5d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -446,8 +446,8 @@ If text still arrives wrong: `partlySent` is `true`, part of the character at `nextIndex` went out before the refusal: an Enter may have been pressed and not released, so it may already have taken effect, and Anode releases the key when the lease ends. Check the field before you send the rest; Anode never sends it again by itself. -- **Windows doesn't report every refusal.** Input an elevated window doesn't accept is dropped without - an error, so check the field after typing into one. +- **Windows may not report every refusal.** Input an elevated window doesn't accept may be dropped + without an error, so check the field after typing into one. - **The seat stopped during a call.** The call ends with a lost connection or `typing_stopped`. Either way, stopping signs the seat out, which closes the program you were typing into, so don't send the rest. diff --git a/src/Anode/Cli/InputChecks.cs b/src/Anode/Cli/InputChecks.cs index 7d32f5a..ea0662c 100644 --- a/src/Anode/Cli/InputChecks.cs +++ b/src/Anode/Cli/InputChecks.cs @@ -167,7 +167,8 @@ public static string Pacing() && partway.Int("total") == 7 && partway.Int("nextIndex") == next && blocked.Sent.Count == at && (partway["partlySent"] is null) != partly && why.Contains("Windows refused the input (stand-in refusal: SendInput accepted 0 of 2 events)", StringComparison.Ordinal) - && why.Contains(partly ? $"Part of character {at + 1} went out first" : $"Nothing after character {at} was sent.", StringComparison.Ordinal) + && why.Contains(partly ? $"Part of character {at + 1} went out first" : at == 0 ? "Nothing was sent." : $"Nothing after character {at} was sent.", + StringComparison.Ordinal) && !why.Contains("ab\n", StringComparison.Ordinal) && !why.Contains("\U0001F600", StringComparison.Ordinal), $"a refusal at character {at + 1} (partly sent: {partly}) did not end typing with the exact count: " + partway.ToJsonString()); } diff --git a/src/Anode/Core/Input/InputInjector.cs b/src/Anode/Core/Input/InputInjector.cs index 8503103..fec4f91 100644 --- a/src/Anode/Core/Input/InputInjector.cs +++ b/src/Anode/Core/Input/InputInjector.cs @@ -130,7 +130,8 @@ private static uint SendCounted(Input[] inputs, out string? blocked) /// /// Sends one step of typing. A refusal says whether part of the step went out: an earlier part, or some of these - /// events. Windows doesn't report every refusal, though: input an elevated window doesn't take is dropped silently. + /// events. Windows may not report every refusal, though: input an elevated window doesn't take may be dropped + /// without an error. /// private static void TypingStep(Input[] events, bool earlierPartSent) { diff --git a/src/Anode/Core/Input/TextTyping.cs b/src/Anode/Core/Input/TextTyping.cs index 942342e..ce378f5 100644 --- a/src/Anode/Core/Input/TextTyping.cs +++ b/src/Anode/Core/Input/TextTyping.cs @@ -128,7 +128,7 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti var units = Units(text); double start = typist.Now(), previous = start; string ranOut = string.Create(CultureInfo.InvariantCulture, $"typing would have run past {stop / 1000.0:0.#} seconds, the time this call has"); - const string cancelled = "the request was cancelled because its time ran out (time spent waiting for the desktop counts)"; + const string cancelled = "the request was cancelled because its time ran out (time spent waiting for the desktop counts) or the seat is stopping"; for (int i = 0; i < units.Count; i++) { if (i > 0) @@ -181,7 +181,7 @@ private static JsonObject Stopped(List units, int typed, string reason, bo string sent = partlySent ? $"Part of character {typed + 1} went out first, so it may already have taken effect, as a pressed Enter does; " + "nothing after it was sent." - : $"Nothing after character {typed} was sent."; + : typed == 0 ? "Nothing was sent." : $"Nothing after character {typed} was sent."; var failure = JsonLine.Fail($"Typed the first {typed} of {units.Count} characters, then stopped: {reason}. {sent} " + $"Check what reached the field before you send the rest (from UTF-16 index {units[typed].Index})."); failure["errorCode"] = "typing_stopped"; diff --git a/src/Anode/Seat/SeatHost.cs b/src/Anode/Seat/SeatHost.cs index 37acb1b..9abcffd 100644 --- a/src/Anode/Seat/SeatHost.cs +++ b/src/Anode/Seat/SeatHost.cs @@ -177,7 +177,7 @@ private static async Task HandleOwnedAsync(JsonObject request, Cance bool changesUi = op.StartsWith("input.", StringComparison.Ordinal) || op is "run" or "steam.launch" or "ps.kill" or "android.studio" or "browser.open"; if (!desktop && !changesUi) return Dispatch(op, request); if (changesUi) Desktop.InvalidateObservations(); - // Typing can take most of a request's time, so it stops at the lease's deadline, or at a shutdown sent on + // Typing can take most of a request's time, so it stops at the request's deadline, or at a shutdown sent on // another connection. The daemon's own shutdown waits behind it; the sign-out that follows ends it. if (op == "input.text") return InputInjector.TypeText(request.Str("text") ?? throw new ArgumentException("text needs 'text'."),