[Claude in Chrome] javascript_tool: a call reported as "timed out" can have completed its side effect (POST accepted), and the error does not say so

Status Open
Reported on v2.1.296
Maintainer reply None cached
Activity 0 comments · opened Oct 12, 2026

Summary

When a javascript_tool call (Claude in Chrome MCP tools, driven from Claude Code) runs
longer than the tool waits, the call returns an error of this form:

Failed to execute JavaScript: CDP sendCommand "Runtime.evaluate" timed out after 45000ms on tab <id>. The renderer may be frozen or unresponsive.

The error does not say whether the script started or may still complete. In this
reproduction, the server recorded the POST before the timeout and the page received
HTTP 200 afterwards. Retrying a non-idempotent action in this state can create a
duplicate. Nothing in the error distinguishes "not executed" from "executed, result
unknown".

Environment

  • OS: Windows 11 Home (build 26300)
  • Claude Code: 2.1.296 (also seen on 2.1.283)
  • Claude in Chrome extension: 1.0.100
  • Browser: Google Chrome (stable) 154.0.8037.97 (64-bit)

What happened

In a browser-automation session on Windows 11, a javascript_tool call did roughly this
on a page of an ordinary web application (a CSRF token read from a meta tag, a JSON POST
to the site's own endpoint, then a short wait before returning):

await fetch('/api/posts', { method: 'POST', headers: { ... }, body: ... });  // the site's create endpoint
await new Promise(r => setTimeout(r, 9000));
return 'done';

The script included a 9 s wait after the POST response. Why that call exceeded 45 s was
not determined; the reproduction below forces the condition with a slow server instead.
The call returned the timeout error quoted above. The POST had in fact been accepted: the
created record was visible when the site was read again afterwards. It was noticed only
because the page was re-read before retrying. A retry based on the error text alone could
have created a duplicate record.

Minimal reproduction

A local server (Python standard library only; see Attachments) that

  • serves a trivial page at / with a csrf-like meta tag,
  • accepts POST /submit/<delay> (or POST /submit?delay=<delay>), appends one line to posts.log on receipt, and only

then waits <delay> seconds before answering {"ok": true, "count": N},

  • answers GET /count with the number of recorded POSTs.

Steps:

  1. python server.py (binds 127.0.0.1:8765); curl http://127.0.0.1:8765/count gives

{"count": 0}.

  1. navigate to http://127.0.0.1:8765/.
  2. javascript_tool with the snippet below, pasted as is. It uses top-level await

(no wrapper function); the last line is the expression out, which is the value the
tool is expected to return. It POSTs to /submit/50 (50 s server-side delay before
the response) and awaits the response. The result is also stored in a window
property and logged to the console:

const t0 = Date.now();
const nonce = 'AT-' + t0;
const meta = document.querySelector('meta[name="csrf-token"]');
const csrf = meta ? meta.content : 'no-meta';
window.__repro_AT = 'started ' + nonce;
console.log('REPRO_AT started ' + nonce);
let out;
try {
  const r = await fetch('/submit/50', {
    method: 'POST',
    headers: { 'Content-Type': 'text/plain', 'X-CSRF-Token': csrf },
    body: 'nonce ' + nonce
  });
  const text = await r.text();
  out = 'done status ' + r.status + ' body ' + text + ' elapsed_ms ' + (Date.now() - t0) + ' nonce ' + nonce;
} catch (e) {
  out = 'error ' + String(e) + ' elapsed_ms ' + (Date.now() - t0) + ' nonce ' + nonce;
}
out = out.replace(/[?&=]/g, '_');
window.__repro_AT = out;
console.log('REPRO_AT ' + out);
out
  1. Note the tool's result.
  2. curl http://127.0.0.1:8765/count.
  3. After about 55 s, javascript_tool with String(window.__repro_AT || 'not set'), or

read_console_messages (lines starting with REPRO_AT), to see what the page itself
received.

Observed

Run on Claude Code 2.1.283 (the two logs under Attachments are from this run). Local server (Python stdlib, 127.0.0.1:8765) that appends a line to a log the moment a POST arrives, then waits 50 s before answering. One tab. Times are local.

Control POST from curl: count 0 -> 1.

  1. 08:10:57.8 count = 2. The second POST in the log is an earlier trial of the same script in the async-IIFE form described under Side observation (posts_run_20261012.log: nonce A-1791760234139, recorded 08:10:34.2, request line POST /submit?delay=50, answered 200 at 08:11:24.2 after 50 s); the control POST is nonce curl-control, 08:09:45.7. Called javascript_tool with the step-3 snippet (top-level await, no IIFE; abridged):

const r = await fetch('/submit/50', {method:'POST', body:'nonce ' + nonce}); ... out

  1. Server log: 08:10:59.001 POST recorded count=3 delay=50 (about 1 s after the call)
  2. javascript_tool returned an error after about 45 s:

"Failed to execute JavaScript: CDP sendCommand "Runtime.evaluate" timed out after 45000ms on tab <id>. The renderer may be frozen or unresponsive."

  1. 08:11:45.6 count = 3 (the POST had been accepted).
  2. A later javascript_tool call reading a window property set by the same script returned:

"done status 200 body {"ok": true, "count": 3} elapsed_ms 50034 nonce AT-..."
i.e. the page received HTTP 200 after 50 s, after the tool had already reported failure.

Side observation: the same script wrapped in an async IIFE (returning a Promise) made javascript_tool return "{}" within about 10 s instead of waiting; that POST was also recorded and later completed with 200. A caller seeing "{}" also has no indication that a request is still in flight.

Re-run on Claude Code 2.1.296 (same kit, one attempt, no retry; log not attached): javascript_tool returned the same "timed out after 45000ms" error after about 45 s; the server had recorded the POST 0.05 s after the script started (count 1 -> 2); the page received HTTP 200 at 50,267 ms (read back from window.__repro_AT). The nonce in the POST log matched the nonce in the page's 200 result.

Expected

The timeout should make the unknown outcome explicit. The result of a timed-out
evaluation should state that the script was dispatched to the page and may still be
running, and that its side effects (network requests, DOM changes) may have completed, as
something distinct from an error where the script was never run (for example when the
renderer could not be reached before dispatch).

A longer configurable wait would also help with slow scripts; if it expires, the outcome
should still be reported as unknown.

Impact

Automation that uses javascript_tool for a non-idempotent action (creating a post,
submitting a form, sending a message) cannot safely retry after this error. The operator
has to re-read the target to learn whether the action happened, which is not always
possible: there may be no read endpoint, or the effect may not be visible to the caller.

Suggestion

Make the error payload explicit about what is known, for example
evaluation timed out after 45000 ms; the script was sent to the page and may have completed (result unknown)
as opposed to "script was not executed". If the tool can tell whether Runtime.evaluate
was dispatched before the deadline, surfacing that one bit is enough for a caller to
choose between "retry" and "verify first".

Workaround in use meanwhile: for non-idempotent actions the script stores its result in
a window property and logs it to the console before returning, so that after a timeout
the outcome can be fetched with a second, fast javascript_tool call or with
read_console_messages instead of retrying.

How to verify a fix

  • Reload the page, record /count as N, and run the /submit/50 reproduction once,

without retrying.

  • After completion, /count must read N + 1, and the nonce in the POST log must match

the one in the page's recorded HTTP 200 result.

  • Pass if the tool returns that result, or explicitly reports an unknown outcome: that the

script may still be running and that side effects may already have occurred.

  • Also test a failure confirmed before dispatch: no POST should be recorded, and the error

should distinguish that case from an unknown outcome.

Related: #101431 (its item 3 shows the same error text when an alert() blocks the tab; this report is about a script that is still running normally, and about the side effects that have already completed when the error is returned).

Attachments

<details><summary>server.py</summary>

#!/usr/bin/env python3
"""Minimal local server for reproducing "the tool timed out but the POST went through".

Python standard library only. Binds 127.0.0.1:8765 (override: python server.py 8766).

Routes
  GET  /                 page.html (title + csrf-like meta tag)
  GET  /a   GET /b       two clearly different pages (for the navigate -> get_page_text check)
                         optional  ?slow=N  -> server waits N seconds before sending the page
  POST /submit           record the POST, then respond  {"ok": true, "count": N}
  POST /submit?delay=N   same, but the response is sent N seconds AFTER the record was written
  POST /submit/N         same as ?delay=N (for callers that cannot type ? or =)
  GET  /count            {"count": N}      N = number of lines in posts.log
  GET  /log              posts.log as text/plain

posts.log sits next to this file. One line per accepted POST:
  <ISO timestamp, ms>\tlen=<body length>\tdelay=<seconds>\tbody=<first 80 chars of body>
The line is written, flushed and fsynced BEFORE any delay, so /count tells you whether the
server received the POST regardless of whether the client ever saw the response.
Delete posts.log to reset the count.
"""
import json
import os
import re
import sys
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit

HERE = os.path.dirname(os.path.abspath(__file__))
LOG_PATH = os.path.join(HERE, "posts.log")
PAGE_PATH = os.path.join(HERE, "page.html")
DEFAULT_PORT = 8765
MAX_DELAY = 600.0  # seconds; guards against typos such as delay=5000

LOCK = threading.Lock()
SERVE_COUNT = {"a": 0, "b": 0}


def now_iso():
    return datetime.now().astimezone().isoformat(timespec="milliseconds")


def _num(text, default=0.0):
    try:
        return float(text)
    except (TypeError, ValueError):
        return default


def _count_locked():
    try:
        with open(LOG_PATH, "rb") as f:
            return sum(1 for _ in f)
    except FileNotFoundError:
        return 0


def count_posts():
    with LOCK:
        return _count_locked()


def record_post(body, delay):
    """Append one line to posts.log and return the new count. Called BEFORE any delay."""
    excerpt = body[:80].decode("utf-8", "replace")
    excerpt = excerpt.replace("\r", " ").replace("\n", " ").replace("\t", " ")
    line = "%s\tlen=%d\tdelay=%g\tbody=%s\n" % (now_iso(), len(body), delay, excerpt)
    with LOCK:
        with open(LOG_PATH, "a", encoding="utf-8") as f:
            f.write(line)
            f.flush()
            os.fsync(f.fileno())
        return _count_locked()


def page_ab(which, slow):
    """Two pages that cannot be confused with each other: A says ALPHA, B says BRAVO."""
    word = {"a": "ALPHA", "b": "BRAVO"}[which]
    with LOCK:
        SERVE_COUNT[which] += 1
        n = SERVE_COUNT[which]
    marker = "PAGE-%s-%s-%s" % (which.upper(), word, word)
    html = (
        "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
        "<title>PAGE-%s %s</title>\n</head>\n<body>\n"
        "<h1>PAGE-%s %s</h1>\n"
        "<p>marker: %s</p>\n"
        "<p>served at %s</p>\n"
        "<p>serve number %d of this page since the server started</p>\n"
        "<p>server-side delay before sending: %g s</p>\n"
        "</body>\n</html>\n"
    ) % (which.upper(), word, which.upper(), word, marker, now_iso(), n, slow)
    return html.encode("utf-8")


class Handler(BaseHTTPRequestHandler):
    server_version = "ReproDoublePost/1.0"

    # --- logging with millisecond timestamps on stderr -----------------------------------
    def log_message(self, fmt, *args):
        sys.stderr.write("%s %s %s\n" % (now_iso(), self.address_string(), fmt % args))
        sys.stderr.flush()

    # --- helpers ---------------------------------------------------------------------------
    def _send(self, status, ctype, payload):
        try:
            self.send_response(status)
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(len(payload)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(payload)
            self.wfile.flush()
            return True
        except (ConnectionError, OSError) as e:
            # The client went away (tab closed / navigated) before we finished. Not an error
            # for this experiment: the record was already written.
            self.log_message("client gone before response could be sent: %s", e)
            return False

    def _json(self, status, obj):
        return self._send(status, "application/json", json.dumps(obj).encode("utf-8"))

    # --- GET ------------------------------------------------------------------------------
    def do_GET(self):
        parts = urlsplit(self.path)
        query = parse_qs(parts.query)
        path = parts.path

        if path == "/":
            try:
                with open(PAGE_PATH, "rb") as f:
                    data = f.read()
            except FileNotFoundError:
                self._send(500, "text/plain; charset=utf-8", b"page.html is missing next to server.py")
                return
            self._send(200, "text/html; charset=utf-8", data)

        elif path in ("/a", "/b"):
            slow = min(max(_num(query.get("slow", ["0"])[0]), 0.0), MAX_DELAY)
            if slow:
                self.log_message("GET %s: sleeping %g s before sending", path, slow)
                time.sleep(slow)
            self._send(200, "text/html; charset=utf-8", page_ab(path[1:], slow))

        elif path == "/count":
            self._json(200, {"count": count_posts()})

        elif path == "/log":
            try:
                with open(LOG_PATH, "rb") as f:
                    data = f.read()
            except FileNotFoundError:
                data = b""
            self._send(200, "text/plain; charset=utf-8", data)

        else:
            self._send(404, "text/plain; charset=utf-8", b"not found")

    # --- POST -----------------------------------------------------------------------------
    def do_POST(self):
        parts = urlsplit(self.path)
        m = re.fullmatch(r"/submit(?:/(\d+(?:\.\d+)?))?", parts.path)
        if not m:
            self._send(404, "text/plain; charset=utf-8", b"not found")
            return

        if m.group(1) is not None:
            delay = _num(m.group(1))
        else:
            delay = _num(parse_qs(parts.query).get("delay", ["0"])[0])
        delay = min(max(delay, 0.0), MAX_DELAY)

        length = int(self.headers.get("Content-Length") or 0)
        body = self.rfile.read(length) if length > 0 else b""

        n = record_post(body, delay)  # <-- recorded first, before any waiting
        self.log_message("POST recorded  count=%d len=%d delay=%g", n, len(body), delay)

        if delay:
            time.sleep(delay)

        if self._json(200, {"ok": True, "count": n}):
            self.log_message("POST response sent  count=%d (after %g s)", n, delay)
        else:
            self.log_message("POST response NOT delivered  count=%d (after %g s) - record stays", n, delay)


def main():
    port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
    ThreadingHTTPServer.daemon_threads = True
    ThreadingHTTPServer.allow_reuse_address = True
    httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
    print("serving on http://127.0.0.1:%d/   posts.log: %s   (Ctrl+C to stop)" % (port, LOG_PATH), flush=True)
    print("current count: %d" % count_posts(), flush=True)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        httpd.server_close()


if __name__ == "__main__":
    main()

</details>

<details><summary>page.html</summary>

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Repro Double Post</title>
<meta name="csrf-token" content="repro-csrf-token-0001">
</head>
<body>
<h1>Repro Double Post</h1>
<p>marker: ROOT-PAGE-READY</p>
<p>This page does nothing by itself. The operator runs the JavaScript snippets from STEPS.md against it.</p>
<p>The csrf-like token is in a meta tag named csrf-token; the snippets read it and send it as a request header, the way a real site would.</p>
</body>
</html>

</details>

<details><summary>posts_run_20261012.log (2.1.283 run)</summary>

2026-10-12T08:09:45.683+09:00	len=18	delay=0	body=nonce curl-control
2026-10-12T08:10:34.169+09:00	len=21	delay=50	body=nonce A-1791760234139
2026-10-12T08:10:58.989+09:00	len=22	delay=50	body=nonce AT-1791760258978

</details>

<details><summary>server_run_20261012.log (2.1.283 run)</summary>

serving on http://127.0.0.1:8765/   posts.log: <local path>\posts.log
current count: 0
2026-10-12T08:09:45.552+09:00 127.0.0.1 "GET /count HTTP/1.1" 200 -
2026-10-12T08:09:45.698+09:00 127.0.0.1 POST recorded  count=1 len=18 delay=0
2026-10-12T08:09:45.698+09:00 127.0.0.1 "POST /submit HTTP/1.1" 200 -
2026-10-12T08:09:45.700+09:00 127.0.0.1 POST response sent  count=1 (after 0 s)
2026-10-12T08:09:45.820+09:00 127.0.0.1 "GET /count HTTP/1.1" 200 -
2026-10-12T08:09:52.278+09:00 127.0.0.1 "GET / HTTP/1.1" 200 -
2026-10-12T08:10:24.374+09:00 127.0.0.1 "GET /count HTTP/1.1" 200 -
2026-10-12T08:10:34.177+09:00 127.0.0.1 POST recorded  count=2 len=21 delay=50
2026-10-12T08:10:36.864+09:00 127.0.0.1 "GET /count HTTP/1.1" 200 -
2026-10-12T08:10:39.067+09:00 127.0.0.1 "GET /favicon.ico HTTP/1.1" 404 -
2026-10-12T08:10:58.083+09:00 127.0.0.1 "GET /count HTTP/1.1" 200 -
2026-10-12T08:10:59.001+09:00 127.0.0.1 POST recorded  count=3 len=22 delay=50
2026-10-12T08:11:24.178+09:00 127.0.0.1 "POST /submit?delay=50 HTTP/1.1" 200 -
2026-10-12T08:11:24.180+09:00 127.0.0.1 POST response sent  count=2 (after 50 s)
2026-10-12T08:11:45.795+09:00 127.0.0.1 "GET /count HTTP/1.1" 200 -
2026-10-12T08:11:49.002+09:00 127.0.0.1 "POST /submit/50 HTTP/1.1" 200 -
2026-10-12T08:11:49.004+09:00 127.0.0.1 POST response sent  count=3 (after 50 s)

</details>

View original on GitHub ↗