Skip to content

fix: NEWOBJ_EX objects re-encode as copyreg.__newobj_ex__ REDUCE (#35) - #57

Merged
jensens merged 4 commits into
mainfrom
fix/newobj-ex
Sep 30, 2026
Merged

jensens merged 4 commits into
mainfrom
fix/newobj-ex

Conversation

@jensens

@jensens jensens commented Sep 30, 2026

Copy link
Copy Markdown
Member

Fixes #35

Built on #56 (fix/reduce-build-newobj) and targeting main so CI runs: the diff shows #56's four commits plus four of its own. Merge #56 first and this shrinks to its own commits; merging this one first brings #56 along.

The problem

A protocol 4 pickle creating an object with NEWOBJ_EX (__getnewargs_ex__ with keyword arguments) decoded into a Reduce whose args were a dict {"@args": ..., "@kwargs": ...}, and re-encoding wrote GLOBAL <dict> NEWOBJ, which CPython rejects (NEWOBJ args argument must be a tuple); with a following BUILD the object came back without its constructor arguments. ZODB writes protocol 3, so only pickle_to_json of protocol 4 pickles met it.

Design (a fork the issue left open, decided in the plan)

Instead of a new kwargs key, the decoder turns cls args kwargs NEWOBJ_EX into copyreg.__newobj_ex__(cls, args, kwargs) with newobj: false: the callable CPython's own object.__reduce_ex__ returns for __getnewargs_ex__ objects (the pickler only rewrites it into NEWOBJ_EX at protocol 4). It is a plain REDUCE for every writer, reader and encoder, loads to cls.__new__(cls, *args, **kwargs) on any Python 3.4 or newer, and a following BUILD becomes its state. The reviewer checked the ruling against both CPython unpicklers: the REDUCE form is never less permissive than NEWOBJ_EX and exactly as permissive as the pure-Python unpickler.

The encoders also translate the shape 1.6.x wrote for these objects (the two-key @args/@kwargs dict as args, with or without a newobj key, and nested in an instance's @args/@state state) into the same call, so old pickle_to_json output becomes loadable. A dict is never a valid REDUCE/NEWOBJ argument, so nothing loadable is misread.

Evidence

  • 242 Rust and 402 Python tests; the Python tests round-trip __getnewargs_ex__ classes through every path at protocol 4, with and without state, with true empty args and kwargs (a custom __reduce_ex__, the only way CPython emits them), through ZODB records written by one pickler (shared memo), and load both legacy shapes.
  • The 1,692-record sample produces identical PG JSON, dict-path output and re-encoded pickles.
  • A fresh reviewer ran 21 __getnewargs_ex__ shapes at protocols 2 to 5 through five paths against pickle.loads of the source with the C and the pure-Python unpickler, nested NEWOBJ_EX 1,100 deep (clean error, no crash), and the 1.6.1 wheel's actual output. Its one Important finding (the 1.6.1 shape has no newobj key, the translation was gated on it) is the last commit.

Assisted-by: Claude Fable 5.1

`cls args kwargs NEWOBJ_EX` becomes `Reduce { copyreg.__newobj_ex__,
(cls, args, kwargs) }`, the callable CPython's own `object.__reduce_ex__`
returns for `__getnewargs_ex__` objects (the pickler rewrites it into
`NEWOBJ_EX` only at protocol 4). It is a plain REDUCE for the writers,
readers and encoders, loads to `cls.__new__(cls, *args, **kwargs)` on any
Python 3.4+, and a following BUILD becomes its state. The `@args`/`@kwargs`
dict the decoder used to build produced a pickle CPython rejects.

Assisted-by: Claude Fable 5.1
The encoders accept the shape earlier releases wrote for NEWOBJ_EX objects
(`newobj` with a dict `args` holding `@args` and `@kwargs`, also nested in
the `@args`/`@state` state of an instance) and emit the same
`copyreg.__newobj_ex__(cls, args, kwargs) REDUCE` call the decoder now
produces. Python tests round-trip `__getnewargs_ex__` classes through every
path at protocol 4, with and without state, and load both legacy shapes.

Assisted-by: Claude Fable 5.1
Assisted-by: Claude Fable 5.1
…ies (#35)

Fresh review findings: 1.6.1 wrote the NEWOBJ_EX shape without a `newobj`
key (the field did not exist), so the translation to the copyreg call must
not depend on it; a dict is never a valid REDUCE or NEWOBJ argument, so the
two-key `@args`/`@kwargs` dict is translated wherever it appears. The tests
build ZODB records with one pickler (shared memo, with a memo reference in
the state), add true empty args and kwargs through a custom `__reduce_ex__`,
and the 1.6.1 shape; the args and kwargs of the call are encoded one level
deeper, as items of the tuple; the docs say the encoders translate the old
shape rather than that readers accept it.

Assisted-by: Claude Fable 5.1
@jensens
jensens force-pushed the fix/newobj-ex branch 2 times, most recently from c86b976 to 5afc700 Compare September 30, 2026 15:17
@jensens
jensens merged commit 9627ddc into main Sep 30, 2026
10 checks passed
@jensens
jensens deleted the fix/newobj-ex branch September 30, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NEWOBJ_EX (protocol 4) objects cannot be re-encoded

1 participant