DEV Community

babycat
babycat

Posted on

The Retry Keystroke Failed After Cancel: A Dead AbortSignal Retrospective

I built a small streaming chat shell to practice cancel, retry, and focus recovery under real failure. I cancelled a streaming answer with Escape, tabbed to Retry, and pressed Enter without touching the mouse. The button still looked enabled, yet the composer stayed disabled and the live region never spoke. Have you ever watched a keyboard user hit Retry and assume the whole product had frozen?

The model was not stuck, and the network panel was not the first useful clue. The first attempt had aborted, but Retry still held the same AbortController. signal.aborted was already true, so the next fetch never started. The interface looked ready while the attempt machine was still sitting on a corpse.

The interaction that failed

Pointer users rarely expose this bug, because they cancel and click in one visual cluster. Keyboard users traverse a different path, and that path is the product. I treat this sequence as the regression, not as an edge case.

  1. Focus the composer and submit a prompt with Enter, not a pointer click.
  2. While the status says generating, press Escape to cancel instead of locating Stop.
  3. Notice where focus went after Stop disappeared from the document.
  4. Tab to Retry, press Enter, and watch the composer, status text, and live region.

I wanted a new attempt, a spoken retry confirmation, and focus landing on Stop or the composer. I got silence, a dead textarea, and a Retry control that swallowed the keystroke. Why ship a button that looks enabled while its signal cannot start work?

Draw the state table before you patch the button

Retry is not a click handler glued onto a cancelled bubble. Retry is a transition that must create a new attempt. I should have drawn this table before the second button ever landed in the tree.

State Composer Stop Retry Status text Live region Focus target
idle enabled hidden hidden Ready polite, idle composer
streaming disabled enabled hidden Generating polite, generating Stop
cancelled enabled hidden enabled Cancelled assertive, cancelled Retry
error enabled hidden enabled Could not finish assertive, error Retry
retrying disabled enabled hidden Retrying polite, retrying Stop

If Retry is visible while status === "cancelled" and the controller still belongs to that cancelled attempt, the table is already lying. Can a screen reader user trust a control that is enabled in the DOM but wired to a dead signal? What happens to focus when Stop unmounts during cancel and nothing claims it?

A minimal reproduction

This sketch is labeled as a local reproduction, not as a production chat client. Serve the file over http://localhost so fetch and focus behave like a real page. The stream is fake, which keeps the abort ordering visible without a backend.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Dead AbortSignal retry bug</title>
</head>
<body>
  <h1>Cancel, then retry with the keyboard</h1>
  <p id="status" role="status" aria-live="polite">Ready</p>
  <label for="composer">Message</label>
  <textarea id="composer" rows="3">Explain abort retry</textarea>
  <p>
    <button type="button" id="send">Send</button>
    <button type="button" id="stop" hidden>Stop</button>
    <button type="button" id="retry" hidden>Retry</button>
  </p>
  <pre id="log" aria-label="Attempt log"></pre>
  <script>
    const composer = document.getElementById("composer");
    const statusNode = document.getElementById("status");
    const sendBtn = document.getElementById("send");
    const stopBtn = document.getElementById("stop");
    const retryBtn = document.getElementById("retry");
    const log = document.getElementById("log");

    let status = "idle";
    let lastPrompt = "";
    let controller = new AbortController(); // reused across attempts: the bug

    function setStatus(next, announcement) {
      status = next;
      statusNode.textContent = announcement;
      const streaming = next === "streaming" || next === "retrying";
      composer.disabled = streaming;
      sendBtn.hidden = streaming;
      stopBtn.hidden = !streaming;
      retryBtn.hidden = !(next === "cancelled" || next === "error");
    }

    function fakeStream(signal) {
      return new Promise((resolve, reject) => {
        const timer = setTimeout(() => resolve("done"), 4000);
        signal.addEventListener("abort", () => {
          clearTimeout(timer);
          reject(new DOMException("Aborted", "AbortError"));
        });
      });
    }

    async function sendPrompt(prompt, mode) {
      lastPrompt = prompt;
      setStatus(mode, mode === "retrying" ? "Retrying" : "Generating");
      stopBtn.focus();
      log.textContent += `\nstart ${mode} aborted=${controller.signal.aborted}`;
      try {
        if (controller.signal.aborted) return; // Retry keystroke dies here
        await fakeStream(controller.signal);
        setStatus("idle", "Ready");
        composer.focus();
      } catch (err) {
        if (err.name === "AbortError") {
          setStatus("cancelled", "Cancelled");
          // Stop unmounts; focus is not moved. Composer stays easy to miss.
        } else {
          setStatus("error", "Could not finish");
          retryBtn.focus();
        }
      }
    }

    sendBtn.addEventListener("click", () => sendPrompt(composer.value, "streaming"));
    stopBtn.addEventListener("click", () => controller.abort());
    retryBtn.addEventListener("click", () => sendPrompt(lastPrompt, "retrying"));
    document.addEventListener("keydown", (event) => {
      if (event.key === "Escape" && (status === "streaming" || status === "retrying")) {
        controller.abort();
      }
    });
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Run that path with the keyboard only. Cancel during the four second wait, then activate Retry without a pointer. The log line will show aborted=true on the second start, and the function returns before any new work. Is Retry a retry if it never creates a new attempt?

