Running Isaac Sim in a browser on TCP-only clouds (RunPod): WebRTC livestream can't work — here's a noVNC approach that does

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:

  1. It is guaranteed to fail in any port-mapped deployment. Not flaky — deterministic.

  2. 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.

  3. It rewrites the error code. In the .catch path:

    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 — serverAddress plus 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.ragnarokConfig already carries connectivityCheckTimeout; a connectivityCheckUrl (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.