diff --git a/utils/imgui2rst.das b/utils/imgui2rst.das index 0f117f3..97a0c8d 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 93cb7d0..5de9cf1 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 6b38b41..e442184 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) } }