What DevTools actually showed

I stopped looking at token text and started looking at attempt identity. The cancelled run and the retry run were the same object in memory. That is a debugging technique I now reuse on every streaming control, because visual status text lies faster than AbortSignal does.

  • Log controller.signal.aborted at the top of every send, cancel, and retry call.
  • Log controller.signal === previousSignal so accidental reuse is obvious.
  • Watch focus with a :focus outline; Stop vanishing is a focus bug, not a copy bug.
  • Confirm the live region text changed, then confirm the change was announced, not only painted.

The Network panel stayed quiet because the fake stream never reached fetch. That silence was honest. The UI still pretended a second request existed. Have you caught yourself debugging the model when the client refused to send a body?

Root cause: one controller for two attempts

Three mistakes stacked, and only one of them lived in JavaScript event wiring. The controller was a singleton, Retry bailed when aborted was true, and cancel never parked focus on Retry. Each mistake is enough to strand a keyboard user. Together they look like a frozen app.

The singleton AbortController is the root. abort() is not a pause. It is terminal for that signal. Reusing it is like retrying a race after you have already fired the starter pistol into the ground. A guard that reads if (controller.signal.aborted) return then turns Retry into a no-op, which is worse than an error state.

Cancel also removed Stop while it still held focus. Focus escaped to the document body, so the next Tab walk was longer than the pointer path. The composer stayed disabled whenever I marked the status as streaming and then returned early, because the early return skipped the idle restore. The live region used role="status" for cancel, which is polite, so an assertive failure never interrupted. Which of those would you have patched first if you only watched the mouse path?

The fix: a typed attempt, a fresh signal, and restored focus

I treat each send as a new attempt record, not as a mutation of the last request. The status union below is a proposal for the shell, not a claim about a shipped design system. It exists so Retry cannot share an aborted signal.

type ChatStatus =
  | "idle"
  | "streaming"
  | "retrying"
  | "cancelled"
  | "error";

type Attempt = {
  id: number;
  prompt: string;
  controller: AbortController;
};

let status: ChatStatus = "idle";
let attempt: Attempt | null = null;
let attemptId = 0;

function startAttempt(prompt: string, next: "streaming" | "retrying") {
  attempt = {
    id: ++attemptId,
    prompt,
    controller: new AbortController(),
  };
  status = next;
  return attempt;
}
Enter fullscreen mode Exit fullscreen mode

Retry must call startAttempt, never sendPrompt with leftover plumbing. Cancel still calls attempt.controller.abort(), but it also moves focus to Retry and re-enables the composer. Error does the same, because recovery is a keyboard destination, not a toast.

function cancelAttempt() {
  if (!attempt) return;
  attempt.controller.abort();
}

