Harden Jupyter state lifecycle and cleanup

This commit is contained in:
saymrwulf 2026-04-15 15:29:01 +02:00
parent ef778073de
commit 377c96de8a
9 changed files with 268 additions and 95 deletions

2
.gitignore vendored
View file

@ -4,6 +4,8 @@
.ipython/ .ipython/
.jupyter_runtime/ .jupyter_runtime/
.jupyter_data/lab/workspaces/ .jupyter_data/lab/workspaces/
.jupyter_data/nbsignatures.db
.jupyter_data/notebook_secret
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/

Binary file not shown.

View file

@ -1,18 +0,0 @@
SrXNuCCg+wrEA1L1jVumnXf3ChsNATdtqH3UiZud+nbMUHxjUqqRRPcHmyufdB8eMh0TPu+cIe8Z
t0caRkarRaz/ouQ9rwyFjxgt4UMFFRHXqAofkPH4zF89S4s83U+C2mZ911tUoAr7rql6lW0UDrro
U4RYWQFd356AfiDwmGhC2AVO7/Yt2NmOgawxWvqsuAaIzSIjDvFY/kU+UNu2nyW7FrhumjgmBp93
Hk4BOZb8xnEcz24E9wVQ/9Fn2XXcIFGB+DowTQPkai3xIo2zx1o5+6+Vbsq1b7dfESlliXj6Nh0o
R3haoHFr4B6Q/biz7YMZq3njh2Mq/6NzoK71exnJ4vjm6/ucLlX94DEeyvlsK8XtBf+rO/Ux2kQ4
P8U3VqqEcsFE36k7jVGqMv00m/PKL1W4A/C2HdutD6WsGDu8F6+jI3lKgrWVcylwqZf0t0TQ9zKf
pqlFO1PmsIubDRwd6rfiZbBC8S1fMzBMWfs4jM2FVWALIz+fQZZ2pUBxmMPsk3M7SmmKw8Zyc/nj
U+SroBGM1eb4W7UERCCsa990B00zmNCz5s/VJkrn9TVwAT48vVuxphKPxMeyiwtHuEHiSkN2fb/l
8aaaL1DiFk8KSogKsJZ9JgEpnqMXqxEJ6JF+IRJrfHHD8jPuatXjPqVjdMgpSnWG6ngrbAd2cw4A
lHOqJG8EuRieYh/vx/xc+b8+LumXol0m1Gz7W1pVRXXrh+rVrMk3eUJO4yAbcXIZM3/Sqe8N7ZAH
yxtzFCce5mgNGdk+1I3GfLTU999fbdERodpsnlKoSX7eB+fT+8z68kucSx4OYswIaXLCk58Fjz+1
ZuvO8SsiNoSoQ9tIv1B0aKFGST4BLyQ1NccTIJjhAGiE65jzpDuaqRl9HyTXF3Wj7TeSlOYbOJLh
1H3CupTXl3PjH9i6K1zrx4TrOEe5J4b/iaIXlw8PzUSBL/yeaGHou2NzW2oTOQ1Ev9P9n9SfyIU3
BuCvqiUTbm2k8Ktk9g7mcHUw+63syE0b9CTPXzdAlvI30j+K2s+zs6fWtpZXsIy+Xz9rcBX3Nztm
nl/Z3yLa3+khqS7hY/kxgURjvm0XxPwuGoWt9dJFkkoQXLiOrzKiCD773qwrLHEyHx0TI4ntfFV0
TducvMBpiAvaN1KnlJU8K6yyeqZdJytxJngZGcRgtz/IjySByb75zLpInoHf0HKxgjPfSHvXCVlP
vuKq5a16PU7Dlj01u3szIR16bh/qXeKn3h89gslw0fR+ILWnzTuy8VlqtbgfSW+Ohj5X5VCok6k/
dr21sWY2J1GlZmu9N/4Hpj49kORRj/lb5UXZIGsqxSeK4yHDGTGR1YBK+GPmARkzzvF/qDY1FQ==

