Skip to content

Commit d50634a

Browse files
authored
Chunk ssl sendfile (#176)
* Use more binary modes in file and test operations * Implement sending file in chunks over SSL
1 parent 1590f71 commit d50634a

4 files changed

Lines changed: 108 additions & 19 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ thousand_island-*.tar
2525
# Ignore dialyzer caches
2626
/priv/plts/
2727

28+
/tmp/
2829

2930
# ElixirLS
30-
/.elixir_ls
31+
/.elixir_ls

lib/thousand_island/transports/ssl.ex

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ defmodule ThousandIsland.Transports.SSL do
4545

4646
@hardcoded_options [mode: :binary, active: false]
4747

48+
# Default chunk size: 8MB - balances memory usage vs syscall overhead
49+
@sendfile_chunk_size 8 * 1024 * 1024
50+
4851
@impl ThousandIsland.Transport
4952
@spec listen(:inet.port_number(), [:ssl.tls_server_option()]) ::
5053
ThousandIsland.Transport.on_listen()
@@ -126,18 +129,12 @@ defmodule ThousandIsland.Transports.SSL do
126129
length :: non_neg_integer()
127130
) :: ThousandIsland.Transport.on_sendfile()
128131
def sendfile(socket, filename, offset, length) do
129-
# We can't use :file.sendfile here since it works on clear sockets, not ssl
130-
# sockets. Build our own (much slower and not optimized for large files) version.
131-
case :file.open(filename, [:raw]) do
132+
# We can't use :file.sendfile here since it works on clear sockets, not ssl sockets.
133+
# Build our own version with chunking for large files.
134+
case :file.open(filename, [:read, :raw, :binary]) do
132135
{:ok, fd} ->
133136
try do
134-
with {:ok, data} <- :file.pread(fd, offset, length),
135-
:ok <- :ssl.send(socket, data) do
136-
{:ok, length}
137-
else
138-
:eof -> {:error, :eof}
139-
{:error, reason} -> {:error, reason}
140-
end
137+
sendfile_loop(socket, fd, offset, length, 0)
141138
after
142139
:file.close(fd)
143140
end
@@ -147,6 +144,28 @@ defmodule ThousandIsland.Transports.SSL do
147144
end
148145
end
149146

147+
defp sendfile_loop(_socket, _fd, _offset, sent, sent) when 0 != sent do
148+
{:ok, sent}
149+
end
150+
151+
defp sendfile_loop(socket, fd, offset, length, sent) do
152+
with read_size <- chunk_size(length, sent, @sendfile_chunk_size),
153+
{:ok, data} <- :file.pread(fd, offset, read_size),
154+
:ok <- :ssl.send(socket, data) do
155+
now_sent = byte_size(data)
156+
sendfile_loop(socket, fd, offset + now_sent, length, sent + now_sent)
157+
else
158+
:eof ->
159+
{:ok, sent}
160+
161+
{:error, reason} ->
162+
{:error, reason}
163+
end
164+
end
165+
166+
defp chunk_size(0, _sent, chunk_size), do: chunk_size
167+
defp chunk_size(length, sent, chunk), do: min(length - sent, chunk)
168+
150169
@impl ThousandIsland.Transport
151170
@spec getopts(socket(), ThousandIsland.Transport.socket_get_options()) ::
152171
ThousandIsland.Transport.on_getopts()

lib/thousand_island/transports/tcp.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ defmodule ThousandIsland.Transports.TCP do
107107
length :: non_neg_integer()
108108
) :: ThousandIsland.Transport.on_sendfile()
109109
def sendfile(socket, filename, offset, length) do
110-
case :file.open(filename, [:raw]) do
110+
case :file.open(filename, [:read, :raw, :binary]) do
111111
{:ok, fd} ->
112112
try do
113113
:file.sendfile(fd, socket, offset, length, [])

test/thousand_island/socket_test.exs

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,22 @@ defmodule ThousandIsland.SocketTest do
33

44
use Machete
55

6-
def gen_tcp_setup(_context) do
7-
{:ok, %{client_mod: :gen_tcp, client_opts: [active: false], server_opts: []}}
6+
@eight_mb_chunks 8 * 1024 * 1024
7+
@large_file_size 256 * 1024 * 1024
8+
9+
def gen_tcp_setup(context) do
10+
if context[:tmp_dir], do: maybe_create_big_file(context.tmp_dir)
11+
{:ok, %{client_mod: :gen_tcp, client_opts: [:binary, active: false], server_opts: []}}
812
end
913

