Skip to content

Commit dcd037f

Browse files
samcoferclaude
andauthored
fix(workbench): keep JupyterLab sessions fresh to stop launch/exec skip (#532)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9c22118 commit dcd037f

4 files changed

Lines changed: 377 additions & 18 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Selftests for the JupyterLab notebook-cleanup helpers on WorkbenchClient.
2+
3+
These cover the pure URL/header builders and the ``delete_jupyter_notebook``
4+
method (via an ``httpx.MockTransport``), so the contents-API teardown that keeps
5+
JupyterLab sessions fresh is exercised without a live Workbench.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import httpx
11+
import pytest
12+
13+
from vip.clients.workbench import (
14+
WorkbenchClient,
15+
jupyterlab_app_base,
16+
jupyterlab_contents_delete_url,
17+
jupyterlab_xsrf_headers,
18+
)
19+
20+
# -- jupyterlab_app_base -----------------------------------------------------
21+
22+
23+
@pytest.mark.parametrize(
24+
"page_url, expected",
25+
[
26+
# Standard per-session proxy prefix, notebook open under /lab/tree.
27+
(
28+
"https://wb.example.com/s/abc123/lab/tree/Untitled.ipynb",
29+
"https://wb.example.com/s/abc123",
30+
),
31+
# Bare /lab root.
32+
("https://wb.example.com/s/abc123/lab", "https://wb.example.com/s/abc123"),
33+
# Query string and fragment are stripped.
34+
(
35+
"https://wb.example.com/s/xyz/lab/tree/Untitled1.ipynb?reset#cell",
36+
"https://wb.example.com/s/xyz",
37+
),
38+
# No /lab segment — return the URL trimmed rather than raising.
39+
("https://wb.example.com/s/abc123/", "https://wb.example.com/s/abc123"),
40+
# Real Workbench shape (validated live via CDP on pwb.demo): JupyterLab
41+
# inserts a /lab/workspaces/<id>/tree/<nb> segment. The base must still
42+
# cut at /lab and match PageConfig.baseUrl (".../s/<sid>").
43+
(
44+
"https://pwb.demo.soleng.posit.it/s/0da2921f2ed72297f2efa/lab/workspaces/auto-m/tree/Untitled7.ipynb",
45+
"https://pwb.demo.soleng.posit.it/s/0da2921f2ed72297f2efa",
46+
),
47+
],
48+
)
49+
def test_jupyterlab_app_base(page_url, expected):
50+
assert jupyterlab_app_base(page_url) == expected
51+
52+
53+
# -- jupyterlab_contents_delete_url ------------------------------------------
54+
55+
56+
def test_contents_delete_url_basic():
57+
url = jupyterlab_contents_delete_url(
58+
"https://wb.example.com/s/abc123/lab/tree/Untitled.ipynb", "Untitled.ipynb"
59+
)
60+
assert url == "https://wb.example.com/s/abc123/api/contents/Untitled.ipynb"
61+
62+
63+
def test_contents_delete_url_quotes_spaces_and_strips_slashes():
64+
url = jupyterlab_contents_delete_url("https://wb.example.com/s/abc123/lab", "/_vip run 1.ipynb")
65+
# Leading slash stripped, space percent-encoded.
66+
assert url == "https://wb.example.com/s/abc123/api/contents/_vip%20run%201.ipynb"
67+
68+
69+
# -- jupyterlab_xsrf_headers -------------------------------------------------
70+
71+
72+
def test_xsrf_headers_present():
73+
assert jupyterlab_xsrf_headers({"_xsrf": "tok123"}) == {"X-XSRFToken": "tok123"}
74+
75+
76+
def test_xsrf_headers_absent():
77+
assert jupyterlab_xsrf_headers({"other": "v"}) == {}
78+
79+
80+
# -- delete_jupyter_notebook (MockTransport) ---------------------------------
81+
82+
83+
def _client_with_handler(handler) -> WorkbenchClient:
84+
wc = WorkbenchClient("https://wb.example.com")
85+
wc._client.close()
86+
wc._client = httpx.Client(
87+
base_url="https://wb.example.com",
88+
transport=httpx.MockTransport(handler),
89+
)
90+
return wc
91+
92+
93+
def test_delete_notebook_success_sends_xsrf_and_targets_contents_api():
94+
seen: dict[str, object] = {}
95+
96+
def handler(request: httpx.Request) -> httpx.Response:
97+
seen["method"] = request.method
98+
seen["path"] = request.url.path
99+
seen["xsrf"] = request.headers.get("X-XSRFToken")
100+
return httpx.Response(204)
101+
102+
wc = _client_with_handler(handler)
103+
ok = wc.delete_jupyter_notebook(
104+
"https://wb.example.com/s/abc123/lab/tree/Untitled.ipynb",
105+
"Untitled.ipynb",
106+
{"_xsrf": "tok123"},
107+
)
108+
assert ok is True
109+
assert seen["method"] == "DELETE"
110+
assert seen["path"] == "/s/abc123/api/contents/Untitled.ipynb"
111+
assert seen["xsrf"] == "tok123"
112+
113+
114+
def test_delete_notebook_404_treated_as_success():
115+
def handler(request: httpx.Request) -> httpx.Response:
116+
return httpx.Response(404)
117+
118+
wc = _client_with_handler(handler)
119+
assert (
120+
wc.delete_jupyter_notebook("https://wb.example.com/s/abc/lab", "Untitled.ipynb", {}) is True
121+
)
122+
123+
124+
def test_delete_notebook_server_error_is_false():
125+
def handler(request: httpx.Request) -> httpx.Response:
126+
return httpx.Response(500)
127+
128+
wc = _client_with_handler(handler)
129+
assert (
130+
wc.delete_jupyter_notebook(
131+
"https://wb.example.com/s/abc/lab", "Untitled.ipynb", {"_xsrf": "t"}
132+
)
133+
is False
134+
)
135+
136+
137+
def test_delete_notebook_transport_error_is_false():
138+
def handler(request: httpx.Request) -> httpx.Response:
139+
raise httpx.ConnectError("boom")
140+
141+
wc = _client_with_handler(handler)
142+
assert (
143+
wc.delete_jupyter_notebook("https://wb.example.com/s/abc/lab", "Untitled.ipynb", {})
144+
is False
145+
)

src/vip/clients/workbench.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,57 @@ def is_vip_session(label: str) -> bool:
2929
return any(label.startswith(prefix) for prefix in _VIP_SESSION_PREFIXES)
3030

3131

32+
def jupyterlab_app_base(page_url: str) -> str:
33+
"""Return the JupyterLab app-root URL from a session page URL.
34+
35+
A Workbench JupyterLab session is served under a per-session proxy prefix,
36+
e.g. ``https://wb.example.com/s/abc123/lab/tree/Untitled.ipynb``. The
37+
JupyterLab server (and its ``/api/contents`` REST surface) is mounted at the
38+
segment ending in ``/lab`` — everything *before* ``/lab`` is the app base
39+
(``https://wb.example.com/s/abc123``). The returned value has no trailing
40+
slash. If the URL has no ``/lab`` segment (unexpected), the input is
41+
returned with any query/fragment and trailing slash stripped, so callers
42+
still get a usable base rather than raising mid-teardown.
43+
"""
44+
# Drop query string and fragment first — the contents API path is built
45+
# fresh, so anything after ``?``/``#`` is irrelevant and would corrupt the base.
46+
core = page_url.split("?", 1)[0].split("#", 1)[0]
47+
marker = "/lab"
48+
idx = core.find(marker)
49+
if idx != -1:
50+
return core[:idx].rstrip("/")
51+
return core.rstrip("/")
52+
53+
54+
def jupyterlab_contents_delete_url(page_url: str, notebook_name: str) -> str:
55+
"""Build the ``DELETE /api/contents/<notebook>`` URL for a JupyterLab session.
56+
57+
*page_url* is the current session page URL (see :func:`jupyterlab_app_base`);
58+
*notebook_name* is the notebook's filename as shown on its dock tab (e.g.
59+
``"Untitled.ipynb"``). The name is URL-quoted so spaces/special characters
60+
in a renamed notebook do not break the path. Any leading slashes on the
61+
name are stripped so the result is always ``<app_base>/api/contents/<name>``.
62+
"""
63+
from urllib.parse import quote
64+
65+
base = jupyterlab_app_base(page_url)
66+
clean = notebook_name.strip().lstrip("/")
67+
return f"{base}/api/contents/{quote(clean)}"
68+
69+
70+
def jupyterlab_xsrf_headers(cookies: dict[str, str]) -> dict[str, str]:
71+
"""Return the XSRF header JupyterLab requires for mutating contents-API calls.
72+
73+
JupyterLab protects non-GET ``/api`` requests with a double-submit token:
74+
the ``_xsrf`` cookie value must be echoed back in the ``X-XSRFToken`` header.
75+
Returns ``{"X-XSRFToken": <value>}`` when the cookie is present, or an empty
76+
dict when it is absent (older/token-auth deployments) so the caller can still
77+
attempt the request without sending a bogus header.
78+
"""
79+
token = cookies.get("_xsrf")
80+
return {"X-XSRFToken": token} if token else {}
81+
82+
3283
class WorkbenchClient(BaseClient):
3384
"""Minimal Workbench HTTP wrapper."""
3485

@@ -76,6 +127,39 @@ def set_cookies(self, cookies: dict[str, str]) -> None:
76127
"""Set cookies on the client instance for authenticated requests."""
77128
self._client.cookies.update(cookies)
78129

130+
def delete_jupyter_notebook(
131+
self, page_url: str, notebook_name: str, cookies: dict[str, str]
132+
) -> bool:
133+
"""Delete a notebook file from a live JupyterLab session's contents API.
134+
135+
Sends ``DELETE <app_base>/api/contents/<notebook_name>`` authenticated
136+
with the browser session *cookies* and the ``X-XSRFToken`` header
137+
JupyterLab requires for mutating calls (see
138+
:func:`jupyterlab_contents_delete_url` / :func:`jupyterlab_xsrf_headers`).
139+
140+
This exists so a test that creates a notebook can remove it *before the
141+
session is quit* — the contents API only exists while the session is
142+
alive — so JupyterLab's layout-restorer stops reopening stale
143+
``Untitled*.ipynb`` tabs (and eagerly starting their kernels) on every
144+
subsequent session launch, which widens the launch/exec interaction race.
145+
146+
Returns True when the server accepts the delete (HTTP < 400, including
147+
the ``204`` success and a ``404`` for an already-absent file), False on
148+
any other status or transport error. Never raises — teardown cleanup is
149+
a best-effort safety net, not an assertion.
150+
"""
151+
url = jupyterlab_contents_delete_url(page_url, notebook_name)
152+
headers = jupyterlab_xsrf_headers(cookies)
153+
try:
154+
# Set cookies on the client instance (per-request cookies= is
155+
# deprecated in httpx and its persistence semantics are ambiguous).
156+
self._client.cookies.update(cookies)
157+
resp = self._client.request("DELETE", url, headers=headers)
158+
except Exception:
159+
return False
160+
# 404 → already gone, treat as success (idempotent teardown).
161+
return resp.status_code < 400 or resp.status_code == 404
162+
79163
def list_sessions(self) -> list[dict[str, Any]]:
80164
"""List active sessions for the authenticated user."""
81165
resp = self._client.get("/api/sessions")

src/vip_tests/workbench/pages/jupyterlab_session.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ class JupyterLabSession:
2626
DIALOG = ".jp-Dialog"
2727
DIALOG_ACCEPT = ".jp-Dialog .jp-Dialog-button.jp-mod-accept"
2828

29+
# Kernel execution-status indicator on the notebook toolbar. JupyterLab sets
30+
# its ``data-status`` to "idle" once the kernel is connected and ready, and
31+
# "busy" while a cell is running. Gating the first cell run on "idle" avoids
32+
# typing/running before the kernel has finished connecting, which drops the
33+
# input and produces the spurious "kernel did not produce output" timeout.
34+
KERNEL_STATUS = ".jp-Notebook-ExecutionIndicator"
35+
KERNEL_STATUS_IDLE = ".jp-Notebook-ExecutionIndicator[data-status='idle']"
36+
37+
# Current (active) notebook tab in the dock area. Its label text is the
38+
# notebook's filename (e.g. "Untitled.ipynb"), used to build the contents-API
39+
# path for teardown deletion.
40+
CURRENT_TAB_LABEL = ".lm-DockPanel-tabBar .lm-TabBar-tab.jp-mod-current .lm-TabBar-tabLabel"
41+
2942
# Sidebar
3043
FILE_BROWSER = ".jp-FileBrowser"
3144
SIDEBAR_LEFT = ".jp-SideBar.jp-mod-left"

0 commit comments

Comments
 (0)