From 9781c9e676b6fe4bfe465cbb52b62c298344a208 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 5 Mar 2026 19:33:44 -0700 Subject: [PATCH] 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. --- backend/src/spicebook/storage/filesystem.py | 32 ++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/backend/src/spicebook/storage/filesystem.py b/backend/src/spicebook/storage/filesystem.py index a80ca18..0d9b156 100644 --- a/backend/src/spicebook/storage/filesystem.py +++ b/backend/src/spicebook/storage/filesystem.py @@ -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") - 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)