VibecoderMcSwaggins commited on
Commit
1973147
·
unverified ·
1 Parent(s): 987c4be

fix(ui): vendor NiiVue library to bypass HF Spaces CSP (#24) (#24)

Browse files

* fix(ui): vendor NiiVue library to bypass HF Spaces CSP (#24)

Root cause: HuggingFace Spaces CSP blocks external CDN imports.
The dynamic import from unpkg.com caused the Gradio frontend to hang
on "Loading..." indefinitely while the Python backend ran correctly.

Fix: Vendor NiiVue v0.65.0 locally instead of CDN:
- Add src/stroke_deepisles_demo/ui/assets/niivue.js (2.9MB)
- Update viewer.py to use /gradio_api/file= for local serving
- Add allowed_paths to demo.launch() in both entry points
- Exclude assets/ from pre-commit large file check

All 136 tests pass. Lint and type checks clean.

* docs: add pre-commit-config to files modified table (CodeRabbit)

.pre-commit-config.yaml CHANGED
@@ -19,7 +19,11 @@ repos:
19
  rev: v6.0.0
20
  hooks:
21
  - id: trailing-whitespace
 
22
  - id: end-of-file-fixer
 
23
  - id: check-yaml
24
  - id: check-added-large-files
25
  args: [--maxkb=1000]
 
 
 
19
  rev: v6.0.0
20
  hooks:
21
  - id: trailing-whitespace
22
+ exclude: ^src/stroke_deepisles_demo/ui/assets/
23
  - id: end-of-file-fixer
24
+ exclude: ^src/stroke_deepisles_demo/ui/assets/
25
  - id: check-yaml
26
  - id: check-added-large-files
27
  args: [--maxkb=1000]
28
+ # Exclude vendored assets (NiiVue library ~2.9MB)
29
+ exclude: ^src/stroke_deepisles_demo/ui/assets/
app.py CHANGED
@@ -9,6 +9,8 @@ See:
9
  - https://huggingface.co/docs/hub/spaces-sdks-docker
10
  """
11
 
 
 
12
  import gradio as gr
13
 
14
  from stroke_deepisles_demo.core.config import get_settings
@@ -28,10 +30,16 @@ if __name__ == "__main__":
28
  # - server_port: 7860 is HF Spaces default
29
  # - theme: Gradio 6 uses launch() for theme
30
  # - css: Hide footer for cleaner look
 
 
 
 
 
31
  demo.launch(
32
  server_name=settings.gradio_server_name,
33
  server_port=settings.gradio_server_port,
34
  share=settings.gradio_share,
35
  theme=gr.themes.Soft(),
36
  css="footer {visibility: hidden}",
 
37
  )
 
9
  - https://huggingface.co/docs/hub/spaces-sdks-docker
10
  """
11
 
12
+ from pathlib import Path
13
+
14
  import gradio as gr
15
 
16
  from stroke_deepisles_demo.core.config import get_settings
 
30
  # - server_port: 7860 is HF Spaces default
31
  # - theme: Gradio 6 uses launch() for theme
32
  # - css: Hide footer for cleaner look
33
+
34
+ # Allow access to local assets (e.g., niivue.js)
35
+ # Assets are located in src/stroke_deepisles_demo/ui/assets
36
+ assets_dir = Path(__file__).parent / "src" / "stroke_deepisles_demo" / "ui" / "assets"
37
+
38
  demo.launch(
39
  server_name=settings.gradio_server_name,
40
  server_port=settings.gradio_server_port,
41
  share=settings.gradio_share,
42
  theme=gr.themes.Soft(),
43
  css="footer {visibility: hidden}",
44
+ allowed_paths=[str(assets_dir)],
45
  )
docs/specs/24-bug-hf-spaces-loading-forever.md ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bug #24: HuggingFace Space Stuck on "Loading..." (P0)
2
+
3
+ **Date:** 2025-12-09
4
+ **Status:** FIXED
5
+ **Branch:** `debug/hf-spaces-loading-forever`
6
+ **Space:** https://huggingface.co/spaces/VibecoderMcSwaggins/stroke-deepisles-demo
7
+
8
+ ---
9
+
10
+ ## Symptom
11
+
12
+ The HuggingFace Space shows:
13
+ - **Status badge:** "Running on T4" (green) ✓
14
+ - **App panel:** Stuck on "Loading..." indefinitely ✗
15
+
16
+ The Docker container has started successfully (hence "Running on T4"), but the Gradio frontend never receives a response from the backend.
17
+
18
+ ---
19
+
20
+ ## What We Know
21
+
22
+ ### Local Testing: Works Fine
23
+ ```python
24
+ # All pass in ~1.3 seconds
25
+ from stroke_deepisles_demo.ui.app import create_app
26
+ demo = create_app() # Returns gr.Blocks successfully
27
+ ```
28
+
29
+ ### Code on HF Space
30
+ The Space is synced with `main` branch (commit `10a72ea`), NOT the PR #23 branch.
31
+ - `js_on_load` parameter was already added in commit `bc1d8e8`
32
+ - Server binds to `0.0.0.0:7860` (correct for Docker)
33
+ - Dataset loading uses pre-computed case IDs (no network calls on startup)
34
+
35
+ ### Configuration Verified
36
+ | Setting | Value | Status |
37
+ |---------|-------|--------|
38
+ | `sdk` | `docker` | ✓ Correct |
39
+ | `app_port` | `7860` | ✓ Correct |
40
+ | `server_name` | `0.0.0.0` | ✓ Correct |
41
+ | `server_port` | `7860` | ✓ Correct |
42
+ | Gradio version | `>=6.0.0,<7.0.0` | ✓ Correct |
43
+
44
+ ---
45
+
46
+ ## Hypotheses
47
+
48
+ ### H1: Python Startup Crash (Silent)
49
+ The Python app may be crashing during startup but HF Spaces still shows "Running on T4" because the container process is alive (perhaps a shell wrapper).
50
+
51
+ **Check:** Look at HF Spaces logs for Python tracebacks.
52
+
53
+ ### H2: Gradio Server Not Binding
54
+ The Gradio server may be failing to bind or timing out before accepting connections.
55
+
56
+ **Check:** Look for "Running on local URL" in logs.
57
+
58
+ ### H3: HF Spaces Platform Issue
59
+ HuggingFace Spaces may have a platform-wide issue affecting Docker SDK spaces.
60
+
61
+ **Check:** https://status.huggingface.co/ and HF Forums.
62
+
63
+ ### H4: Memory/Resource Exhaustion
64
+ The T4 instance may be running out of memory during startup.
65
+
66
+ **Check:** Look for OOM errors in logs.
67
+
68
+ ### H5: Dependencies Installation Failure
69
+ The `git+https://github.com/CloseChoice/datasets.git@...` dependency may fail to install.
70
+
71
+ **Check:** Build logs for pip install errors.
72
+
73
+ ---
74
+
75
+ ## Diagnostic Steps
76
+
77
+ ### 1. Check HF Spaces Logs
78
+ Go to the Space → Settings → Logs and look for:
79
+ - Python tracebacks
80
+ - "Running on local URL: http://0.0.0.0:7860"
81
+ - Memory errors
82
+ - Dependency installation errors
83
+
84
+ ### 2. Factory Rebuild
85
+ Settings → Factory rebuild to force a clean Docker build.
86
+
87
+ ### 3. Check HF Status
88
+ Visit https://status.huggingface.co/ for platform outages.
89
+
90
+ ### 4. Test Minimal Dockerfile
91
+ Create a minimal test Space with just Gradio to isolate the issue:
92
+
93
+ ```dockerfile
94
+ FROM python:3.11-slim
95
+ RUN pip install gradio
96
+ COPY <<EOF app.py
97
+ import gradio as gr
98
+ demo = gr.Interface(fn=lambda x: x, inputs="text", outputs="text")
99
+ demo.launch(server_name="0.0.0.0", server_port=7860)
100
+ EOF
101
+ CMD ["python", "app.py"]
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Related Issues
107
+
108
+ - [HF Forum: Space stuck at Starting](https://discuss.huggingface.co/t/hf-space-stuck-at-starting/170911) (Nov 2025)
109
+ - [HF Forum: Dockerized app stuck at Building](https://discuss.huggingface.co/t/dockerized-gradio-app-stuck-at-building-despite-clean-logs/65558)
110
+ - [HF Forum: How to debug Spaces](https://discuss.huggingface.co/t/how-to-debug-spaces-on-hf-co/13191)
111
+ - [Gradio Issue #11401: Errors accessing spaces](https://github.com/gradio-app/gradio/issues/11401) (June 2025)
112
+
113
+ ---
114
+
115
+ ## Questions for User
116
+
117
+ 1. **When did the Space last work correctly?** (Before which commit/PR?)
118
+ 2. **What do the HF Spaces logs show?** (Settings → Logs)
119
+ 3. **Has a factory rebuild been attempted?**
120
+ 4. **Is HF Spaces having any platform issues today?**
121
+
122
+ ---
123
+
124
+ ## Next Steps
125
+
126
+ 1. [ ] User to check HF Spaces logs
127
+ 2. [ ] User to attempt factory rebuild
128
+ 3. [ ] Check if issue is platform-wide (HF status page)
129
+ 4. [ ] If needed, create minimal reproduction Space
130
+ 5. [ ] If dependency issue, consider vendoring the datasets fork
131
+
132
+ ---
133
+
134
+ ## Resolution
135
+
136
+ **Status:** FIXED (2025-12-09)
137
+
138
+ ### Root Cause
139
+
140
+ **Content Security Policy (CSP) blocking external CDN imports.**
141
+
142
+ The NiiVue library was being loaded via dynamic ES module import from unpkg.com CDN:
143
+ ```javascript
144
+ const { Niivue } = await import('https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js');
145
+ ```
146
+
147
+ HuggingFace Spaces enforces strict CSP headers that block external script imports. The import would either:
148
+ 1. Be silently blocked by CSP
149
+ 2. Hang indefinitely waiting for a response that never comes
150
+
151
+ This caused the Gradio frontend to remain stuck on "Loading..." even though the Python backend was running correctly.
152
+
153
+ **Evidence:**
154
+ - HF Spaces logs showed `Running on local URL: http://0.0.0.0:7860` (server healthy)
155
+ - No Python tracebacks (no backend crash)
156
+ - `list_case_ids()` uses pre-computed constants (no network blocking)
157
+ - Classic symptom of client-side JS execution failure
158
+
159
+ ### Fix Applied
160
+
161
+ **Vendored the NiiVue library locally** instead of relying on external CDN:
162
+
163
+ 1. **Downloaded NiiVue to local assets:**
164
+ - `src/stroke_deepisles_demo/ui/assets/niivue.js` (2.9MB)
165
+
166
+ 2. **Updated `viewer.py` to use local path:**
167
+ ```python
168
+ _ASSET_DIR = Path(__file__).parent / "assets"
169
+ _NIIVUE_JS_PATH = _ASSET_DIR / "niivue.js"
170
+ NIIVUE_JS_URL = f"/gradio_api/file={_NIIVUE_JS_PATH.resolve()}"
171
+ ```
172
+
173
+ 3. **Added `allowed_paths` to `demo.launch()`:**
174
+ ```python
175
+ assets_dir = Path(__file__).parent / "assets"
176
+ demo.launch(
177
+ # ...
178
+ allowed_paths=[str(assets_dir)],
179
+ )
180
+ ```
181
+
182
+ ### Files Modified
183
+
184
+ | File | Changes |
185
+ |------|---------|
186
+ | `src/stroke_deepisles_demo/ui/assets/niivue.js` | NEW - Vendored NiiVue v0.65.0 |
187
+ | `src/stroke_deepisles_demo/ui/viewer.py` | Use local path instead of CDN |
188
+ | `src/stroke_deepisles_demo/ui/app.py` | Add `allowed_paths` to launch() |
189
+ | `app.py` | Add `allowed_paths` to launch() |
190
+ | `.pre-commit-config.yaml` | Exclude assets/ from hooks |
191
+
192
+ ### Verification
193
+
194
+ - All 136 tests pass
195
+ - Ruff lint passes
196
+ - Mypy type check passes
197
+ - Local Gradio app loads correctly
198
+
199
+ ### Why This Is The Professional Solution
200
+
201
+ 1. **Self-contained:** No external dependencies at runtime
202
+ 2. **Reliable:** Immune to CDN outages or rate limits
203
+ 3. **Security-compliant:** Respects HF Spaces CSP policy
204
+ 4. **Reproducible:** Same NiiVue version always loaded
205
+ 5. **Standard practice:** Vendoring is the recommended approach for HF Spaces
src/stroke_deepisles_demo/ui/app.py CHANGED
@@ -274,6 +274,9 @@ if __name__ == "__main__":
274
  settings = get_settings()
275
  setup_logging(settings.log_level, format_style=settings.log_format)
276
 
 
 
 
277
  get_demo().launch(
278
  server_name=settings.gradio_server_name,
279
  server_port=settings.gradio_server_port,
@@ -281,4 +284,5 @@ if __name__ == "__main__":
281
  theme=gr.themes.Soft(),
282
  css="footer {visibility: hidden}",
283
  show_error=True, # Show full Python tracebacks in UI for debugging
 
284
  )
 
274
  settings = get_settings()
275
  setup_logging(settings.log_level, format_style=settings.log_format)
276
 
277
+ # Allow access to local assets (e.g., niivue.js)
278
+ assets_dir = Path(__file__).parent / "assets"
279
+
280
  get_demo().launch(
281
  server_name=settings.gradio_server_name,
282
  server_port=settings.gradio_server_port,
 
284
  theme=gr.themes.Soft(),
285
  css="footer {visibility: hidden}",
286
  show_error=True, # Show full Python tracebacks in UI for debugging
287
+ allowed_paths=[str(assets_dir)],
288
  )
src/stroke_deepisles_demo/ui/assets/niivue.js ADDED
The diff for this file is too large to render. See raw diff
 
src/stroke_deepisles_demo/ui/viewer.py CHANGED
@@ -14,20 +14,23 @@ from __future__ import annotations
14
 
15
  import json
16
  import uuid
17
- from typing import TYPE_CHECKING
18
 
19
  import numpy as np
20
  from matplotlib.figure import Figure
21
 
22
  from stroke_deepisles_demo.metrics import load_nifti_as_array
23
 
24
- if TYPE_CHECKING:
25
- from pathlib import Path
26
-
27
-
28
  # NiiVue version - updated to latest stable (Dec 2025)
 
 
29
  NIIVUE_VERSION = "0.65.0"
30
- NIIVUE_CDN_URL = f"https://unpkg.com/@niivue/niivue@{NIIVUE_VERSION}/dist/index.js"
 
 
 
 
 
31
 
32
 
33
  def nifti_to_gradio_url(nifti_path: Path) -> str:
@@ -383,8 +386,8 @@ NIIVUE_ON_LOAD_JS = f"""
383
 
384
  if (status) status.innerText = 'Loading NiiVue...';
385
 
386
- // Dynamically import NiiVue from CDN
387
- const {{ Niivue }} = await import('{NIIVUE_CDN_URL}');
388
 
389
  // Initialize NiiVue
390
  const nv = new Niivue({{
@@ -473,8 +476,8 @@ NIIVUE_UPDATE_JS = f"""
473
  return;
474
  }}
