Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/loud-nodes-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@self/app": patch
"gradio": patch
---

fix:Fix SSR mode failing on every render (`Cannot find module 'postcss'`) in installed builds, and serve without SSR on the expected port when the Node server can't start

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as-is: no space after fix: is this repo's existing convention — most changesets on main are written that way (fix:Preserve original filenames..., fix:Reset audio playback position..., feat:workflow: add model endpoint integration), and .changeset/changeset.cjs strips the prefix when generating the changelog, so the space doesn't affect the output.

Applied the other suggestion — the warning now uses self.local_url so it can't print 0.0.0.0.

93 changes: 86 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 @@ -2860,6 +2861,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 +3067,41 @@ 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 "
"without SSR on "
f"{self.protocol}://{self.server_name}:{self.server_port}. "
"See the Node server output above."
)
Comment on lines +3093 to +3097
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 +3376,60 @@ 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 bound.
"""
from gradio import http_server

old_server = self.server
try:
server_name, server_port, local_url, server = http_server.start_server(
app=self.app,
server_name=self.server_name,
server_port=user_port,
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
ssl_keyfile_password=ssl_keyfile_password,
)
except (OSError, ServerFailedToStartError):
return False

# Only let go of the internal port once the new one is actually serving, so
# a failure here leaves the app reachable where it already was.
if old_server is not None:
try:
old_server.close()
except Exception:
pass

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
2 changes: 1 addition & 1 deletion js/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build && node after_build.js && node cleanup_build.js",
"build": "vite build && node after_build.js && node verify_build.js && node cleanup_build.js",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
Expand Down
Loading
Loading