File size: 9,571 Bytes
19dfd32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bae67da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19dfd32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""
Hugging Face Spaces entrypoint β€” Gradio SDK on the free ZeroGPU tier
====================================================================

Free personal accounts can only host Gradio Spaces on ZeroGPU hardware
(CPU-basic requires a paid plan), so this launcher satisfies ZeroGPU's
startup contract while keeping the exact three-service architecture the
Docker image used:

    this process (port 7860, public)
      β”œβ”€β”€ Agent.worker      β†’ spawned uvicorn :8700  (Text2SQL / DocVQA backend)
      β”œβ”€β”€ Agent.mcp_server  β†’ spawned uvicorn :8701  (MCP tool server)
      └── web.app FastAPI   β†’ grafted into Gradio's own server on :7860

ZeroGPU contract (all four sources of failure, proven by real deploys):
  1. ``spaces`` must be imported BEFORE gradio ("ZeroGPU spaces error if
     the spaces library is not imported first β€” stricter since v2").
  2. At least one ``@spaces.GPU`` function must exist at module level.
  3. ZeroGPU's startup scan walks GRADIO'S REGISTERED EVENT HANDLERS β€” a
     decorated function that is never bound to any event does not count
     ("No @spaces.GPU function detected during startup"). Our no-op probe
     is therefore registered as the ``.click`` handler of an invisible
     button, so it is discoverable but can never actually be invoked
     (nothing requests a GPU β†’ zero quota consumed).
  4. A real ``demo.launch()`` is mandatory (``gr.mount_gradio_app`` +
     ``uvicorn.run`` suppresses the ZeroGPU startup report path).

Serving strategy (proven pattern for FastAPI-on-ZeroGPU spaces):
  - ``demo.launch(server_port=7860, prevent_thread_lock=True,
    ssr_mode=False)`` makes Gradio own the public port.
  - Our full FastAPI web app (sepia UI + /api proxies) is then grafted
    into ``demo.app.router.routes`` as a Mount inserted in FRONT of
    Gradio's routes, so ``/`` serves the sepia UI (Gradio also registers
    ``get /`` and ``get /static/{path}``, which would otherwise shadow it).
  - ``ssr_mode=False`` is mandatory: Spaces' Node SSR proxy intercepts
    every request it does not recognise as Gradio-owned and never
    forwards it to Python.
  - Mounted sub-apps do not receive lifespan events, so the shared
    httpx client of ``web.app`` is created eagerly after launch.

