Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion dash_app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def main() -> None:

app = create_combined_app(api_token=args.token or None)
print(f"MaterialScope (Dash) starting on http://{args.host}:{args.port}", flush=True)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
uvicorn.run(app, host=args.host, port=args.port, log_level="info", http="h11")


if __name__ == "__main__":
Expand Down
63 changes: 63 additions & 0 deletions tests/test_deployment_contract.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import ast
import json
import sys
import tomllib
from pathlib import Path

Expand Down Expand Up @@ -58,6 +60,67 @@ def test_container_entrypoint_runs_combined_dash_server_only():
assert "&" not in start_script


def _uvicorn_run_kwargs() -> dict[str, str | int | None]:
"""Extract the keyword arguments of the ``uvicorn.run(...)`` call in the
combined Dash server entrypoint. Non-literal arguments (e.g. ``args.host``)
evaluate to None — the contract only pins literal choices like ``http``."""
tree = ast.parse(_repo_text("dash_app/server.py"))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if (
isinstance(func, ast.Attribute)
and func.attr == "run"
and isinstance(func.value, ast.Name)
and func.value.id == "uvicorn"
):
kwargs: dict[str, str | int | None] = {}
for kw in node.keywords:
if kw.arg is None:
continue
try:
kwargs[kw.arg] = ast.literal_eval(kw.value)
except ValueError:
kwargs[kw.arg] = None
return kwargs
raise AssertionError("dash_app/server.py does not call uvicorn.run(...)")


def test_combined_server_pins_h11_http_parser():
"""The deployed server must pin uvicorn's HTTP parser to h11.

requirements.txt depends on plain ``uvicorn`` (no [standard] extra), so
httptools only ever reaches container images transitively. With
``http="auto"`` uvicorn silently selects the httptools parser whenever it
is importable, and current uvicorn/httptools releases reject valid
proxy-generated request shapes (e.g. absolute-form targets) with
``400 Invalid HTTP request received.`` where h11 accepts them — on Vercel
that 400s every POST /_dash-update-component and the Dash shell never
populates. h11 is uvicorn's own hard dependency and accepts those shapes.
"""
kwargs = _uvicorn_run_kwargs()
assert kwargs.get("http") == "h11"


def test_h11_pin_resolves_to_h11_protocol_even_when_httptools_is_importable():
"""Runtime half of the parser contract: with httptools importable (the
transitive-install scenario behind the Vercel 400s), the server's pinned
configuration must still resolve uvicorn's protocol to H11Protocol."""
import unittest.mock

import uvicorn
from uvicorn.protocols.http.h11_impl import H11Protocol

async def asgi_app(scope, receive, send): # pragma: no cover - never run
return

with unittest.mock.patch.dict(sys.modules, {"httptools": unittest.mock.MagicMock()}):
config = uvicorn.Config(asgi_app, http="h11", lifespan="off")
config.load()
assert config.http_protocol_class is H11Protocol


def test_vercel_seam_reuses_docker_contract():
"""Vercel deploys the existing Dockerfile as a container service — no
duplicated Docker contract (no Dockerfile.vercel, no copied steps)."""
Expand Down
Loading