Skip to content

Commit d4a019e

Browse files
authored
Stop upload channel with {:shutdown, :closed} on writer error so the LiveView survives (#4320)
A custom UploadWriter returning {:error, _} (from write_chunk/2 or close/2) currently brings down the whole LiveView, not just the failed entry. The upload channel replies with the error but stays alive. The JS client's error/1 then calls leave(), so the channel exits {:shutdown, :left}, and the parent LiveView's {:DOWN} handler stops on any non-:closed upload-channel exit ({:channel_upload_exit, _}). Oversized files already self-close with {:shutdown, :closed} in the same handle_in/3. Writer errors were the one branch that didn't. Stopping with {:shutdown, :closed} drops the failed entry (threading the stored cid so component uploads drop too) and the LiveView survives. The error reply is still delivered to the client before the channel stops. A close(:done) failure already closed the writer before this branch runs, so the branch now skips a second close/2 (as handle_call(:cancel, ...) already does). Otherwise a writer that is not safe to close twice could raise and crash the channel with an abnormal exit, killing the LiveView through the same path this change is meant to fix.
1 parent 9e445c8 commit d4a019e

2 files changed

Lines changed: 87 additions & 10 deletions

File tree

lib/phoenix_live_view/upload_channel.ex

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,20 @@ defmodule Phoenix.LiveView.UploadChannel do
102102
{:reply, :ok, new_socket}
103103

104104
{:error, reason, new_socket} ->
105+
# a close(:done) failure already closed the writer; don't close it twice
105106
new_socket =
106-
case close_writer(new_socket, {:error, reason}) do
107-
{:ok, new_socket} -> new_socket
108-
{:error, _reason, new_socket} -> new_socket
107+
if new_socket.assigns.writer_closed? do
108+
new_socket
109+
else
110+
case close_writer(new_socket, {:error, reason}) do
111+
{:ok, new_socket} -> new_socket
112+
{:error, _reason, new_socket} -> new_socket
113+
end
109114
end
110115

111116
Channel.report_writer_error(socket.assigns.live_view_pid, reason)
112117

113-
{:reply, {:error, %{reason: :writer_error}}, new_socket}
118+
{:stop, {:shutdown, :closed}, {:error, %{reason: :writer_error}}, new_socket}
114119
end
115120
else
116121
reply = %{reason: :file_size_limit_exceeded, limit: max_file_size}

test/phoenix_live_view/upload/channel_test.exs

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,35 @@ defmodule Phoenix.LiveView.UploadChannelTest do
4242
end
4343
end
4444

45+
defmodule CloseErrorWriter do
46+
@behaviour Phoenix.LiveView.UploadWriter
47+
48+
@impl true
49+
defdelegate init(test_name), to: TestWriter
50+
51+
@impl true
52+
defdelegate meta(test_name), to: TestWriter
53+
54+
@impl true
55+
defdelegate write_chunk(data, test_name), to: TestWriter
56+
57+
@impl true
58+
def close(test_name, :done) do
59+
send(test_name, {:close, :done})
60+
{:error, :close_failed}
61+
end
62+
63+
def close(test_name, reason), do: TestWriter.close(test_name, reason)
64+
end
65+
4566
def build_writer(_name, %Phoenix.LiveView.UploadEntry{}, %Phoenix.LiveView.Socket{}) do
4667
{TestWriter, :test_writer}
4768
end
4869

70+
def build_close_error_writer(_name, %Phoenix.LiveView.UploadEntry{}, %Phoenix.LiveView.Socket{}) do
71+
{CloseErrorWriter, :test_writer}
72+
end
73+
4974
def valid_token(lv_pid, ref) do
5075
LiveView.Static.sign_token(@endpoint, %{pid: lv_pid, ref: ref})
5176
end
@@ -858,24 +883,71 @@ defmodule Phoenix.LiveView.UploadChannelTest do
858883

859884
@tag allow: [
860885
max_entries: 1,
861-
chunk_size: 50,
886+
chunk_size: 5,
862887
accept: :any,
863888
writer: &__MODULE__.build_writer/3
864889
]
865-
test "writer with error", %{lv: lv} do
890+
test "writer write_chunk/2 error self-closes the upload channel without bringing down the LiveView",
891+
%{lv: lv} do
866892
Process.register(self(), :test_writer)
867893

868-
content = "error"
894+
# first chunk writes ok; second chunk ("error") fails write_chunk
895+
avatar =
896+
file_input(lv, "form", :avatar, [
897+
%{name: "foo.jpeg", content: "00000error"}
898+
])
899+
900+
assert render_upload(avatar, "foo.jpeg", 50) =~ "#{@context}:foo.jpeg:50%"
901+
assert %{"foo.jpeg" => channel_pid} = UploadClient.channel_pids(avatar)
902+
903+
unlink(channel_pid, lv, avatar)
904+
Process.monitor(channel_pid)
905+
906+
render_upload(avatar, "foo.jpeg", 50)
907+
assert_receive {:close, {:error, :custom_error}}
908+
909+
# the upload channel self-closes instead of being left {:shutdown, :left}
910+
assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000
911+
912+
# the failed entry is dropped and the LiveView is still alive
913+
assert get_uploaded_entries(lv, :avatar) == {[], []}
914+
end
915+
916+
@tag allow: [
917+
max_entries: 1,
918+
chunk_size: 50,
919+
accept: :any,
920+
writer: &__MODULE__.build_close_error_writer/3
921+
]
922+
test "writer close error self-closes the upload channel without bringing down the LiveView",
923+
%{lv: lv} do
924+
Process.register(self(), :test_writer)
925+
926+
content = String.duplicate("0", 100)
869927

870928
avatar =
871929
file_input(lv, "form", :avatar, [
872930
%{name: "foo.jpeg", content: content}
873931
])
874932

875-
assert render_upload(avatar, "foo.jpeg") =~
876-
~s/entry_error:{:writer_failure, :custom_error}/
933+
assert render_upload(avatar, "foo.jpeg", 50) =~ "#{@context}:foo.jpeg:50%"
934+
assert %{"foo.jpeg" => channel_pid} = UploadClient.channel_pids(avatar)
877935

878-
assert_receive {:close, {:error, :custom_error}}
936+
unlink(channel_pid, lv, avatar)
937+
Process.monitor(channel_pid)
938+
939+
# uploading the final chunk completes the file and runs close(:done), which errors
940+
render_upload(avatar, "foo.jpeg", 50)
941+
assert_receive {:close, :done}
942+
943+
# close(:done) already closed the writer, so the error branch must not close it again
944+
refute_receive {:close, {:error, :close_failed}}
945+
946+
# the upload channel self-closes instead of being left {:shutdown, :left}
947+
assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000
948+
949+
# the failed entry is dropped and the LiveView is still alive
950+
assert get_uploaded_entries(lv, :avatar) == {[], []}
879951
end
880952
end
881953
end

0 commit comments

Comments
 (0)