diff --git a/gleam.toml b/gleam.toml index 51ec110..fcaabef 100644 --- a/gleam.toml +++ b/gleam.toml @@ -14,6 +14,9 @@ version = "1.0.0" [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" +gleam_otp = ">= 1.2.0 and < 2.0.0" +gleam_erlang = ">= 1.3.0 and < 2.0.0" +simplifile = ">= 2.3.1 and < 3.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 61ab519..c3d61b4 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,10 +2,17 @@ # You typically do not need to edit this file packages = [ + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, ] [requirements] +gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } +gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +simplifile = { version = ">= 2.3.1 and < 3.0.0" } diff --git a/src/musicplayer.gleam b/src/musicplayer.gleam index ca2796e..c98a0e3 100644 --- a/src/musicplayer.gleam +++ b/src/musicplayer.gleam @@ -1,41 +1,5 @@ import gleam/io -import tcp/reason -import tcp/tcp - pub fn main() -> Nil { - let socket_path = "/tmp/musicplayer.sock" - - case tcp.connect(socket_path) { - Error(r) -> - io.println("Failed to connect to socket: " <> reason.to_string(r)) - Ok(socket) -> { - io.println("connected") - - let messages = ["hello, \n", "world!\n"] - - messages |> send_messages(socket, _) - - io.println("closing") - tcp.close(socket) - } - } -} - -fn send_messages(socket: tcp.Socket, messages: List(String)) -> Nil { - case messages { - [] -> Nil - [message, ..rest] -> { - send_message(socket, message) - send_messages(socket, rest) - } - } -} - -fn send_message(socket: tcp.Socket, message: String) -> Nil { - case tcp.send(socket, message) { - Error(r) -> - io.println("Failed to send message to socket: " <> reason.to_string(r)) - Ok(_) -> io.println("Sent message to socket") - } + io.println("musicplayer") } diff --git a/src/tcp/echo_server.gleam b/src/tcp/echo_server.gleam new file mode 100644 index 0000000..9e24c9f --- /dev/null +++ b/src/tcp/echo_server.gleam @@ -0,0 +1,81 @@ +import gleam/erlang/process.{type Subject} +import gleam/io +import gleam/otp/actor +import gleam/result +import gleam/string + +import tcp/reason.{type Reason} +import tcp/tcp + +pub type Message { + Shutdown + ReadyToAccept(subject: Subject(Message), listen_socket: tcp.Socket) +} + +pub fn new(socket_path: String) -> Result(tcp.Socket, String) { + let server = actor.new(Nil) |> actor.on_message(handle_message) |> actor.start + + case tcp.listen(socket_path), server { + Error(r), _ -> Error(reason.to_string(r)) + _, Error(start_error) -> + case start_error { + actor.InitExited(_) -> Error("InitExited") + actor.InitFailed(_) -> Error("InitFailed") + actor.InitTimeout -> Error("InitTimeout") + } + Ok(listen_socket), Ok(b) -> { + let subject = b.data + actor.send(subject, ReadyToAccept(subject, listen_socket)) + Ok(listen_socket) + } + } +} + +fn handle_message(_: Nil, message: Message) -> actor.Next(Nil, Message) { + case message { + Shutdown -> actor.stop() + + ReadyToAccept(subject, listen_socket) -> { + case tcp.accept(listen_socket) { + Error(r) -> + actor.stop_abnormal( + "Could not accept connection :" <> reason.to_string(r), + ) + Ok(socket) -> { + case receive_from_connection(socket) { + Error(r) -> + io.println_error( + "Failed to receive from connection :" <> reason.to_string(r), + ) + Ok(_) -> Nil + } + + actor.send(subject, ReadyToAccept(subject, listen_socket)) + actor.continue(Nil) + } + } + } + } +} + +fn receive_from_connection(socket: tcp.Socket) -> Result(Nil, Reason) { + result.try(receive_until_closed(socket, ""), fn(data) { + let _ = tcp.send(socket, data) + tcp.close(socket) + Ok(Nil) + }) +} + +fn receive_until_closed( + socket: tcp.Socket, + result: String, +) -> Result(String, Reason) { + case tcp.receive(socket, 10_000) { + Error(reason.Closed) -> Ok(result) + Error(err) -> Error(err) + Ok(data) -> { + let result = data |> string.append(result, _) + receive_until_closed(socket, result) + } + } +} diff --git a/src/tcp/reason.gleam b/src/tcp/reason.gleam index 0e58a1c..900ed69 100644 --- a/src/tcp/reason.gleam +++ b/src/tcp/reason.gleam @@ -4,6 +4,8 @@ pub type Reason { /// from `send` Closed + Overflow + /// Address already in use Eaddrinuse /// Cannot assign requested address @@ -158,6 +160,7 @@ pub type Reason { pub fn to_string(reason: Reason) -> String { case reason { + Overflow -> "overflow" Closed -> "Connection closed (closed)" Eacces -> "Permission denied (eacces)" Eaddrinuse -> "Address already in use (eaddrinuse)" diff --git a/src/tcp/tcp.gleam b/src/tcp/tcp.gleam index bcaa9d9..77c2053 100644 --- a/src/tcp/tcp.gleam +++ b/src/tcp/tcp.gleam @@ -1,4 +1,6 @@ -import gleam/bytes_tree +import gleam/bit_array +import gleam/erlang/atom +import gleam/result import tcp/reason.{type Reason} @@ -17,25 +19,82 @@ type ModeValue { type TCPOption { Active(Bool) Mode(ModeValue) + Reuseaddr(Bool) + Ifaddr(Local) + ExitOnClose(Bool) +} + +pub fn listen(socket_path: String) -> Result(Socket, Reason) { + let options = [ + Mode(Binary), + Active(False), + Reuseaddr(True), + Ifaddr(Local(socket_path)), + ExitOnClose(False), + ] + // port zero with `local` address + let port = 0 + + gen_tcp_listen(port, options) +} + +pub fn accept(listen_socket: Socket) -> Result(Socket, Reason) { + gen_tcp_accept(listen_socket) +} + +pub fn receive(socket: Socket, timeout: Int) -> Result(String, Reason) { + // Get all bytes + let length = 0 + + use bits <- result.try(gen_tcp_recv(socket, length, timeout)) + case bits |> bit_array.to_string { + // TODO what error is best? + Error(_) -> Error(reason.Ebadmsg) + Ok(s) -> Ok(s) + } } pub fn connect(socket_path: String) -> Result(Socket, Reason) { let options = [Mode(Binary), Active(False)] + // port zero with `local` address + let port = 0 + // timeout in ms let timeout = 1000 - gen_tcp_connect(Local(socket_path), 0, options, timeout) + gen_tcp_connect(Local(socket_path), port, options, timeout) } pub fn send(socket: Socket, message: String) -> Result(Nil, Reason) { - gen_tcp_send(socket, bytes_tree.from_string(message)) + gen_tcp_send(socket, bit_array.from_string(message)) } pub fn close(socket: Socket) -> Nil { gen_tcp_close(socket) } +pub fn shutdown(socket: Socket) -> Result(Nil, Reason) { + let how = atom.create("write") + gen_tcp_shutdown(socket, how) +} + +// https://www.erlang.org/doc/apps/kernel/gen_tcp.html#listen/2 +@external(erlang, "gen_tcp", "listen") +fn gen_tcp_listen(port: Int, option: List(TCPOption)) -> Result(Socket, Reason) + +// https://www.erlang.org/doc/apps/kernel/gen_tcp.html#accept/1 +@external(erlang, "gen_tcp", "accept") +fn gen_tcp_accept(listen_socket: Socket) -> Result(Socket, Reason) + +// https://www.erlang.org/doc/apps/kernel/gen_tcp.html#recv/3 +@external(erlang, "gen_tcp", "recv") +fn gen_tcp_recv( + socket: Socket, + length: Int, + timeout: Int, +) -> Result(BitArray, Reason) + // https://www.erlang.org/doc/apps/kernel/gen_tcp.html#connect/4 @external(erlang, "gen_tcp", "connect") fn gen_tcp_connect( @@ -47,11 +106,12 @@ fn gen_tcp_connect( // https://www.erlang.org/doc/apps/kernel/gen_tcp.html#send/2 @external(erlang, "tcp_ffi", "send") -fn gen_tcp_send( - socket: Socket, - packet: bytes_tree.BytesTree, -) -> Result(Nil, Reason) +fn gen_tcp_send(socket: Socket, packet: BitArray) -> Result(Nil, Reason) // https://www.erlang.org/doc/apps/kernel/gen_tcp.html#close/1 @external(erlang, "gen_tcp", "close") fn gen_tcp_close(socket: Socket) -> Nil + +// https://www.erlang.org/doc/apps/kernel/gen_tcp.html#shutdown/2 +@external(erlang, "tcp_ffi", "shutdown") +fn gen_tcp_shutdown(socket: Socket, how: atom.Atom) -> Result(Nil, Reason) diff --git a/src/tcp/tcp_ffi.erl b/src/tcp/tcp_ffi.erl index c651fec..c71be0a 100644 --- a/src/tcp/tcp_ffi.erl +++ b/src/tcp/tcp_ffi.erl @@ -1,8 +1,14 @@ -module(tcp_ffi). --export([send/2]). +-export([send/2, shutdown/2]). send(Socket, Packet) -> case gen_tcp:send(Socket, Packet) of ok -> {ok, nil}; Res -> Res end. + +shutdown(Socket, How) -> + case gen_tcp:shutdown(Socket, How) of + ok -> {ok, nil}; + Res -> Res + end. \ No newline at end of file diff --git a/test/musicplayer_test.gleam b/test/musicplayer_test.gleam index fba3c88..3d4962d 100644 --- a/test/musicplayer_test.gleam +++ b/test/musicplayer_test.gleam @@ -1,13 +1,30 @@ +import gleam/list import gleeunit +import simplifile + +import tcp/echo_server +import tcp/tcp pub fn main() -> Nil { gleeunit.main() } -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - let name = "Joe" - let greeting = "Hello, " <> name <> "!" +pub fn tcp_send_shutdown_receive_test() { + let socket_path = "/tmp/musicplayer-test.sock" - assert greeting == "Hello, Joe!" + let assert Ok(_) = echo_server.new(socket_path) + let assert Ok(socket) = tcp.connect(socket_path) + + let messages = ["hello, ", "world!\n"] + list.each(messages, fn(message) { + let assert Ok(_) = tcp.send(socket, message) + }) + + let assert Ok(_) = tcp.shutdown(socket) + + let timeout_ms = 100 + assert Ok("hello, world!\n") == tcp.receive(socket, timeout_ms) + + // TODO find better way to always do cleanup + simplifile.delete(socket_path) }