Skip to content

Commit 6430334

Browse files
authored
Merge pull request #904 from benoitc/feature/set-owner-mid-stream
Allow set_owner while a response body is streaming
2 parents d5051db + 89eb0f6 commit 6430334

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

src/hackney_conn.erl

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,6 +1556,18 @@ receiving(info, {ssl_error, Socket, _Reason}, #conn_data{socket = Socket} = Data
15561556
receiving(info, {'DOWN', Ref, process, _Pid, _Reason}, #conn_data{owner_mon = Ref} = Data) ->
15571557
{stop, normal, Data};
15581558

1559+
receiving({call, From}, {set_owner, NewOwner}, #conn_data{owner_mon = OldMon} = Data) ->
1560+
%% Reparent mid-stream: swap the monitored owner so the process reading the
1561+
%% body can exit without stopping the connection. Socket/async state untouched.
1562+
demonitor(OldMon, [flush]),
1563+
NewMon = monitor(process, NewOwner),
1564+
{keep_state, Data#conn_data{owner = NewOwner, owner_mon = NewMon},
1565+
[{reply, From, ok}]};
1566+
receiving(cast, {set_owner, NewOwner}, #conn_data{owner_mon = OldMon} = Data) ->
1567+
demonitor(OldMon, [flush]),
1568+
NewMon = monitor(process, NewOwner),
1569+
{keep_state, Data#conn_data{owner = NewOwner, owner_mon = NewMon}};
1570+
15591571
receiving(EventType, Event, Data) ->
15601572
handle_common(EventType, Event, receiving, Data).
15611573

@@ -1618,6 +1630,18 @@ streaming(info, {ssl_error, Socket, Reason}, #conn_data{socket = Socket, async_r
16181630
streaming(info, {'DOWN', Ref, process, _Pid, _Reason}, #conn_data{owner_mon = Ref} = Data) ->
16191631
{stop, normal, Data};
16201632

1633+
streaming({call, From}, {set_owner, NewOwner}, #conn_data{owner_mon = OldMon} = Data) ->
1634+
%% Reparent the lifecycle monitor mid-stream. stream_to is left as-is, so the
1635+
%% async message target does not change; only owner death handling moves.
1636+
demonitor(OldMon, [flush]),
1637+
NewMon = monitor(process, NewOwner),
1638+
{keep_state, Data#conn_data{owner = NewOwner, owner_mon = NewMon},
1639+
[{reply, From, ok}]};
1640+
streaming(cast, {set_owner, NewOwner}, #conn_data{owner_mon = OldMon} = Data) ->
1641+
demonitor(OldMon, [flush]),
1642+
NewMon = monitor(process, NewOwner),
1643+
{keep_state, Data#conn_data{owner = NewOwner, owner_mon = NewMon}};
1644+
16211645
streaming(EventType, Event, Data) ->
16221646
handle_common(EventType, Event, streaming, Data).
16231647

test/hackney_conn_tests.erl

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ hackney_conn_integration_test_() ->
5959
%% Async tests
6060
{"async request continuous", {timeout, 30, fun test_async_continuous/0}},
6161
{"async request once mode", {timeout, 30, fun test_async_once/0}},
62+
%% Mid-stream ownership reassignment
63+
{"set_owner while receiving body", {timeout, 30, fun test_set_owner_while_receiving/0}},
64+
{"set_owner while streaming async", {timeout, 30, fun test_set_owner_while_streaming/0}},
65+
{"set_owner API unchanged in other states", {timeout, 30, fun test_set_owner_api_unchanged/0}},
6266
%% 1XX response handling
6367
{"skip 1XX informational responses", {timeout, 30, fun test_skip_1xx_responses/0}}
6468
]}.
@@ -632,6 +636,146 @@ test_async_once() ->
632636

633637
hackney_conn:stop(Pid).
634638

639+
test_set_owner_while_receiving() ->
640+
%% HTTP/1.1: a short-lived worker starts the response and reads one chunk,
641+
%% then a third process reassigns ownership to a long-lived reader. The
642+
%% original owner exits and the reader drains the body to done, proving the
643+
%% connection is no longer torn down by the original owner's DOWN.
644+
Size = 200000,
645+
Opts = #{
646+
host => "127.0.0.1",
647+
port => ?PORT,
648+
transport => hackney_tcp,
649+
connect_timeout => 5000,
650+
recv_timeout => 5000
651+
},
652+
{ok, Pid} = hackney_conn:start_link(Opts),
653+
ok = hackney_conn:connect(Pid),
654+
655+
Parent = self(),
656+
Path = <<"/chunked/", (integer_to_binary(Size))/binary>>,
657+
658+
{Worker, WMon} = spawn_monitor(fun() ->
659+
receive go -> ok end,
660+
{ok, 200, _} = hackney_conn:request(Pid, <<"GET">>, Path, [], <<>>),
661+
{ok, C1} = hackney_conn:stream_body(Pid),
662+
Parent ! {chunk1, self(), C1},
663+
receive stop -> ok end
664+
end),
665+
666+
%% Tie the connection lifecycle to the worker (connected state).
667+
ok = hackney_conn:set_owner(Pid, Worker),
668+
Worker ! go,
669+
670+
FirstChunk = receive
671+
{chunk1, Worker, C} -> C
672+
after 5000 -> error(chunk1_timeout)
673+
end,
674+
?assert(byte_size(FirstChunk) > 0),
675+
676+
%% Reassign ownership mid-stream (receiving state) to a long-lived reader
677+
%% that stays alive as owner until we have verified.
678+
Reader = spawn(fun() ->
679+
Rest = stream_all(Pid, <<>>),
680+
Parent ! {rest, self(), Rest},
681+
receive stop -> ok end
682+
end),
683+
?assertEqual(ok, hackney_conn:set_owner(Pid, Reader)),
684+
685+
%% Original owner exits; the connection must survive.
686+
Worker ! stop,
687+
receive {'DOWN', WMon, process, Worker, _} -> ok after 5000 -> error(worker_down_timeout) end,
688+
?assert(is_process_alive(Pid)),
689+
690+
Rest = receive
691+
{rest, Reader, R} -> R
692+
after 10000 -> error(rest_timeout)
693+
end,
694+
695+
?assertEqual(Size, byte_size(FirstChunk) + byte_size(Rest)),
696+
Reader ! stop,
697+
hackney_conn:stop(Pid).
698+
699+
test_set_owner_while_streaming() ->
700+
%% Async continuous: the request runs with the worker as lifecycle owner and
701+
%% a separate collector as stream_to. Mid-stream (streaming state) a third
702+
%% process reassigns ownership away from the worker, which then exits without
703+
%% stopping the connection. stream_to is unchanged, so the collector still
704+
%% receives every message through done.
705+
Size = 2000000,
706+
Opts = #{
707+
host => "127.0.0.1",
708+
port => ?PORT,
709+
transport => hackney_tcp,
710+
connect_timeout => 5000,
711+
recv_timeout => 5000
712+
},
713+
{ok, Pid} = hackney_conn:start_link(Opts),
714+
ok = hackney_conn:connect(Pid),
715+
716+
Parent = self(),
717+
Path = <<"/chunked/", (integer_to_binary(Size))/binary>>,
718+
719+
{Owner, OMon} = spawn_monitor(fun() -> receive stop -> ok end end),
720+
%% Owner becomes the lifecycle owner (connected state).
721+
ok = hackney_conn:set_owner(Pid, Owner),
722+
723+
%% Collector issues the async request as its own caller, so stream_to is the
724+
%% collector while owner stays Owner (do_request_async leaves owner unchanged
725+
%% when StreamTo == caller).
726+
Collector = spawn(fun() ->
727+
{ok, Ref} = hackney_conn:request_async(Pid, <<"GET">>, Path, [], <<>>, true),
728+
Parent ! {started, self()},
729+
Msgs = receive_all_async(Ref, []),
730+
Parent ! {collected, self(), Msgs}
731+
end),
732+
733+
%% Wait until the request is issued (streaming has begun), then reassign
734+
%% ownership to the long-lived parent while the body is still draining.
735+
receive {started, Collector} -> ok after 5000 -> error(started_timeout) end,
736+
?assertEqual(ok, hackney_conn:set_owner(Pid, self())),
737+
738+
%% Original owner exits; the connection must survive.
739+
Owner ! stop,
740+
receive {'DOWN', OMon, process, Owner, _} -> ok after 5000 -> error(owner_down_timeout) end,
741+
?assert(is_process_alive(Pid)),
742+
743+
Msgs = receive
744+
{collected, Collector, M} -> M
745+
after 15000 -> error(collect_timeout)
746+
end,
747+
?assert(lists:member(done, Msgs)),
748+
?assertEqual(Size, iolist_size([B || B <- Msgs, is_binary(B)])),
749+
hackney_conn:stop(Pid).
750+
751+
test_set_owner_api_unchanged() ->
752+
%% Regression: adding receiving/streaming handlers must not change the API
753+
%% elsewhere. set_owner still succeeds in connected and is still rejected
754+
%% with invalid_state in an untouched state (streaming_once / async once).
755+
Opts = #{
756+
host => "127.0.0.1",
757+
port => ?PORT,
758+
transport => hackney_tcp,
759+
connect_timeout => 5000,
760+
recv_timeout => 5000
761+
},
762+
{ok, Pid} = hackney_conn:start_link(Opts),
763+
ok = hackney_conn:connect(Pid),
764+
765+
%% connected: reassignment still works, then hand ownership back to us.
766+
Target = spawn(fun() -> receive stop -> ok end end),
767+
?assertEqual(ok, hackney_conn:set_owner(Pid, Target)),
768+
?assertEqual(ok, hackney_conn:set_owner(Pid, self())),
769+
Target ! stop,
770+
771+
%% streaming_once (async once, awaiting stream_next): still rejected.
772+
{ok, Ref} = hackney_conn:request_async(Pid, <<"GET">>, <<"/get">>, [], <<>>, once),
773+
receive {hackney_response, Ref, {status, _, _}} -> ok after 5000 -> error(status_timeout) end,
774+
receive {hackney_response, Ref, {headers, _}} -> ok after 5000 -> error(headers_timeout) end,
775+
?assertEqual({error, invalid_state}, hackney_conn:set_owner(Pid, self())),
776+
777+
hackney_conn:stop(Pid).
778+
635779
%%====================================================================
636780
%% 1XX Response Tests
637781
%%====================================================================

0 commit comments

Comments
 (0)