johnbridges commited on
Commit
d1fd38d
·
1 Parent(s): 88ea3d4

fixed startup

Browse files
Files changed (3) hide show
  1. app.py +68 -11
  2. listener.py +5 -0
  3. test_app_startup.py +107 -0
app.py CHANGED
@@ -1,5 +1,6 @@
1
  # app.py
2
  import asyncio, logging
 
3
  import gradio as gr
4
 
5
  from config import settings
@@ -56,18 +57,69 @@ DECLS = [
56
 
57
  listener = RabbitListenerBase(base, instance_name=settings.RABBIT_INSTANCE_NAME, handlers=handlers)
58
 
59
- # ----------------- Startup init -----------------
60
- async def _startup_init():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  try:
62
- await base.connect() # connect to RabbitMQ
63
- await listener.start(DECLS) # start queue listeners
64
- return "OpenAI MQ + TimesFM: ready"
65
- except Exception as e:
66
- log.exception("Startup init failed")
67
- return f"ERROR: {e}"
 
 
 
68
 
69
  async def ping():
70
- return "ok"
71
 
72
  # ----------------- Gradio UI -----------------
73
  with gr.Blocks(title="OpenAI over RabbitMQ (local vLLM)", theme=gr.themes.Soft()) as demo:
@@ -78,12 +130,17 @@ with gr.Blocks(title="OpenAI over RabbitMQ (local vLLM)", theme=gr.themes.Soft()
78
  out = gr.Textbox(label="Ping result")
79
  btn.click(ping, inputs=None, outputs=out)
80
  init_status = gr.Textbox(label="Startup status", interactive=False)
81
- demo.load(fn=_startup_init, inputs=None, outputs=init_status)
82
 
83
  with gr.Tab("@spaces.GPU Probe"):
84
  gpu_btn = gr.Button("GPU Ready Probe", variant="primary")
85
  gpu_out = gr.Textbox(label="GPU Probe Result", interactive=False)
86
  gpu_btn.click(gpu_entrypoint, inputs=None, outputs=gpu_out)
87
 
 
88
  if __name__ == "__main__":
89
- demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True, debug=True, mcp_server=True)
 
 
 
 
 
1
  # app.py
2
  import asyncio, logging
3
+ from contextlib import asynccontextmanager
4
  import gradio as gr
5
 
6
  from config import settings
 
57
 
58
  listener = RabbitListenerBase(base, instance_name=settings.RABBIT_INSTANCE_NAME, handlers=handlers)
59
 
60
+ # ----------------- Service lifecycle -----------------
61
+ _startup_lock = asyncio.Lock()
62
+ _service_status = "Starting OpenAI MQ + TimesFM"
63
+ _listener_started = False
64
+
65
+
66
+ async def _start_listener() -> None:
67
+ """Connect and register consumers once for this process."""
68
+ global _listener_started, _service_status
69
+
70
+ async with _startup_lock:
71
+ if _listener_started and base.is_connected():
72
+ return
73
+
74
+ _service_status = "Connecting OpenAI MQ + TimesFM"
75
+ try:
76
+ await base.connect()
77
+ await listener.start(DECLS)
78
+ except Exception as exc:
79
+ _listener_started = False
80
+ _service_status = f"ERROR: {exc}"
81
+ # A partial declaration must not leave a stale connection around if
82
+ # the hosting platform retries this process.
83
+ await base.close()
84
+ log.exception("RabbitMQ listener startup failed")
85
+ raise
86
+
87
+ _listener_started = True
88
+ _service_status = "OpenAI MQ + TimesFM: ready"
89
+ log.info(_service_status)
90
+
91
+
92
+ async def _on_startup() -> None:
93
+ # Fail the application startup if RabbitMQ is unavailable. Serving the UI
94
+ # without its consumer makes a healthy-looking but non-functional service.
95
+ await _start_listener()
96
+
97
+
98
+ async def _on_shutdown() -> None:
99
+ global _listener_started, _service_status
100
+ _listener_started = False
101
+ _service_status = "Stopped"
102
+ await base.close()
103
+
104
+
105
+ @asynccontextmanager
106
+ async def service_lifespan(app):
107
+ # Pass this to launch: Gradio replaces demo.app and composes this lifespan
108
+ # with its own lifecycle (including MCP) on the application it actually serves.
109
+ await _on_startup()
110
  try:
111
+ yield
112
+ finally:
113
+ await _on_shutdown()
114
+
115
+
116
+ async def service_status() -> str:
117
+ """Read-only UI status; lifecycle startup is handled by FastAPI."""
118
+ return _service_status
119
+
120
 
121
  async def ping():
122
+ return "ok" if _listener_started and base.is_connected() else _service_status
123
 
124
  # ----------------- Gradio UI -----------------
125
  with gr.Blocks(title="OpenAI over RabbitMQ (local vLLM)", theme=gr.themes.Soft()) as demo:
 
130
  out = gr.Textbox(label="Ping result")
131
  btn.click(ping, inputs=None, outputs=out)
132
  init_status = gr.Textbox(label="Startup status", interactive=False)
133
+ demo.load(fn=service_status, inputs=None, outputs=init_status)
134
 
135
  with gr.Tab("@spaces.GPU Probe"):
136
  gpu_btn = gr.Button("GPU Ready Probe", variant="primary")
137
  gpu_out = gr.Textbox(label="GPU Probe Result", interactive=False)
138
  gpu_btn.click(gpu_entrypoint, inputs=None, outputs=gpu_out)
139
 
140
+
141
  if __name__ == "__main__":
142
+ demo.launch(
143
+ server_name="0.0.0.0", server_port=7860, show_error=True,
144
+ debug=True, mcp_server=True,
145
+ app_kwargs={"lifespan": service_lifespan},
146
+ )
listener.py CHANGED
@@ -15,6 +15,7 @@ class RabbitListenerBase:
15
  self._instance_name = instance_name
16
  self._handlers = handlers
17
  self._consumers: List[aio_pika.abc.AbstractRobustQueue] = []
 
18
 
19
  def _qname(self, exchange: str, routing_keys: List[str]) -> str:
20
  rk_part = "-".join(sorted([rk for rk in (routing_keys or [""]) if rk])) or ""
@@ -22,6 +23,9 @@ class RabbitListenerBase:
22
  return f"{self._instance_name}-{exchange}{suffix}"
23
 
24
  async def start(self, declarations: List[dict]) -> None:
 
 
 
25
  for d in declarations:
26
  exch = d["ExchangeName"]
27
  ttl = d.get("MessageTimeout") or None
@@ -33,6 +37,7 @@ class RabbitListenerBase:
33
  # manual ack, parity with .NET (autoAck: false)
34
  await q.consume(self._make_consumer(d["FuncName"]), no_ack=False)
35
  self._consumers.append(q)
 
36
 
37
  def _make_consumer(self, func_name: str):
38
  handler = self._handlers.get(func_name)
 
15
  self._instance_name = instance_name
16
  self._handlers = handlers
17
  self._consumers: List[aio_pika.abc.AbstractRobustQueue] = []
18
+ self._started = False
19
 
20
  def _qname(self, exchange: str, routing_keys: List[str]) -> str:
21
  rk_part = "-".join(sorted([rk for rk in (routing_keys or [""]) if rk])) or ""
 
23
  return f"{self._instance_name}-{exchange}{suffix}"
24
 
25
  async def start(self, declarations: List[dict]) -> None:
26
+ if self._started:
27
+ return
28
+
29
  for d in declarations:
30
  exch = d["ExchangeName"]
31
  ttl = d.get("MessageTimeout") or None
 
37
  # manual ack, parity with .NET (autoAck: false)
38
  await q.consume(self._make_consumer(d["FuncName"]), no_ack=False)
39
  self._consumers.append(q)
40
+ self._started = True
41
 
42
  def _make_consumer(self, func_name: str):
43
  handler = self._handlers.get(func_name)
test_app_startup.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exercise real Gradio launch with fake RabbitMQ and forecasting dependencies.
2
+
3
+ Run with: python -m unittest test_app_startup
4
+ Requires gradio[mcp] (5.42.0, as pinned by the Space) and aio-pika.
5
+ """
6
+ import asyncio
7
+ from pathlib import Path
8
+ import runpy
9
+ import socket
10
+ import sys
11
+ import types
12
+ import unittest
13
+ from unittest.mock import AsyncMock, Mock, patch
14
+
15
+ import gradio as gr
16
+ from gradio.routes import App
17
+ from starlette.testclient import TestClient
18
+
19
+
20
+ class AppStartupTests(unittest.TestCase):
21
+ def setUp(self):
22
+ self.base = Mock()
23
+ self.connected = False
24
+
25
+ async def connect():
26
+ self.connected = True
27
+
28
+ async def close():
29
+ self.connected = False
30
+
31
+ self.base.connect = AsyncMock(side_effect=connect)
32
+ self.base.close = AsyncMock(side_effect=close)
33
+ self.base.is_connected = lambda: self.connected
34
+ self.queue = Mock()
35
+ self.queue.consume = AsyncMock(return_value="test-consumer")
36
+ self.base.declare_queue_bind = AsyncMock(return_value=self.queue)
37
+ servers = Mock(handle_chat_create=AsyncMock(), handle_images_generate=AsyncMock())
38
+ self.modules = {}
39
+ for name, attrs in {
40
+ "config": {"settings": types.SimpleNamespace(
41
+ RABBIT_EXCHANGE_TYPE="direct", RABBIT_ROUTING_KEY="execute.api",
42
+ RABBIT_INSTANCE_NAME="startup-test")},
43
+ "rabbit_base": {"RabbitBase": Mock(return_value=self.base)},
44
+ "rabbit_repo": {"RabbitRepo": Mock()},
45
+ "oa_server": {"OpenAIServers": Mock(return_value=servers)},
46
+ "timesfm_backend": {"TimesFMBackend": Mock(), "StubImagesBackend": Mock()},
47
+ }.items():
48
+ module = types.ModuleType(name)
49
+ module.__dict__.update(attrs)
50
+ self.modules[name] = module
51
+ self.modules["spaces"] = None
52
+
53
+ def load_app(self, name="startup_test"):
54
+ with patch.dict(sys.modules, self.modules):
55
+ return runpy.run_path(str(Path(__file__).with_name("app.py")), run_name=name)
56
+
57
+ def test_launch_starts_consumers_without_browser_and_shutdown_closes_connection(self):
58
+ original_launch = gr.Blocks.launch
59
+ launched = []
60
+
61
+ def launch(blocks, **kwargs):
62
+ # Execute the production entry point and real launch implementation.
63
+ # Only change networking/blocking options so this test can finish.
64
+ launched.append(blocks)
65
+ with socket.socket() as sock:
66
+ sock.bind(("127.0.0.1", 0))
67
+ port = sock.getsockname()[1]
68
+ kwargs.update(server_name="127.0.0.1", server_port=port,
69
+ prevent_thread_lock=True, debug=False, quiet=True,
70
+ inbrowser=False, share=False)
71
+ return original_launch(blocks, **kwargs)
72
+
73
+ try:
74
+ with patch.object(gr.Blocks, "launch", launch):
75
+ app = self.load_app("__main__")
76
+ self.base.connect.assert_awaited_once()
77
+ self.assertEqual(self.queue.consume.await_count, 2)
78
+ self.assertTrue(app["demo"].mcp_server)
79
+ for _ in range(2):
80
+ self.assertIn("ready", asyncio.run(app["service_status"]()))
81
+ self.assertEqual(asyncio.run(app["ping"]()), "ok")
82
+ self.assertEqual(self.queue.consume.await_count, 2)
83
+ finally:
84
+ for blocks in launched:
85
+ blocks.close(verbose=False)
86
+ self.base.close.assert_awaited_once()
87
+ self.assertFalse(self.connected)
88
+
89
+ def test_startup_failure_propagates_and_closes_partial_connection(self):
90
+ app = self.load_app()
91
+ self.base.declare_queue_bind.side_effect = RuntimeError("binding failed")
92
+ # Use Gradio's real application factory and lifespan composition.
93
+ server = App.create_app(app["demo"],
94
+ app_kwargs={"lifespan": app["service_lifespan"]},
95
+ mcp_server=True)
96
+ entered = False
97
+ with self.assertRaises(Exception):
98
+ with TestClient(server):
99
+ entered = True
100
+ self.assertFalse(entered, "Server became available despite failed startup")
101
+ self.base.close.assert_awaited_once()
102
+ self.assertFalse(self.connected)
103
+ self.assertEqual(asyncio.run(app["service_status"]()), "ERROR: binding failed")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ unittest.main()