Skip to content

Commit 11e1ac2

Browse files
authored
fix: segfault, where a python callback fields would be GCed by JS while (#85)
* fix segfault, where a python callback fields would be GCed by JS while the callback is still alive * remove unrrelated comment * fix lint * try fix ci * try fix ci
1 parent bafbae3 commit 11e1ac2

File tree

6 files changed

+81
-23
lines changed

6 files changed

+81
-23
lines changed

.github/workflows/checks.yml

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,24 +47,18 @@ jobs:
4747
- name: Setup latest deno version
4848
uses: denoland/setup-deno@v2
4949

50+
- name: Setup Python
51+
uses: actions/setup-python@v5
52+
with:
53+
python-version: "3.13"
54+
5055
- name: Setup Bun
5156
if: ${{ matrix.os != 'windows-latest' }}
5257
uses: oven-sh/setup-bun@v1
5358

54-
- name: Setup Python (Windows)
55-
uses: actions/setup-python@v2
56-
if: ${{ matrix.os == 'windows-latest' }}
57-
with:
58-
python-version: "3.13"
59-
6059
- name: Install NumPy
61-
if: ${{ matrix.os != 'macos-latest' }}
6260
run: python3 -m pip install numpy
6361

64-
- name: Install NumPy on MacOs
65-
if: ${{ matrix.os == 'macos-latest' }}
66-
run: python3 -m pip install --user --break-system-packages numpy
67-
6862
- name: Run deno test
6963
run: deno task test
7064

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"check:mod": "deno check --unstable-ffi mod.ts",
1111
"check:ext": "deno check --unstable-ffi ext/*.ts",
1212
"check:examples": "deno check --unstable-ffi examples/*.ts",
13-
"test": "deno test --unstable-ffi -A test/test.ts",
13+
"test": "deno test --unstable-ffi -A test/test.ts && deno test -A --v8-flags=--expose-gc test/test_with_gc.ts",
1414
"example:hello_python": "deno run -A --unstable-ffi examples/hello_python.ts",
1515
"example:matplotlib": "deno run -A --unstable-ffi examples/matplotlib.ts",
1616
"example:pip_import": "deno run -A --unstable-ffi examples/pip_import.ts",

ext/pip.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// deno-lint-ignore-file no-import-prefix
12
import { kw, python, PythonError } from "../mod.ts";
23

34
import { join } from "jsr:@std/path@^1/join";

src/python.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ export class Callback {
143143
result: "pointer";
144144
}>;
145145

146+
// Keep native-facing buffers alive for as long as this Callback exists
147+
/** @private */
148+
_pyMethodDef?: Uint8Array<ArrayBuffer>;
149+
/** @private */
150+
_nameBuf?: Uint8Array<ArrayBuffer>;
151+
/** @private */
152+
_docBuf?: Uint8Array<ArrayBuffer>;
153+
146154
constructor(public callback: PythonJSCallback) {
147155
this.unsafe = new Deno.UnsafeCallback(
148156
{
@@ -490,49 +498,56 @@ export class PyObject {
490498
} else if (v instanceof Callback) {
491499
// https://docs.python.org/3/c-api/structures.html#c.PyMethodDef
492500
// there are extra 4 bytes of padding after ml_flags field
493-
const pyMethodDef = new Uint8Array(8 + 8 + 4 + 4 + 8);
494-
const view = new DataView(pyMethodDef.buffer);
501+
// Build and pin PyMethodDef + string buffers on the Callback instance
502+
const methodDef = new Uint8Array(8 + 8 + 4 + 4 + 8);
503+
v._pyMethodDef = methodDef;
504+
const view = new DataView(methodDef.buffer);
495505
const LE =
496506
new Uint8Array(new Uint32Array([0x12345678]).buffer)[0] !== 0x7;
497507

498508
const name = "JSCallback:" + (v.callback.name || "anonymous");
499-
const nameBuf = new TextEncoder().encode(`${name}\0`);
509+
v._nameBuf = new TextEncoder().encode(`${name}\0`);
500510
view.setBigUint64(
501511
0,
502-
BigInt(Deno.UnsafePointer.value(Deno.UnsafePointer.of(nameBuf)!)),
512+
BigInt(
513+
Deno.UnsafePointer.value(Deno.UnsafePointer.of(v._nameBuf)!),
514+
),
503515
LE,
504516
);
505517
view.setBigUint64(
506518
8,
507519
BigInt(Deno.UnsafePointer.value(v.unsafe.pointer)),
508520
LE,
509521
);
522+
// METH_VARARGS | METH_KEYWORDS
510523
view.setInt32(16, 0x1 | 0x2, LE);
524+
511525
// https://github.com/python/cpython/blob/f27593a87c344f3774ca73644a11cbd5614007ef/Objects/typeobject.c#L688
512526
const SIGNATURE_END_MARKER = ")\n--\n\n";
513527
// We're not using the correct arguments name, but just using dummy ones (because they're not accessible in js)
514528
const fnArgs = [...Array(v.callback.length).keys()]
515529
.map((_, i) => String.fromCharCode(97 + i)).join(",");
516-
const docBuf = `${name}(${fnArgs}${SIGNATURE_END_MARKER}\0`;
530+
v._docBuf = new TextEncoder().encode(
531+
`${name}(${fnArgs}${SIGNATURE_END_MARKER}\0`,
532+
);
517533
view.setBigUint64(
518534
24,
519535
BigInt(
520-
Deno.UnsafePointer.value(
521-
Deno.UnsafePointer.of(new TextEncoder().encode(docBuf))!,
522-
),
536+
Deno.UnsafePointer.value(Deno.UnsafePointer.of(v._docBuf)!),
523537
),
524538
LE,
525539
);
526540
const fn = py.PyCFunction_NewEx(
527-
pyMethodDef,
541+
v._pyMethodDef,
528542
PyObject.from(null).handle,
529543
null,
530544
);
531545

532546
// NOTE: we need to extend `pyMethodDef` lifetime
533547
// Otherwise V8 can release it before the callback is called
548+
// Is this still needed (after the change of pinning fields to the callabck) ? might be
534549
const pyObject = new PyObject(fn);
535-
pyObject.#pyMethodDef = pyMethodDef;
550+
pyObject.#pyMethodDef = methodDef;
536551
return pyObject;
537552
} else if (v instanceof PyObject) {
538553
return v;

src/util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function postSetup(lib: string) {
4242
/**
4343
* Encodes a C string.
4444
*/
45-
export function cstr(str: string): Uint8Array {
45+
export function cstr(str: string): Uint8Array<ArrayBuffer> {
4646
const buf = new Uint8Array(str.length + 1);
4747
encoder.encodeInto(str, buf);
4848
return buf;

test/test_with_gc.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import python, { Callback } from "../mod.ts";
2+
import { assertEquals } from "./asserts.ts";
3+
4+
Deno.test("callbacks are not gc'd while still needed by python", () => {
5+
const pyModule = python.runModule(
6+
`
7+
stored_callback = None
8+
9+
def store_and_call_callback(cb):
10+
global stored_callback
11+
stored_callback = cb
12+
return stored_callback()
13+
14+
def call_stored_callback():
15+
global stored_callback
16+
if stored_callback is None:
17+
return -1
18+
return stored_callback()
19+
`,
20+
"test_gc_module",
21+
);
22+
23+
let callCount = 0;
24+
const callback = () => {
25+
callCount++;
26+
return callCount * 10;
27+
};
28+
29+
// Store the callback in Python and call it
30+
const callbackObj = new Callback(callback);
31+
assertEquals(pyModule.store_and_call_callback(callbackObj).valueOf(), 10);
32+
assertEquals(callCount, 1);
33+
34+
for (let i = 0; i < 10; i++) {
35+
// @ts-ignore:requires: --v8-flags=--expose-gc
36+
gc();
37+
}
38+
39+
// If the callback was incorrectly GC'd, this should segfault
40+
// But it should work because Python holds a reference
41+
assertEquals(pyModule.call_stored_callback().valueOf(), 20);
42+
assertEquals(callCount, 2);
43+
44+
// Call it again to be sure
45+
assertEquals(pyModule.call_stored_callback().valueOf(), 30);
46+
assertEquals(callCount, 3);
47+
callbackObj.destroy();
48+
});

0 commit comments

Comments
 (0)