View file

@ -23,6 +23,13 @@ Use this the first time you open the platform, or anytime you want a clean local
bash ./scripts/app.sh start --open bash ./scripts/app.sh start --open
``` ```
If JupyterLab later looks haunted by old tabs or stale session state, reset the repo-local Jupyter state and start again:
```bash
bash ./scripts/app.sh reset-state
bash ./scripts/app.sh start --open
```
4. Open the localhost URL printed by the script. 4. Open the localhost URL printed by the script.
5. Confirm the kernel in the top-right corner is `QuantumLearning (.venv)`. 5. Confirm the kernel in the top-right corner is `QuantumLearning (.venv)`.
6. Run `Kernel -> Restart Kernel and Run All Cells...` inside `notebooks/COURSE_BLUEPRINT.ipynb`. 6. Run `Kernel -> Restart Kernel and Run All Cells...` inside `notebooks/COURSE_BLUEPRINT.ipynb`.

View file

@ -77,6 +77,7 @@ Other normal consumer commands:
bash ./scripts/app.sh status bash ./scripts/app.sh status
bash ./scripts/app.sh restart bash ./scripts/app.sh restart
bash ./scripts/app.sh stop bash ./scripts/app.sh stop
bash ./scripts/app.sh reset-state
bash ./scripts/app.sh logs -f bash ./scripts/app.sh logs -f
``` ```
@ -106,11 +107,18 @@ It reports:
- whether `.venv/` exists and which Python version it uses - whether `.venv/` exists and which Python version it uses
- whether the project-local Playwright browser runtime is installed - whether the project-local Playwright browser runtime is installed
- whether a repo-local Jupyter server is running and, if so, its full URL - whether a repo-local Jupyter server is running and, if so, its full URL
- whether saved Jupyter UI state is still present and how to reset it
If you lose the Jupyter URL, `bash ./scripts/app.sh status` is the fastest recovery path. If you lose the Jupyter URL, `bash ./scripts/app.sh status` is the fastest recovery path.
In restricted automation sandboxes, the script may report that the live process probe is unavailable. In a normal terminal on your Mac, it will report the running server directly when the runtime file and process are both visible. In restricted automation sandboxes, the script may report that the live process probe is unavailable. In a normal terminal on your Mac, it will report the running server directly when the runtime file and process are both visible.
If JupyterLab reopens old tabs or appears to remember stale notebook activity, that is usually saved workspace state rather than a still-running repo-local server. Clear it with:
```bash
bash ./scripts/app.sh reset-state
```
## Validation Modes ## Validation Modes
Quick local validation: Quick local validation:
@ -176,6 +184,7 @@ If something feels wrong, use this order:
2. `bash ./scripts/app.sh validate --quick` 2. `bash ./scripts/app.sh validate --quick`
3. if needed, `bash ./scripts/app.sh validate --full` 3. if needed, `bash ./scripts/app.sh validate --full`
4. restart Jupyter with `bash ./scripts/app.sh restart` 4. restart Jupyter with `bash ./scripts/app.sh restart`
5. if the UI still looks stale, run `bash ./scripts/app.sh reset-state`
If `.venv/` is missing or broken, re-run: If `.venv/` is missing or broken, re-run:

View file

@ -61,6 +61,7 @@ bash ./scripts/app.sh start --open
bash ./scripts/app.sh status bash ./scripts/app.sh status
bash ./scripts/app.sh restart bash ./scripts/app.sh restart
bash ./scripts/app.sh stop bash ./scripts/app.sh stop
bash ./scripts/app.sh reset-state
``` ```
If you want a fast operational check immediately after bootstrap: If you want a fast operational check immediately after bootstrap:
@ -96,6 +97,12 @@ If you want to inspect the live log:
bash ./scripts/app.sh logs -f bash ./scripts/app.sh logs -f
``` ```
If JupyterLab reopens old tabs, looks like notebooks are still running, or you want a true clean slate for the repo-local Jupyter state:
```bash
bash ./scripts/app.sh reset-state
```
Run the tests: Run the tests:
```bash ```bash

