Skip to content

Commit 4fecd67

Browse files
committed
Add channel reference round-trip conversion support
- Add PyCapsule conversion for channel resources in py_convert.c - Channel refs are wrapped in PyCapsule when passed to Python - PyCapsule is unwrapped back to channel resource when returned to Erlang - Add test_channel_ref.py module for testing py:call with channel refs - Add channel_ref_roundtrip_test to validate basic eval conversion - Add channel_ref_call_test to validate py:call with channel ref args
1 parent 87b569f commit 4fecd67

3 files changed

Lines changed: 116 additions & 2 deletions

File tree

‎c_src/py_convert.c‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@
4949
/* Stack allocation threshold for small tuples/maps to avoid heap allocation */
5050
#define SMALL_CONTAINER_THRESHOLD 16
5151

52+
/* Capsule name for channel references */
53+
#define CHANNEL_CAPSULE_NAME "erlang.channel_ref"
54+
55+
/**
56+
* @brief PyCapsule destructor for channel references
57+
*
58+
* Called when a PyCapsule wrapping a channel resource is garbage collected.
59+
* Releases the resource reference that was kept when the capsule was created.
60+
*/
61+
static void channel_capsule_destructor(PyObject *capsule) {
62+
void *ptr = PyCapsule_GetPointer(capsule, CHANNEL_CAPSULE_NAME);
63+
if (ptr != NULL) {
64+
enif_release_resource(ptr);
65+
}
66+
}
67+
5268
/* ============================================================================
5369
* Python to Erlang Conversion
5470
* ============================================================================ */
@@ -334,6 +350,18 @@ static ERL_NIF_TERM py_to_term(ErlNifEnv *env, PyObject *obj) {
334350
PyErr_Clear();
335351
}
336352

353+
/* Handle PyCapsule containing channel reference */
354+
if (PyCapsule_CheckExact(obj)) {
355+
void *ptr = PyCapsule_GetPointer(obj, CHANNEL_CAPSULE_NAME);
356+
if (ptr != NULL) {
357+
/* This is a channel reference capsule - convert back to resource term */
358+
py_channel_t *channel = (py_channel_t *)ptr;
359+
return enif_make_resource(env, channel);
360+
}
361+
/* Not a channel capsule, clear error and fall through */
362+
PyErr_Clear();
363+
}
364+
337365
/*
338366
* Fallback: convert any other object to its string representation.
339367
* This handles custom classes, functions, modules, etc.
@@ -552,6 +580,21 @@ static PyObject *term_to_py(ErlNifEnv *env, ERL_NIF_TERM term) {
552580
return wrapper->obj;
553581
}
554582

583+
/* Check for channel resource - wrap in PyCapsule for round-trip */
584+
py_channel_t *channel;
585+
if (enif_get_resource(env, term, CHANNEL_RESOURCE_TYPE, (void **)&channel)) {
586+
/* Create a PyCapsule wrapping the channel pointer.
587+
* We increment the resource refcount so it stays valid while Python holds it.
588+
* The destructor will decrement it when the capsule is garbage collected. */
589+
enif_keep_resource(channel);
590+
PyObject *capsule = PyCapsule_New(channel, CHANNEL_CAPSULE_NAME, channel_capsule_destructor);
591+
if (capsule == NULL) {
592+
enif_release_resource(channel);
593+
return NULL;
594+
}
595+
return capsule;
596+
}
597+
555598
/* Fallback: return None for unknown types */
556599
Py_RETURN_NONE;
557600
}

‎priv/test_channel_ref.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Test module for channel reference passing via py:call
2+
3+
def identity(x):
4+
"""Return the argument unchanged."""
5+
return x
6+
7+
def get_channel_type(ch_ref):
8+
"""Return the type name of the channel reference."""
9+
return type(ch_ref).__name__
10+
11+
def store_and_return(ch_ref):
12+
"""Store in a list and return."""
13+
container = [ch_ref]
14+
return container[0]

‎test/py_channel_SUITE.erl‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
async_receive_immediate_test/1,
3030
async_receive_wait_test/1,
3131
async_iteration_test/1,
32-
async_closed_channel_test/1
32+
async_closed_channel_test/1,
33+
channel_ref_roundtrip_test/1,
34+
channel_ref_call_test/1
3335
]).
3436

3537
all() -> [
@@ -48,7 +50,9 @@ all() -> [
4850
async_receive_immediate_test,
4951
async_receive_wait_test,
5052
async_iteration_test,
51-
async_closed_channel_test
53+
async_closed_channel_test,
54+
channel_ref_roundtrip_test,
55+
channel_ref_call_test
5256
].
5357

5458
init_per_suite(Config) ->
@@ -287,3 +291,56 @@ async_closed_channel_test(_Config) ->
287291
{ok, true} = py:eval(Ctx, <<"issubclass(ChannelClosed, Exception)">>),
288292

289293
ok.
294+
295+
%% @doc Test that channel references can be passed to Python and back via eval
296+
channel_ref_roundtrip_test(_Config) ->
297+
{ok, Ch} = py_channel:new(),
298+
299+
%% Pass channel ref to Python via NIF and get it back
300+
%% This tests the PyCapsule conversion in py_convert.c
301+
{ok, ReturnedRef} = py:eval(<<"ch_ref">>, #{<<"ch_ref">> => Ch}),
302+
303+
%% The returned ref should be usable as a channel
304+
%% Send data through original channel
305+
ok = py_channel:send(Ch, <<"test_data">>),
306+
307+
%% Receive using the returned ref (should be the same channel)
308+
{ok, <<"test_data">>} = py_nif:channel_try_receive(ReturnedRef),
309+
310+
%% Verify channel info works on returned ref
311+
Info = py_channel:info(ReturnedRef),
312+
false = maps:get(closed, Info),
313+
314+
ok = py_channel:close(Ch).
315+
316+
%% @doc Test that channel references can be passed via py:call
317+
channel_ref_call_test(_Config) ->
318+
{ok, Ch} = py_channel:new(),
319+
320+
%% Test passing channel ref through py:call to a Python function
321+
%% The test_channel_ref module has identity, get_channel_type, store_and_return
322+
{ok, ReturnedRef} = py:call(test_channel_ref, identity, [Ch]),
323+
324+
%% The returned ref should be usable as a channel
325+
ok = py_channel:send(Ch, <<"identity_data">>),
326+
{ok, <<"identity_data">>} = py_nif:channel_try_receive(ReturnedRef),
327+
328+
%% Test that Python sees it as a PyCapsule
329+
{ok, <<"PyCapsule">>} = py:call(test_channel_ref, get_channel_type, [Ch]),
330+
331+
%% Test storing in a container and returning
332+
{ok, StoredRef} = py:call(test_channel_ref, store_and_return, [Ch]),
333+
334+
ok = py_channel:send(Ch, <<"stored_data">>),
335+
{ok, <<"stored_data">>} = py_nif:channel_try_receive(StoredRef),
336+
337+
%% Also test passing through containers via eval
338+
{ok, [Ref1, Ref2]} = py:eval(<<"[a, b]">>, #{<<"a">> => Ch, <<"b">> => Ch}),
339+
340+
ok = py_channel:send(Ch, <<"ref1_data">>),
341+
{ok, <<"ref1_data">>} = py_nif:channel_try_receive(Ref1),
342+
343+
ok = py_channel:send(Ch, <<"ref2_data">>),
344+
{ok, <<"ref2_data">>} = py_nif:channel_try_receive(Ref2),
345+
346+
ok = py_channel:close(Ch).

0 commit comments

Comments
 (0)