Skip to content

MemoryError is raised through a path that allocates #8536

Description

@youknowone

Summary

vm.new_memory_error("") is how a failed allocation is reported, and raising it allocates four times. Under real memory pressure those allocations abort the process, which is the one situation the exception exists for.

Most of those call sites want something narrower: "an allocation just failed, say so". That deserves its own vm.no_memory_error(), taking no message, on a path that does not allocate — PyErr_NoMemory(), whose whole point is that it hands back a preallocated MemoryError rather than building one.

Details

vm.new_memory_error(msg) (crates/vm/src/vm/vm_new.rs:1019, through define_exception_fn!) goes

new_memory_error(msg)
  -> new_exception_msg(type, msg)          vm_new.rs:407
       -> ctx.new_str(msg)                 context.rs:482   allocates a PyStr
       -> vec![...]                                          allocates a Vec
  -> new_exception(type, args)             vm_new.rs:344
       -> PyBaseException::new(args, vm)                     allocates the args tuple
       -> into_ref_with_type_lazy_dict(..)                   allocates the object

Every one of those object allocations lands on crates/vm/src/object/core.rs:1193, which calls alloc::alloc::handle_alloc_error on failure. With panic = "abort" (Cargo.toml:125) that is the process, not an exception.

The empty message is not free either. ctx.new_str("") takes the s.into_ref(self) branch: the latin1 singleton cache it checks first only covers single characters, so an empty message allocates a fresh PyStr on every raise, even though ctx.empty_str is sitting right there already interned (context.rs:41, context.rs:362).

What the call sites look like

28 calls to new_memory_error today; 20 of them pass "":

argument count
"" 20
"source location is too large" 3
"failed to query cert store" 1
"failed to get cert store" 1
"failed to allocate BIO" 1
(non-literal) 2

The "" ones are the PyErr_NoMemory() shape — a try_reserve returned Err, or a foreign allocator returned null — and they read better as vm.no_memory_error() than as an error type plus an empty string that then has to be allocated.

The ones carrying a message are a separate question and can stay as they are; a few of them are arguably not MemoryError at all (nothing was allocated, a size was refused), but that is worth deciding case by case rather than in bulk.

What it would take

CPython keeps preallocated MemoryError instances so the raise itself never allocates, refilling the freelist when memory is available again and falling back to a statically allocated one when it is not. RustPython has the pieces to do the same: ctx.empty_str and ctx.empty_tuple already exist, and a preallocated instance could live next to them in Context.

The hazards are the ones CPython also has to handle, and are the actual design work here:

  • a shared instance accumulates state across raises — __traceback__, __context__, __cause__ — so it has to be reset, or handed out from a pool and returned
  • reference cycles through a retained traceback
  • thread safety, since the instance would be shared across interpreter threads

Not urgent — nothing here is a live crash — but the current shape means the OOM path is the least reliable path in the interpreter, which is backwards.

reported by Claude

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions