#8764·langgraph

Crash before first durable checkpoint can silently drop an accepted run with no durable failure record

Author: mstevens843Created Aug 30, 2026Updated Sep 17, 2026
Labelsbugexternal

Checked other resources

  • This is a bug, not a usage question.
  • I added a clear and descriptive title that summarizes this issue.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangGraph rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package).
  • This is not related to the langchain-community package.
  • I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.

Related Issues / PRs

Related but separate from #8039 and #8753.

This is also adjacent to #5672, but not the same case: #5672 is about cancellation losing streamed state after a run has begun streaming. This issue is about process death before the first durable checkpoint exists.

Reproduction Steps / Example Code (Python)

python
from __future__ import annotations

import os
import signal
import sqlite3
import subprocess
import sys
import tempfile
import textwrap
from pathlib import Path

SUBJECT = r"""
from __future__ import annotations

import os
import signal
import sqlite3
import sys
from typing import Any, TypedDict

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph


class S(TypedDict):
    done: bool


seen_first_put = False


class CrashBeforeFirstPut(SqliteSaver):
    def put(self, *args: Any, **kwargs: Any) -> Any:
        global seen_first_put
        if not seen_first_put:
            seen_first_put = True
            os.kill(os.getpid(), signal.SIGKILL)
        return super().put(*args, **kwargs)


def node(state: S) -> S:
    with open(sys.argv[2], "a", encoding="utf-8") as f:
        f.write("effect\n")
    return {"done": True}


graph = StateGraph(S)
graph.add_node("node", node)
graph.add_edge(START, "node")
graph.add_edge("node", END)

conn = sqlite3.connect(sys.argv[1], check_same_thread=False)
saver = CrashBeforeFirstPut(conn)
saver.setup()
app = graph.compile(checkpointer=saver)
app.invoke({"done": False}, {"configurable": {"thread_id": "accepted-run"}}, durability="sync")
"""

RECOVERY = r"""
from __future__ import annotations

import sqlite3
import sys
from typing import TypedDict

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph


class S(TypedDict):
    done: bool


def node(state: S) -> S:
    with open(sys.argv[2], "a", encoding="utf-8") as f:
        f.write("effect\n")
    return {"done": True}


graph = StateGraph(S)
graph.add_node("node", node)
graph.add_edge(START, "node")
graph.add_edge("node", END)

conn = sqlite3.connect(sys.argv[1], check_same_thread=False)
saver = SqliteSaver(conn)
saver.setup()
app = graph.compile(checkpointer=saver)

try:
    out = app.invoke(None, {"configurable": {"thread_id": "accepted-run"}}, durability="sync")
    print(f"recovery returned: {out}")
except Exception as exc:
    print(f"recovery raised: {type(exc).__name__}: {exc}")
"""


def checkpoint_count(db: Path) -> int | str:
    try:
        with sqlite3.connect(db) as conn:
            return conn.execute("select count(*) from checkpoints").fetchone()[0]
    except Exception as exc:
        return f"{type(exc).__name__}: {exc}"


with tempfile.TemporaryDirectory() as tmp:
    root = Path(tmp)
    db = root / "checkpoints.sqlite"
    effects = root / "effects.txt"
    subject = root / "subject.py"
    recovery = root / "recovery.py"
    subject.write_text(textwrap.dedent(SUBJECT), encoding="utf-8")
    recovery.write_text(textwrap.dedent(RECOVERY), encoding="utf-8")

    crashed = subprocess.run(
        [sys.executable, str(subject), str(db), str(effects)],
        text=True,
        capture_output=True,
    )
    print(f"subject exit code: {crashed.returncode}")

    recovered = subprocess.run(
        [sys.executable, str(recovery), str(db), str(effects)],
        text=True,
        capture_output=True,
    )
    print(recovered.stdout.strip())

    effect_count = effects.read_text(encoding="utf-8").count("effect") if effects.exists() else 0
    print(f"effect count: {effect_count}")
    print(f"durable checkpoints: {checkpoint_count(db)}")

Error Message and Stack Trace (if applicable)

bash
subject exit code: -9
recovery raised: EmptyInputError: Received no input for __start__
effect count: 0
durable checkpoints: 0

Description

A LangGraph run that dies before its first durable checkpoint leaves recovery with nothing to resume. That part is expected mechanically: if nothing was persisted, there is no state to replay.

The issue is the visibility boundary. For fire-and-forget / background invocation shapes, the run can be accepted by the caller's system, die before the first checkpoint, and then leave no durable record that the run ever existed or failed. A later recovery attempt has no checkpoint and raises EmptyInputError, but a system that is not synchronously waiting on the killed process has no durable failure marker to observe.

This came out while calibrating crashpoint's LangGraph #8039 probe. The original "before effect" crash point sometimes landed before LangGraph's first durable checkpoint. Those runs recovered as LOST: zero effects crossed, and fresh-process recovery had nothing to resume. I then refined crashpoint's b0 barrier to crash after the entry checkpoint was durable, because this pre-checkpoint loss is a separate failure mode from #8039's put_writes / put ordering race.

Recorded here: https://github.com/mstevens843/crashpoint/blob/main/results/03-langgraph-and-controls.md

Expected Behavior

One of these should be available or documented clearly:

  • a durable "accepted / started" record before user node execution;
  • a durable failed/aborted marker when recovery sees a known run with no resumable checkpoint;
  • or explicit documentation that callers must provide their own acceptance ledger if they need fire-and-forget run admission to be durable.

Actual Behavior

If the process dies before the first checkpoint is persisted:

  • recovery has no checkpoint for the thread;
  • invoke(None, config, durability="sync") raises EmptyInputError: Received no input for __start__;
  • no user effect crosses;
  • and, absent an external acceptance ledger, there is no durable record that the run was ever accepted.

Why This Matters

This is a different shape from duplicate external effects. The duplicate case teaches the operator something happened twice. This case can teach nothing happened at all: a background run disappears before the runtime has a durable record for it.

System Info

System Information

OS: Darwin OS Version: Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:40 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6041 Python Version: 3.12.13 (main, Jun 23 2026, 15:44:24) [Clang 22.1.3 ]

Package Information

langchain_core: 1.6.1 langsmith: 0.11.1 langchain_protocol: 0.0.19 langgraph_sdk: 0.4.3

Optional package:

langgraph: 1.2.11 langgraph-checkpoint-sqlite: 3.1.1