475
 
476
- // Dynamically import NiiVue from CDN
477
- const {{ Niivue }} = await import('{NIIVUE_CDN_URL}');
478
 
479
  // Initialize NiiVue
480
  const nv = new Niivue({{
 
14
 
15
  import json
16
  import uuid
17
+ from pathlib import Path
18
 
19
  import numpy as np
20
  from matplotlib.figure import Figure
21
 
22
  from stroke_deepisles_demo.metrics import load_nifti_as_array
23
 
 
 
 
 
24
  # NiiVue version - updated to latest stable (Dec 2025)
25
+ # Switched to local vendoring to avoid CSP issues on HuggingFace Spaces (Issue #24)
26
+ # The file is located in src/stroke_deepisles_demo/ui/assets/niivue.js
27
  NIIVUE_VERSION = "0.65.0"
28
+ _ASSET_DIR = Path(__file__).parent / "assets"
29
+ _NIIVUE_JS_PATH = _ASSET_DIR / "niivue.js"
30
+
31
+ # Ensure absolute path for Gradio serving
32
+ # NOTE: This path must be added to allowed_paths in demo.launch()
33
+ NIIVUE_JS_URL = f"/gradio_api/file={_NIIVUE_JS_PATH.resolve()}"
34
 
35
 
36
  def nifti_to_gradio_url(nifti_path: Path) -> str:
 
386
 
387
  if (status) status.innerText = 'Loading NiiVue...';
388
 
389
+ // Dynamically import NiiVue from local asset (vendored)
390
+ const {{ Niivue }} = await import('{NIIVUE_JS_URL}');
391
 
392
  // Initialize NiiVue
393
  const nv = new Niivue({{
 
476
  return;
477
  }}
478
 
479
+ // Dynamically import NiiVue from local asset (vendored)
480
+ const {{ Niivue }} = await import('{NIIVUE_JS_URL}');
481
 
482
  // Initialize NiiVue
483
  const nv = new Niivue({{