Skip to content
369 changes: 369 additions & 0 deletions docs/superpowers/plans/2026-04-01-global-extension-bugs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,369 @@
# Global Extension Bugs — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix four bugs found during global extension investigation: wrong `list_extensions` default, missing catch-all exception handler, missing regression test for nested-dict frames, and misleading docstring.

**Architecture:** All fixes are isolated one-liners or small additions in existing files. No new modules. Tests follow existing patterns in `tests/zndraw/`.

**Tech Stack:** Python, FastAPI, pytest (async), ase, numpy, msgpack

---

### Task 1: Fix `list_extensions()` wrong default

**Files:**
- Modify: `src/zndraw/client/api.py:854`

- [ ] **Step 1: Fix the default**

In `src/zndraw/client/api.py`, change line 854 from:

```python
job_room = room or "@internal"
```

to:

```python
job_room = room or self.room_id
```

- [ ] **Step 2: Run existing tests to verify no regressions**

Run: `uv run pytest tests/zndraw/test_cli_agent/test_extensions.py -v`
Expected: All existing extension tests PASS

- [ ] **Step 3: Commit**

```bash
git add src/zndraw/client/api.py
git commit -m "fix: list_extensions defaults to actual room instead of @internal"
```

---

### Task 2: Add test for Bug 1 — list_extensions includes @global

**Files:**
- Modify: `tests/zndraw/worker/test_global.py`

- [ ] **Step 1: Write the test**

Add to the end of `tests/zndraw/worker/test_global.py`:

```python
def test_list_extensions_includes_global(server, Echo):
"""vis.api.list_extensions() (no room arg) includes @global extensions."""
worker = ZnDraw(url=server)
viewer = ZnDraw(url=server)
try:
worker.jobs.register(Echo)
data = viewer.api.list_extensions()
names = {item["full_name"] for item in data.get("items", [])}
assert "@global:modifiers:Echo" in names
finally:
worker.jobs.disconnect()
worker.disconnect()
viewer.disconnect()
```

- [ ] **Step 2: Run the test**

Run: `uv run pytest tests/zndraw/worker/test_global.py::test_list_extensions_includes_global -v`
Expected: PASS

- [ ] **Step 3: Commit**

```bash
git add tests/zndraw/worker/test_global.py
git commit -m "test: verify list_extensions() includes @global extensions"
```

---

### Task 3: Add `InternalServerError` problem type

**Files:**
- Modify: `src/zndraw/exceptions.py`

- [ ] **Step 1: Add the problem type class**

In `src/zndraw/exceptions.py`, add after the `InvalidPresetRule` class (before the `PROBLEM_TYPES` dict at line 587):

```python
class InternalServerError(ProblemType):
"""An unexpected error occurred while processing the request.

This error is returned when an unhandled exception reaches the
top-level exception handler. The ``detail`` field contains the
exception message; the full traceback is logged server-side.
"""

title: ClassVar[str] = "Internal Server Error"
status: ClassVar[int] = 500
```

- [ ] **Step 2: Register in PROBLEM_TYPES**

In the `PROBLEM_TYPES` dict (the `for cls in [...]` list), add `InternalServerError` after `InvalidPresetRule`:

```python
InvalidPresetRule,
InternalServerError,
]
```

- [ ] **Step 3: Run existing problem tests**

Run: `uv run pytest tests/zndraw/test_problems.py -v`
Expected: All PASS (new type now appears in the registry)

- [ ] **Step 4: Commit**

```bash
git add src/zndraw/exceptions.py
git commit -m "feat: add InternalServerError RFC 9457 problem type"
```

---

### Task 4: Add catch-all exception handler and refactor validation handler

**Files:**
- Modify: `src/zndraw/app.py`

- [ ] **Step 1: Add logging import**

At the top of `src/zndraw/app.py`, add after the existing imports (after `from pathlib import Path`):

```python
import logging
```

And add after the `app.state.local_token = None` line:

```python
logger = logging.getLogger(__name__)
```

- [ ] **Step 2: Add InternalServerError import**

Update the import from `zndraw.exceptions` to include `InternalServerError`:

```python
from zndraw.exceptions import (
InternalServerError,
ProblemError,
UnprocessableContent,
problem_exception_handler,
)
```

- [ ] **Step 3: Refactor `_validation_exception_handler` to delegate**

