Handle corrupt/empty notebook files, atomic writes

_read_notebook_file returns None on empty or unparseable JSON instead
of crashing with JSONDecodeError (surfaced as 500). save_notebook now
writes to a temp file and atomically renames, preventing empty files
from container restarts mid-write.
This commit is contained in:
Ryan Malloy 2026-03-05 19:33:44 -07:00
parent a2b76539da
commit 9781c9e676

View File

@ -2,7 +2,9 @@
import json import json
import logging import logging
import os
import re import re
import tempfile
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -116,6 +118,9 @@ def list_notebooks(directory: Path) -> list[NotebookSummary]:
try: try:
nb = _read_notebook_file(path) nb = _read_notebook_file(path)
if nb is None:
logger.warning("Skipping empty/corrupt notebook: %s", path)
continue
description, schematic_svg = _extract_card_data(nb) description, schematic_svg = _extract_card_data(nb)
summaries.append(NotebookSummary( summaries.append(NotebookSummary(
id=nb_id, id=nb_id,
@ -152,10 +157,18 @@ def save_notebook(directory: Path, notebook_id: str, notebook: Notebook) -> None
notebook.metadata.modified = _now_iso() notebook.metadata.modified = _now_iso()
path = user_dir / f"{notebook_id}{SPICEBOOK_EXT}" path = user_dir / f"{notebook_id}{SPICEBOOK_EXT}"
path.write_text( content = notebook.model_dump_json(indent=2)
notebook.model_dump_json(indent=2), # Atomic write: write to temp file then rename, so a crash can't leave an empty file
encoding="utf-8", fd, tmp = tempfile.mkstemp(dir=user_dir, suffix=".tmp")
) try:
os.write(fd, content.encode("utf-8"))
os.fsync(fd)
os.close(fd)
os.replace(tmp, path)
except BaseException:
os.close(fd)
os.unlink(tmp)
raise
def create_notebook(directory: Path, title: str, engine: str = "ngspice") -> tuple[str, Notebook]: def create_notebook(directory: Path, title: str, engine: str = "ngspice") -> tuple[str, Notebook]:
@ -196,8 +209,13 @@ def delete_notebook(directory: Path, notebook_id: str) -> bool:
return False return False
def _read_notebook_file(path: Path) -> Notebook: def _read_notebook_file(path: Path) -> Notebook | None:
"""Read and validate a .spicebook JSON file.""" """Read and validate a .spicebook JSON file. Returns None on corrupt/empty files."""
raw = path.read_text(encoding="utf-8") raw = path.read_text(encoding="utf-8")
data = json.loads(raw) if not raw.strip():
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None
return Notebook.model_validate(data) return Notebook.model_validate(data)