Outside ZeroGPU (local dev / Docker), the previous behaviour is kept:
``gr.mount_gradio_app`` + ``uvicorn.run``.
"""
import os
import socket
import sys
import time
import traceback

# -----------------------------------------------------------------------------
# Diagnostic env snapshot β€” printed FIRST so every deploy log is self-sufficient
# -----------------------------------------------------------------------------
_ZEROGPU_ENV_KEYS = (
    "SPACES_ZERO_GPU",
    "SPACES_ZERO_DEVICE_API_URL",
    "SPACES_GRADIO_AUTO_WRAP",
    "ZEROGPU_V2",
    "ZEROGPU_OFFLOAD_DIR",
    "GRADIO_SERVER_PORT",
    "PORT",
)
print("[LAUNCHER] QueryLens app.py starting; ZeroGPU env snapshot:", flush=True)
for _key in _ZEROGPU_ENV_KEYS:
    print(f"[LAUNCHER]   env {_key}={os.environ.get(_key)!r}", flush=True)

# ZeroGPU rule 1: import spaces BEFORE anything else (gradio in particular)
try:
    import spaces  # noqa: E402  (provided by the HF runtime; absent locally)
except Exception:
    spaces = None
    print("[LAUNCHER] 'spaces' import FAILED β€” running in non-ZeroGPU mode:", flush=True)
    traceback.print_exc()

if spaces is not None:
    # NB: spaces.config is a MODULE; the Settings instance is spaces.config.Config.
    _zero_settings = getattr(spaces.config, "Config", None)
    print(
        f"[LAUNCHER] spaces zero_gpu = {getattr(_zero_settings, 'zero_gpu', None)}, "
        f"device_api = {getattr(_zero_settings, 'zero_device_api_url', None)!r}",
        flush=True,
    )

import subprocess
from pathlib import Path

ROOT = Path(__file__).resolve().parent
PORT = int(os.environ.get("PORT", "7860"))


# -----------------------------------------------------------------------------
# Background services (same as start_hf.sh in the Docker image).
# Port-guarded: the Spaces runtime can re-execute this script inside the same
# container, and duplicate uvicorn children would just exit with bind errors.
# -----------------------------------------------------------------------------
def _listening(port: int) -> bool:
    with socket.socket() as s:  # type: ignore[name-defined]
        s.settimeout(0.25)
        return s.connect_ex(("127.0.0.1", port)) == 0


def _spawn(module: str, port: int, name: str):
    if _listening(port):
        print(f"[LAUNCHER] {name} already listening on :{port} β€” skipping spawn", flush=True)
        return None
    print(f"[LAUNCHER] starting {name} on :{port} ...", flush=True)
    return subprocess.Popen(
        [sys.executable, "-u", "-m", "uvicorn", module,
         "--host", "0.0.0.0", "--port", str(port)],
        cwd=str(ROOT),
    )


_children = {}


def _supervise(specs):
    """Respawn dead background services forever.

    The Spaces container can OOM-kill or crash the worker (it is the
    heaviest process: torch + llama-index + chat stores). Without
    supervision that meant "all queries fail until a manual restart";
    with it, the launcher brings a fresh worker back within seconds.
    """
    while True:
        for key, module, port, name in specs:
            if _listening(port):
                continue  # healthy (or handled by an older instance)
            proc = _children.get(key)
            if proc is not None and proc.poll() is None:
                continue  # spawned and still starting up (heavy import)
            print(f"[LAUNCHER] {name} is DOWN - respawning on :{port}", flush=True)
            try:
                _children[key] = _spawn(module, port, name)
            except Exception as spawn_err:
                print(f"[LAUNCHER] respawn of {name} failed: {spawn_err}", flush=True)
        time.sleep(10)


_supervise_specs = (
    ("worker", "Agent.worker:app", 8700, "backend worker"),
    ("mcp", "Agent.mcp_server:app", 8701, "MCP tool server"),
)
for _key, _module, _port, _name in _supervise_specs:
    _children[_key] = _spawn(_module, _port, _name)

import threading  # noqa: E402

threading.Thread(target=_supervise, args=(_supervise_specs,), daemon=True).start()

import gradio as gr  # noqa: E402  (after 'spaces' β€” ZeroGPU rule 1)
from web.app import app as fastapi_app  # noqa: E402

_ZERO_GPU = bool(spaces is not None and getattr(getattr(spaces.config, "Config", None), "zero_gpu", False))
print(f"[LAUNCHER] ZeroGPU branch = {_ZERO_GPU}", flush=True)

if spaces is not None:
    @spaces.GPU
    def _zerogpu_platform_check():
        """Registered below as an invisible-button click handler.

        Never meaningfully called in the UI: it exists because ZeroGPU's
        startup scan walks Gradio's registered event handlers and kills the
        container when none of them is decorated. The decorator only
        allocates a GPU when the function actually runs, and the button is
        invisible β€” so zero GPU quota is consumed for our CPU-only app."""
        return None

with gr.Blocks(title="QueryLens") as _lp:
    gr.Markdown(
        "**QueryLens** β€” the full sepia UI is served at this Space's root `/`.\n\n"
        "This page is only an anchor for the Gradio runtime."
    )
    gr.HTML('<a href="/" style="text-decoration:none;font-weight:600;">← Open the QueryLens UI</a>')
    if spaces is not None:
        # ZeroGPU rule 3: the decorated no-op must be bound to a real event
        # so the startup scan discovers it. Invisible => unclickable.
        _probe_btn = gr.Button("ZeroGPU platform probe", visible=False)
        _probe_btn.click(_zerogpu_platform_check, None, None)


if __name__ == "__main__":
    if _ZERO_GPU:
        import httpx
        import web.app as _webapp
        from starlette.routing import Mount

        try:
            # Gradio owns the public port; prevent_thread_lock returns control
            # so we can graft the web app below, then block_thread holds the
            # main thread (what launch() would have done).
            _lp.launch(
                server_name="0.0.0.0",
                server_port=PORT,
                prevent_thread_lock=True,
                ssr_mode=False,
            )
        except OSError as exc:
            # The Spaces runtime re-executed this script while a first copy is
            # already serving β€” do not touch the port, just idle forever.
            print(f"[LAUNCHER] port {PORT} already held by the first instance ({exc}); idling", flush=True)
            while True:
                time.sleep(3600)

        # Mounted sub-apps do not receive startup events β€” initialise the
        # shared httpx client eagerly so /api proxies never see client=None.
        if _webapp.client is None:
            _webapp.client = httpx.AsyncClient(timeout=_webapp.WORKER_TIMEOUT)

        # Serve the full web app in front of Gradio's own routes: the visible
        # UI (root /) becomes the sepia web app, /api/* proxies work as-is.
        _lp.app.router.routes.insert(0, Mount("/", app=fastapi_app, name="querylens_web"))
        print(f"[LAUNCHER] QueryLens web UI live on http://0.0.0.0:{PORT}", flush=True)
        try:
            _lp.block_thread()
        except KeyboardInterrupt:
            pass
    else:
        app = gr.mount_gradio_app(fastapi_app, _lp, path="/gradio-lp")
        print(f"[LAUNCHER] QueryLens web UI live on http://0.0.0.0:{PORT}", flush=True)
        import uvicorn
        uvicorn.run(app, host="0.0.0.0", port=PORT)