What happens
When a Python Worker returns bytes via Response(some_bytes, ...), the first 8 bytes of the response body are overwritten with dlmalloc internal tree chunk metadata (parent pointer + index field). The remaining bytes are delivered correctly. No error is raised. This affects all consumption methods — direct HTTP, Service Bindings, etc.
- Small-to-medium payloads: corrupted 100% of the time (confirmed up to ~300KB+)
- Sufficiently large payloads: not affected (e.g. 500KB+)
- The exact threshold is not fixed — it depends on dlmalloc's heap state at deallocation time (whether the freed chunk is inserted into a tree bin or merged with the top chunk). The boundary varies across deployments and instances.
- Using
to_js() to copy bytes before passing to Response: not affected
Root cause
The bug is in cloudflare/workers-py, not in workerd itself.
File: packages/runtime-sdk/src/workers/_workers.py, lines 348–358 and 431–436
@contextmanager
def _get_js_body(body):
if isinstance(body, bytes):
proxy_bytes = create_proxy(body)
proxy_buffer = proxy_bytes.getBuffer()
try:
yield proxy_buffer.data # Uint8Array VIEW into WASM linear memory
return
finally:
proxy_buffer.release()
proxy_bytes.destroy()
# Line 431: "To avoid unnecessary copies we use this context manager."
with _get_js_body(body) as js_body:
js_resp = js.Response.new(js_body, **options)
getBuffer().data returns a Uint8Array view backed by WASM linear memory — not a copy. Pyodide docs state: "The toJs() API copies the buffer into JavaScript, whereas the getBuffer() method allows low level access to the WASM memory backing the buffer."
The JS Response does not eagerly copy the body. After the handler function returns, the Python bytes object's last reference is dropped, CPython frees it, and dlmalloc reclaims the underlying memory — overwriting part of it with its own bookkeeping. When the body is later consumed, the first 8 bytes have already been overwritten.
Why exactly 8 bytes, and why at that position
Verified by dumping WASM memory before and after object deallocation via a /investigate endpoint (code in the reproduction repository).
Python bytes object layout on wasm32 (header = 16 bytes):
malloc return addr (+0x01291c88):
+0: ob_refcnt (4B)
+4: *ob_type (4B)
+8: ob_size (4B)
+12: ob_shash (4B)
+16: ob_val[0..] (byte data) ← getBuffer().data points HERE (+0x01291c98)
getBuffer().data points to offset +16 from the malloc return address.
dlmalloc malloc_tree_chunk layout written on free (wasm32, SIZE_SZ=4):
user data +0: fd (4B) → overwrites ob_refcnt
+4: bk (4B) → overwrites *ob_type
+8: child[0] (4B) → overwrites ob_size
+12: child[1] (4B) → overwrites ob_shash
+16: parent (4B) → overwrites ob_val[0:4] ← corrupted byte 0–3
+20: index (4B) → overwrites ob_val[4:8] ← corrupted byte 4–7
Offset +16 and +20 of the tree chunk land exactly on the first 8 bytes of ob_val — which is where getBuffer().data points. The corruption is dlmalloc writing its parent pointer and tree bin index into what it now considers freed memory.
WASM memory dump confirming this (1KB payload, del data triggers deallocation):
Before free (PyObject header, 24 bytes at 0x01291c88):
03 00 00 00 f0 83 2a 00 00 04 00 00 ff ff ff ff 01 01 01 01 01 01 01 01
[ob_refcnt=3][*ob_type ][ob_size=1024][ob_shash=-1][ob_val... ]
After free (same 24 bytes):
80 1c 29 01 80 1c 29 01 00 00 00 00 00 00 00 00 e8 56 13 01 04 00 00 00
[fd ][bk ][child[0]=0 ][child[1]=0 ][parent ][index=4 ]
ob_val bytes 0–3 (01 01 01 01) became e8 56 13 01 (parent pointer), and bytes 4–7 became 04 00 00 00 (index=4). This is what the response consumer reads.
The index field increases with allocation size (example from one deployment):
Size Corrupted first 8 bytes parent index
1KB e8 56 13 01 04 00 00 00 0x011356e8 4
10KB 28 ab 44 01 0a 00 00 00 0x0144ab28 10
50KB 8c fe 35 00 0f 00 00 00 0x0035fe8c 15
100KB 94 fe 35 00 11 00 00 00 0x0035fe94 17
Exact parent pointers and index values vary across deployments and instances, but the structure is consistent: first 4 bytes are a heap pointer, next 4 bytes are a small integer that increases with allocation size.
Additional finding: corruption timing
The WASM memory dump also shows that corruption does not occur at proxy.destroy(). After destroy(), only ob_refcnt decreases (3 → 1); the byte data is untouched. The actual corruption occurs when the Python bytes object is deallocated (del / function return → refcount reaches 0 → free()).
Corruption pattern (independently verified)
Direct curl to the Python Worker using ZIP test payloads (one deployment, 3 calls per size):
Size Expected first 8B Received first 8B Result
1KB 504b030414000000 d862250104000000 CORRUPTED (3/3)
10KB 504b030414000000 000000000c000000 CORRUPTED (3/3)
50KB 504b030414000000 5071330112000000 CORRUPTED (3/3)
100KB 504b030414000000 5071330112000000 CORRUPTED (3/3)
200KB 504b030414000000 504b030414000000 CLEAN (3/3)
500KB 504b030414000000 504b030414000000 CLEAN (3/3)
Note: 200KB results vary across deployments — some instances show corruption at this size. The boundary is not fixed.
With raw bytes (no ZIP compression), corruption extends well beyond 100KB. The exact upper threshold varies by deployment and heap state.
Corruption is deterministic: same size always produces identical corrupted bytes regardless of content.
Workaround
from pyodide.ffi import to_js
data = build_something()
return Response(to_js(data), headers={...})
to_js() copies to the JS heap, making the data independent of WASM memory lifetime. 0/18 corrupted with this workaround.
Suggested fix
In cloudflare/workers-py, replace getBuffer().data with to_js() in _get_js_body():
@contextmanager
def _get_js_body(body):
if isinstance(body, bytes):
yield to_js(body)
return
if isinstance(body, FormData):
yield body.js_object
return
yield body
Reproduction
Repository: https://github.com/KJeon10/repro-python-binary-corruption
The repository contains:
worker-python/src/entry.py — Python Worker with /investigate endpoint for WASM memory dump
worker-ts/src/index.ts — TypeScript Worker for automated batch verification via Service Binding
cd worker-python && npm i && npx wrangler deploy
cd worker-ts && npm i && npx wrangler deploy
# Batch corruption test
curl https://repro-ts-checker.YOUR_SUBDOMAIN.workers.dev/batch | jq .summary
# Direct HTTP corruption test
curl -s -D- -o /tmp/test.bin "https://repro-python-binary.YOUR_SUBDOMAIN.workers.dev/?size=100"
# Compare X-SHA256 header with: sha256sum /tmp/test.bin
# WASM memory investigation
curl "https://repro-python-binary.YOUR_SUBDOMAIN.workers.dev/investigate?size=1" | jq .
Environment
- Bug location:
cloudflare/workers-py (runtime SDK), not workerd
- Pyodide: 0.28.2
- Wrangler: latest
- compatibility_date: 2026-03-31
- compatibility_flags:
["python_workers"]
- Tested on Cloudflare production
Note on filing location:
A closely related issue was previously reported at cloudflare/workers-py#66, which identified the same root cause (getBuffer() returning a WASM memory view rather than a copy). However, it was closed after the reporter misattributed the problem to to_js() rather than getBuffer(), and a Pyodide maintainer's correction ("getBuffer returns a view, all other apis return a copy") was not connected back to _get_js_body() using getBuffer(). Filing here for broader visibility.
What happens
When a Python Worker returns
bytesviaResponse(some_bytes, ...), the first 8 bytes of the response body are overwritten with dlmalloc internal tree chunk metadata (parentpointer +indexfield). The remaining bytes are delivered correctly. No error is raised. This affects all consumption methods — direct HTTP, Service Bindings, etc.to_js()to copy bytes before passing toResponse: not affectedRoot cause
The bug is in
cloudflare/workers-py, not inworkerditself.File:
packages/runtime-sdk/src/workers/_workers.py, lines 348–358 and 431–436getBuffer().datareturns aUint8Arrayview backed by WASM linear memory — not a copy. Pyodide docs state: "ThetoJs()API copies the buffer into JavaScript, whereas thegetBuffer()method allows low level access to the WASM memory backing the buffer."The JS
Responsedoes not eagerly copy the body. After the handler function returns, the Pythonbytesobject's last reference is dropped, CPython frees it, and dlmalloc reclaims the underlying memory — overwriting part of it with its own bookkeeping. When the body is later consumed, the first 8 bytes have already been overwritten.Why exactly 8 bytes, and why at that position
Verified by dumping WASM memory before and after object deallocation via a
/investigateendpoint (code in the reproduction repository).Python
bytesobject layout on wasm32 (header = 16 bytes):getBuffer().datapoints to offset +16 from the malloc return address.dlmalloc
malloc_tree_chunklayout written on free (wasm32, SIZE_SZ=4):Offset +16 and +20 of the tree chunk land exactly on the first 8 bytes of
ob_val— which is wheregetBuffer().datapoints. The corruption is dlmalloc writing itsparentpointer and tree binindexinto what it now considers freed memory.WASM memory dump confirming this (1KB payload,
del datatriggers deallocation):ob_valbytes 0–3 (01 01 01 01) becamee8 56 13 01(parent pointer), and bytes 4–7 became04 00 00 00(index=4). This is what the response consumer reads.The
indexfield increases with allocation size (example from one deployment):Exact
parentpointers andindexvalues vary across deployments and instances, but the structure is consistent: first 4 bytes are a heap pointer, next 4 bytes are a small integer that increases with allocation size.Additional finding: corruption timing
The WASM memory dump also shows that corruption does not occur at
proxy.destroy(). Afterdestroy(), onlyob_refcntdecreases (3 → 1); the byte data is untouched. The actual corruption occurs when the Pythonbytesobject is deallocated (del/ function return → refcount reaches 0 →free()).Corruption pattern (independently verified)
Direct
curlto the Python Worker using ZIP test payloads (one deployment, 3 calls per size):Note: 200KB results vary across deployments — some instances show corruption at this size. The boundary is not fixed.
With raw
bytes(no ZIP compression), corruption extends well beyond 100KB. The exact upper threshold varies by deployment and heap state.Corruption is deterministic: same size always produces identical corrupted bytes regardless of content.
Workaround
to_js()copies to the JS heap, making the data independent of WASM memory lifetime. 0/18 corrupted with this workaround.Suggested fix
In
cloudflare/workers-py, replacegetBuffer().datawithto_js()in_get_js_body():Reproduction
Repository: https://github.com/KJeon10/repro-python-binary-corruption
The repository contains:
worker-python/src/entry.py— Python Worker with/investigateendpoint for WASM memory dumpworker-ts/src/index.ts— TypeScript Worker for automated batch verification via Service BindingEnvironment
cloudflare/workers-py(runtime SDK), notworkerd["python_workers"]A closely related issue was previously reported at cloudflare/workers-py#66, which identified the same root cause (
getBuffer()returning a WASM memory view rather than a copy). However, it was closed after the reporter misattributed the problem toto_js()rather thangetBuffer(), and a Pyodide maintainer's correction ("getBuffer returns a view, all other apis return a copy") was not connected back to_get_js_body()usinggetBuffer(). Filing here for broader visibility.