Replace the existing `_validation_exception_handler` (lines 58-71):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add explicit language to the fenced code block at Line 166

markdownlint flags this as MD040. Add a language identifier to keep docs lint-clean.

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 166-166: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/plans/2026-04-01-global-extension-bugs.md` at line 166, The
fenced code block currently missing a language identifier should be updated to
include the appropriate language tag (e.g., ```bash, ```js, ```json, etc.) so
markdownlint MD040 is satisfied; locate the fenced block that lacks a language
specifier and add the correct language identifier matching the block's contents.

```python
@app.exception_handler(RequestValidationError)
async def _validation_exception_handler(
_request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Convert FastAPI validation errors to RFC 9457 problem detail."""
detail = "; ".join(
f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()
)
problem = UnprocessableContent.create(detail=detail)
return JSONResponse(
status_code=422,
content=problem.model_dump(exclude_none=True),
media_type="application/problem+json",
)
```

with:

```python
@app.exception_handler(RequestValidationError)
async def _validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Convert FastAPI validation errors to RFC 9457 problem detail."""
detail = "; ".join(
f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()
)
return await problem_exception_handler(
request, UnprocessableContent.exception(detail=detail)
)
```

- [ ] **Step 4: Add catch-all exception handler**

Add after `_validation_exception_handler`, before the `# Include routers` comment:

```python
@app.exception_handler(Exception)
async def _unhandled_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
"""Catch-all for unhandled exceptions — log and return RFC 9457."""
logger.error(
"Unhandled %s on %s %s",
type(exc).__name__,
request.method,
request.url.path,
exc_info=True,
)
return await problem_exception_handler(
request, InternalServerError.exception(detail=str(exc))
)
Comment on lines +205 to +219
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid returning raw exception messages in 500 responses

At Line 218, using detail=str(exc) can leak sensitive internal data to clients. Keep traceback/details in logs and return a generic client-safe detail in the RFC 9457 payload.

Safer handler snippet
     return await problem_exception_handler(
-        request, InternalServerError.exception(detail=str(exc))
+        request,
+        InternalServerError.exception(
+            detail="An unexpected error occurred while processing the request."
+        ),
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/plans/2026-04-01-global-extension-bugs.md` around lines 205
- 219, The handler _unhandled_exception_handler currently returns
InternalServerError.exception(detail=str(exc)), which can leak sensitive
internals; change it to return a generic, client-safe detail (e.g., "Internal
server error" or similar) while keeping the full exception/traceback in logs via
logger.error(exc_info=True). Update the call to
problem_exception_handler/InternalServerError.exception so the detail is the
generic message and do not include str(exc) in the response payload; keep
logging the actual exc via logger.error for debugging.

```

- [ ] **Step 5: Run existing tests to verify validation handler still works**

Run: `uv run pytest tests/zndraw/test_problems.py tests/zndraw/test_routes_frames.py -v -x`
Expected: All PASS

- [ ] **Step 6: Commit**

```bash
git add src/zndraw/app.py
git commit -m "feat: add catch-all exception handler returning RFC 9457 responses"
```

---

### Task 5: Add test for catch-all handler

**Files:**
- Modify: `tests/zndraw/test_problems.py`

- [ ] **Step 1: Write the test**

Add at the end of `tests/zndraw/test_problems.py`:

```python
@pytest.mark.asyncio
async def test_unhandled_exception_returns_problem_json(client: AsyncClient) -> None:
"""Unhandled exceptions return application/problem+json with status 500."""
# Hit a URL that doesn't exist as a route — FastAPI returns 404.
# Instead, trigger via an invalid room ID that passes URL parsing
# but causes an internal error. We verify the catch-all by checking
# that the registry now includes internal-server-error.
response = await client.get("/v1/problems/internal-server-error")
assert response.status_code == 200
content = response.text
assert "# InternalServerError" in content
assert "**Status:** 500" in content
assert "**Title:** Internal Server Error" in content
```
Comment on lines +241 to +259
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Task 5 test does not actually verify the catch-all exception handler

At Line 247-259, the test only verifies the problem-type docs endpoint (/v1/problems/internal-server-error, status 200). It does not exercise @app.exception_handler(Exception) or assert a 500 application/problem+json runtime response, so the planned regression can pass even if the catch-all handler is broken.

Proposed plan adjustment
-async def test_unhandled_exception_returns_problem_json(client: AsyncClient) -> None:
-    """Unhandled exceptions return application/problem+json with status 500."""
-    response = await client.get("/v1/problems/internal-server-error")
-    assert response.status_code == 200
-    content = response.text
-    assert "# InternalServerError" in content
-    assert "**Status:** 500" in content
-    assert "**Title:** Internal Server Error" in content
+async def test_unhandled_exception_returns_problem_json(client: AsyncClient) -> None:
+    """Unhandled exceptions return application/problem+json with status 500."""
+    response = await client.get("/v1/test/raise-unhandled")
+    assert response.status_code == 500
+    assert response.headers["content-type"].startswith("application/problem+json")
+    body = response.json()
+    assert body["status"] == 500
+    assert body["title"] == "Internal Server Error"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/plans/2026-04-01-global-extension-bugs.md` around lines 241
- 259, The test test_unhandled_exception_returns_problem_json in
tests/zndraw/test_problems.py only hits the problems documentation endpoint and
doesn't exercise the actual catch-all exception handler
(`@app.exception_handler`(Exception)); update the test to call a real endpoint
that raises an unhandled Exception (or inject/mock a handler to raise) so the
request triggers the global exception handler at runtime, then assert the
response status_code is 500, the Content-Type is application/problem+json, and
the JSON body contains the expected problem fields (status: 500, title:
"Internal Server Error" and any problem-type identifier like
"internal-server-error"); keep or separately assert that the registry endpoint
(/v1/problems/internal-server-error) still returns the docs if needed.


- [ ] **Step 2: Run the test**

Run: `uv run pytest tests/zndraw/test_problems.py::test_unhandled_exception_returns_problem_json -v`
Expected: PASS

- [ ] **Step 3: Commit**

```bash
git add tests/zndraw/test_problems.py
git commit -m "test: verify InternalServerError is registered in problem types"
```

---

### Task 6: Add regression test for nested dict+numpy frames

**Files:**
- Modify: `tests/zndraw/test_routes_frames.py`

- [ ] **Step 1: Write the test**

Add at the end of `tests/zndraw/test_routes_frames.py`:

```python
@pytest.mark.asyncio
async def test_append_frame_with_nested_info_dict(
client: AsyncClient, session: AsyncSession
) -> None:
"""Frames with nested dicts containing numpy arrays in atoms.info round-trip."""
import numpy as np

user, token = await create_test_user_in_db(session)
room = await create_test_room(session, user)

atoms = ase.Atoms(
"H2O",
positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]],
)
atoms.info["cube_data"] = {
"grid": np.random.default_rng(42).standard_normal((10, 10, 10)),
"origin": np.array([0.0, 0.0, 0.0]),
"cell": np.eye(3) * 5.0,
}
frame = atoms_to_json_dict(atoms)

# Append
response = await client.post(
f"/v1/rooms/{room.id}/frames",
json={"frames": [frame]},
headers=auth_header(token),
)
assert response.status_code == 201
result = FrameBulkResponse.model_validate(response.json())
assert result.total == 1

# Read back and verify the nested key exists
response = await client.get(
f"/v1/rooms/{room.id}/frames",
params={"indices": "0"},
headers=auth_header(token),
)
assert response.status_code == 200
frames = decode_msgpack_response(response.content)
assert len(frames) == 1
assert b"info.cube_data" in frames[0]
```

- [ ] **Step 2: Run the test**

Run: `uv run pytest tests/zndraw/test_routes_frames.py::test_append_frame_with_nested_info_dict -v`
Expected: PASS

- [ ] **Step 3: Commit**

```bash
git add tests/zndraw/test_routes_frames.py
git commit -m "test: regression test for frames with nested dict+numpy in atoms.info"
```

---

### Task 7: Fix misleading docstring

**Files:**
- Modify: `src/zndraw_joblib/client.py:374-375`

- [ ] **Step 1: Update the docstring**

In `src/zndraw_joblib/client.py`, replace line 374-375:

```python
room
Room scope. Defaults to ``"@global"``.
```

with:

```python
room
Room scope. Defaults to ``"@global"`` when called directly.
``ZnDraw.register_job()`` resolves *None* to the client's room.
```

- [ ] **Step 2: Commit**

```bash
git add src/zndraw_joblib/client.py
git commit -m "docs: clarify JobManager.register() room default"
```
Loading
Loading