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 logging
import os
import re
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
@ -116,6 +118,9 @@ def list_notebooks(directory: Path) -> list[NotebookSummary]:
try:
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)
summaries.append(NotebookSummary(
id=nb_id,
@ -152,10 +157,18 @@ def save_notebook(directory: Path, notebook_id: str, notebook: Notebook) -> None
notebook.metadata.modified = _now_iso()
path = user_dir / f"{notebook_id}{SPICEBOOK_EXT}"
path.write_text(
notebook.model_dump_json(indent=2),
encoding="utf-8",
)
content = notebook.model_dump_json(indent=2)
# Atomic write: write to temp file then rename, so a crash can't leave an empty file
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]:
@ -196,8 +209,13 @@ def delete_notebook(directory: Path, notebook_id: str) -> bool:
return False
def _read_notebook_file(path: Path) -> Notebook:
"""Read and validate a .spicebook JSON file."""
def _read_notebook_file(path: Path) -> Notebook | None:
"""Read and validate a .spicebook JSON file. Returns None on corrupt/empty files."""
raw = path.read_text(encoding="utf-8")
if not raw.strip():
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None
return Notebook.model_validate(data)