Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ exceptions look and feel genuine to debuggers and introspection tools.

## Core Functions

- `save_traceback(exc: BaseException, file: Path | BytesIO)`:
- `save_traceback(exc: BaseException, file: Path | BytesIO, proxy_types=DEFAULT_PROXY_TYPES)`:
Serializes an exception, its traceback, and all picklable local/global variables to a binary file or buffer.
`proxy_types` names classes whose instances must never be touched (see
[Object proxies](#object-proxies)).
- `load_traceback(file: Path | BytesIO) -> Never`:
Loads the serialized state, reconstructs the exception and its full traceback chain (including `__cause__` and `__context__`),
and raises it.
Expand Down Expand Up @@ -64,6 +66,30 @@ if isinstance(data, ExceptionGroupData):
print(f"Sub-exception frames: {len(sub_exc_data.tb_frames)}")
```

### Object proxies

An object proxy such as an [`rpyc`](https://rpyc.readthedocs.io/) netref turns every
instance operation - attribute reads, `repr`, `isinstance` (through `__class__`),
pickling (through `__reduce_ex__`) - into a remote call that blocks for the peer's
full timeout once the connection breaks, and a dead peer is often *why* the code
crashed. So `save_traceback` recognises proxies from their **type alone** and writes
a placeholder like `<proxy rpyc.core.netref.SaharaClient at 0x7f...>` without
touching the instance:

```python
save_traceback(e, dump_file, proxy_types=(*DEFAULT_PROXY_TYPES, MyProxyBase))
```

An entry is a class or a fully-qualified class name such as
`"rpyc.core.netref.BaseNetref"`, which names a proxy family without importing its
package; both forms match subclasses, since only `type(value).__mro__` is consulted.
`DEFAULT_PROXY_TYPES` covers rpyc netrefs out of the box. Proxies are replaced
wherever they appear: frame variables, items nested in containers, and an
exception's `args` or attributes.

An unregistered proxy is still slow to save, but can no longer lose the dump: a
placeholder falls back to the bare object repr when `repr` or `str` raises.

## Technical Implementation

- **True Frame Reconstruction**: Uses `ctypes` to call `PyFrame_New` from the Python C API. This creates real `frame` objects
Expand Down
3 changes: 2 additions & 1 deletion offline_debug/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

from ._inner.load_traceback import load_traceback, parse_traceback
from ._inner.models import ExceptionData, ExceptionGroupData, FrameData
from ._inner.save_traceback import save_traceback
from ._inner.save_traceback import DEFAULT_PROXY_TYPES, save_traceback

__all__ = [
"DEFAULT_PROXY_TYPES",
"ExceptionData",
"ExceptionGroupData",
"FrameData",
Expand Down
80 changes: 75 additions & 5 deletions offline_debug/_inner/_pickle_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import io
import pickle
import types
from collections.abc import Callable, Iterable
from typing import IO, Any

# Pickle protocol hooks that let a class control its own serialization.
Expand Down Expand Up @@ -73,6 +74,61 @@ def _has_python_constructor(cls: type[BaseException]) -> bool:
)


# Decides, from a value's type alone, whether it is a proxy the save must not touch.
ProxyMatcher = Callable[[object], bool]


def _no_proxies(value: object) -> bool: # noqa: ARG001 - the matcher for an empty registry
return False


def proxy_matcher(entries: Iterable[type | str] = ()) -> ProxyMatcher:
"""
Build the predicate that recognises the proxies a save must never touch.

A proxy such as an ``rpyc`` netref turns every instance operation - attribute
reads, ``repr``, ``hash``, ``isinstance`` through ``__class__``, pickling
through ``__reduce_ex__`` - into a remote call that blocks for the peer's full
timeout on a broken connection. The save only wants to note that the value was
there.

An entry is a class, or the fully-qualified name of one such as
``"rpyc.core.netref.BaseNetref"``, which names a proxy family without importing
its package. Either form matches subclasses, since only ``type(value).__mro__``
is consulted - the one thing guaranteed local for every object, and a property
the predicate must keep: it runs before the pickler's own ``isinstance``.
"""
classes: set[type] = set()
names: set[str] = set()
for entry in entries:
if isinstance(entry, type):
classes.add(entry)
elif isinstance(entry, str):
names.add(entry)
else:
msg = (
"proxy_types entries must be classes or fully-qualified class names, "
f"got {type(entry).__name__}"
)
raise TypeError(msg)
if not classes and not names:
return _no_proxies

def matches(value: object) -> bool:
return any(
cls in classes or f"{cls.__module__}.{cls.__qualname__}" in names
for cls in type(value).__mro__
)

return matches


def proxy_placeholder(value: object) -> str:
"""Describe a proxy from local facts only: its class and its identity."""
cls = type(value)
return f"<proxy {cls.__module__}.{cls.__qualname__} at 0x{id(value):x}>"


def reconstruct_exception(
cls: type[BaseException], args: tuple[Any, ...], state: dict[str, Any] | None
) -> BaseException:
Expand Down Expand Up @@ -112,11 +168,25 @@ def reconstruct_exception_group(


class CustomExceptionPickler(pickle.Pickler):
"""Pickler that takes over reconstruction of constructor-rejecting exceptions."""
"""
Pickler that takes over reconstruction of constructor-rejecting exceptions.

It also writes a placeholder for a registered proxy (see :func:`proxy_matcher`)
wherever one appears - nested in a container, in an exception's ``args`` or
attributes - instead of asking it how to pickle itself, which is a remote call.
"""

def __init__(self, file: IO[bytes], is_proxy: ProxyMatcher = _no_proxies) -> None:
super().__init__(file)
self._is_proxy = is_proxy

# The inline suppression below is needed because this returns NotImplemented to
# fall back to default reduction, which the stdlib stub's return type omits.
def reducer_override(self, obj: object, /) -> object: # ty: ignore[invalid-method-override]
# Proxies first: even the isinstance() below can reach the peer through
# a proxy's __class__, so nothing may touch the instance before this.
if self._is_proxy(obj):
return str, (proxy_placeholder(obj),)
if not isinstance(obj, BaseException):
return NotImplemented
cls = type(obj)
Expand All @@ -132,13 +202,13 @@ def reducer_override(self, obj: object, /) -> object: # ty: ignore[invalid-meth
return reconstruct_exception, (cls, obj.args, state)


def exception_safe_dump(obj: object, file: IO[bytes]) -> None:
def exception_safe_dump(obj: object, file: IO[bytes], is_proxy: ProxyMatcher = _no_proxies) -> None:
"""Serialize ``obj`` to an open binary ``file`` using :class:`CustomExceptionPickler`."""
CustomExceptionPickler(file).dump(obj)
CustomExceptionPickler(file, is_proxy).dump(obj)


def exception_safe_dumps(obj: object) -> bytes:
def exception_safe_dumps(obj: object, is_proxy: ProxyMatcher = _no_proxies) -> bytes:
"""Serialize ``obj`` to bytes using :class:`CustomExceptionPickler`."""
buf = io.BytesIO()
exception_safe_dump(obj, buf)
exception_safe_dump(obj, buf, is_proxy)
return buf.getvalue()
89 changes: 72 additions & 17 deletions offline_debug/_inner/save_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,49 @@
import marshal
import pickle
import types
from collections.abc import Iterable
from io import BytesIO
from pathlib import Path

from offline_debug._inner._pickle_helpers import exception_safe_dump, exception_safe_dumps
from offline_debug._inner._pickle_helpers import (
ProxyMatcher,
exception_safe_dump,
exception_safe_dumps,
proxy_matcher,
proxy_placeholder,
)
from offline_debug._inner.models import ExceptionData, ExceptionGroupData, FrameData

# Internal attributes that are either unpicklable or redundant in a new process.
# We exclude these specifically because they are automatically recreated
# when the new frame is initialized or when the module is imported.
_INTERNAL_ATTRIBUTES_TO_SKIP = ("__builtins__", "__doc__", "__loader__", "__package__", "__spec__")

# Proxy classes recognised out of the box, by name so the package need not be
# installed. Pass ``proxy_types=(*DEFAULT_PROXY_TYPES, ...)`` to extend the set.
DEFAULT_PROXY_TYPES: tuple[type | str, ...] = ("rpyc.core.netref.BaseNetref",)


def _safe_repr(value: object) -> str:
"""
``repr(value)``, falling back to the bare object repr if it raises.

A placeholder describes a value that already failed to pickle, whose ``repr``
may well fail too - and a placeholder that raises would lose the whole dump.
"""
try:
return repr(value)
except BaseException: # noqa: BLE001 - see docstring
return object.__repr__(value)


def _safe_str(value: object) -> str:
"""``str(value)``, falling back to the bare object repr if it raises."""
try:
return str(value)
except BaseException: # noqa: BLE001 - as for _safe_repr
return object.__repr__(value)


def _get_stack_depth(frame: types.FrameType) -> int:
"""Calculate the depth of the current stack frame."""
Expand All @@ -25,20 +57,26 @@ def _get_stack_depth(frame: types.FrameType) -> int:
return depth


def _filter_dict(d: dict, roundtrip_cache: dict[int, str | None]) -> dict:
def _filter_dict(d: dict, roundtrip_cache: dict[int, str | None], is_proxy: ProxyMatcher) -> dict:
"""
Filter dictionary to include only items that survive a pickle round-trip.

``roundtrip_cache`` maps ``id(value)`` to ``None`` (survives) or a placeholder
string, so a value shared across frames (e.g. module globals) is only checked
once per save. The cached objects stay alive for the whole save because the
frames still reference them, so the ids are stable.

A registered proxy is replaced here, before the round-trip check would pickle
it - a remote call. One nested inside a container is left to the pickler, which
writes the same placeholder in its place.
"""
result = {}
for k, v in d.items():
if k in _INTERNAL_ATTRIBUTES_TO_SKIP:
continue
cache_key = id(v)
if cache_key not in roundtrip_cache and is_proxy(v):
roundtrip_cache[cache_key] = proxy_placeholder(v)
if cache_key not in roundtrip_cache:
try:
# We must verify that the value survives a full pickle round-trip
Expand All @@ -48,18 +86,18 @@ def _filter_dict(d: dict, roundtrip_cache: dict[int, str | None]) -> dict:
# Such values would otherwise break the entire load, so we replace
# them with a placeholder. We use the same pickler that serializes
# these dicts so the check reflects what will actually be written.
pickle.loads(exception_safe_dumps(v)) # noqa: S301
pickle.loads(exception_safe_dumps(v, is_proxy)) # noqa: S301
roundtrip_cache[cache_key] = None
except BaseException: # noqa: BLE001 - even a KeyboardInterrupt raised by a
# value's reconstruction must not abort capturing the traceback.
roundtrip_cache[cache_key] = f"<unpicklable {type(v).__name__}: {v!r}>"
roundtrip_cache[cache_key] = f"<unpicklable {type(v).__name__}: {_safe_repr(v)}>"
placeholder = roundtrip_cache[cache_key]
result[k] = v if placeholder is None else placeholder
return result


def _serialize_exc_data(
exc: BaseException, roundtrip_cache: dict[int, str | None]
exc: BaseException, roundtrip_cache: dict[int, str | None], is_proxy: ProxyMatcher
) -> ExceptionData:
"""Serialize an exception graph, preserving cycles and shared nodes."""
memo: dict[int, ExceptionData] = {}
Expand All @@ -70,7 +108,7 @@ def node_for(current: BaseException) -> ExceptionData:
if node is not None:
return node

node = _serialize_exception(current, roundtrip_cache)
node = _serialize_exception(current, roundtrip_cache, is_proxy)
memo[id(current)] = node
pending.append(current)
return node
Expand All @@ -90,7 +128,7 @@ def node_for(current: BaseException) -> ExceptionData:


def _serialize_exception(
exc: BaseException, roundtrip_cache: dict[int, str | None]
exc: BaseException, roundtrip_cache: dict[int, str | None], is_proxy: ProxyMatcher
) -> ExceptionData:
"""Serialize one exception without following graph edges."""
tb_frames: list[FrameData] = []
Expand All @@ -110,8 +148,8 @@ def _serialize_exception(
tb_frames.append(
FrameData(
code=marshal.dumps(f.f_code),
globals=_filter_dict(f.f_globals, roundtrip_cache),
locals=_filter_dict(f.f_locals, roundtrip_cache),
globals=_filter_dict(f.f_globals, roundtrip_cache, is_proxy),
locals=_filter_dict(f.f_locals, roundtrip_cache, is_proxy),
lasti=curr_tb.tb_lasti,
lineno=curr_tb.tb_lineno,
stack_depth=_get_stack_depth(f),
Expand All @@ -121,14 +159,15 @@ def _serialize_exception(
curr_tb = curr_tb.tb_next

try:
exc_pickle = exception_safe_dumps(exc)
exc_pickle = exception_safe_dumps(exc, is_proxy)
# A dump that cannot be loaded later is worse than a placeholder, so also
# verify the exception survives loading (e.g. a custom __reduce__ whose
# reconstruction fails only at load time).
pickle.loads(exc_pickle) # noqa: S301
except Exception: # noqa: BLE001
except BaseException: # noqa: BLE001 - as in _filter_dict: nothing a value does
# while being pickled may abort capturing the traceback.
exc_pickle = exception_safe_dumps(
RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}")
RuntimeError(f"Unpicklable exception {type(exc).__name__}: {_safe_str(exc)}")
)

if isinstance(exc, BaseExceptionGroup):
Expand All @@ -145,17 +184,33 @@ def _serialize_exception(
return ExceptionData(exc_pickle=exc_pickle, tb_frames=tb_frames)


def save_traceback(exc: BaseException, file: Path | BytesIO | None) -> ExceptionData:
"""Serialize an exception and its traceback to a file."""
data = _serialize_exc_data(exc, roundtrip_cache={})
def save_traceback(
exc: BaseException,
file: Path | BytesIO | None,
proxy_types: Iterable[type | str] = DEFAULT_PROXY_TYPES,
) -> ExceptionData:
"""
Serialize an exception and its traceback to a file.

``proxy_types`` lists classes whose instances must not be touched - object
proxies such as ``rpyc`` netrefs, for which reading an attribute, ``repr``,
``isinstance`` or pickling is a remote call that blocks for the peer's full
timeout on a broken connection. They are recognised by type alone and saved as
a placeholder naming the class and identity. An entry is a class or a
fully-qualified class name (``"pkg.module.Class"``), naming a proxy family
without importing its package; both match subclasses. Defaults to
:data:`DEFAULT_PROXY_TYPES`.
"""
is_proxy = proxy_matcher(proxy_types)
data = _serialize_exc_data(exc, roundtrip_cache={}, is_proxy=is_proxy)
if file is None:
return data

if isinstance(file, Path):
with file.open("wb") as f:
exception_safe_dump(data, f)
exception_safe_dump(data, f, is_proxy)
elif isinstance(file, BytesIO):
exception_safe_dump(data, file)
exception_safe_dump(data, file, is_proxy)
else:
msg = f"Unexpected type for file {type(file).__name__}"
raise TypeError(msg)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "offline-debug"
version = "0.3.2"
version = "0.3.3"
description = "Debug exceptions offline by saving them to a dump and raising them at a later point."
readme = "README.md"
authors = [
Expand Down
Loading
Loading