10-
def ssl_setup(_context) do
14+
def ssl_setup(context) do
15+
if context[:tmp_dir], do: maybe_create_big_file(context.tmp_dir)
16+
1117
{:ok,
1218
%{
1319
client_mod: :ssl,
1420
client_opts: [
21+
:binary,
1522
active: false,
1623
verify: :verify_peer,
1724
cacertfile: Path.join(__DIR__, "../support/ca.pem")
@@ -50,6 +57,18 @@ defmodule ThousandIsland.SocketTest do
5057
end
5158
end
5259

60+
defmodule LargeSendfile do
61+
use ThousandIsland.Handler
62+
63+
@impl ThousandIsland.Handler
64+
def handle_connection(socket, state) do
65+
large_file_path = Path.join(state[:tmp_dir], "large_sendfile")
66+
ThousandIsland.Socket.sendfile(socket, large_file_path, 0, 0)
67+
send(state[:test_pid], Process.info(self(), :monitored_by))
68+
{:close, state}
69+
end
70+
end
71+
5372
defmodule Closer do
5473
use ThousandIsland.Handler
5574

@@ -88,7 +107,7 @@ defmodule ThousandIsland.SocketTest do
88107
{:ok, client} = context.client_mod.connect(~c"localhost", port, context.client_opts)
89108

90109
assert context.client_mod.send(client, "HELLO") == :ok
91-
assert context.client_mod.recv(client, 0) == {:ok, ~c"HELLO"}
110+
assert context.client_mod.recv(client, 0) == {:ok, "HELLO"}
92111
end
93112

94113
test "it should close connections", context do
@@ -105,7 +124,7 @@ defmodule ThousandIsland.SocketTest do
105124
{:ok, client} = context.client_mod.connect(~c"localhost", port, context.client_opts)
106125

107126
:ok = context.client_mod.send(client, "HELLO")
108-
{:ok, ~c"HELLO"} = context.client_mod.recv(client, 0)
127+
{:ok, "HELLO"} = context.client_mod.recv(client, 0)
109128
context.client_mod.close(client)
110129

111130
assert_receive {:telemetry, [:thousand_island, :connection, :recv], measurements,
@@ -151,7 +170,18 @@ defmodule ThousandIsland.SocketTest do
151170
server_opts = Keyword.put(context.server_opts, :handler_options, test_pid: self())
152171
{:ok, port} = start_handler(Sendfile, server_opts)
153172
{:ok, client} = context.client_mod.connect(~c"localhost", port, context.client_opts)
154-
assert context.client_mod.recv(client, 9) == {:ok, ~c"ABCDEFBCD"}
173+
assert context.client_mod.recv(client, 9) == {:ok, "ABCDEFBCD"}
174+
assert_receive {:monitored_by, []}
175+
end
176+
177+
@tag :tmp_dir
178+
test "it should send large files", %{tmp_dir: tmp_dir} = context do
179+
opts = [test_pid: self(), tmp_dir: tmp_dir]
180+
server_opts = Keyword.put(context.server_opts, :handler_options, opts)
181+
{:ok, port} = start_handler(LargeSendfile, server_opts)
182+
{:ok, client} = context.client_mod.connect(~c"localhost", port, context.client_opts)
183+
total_received = receive_all_data(context.client_mod, client, @large_file_size, "")
184+
assert byte_size(total_received) == @large_file_size
155185
assert_receive {:monitored_by, []}
156186
end
157187
end
@@ -193,7 +223,18 @@ defmodule ThousandIsland.SocketTest do
193223
server_opts = Keyword.put(context.server_opts, :handler_options, test_pid: self())
194224
{:ok, port} = start_handler(Sendfile, server_opts)
195225
{:ok, client} = context.client_mod.connect(~c"localhost", port, context.client_opts)
196-
assert context.client_mod.recv(client, 9) == {:ok, ~c"ABCDEFBCD"}
226+
assert context.client_mod.recv(client, 9) == {:ok, "ABCDEFBCD"}
227+
assert_receive {:monitored_by, [_pid]}
228+
end
229+
230+
@tag :tmp_dir
231+
test "it should send large files", %{tmp_dir: tmp_dir} = context do
232+
opts = [test_pid: self(), tmp_dir: tmp_dir]
233+
server_opts = Keyword.put(context.server_opts, :handler_options, opts)
234+
{:ok, port} = start_handler(LargeSendfile, server_opts)
235+
{:ok, client} = context.client_mod.connect(~c"localhost", port, context.client_opts)
236+
total_received = receive_all_data(context.client_mod, client, @large_file_size, "")
237+
assert byte_size(total_received) == @large_file_size
197238
assert_receive {:monitored_by, [_pid]}
198239
end
199240
end
@@ -204,4 +245,32 @@ defmodule ThousandIsland.SocketTest do
204245
{:ok, {_ip, port}} = ThousandIsland.listener_info(server_pid)
205246
{:ok, port}
206247
end
248+
249+
defp maybe_create_big_file(tmp_dir) do
250+
path = Path.join(tmp_dir, "large_sendfile")
251+
252+
unless File.exists?(path) and File.stat!(path).size == @large_file_size do
253+
# Create a large file by writing 8MB chunks to avoid memory issues
254+
chunks_needed = div(@large_file_size, @eight_mb_chunks)
255+
chunk_data = :binary.copy(<<0>>, @eight_mb_chunks)
256+
{:ok, file} = File.open(path, [:write, :binary])
257+
for _i <- 1..chunks_needed, do: IO.binwrite(file, chunk_data)
258+
File.close(file)
259+
end
260+
end
261+
262+
defp receive_all_data(_, _, total_size, acc) when total_size <= 0, do: acc
263+
264+
defp receive_all_data(client_mod, client, total_size, acc) do
265+
case client_mod.recv(client, @eight_mb_chunks) do
266+
{:ok, data} ->
267+
receive_all_data(client_mod, client, total_size - byte_size(data), acc <> data)
268+
269+
{:error, :closed} when byte_size(acc) == total_size ->
270+
acc
271+
272+
{:error, reason} ->
273+
raise "Failed to receive data: #{inspect(reason)}"
274+
end
275+
end
207276
end

0 commit comments

Comments
 (0)