View file

@ -22,6 +22,9 @@ Commands:
stop stop
Stop the repo-local Jupyter server for this project. Stop the repo-local Jupyter server for this project.
reset-state
Remove repo-local Jupyter runtime and saved UI state files.
restart [--port PORT] [--open] [--foreground] restart [--port PORT] [--open] [--foreground]
Restart the repo-local Jupyter server. Restart the repo-local Jupyter server.
@ -46,6 +49,7 @@ Examples:
bash ./scripts/app.sh restart bash ./scripts/app.sh restart
bash ./scripts/app.sh status bash ./scripts/app.sh status
bash ./scripts/app.sh stop bash ./scripts/app.sh stop
bash ./scripts/app.sh reset-state
bash ./scripts/app.sh validate --quick bash ./scripts/app.sh validate --quick
EOF EOF
} }
@ -224,6 +228,90 @@ if verbose:
PY PY
} }
purge_runtime_artifacts() {
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR" "${1:-0}"
from __future__ import annotations
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
verbose = sys.argv[2] == "1"
runtime_dir = root / ".jupyter_runtime"
patterns = (
"jpserver-*.json",
"jpserver-*-open.html",
"nbserver-*.json",
"nbserver-*-open.html",
"kernel-*.json",
)
removed = []
for pattern in patterns:
for path in sorted(runtime_dir.glob(pattern)):
if path.exists():
path.unlink()
removed.append(path)
if verbose:
for path in removed:
print(path)
PY
}
purge_saved_ui_state() {
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR" "${1:-0}"
from __future__ import annotations
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
verbose = sys.argv[2] == "1"
data_dir = root / ".jupyter_data"
paths = [
data_dir / "nbsignatures.db",
data_dir / "notebook_secret",
]
workspace_dir = data_dir / "lab" / "workspaces"
removed = []
for path in paths:
if path.exists():
path.unlink()
removed.append(path)
for path in sorted(workspace_dir.glob("*.jupyterlab-workspace")):
if path.exists():
path.unlink()
removed.append(path)
if verbose:
for path in removed:
print(path)
PY
}
print_ui_state_summary() {
local workspace_count="0"
if compgen -G "$ROOT_DIR/.jupyter_data/lab/workspaces/*.jupyterlab-workspace" >/dev/null; then
workspace_count="$(find "$ROOT_DIR/.jupyter_data/lab/workspaces" -maxdepth 1 -name '*.jupyterlab-workspace' | wc -l | tr -d ' ')"
fi
local trust_db="absent"
local notebook_secret="absent"
[[ -f "$ROOT_DIR/.jupyter_data/nbsignatures.db" ]] && trust_db="present"
[[ -f "$ROOT_DIR/.jupyter_data/notebook_secret" ]] && notebook_secret="present"
if [[ "$workspace_count" == "0" && "$trust_db" == "absent" && "$notebook_secret" == "absent" ]]; then
echo "Jupyter UI state: clean"
return 0
fi
echo "Jupyter UI state: workspaces=$workspace_count, trust-db=$trust_db, notebook-secret=$notebook_secret"
echo " Clean slate: bash ./scripts/app.sh reset-state"
}
have_running_server() { have_running_server() {
local json_path local json_path
cleanup_stale_runtime_files >/dev/null cleanup_stale_runtime_files >/dev/null
@ -256,24 +344,34 @@ stop_server() {
ensure_venv ensure_venv
ensure_dirs ensure_dirs
local json_path pid local json_path pid removed_paths
cleanup_stale_runtime_files >/dev/null cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)" json_path="$(server_json_for_root || true)"
if [[ -z "$json_path" ]]; then if [[ -z "$json_path" ]]; then
echo "No repo-local Jupyter runtime file found." echo "No repo-local Jupyter runtime file found."
removed_paths="$(purge_runtime_artifacts 1 || true)"
if [[ -n "$removed_paths" ]]; then
echo "Removed stale runtime files:"
echo "$removed_paths"
fi
return 0 return 0
fi fi
pid="$(server_field "$json_path" "pid")" pid="$(server_field "$json_path" "pid")"
if ! kill -0 "$pid" >/dev/null 2>&1; then if ! kill -0 "$pid" >/dev/null 2>&1; then
local removed_paths
removed_paths="$(cleanup_stale_runtime_files 1 || true)" removed_paths="$(cleanup_stale_runtime_files 1 || true)"
local extra_removed
extra_removed="$(purge_runtime_artifacts 1 || true)"
echo "Jupyter runtime file exists, but the process is not running." echo "Jupyter runtime file exists, but the process is not running."
echo "Runtime file: $json_path" echo "Runtime file: $json_path"
if [[ -n "$removed_paths" ]]; then if [[ -n "$removed_paths" ]]; then
echo "Removed stale runtime files:" echo "Removed stale runtime files:"
echo "$removed_paths" echo "$removed_paths"
fi fi
if [[ -n "$extra_removed" ]]; then
echo "Removed leftover runtime artifacts:"
echo "$extra_removed"
fi
return 0 return 0
fi fi
@ -281,6 +379,7 @@ stop_server() {
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if ! kill -0 "$pid" >/dev/null 2>&1; then if ! kill -0 "$pid" >/dev/null 2>&1; then
cleanup_stale_runtime_files >/dev/null cleanup_stale_runtime_files >/dev/null
purge_runtime_artifacts >/dev/null
echo "Stopped Jupyter (pid $pid)." echo "Stopped Jupyter (pid $pid)."
return 0 return 0
fi fi
@ -317,6 +416,8 @@ start_server() {
fi fi
fi fi
purge_runtime_artifacts >/dev/null
if [[ "$foreground" == "1" ]]; then if [[ "$foreground" == "1" ]]; then
exec "$ROOT_DIR/.venv/bin/jupyter" lab \ exec "$ROOT_DIR/.venv/bin/jupyter" lab \
--no-browser \ --no-browser \
@ -378,6 +479,124 @@ validate_command() {
bash "$ROOT_DIR/scripts/run_validation.sh" "$@" bash "$ROOT_DIR/scripts/run_validation.sh" "$@"
} }
reset_state_command() {
ensure_venv
ensure_dirs
if have_running_server; then
stop_server >/dev/null
else
cleanup_stale_runtime_files >/dev/null
purge_runtime_artifacts >/dev/null
fi
local removed_runtime removed_ui
removed_runtime="$(purge_runtime_artifacts 1 || true)"
removed_ui="$(purge_saved_ui_state 1 || true)"
if [[ -z "$removed_runtime" && -z "$removed_ui" ]]; then
echo "Jupyter state was already clean."
return 0
fi
if [[ -n "$removed_runtime" ]]; then
echo "Removed runtime artifacts:"
echo "$removed_runtime"
fi
if [[ -n "$removed_ui" ]]; then
echo "Removed saved UI state:"
echo "$removed_ui"
fi
}
status_command() {
cd "$ROOT_DIR"
ensure_dirs
local branch commit git_state
branch="$(git branch --show-current)"
commit="$(git rev-parse --short HEAD)"
if [[ -n "$(git status --short)" ]]; then
git_state="dirty"
else
git_state="clean"
fi
echo "Root: $ROOT_DIR"
echo "Git: $branch @ $commit ($git_state)"
if [[ -x "$ROOT_DIR/.venv/bin/python" ]]; then
local python_version removed_runtime json_path pid url
python_version="$("$ROOT_DIR/.venv/bin/python" -c 'import sys; print(sys.version.split()[0])')"
echo "Venv: present (Python $python_version)"
cleanup_stale_runtime_files >/dev/null
if ! have_running_server; then
removed_runtime="$(purge_runtime_artifacts 1 || true)"
else
removed_runtime=""
fi
json_path="$(server_json_for_root || true)"
if compgen -G "$ROOT_DIR/.playwright-browsers/chromium-*" >/dev/null; then
echo "Browser runtime: installed"
else
echo "Browser runtime: missing"
fi
if [[ -f "$LOG_FILE" ]]; then
echo "Jupyter log: $LOG_FILE"
else
echo "Jupyter log: none yet"
fi
if [[ -n "$json_path" ]]; then
pid="$(server_field "$json_path" "pid")"
url="$(server_url_from_json "$json_path")"
if kill -0 "$pid" >/dev/null 2>&1; then
echo "Jupyter: running (pid $pid)"
echo " URL: $url"
echo " Runtime file: $json_path"
else
echo "Jupyter: runtime file exists but process is not running"
echo " Runtime file: $json_path"
fi
else
echo "Jupyter: no repo-local runtime file found"
if [[ -n "${removed_runtime:-}" ]]; then
echo " Purged stale runtime artifacts:"
echo "$removed_runtime"
fi
fi
print_ui_state_summary
else
echo "Venv: missing"
if compgen -G "$ROOT_DIR/.playwright-browsers/chromium-*" >/dev/null; then
echo "Browser runtime: installed"
else
echo "Browser runtime: missing"
fi
if [[ -f "$LOG_FILE" ]]; then
echo "Jupyter log: $LOG_FILE"
else
echo "Jupyter log: none yet"
fi
echo "Jupyter: repo-local environment is missing"
print_ui_state_summary
fi
echo "Consumer:"
echo " Start: bash ./scripts/app.sh start --open"
echo " Stop: bash ./scripts/app.sh stop"
echo " Restart: bash ./scripts/app.sh restart"
echo " Reset: bash ./scripts/app.sh reset-state"
echo " Logs: bash ./scripts/app.sh logs -f"
echo "Validation:"
echo " Quick: bash ./scripts/app.sh validate --quick"
echo " Full: bash ./scripts/app.sh validate --full"
}
start_command() { start_command() {
local port="8888" local port="8888"
local foreground="0" local foreground="0"
@ -441,11 +660,17 @@ case "$COMMAND" in
stop) stop)
stop_server stop_server
;; ;;
reset-state)
reset_state_command "$@"
;;
restart) restart)
restart_command "$@" restart_command "$@"
;; ;;
status) status)
exec bash "$ROOT_DIR/scripts/project_status.sh" status_command
;;
_status_internal)
status_command
;; ;;
url) url)
ensure_venv ensure_venv