function onCancelled() {
  setStatus("cancelled", "Cancelled. Retry is available.");
  document.getElementById("status").setAttribute("aria-live", "assertive");
  composer.disabled = false;
  retryBtn.hidden = false;
  retryBtn.focus();
}

async function sendPrompt(prompt, mode) {
  const current = startAttempt(prompt, mode);
  setStatus(mode, mode === "retrying" ? "Retrying the last message." : "Generating.");
  stopBtn.hidden = false;
  stopBtn.focus();
  try {
    await fakeStream(current.controller.signal);
    if (attempt && attempt.id !== current.id) return; // stale attempt
    setStatus("idle", "Ready");
    composer.focus();
  } catch (err) {
    if (attempt && attempt.id !== current.id) return;
    if (err.name === "AbortError") onCancelled();
    else {
      setStatus("error", "Could not finish. Retry is available.");
      retryBtn.focus();
    }
  }
}

function retry() {
  if (!lastPrompt) return;
  document.getElementById("status").setAttribute("aria-live", "polite");
  sendPrompt(lastPrompt, "retrying");
}
Enter fullscreen mode Exit fullscreen mode

Stale attempts matter once Retry is honest. A cancelled reader can still resolve after a new attempt starts if you forget the id check. Then tokens from a dead run append onto the new bubble, and the live region reads mixed text. Pointer users call that a glitch. Keyboard and screen reader users call it an untrustworthy transcript.

I also keep Stop and Retry as real <button> elements, not clickable <div> nodes. Escape remains a document-level cancel while streaming, so the action is pointer independent. Do not dump each token into an assertive live region; announce state changes, not every partial word. The control is ready when the state table, the signal, and the focus target agree.

Reproducing delayed streams without guessing

A local timer proves the AbortSignal bug, but it does not prove first-byte delay, idle disconnects, or cancel during headers. I needed a real network path that could stall before the first token, because mocked timers never order AbortError against fetch the same way. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I pointed the same chat shell at MonkeyCode's free model access on the free server option, then cancelled and retried after a slow first byte.

That is not a benchmark, and I am not claiming quotas, uptime, hardware, or model names. It was only a convenient way to force the error and cancel transitions this UI has to survive. If the first byte is slow, Retry still needs a fresh controller. If the connection drops, the error state still needs focus on a recoverable control. The free server did not fix accessibility. It only made the failure honest.

A QA matrix for cancel then retry

This matrix is a proposed checklist, not a completed lab report. Please run it and send the exact transition that failed, plus browser, OS, and assistive-technology versions. I care about the cancel-to-retry hop more than the happy path that streams to completion.

Transition Keyboard Screen reader What must not happen
idle to streaming Enter in composer Hears generating, politely Focus lost to the page body
streaming to cancelled Escape Hears cancelled, assertively Stop remains focused after unmount
cancelled to retrying Enter on Retry Hears retrying Reused aborted signal, no-op click
retrying to error Stop hidden, Retry shown Hears could not finish Composer left disabled
Stale reader after retry N/A Transcript stays on the new attempt Cancelled tokens append onto Retry

If you only test with a mouse on the happy path, you will ship the bug I started with. If you only add aria-live and leave the singleton controller, you will announce a retry that never started. Accessibility here is state, focus, and abort lifetime, not a single ARIA attribute.

Limitations, and who should skip this

Client abort() does not guarantee the server stopped generating. You still need a backend cancel channel if leftover work costs money or leaks into logs. A free server can drop idle connections, which is useful for error UI and useless as a load test. This shell also skips markdown partials, auth cookies, and multi-turn payload assembly on purpose.

Do not use this approach if you are not shipping a streaming composer. Ordinary forms should not copy live regions or Escape-to-cancel. Native mobile chat is a different focus model, and design-research writeups of agent personality do not belong in this debugging loop. Unsupported conformance claims would also be a mistake; this is a recovery pattern, not a WCAG certificate.

I still want Retry to mean a new attempt with a living signal, a spoken state change, and a focus target a keyboard user can feel. If that bar feels high for a chat toy, the toy is already a product surface. Reproduce the Escape-then-Enter transition, and tell me which row in the matrix broke first.

Top comments (0)