Skip to content
5 changes: 5 additions & 0 deletions .changeset/free-eggs-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gradio": minor
---

feat:Report why the Node SSR server failed, and serve without SSR on the expected port
112 changes: 105 additions & 7 deletions gradio/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
Error,
InvalidApiNameError,
InvalidComponentError,
ServerFailedToStartError,
ShareCertificateWriteError,
)
from gradio.helpers import create_tracker, skip, special_args
Expand Down Expand Up @@ -641,6 +642,15 @@ def _find_free_port(host: str, start: int, try_count: int = 100) -> int:
raise OSError(f"Cannot find empty port in range: {start}-{start + try_count - 1}.")


def _port_is_free(host: str, port: int) -> bool:
"""Whether *port* can be bound, checked the same way as `_find_free_port`."""
try:
_find_free_port(host, start=port, try_count=1)
except OSError:
return False
return True


class BlocksConfig:
def __init__(self, root_block: Blocks):
self._id: int = 0
Expand Down Expand Up @@ -2860,6 +2870,9 @@ def reverse(text):
resolved_num_workers = int(env_val)

self._node_is_proxy = False
# Set when SSR was requested but Node couldn't serve, and we fell back to
# serving the app from Python without SSR.
self._ssr_degraded = False
static_worker_ports: list[int] = []
# Stashed kwargs for the deferred production Node proxy start.
# When set, the user-facing Node front proxy is started after
Expand Down Expand Up @@ -3063,20 +3076,40 @@ def reverse(text):
self.local_api_url = f"{self.local_url.rstrip('/')}{API_PREFIX}/"
if self.mcp_server_obj:
self.mcp_server_obj._local_url = self.local_url
elif not quiet:
warnings.warn(
"Failed to start Node front proxy for SSR; Gradio is "
f"reachable directly on the internal Python port "
f":{self.server_port}. Check the Node installation "
"or set GRADIO_NODE_PATH."
else:
# Python is listening on an internal port that only Node was
# ever going to route to, so with Node gone the app is
# unreachable at the address the user (or the Space's health
# check) is watching. Move Python onto the user-facing port and
# serve without SSR: worse than SSR, far better than dead.
self._ssr_degraded = self._serve_without_node_proxy(
user_port=_pending_node_proxy_kwargs["server_port"],
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
ssl_keyfile_password=ssl_keyfile_password,
)
if not quiet:
if self._ssr_degraded:
warnings.warn(
"Failed to start Node front proxy for SSR; serving "
f"without SSR on {self.local_url} "
"(see the Node server output above)."
)
else:
warnings.warn(
"Failed to start Node front proxy for SSR, and the "
f"user-facing port could not be taken over; Gradio is "
f"reachable only on the internal Python port "
f":{self.server_port}. See the Node server output "
"above, or set ssr_mode=False."
)

if not self.is_colab and not quiet:
if self._node_is_proxy and self.node_port is not None:
print(
f"* Running on local URL: {self.protocol}://{self.server_name}:{self.node_port}, with SSR ⚡ (Node proxy -> Python :{self.server_port})"
)
elif self.ssr_mode:
elif self.ssr_mode and not self._ssr_degraded:
print(
f"* Running on local URL: {self.protocol}://{self.server_name}:{self.server_port}, with SSR ⚡ (dev mode)"
)
Expand Down Expand Up @@ -3351,6 +3384,71 @@ def integrate(
data = {"integration": analytics_integration}
analytics.integration_analytics(data)

def _serve_without_node_proxy(
self,
user_port: int,
ssl_keyfile: str | None = None,
ssl_certfile: str | None = None,
ssl_keyfile_password: str | None = None,
) -> bool:
"""Moves the running Python server from its internal port onto the
user-facing port, for when the Node front proxy failed to start.

In proxy mode Python deliberately binds an internal port and lets Node own
the user-facing one. If Node never comes up, that leaves the app answering
on a port nobody is asking about — on Spaces the container is judged by the
user-facing port, so the app is reported as crashed even though Python is
healthy. Rebinding there serves the app client-side rendered instead.

Returns whether the move happened; the existing server keeps running if the
user-facing port could not be taken over.
"""
from gradio import http_server

internal_port = self.server_port
old_server = self.server

def serve_on(port: int):
return http_server.start_server(
app=self.app,
server_name=self.server_name,
server_port=port,
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
ssl_keyfile_password=ssl_keyfile_password,
)

# Check the port before giving up the one we have. The two servers can't
# overlap: they share an app, so the second would run its lifespan a second
# time and the first's shutdown would delete the app's cache files from
# under it.
if not _port_is_free(self.server_name, user_port):
return False

if old_server is not None:
old_server.close()

try:
server_name, server_port, local_url, server = serve_on(user_port)
except (OSError, ServerFailedToStartError):
# Lost the race for the user-facing port after releasing ours, so go
# back to the internal one rather than leave nothing listening.
server_name, server_port, local_url, server = serve_on(internal_port)
self.server = server
return False

self.server_name = server_name
self.server_port = server_port
self.local_url = local_url
self.local_api_url = f"{local_url.rstrip('/')}{API_PREFIX}/"
self.server = server
self.protocol = (
"https" if local_url.startswith("https") or self.is_colab else "http"
)
if self.mcp_server_obj:
self.mcp_server_obj._local_url = local_url
return True

def close(self, verbose: bool = True) -> None:
"""
Closes the Interface that was launched and frees the port.
Expand Down
55 changes: 50 additions & 5 deletions gradio/node_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import socket
import subprocess
import sys
import threading
import time
import warnings
from collections import deque
from concurrent.futures import TimeoutError
from contextlib import closing
from http.client import HTTPConnection
Expand Down Expand Up @@ -98,6 +100,10 @@ def start_node_process(
return None, None

node_process = None
# Node's own output is hidden unless debug=True, which means a server that starts
# but fails to render leaves no trace. Keep the last of it so a failed startup can
# say what actually went wrong instead of guessing at the Node version.
node_errors: str = ""

for port in server_ports:
try:
Expand Down Expand Up @@ -139,10 +145,23 @@ def start_node_process(
node_process = subprocess.Popen(
[node_path, "--import", register_file, SSR_APP_PATH],
env=env,
stdout=subprocess.DEVNULL if not debug else None,
stderr=subprocess.DEVNULL if not debug else None,
stdout=None if debug else subprocess.DEVNULL,
stderr=None if debug else subprocess.PIPE,
)

# The pipe has to be drained for the lifetime of the process or Node
# blocks once the buffer fills, so a thread keeps reading it and holds
# on to only the most recent lines.
recent_errors: deque[str] = deque(maxlen=50)
stderr_thread = None
if node_process.stderr is not None:
stderr_thread = threading.Thread(
target=drain_stderr,
args=(node_process.stderr, recent_errors),
daemon=True,
)
stderr_thread.start()

# Node starts only after the Python backend is already
# listening (Blocks.launch defers the front-proxy start), so we
# can verify that Node actually renders a page rather than merely
Expand All @@ -162,6 +181,11 @@ def start_node_process(
# If verification failed, terminate the process and try the next port
node_process.terminate()
node_process.wait(timeout=2)
# Let the reader finish the lines already in the pipe before we
# look at them, otherwise the diagnosis can race the output.
if stderr_thread is not None:
stderr_thread.join(timeout=2)
node_errors = "".join(recent_errors).strip() or node_errors
node_process = None

except OSError:
Expand All @@ -179,16 +203,37 @@ def start_node_process(
print(
f"Cannot start Node server on any port in the range {server_ports[0]}-{server_ports[-1]}."
)
print(
"Please install Node 20 or higher and set the environment variable GRADIO_NODE_PATH to the path of your Node executable."
)
if node_errors:
# Node ran and told us why it couldn't serve requests, so pass that along
# rather than pointing at the Node installation, which is evidently fine.
# The same failure usually repeats once per probe, so one tail is enough.
print("The Node server reported:")
print("\n".join(node_errors.splitlines()[-20:]))
else:
print(
"Please install Node 20 or higher and set the environment variable GRADIO_NODE_PATH to the path of your Node executable."
)
print(
"You can explicitly specify a port by setting the environment variable GRADIO_NODE_PORT."
)

return None, None


def drain_stderr(stream, buffer: deque[str]) -> None:
"""Reads the Node server's stderr, keeping only the most recent lines."""
try:
for line in iter(stream.readline, b""):
buffer.append(line.decode("utf-8", errors="replace"))
except Exception:
pass
finally:
try:
stream.close()
except Exception:
pass


def attempt_connection(host: str, port: int) -> bool:
"""Attempts a single connection to the server."""
try:
Expand Down
Loading