77
scripts/project_status.sh Executable file → Normal file
View file

@ -8,8 +8,8 @@ usage() {
Usage: bash ./scripts/project_status.sh Usage: bash ./scripts/project_status.sh
Print the current local operational state of the QuantumLearning repo: Print the current local operational state of the QuantumLearning repo:
git state, repo-local Python environment, Playwright browser runtime, and git state, repo-local Python environment, Playwright browser runtime,
the latest repo-local Jupyter server if one is running. repo-local Jupyter status, and saved Jupyter UI state.
EOF EOF
} }
@ -18,75 +18,4 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
exit 0 exit 0
fi fi
cd "$ROOT_DIR" exec bash "$ROOT_DIR/scripts/app.sh" _status_internal
BRANCH="$(git branch --show-current)"
COMMIT="$(git rev-parse --short HEAD)"
if [[ -n "$(git status --short)" ]]; then
GIT_STATE="dirty"
else
GIT_STATE="clean"
fi
echo "Root: $ROOT_DIR"
echo "Git: $BRANCH @ $COMMIT ($GIT_STATE)"
if [[ -x "$ROOT_DIR/.venv/bin/python" ]]; then
PYTHON_VERSION="$("$ROOT_DIR/.venv/bin/python" -c 'import sys; print(sys.version.split()[0])')"
echo "Venv: present (Python $PYTHON_VERSION)"
else
echo "Venv: missing"
fi
if compgen -G "$ROOT_DIR/.playwright-browsers/chromium-*" >/dev/null; then
echo "Browser runtime: installed"
else
echo "Browser runtime: missing"
fi
if [[ -f "$ROOT_DIR/.logs/jupyterlab.log" ]]; then
echo "Jupyter log: $ROOT_DIR/.logs/jupyterlab.log"
else
echo "Jupyter log: none yet"
fi
if compgen -G "$ROOT_DIR/.jupyter_runtime/jpserver-*.json" >/dev/null; then
LATEST_SERVER_JSON="$(ls -t "$ROOT_DIR"/.jupyter_runtime/jpserver-*.json | head -n 1)"
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$LATEST_SERVER_JSON"
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text())
pid = int(data["pid"])
try:
os.kill(pid, 0)
except ProcessLookupError:
print("Jupyter: runtime file exists but process is not running")
print(f" Runtime file: {path}")
except PermissionError:
print("Jupyter: runtime file present; live process probe is unavailable in this environment")
print(f" URL: {data['url']}lab/tree/notebooks/COURSE_BLUEPRINT.ipynb?token={data['token']}")
print(f" Runtime file: {path}")
else:
print(f"Jupyter: running (pid {pid})")
print(f" URL: {data['url']}lab/tree/notebooks/COURSE_BLUEPRINT.ipynb?token={data['token']}")
print(f" Runtime file: {path}")
PY
else
echo "Jupyter: no repo-local runtime file found"
fi
echo "Consumer:"
echo " Start: bash ./scripts/app.sh start --open"
echo " Stop: bash ./scripts/app.sh stop"
echo " Restart: bash ./scripts/app.sh restart"
echo " Logs: bash ./scripts/app.sh logs -f"
echo "Validation:"
echo " Quick: bash ./scripts/app.sh validate --quick"
echo " Full: bash ./scripts/app.sh validate --full"

