The port-80 connectivity check — Detailed Analysis
Environment:
-
Image:
nvcr.io/nvidia/isaac-sim:4.0.0(Isaac Sim 4.0.0-rc.21) -
Host: RunPod GPU pod (container, Docker bridge net
172.18.0.2) -
Client:
/streaming/webrtc-demo/served by the pod, Chrome 150 -
Pod public IP:
213.173.109.229 -
Reachable ports: Only these, via RunPod’s port mapping:
-
213.173.109.229:19321 -> :3478(coturn, my TURN relay) -
213.173.109.229:19322 -> :8211(Isaac web client + REST) -
213.173.109.229:19323 -> :49100(WebRTC signaling)
-
Nothing is listening on 213.173.109.229:80, and nothing can be. RunPod exposes services only on mapped ports or through its HTTPS proxy. This is normal for essentially every proxy-based cloud GPU provider.
What the client does
kit-player.js (the Ragnarok client) runs checkConnectivity() whenever a session error is reported. Deminified:
JavaScript
checkConnectivity(evt, session, cb) {
if (utils.shouldRunConnectivityTest(session.error.code)) {
let timeout = 1500; // connectivityCheckTimeout
if (RagnarokSettings.ragnarokConfig.connectivityCheckTimeout) {
timeout = RagnarokSettings.ragnarokConfig.connectivityCheckTimeout;
}
// ---- the probe URL: scheme + serverAddress, NO PORT ----
const url = ("http:" === window.location.protocol ? "http://" : "https://")
+ this.initializeParams.serverAddress;
Log.i(TAG, "connectivity url: " + url);
utils.customFetch(url, timeout, { method: "OPTIONS" })
.then(res => {
if ((res.status >= 200 && res.status < 300) || res.status === 403) {
evt.connectivity = "online(...)";
} else {
evt.connectivity = "offline_wrong_status(" + res.status + ")";
}
this.notifyClientWithError(evt, session, cb);
})
.catch(err => {
if (err.name === "AbortError") {
evt.connectivity = "timeout";
} else {
evt.connectivity = "offline(" + err.name + ":" + err.message + ")";
// ---- the error code is REWRITTEN here ----
session.error.code = utils.convertErrorOnConnectivityTest(session.error.code);
evt.result = utils.GetHexString(session.error.code);
}
this.notifyClientWithError(evt, session, cb);
});
} else {
this.notifyClientWithError(evt, session, cb);
}
}
serverAddress is taken from the page’s ?server= parameter. In my case, that is 213.173.109.229 — a bare host with no port, so the probe resolves to [``http://213.173.109.229:80/``](``http://213.173.109.229:80/``). That address is correct as a host. The client is talking to it right now — it loaded the page from :19322 and is running signaling against :19323. But port 80 on that host is not, and never will be, a service.
Why this can never succeed here
The probe is hardcoded to the default port for the scheme. In any environment where the server is reached on a non-default port — port mapping, Docker publish, Kubernetes NodePort, an SSH tunnel, or a reverse proxy on a custom port — the probe targets an address that is not the service. The result is deterministic:
OPTIONS [http://213.173.109.229/](http://213.173.109.229/) net::ERR_CONNECTION_REFUSED
Two things are being conflated: “is the host reachable” and “is port 80 on the host serving HTTP with permissive CORS”. Only the first is what the check wants to know; only the second is what it actually tests.
The Network panel also shows the CORS preflight failing alongside the fetch. So even if something were listening on :80, a cross-origin OPTIONS from [http://213.173.109.229:19322](http://213.173.109.229:19322) would still need explicit CORS headers to be treated as success. The probe has two independent ways to fail before it ever measures connectivity.
Observed behaviour — reproduced twice, independently
Occurrence 1 (17:00:41):
Plaintext
[sleepdetector] sleep 5579 0
[gridapp] connectivity url: http://213.173.109.229
[streamclient] Stopping Stream with error 0xC0F22206
OPTIONS http://213.173.109.229/ net::ERR_CONNECTION_REFUSED <- kit-player.js:7
[streamclient] Couldn't send control channel message
[streamclient] Audio track muted
[RWorker] WebSocket connection closed
Occurrence 2 (17:19:12), same sequence:
Plaintext
[sleepdetector] sleep 5677 0
[gridapp] connectivity url: http://213.173.109.229
[streamclient] Stopping Stream with error 0xC0F22206
OPTIONS http://213.173.109.229/ net::ERR_CONNECTION_REFUSED
Stack, straight from the console:
Plaintext
t.customFetch @ kit-player.js:7
checkConnectivity @ kit-player.js:91
onSessionStart @ kit-player.js:91
notifyStart @ kit-player.js:109
stopStreamWithError @ kit-player.js:109
streamBeginTimeout @ kit-player.js:109
I also saw the same probe on errors 0xC0F22210, 0xC0F22219, and 0x00F22003. Every failure I encountered, whatever its origin, ran this probe and it failed every time.
What the impact actually is — being precise
I want to correct my own original wording. In my forum post, I said the check “converts a recoverable state into a fatal stop.” Reading the code carefully, that overstates it, and I’d rather be exact:
The probe does not itself stop the stream. By the time checkConnectivity runs, stopStreamWithError has already been called — in my case from streamBeginTimeout, because no media ever arrived (the real, separate problem: SRTP over UDP on a TCP-only host). Teardown is already underway.
What it does do is three things, and the third is the harmful one:
-
It is guaranteed to fail in any port-mapped deployment. Not flaky — deterministic.
-
It mislabels every failure as a network outage. Every error I hit was ultimately reported as
offline(...), regardless of true cause. As a user, this cost me a great deal of debugging time chasing a network problem that did not exist. The client was telling me the network was down while it was concurrently holding a healthy WebSocket to that very host. -
It rewrites the error code. In the
.catchpath:JavaScript
session.error.code = utils.convertErrorOnConnectivityTest(session.error.code);And that function performs real mappings:
JavaScript
switch (e) { case 3237093906: if (g()) t = 15867908; break; // 0xC0F22212 -> 0x00F22004 case 3237093654: t = 15867909; break; // 0xC0F22116 -> 0x00F22005 }The mutated code then feeds the client’s own resume logic:
JavaScript
if (t.error) { const e = utils.canResume(t.error.code); t.isResumable = e; Log.i(TAG, "canResume " + e); }So a probe that cannot succeed in this environment is silently reclassifying error codes on a path that decides whether the session is resumable. I have not fully mapped
canResume()'s table, so I won’t assert that this always flips a resumable session to non-resumable — but the data flow is there, and it is driven by a probe whose failure carries no information about the network at all.
Suggested fix
The client already knows at least two endpoints that are known good — it was served from one and is speaking signaling to the other. Any of these removes the failure mode:
-
Probe the signaling endpoint it is already connected to —
serverAddressplus the signal port. Most faithful to intent: “can I still reach the server I’m streaming from.” -
Probe
window.location.origin— the page it was loaded from, by definition reachable. -
Make it configurable.
RagnarokSettings.ragnarokConfigalready carriesconnectivityCheckTimeout; aconnectivityCheckUrl(or a disable flag) alongside it would be a natural, minimal change.
Even option 3 alone would have saved me hours, and would unblock every user on a port-mapped host.
Related, possibly the same root area
On sessions that did reach a connected state, the server logged:
Plaintext
[carb.livestream-rtc.plugin] Stream Server: connected stream 0x... on connection 0x...
[carb.livestream-rtc.plugin] Stream Server: configuration update packet from client
[carb.livestream-rtc.plugin] Stream Server: streaming at 0 x 0.
[carb.livestream-rtc.plugin] Client (nil) disconnected.
streaming at 0 x 0 — a zero-area render viewport — while the client had requested and logged Overriding stream resolution to : 1280x720. If the encoder is producing no pixels, that would independently explain black frames on otherwise-connected sessions. I never chased this one down; flagging it in case it’s known or points somewhere useful.
For completeness — how far the TURN path got
Relevant because it shows ICE itself is not the blocker. With a coturn relay in the pod and iceTransportPolicy: "relay" forced on the client, Chrome reached:
Plaintext
ICE connection state: new => checking => connected
transport (iceState=connected, dtlsState=connected)
candidate-pair (state=succeeded)
selected pair: relay(tcp) turn:213.173.109.229:19321?transport=tcp <=> 172.18.0.2:1024
So ICE completes fine over a TCP relay. The wall is exactly where you said it is: the media itself is SRTP over UDP, and no amount of correct addressing creates a UDP path through a TCP-only proxy.



