Studio: fix stuck composer prompt on first send and unreachable --secure Cloudflare links (#7340)

* Studio: clear composer draft on send

* Studio: verify the Cloudflare link is reachable before printing it

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: wait for tunnel DNS propagation before verifying the public URL

* Studio: bound tunnel DNS wait and health probe by one deadline

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: keep composer draft when overlay send validation fails

* Studio: retry transient DoH failures while waiting for tunnel DNS

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Nilay 2026-07-23 13:09:14 +05:30 committed by GitHub
commit bfb6b9600c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 327 additions and 6 deletions

View file

@ -20,6 +20,7 @@ import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Optional, Tuple
@ -40,6 +41,22 @@ _RELEASE_BASE = "https://github.com/cloudflare/cloudflared/releases/latest/downl
_READY_TIMEOUT = 15.0 # seconds to wait for the URL + a registered edge connection
_DOWNLOAD_TIMEOUT = 60 # urlopen timeout for the one-time binary download
# A registered edge connection does not mean the hostname resolves yet, so the
# URL is fetched once before it is advertised.
_PUBLIC_PROBE_PATH = "/api/health"
_PUBLIC_PROBE_MARKER = "Unsloth UI Backend"
# One deadline for DNS propagation + the health probe, bounding the startup stall.
_PUBLIC_PROBE_TIMEOUT = 45.0
_PUBLIC_PROBE_ATTEMPT_TIMEOUT = 5.0
_PUBLIC_PROBE_RETRY_DELAY = 1.0
# Wait for the hostname via DoH first: an early OS lookup negative-caches the
# NXDOMAIN for up to 30 min.
_DNS_POLL_DELAY = 2.0
# Retry transient DoH failures, but give up fast when DoH is blocked outright.
_DNS_MAX_DOH_ERRORS = 3
_DOH_URL = "https://cloudflare-dns.com/dns-query?name={host}&type=A"
def _windows_hidden_kwargs() -> dict:
"""Suppress a child console window on Windows; no-op elsewhere."""
@ -191,6 +208,59 @@ def ensure_cloudflared() -> Optional[str]:
return None
def _wait_for_dns(host: str, deadline: float) -> None:
import json
import urllib.request
errors = 0
while True:
answered = False
try:
req = urllib.request.Request(
_DOH_URL.format(host = host),
headers = {"Accept": "application/dns-json", "User-Agent": "unsloth-studio"},
)
with urllib.request.urlopen(req, timeout = 5) as response:
answered = bool(json.loads(response.read(65536)).get("Answer"))
errors = 0
except Exception:
errors += 1
if errors >= _DNS_MAX_DOH_ERRORS:
return
if answered:
return
remaining = deadline - time.monotonic()
if remaining <= 0:
return
time.sleep(min(_DNS_POLL_DELAY, remaining))
def verify_public_url(url: str, timeout: float = _PUBLIC_PROBE_TIMEOUT) -> bool:
import json
import urllib.request
from urllib.parse import urlsplit
deadline = time.monotonic() + timeout
host = urlsplit(url).hostname
if host:
_wait_for_dns(host, deadline)
probe_url = f"{url.rstrip('/')}{_PUBLIC_PROBE_PATH}"
while True:
try:
req = urllib.request.Request(probe_url, headers = {"User-Agent": "unsloth-studio"})
with urllib.request.urlopen(req, timeout = _PUBLIC_PROBE_ATTEMPT_TIMEOUT) as response:
body = response.read(4096)
if json.loads(body).get("service") == _PUBLIC_PROBE_MARKER:
return True
except Exception:
pass
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
time.sleep(min(_PUBLIC_PROBE_RETRY_DELAY, remaining))
class CloudflareTunnel:
"""A cloudflared quick tunnel to http://localhost:<port>. Best-effort throughout.
@ -322,11 +392,12 @@ def start_studio_tunnel(port: int, timeout: float = _READY_TIMEOUT) -> Optional[
"""Start a quick tunnel and return its public URL once it is actually
serving, or None (best-effort).
Waits for cloudflared to both mint the URL and register an edge connection
before returning, so the caller never advertises a URL that yields Cloudflare
error 1033 (HTTP 530). If a URL is minted but no connection registers within
the window (e.g. quic is blocked on this network), retries once forcing the
http2 protocol. On any failure the tunnel is stopped and None is returned.
Waits for cloudflared to both mint the URL and register an edge connection,
then fetches /api/health over the public URL, so the caller never advertises
a link that yields Cloudflare error 1033 (HTTP 530) or an unresolvable host.
If a URL is minted but no connection registers within the window (e.g. quic
is blocked on this network), retries once forcing the http2 protocol. On any
failure the tunnel is stopped and None is returned.
"""
global _active_tunnel, _shutdown_requested
binary = ensure_cloudflared()
@ -349,9 +420,13 @@ def start_studio_tunnel(port: int, timeout: float = _READY_TIMEOUT) -> Optional[
prior, _active_tunnel = _active_tunnel, tunnel
if prior is not None:
prior.stop()
registered = False
try:
tunnel.start()
url = tunnel.wait_for_ready(timeout)
registered = url is not None
if url and not verify_public_url(url):
url = None
except Exception:
url = None
if url:
@ -371,6 +446,9 @@ def start_studio_tunnel(port: int, timeout: float = _READY_TIMEOUT) -> Optional[
# http2 will not help, so do not burn another window on it.
if not saw_url:
return None
# probe failure after registering is DNS propagation; http2 would not help
if registered:
return None
return None

View file

@ -403,11 +403,234 @@ def test_reader_ignores_api_endpoint_failure_line():
assert t.error == "cloudflared exited before emitting a tunnel URL"
# ── public reachability probe ────────────────────────────────────────
class _FakeResponse:
def __init__(self, body):
self._body = body
def read(self, size = -1):
return self._body
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def _patch_urlopen(monkeypatch, handler):
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout = None: handler(req))
@pytest.fixture(autouse = True)
def _stub_dns_wait(monkeypatch, request):
if request.node.name.startswith("test_verify_public_url"):
monkeypatch.setattr(ct, "_wait_for_dns", lambda *a, **kw: None)
def test_wait_for_dns_polls_until_answer(monkeypatch):
calls = []
def handler(req):
calls.append(req.full_url)
if len(calls) < 3:
return _FakeResponse(b'{"Status":3}')
return _FakeResponse(b'{"Status":0,"Answer":[{"data":"104.16.0.1"}]}')
_patch_urlopen(monkeypatch, handler)
monkeypatch.setattr(ct.time, "sleep", lambda _s: None)
ct._wait_for_dns("words.trycloudflare.com", ct.time.monotonic() + 5)
assert len(calls) == 3
assert "name=words.trycloudflare.com" in calls[0]
def test_wait_for_dns_gives_up_at_deadline(monkeypatch):
_patch_urlopen(monkeypatch, lambda req: _FakeResponse(b'{"Status":3}'))
monkeypatch.setattr(ct.time, "sleep", lambda _s: None)
ct._wait_for_dns("words.trycloudflare.com", ct.time.monotonic() + 0.05)
def test_wait_for_dns_retries_transient_doh_error(monkeypatch):
calls = []
def handler(req):
calls.append(req.full_url)
if len(calls) < 3:
raise OSError("transient")
return _FakeResponse(b'{"Status":0,"Answer":[{"data":"104.16.0.1"}]}')
_patch_urlopen(monkeypatch, handler)
monkeypatch.setattr(ct.time, "sleep", lambda _s: None)
ct._wait_for_dns("words.trycloudflare.com", ct.time.monotonic() + 5)
assert len(calls) == 3
def test_wait_for_dns_bails_on_persistent_doh_errors(monkeypatch):
calls = []
def handler(req):
calls.append(req.full_url)
raise OSError("blocked")
_patch_urlopen(monkeypatch, handler)
monkeypatch.setattr(ct.time, "sleep", lambda _s: None)
ct._wait_for_dns("words.trycloudflare.com", ct.time.monotonic() + 5)
assert len(calls) == ct._DNS_MAX_DOH_ERRORS
def test_verify_public_url_accepts_studio_marker(monkeypatch):
seen = {}
def handler(req):
seen["url"] = req.full_url
return _FakeResponse(b'{"status":"healthy","service":"Unsloth UI Backend"}')
_patch_urlopen(monkeypatch, handler)
assert ct.verify_public_url("https://words.trycloudflare.com") is True
assert seen["url"] == "https://words.trycloudflare.com/api/health"
def test_verify_public_url_waits_for_dns_first(monkeypatch):
order = []
monkeypatch.setattr(ct, "_wait_for_dns", lambda host, deadline: order.append(("dns", host)))
def handler(req):
order.append(("probe", req.full_url))
return _FakeResponse(b'{"service":"Unsloth UI Backend"}')
_patch_urlopen(monkeypatch, handler)
assert ct.verify_public_url("https://words.trycloudflare.com") is True
assert order[0] == ("dns", "words.trycloudflare.com")
assert order[1][0] == "probe"
def test_verify_public_url_dns_wait_and_probe_share_deadline(monkeypatch):
# An exhausted DNS wait leaves the probe a single attempt, not a fresh window.
calls = []
monkeypatch.setattr(ct, "_wait_for_dns", lambda host, deadline: None)
def handler(req):
calls.append(req.full_url)
raise OSError("unreachable")
_patch_urlopen(monkeypatch, handler)
assert ct.verify_public_url("https://words.trycloudflare.com", timeout = 0) is False
assert len(calls) == 1
def test_verify_public_url_retries_then_succeeds(monkeypatch):
calls = []
def handler(req):
calls.append(req.full_url)
if len(calls) < 3:
raise OSError("Name or service not known")
return _FakeResponse(b'{"service":"Unsloth UI Backend"}')
_patch_urlopen(monkeypatch, handler)
monkeypatch.setattr(ct.time, "sleep", lambda _s: None)
assert ct.verify_public_url("https://words.trycloudflare.com") is True
assert len(calls) == 3
def test_verify_public_url_rejects_unreachable_host(monkeypatch):
def handler(req):
raise OSError("Name or service not known")
_patch_urlopen(monkeypatch, handler)
monkeypatch.setattr(ct.time, "sleep", lambda _s: None)
assert ct.verify_public_url("https://words.trycloudflare.com", timeout = 0.05) is False
def test_verify_public_url_rejects_foreign_responder(monkeypatch):
# e.g. a Cloudflare error page: no service marker in the body.
_patch_urlopen(monkeypatch, lambda req: _FakeResponse(b"<html>error 1033</html>"))
monkeypatch.setattr(ct.time, "sleep", lambda _s: None)
assert ct.verify_public_url("https://words.trycloudflare.com", timeout = 0.05) is False
@pytest.fixture(autouse = True)
def _stub_public_probe(monkeypatch, request):
# start_studio_tunnel tests use fake hostnames; keep them off the network.
if not request.node.name.startswith("test_start_studio_tunnel"):
return
monkeypatch.setattr(ct, "verify_public_url", lambda url, **kw: True)
def test_start_studio_tunnel_no_binary(monkeypatch):
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: None)
assert ct.start_studio_tunnel(8080) is None
def test_start_studio_tunnel_drops_url_that_is_not_publicly_reachable(monkeypatch):
attempts = []
class _Stub:
def __init__(
self,
port,
binary,
protocol = None,
):
self.url = None
attempts.append(protocol)
def start(self):
self.url = "https://words.trycloudflare.com"
def wait_for_ready(self, timeout):
return self.url
def stop(self):
pass
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
monkeypatch.setattr(ct, "verify_public_url", lambda url, **kw: False)
assert ct.start_studio_tunnel(8080) is None
assert attempts == [None]
assert ct._active_tunnel is None
def test_start_studio_tunnel_returns_url_once_probe_passes(monkeypatch):
probed = []
class _Stub:
def __init__(
self,
port,
binary,
protocol = None,
):
self.url = None
self.protocol = protocol
def start(self):
self.url = "https://words.trycloudflare.com"
def wait_for_ready(self, timeout):
return self.url
def stop(self):
pass
def _probe(url, **kw):
probed.append(url)
return True
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
monkeypatch.setattr(ct, "verify_public_url", _probe)
try:
assert ct.start_studio_tunnel(8080) == "https://words.trycloudflare.com"
assert probed == ["https://words.trycloudflare.com"]
finally:
ct.stop_studio_tunnel()
def test_start_studio_tunnel_registers_before_wait(monkeypatch):
# The tunnel must be visible to stop_studio_tunnel() during the readiness
# wait, else a shutdown in that window orphans cloudflared.

View file

@ -1570,6 +1570,18 @@ const Composer: FC<{
const t = setTimeout(() => writeComposerDraft(draftKey, composerText), 300);
return () => clearTimeout(t);
}, [composerText, draftKey]);
// Without this the restore effect above puts the sent text back when the
// runtime rebinds on the first message.
const draftKeyRef = useRef(draftKey);
useEffect(() => {
draftKeyRef.current = draftKey;
}, [draftKey]);
const clearStoredDraft = useCallback(() => {
const key = draftKeyRef.current;
if (key) {
writeComposerDraft(key, "");
}
}, []);
// react-textarea-autosize re-measures only on value change or window resize,
// not on the width swap from expanding, so it keeps the taller height and
// leaves a stray blank row. Nudge a resize whenever input width changes.
@ -1720,9 +1732,10 @@ const Composer: FC<{
setPendingSend(false);
dismissWaitToast();
if (text.trim().length > 0 || attachments.length > 0) {
clearStoredDraft();
aui.composer().send();
}
}, [pendingSend, indexingActive, aui, dismissWaitToast]);
}, [pendingSend, indexingActive, aui, clearStoredDraft, dismissWaitToast]);
// Drop any queued send + toast on unmount (e.g. thread switch).
useEffect(
@ -1765,6 +1778,7 @@ const Composer: FC<{
flushResourcesSync(() => {
aui.composer().setText("");
});
clearStoredDraft();
startPromptQueue(
[queuedPrompt],
createPromptQueueTarget(),
@ -1798,6 +1812,7 @@ const Composer: FC<{
closeOverlay();
return;
}
clearStoredDraft();
setImageToolsEnabled(true);
setPendingImageEditReference({
threadId: overlay.threadId ?? referenceThreadId,
@ -1815,11 +1830,15 @@ const Composer: FC<{
);
});
closeOverlay();
return;
}
clearStoredDraft();
},
[
aui,
canQueueCurrentPrompt,
clearStoredDraft,
closeOverlay,
composerText,
createPromptQueueTarget,
@ -1921,6 +1940,7 @@ const Composer: FC<{
flushResourcesSync(() => {
aui.composer().setText("");
});
clearStoredDraft();
startPromptQueue([queuedPrompt], createPromptQueueTarget(), true);
}}
onSendClick={interceptSend}