Current section

Files

Jump to
erlang_python test py_multi_loop_SUITE.erl
Raw

test/py_multi_loop_SUITE.erl

%%% @doc Common Test suite for multi-loop isolation.
%%%
%%% Tests that multiple erlang_event_loop_t instances are fully isolated:
%%% - Each loop has its own pending queue
%%% - Events don't cross-dispatch between loops
%%% - Destroying one loop doesn't affect others
%%%
%%% These tests initially fail with global g_python_event_loop coupling
%%% and should pass after per-loop isolation is implemented.
-module(py_multi_loop_SUITE).
-include_lib("common_test/include/ct.hrl").
-include_lib("stdlib/include/assert.hrl").
-export([
all/0,
init_per_suite/1,
end_per_suite/1,
init_per_testcase/2,
end_per_testcase/2
]).
-export([
test_two_loops_concurrent_timers/1,
test_two_loops_cross_isolation/1,
test_loop_cleanup_no_leak/1
]).
all() ->
[
test_two_loops_concurrent_timers,
test_two_loops_cross_isolation,
test_loop_cleanup_no_leak
].
init_per_suite(Config) ->
case application:ensure_all_started(erlang_python) of
{ok, _} ->
case wait_for_event_loop(5000) of
ok ->
Config;
{error, Reason} ->
ct:fail({event_loop_not_ready, Reason})
end;
{error, {App, Reason}} ->
ct:fail({failed_to_start, App, Reason})
end.
wait_for_event_loop(Timeout) when Timeout =< 0 ->
{error, timeout};
wait_for_event_loop(Timeout) ->
case py_event_loop:get_loop() of
{ok, LoopRef} when is_reference(LoopRef) ->
case py_nif:event_loop_new() of
{ok, TestLoop} ->
py_nif:event_loop_destroy(TestLoop),
ok;
_ ->
timer:sleep(100),
wait_for_event_loop(Timeout - 100)
end;
_ ->
timer:sleep(100),
wait_for_event_loop(Timeout - 100)
end.
end_per_suite(_Config) ->
ok = application:stop(erlang_python),
ok.
init_per_testcase(_TestCase, Config) ->
Config.
end_per_testcase(_TestCase, _Config) ->
ok.
%% ============================================================================
%% Test: Two loops with concurrent timers
%% ============================================================================
%%
%% Creates two independent event loops (LoopA and LoopB).
%% Each schedules 100 timers with unique callback IDs.
%% Verifies that:
%% - Each loop receives exactly its own 100 timer events
%% - No timers are lost or duplicated
%% - No cross-dispatch between loops
%%
%% Expected behavior with per-loop isolation:
%% LoopA receives callback IDs 1-100
%% LoopB receives callback IDs 1001-1100
%%
%% Current behavior with global coupling:
%% Both loops share pending queue, events may be lost or misrouted
test_two_loops_concurrent_timers(_Config) ->
%% Create two independent event loops
{ok, LoopA} = py_nif:event_loop_new(),
{ok, LoopB} = py_nif:event_loop_new(),
%% Start routers for each loop
{ok, RouterA} = py_event_router:start_link(LoopA),
{ok, RouterB} = py_event_router:start_link(LoopB),
ok = py_nif:event_loop_set_router(LoopA, RouterA),
ok = py_nif:event_loop_set_router(LoopB, RouterB),
%% Schedule 100 timers on each loop with different callback ID ranges
NumTimers = 100,
LoopABase = 1, %% Callback IDs 1-100
LoopBBase = 1001, %% Callback IDs 1001-1100
%% Schedule timers on LoopA (small delay for quick test)
_TimerRefsA = [begin
CallbackId = LoopABase + I - 1,
{ok, TimerRef} = py_nif:call_later(LoopA, 10, CallbackId),
TimerRef
end || I <- lists:seq(1, NumTimers)],
%% Schedule timers on LoopB
_TimerRefsB = [begin
CallbackId = LoopBBase + I - 1,
{ok, TimerRef} = py_nif:call_later(LoopB, 10, CallbackId),
TimerRef
end || I <- lists:seq(1, NumTimers)],
%% Wait for all timers to fire
timer:sleep(200),
%% Collect pending events from each loop
EventsA = py_nif:get_pending(LoopA),
EventsB = py_nif:get_pending(LoopB),
%% Extract callback IDs
CallbackIdsA = [CallbackId || {CallbackId, timer} <- EventsA],
CallbackIdsB = [CallbackId || {CallbackId, timer} <- EventsB],
ct:pal("LoopA received ~p timer events: ~p", [length(CallbackIdsA), lists:sort(CallbackIdsA)]),
ct:pal("LoopB received ~p timer events: ~p", [length(CallbackIdsB), lists:sort(CallbackIdsB)]),
%% Verify: LoopA should have exactly IDs 1-100
ExpectedA = lists:seq(LoopABase, LoopABase + NumTimers - 1),
%% LoopA should receive 100 timers
?assertEqual(NumTimers, length(CallbackIdsA)),
%% LoopA callback IDs should match expected
?assertEqual(ExpectedA, lists:sort(CallbackIdsA)),
%% Verify: LoopB should have exactly IDs 1001-1100
ExpectedB = lists:seq(LoopBBase, LoopBBase + NumTimers - 1),
%% LoopB should receive 100 timers
?assertEqual(NumTimers, length(CallbackIdsB)),
%% LoopB callback IDs should match expected
?assertEqual(ExpectedB, lists:sort(CallbackIdsB)),
%% Verify: No overlap between loops (no cross-dispatch)
Intersection = lists:filter(fun(Id) -> lists:member(Id, CallbackIdsB) end, CallbackIdsA),
?assertEqual([], Intersection),
%% Cleanup
py_event_router:stop(RouterA),
py_event_router:stop(RouterB),
py_nif:event_loop_destroy(LoopA),
py_nif:event_loop_destroy(LoopB),
ok.
%% ============================================================================
%% Test: Cross-isolation verification
%% ============================================================================
%%
%% Verifies that events dispatched to LoopA are never seen by LoopB.
%% Uses fd callbacks which are more direct than timers.
%%
%% Expected behavior with per-loop isolation:
%% LoopA pending queue receives LoopA events only
%% LoopB pending queue receives nothing
%%
%% Current behavior with global coupling:
%% Both loops share the same global pending queue
test_two_loops_cross_isolation(_Config) ->
{ok, LoopA} = py_nif:event_loop_new(),
{ok, LoopB} = py_nif:event_loop_new(),
{ok, RouterA} = py_event_router:start_link(LoopA),
{ok, RouterB} = py_event_router:start_link(LoopB),
ok = py_nif:event_loop_set_router(LoopA, RouterA),
ok = py_nif:event_loop_set_router(LoopB, RouterB),
%% Create a pipe for LoopA only
{ok, {ReadFd, WriteFd}} = py_nif:create_test_pipe(),
%% Register reader on LoopA with callback ID 42
CallbackIdA = 42,
{ok, FdRefA} = py_nif:add_reader(LoopA, ReadFd, CallbackIdA),
%% Write to trigger read event
ok = py_nif:write_test_fd(WriteFd, <<"test data">>),
%% Wait for event to be dispatched
timer:sleep(100),
%% Get pending from both loops
EventsA = py_nif:get_pending(LoopA),
EventsB = py_nif:get_pending(LoopB),
ct:pal("LoopA events: ~p", [EventsA]),
ct:pal("LoopB events: ~p", [EventsB]),
%% LoopA should have the read event
ReadEventsA = [E || {_Cid, read} = E <- EventsA],
%% LoopA should have 1 read event
?assertEqual(1, length(ReadEventsA)),
%% LoopB should have NO events - this is the isolation test
?assertEqual([], EventsB),
%% Cleanup
py_nif:remove_reader(LoopA, FdRefA),
py_nif:close_test_fd(ReadFd),
py_nif:close_test_fd(WriteFd),
py_event_router:stop(RouterA),
py_event_router:stop(RouterB),
py_nif:event_loop_destroy(LoopA),
py_nif:event_loop_destroy(LoopB),
ok.
%% ============================================================================
%% Test: Loop cleanup without leaks
%% ============================================================================
%%
%% Destroys LoopA while LoopB continues operating.
%% Verifies that:
%% - LoopB continues to receive its events
%% - No memory corruption or resource leaks
%% - Events scheduled on destroyed loop don't crash system
%%
%% Expected behavior with per-loop isolation:
%% LoopB operates independently after LoopA destruction
%%
%% Current behavior with global coupling:
%% Destroying LoopA may clear g_python_event_loop affecting LoopB
test_loop_cleanup_no_leak(_Config) ->
{ok, LoopA} = py_nif:event_loop_new(),
{ok, LoopB} = py_nif:event_loop_new(),
{ok, RouterA} = py_event_router:start_link(LoopA),
{ok, RouterB} = py_event_router:start_link(LoopB),
ok = py_nif:event_loop_set_router(LoopA, RouterA),
ok = py_nif:event_loop_set_router(LoopB, RouterB),
%% Schedule a timer on LoopB that will fire after LoopA is destroyed
CallbackIdB = 999,
{ok, _TimerRefB} = py_nif:call_later(LoopB, 100, CallbackIdB),
%% Schedule a timer on LoopA (won't be received since we destroy it)
{ok, _TimerRefA} = py_nif:call_later(LoopA, 50, 111),
%% Destroy LoopA before its timer fires
timer:sleep(20),
py_event_router:stop(RouterA),
py_nif:event_loop_destroy(LoopA),
%% Wait for LoopB timer to fire
timer:sleep(150),
%% LoopB should still receive its event
EventsB = py_nif:get_pending(LoopB),
ct:pal("LoopB events after LoopA destruction: ~p", [EventsB]),
%% Verify LoopB still works - should receive its timer after LoopA destroyed
TimerEventsB = [CallbackId || {CallbackId, timer} <- EventsB],
?assertEqual([CallbackIdB], TimerEventsB),
%% Schedule another timer on LoopB to verify it still works
CallbackIdB2 = 1000,
{ok, _} = py_nif:call_later(LoopB, 10, CallbackIdB2),
timer:sleep(50),
EventsB2 = py_nif:get_pending(LoopB),
TimerEventsB2 = [CallbackId || {CallbackId, timer} <- EventsB2],
%% LoopB should still work after LoopA destroyed
?assertEqual([CallbackIdB2], TimerEventsB2),
%% Cleanup
py_event_router:stop(RouterB),
py_nif:event_loop_destroy(LoopB),
ok.