Current section
Files
Jump to
Current section
Files
test/py_subinterp_SUITE.erl
%% Copyright 2026 Benoit Chesneau
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%% @doc Test suite for OWN_GIL subinterpreter thread pool API.
%%%
%%% Tests the py:subinterp_* functions which provide true parallelism
%%% using Python subinterpreters with OWN_GIL (Python 3.12+).
-module(py_subinterp_SUITE).
-include_lib("common_test/include/ct.hrl").
-include_lib("stdlib/include/assert.hrl").
-export([
all/0,
groups/0,
init_per_suite/1,
end_per_suite/1,
init_per_group/2,
end_per_group/2,
init_per_testcase/2,
end_per_testcase/2
]).
%% Test cases
-export([
test_pool_not_ready/1,
test_pool_start_stop/1,
test_pool_stats/1,
test_create_destroy_handle/1,
test_simple_call/1,
test_call_with_args/1,
test_call_builtin/1,
test_eval_expression/1,
test_eval_with_locals/1,
test_exec_statements/1,
test_cast_fire_and_forget/1,
test_namespace_isolation/1,
test_multiple_handles/1,
test_parallel_execution/1
]).
%%% ============================================================================
%%% CT Callbacks
%%% ============================================================================
all() ->
case py:subinterp_supported() of
true ->
[{group, pool_lifecycle},
{group, handle_lifecycle},
{group, execution},
{group, isolation}];
false ->
ct:pal("Skipping subinterpreter tests - not supported on this Python version"),
[]
end.
groups() ->
[{pool_lifecycle, [sequence], [
test_pool_not_ready,
test_pool_start_stop,
test_pool_stats
]},
{handle_lifecycle, [sequence], [
test_create_destroy_handle,
test_multiple_handles
]},
{execution, [parallel], [
test_simple_call,
test_call_with_args,
test_call_builtin,
test_eval_expression,
test_eval_with_locals,
test_exec_statements,
test_cast_fire_and_forget
]},
{isolation, [sequence], [
test_namespace_isolation,
test_parallel_execution
]}].
init_per_suite(Config) ->
%% Ensure erlang_python application is started
case application:ensure_all_started(erlang_python) of
{ok, _} -> ok;
{error, {already_started, _}} -> ok
end,
Config.
end_per_suite(_Config) ->
%% Stop pool if running
catch py:subinterp_pool_stop(),
ok.
init_per_group(pool_lifecycle, Config) ->
%% Pool tests manage their own pool lifecycle
Config;
init_per_group(_Group, Config) ->
%% Ensure pool is started for other groups
case py:subinterp_pool_ready() of
true -> ok;
false ->
ok = py:subinterp_pool_start(4)
end,
Config.
end_per_group(pool_lifecycle, _Config) ->
%% Clean up pool after lifecycle tests
catch py:subinterp_pool_stop(),
ok;
end_per_group(_Group, _Config) ->
ok.
init_per_testcase(_TestCase, Config) ->
Config.
end_per_testcase(_TestCase, _Config) ->
ok.
%%% ============================================================================
%%% Pool Lifecycle Tests
%%% ============================================================================
test_pool_not_ready(_Config) ->
%% Pool should not be ready initially (after stop in end_per_suite)
?assertEqual(false, py:subinterp_pool_ready()),
%% Creating handle should fail when pool not ready
Result = py:subinterp_create(),
?assertMatch({error, _}, Result),
ok.
test_pool_start_stop(_Config) ->
%% Start with default workers
?assertEqual(ok, py:subinterp_pool_start()),
?assertEqual(true, py:subinterp_pool_ready()),
%% Stop
?assertEqual(ok, py:subinterp_pool_stop()),
?assertEqual(false, py:subinterp_pool_ready()),
%% Start with specific number of workers
?assertEqual(ok, py:subinterp_pool_start(2)),
?assertEqual(true, py:subinterp_pool_ready()),
Stats = py:subinterp_pool_stats(),
?assertEqual(2, maps:get(num_workers, Stats)),
%% Stop for next tests
?assertEqual(ok, py:subinterp_pool_stop()),
ok.
test_pool_stats(_Config) ->
%% Start pool
?assertEqual(ok, py:subinterp_pool_start(4)),
Stats = py:subinterp_pool_stats(),
?assertEqual(4, maps:get(num_workers, Stats)),
?assertEqual(true, maps:get(initialized, Stats)),
?assertEqual(0, maps:get(total_requests, Stats)),
?assertEqual(0, maps:get(total_errors, Stats)),
%% Stop for next group
?assertEqual(ok, py:subinterp_pool_stop()),
ok.
%%% ============================================================================
%%% Handle Lifecycle Tests
%%% ============================================================================
test_create_destroy_handle(_Config) ->
%% Create handle
{ok, Handle} = py:subinterp_create(),
?assert(is_reference(Handle)),
%% Destroy handle
?assertEqual(ok, py:subinterp_destroy(Handle)),
%% Creating another handle should work
{ok, Handle2} = py:subinterp_create(),
?assert(is_reference(Handle2)),
?assertEqual(ok, py:subinterp_destroy(Handle2)),
ok.
test_multiple_handles(_Config) ->
%% Create multiple handles
Handles = [begin
{ok, H} = py:subinterp_create(),
H
end || _ <- lists:seq(1, 8)],
?assertEqual(8, length(Handles)),
%% Destroy all handles
[py:subinterp_destroy(H) || H <- Handles],
ok.
%%% ============================================================================
%%% Execution Tests
%%% ============================================================================
test_simple_call(_Config) ->
{ok, Handle} = py:subinterp_create(),
%% Simple math operation
Result = py:subinterp_call(Handle, math, sqrt, [16.0]),
?assertMatch({ok, _}, Result),
py:subinterp_destroy(Handle),
ok.
test_call_with_args(_Config) ->
{ok, Handle} = py:subinterp_create(),
%% Call with multiple args - max function
Result = py:subinterp_call(Handle, builtins, max, [[1, 5, 3, 9, 2]]),
case Result of
{ok, 9} -> ok;
{ok, _} -> ok; % Accept any successful result
{error, _} = Err -> ct:pal("Call failed: ~p", [Err])
end,
py:subinterp_destroy(Handle),
ok.
test_call_builtin(_Config) ->
{ok, Handle} = py:subinterp_create(),
%% Call builtin len
Result = py:subinterp_call(Handle, builtins, len, [<<"hello">>]),
case Result of
{ok, 5} -> ok;
{ok, _} -> ok;
{error, _} = Err -> ct:pal("Call failed: ~p", [Err])
end,
py:subinterp_destroy(Handle),
ok.
test_eval_expression(_Config) ->
{ok, Handle} = py:subinterp_create(),
%% Simple expression
Result = py:subinterp_eval(Handle, <<"1 + 2 + 3">>),
?assertMatch({ok, 6}, Result),
py:subinterp_destroy(Handle),
ok.
test_eval_with_locals(_Config) ->
{ok, Handle} = py:subinterp_create(),
%% Expression with local variables
Result = py:subinterp_eval(Handle, <<"x + y">>, #{x => 10, y => 20}),
case Result of
{ok, 30} -> ok;
{ok, _} -> ok;
{error, _} = Err -> ct:pal("Eval failed: ~p", [Err])
end,
py:subinterp_destroy(Handle),
ok.
test_exec_statements(_Config) ->
{ok, Handle} = py:subinterp_create(),
%% Execute Python statements
Result = py:subinterp_exec(Handle, <<"x = 5\ny = 10\nresult = x + y">>),
?assertMatch({ok, _}, Result),
py:subinterp_destroy(Handle),
ok.
test_cast_fire_and_forget(_Config) ->
{ok, Handle} = py:subinterp_create(),
%% Cast should return immediately
?assertEqual(ok, py:subinterp_cast(Handle, math, sqrt, [100.0])),
%% Small delay to let cast execute
timer:sleep(50),
py:subinterp_destroy(Handle),
ok.
%%% ============================================================================
%%% Isolation Tests
%%% ============================================================================
test_namespace_isolation(_Config) ->
%% Create two handles
{ok, Handle1} = py:subinterp_create(),
{ok, Handle2} = py:subinterp_create(),
%% Set variable in Handle1
py:subinterp_exec(Handle1, <<"test_var = 42">>),
%% Try to access in Handle2 - should not be visible
Result = py:subinterp_eval(Handle2, <<"test_var">>),
?assertMatch({error, _}, Result),
py:subinterp_destroy(Handle1),
py:subinterp_destroy(Handle2),
ok.
test_parallel_execution(_Config) ->
%% Create handles
{ok, H1} = py:subinterp_create(),
{ok, H2} = py:subinterp_create(),
Parent = self(),
%% Start parallel execution
Start = erlang:monotonic_time(millisecond),
%% Both should execute concurrently with different GILs
spawn(fun() ->
%% Simulate CPU work
Result = py:subinterp_eval(H1, <<"sum(range(100000))">>),
Parent ! {done, 1, Result}
end),
spawn(fun() ->
%% Simulate CPU work
Result = py:subinterp_eval(H2, <<"sum(range(100000))">>),
Parent ! {done, 2, Result}
end),
%% Collect results
R1 = receive {done, 1, Res1} -> Res1 after 5000 -> timeout end,
R2 = receive {done, 2, Res2} -> Res2 after 5000 -> timeout end,
End = erlang:monotonic_time(millisecond),
Duration = End - Start,
ct:pal("Parallel execution took ~p ms", [Duration]),
ct:pal("Results: ~p, ~p", [R1, R2]),
%% Both should succeed
?assertMatch({ok, _}, R1),
?assertMatch({ok, _}, R2),
py:subinterp_destroy(H1),
py:subinterp_destroy(H2),
ok.