From e26a9578a104d507408d471977dd489a17e59844 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Fri, 5 Jun 2026 15:53:53 -0700 Subject: [PATCH] playwright: per-request HTTP timeout so a wedged host fails fast (E1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-API client (live_api_transport's POST /command, plus wait_until_ready / get_text / post_signal) used dasHV's convenience GET/POST, which inherit libhv's default (effectively unbounded) response timeout. The host serves the live API on the GLFW main thread that also advances frames, so when a synth command wedges that thread the host stops answering AND stops rendering — and the client blocks on the POST with no timeout, running the test body all the way to the 120s popen watchdog (DEFAULT_TEST_TIMEOUT_SEC) instead of failing in seconds. Seen on #118 windows-latest: test_imgui_synth_ctrl_shortcuts wedged for 159s and tripped "daslang-live timed out (>120s)". Add http_get / http_post helpers in imgui_transport that build an HttpRequest via the existing request() rail with a hard per-request timeout (DEFAULT_REQUEST_TIMEOUT_SEC = 10s, clamped to 1..65535 so misuse can't wrap the uint16 field), and route every client call through them. A wedged host now yields a null / status-0 reply in ~10s, so the wait_until budgets actually bound the failure and the diagnostic is legible instead of a mute 120s hang. Client-side only — no host or daslang change. The convenience GET/POST stay as-is; this uses dasHV's existing request(req) rail (HttpRequest.timeout). The new public http_get / http_post are grouped under "Raw HTTP" in utils/imgui2rst.das to satisfy the Uncategorized doc gate. Functional validation is the #118 rebase on CI (the windows-headless env where the wedge reproduces). Co-Authored-By: Claude Opus 4.8 (1M context) --- utils/imgui2rst.das | 1 + widgets/imgui_playwright.das | 24 ++++--------- widgets/imgui_transport.das | 70 ++++++++++++++++++++++++++++++------ 3 files changed, 67 insertions(+), 28 deletions(-) diff --git a/utils/imgui2rst.das b/utils/imgui2rst.das index 0f117f3b..97a0c8d1 100644 --- a/utils/imgui2rst.das +++ b/utils/imgui2rst.das @@ -181,6 +181,7 @@ def document_module_imgui_transport() { group_by_regex("Send / receive", mod, %regex~^(send_imgui_command|receive_imgui_command|receive_imgui_snapshot|post_command)$%%), group_by_regex("Awaiting", mod, %regex~^(await_imgui|await_imgui_frame|send_with_await)$%%), group_by_regex("Framing / encoding", mod, %regex~^(encode_|decode_|frame_).*%%), + group_by_regex("Raw HTTP", mod, %regex~^(http_get|http_post|HttpReply)$%%), hide_group(group_by_regex("Internal", mod, %regex~^(_|priv_).*%%)) ) document("Transport — pluggable lambda-based IPC for imgui commands", mod, "imgui_transport.rst", groups) diff --git a/widgets/imgui_playwright.das b/widgets/imgui_playwright.das index 93cb7d01..5de9cf1a 100644 --- a/widgets/imgui_playwright.das +++ b/widgets/imgui_playwright.das @@ -183,33 +183,21 @@ def public wait_until_ready(app : ImguiApp; timeout_sec : float) : bool { let start = ref_time_ticks() let timeout_usec = int(timeout_sec * 1000000.0f) while (get_time_usec(start) < timeout_usec) { - var ok = false - GET("{app.base_url}/status") $(resp) { - if (resp != null && resp.status_code == http_status.OK) { - ok = true - } - } - if (ok) return true + if (http_get("{app.base_url}/status").status == int(http_status.OK)) return true sleep(READY_POLL_INTERVAL_MS) } return false } def public get_text(app : ImguiApp; uri : string) : string { - //! GET `uri`; return the raw response body or "". - var out : string - GET("{app.base_url}{uri}") $(resp) { - if (resp != null) { - out = string(resp.body) - } - } - return out + //! GET `uri`; return the raw response body or "" (bounded by the per-request timeout). + return http_get("{app.base_url}{uri}").body } def private post_signal(app : ImguiApp; uri : string) { - POST("{app.base_url}{uri}", "", {"Content-Type" => "application/json"}) $(_resp) { - // intentionally drop the response — caller polled /status to confirm - } + // Caller polls /status to confirm the effect; the response is dropped, but the + // per-request timeout still bounds a wedged host. + let _ = http_post("{app.base_url}{uri}", "") } // ===== Command dispatch ===== diff --git a/widgets/imgui_transport.das b/widgets/imgui_transport.das index 6b38b41d..e4421843 100644 --- a/widgets/imgui_transport.das +++ b/widgets/imgui_transport.das @@ -9,6 +9,7 @@ require dashv/dashv_boost public require daslib/json public require daslib/json_boost public require daslib/fio +require math // Module overview lives in handmade/module-imgui_transport.rst. // Wait helpers are client-side only — server-side blocking would deadlock @@ -19,24 +20,73 @@ typedef Transport = lambda<(action : string; var payload : JsonValue?) : JsonVal let DEFAULT_AWAIT_POLL_MS : uint = 50u let DEFAULT_AWAIT_TIMEOUT_SEC : float = 5.0f +// Per-request HTTP timeout (seconds). Every client call below sets it so a wedged +// host — one that accepts the connection but stops answering, e.g. the GLFW main +// thread (which also serves the live API) blocked mid-command — fails in seconds +// instead of blocking the caller all the way to the popen watchdog. +let public DEFAULT_REQUEST_TIMEOUT_SEC : int = 10 + +struct public HttpReply { + status : int //!< HTTP status code; 0 when there was no response (connect refused or request timed out). + body : string //!< Response body, or "" on no response. +} + +def private clamp_timeout_sec(sec : int) : uint16 { + // libhv's HttpRequest.timeout is uint16 seconds; clamp so a negative or oversized + // value can't wrap into a near-zero or multi-hour timeout that defeats fail-fast. + return uint16(clamp(sec, 1, 65535)) +} + +def public http_get(url : string; timeout_sec : int = DEFAULT_REQUEST_TIMEOUT_SEC) : HttpReply { + //! GET ``url`` with a hard per-request timeout. ``status == 0`` means no response (refused / timed out). + var reply : HttpReply + with_http_request() $(var req) { + req.method = http_method.GET + req.url := url + req.timeout = clamp_timeout_sec(timeout_sec) + request(req) $(resp) { + if (resp != null) { + reply.status = int(resp.status_code) + reply.body = string(resp.body) + } + } + } + return reply +} + +def public http_post(url, body : string; timeout_sec : int = DEFAULT_REQUEST_TIMEOUT_SEC) : HttpReply { + //! POST ``body`` as ``application/json`` to ``url`` with a hard per-request timeout. + var reply : HttpReply + with_http_request() $(var req) { + req.method = http_method.POST + req.url := url + req.body := body + req.timeout = clamp_timeout_sec(timeout_sec) + set_content_type(req, "application/json") + request(req) $(resp) { + if (resp != null) { + reply.status = int(resp.status_code) + reply.body = string(resp.body) + } + } + } + return reply +} + def public live_api_transport(url : string) : Transport { - //! Default transport: POSTs ``{"name": action, "args": payload}`` to ``/command`` and parses the JSON response. ``url`` is captured by value, so the returned lambda is safe to reuse. + //! Default transport: POSTs ``{"name": action, "args": payload}`` to ``/command`` and parses + //! the JSON response. ``url`` is captured by value, so the returned lambda is safe to reuse. Uses + //! ``http_post``'s per-request timeout, so a wedged host yields null rather than blocking the body. return <- @ capture(= url) (action : string; var payload : JsonValue ?) : JsonValue ? { var body_tab : table body_tab |> insert("name", JV(action)) if (payload != null) { body_tab |> insert("args", payload) } - let body = write_json(JV(body_tab)) - var out : string - POST("{url}/command", body, {"Content-Type" => "application/json"}) $(resp) { - if (resp != null) { - out = string(resp.body) - } - } - if (empty(out)) return null + let reply = http_post("{url}/command", write_json(JV(body_tab))) + if (empty(reply.body)) return null var err_str : string - return read_json(out, err_str) + return read_json(reply.body, err_str) } }