From bbb35dbb7a9b0fed0aaed49c8a5e709215b27933 Mon Sep 17 00:00:00 2001 From: Abubakar Abid Date: Tue, 28 Jul 2026 12:39:53 -0700 Subject: [PATCH 1/7] Bundle postcss into the SSR build so ssr_mode works in installed builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sanitize-html require()s postcss at module load to parse style attributes. Before the vite 8 upgrade postcss was bundled into the SSR output; after it, it became external and is looked up at runtime — where the only node_modules the build output has is the one after_build.js installs. Locally and in CI the repo's own node_modules satisfies it, so every SSR render 500s only once gradio is installed from a wheel, which left Python on its internal port and crashed HF Spaces. Adds postcss to ssr.noExternal, a post-build check that fails when the SSR output loads a package it doesn't ship, and surfaces Node's stderr instead of blaming the Node installation when startup verification fails. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/loud-nodes-render.md | 6 + gradio/blocks.py | 4 +- gradio/node_server.py | 55 +++++++- js/app/package.json | 2 +- js/app/verify_build.js | 216 ++++++++++++++++++++++++++++++++ js/app/vite.config.ts | 6 +- test/test_node_proxy.py | 63 ++++++++++ 7 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 .changeset/loud-nodes-render.md create mode 100644 js/app/verify_build.js diff --git a/.changeset/loud-nodes-render.md b/.changeset/loud-nodes-render.md new file mode 100644 index 00000000000..a454e9f2a8f --- /dev/null +++ b/.changeset/loud-nodes-render.md @@ -0,0 +1,6 @@ +--- +"@self/app": patch +"gradio": patch +--- + +fix:Fix SSR mode failing on every render (`Cannot find module 'postcss'`) in installed builds diff --git a/gradio/blocks.py b/gradio/blocks.py index 4290158b6ce..b3d9fd25959 100644 --- a/gradio/blocks.py +++ b/gradio/blocks.py @@ -3067,8 +3067,8 @@ def reverse(text): 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." + f":{self.server_port}. See the Node server output above, " + "or set ssr_mode=False to serve on the expected port." ) if not self.is_colab and not quiet: diff --git a/gradio/node_server.py b/gradio/node_server.py index a0b31e0931a..7eb5d3f9563 100644 --- a/gradio/node_server.py +++ b/gradio/node_server.py @@ -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 @@ -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: @@ -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 @@ -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: @@ -179,9 +203,16 @@ 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." ) @@ -189,6 +220,20 @@ def start_node_process( 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: diff --git a/js/app/package.json b/js/app/package.json index 1234ae7c685..942f84022c4 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -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", diff --git a/js/app/verify_build.js b/js/app/verify_build.js new file mode 100644 index 00000000000..a8d47e632c9 --- /dev/null +++ b/js/app/verify_build.js @@ -0,0 +1,216 @@ +/** + * Guards against the SSR bundle depending on a package that isn't shipped with it. + * + * The build output in gradio/templates/node/build is what ends up in the wheel, and + * the only node_modules it has at runtime is the one after_build.js installs. During + * development every bare specifier also resolves against the repo's own node_modules, + * so a dependency that vite decides to externalise instead of bundling looks fine + * locally and in CI, then throws "Cannot find module" on every render once installed + * from a wheel (see the postcss/sanitize-html regression in #13329). + * + * This walks the built server code, collects every module specifier that is reachable + * at runtime, and fails the build if one of them can't be satisfied by what we ship. + */ +import { readFileSync, existsSync, readdirSync, statSync } from "fs"; +import { builtinModules, createRequire } from "module"; +import { join, resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { parseAstAsync } from "vite"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const build_path = resolve(__dirname, "../../gradio/templates/node/build"); + +// Entry points and chunks that Node actually loads. `client/` is browser output and +// `node_modules/` is already-shipped third party code, so neither is scanned. +const scan_roots = [ + "index.js", + "handler.js", + "shims.js", + "env.js", + "proxy_routes.js", + "server" +]; + +// gradio/templates/hooks.mjs redirects these to the prebuilt svelte bundles that +// ship under gradio/templates/frontend, so they are never resolved from disk. +const hook_resolved = (specifier) => + specifier === "svelte" || specifier.startsWith("svelte/"); + +const builtins = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`) +]); + +// Conservative match for "this string could be a real bare import specifier", +// used because aliased `require` calls can't be identified by callee name. +const specifier_re = /^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(?:\/[\w.-]+)*$/i; + +function walk_js_files(entry) { + const full = join(build_path, entry); + if (!existsSync(full)) return []; + if (statSync(full).isFile()) return full.endsWith(".js") ? [full] : []; + + const files = []; + for (const child of readdirSync(full)) { + files.push(...walk_js_files(join(entry, child))); + } + return files; +} + +/** + * The string value of a node, if it is statically known. Minified output writes + * string literals as template literals (`` require(`postcss`) ``), so both forms + * have to be recognised. + */ +function static_string(node) { + if (!node) return null; + if (node.type === "Literal") { + return typeof node.value === "string" ? node.value : null; + } + if (node.type === "TemplateLiteral" && node.expressions.length === 0) { + return node.quasis[0].value.cooked; + } + return null; +} + +/** + * Collects candidate runtime specifiers from a module's AST. Static imports and + * dynamic `import()` are read directly; `require()` calls are matched by shape + * (a single string literal argument) rather than by callee name, because the + * commonjs interop in bundled dependencies calls a minified alias of + * `createRequire(import.meta.url)` — which is exactly how postcss slipped + * through. Parsing rather than grepping keeps JSDoc `@import` comments out. + */ +function collect_specifiers(node, found = new Set()) { + if (node === null || typeof node !== "object") return found; + + if (Array.isArray(node)) { + for (const child of node) collect_specifiers(child, found); + return found; + } + + if ( + node.type === "ImportDeclaration" || + node.type === "ExportNamedDeclaration" || + node.type === "ExportAllDeclaration" || + node.type === "ImportExpression" + ) { + const source = static_string(node.source); + if (source !== null) found.add(source); + } + + if (node.type === "CallExpression" && node.arguments?.length === 1) { + const argument = static_string(node.arguments[0]); + if (argument !== null) found.add(argument); + } + + for (const key of Object.keys(node)) { + if (key === "type" || key === "start" || key === "end") continue; + collect_specifiers(node[key], found); + } + + return found; +} + +function package_name(specifier) { + const parts = specifier.split("/"); + return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} + +/** Whether the package is present in the node_modules we ship in the build output. */ +function is_shipped(name) { + return existsSync(join(build_path, "node_modules", name, "package.json")); +} + +/** + * Whether the specifier resolves the way it does during development, i.e. by walking + * up from the build output into the repo's own node_modules. That fallback is what + * hides an unshipped dependency locally, so it is also what identifies one. A + * candidate that resolves nowhere is almost always an unrelated string literal + * (`t("foo")`) rather than a dependency. + */ +function resolves_in_repo(specifier, referrer, name) { + try { + createRequire(referrer).resolve(specifier); + return true; + } catch { + // A package can exist but refuse a bare resolve (no `main`, subpath not + // exported), so fall back to checking that the directory is there. + const repo_modules = resolve(__dirname, "../../node_modules"); + return ( + existsSync(join(repo_modules, name, "package.json")) || + existsSync(join(__dirname, "node_modules", name, "package.json")) + ); + } +} + +async function verify() { + const files = scan_roots.flatMap(walk_js_files); + if (files.length === 0) { + console.error( + `No built server files found in ${build_path} — did the build run?` + ); + process.exit(1); + } + + /** @type {Map} */ + const missing = new Map(); + + for (const file of files) { + let ast; + try { + ast = await parseAstAsync(readFileSync(file, "utf-8")); + } catch { + // Not parseable as an ES module; nothing we can assert about it. + continue; + } + + for (const specifier of collect_specifiers(ast)) { + if ( + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("#") || + builtins.has(specifier) || + hook_resolved(specifier) || + !specifier_re.test(specifier) + ) { + continue; + } + + const name = package_name(specifier); + if (is_shipped(name) || !resolves_in_repo(specifier, file, name)) { + continue; + } + + const referrers = missing.get(name) ?? []; + referrers.push(file.slice(build_path.length + 1)); + missing.set(name, referrers); + } + } + + if (missing.size === 0) { + console.log( + `SSR build verified: ${files.length} server files, no unshipped runtime dependencies.` + ); + return; + } + + console.error( + "\nSSR build is not self-contained. These packages are loaded at runtime but are not shipped in the build output:\n" + ); + for (const [name, referrers] of missing) { + console.error(` ${name}`); + for (const referrer of [...new Set(referrers)].slice(0, 3)) { + console.error(` referenced by ${referrer}`); + } + } + console.error( + "\nThey resolve during development via the repo's node_modules, but a wheel install has no such fallback,\n" + + "so every SSR render would fail with 'Cannot find module'.\n" + + "Fix by adding the package to `ssr.noExternal` in js/app/vite.config.ts so it gets bundled,\n" + + "or to the dependencies installed by js/app/after_build.js so it gets shipped.\n" + ); + process.exit(1); +} + +verify(); diff --git a/js/app/vite.config.ts b/js/app/vite.config.ts index 22e3ef17d0c..5acf501113f 100644 --- a/js/app/vite.config.ts +++ b/js/app/vite.config.ts @@ -45,7 +45,11 @@ export default defineConfig(({ mode }) => { resolve: { conditions: ["gradio"] }, - noExternal: ["@gradio/*", "@huggingface/space-header"], + // postcss is a runtime `require` inside sanitize-html (it parses style + // attributes with it). If it stays external, the SSR bundle asks for it + // at render time and every render 500s in a wheel install, where only + // the build output's own node_modules exists. + noExternal: ["@gradio/*", "@huggingface/space-header", "postcss"], external: mode === "development" ? [] : ["svelte", "svelte/*"] }, build: { diff --git a/test/test_node_proxy.py b/test/test_node_proxy.py index 330647aa7cf..c1ff8e0f031 100644 --- a/test/test_node_proxy.py +++ b/test/test_node_proxy.py @@ -1,6 +1,8 @@ """Tests for the Node-as-proxy architecture and static worker routing.""" import shutil +import sys +import time from pathlib import Path import httpx @@ -349,3 +351,64 @@ def probe(): ) finally: self._cleanup(demo) + + +@pytest.mark.skipif( + sys.platform == "win32", reason="uses a shell script to stand in for node" +) +class TestNodeStartupDiagnostics: + """When the Node SSR server starts but can't serve a page, the reason it + gives has to reach the user. It previously went to DEVNULL and the failure + was reported as a missing Node installation — which is what made the + unshipped-postcss regression so hard to diagnose on HF Spaces.""" + + def _fake_node(self, tmp_path, message): + """A stand-in for node that complains on stderr and then just sits there, + like a Node server that binds its port but 500s on every render.""" + script = tmp_path / "fake_node.sh" + script.write_text(f'#!/bin/sh\necho "{message}" >&2\nsleep 30\n') + script.chmod(0o755) + return str(script) + + def test_node_stderr_is_reported_on_failure(self, tmp_path, monkeypatch, capsys): + from gradio import node_server + + message = "Error: Cannot find module 'postcss'" + + # Real verification polls for up to 30s, so give the process the moment it + # needs to write its error out before we give up on it. + def slow_failing_verify(*args, **kwargs): + time.sleep(1) + return False + + monkeypatch.setattr(node_server, "verify_server_startup", slow_failing_verify) + + process, port = node_server.start_node_process( + node_path=self._fake_node(tmp_path, message), + server_name="127.0.0.1", + server_ports=[18871], + ) + + assert process is None and port is None + out = capsys.readouterr().out + assert message in out, f"Node's stderr was not surfaced. Got: {out}" + assert "install Node 20" not in out, ( + "Blamed the Node installation even though Node ran and explained " + f"itself. Got: {out}" + ) + + def test_missing_node_still_suggests_installing_node(self, monkeypatch, capsys): + """With nothing on stderr to go on, the Node-installation hint is still + the most useful thing to say.""" + from gradio import node_server + + monkeypatch.setattr(node_server, "verify_server_startup", lambda *a, **k: False) + + process, port = node_server.start_node_process( + node_path="/nonexistent/node", + server_name="127.0.0.1", + server_ports=[18872], + ) + + assert process is None and port is None + assert "install Node 20" in capsys.readouterr().out From 97615435f4f03458f477d462e4aa96801bd93ad7 Mon Sep 17 00:00:00 2001 From: Abubakar Abid Date: Tue, 28 Jul 2026 12:50:18 -0700 Subject: [PATCH 2/7] Serve without SSR on the user-facing port when Node can't start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In proxy mode Python binds an internal port and lets Node own the user-facing one, so a Node server that never serves left the app answering only where nothing routes — a Space is judged by the user-facing port, so it was reported as crashed while Python was healthy. Move Python onto the user-facing port instead and serve client-side rendered. The new listener is started before the internal one is released, so failing to take the port leaves the app reachable where it already was. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/loud-nodes-render.md | 2 +- gradio/blocks.py | 93 ++++++++++++++++++++++++++++++--- test/test_node_proxy.py | 48 +++++++++++++++++ 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/.changeset/loud-nodes-render.md b/.changeset/loud-nodes-render.md index a454e9f2a8f..0585433d2d5 100644 --- a/.changeset/loud-nodes-render.md +++ b/.changeset/loud-nodes-render.md @@ -3,4 +3,4 @@ "gradio": patch --- -fix:Fix SSR mode failing on every render (`Cannot find module 'postcss'`) in installed builds +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 diff --git a/gradio/blocks.py b/gradio/blocks.py index b3d9fd25959..21747ca2a4a 100644 --- a/gradio/blocks.py +++ b/gradio/blocks.py @@ -71,6 +71,7 @@ Error, InvalidApiNameError, InvalidComponentError, + ServerFailedToStartError, ShareCertificateWriteError, ) from gradio.helpers import create_tracker, skip, special_args @@ -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 @@ -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}. See the Node server output above, " - "or set ssr_mode=False to serve on the expected port." + 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." + ) + 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)" ) @@ -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. diff --git a/test/test_node_proxy.py b/test/test_node_proxy.py index c1ff8e0f031..2a84ad30ce1 100644 --- a/test/test_node_proxy.py +++ b/test/test_node_proxy.py @@ -412,3 +412,51 @@ def test_missing_node_still_suggests_installing_node(self, monkeypatch, capsys): assert process is None and port is None assert "install Node 20" in capsys.readouterr().out + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node not installed") +class TestNodeProxyFallback: + """In proxy mode Python binds an internal port and lets Node own the + user-facing one. If Node can't serve, the app has to move onto the + user-facing port rather than answer only on a port nothing routes to — + otherwise a Space is reported as crashed while Python is healthy.""" + + def _cleanup(self, demo): + try: + demo.close() + except Exception: + pass + + def test_serves_on_user_facing_port_when_node_fails(self, monkeypatch): + import gradio as gr + import gradio.blocks as blocks_mod + + user_port = 18866 + + def failing_start_node(**kwargs): + return kwargs["server_name"], None, None + + monkeypatch.setattr(blocks_mod, "start_node_server", failing_start_node) + + demo = gr.Interface(lambda x: x, "text", "text") + try: + demo.launch( + ssr_mode=True, + server_port=user_port, + prevent_thread_lock=True, + quiet=True, + ) + + assert demo._ssr_degraded, ( + "Node failed to start but Gradio did not fall back to serving " + "on the user-facing port" + ) + assert demo.server_port == user_port, ( + f"Expected Python on the user-facing port {user_port}, " + f"got {demo.server_port}" + ) + assert f":{user_port}" in demo.local_url + resp = httpx.get(f"http://127.0.0.1:{user_port}/", timeout=10) + assert resp.status_code == 200 + finally: + self._cleanup(demo) From 167aced378fac955380f6b7315248c16c479500a Mon Sep 17 00:00:00 2001 From: Abubakar Abid Date: Tue, 28 Jul 2026 12:57:26 -0700 Subject: [PATCH 3/7] Detect require calls precisely, and don't overlap the two servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build check matched any one-argument call with a string literal as a require, so `headers.get("cookie")` read as a dependency on the `cookie` package. It only passed locally because pnpm's layout can't resolve `cookie` from the build output, while CI's can — the check failed every frontend build. Resolve `require` through its binding instead: follow the name imported as `createRequire` to the name its result is bound to, which is what the minified CJS interop calls. With no false positives left, drop the "resolves in the repo" filter and treat any unshipped runtime dependency as a failure. Also close the internal server before binding the user-facing port. The two share an app, so overlapping them ran its lifespan twice and let the first server's shutdown delete the app's cache files from under the second. If the port is lost after releasing ours, go back to the internal one. Co-Authored-By: Claude Opus 5 (1M context) --- gradio/blocks.py | 42 ++++++++---- js/app/verify_build.js | 138 +++++++++++++++++++++++----------------- test/test_node_proxy.py | 30 +++++++++ 3 files changed, 141 insertions(+), 69 deletions(-) diff --git a/gradio/blocks.py b/gradio/blocks.py index 21747ca2a4a..0fde3759a98 100644 --- a/gradio/blocks.py +++ b/gradio/blocks.py @@ -642,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 @@ -3393,30 +3402,41 @@ def _serve_without_node_proxy( 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. + user-facing port could not be taken over. """ from gradio import http_server + internal_port = self.server_port old_server = self.server - try: - server_name, server_port, local_url, server = http_server.start_server( + + def serve_on(port: int): + return http_server.start_server( app=self.app, server_name=self.server_name, - server_port=user_port, + server_port=port, ssl_keyfile=ssl_keyfile, ssl_certfile=ssl_certfile, ssl_keyfile_password=ssl_keyfile_password, ) - except (OSError, ServerFailedToStartError): + + # 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 - # 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 + 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 diff --git a/js/app/verify_build.js b/js/app/verify_build.js index a8d47e632c9..aa347790202 100644 --- a/js/app/verify_build.js +++ b/js/app/verify_build.js @@ -12,7 +12,7 @@ * at runtime, and fails the build if one of them can't be satisfied by what we ship. */ import { readFileSync, existsSync, readdirSync, statSync } from "fs"; -import { builtinModules, createRequire } from "module"; +import { builtinModules } from "module"; import { join, resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { parseAstAsync } from "vite"; @@ -41,10 +41,6 @@ const builtins = new Set([ ...builtinModules.map((m) => `node:${m}`) ]); -// Conservative match for "this string could be a real bare import specifier", -// used because aliased `require` calls can't be identified by callee name. -const specifier_re = /^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(?:\/[\w.-]+)*$/i; - function walk_js_files(entry) { const full = join(build_path, entry); if (!existsSync(full)) return []; @@ -73,40 +69,91 @@ function static_string(node) { return null; } -/** - * Collects candidate runtime specifiers from a module's AST. Static imports and - * dynamic `import()` are read directly; `require()` calls are matched by shape - * (a single string literal argument) rather than by callee name, because the - * commonjs interop in bundled dependencies calls a minified alias of - * `createRequire(import.meta.url)` — which is exactly how postcss slipped - * through. Parsing rather than grepping keeps JSDoc `@import` comments out. - */ -function collect_specifiers(node, found = new Set()) { - if (node === null || typeof node !== "object") return found; +/** Every node in the tree, so the passes below can look at them in any order. */ +function flatten(node, nodes = []) { + if (node === null || typeof node !== "object") return nodes; if (Array.isArray(node)) { - for (const child of node) collect_specifiers(child, found); - return found; + for (const child of node) flatten(child, nodes); + return nodes; } - if ( - node.type === "ImportDeclaration" || - node.type === "ExportNamedDeclaration" || - node.type === "ExportAllDeclaration" || - node.type === "ImportExpression" - ) { - const source = static_string(node.source); - if (source !== null) found.add(source); + if (typeof node.type === "string") nodes.push(node); + for (const key of Object.keys(node)) { + if (key === "type" || key === "start" || key === "end") continue; + flatten(node[key], nodes); } + return nodes; +} - if (node.type === "CallExpression" && node.arguments?.length === 1) { - const argument = static_string(node.arguments[0]); - if (argument !== null) found.add(argument); +/** + * Collects the module specifiers a file loads at runtime: static imports and + * re-exports, dynamic `import()`, and `require()`. + * + * `require` has to be resolved through its binding rather than matched by name. + * The commonjs interop in bundled dependencies calls a minified alias of + * `createRequire(import.meta.url)` (postcss was loaded as ``d(`postcss`)``), so + * matching only the name `require` would miss it. Matching any one-argument call + * with a string literal instead is far too loose: `headers.get("cookie")` then + * reads as a dependency on the `cookie` package. + */ +function collect_specifiers(ast) { + const nodes = flatten(ast); + const found = new Set(); + + // Local names bound to `createRequire` itself. + const create_require_names = new Set(); + for (const node of nodes) { + if ( + node.type !== "ImportDeclaration" || + !["module", "node:module"].includes(static_string(node.source)) + ) { + continue; + } + for (const specifier of node.specifiers ?? []) { + if ( + specifier.type === "ImportSpecifier" && + specifier.imported?.name === "createRequire" + ) { + create_require_names.add(specifier.local.name); + } + } } - for (const key of Object.keys(node)) { - if (key === "type" || key === "start" || key === "end") continue; - collect_specifiers(node[key], found); + // Local names bound to the require function `createRequire` returns. + const require_names = new Set(["require"]); + for (const node of nodes) { + if ( + node.type === "VariableDeclarator" && + node.id?.type === "Identifier" && + node.init?.type === "CallExpression" && + node.init.callee?.type === "Identifier" && + create_require_names.has(node.init.callee.name) + ) { + require_names.add(node.id.name); + } + } + + for (const node of nodes) { + if ( + node.type === "ImportDeclaration" || + node.type === "ExportNamedDeclaration" || + node.type === "ExportAllDeclaration" || + node.type === "ImportExpression" + ) { + const source = static_string(node.source); + if (source !== null) found.add(source); + } + + if ( + node.type === "CallExpression" && + node.callee?.type === "Identifier" && + require_names.has(node.callee.name) && + node.arguments?.length === 1 + ) { + const argument = static_string(node.arguments[0]); + if (argument !== null) found.add(argument); + } } return found; @@ -122,28 +169,6 @@ function is_shipped(name) { return existsSync(join(build_path, "node_modules", name, "package.json")); } -/** - * Whether the specifier resolves the way it does during development, i.e. by walking - * up from the build output into the repo's own node_modules. That fallback is what - * hides an unshipped dependency locally, so it is also what identifies one. A - * candidate that resolves nowhere is almost always an unrelated string literal - * (`t("foo")`) rather than a dependency. - */ -function resolves_in_repo(specifier, referrer, name) { - try { - createRequire(referrer).resolve(specifier); - return true; - } catch { - // A package can exist but refuse a bare resolve (no `main`, subpath not - // exported), so fall back to checking that the directory is there. - const repo_modules = resolve(__dirname, "../../node_modules"); - return ( - existsSync(join(repo_modules, name, "package.json")) || - existsSync(join(__dirname, "node_modules", name, "package.json")) - ); - } -} - async function verify() { const files = scan_roots.flatMap(walk_js_files); if (files.length === 0) { @@ -171,16 +196,13 @@ async function verify() { specifier.startsWith("/") || specifier.startsWith("#") || builtins.has(specifier) || - hook_resolved(specifier) || - !specifier_re.test(specifier) + hook_resolved(specifier) ) { continue; } const name = package_name(specifier); - if (is_shipped(name) || !resolves_in_repo(specifier, file, name)) { - continue; - } + if (is_shipped(name)) continue; const referrers = missing.get(name) ?? []; referrers.push(file.slice(build_path.length + 1)); diff --git a/test/test_node_proxy.py b/test/test_node_proxy.py index 2a84ad30ce1..de64cb0aaf3 100644 --- a/test/test_node_proxy.py +++ b/test/test_node_proxy.py @@ -460,3 +460,33 @@ def failing_start_node(**kwargs): assert resp.status_code == 200 finally: self._cleanup(demo) + + def test_keeps_internal_port_when_user_port_is_taken(self, monkeypatch): + """If something else holds the user-facing port, the app has to keep + serving where it already is rather than end up with no listener.""" + import socket + + import gradio as gr + + user_port = 18868 + squatter = socket.socket() + squatter.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + squatter.bind(("127.0.0.1", user_port)) + squatter.listen(1) + + demo = gr.Interface(lambda x: x, "text", "text") + try: + demo.launch( + ssr_mode=True, + server_port=user_port, + prevent_thread_lock=True, + quiet=True, + ) + + assert not demo._ssr_degraded + assert demo.server_port != user_port + resp = httpx.get(f"http://127.0.0.1:{demo.server_port}/", timeout=10) + assert resp.status_code == 200 + finally: + squatter.close() + self._cleanup(demo) From 7020aa0bfb1a2ba6b3d9a687e3f5df93d40d9946 Mon Sep 17 00:00:00 2001 From: Abubakar Abid Date: Tue, 28 Jul 2026 12:59:09 -0700 Subject: [PATCH 4/7] Report the degraded URL from local_url so it can't print 0.0.0.0 Per Copilot review: start_server already normalizes 0.0.0.0 to localhost when it builds local_url, so the warning should use that rather than reassembling the address from server_name. Co-Authored-By: Claude Opus 5 (1M context) --- gradio/blocks.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gradio/blocks.py b/gradio/blocks.py index 0fde3759a98..a044c3c6522 100644 --- a/gradio/blocks.py +++ b/gradio/blocks.py @@ -3092,9 +3092,8 @@ def reverse(text): 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." + f"without SSR on {self.local_url} " + "(see the Node server output above)." ) else: warnings.warn( From 3baedaebecf92908345abc2caf336f2b5d8eb1ee Mon Sep 17 00:00:00 2001 From: Abubakar Abid Date: Tue, 28 Jul 2026 13:03:14 -0700 Subject: [PATCH 5/7] Narrow local_url before the membership check to satisfy the typechecker Co-Authored-By: Claude Opus 5 (1M context) --- test/test_node_proxy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_node_proxy.py b/test/test_node_proxy.py index de64cb0aaf3..6dc13dade0e 100644 --- a/test/test_node_proxy.py +++ b/test/test_node_proxy.py @@ -455,6 +455,7 @@ def failing_start_node(**kwargs): f"Expected Python on the user-facing port {user_port}, " f"got {demo.server_port}" ) + assert demo.local_url is not None assert f":{user_port}" in demo.local_url resp = httpx.get(f"http://127.0.0.1:{user_port}/", timeout=10) assert resp.status_code == 200 From c913481cf032d546df1611569db59db1f324acd8 Mon Sep 17 00:00:00 2001 From: gradio-pr-bot Date: Wed, 29 Jul 2026 02:11:35 +0000 Subject: [PATCH 6/7] add changeset --- .changeset/free-eggs-learn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/free-eggs-learn.md diff --git a/.changeset/free-eggs-learn.md b/.changeset/free-eggs-learn.md new file mode 100644 index 00000000000..5aaaf1cdf45 --- /dev/null +++ b/.changeset/free-eggs-learn.md @@ -0,0 +1,5 @@ +--- +"gradio": minor +--- + +feat:Report why the Node SSR server failed, and serve without SSR on the expected port From 4ea89dc803bd5e280a7c05d1d689da1227243a69 Mon Sep 17 00:00:00 2001 From: Abubakar Abid Date: Tue, 28 Jul 2026 19:17:44 -0700 Subject: [PATCH 7/7] changes --- test/test_node_proxy.py | 142 ---------------------------------------- 1 file changed, 142 deletions(-) diff --git a/test/test_node_proxy.py b/test/test_node_proxy.py index 6dc13dade0e..330647aa7cf 100644 --- a/test/test_node_proxy.py +++ b/test/test_node_proxy.py @@ -1,8 +1,6 @@ """Tests for the Node-as-proxy architecture and static worker routing.""" import shutil -import sys -import time from pathlib import Path import httpx @@ -351,143 +349,3 @@ def probe(): ) finally: self._cleanup(demo) - - -@pytest.mark.skipif( - sys.platform == "win32", reason="uses a shell script to stand in for node" -) -class TestNodeStartupDiagnostics: - """When the Node SSR server starts but can't serve a page, the reason it - gives has to reach the user. It previously went to DEVNULL and the failure - was reported as a missing Node installation — which is what made the - unshipped-postcss regression so hard to diagnose on HF Spaces.""" - - def _fake_node(self, tmp_path, message): - """A stand-in for node that complains on stderr and then just sits there, - like a Node server that binds its port but 500s on every render.""" - script = tmp_path / "fake_node.sh" - script.write_text(f'#!/bin/sh\necho "{message}" >&2\nsleep 30\n') - script.chmod(0o755) - return str(script) - - def test_node_stderr_is_reported_on_failure(self, tmp_path, monkeypatch, capsys): - from gradio import node_server - - message = "Error: Cannot find module 'postcss'" - - # Real verification polls for up to 30s, so give the process the moment it - # needs to write its error out before we give up on it. - def slow_failing_verify(*args, **kwargs): - time.sleep(1) - return False - - monkeypatch.setattr(node_server, "verify_server_startup", slow_failing_verify) - - process, port = node_server.start_node_process( - node_path=self._fake_node(tmp_path, message), - server_name="127.0.0.1", - server_ports=[18871], - ) - - assert process is None and port is None - out = capsys.readouterr().out - assert message in out, f"Node's stderr was not surfaced. Got: {out}" - assert "install Node 20" not in out, ( - "Blamed the Node installation even though Node ran and explained " - f"itself. Got: {out}" - ) - - def test_missing_node_still_suggests_installing_node(self, monkeypatch, capsys): - """With nothing on stderr to go on, the Node-installation hint is still - the most useful thing to say.""" - from gradio import node_server - - monkeypatch.setattr(node_server, "verify_server_startup", lambda *a, **k: False) - - process, port = node_server.start_node_process( - node_path="/nonexistent/node", - server_name="127.0.0.1", - server_ports=[18872], - ) - - assert process is None and port is None - assert "install Node 20" in capsys.readouterr().out - - -@pytest.mark.skipif(shutil.which("node") is None, reason="Node not installed") -class TestNodeProxyFallback: - """In proxy mode Python binds an internal port and lets Node own the - user-facing one. If Node can't serve, the app has to move onto the - user-facing port rather than answer only on a port nothing routes to — - otherwise a Space is reported as crashed while Python is healthy.""" - - def _cleanup(self, demo): - try: - demo.close() - except Exception: - pass - - def test_serves_on_user_facing_port_when_node_fails(self, monkeypatch): - import gradio as gr - import gradio.blocks as blocks_mod - - user_port = 18866 - - def failing_start_node(**kwargs): - return kwargs["server_name"], None, None - - monkeypatch.setattr(blocks_mod, "start_node_server", failing_start_node) - - demo = gr.Interface(lambda x: x, "text", "text") - try: - demo.launch( - ssr_mode=True, - server_port=user_port, - prevent_thread_lock=True, - quiet=True, - ) - - assert demo._ssr_degraded, ( - "Node failed to start but Gradio did not fall back to serving " - "on the user-facing port" - ) - assert demo.server_port == user_port, ( - f"Expected Python on the user-facing port {user_port}, " - f"got {demo.server_port}" - ) - assert demo.local_url is not None - assert f":{user_port}" in demo.local_url - resp = httpx.get(f"http://127.0.0.1:{user_port}/", timeout=10) - assert resp.status_code == 200 - finally: - self._cleanup(demo) - - def test_keeps_internal_port_when_user_port_is_taken(self, monkeypatch): - """If something else holds the user-facing port, the app has to keep - serving where it already is rather than end up with no listener.""" - import socket - - import gradio as gr - - user_port = 18868 - squatter = socket.socket() - squatter.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - squatter.bind(("127.0.0.1", user_port)) - squatter.listen(1) - - demo = gr.Interface(lambda x: x, "text", "text") - try: - demo.launch( - ssr_mode=True, - server_port=user_port, - prevent_thread_lock=True, - quiet=True, - ) - - assert not demo._ssr_degraded - assert demo.server_port != user_port - resp = httpx.get(f"http://127.0.0.1:{demo.server_port}/", timeout=10) - assert resp.status_code == 200 - finally: - squatter.close() - self._cleanup(demo)