View file

@ -56,6 +56,7 @@ def test_project_status_script_runs():
assert result.returncode == 0 assert result.returncode == 0
assert "Git:" in result.stdout assert "Git:" in result.stdout
assert "Validation:" in result.stdout assert "Validation:" in result.stdout
assert "Jupyter UI state:" in result.stdout
def test_app_status_runs(): def test_app_status_runs():
@ -69,12 +70,15 @@ def test_app_status_runs():
assert result.returncode == 0 assert result.returncode == 0
assert "Consumer:" in result.stdout assert "Consumer:" in result.stdout
assert "Reset:" in result.stdout
def test_app_script_uses_detached_launch_and_stale_runtime_cleanup(): def test_app_script_uses_detached_launch_and_stale_runtime_cleanup():
text = (project_root() / "scripts" / "app.sh").read_text() text = (project_root() / "scripts" / "app.sh").read_text()
assert "cleanup_stale_runtime_files" in text assert "cleanup_stale_runtime_files" in text
assert "purge_runtime_artifacts" in text
assert "reset-state" in text
assert "nohup " in text assert "nohup " in text
@ -83,6 +87,7 @@ def test_operations_docs_use_tested_command_forms():
expected_commands = [ expected_commands = [
"bash ./scripts/app.sh bootstrap", "bash ./scripts/app.sh bootstrap",
"bash ./scripts/app.sh status", "bash ./scripts/app.sh status",
"bash ./scripts/app.sh reset-state",
"bash ./scripts/app.sh validate --quick", "bash ./scripts/app.sh validate --quick",
] ]
@ -90,3 +95,10 @@ def test_operations_docs_use_tested_command_forms():
text = (root / relative_path).read_text() text = (root / relative_path).read_text()
for command in expected_commands: for command in expected_commands:
assert command in text assert command in text
def test_jupyter_runtime_noise_is_ignored():
text = (project_root() / ".gitignore").read_text()
assert ".jupyter_data/nbsignatures.db" in text
assert ".jupyter_data/notebook_secret" in text