diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 91e8c50..4c35569 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -94,3 +94,4 @@ Tests use ExUnit. See `test/httpd_integration_test.exs` for socket-level testing | `include/*.hrl` | Header files - HTTP codes, trace macros | | `priv/` | Static assets (served via `httpd_file_handler`) | | `test/support/` | Test-only modules | +| `erlang_example` | Erlang example implementation | diff --git a/.gitignore b/.gitignore index 35d485b..20325cb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,6 @@ atomvm_httpd-*.tar # Temporary files, for example, from tests. /tmp/ -.elixir_ls/ \ No newline at end of file +.elixir_ls/ + +.envrc \ No newline at end of file diff --git a/TODO.md b/TODO.md index 805fe97..accf0f4 100644 --- a/TODO.md +++ b/TODO.md @@ -83,6 +83,19 @@ Tracking document for memory leak and VM stability issues identified during code --- +## Code Quality Improvements + +### 9. ✅ Replace custom iolist_length with erlang:iolist_size +**File:** `src/httpd.erl` line 582 + +**Problem:** Used custom `iolist_length/1` function instead of the standard `erlang:iolist_size/1` BIF which is available in AtomVM. Custom implementation works correctly but adds unnecessary code maintenance burden. + +**Solution:** Replace with `erlang:iolist_size/1` and remove custom function. + +**Status:** Fixed - replaced custom implementation with standard BIF, all tests passing. + +--- + ## Progress Log | Date | Issue # | Status | Notes | @@ -90,6 +103,8 @@ Tracking document for memory leak and VM stability issues identified during code | 2024-12-07 | - | - | Initial review and documentation | | 2024-12-07 | 5 | ✅ | Fixed map update operator in httpd.erl | | 2024-12-07 | 1 | ✅ | Fixed accept loop crash recovery in gen_tcp_server.erl | +| 2026-01-11 | 9 | ✅ | Replaced custom iolist_length with erlang:iolist_size/1 | +| 2026-01-11 | - | - | Added comprehensive iolist handler tests | --- diff --git a/examples/elixir_http/.formatter.exs b/examples/elixir_http/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/examples/elixir_http/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/examples/elixir_http/.gitignore b/examples/elixir_http/.gitignore new file mode 100644 index 0000000..f9857bb --- /dev/null +++ b/examples/elixir_http/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Temporary files, for example, from tests. +/tmp/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +elixir_http-*.tar + + +*.avm \ No newline at end of file diff --git a/examples/elixir_http/README.md b/examples/elixir_http/README.md new file mode 100644 index 0000000..ccb1ea3 --- /dev/null +++ b/examples/elixir_http/README.md @@ -0,0 +1,21 @@ +# ElixirHttp + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `elixir_http` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:elixir_http, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/examples/elixir_http/lib/elixir_http.ex b/examples/elixir_http/lib/elixir_http.ex new file mode 100644 index 0000000..7748332 --- /dev/null +++ b/examples/elixir_http/lib/elixir_http.ex @@ -0,0 +1,82 @@ +defmodule ElixirHttp do + @moduledoc """ + Elixir httpd example + """ + @compile {:no_warn_undefined, :network} + + def start do + :ok = start_network() + + :ok = start_http() + + Process.sleep(:infinity) + end + + # resolved at compile time + @ssid System.get_env("SSID") + @psk System.get_env("PSK") + + def start_network() do + Process.sleep(500) + config = [ssid: @ssid, psk: @psk] + + case :network.wait_for_sta(config, 15_000) do + {:ok, _} -> + :ok + + {:error, :disconnected} -> + # try again... + start_network() + + {:error, {:already_started, _pid}} -> + Process.sleep(500) + :ok + + {:error, err} -> + IO.puts("Error starting network #{inspect(err)}") + + :error + end + end + + defp start_http(port \\ 8080) do + config = [ + # API endpoints at /api/* + {["api"], + %{ + handler: :httpd_api_handler, + handler_config: %{ + module: ElixirHttp.ApiHandler + } + }}, + # WebSocket at /ws/* + {["ws"], + %{ + handler: :httpd_ws_handler, + handler_config: %{ + module: ElixirHttp.WsHandler + } + }}, + # Static files from priv/ at root + {[], + %{ + handler: :httpd_file_handler, + handler_config: %{ + app: :elixir_http + } + }} + ] + + IO.puts("Starting httpd on port #{port}...") + + case :httpd.start(port, config) do + {:ok, _pid} -> + IO.puts("httpd started.") + :ok + + error -> + IO.puts("An error occurred: #{inspect(error)}") + :error + end + end +end diff --git a/examples/elixir_http/lib/elixir_http/api_handler.ex b/examples/elixir_http/lib/elixir_http/api_handler.ex new file mode 100644 index 0000000..962db6e --- /dev/null +++ b/examples/elixir_http/lib/elixir_http/api_handler.ex @@ -0,0 +1,56 @@ +defmodule ElixirHttp.ApiHandler do + @compile {:no_warn_undefined, :atomvm} + + @moduledoc """ + API handler for system info and memory endpoints. + Implements the httpd_api_handler behavior. + """ + + @behaviour :httpd_api_handler + + @impl true + def handle_api_request(:get, ["system_info"], _http_request, _args) do + # = Map.get(http_request, :socket) + # {:ok, %{addr: host, port: port}} = :socket.peername(socket) + # IO.puts("GET system_info request from #{inspect(host)}:#{port}") + + result = %{ + platform: :atomvm.platform(), + system_architecture: :erlang.system_info(:system_architecture), + atomvm_version: :erlang.system_info(:atomvm_version), + word_size: :erlang.system_info(:wordsize), + esp32_chip_info: get_esp32_chip_info(), + esp_idf_version: :erlang.system_info(:esp_idf_version) + } + + {:ok, result} + end + + def handle_api_request(:get, ["memory"], _http_request, _args) do + # socket = Map.get(http_request, :socket) + # {:ok, %{addr: host, port: port}} = :socket.peername(socket) + # IO.puts("GET memory request from #{inspect(host)}:#{port}") + + {:ok, get_memory_data()} + end + + def handle_api_request(method, path, _http_request, _args) do + IO.puts("ERROR! Unsupported method #{inspect(method)} or path #{inspect(path)}") + {:error, :not_found} + end + + def get_memory_data() do + %{ + atom_count: :erlang.system_info(:atom_count), + process_count: :erlang.system_info(:process_count), + port_count: :erlang.system_info(:port_count), + esp32_free_heap_size: :erlang.system_info(:esp32_free_heap_size), + esp32_largest_free_block: :erlang.system_info(:esp32_largest_free_block), + esp32_minimum_free_size: :erlang.system_info(:esp32_minimum_free_size) + } + end + + defp get_esp32_chip_info() do + :erlang.system_info(:esp32_chip_info) + end +end diff --git a/examples/elixir_http/lib/elixir_http/ws_handler.ex b/examples/elixir_http/lib/elixir_http/ws_handler.ex new file mode 100644 index 0000000..b6e2de1 --- /dev/null +++ b/examples/elixir_http/lib/elixir_http/ws_handler.ex @@ -0,0 +1,56 @@ +defmodule ElixirHttp.WsHandler do + @moduledoc """ + WebSocket handler that sends memory updates to connected clients. + Implements the httpd_ws_handler behavior. + """ + + @behaviour :httpd_ws_handler + + @impl true + def handle_ws_init(websocket, _path, _args) do + IO.puts("Initializing websocket pid=#{inspect(self())}") + last_memory = ElixirHttp.ApiHandler.get_memory_data() + spawn(fn -> update_loop(websocket, last_memory) end) + {:ok, nil} + end + + @impl true + def handle_ws_message("ping", state) do + {:reply, "pong", state} + end + + def handle_ws_message(message, state) do + # IO.puts("Received message from web socket. Message: #{inspect(message)}") + {:noreply, state} + end + + defp update_loop(websocket, last_memory_data) do + Process.sleep(5000) + latest_memory_data = ElixirHttp.ApiHandler.get_memory_data() + + new_memory_data = get_difference(last_memory_data, latest_memory_data) + + case new_memory_data do + [] -> + :ok + + _ -> + binary = :erlang.iolist_to_binary(:json_encoder.encode(Map.new(new_memory_data))) + # IO.puts("Sending websocket message to client #{inspect(binary)} ... ") + :httpd_ws_handler.send(websocket, binary) + IO.puts("sent.") + end + + update_loop(websocket, latest_memory_data) + end + + defp get_difference(map1, map2) do + Enum.reduce(map1, [], fn {key, value}, acc -> + case Map.get(map2, key) do + nil -> [{key, value} | acc] + ^value -> acc + new_value -> [{key, new_value} | acc] + end + end) + end +end diff --git a/examples/elixir_http/mix.exs b/examples/elixir_http/mix.exs new file mode 100644 index 0000000..0c3a841 --- /dev/null +++ b/examples/elixir_http/mix.exs @@ -0,0 +1,37 @@ +defmodule ElixirHttp.MixProject do + use Mix.Project + + def project do + [ + app: :elixir_http, + version: "0.1.0", + elixir: "~> 1.19", + start_permanent: Mix.env() == :prod, + deps: deps(), + atomvm: [ + # Change to Lux.Test for minimal testing without networking + start: ElixirHttp, + flash_offset: 0x250000 + ] + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:pythonx, "~> 0.4.7", runtime: false}, + {:exatomvm, github: "atomvm/ExAtomVM", runtime: false}, + {:atomvm_httpd, path: "../../"}, + # Test-only dependencies + {:req, "~> 0.5", only: :test, runtime: false}, + {:websockex, "~> 0.4.3", only: :test, runtime: false} + ] + end +end diff --git a/examples/elixir_http/mix.lock b/examples/elixir_http/mix.lock new file mode 100644 index 0000000..a261d9c --- /dev/null +++ b/examples/elixir_http/mix.lock @@ -0,0 +1,18 @@ +%{ + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "exatomvm": {:git, "https://github.com/atomvm/ExAtomVM.git", "0f9351417673d347d442c3283cca1bfeb47458f6", []}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "pythonx": {:hex, :pythonx, "0.4.7", "604a3a78377abdaa8739c561cb871c856b0e80d25fd057277839912017004af0", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.2", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "20d8b456df995e6ccd6d88dcf118ba80464194515f71a5c89aacdb824d235c52"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "uf2tool": {:hex, :uf2tool, "1.1.0", "7091931234ca5a256b66ea691983867d51229798622d083f85c1ad779798a734", [:rebar3], [], "hexpm", "1a7e5ca7ef3d19c7a0b0acf3db804b3188e0980884acffa13fd57d733507b73d"}, + "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, +} diff --git a/examples/elixir_http/priv/favicon.ico b/examples/elixir_http/priv/favicon.ico new file mode 100755 index 0000000..b716333 Binary files /dev/null and b/examples/elixir_http/priv/favicon.ico differ diff --git a/examples/elixir_http/priv/index.html b/examples/elixir_http/priv/index.html new file mode 100755 index 0000000..1cbb790 --- /dev/null +++ b/examples/elixir_http/priv/index.html @@ -0,0 +1,195 @@ + + + + + + + + AtomVM Web Console + + + +
+ + + + diff --git a/examples/elixir_http/priv/js/app.js b/examples/elixir_http/priv/js/app.js new file mode 100755 index 0000000..37d3224 --- /dev/null +++ b/examples/elixir_http/priv/js/app.js @@ -0,0 +1,141 @@ +// +// # ---------------------------------------------------------------------------- +// # "THE BEER-WARE LICENSE" (Revision 42): +// # wrote this file. You are hereby granted permission to +// # copy, modify, or mutilate this file without restriction. If you create a +// # work derived from this file, you may optionally include a copy of this notice, +// # for which I would be most grateful, but you are not required to do so. +// # If we meet some day, and you think this stuff is worth it, you can buy me a +// # beer in return. Fred Dushin +// # ---------------------------------------------------------------------------- +// + +// Vanilla JS implementation for AtomVM Web Console + +// --- System Info Fetch and Render --- +function fetchSystemInfo() { + fetch('/api/system_info') + .then(function(response) { return response.json(); }) + .then(function(data) { + // If esp32_chip_info exists, flatten it + if (data.esp32_chip_info) { + data.chip_model = data.esp32_chip_info.model; + data.chip_cores = data.esp32_chip_info.cores; + data.chip_features = data.esp32_chip_info.features; + data.chip_revision = data.esp32_chip_info.revision; + } + renderSystemInfo(data); + }) + .catch(function(err) { + console.log(err) + document.getElementById('system-view').innerHTML = '
Failed to load system info
'; + }); +} + +function renderSystemInfo(data) { + var html = ` + + + + + + + + + +
platform${data.platform ?? ''}
system_architecture${data.system_architecture ?? ''}
word_size${data.word_size ?? ''}
chip_model${data.chip_model ?? ''}
chip_cores${data.chip_cores ?? ''}
chip_features${data.chip_features ?? ''}
chip_revision${data.chip_revision ?? ''}
atomvm_version${data.atomvm_version ?? ''}
esp_idf_version${data.esp_idf_version ?? ''}
`; + document.getElementById('system-view').innerHTML = html; +} + +// --- Memory Info Fetch and Render --- +let memoryData = {}; +function fetchMemoryInfo() { + fetch('/api/memory') + .then(function(response) { return response.json(); }) + .then(function(data) { + memoryData = data; + renderMemoryInfo(data); + }) + .catch(function(err) { + document.getElementById('memory-view').innerHTML = '
Failed to load memory info
'; + }); +} + +function renderMemoryInfo(data) { + var html = ` + + + + + + +
atom_count${data.atom_count ?? ''}
port_count${data.port_count ?? ''}
process_count${data.process_count ?? ''}
esp32_free_heap_size${data.esp32_free_heap_size ?? ''}
esp32_largest_free_block${data.esp32_largest_free_block ?? ''}
esp32_minimum_free_size${data.esp32_minimum_free_size ?? ''}
`; + document.getElementById('memory-view').innerHTML = html; +} + +// --- WebSocket for live memory updates --- +function getWebSocketUrl() { + var hostname = window.location.hostname; + var port = window.location.port; + return "ws://" + hostname + (port ? ":" + port : "") + "/ws"; +} + +function createWebSocket() { + var ws = new window.WebSocket(getWebSocketUrl()); + ws.onmessage = function(event) { + try { + var data = JSON.parse(event.data); + // Only update known memory fields + let changed = false; + for (var key in data) { + if (Object.prototype.hasOwnProperty.call(memoryData, key) && data[key] !== memoryData[key]) { + memoryData[key] = data[key]; + changed = true; + } + } + if (changed) { + renderMemoryInfo(memoryData); + } + } catch (e) { + // ignore parse errors + } + }; + ws.onopen = function(event) { + console.log("WebSocket opened"); + }; + ws.onclose = function(event) { + console.log("WebSocket closed, reconnecting..."); + setTimeout(createWebSocket, 2000); + }; + ws.onerror = function(event) { + console.log("WebSocket error"); + }; + return ws; +} + +// --- Initialization --- +document.addEventListener('DOMContentLoaded', function() { + // Hide loader and show content after 1s + setTimeout(function() { + var loader = document.getElementById('loader'); + var content = document.getElementById('content'); + if (loader) loader.style.display = 'none'; + if (content) content.style.display = 'block'; + }, 1000); + + // Tab switching logic + var tabBtns = document.querySelectorAll('.tab-btn'); + tabBtns.forEach(function(btn) { + btn.addEventListener('click', function() { + tabBtns.forEach(function(b) { b.classList.remove('active'); }); + document.querySelectorAll('.panel').forEach(function(p) { p.classList.remove('active'); }); + btn.classList.add('active'); + var panel = document.getElementById(btn.getAttribute('data-panel')); + if (panel) panel.classList.add('active'); + }); + }); + + fetchSystemInfo(); + fetchMemoryInfo(); + createWebSocket(); +}); diff --git a/examples/elixir_http/test/elixir_http_test.exs b/examples/elixir_http/test/elixir_http_test.exs new file mode 100644 index 0000000..c817178 --- /dev/null +++ b/examples/elixir_http/test/elixir_http_test.exs @@ -0,0 +1,307 @@ +defmodule ElixirHttpTest do + use ExUnit.Case + + @moduledoc """ + HTTP Server stress tests. + + Configure the target server with environment variables: + HTTP_TEST_HOST - IP address of the HTTP server (default: "127.0.0.1") + HTTP_TEST_PORT - Port of the HTTP server (default: "8080") + + Example: + HTTP_TEST_HOST=192.168.25.103 HTTP_TEST_PORT=8080 mix test + """ + + @default_host "127.0.0.1" + @default_port 8080 + @request_timeout 10_000 + # Disable retries - if it fails, we want to know immediately + @req_opts [receive_timeout: @request_timeout, retry: false] + + setup_all do + host = System.get_env("HTTP_TEST_HOST", @default_host) + port = System.get_env("HTTP_TEST_PORT", "#{@default_port}") |> String.to_integer() + base_url = "http://#{host}:#{port}" + + %{host: host, port: port, base_url: base_url} + end + + # Helper to make requests with consistent options + defp req_get(url), do: Req.get(url, @req_opts) + defp req_head(url), do: Req.head(url, @req_opts) + + # Verify Content-Length header matches actual body size + defp verify_content_length(response) do + content_length = get_content_length(response) + body_size = byte_size(response.body) + + if content_length do + assert content_length == body_size, + "Content-Length mismatch: header=#{content_length}, body=#{body_size}" + end + + response + end + + defp get_content_length(response) do + case Req.Response.get_header(response, "content-length") do + [value | _] -> String.to_integer(value) + [] -> nil + end + end + + describe "basic connectivity" do + test "connects to server", %{base_url: base_url} do + assert {:ok, response} = req_get(base_url) + assert response.status in [200, 304] + end + + test "multiple sequential requests", %{base_url: base_url} do + for _ <- 1..10 do + {:ok, response} = req_get(base_url) + assert response.status in [200, 304] + end + end + end + + describe "static file serving" do + test "serves index.html at root", %{base_url: base_url} do + {:ok, response} = req_get(base_url) + assert response.status == 200 + verify_content_length(response) + + assert String.contains?(response.body, "") or + String.contains?(response.body, " + endpoints = ["/", "/api/system_info", "/api/memory"] + + for j <- 1..10 do + endpoint = Enum.at(endpoints, rem(j, 3)) + {:ok, response} = req_get("#{base_url}#{endpoint}") + assert response.status == 200 + end + + :ok + end) + end + + results = Task.await_many(tasks, 120_000) + assert Enum.all?(results, &(&1 == :ok)) + end + end + + describe "HTTP methods and edge cases" do + @tag :skip + test "HEAD request works", %{base_url: base_url} do + # HEAD is not currently supported by httpd + {:ok, response} = req_head(base_url) + assert response.status in [200, 304] + end + + test "handles query parameters", %{base_url: base_url} do + {:ok, response} = req_get("#{base_url}/api/system_info?foo=bar") + assert response.status == 200 + end + + test "handles URL-encoded paths", %{base_url: base_url} do + # Request for a file that doesn't exist but tests URL handling + {:ok, response} = req_get("#{base_url}/test%20file.html") + assert response.status == 404 + end + end + + describe "WebSocket" do + test "connects to WebSocket endpoint", %{host: host, port: port} do + ws_url = "ws://#{host}:#{port}/ws" + + {:ok, pid} = WebSockex.start_link(ws_url, ElixirHttpTest.WsClient, %{parent: self()}) + assert Process.alive?(pid) + + # Send ping and expect pong + WebSockex.send_frame(pid, {:text, "ping"}) + + assert_receive {:ws_message, "pong"}, 5_000 + + WebSockex.cast(pid, :close) + end + + @tag :slow + test "multiple WebSocket messages", %{host: host, port: port} do + ws_url = "ws://#{host}:#{port}/ws" + + {:ok, pid} = WebSockex.start_link(ws_url, ElixirHttpTest.WsClient, %{parent: self()}) + + for _ <- 1..20 do + WebSockex.send_frame(pid, {:text, "ping"}) + assert_receive {:ws_message, "pong"}, 5_000 + end + + WebSockex.cast(pid, :close) + end + + @tag :slow + test "multiple concurrent WebSocket connections", %{host: host, port: port} do + ws_url = "ws://#{host}:#{port}/ws" + + pids = + for _ <- 1..5 do + {:ok, pid} = WebSockex.start_link(ws_url, ElixirHttpTest.WsClient, %{parent: self()}) + pid + end + + # Send ping from all connections + for pid <- pids do + WebSockex.send_frame(pid, {:text, "ping"}) + end + + # Expect pong from all connections + for _ <- pids do + assert_receive {:ws_message, "pong"}, 5_000 + end + + for pid <- pids do + WebSockex.cast(pid, :close) + end + end + end + + # Helper functions + + defp run_concurrent_requests(base_url, num_requests) do + tasks = + for _i <- 1..num_requests do + Task.async(fn -> + {:ok, response} = req_get("#{base_url}/api/system_info") + assert response.status == 200 + :ok + end) + end + + results = Task.await_many(tasks, 60_000) + assert Enum.all?(results, &(&1 == :ok)) + end +end + +defmodule ElixirHttpTest.WsClient do + @moduledoc false + use WebSockex + + def handle_frame({:text, msg}, %{parent: parent} = state) do + send(parent, {:ws_message, msg}) + {:ok, state} + end + + def handle_frame(_frame, state) do + {:ok, state} + end + + def handle_cast(:close, state) do + {:close, state} + end +end diff --git a/examples/elixir_http/test/test_helper.exs b/examples/elixir_http/test/test_helper.exs new file mode 100644 index 0000000..a63e154 --- /dev/null +++ b/examples/elixir_http/test/test_helper.exs @@ -0,0 +1,2 @@ +Application.ensure_all_started(:req) +ExUnit.start() diff --git a/examples/elixir_tcp/.formatter.exs b/examples/elixir_tcp/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/examples/elixir_tcp/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/examples/elixir_tcp/.gitignore b/examples/elixir_tcp/.gitignore new file mode 100644 index 0000000..3c9ec66 --- /dev/null +++ b/examples/elixir_tcp/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Temporary files, for example, from tests. +/tmp/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +elixir_tcp-*.tar + + +*.avm \ No newline at end of file diff --git a/examples/elixir_tcp/README.md b/examples/elixir_tcp/README.md new file mode 100644 index 0000000..c630882 --- /dev/null +++ b/examples/elixir_tcp/README.md @@ -0,0 +1,21 @@ +# ElixirTcp + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `elixir_tcp` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:elixir_tcp, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/examples/elixir_tcp/lib/elixir_tcp.ex b/examples/elixir_tcp/lib/elixir_tcp.ex new file mode 100644 index 0000000..fc58d4d --- /dev/null +++ b/examples/elixir_tcp/lib/elixir_tcp.ex @@ -0,0 +1,53 @@ +defmodule ElixirTcp do + # @compile {:no_warn_undefined, :avm_pubsub} + @compile {:no_warn_undefined, :network} + + @moduledoc """ + Elixir tcp example + """ + + def start do + :ok = start_network() + + :ok = start_tcp() + + Process.sleep(:infinity) + end + + # resolved at compile time + @ssid System.get_env("SSID") + @psk System.get_env("PSK") + + def start_network() do + case :network.wait_for_sta([ssid: @ssid, psk: @psk], 30_000) do + {:ok, _} -> + :ok + + {:error, :disconnected} -> + # try again... + start_network() + + {:error, {:already_started, _pid}} -> + :ok + + {:error, err} -> + IO.puts("Error starting network #{inspect(err)}") + + :error + end + end + + defp start_tcp(port \\ 9090) do + IO.puts("Starting TCP echo Server on port #{port}") + + case :gen_tcp_server.start_link(%{port: port}, ElixirTcp.EchoHandler, []) do + {:ok, _pid} -> + IO.puts("Echo server listening on port #{port}") + :ok + + {:error, reason} -> + IO.puts("Failed to start echo server: #{inspect(reason)}") + :error + end + end +end diff --git a/examples/elixir_tcp/lib/elixir_tcp/echo_handler.ex b/examples/elixir_tcp/lib/elixir_tcp/echo_handler.ex new file mode 100644 index 0000000..becf562 --- /dev/null +++ b/examples/elixir_tcp/lib/elixir_tcp/echo_handler.ex @@ -0,0 +1,25 @@ +defmodule ElixirTcp.EchoHandler do + @moduledoc """ + Simple echo handler for gen_tcp_server. + Echoes back any data received from the client. + """ + + @behaviour :gen_tcp_server + + @impl true + def init(_args) do + {:ok, %{}} + end + + @impl true + def handle_receive(_socket, packet, state) do + IO.puts("Received bytes: #{:erlang.iolist_size(packet)}") + {:reply, packet, state} + end + + @impl true + def handle_tcp_closed(_socket, state) do + IO.puts("Client disconnected") + state + end +end diff --git a/examples/elixir_tcp/mix.exs b/examples/elixir_tcp/mix.exs new file mode 100644 index 0000000..3ff387f --- /dev/null +++ b/examples/elixir_tcp/mix.exs @@ -0,0 +1,36 @@ +defmodule ElixirTcp.MixProject do + use Mix.Project + + def project do + [ + app: :elixir_tcp, + version: "0.1.0", + elixir: "~> 1.19", + start_permanent: Mix.env() == :prod, + deps: deps(), + atomvm: [ + # Change to Lux.Test for minimal testing without networking + start: ElixirTcp, + flash_offset: 0x250000 + ] + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:pythonx, "~> 0.4.7", runtime: false}, + {:exatomvm, github: "atomvm/ExAtomVM", runtime: false}, + {:atomvm_httpd, path: "../../"}, + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/examples/elixir_tcp/mix.lock b/examples/elixir_tcp/mix.lock new file mode 100644 index 0000000..011de3b --- /dev/null +++ b/examples/elixir_tcp/mix.lock @@ -0,0 +1,8 @@ +%{ + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "exatomvm": {:git, "https://github.com/atomvm/ExAtomVM.git", "0f9351417673d347d442c3283cca1bfeb47458f6", []}, + "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, + "pythonx": {:hex, :pythonx, "0.4.7", "604a3a78377abdaa8739c561cb871c856b0e80d25fd057277839912017004af0", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.2", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "20d8b456df995e6ccd6d88dcf118ba80464194515f71a5c89aacdb824d235c52"}, + "uf2tool": {:hex, :uf2tool, "1.1.0", "7091931234ca5a256b66ea691983867d51229798622d083f85c1ad779798a734", [:rebar3], [], "hexpm", "1a7e5ca7ef3d19c7a0b0acf3db804b3188e0980884acffa13fd57d733507b73d"}, +} diff --git a/examples/elixir_tcp/test/elixir_tcp_test.exs b/examples/elixir_tcp/test/elixir_tcp_test.exs new file mode 100644 index 0000000..1617d9e --- /dev/null +++ b/examples/elixir_tcp/test/elixir_tcp_test.exs @@ -0,0 +1,298 @@ +defmodule ElixirTcpTest do + use ExUnit.Case + + @moduledoc """ + TCP Echo Server stress tests. + + Configure the target server with environment variables: + TCP_TEST_HOST - IP address of the echo server (default: "127.0.0.1") + TCP_TEST_PORT - Port of the echo server (default: "9090") + + Example: + TCP_TEST_HOST=192.168.25.103 TCP_TEST_PORT=9090 mix test + """ + + @default_host "127.0.0.1" + @default_port 9090 + @connect_timeout 5_000 + @recv_timeout 5_000 + + setup_all do + host = + System.get_env("TCP_TEST_HOST", @default_host) + |> String.to_charlist() + + port = + System.get_env("TCP_TEST_PORT", "#{@default_port}") + |> String.to_integer() + + %{host: host, port: port} + end + + defp connect(host, port) do + :gen_tcp.connect(host, port, [:binary, active: false, packet: :raw], @connect_timeout) + end + + defp send_and_recv(socket, data) do + :ok = :gen_tcp.send(socket, data) + :gen_tcp.recv(socket, byte_size(data), @recv_timeout) + end + + describe "basic connectivity" do + test "connects to server", %{host: host, port: port} do + assert {:ok, socket} = connect(host, port) + :gen_tcp.close(socket) + end + + test "multiple sequential connections", %{host: host, port: port} do + for _ <- 1..10 do + {:ok, socket} = connect(host, port) + :gen_tcp.close(socket) + end + end + + test "handles connection close gracefully", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + :ok = :gen_tcp.close(socket) + + # Should be able to reconnect immediately + {:ok, socket2} = connect(host, port) + :gen_tcp.close(socket2) + end + end + + describe "echo functionality" do + test "echoes simple message", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + message = "Hello, World!" + assert {:ok, ^message} = send_and_recv(socket, message) + + :gen_tcp.close(socket) + end + + test "echoes binary data", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + # Binary with non-printable characters + message = <<0, 1, 2, 3, 255, 254, 253, 252>> + assert {:ok, ^message} = send_and_recv(socket, message) + + :gen_tcp.close(socket) + end + + test "echoes multiple messages on same connection", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + for i <- 1..20 do + message = "Message #{i}" + assert {:ok, ^message} = send_and_recv(socket, message) + end + + :gen_tcp.close(socket) + end + + test "echoes empty-ish message (single byte)", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + message = "x" + assert {:ok, ^message} = send_and_recv(socket, message) + + :gen_tcp.close(socket) + end + end + + describe "payload sizes" do + test "echoes small payload (100 bytes)", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + message = :crypto.strong_rand_bytes(100) + assert {:ok, ^message} = send_and_recv(socket, message) + + :gen_tcp.close(socket) + end + + test "echoes medium payload (1KB)", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + message = :crypto.strong_rand_bytes(1024) + :ok = :gen_tcp.send(socket, message) + + # May need to recv in chunks for larger payloads + {:ok, response} = recv_all(socket, byte_size(message)) + assert response == message + + :gen_tcp.close(socket) + end + + test "echoes large payload (10KB)", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + message = :crypto.strong_rand_bytes(10 * 1024) + :ok = :gen_tcp.send(socket, message) + + {:ok, response} = recv_all(socket, byte_size(message)) + assert response == message + + :gen_tcp.close(socket) + end + + @tag :slow + test "echoes very large payload (100KB)", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + message = :crypto.strong_rand_bytes(100 * 1024) + :ok = :gen_tcp.send(socket, message) + + {:ok, response} = recv_all(socket, byte_size(message), 30_000) + assert response == message + + :gen_tcp.close(socket) + end + end + + describe "concurrent connections" do + test "handles 5 concurrent connections", %{host: host, port: port} do + run_concurrent_echo_test(host, port, 5) + end + + test "handles 10 concurrent connections", %{host: host, port: port} do + run_concurrent_echo_test(host, port, 10) + end + + @tag :slow + test "handles 20 concurrent connections", %{host: host, port: port} do + run_concurrent_echo_test(host, port, 20) + end + end + + describe "stress tests" do + @tag :slow + test "rapid fire messages (100 messages)", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + for i <- 1..100 do + message = "Rapid #{i}" + assert {:ok, ^message} = send_and_recv(socket, message) + end + + :gen_tcp.close(socket) + end + + @tag :slow + test "sustained load (1000 small messages)", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + for i <- 1..1000 do + message = "Msg#{i}" + assert {:ok, ^message} = send_and_recv(socket, message) + end + + :gen_tcp.close(socket) + end + + @tag :slow + test "mixed payload sizes", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + sizes = [1, 10, 100, 500, 1000, 100, 10, 1] + + for size <- sizes do + message = :crypto.strong_rand_bytes(size) + :ok = :gen_tcp.send(socket, message) + {:ok, response} = recv_all(socket, size) + assert response == message + end + + :gen_tcp.close(socket) + end + + @tag :slow + test "connection churn (50 connect/disconnect cycles)", %{host: host, port: port} do + for i <- 1..50 do + {:ok, socket} = connect(host, port) + message = "Churn test #{i}" + assert {:ok, ^message} = send_and_recv(socket, message) + :gen_tcp.close(socket) + end + end + end + + describe "edge cases" do + test "handles newlines in message", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + message = "Line1\nLine2\r\nLine3\n" + assert {:ok, ^message} = send_and_recv(socket, message) + + :gen_tcp.close(socket) + end + + test "handles unicode", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + message = "Hello 世界 🌍 émoji" + assert {:ok, ^message} = send_and_recv(socket, message) + + :gen_tcp.close(socket) + end + + test "handles null bytes", %{host: host, port: port} do + {:ok, socket} = connect(host, port) + + message = "before\x00after" + assert {:ok, ^message} = send_and_recv(socket, message) + + :gen_tcp.close(socket) + end + end + + # Helper functions + + defp recv_all(socket, expected_size, timeout \\ @recv_timeout) do + recv_all(socket, expected_size, <<>>, timeout) + end + + defp recv_all(_socket, expected_size, acc, _timeout) when byte_size(acc) >= expected_size do + {:ok, acc} + end + + defp recv_all(socket, expected_size, acc, timeout) do + remaining = expected_size - byte_size(acc) + + case :gen_tcp.recv(socket, 0, timeout) do + {:ok, data} -> + recv_all(socket, expected_size, acc <> data, timeout) + + {:error, :timeout} when byte_size(acc) > 0 -> + # Return what we have if we timeout with partial data + {:ok, acc} + + error -> + error + end + end + + defp run_concurrent_echo_test(host, port, num_connections) do + parent = self() + + tasks = + for i <- 1..num_connections do + Task.async(fn -> + {:ok, socket} = connect(host, port) + + for j <- 1..10 do + message = "Conn#{i}-Msg#{j}" + {:ok, response} = send_and_recv(socket, message) + assert response == message + end + + :gen_tcp.close(socket) + :ok + end) + end + + results = Task.await_many(tasks, 30_000) + assert Enum.all?(results, &(&1 == :ok)) + end +end diff --git a/examples/elixir_tcp/test/test_helper.exs b/examples/elixir_tcp/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/examples/elixir_tcp/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/examples/erlang_http/.gitignore b/examples/erlang_http/.gitignore new file mode 100644 index 0000000..45e12e5 --- /dev/null +++ b/examples/erlang_http/.gitignore @@ -0,0 +1,3 @@ +src/config.erl +_build/ +.DS_Store \ No newline at end of file diff --git a/examples/erlang_http/LICENSE b/examples/erlang_http/LICENSE new file mode 100644 index 0000000..efdacd2 --- /dev/null +++ b/examples/erlang_http/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2020, Fred Dushin . + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/erlang_http/README.md b/examples/erlang_http/README.md new file mode 100644 index 0000000..9e119ef --- /dev/null +++ b/examples/erlang_http/README.md @@ -0,0 +1,7 @@ +# atomvm_app + +Welcome to the atomvm_app AtomVM application. + +To build and flash this application to your ESP32 device, issue the `esp32_flash` target + + shell$ rebar3 esp32_flash diff --git a/examples/erlang_http/priv/favicon.ico b/examples/erlang_http/priv/favicon.ico new file mode 100755 index 0000000..b716333 Binary files /dev/null and b/examples/erlang_http/priv/favicon.ico differ diff --git a/examples/erlang_http/priv/index.html b/examples/erlang_http/priv/index.html new file mode 100755 index 0000000..1cbb790 --- /dev/null +++ b/examples/erlang_http/priv/index.html @@ -0,0 +1,195 @@ + + + + + + + + AtomVM Web Console + + + +
+ + + + diff --git a/examples/erlang_http/priv/js/app.js b/examples/erlang_http/priv/js/app.js new file mode 100755 index 0000000..37d3224 --- /dev/null +++ b/examples/erlang_http/priv/js/app.js @@ -0,0 +1,141 @@ +// +// # ---------------------------------------------------------------------------- +// # "THE BEER-WARE LICENSE" (Revision 42): +// # wrote this file. You are hereby granted permission to +// # copy, modify, or mutilate this file without restriction. If you create a +// # work derived from this file, you may optionally include a copy of this notice, +// # for which I would be most grateful, but you are not required to do so. +// # If we meet some day, and you think this stuff is worth it, you can buy me a +// # beer in return. Fred Dushin +// # ---------------------------------------------------------------------------- +// + +// Vanilla JS implementation for AtomVM Web Console + +// --- System Info Fetch and Render --- +function fetchSystemInfo() { + fetch('/api/system_info') + .then(function(response) { return response.json(); }) + .then(function(data) { + // If esp32_chip_info exists, flatten it + if (data.esp32_chip_info) { + data.chip_model = data.esp32_chip_info.model; + data.chip_cores = data.esp32_chip_info.cores; + data.chip_features = data.esp32_chip_info.features; + data.chip_revision = data.esp32_chip_info.revision; + } + renderSystemInfo(data); + }) + .catch(function(err) { + console.log(err) + document.getElementById('system-view').innerHTML = '
Failed to load system info
'; + }); +} + +function renderSystemInfo(data) { + var html = ` + + + + + + + + + +
platform${data.platform ?? ''}
system_architecture${data.system_architecture ?? ''}
word_size${data.word_size ?? ''}
chip_model${data.chip_model ?? ''}
chip_cores${data.chip_cores ?? ''}
chip_features${data.chip_features ?? ''}
chip_revision${data.chip_revision ?? ''}
atomvm_version${data.atomvm_version ?? ''}
esp_idf_version${data.esp_idf_version ?? ''}
`; + document.getElementById('system-view').innerHTML = html; +} + +// --- Memory Info Fetch and Render --- +let memoryData = {}; +function fetchMemoryInfo() { + fetch('/api/memory') + .then(function(response) { return response.json(); }) + .then(function(data) { + memoryData = data; + renderMemoryInfo(data); + }) + .catch(function(err) { + document.getElementById('memory-view').innerHTML = '
Failed to load memory info
'; + }); +} + +function renderMemoryInfo(data) { + var html = ` + + + + + + +
atom_count${data.atom_count ?? ''}
port_count${data.port_count ?? ''}
process_count${data.process_count ?? ''}
esp32_free_heap_size${data.esp32_free_heap_size ?? ''}
esp32_largest_free_block${data.esp32_largest_free_block ?? ''}
esp32_minimum_free_size${data.esp32_minimum_free_size ?? ''}
`; + document.getElementById('memory-view').innerHTML = html; +} + +// --- WebSocket for live memory updates --- +function getWebSocketUrl() { + var hostname = window.location.hostname; + var port = window.location.port; + return "ws://" + hostname + (port ? ":" + port : "") + "/ws"; +} + +function createWebSocket() { + var ws = new window.WebSocket(getWebSocketUrl()); + ws.onmessage = function(event) { + try { + var data = JSON.parse(event.data); + // Only update known memory fields + let changed = false; + for (var key in data) { + if (Object.prototype.hasOwnProperty.call(memoryData, key) && data[key] !== memoryData[key]) { + memoryData[key] = data[key]; + changed = true; + } + } + if (changed) { + renderMemoryInfo(memoryData); + } + } catch (e) { + // ignore parse errors + } + }; + ws.onopen = function(event) { + console.log("WebSocket opened"); + }; + ws.onclose = function(event) { + console.log("WebSocket closed, reconnecting..."); + setTimeout(createWebSocket, 2000); + }; + ws.onerror = function(event) { + console.log("WebSocket error"); + }; + return ws; +} + +// --- Initialization --- +document.addEventListener('DOMContentLoaded', function() { + // Hide loader and show content after 1s + setTimeout(function() { + var loader = document.getElementById('loader'); + var content = document.getElementById('content'); + if (loader) loader.style.display = 'none'; + if (content) content.style.display = 'block'; + }, 1000); + + // Tab switching logic + var tabBtns = document.querySelectorAll('.tab-btn'); + tabBtns.forEach(function(btn) { + btn.addEventListener('click', function() { + tabBtns.forEach(function(b) { b.classList.remove('active'); }); + document.querySelectorAll('.panel').forEach(function(p) { p.classList.remove('active'); }); + btn.classList.add('active'); + var panel = document.getElementById(btn.getAttribute('data-panel')); + if (panel) panel.classList.add('active'); + }); + }); + + fetchSystemInfo(); + fetchMemoryInfo(); + createWebSocket(); +}); diff --git a/examples/erlang_http/rebar.config b/examples/erlang_http/rebar.config new file mode 100644 index 0000000..dd7e423 --- /dev/null +++ b/examples/erlang_http/rebar.config @@ -0,0 +1,11 @@ +{erl_opts, [debug_info]}. +{deps, [ + {atomvm_httpd, {git, "https://github.com/harmon25/atomvm_httpd.git", {branch, "example"}}} +]}. +{plugins, [ + rebar_mix, + atomvm_rebar3_plugin +]}. +{atomvm_rebar3_plugin, [ + {packbeam, [prune]} +]}. diff --git a/examples/erlang_http/rebar.lock b/examples/erlang_http/rebar.lock new file mode 100644 index 0000000..83df9d0 --- /dev/null +++ b/examples/erlang_http/rebar.lock @@ -0,0 +1,4 @@ +[{<<"atomvm_httpd">>, + {git,"https://github.com/harmon25/atomvm_httpd.git", + {ref,"0eb8ff00cf9d8d8816101a37d8c79825a69ede13"}}, + 0}]. diff --git a/examples/erlang_http/src/config.erl-template b/examples/erlang_http/src/config.erl-template new file mode 100644 index 0000000..db30cbd --- /dev/null +++ b/examples/erlang_http/src/config.erl-template @@ -0,0 +1,32 @@ +% +% This file is part of AtomVM. +% +% Copyright 2022 Fred Dushin +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +% +-module(config). + +-export([get/0]). + +%% Copy this file to config.erl and edit to your satisfaction + +get() -> + #{ + sta => [ + {ssid, "myssid"}, + {psk, "mypassword"} + ] + }. diff --git a/examples/erlang_http/src/httpd_example.app.src b/examples/erlang_http/src/httpd_example.app.src new file mode 100644 index 0000000..2fae819 --- /dev/null +++ b/examples/erlang_http/src/httpd_example.app.src @@ -0,0 +1,12 @@ +{application, httpd_example, [ + {description, "An OTP library"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [ + kernel, stdlib + ]}, + {env,[]}, + {modules, []}, + {licenses, ["Apache 2.0"]}, + {links, []} + ]}. diff --git a/examples/erlang_http/src/httpd_example.erl b/examples/erlang_http/src/httpd_example.erl new file mode 100644 index 0000000..f9879a7 --- /dev/null +++ b/examples/erlang_http/src/httpd_example.erl @@ -0,0 +1,242 @@ +% +% Copyright 2022 Fred Dushin +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +% + +-module(httpd_example). + +-behavior(httpd_api_handler). +-export([start/0, handle_api_request/4, handle_ws_init/3, handle_ws_message/2]). + +-export([init_handler/2, handle_http_req/2]). + +start() -> + timer:sleep(5000), + + ok = maybe_start_network(atomvm:platform()), + + timer:sleep(5000), + + + Config = [ + {[<<"api">>], #{ + handler => httpd_api_handler, + handler_config => #{ + module => ?MODULE + } + }}, + {[<<"ws">>], #{ + handler => httpd_ws_handler, + handler_config => #{ + module => ?MODULE + } + }}, + {[<<"ota">>], #{ + handler => ?MODULE + }}, + {[], #{ + handler => httpd_file_handler, + handler_config => #{ + app => ?MODULE + } + }} + ], + + io:format("Starting httpd on port 8080 ...~n", []), + case httpd:start(8080, Config) of + {ok, _Pid} -> + io:format("httpd started.~n", []), + timer:sleep(infinity); + Error -> + io:format("An error occurred: ~p~n", [Error]) + end. + +%% +%% API Handler implementation +%% + +handle_api_request(get, [<<"system_info">>], HttpRequest, _Args) -> + Socket = maps:get(socket, HttpRequest), + {ok, #{addr := Host, port := Port}} = socket:peername(Socket), + io:format("GET system_info request from ~p:~p~n", [Host, Port]), + {ok, #{ + platform => atomvm:platform() + % word_size => erlang:system_info(wordsize), + % system_architecture => erlang:system_info(system_architecture), + % atomvm_version => erlang:system_info(atomvm_version), + % esp32_chip_info => get_esp32_chip_info(), + % esp_idf_version => + % case erlang:system_info(esp_idf_version) of + % undefined -> "n/a"; + % Version -> list_to_binary(Version) + % end + }}; +handle_api_request(get, [<<"memory">>], HttpRequest, _Args) -> + Socket = maps:get(socket, HttpRequest), + {ok, #{addr := Host, port := Port}} = socket:peername(Socket), + io:format("GET memory request from ~p:~p~n", [Host, Port]), + {ok, get_memory_data()}; +handle_api_request(Method, Path, _HttpRequest, _Args) -> + io:format("ERROR! Unsupported method ~p or path ~p~n", [Method, Path]), + not_found. + +get_memory_data() -> + #{ + atom_count => erlang:system_info(atom_count), + process_count => erlang:system_info(process_count), + % port_count => erlang:system_info(port_count), + esp32_free_heap_size => erlang:system_info(esp32_free_heap_size), + esp32_largest_free_block => erlang:system_info(esp32_largest_free_block), + esp32_minimum_free_size => erlang:system_info(esp32_minimum_free_size) + }. + +%% +%% HTTP request handler implementation (for OTA) +%% + +-define(TARGET_PARTITION, <<"app2.avm">>). +-define(ATOMVM_NAMESPACE, atomvm). +-define(BOOT_TARGET_PARTITION_KEY, boot_partition). + +-record(state, { + offset = 0 +}). + +%% @hidden +init_handler(_PathPrefix, _HandlerConfig) -> + {ok, #state{}}. + +%% @hidden +handle_http_req(#{method := post} = HttpRequest, State) -> + #{ + headers := Headers, + body := Body + } = HttpRequest, + Offset = State#state.offset, + case Offset of + 0 -> + io:format("Erasing partition ~p~n", [?TARGET_PARTITION]), + esp:partition_erase_range(?TARGET_PARTITION, 0); + _ -> + ok + end, + BodyLen = erlang:byte_size(Body), + NewOffset = Offset + BodyLen, + ContentLength = get_content_length(Headers), + case NewOffset < ContentLength of + true -> + io:format("Offset: ~p ContentLength: ~p BodyLen: ~p~n", [Offset, ContentLength, BodyLen]), + ok = esp:partition_write(?TARGET_PARTITION, Offset, Body), + io:format("Wrote ~p bytes at offset ~p to partition ~p.~n", [BodyLen, Offset, ?TARGET_PARTITION]), + NewState = State#state{offset = NewOffset}, + {noreply, NewState}; + false -> + io:format("Request complete.~n"), + ok = esp:partition_write(?TARGET_PARTITION, Offset, Body), + io:format("Wrote ~p bytes at offset ~p to partition ~p.~n", [BodyLen, Offset, ?TARGET_PARTITION]), + ok = esp:nvs_set_binary(?ATOMVM_NAMESPACE, ?BOOT_TARGET_PARTITION_KEY, ?TARGET_PARTITION), + io:format("Set boot partition to ~p~n", [?TARGET_PARTITION]), + {close, <<"ok">>} + end; +handle_http_req(_HttpRequest, _State) -> + {error, internal_server_error}. + +get_content_length(Headers) -> + %% TODO handle case + erlang:binary_to_integer(maps:get(<<"Content-Length">>, Headers, <<"0">>)). + +%% +%% WebSocket handlers +%% + +handle_ws_init(WebSocket, _Path, _Args) -> + io:format("Initializing websocket pid=~p~n", [self()]), + spawn(fun() -> update_loop(WebSocket, get_memory_data()) end), + {ok, undefined}. + +handle_ws_message(<<"ping">>, State) -> + {reply, <<"pong">>, State}; +handle_ws_message(Message, State) -> + io:format("Received message from web socket. Message: ~p~n", [Message]), + {noreply, State}. + +update_loop(WebSocket, LastMemoryData) -> + LatestMemoryData = get_memory_data(), + timer:sleep(5000), + % erlang:garbage_collect(), + NewMemoryData = get_difference(LastMemoryData, LatestMemoryData), + case NewMemoryData of + [] -> + ok; + _ -> + Binary = iolist_to_binary(json_encoder:encode(NewMemoryData)), + io:format("Sending websocket message to client ~p ... ", [Binary]), + httpd_ws_handler:send(WebSocket, Binary), + io:format("sent.~n") + end, + update_loop(WebSocket, LatestMemoryData). + +%% +%% Internal functions +%% +%% +get_difference(Map1, Map2) -> + maps:fold( + fun(Key, Value, Accum) -> + case maps:get(Key, Map2, undefined) of + undefined -> + [{Key, Value} | Accum]; + Value -> + Accum; + NewValue -> + [{Key, NewValue} | Accum] + end + end, + [], + Map1 + ). + +get_esp32_chip_info() -> + case erlang:system_info(esp32_chip_info) of + Info when is_map(Info) -> + maps:to_list(Info); + _ -> + [{features, undefined}, {cores, undefined}, {revision, undefined}, {model, undefined}] + end. + +%% @private +maybe_start_network(esp32) -> + Config = maps:get(sta, config:get()), + case network:wait_for_sta(Config, 30000) of + {ok, {Address, Netmask, Gateway}} -> + io:format( + "Acquired IP address: ~p Netmask: ~p Gateway: ~p~n", + [Address, Netmask, Gateway] + ), + ok; + + {error, disconnected} -> + maybe_start_network(esp32); + + {error,{already_started,_}} -> + ok; + + Error -> + io:format("An error occurred starting network: ~p~n", [Error]), + Error + end; +maybe_start_network(_Platform) -> + ok. diff --git a/src/gen_tcp_server.erl b/src/gen_tcp_server.erl index 8acaf10..5b393a5 100644 --- a/src/gen_tcp_server.erl +++ b/src/gen_tcp_server.erl @@ -47,10 +47,31 @@ addr => any }). -define(DEFAULT_SOCKET_OPTIONS, #{ - {socket, reuseaddr} => true + {socket, reuseaddr} => true, + %% Linger: wait up to 5 seconds for data to be sent before closing + {socket, linger} => #{onoff => true, linger => 5} }). %% Smaller chunks work better with lwIP's limited buffers --define(MAX_SEND_CHUNK, 1460). %% TCP MSS - fits in single packet without fragmentation +%% ESP32 lwIP has small TX buffers - smaller chunks reduce buffer pressure +-define(MAX_SEND_CHUNK, 1024). + +%% Socket send retry delay (ms). In tests we override to avoid slowing the suite. +%% On embedded platforms, a small delay between chunks helps lwIP drain buffers. +-define(SEND_RETRY_DELAY_MS, 10). + +%% Inter-chunk delay (ms) to allow lwIP to drain TX buffers on ESP32. +%% Set to 0 for tests on fast host systems. +-define(INTER_CHUNK_DELAY_MS, 5). + +-ifdef(TEST). +-undef(SEND_RETRY_DELAY_MS). +-define(SEND_RETRY_DELAY_MS, 0). +-undef(INTER_CHUNK_DELAY_MS). +-define(INTER_CHUNK_DELAY_MS, 0). + +%% Expose a test seam to validate partial-send handling without needing a real socket. +-export([try_send_binary_fun/2]). +-endif. %% %% API @@ -128,43 +149,31 @@ handle_info({tcp_closed, Socket}, State) -> #state{handler=Handler, handler_state=HandlerState} = State, NewHandlerState = Handler:handle_tcp_closed(Socket, HandlerState), {noreply, State#state{handler_state=NewHandlerState}}; -handle_info({tcp, Socket, Packet}, State) -> +handle_info({tcp, Socket, Packet, LoopPid}, State) -> #state{handler=Handler, handler_state=HandlerState} = State, ?TRACE("received packet: len(~p) from ~p", [erlang:byte_size(Packet), socket:peername(Socket)]), case Handler:handle_receive(Socket, Packet, HandlerState) of {reply, ResponsePacket, ResponseState} -> ?TRACE("Sending reply to endpoint ~p", [socket:peername(Socket)]), - case try_send(Socket, ResponsePacket) of - ok -> - {noreply, State#state{handler_state=ResponseState}}; - {error, closed} -> - ?TRACE("Connection closed during send, cleaning up", []), - {noreply, State#state{handler_state=ResponseState}}; - {error, _Reason} -> - try_close(Socket), - {noreply, State#state{handler_state=ResponseState}} - end; + %% Send response back to loop process for sending + LoopPid ! {send, ResponsePacket, keep_open}, + {noreply, State#state{handler_state=ResponseState}}; {noreply, ResponseState} -> ?TRACE("no reply", []), + LoopPid ! continue, {noreply, State#state{handler_state=ResponseState}}; {close, ResponsePacket} -> ?TRACE("Sending reply to endpoint ~p and closing socket: ~p", [socket:peername(Socket), Socket]), - case try_send(Socket, ResponsePacket) of - ok -> - try_close(Socket); - {error, closed} -> - ok; %% Already closed, nothing to do - {error, _Reason} -> - try_close(Socket) - end, + %% Send response back to loop process for sending, then close + LoopPid ! {send, ResponsePacket, close}, {noreply, State}; close -> ?TRACE("Closing socket ~p", [Socket]), - try_close(Socket), + LoopPid ! close, {noreply, State}; _SomethingElse -> ?TRACE("Unexpected response from handler ~p: ~p", [Handler, _SomethingElse]), - try_close(Socket), + LoopPid ! close, {noreply, State} end; handle_info(Info, State) -> @@ -189,12 +198,14 @@ try_send(Socket, Packet) when is_binary(Packet) -> try_send(Socket, Byte) when is_integer(Byte) -> %% Handles bytes (0-255) in iolists. Unicode must be pre-encoded to UTF-8. ?TRACE("Sending byte ~p as ~p", [Byte, <>]), - try_send(Socket, <>); + try_send_binary(Socket, <>); try_send(Socket, List) when is_list(List) -> case is_string(List) of true -> - try_send(Socket, list_to_binary(List)); - _ -> + %% It's a string (list of integers), convert to binary + try_send_binary(Socket, list_to_binary(List)); + false -> + %% It's an iolist, send each element try_send_iolist(Socket, List) end. @@ -211,37 +222,73 @@ try_send_iolist(Socket, [H | T]) -> try_send_binary(_Socket, <<>>) -> ok; try_send_binary(Socket, Packet) when is_binary(Packet) -> + try_send_binary_fun(fun(Chunk) -> socket:send(Socket, Chunk) end, Packet). + +%% @private +%% Same send loop but with an injectable send fun for testing. +%% SendFun must return the same shapes as socket:send/2. +try_send_binary_fun(_SendFun, <<>>) -> + ok; +try_send_binary_fun(SendFun, Packet) when is_function(SendFun, 1), is_binary(Packet) -> TotalSize = byte_size(Packet), ChunkSize = erlang:min(TotalSize, ?MAX_SEND_CHUNK), <> = Packet, - case socket:send(Socket, Chunk) of + case SendFun(Chunk) of ok -> - %% Give the scheduler a chance to run and let TCP drain - maybe_yield(Rest), - try_send_binary(Socket, Rest); - {ok, Remaining} -> - %% Partial send - combine remaining with rest and retry - try_send_binary(Socket, <>); - {error, closed} -> - %% Only log if we actually had more data to send - case byte_size(Rest) of - 0 -> ok; %% Sent everything, client just closed after - that's fine - _ -> io:format("Connection closed mid-transfer (~p/~p bytes sent)~n", - [ChunkSize, TotalSize]) + %% Small delay between chunks to let lwIP drain TX buffer + case Rest of + <<>> -> ok; + _ -> receive after ?INTER_CHUNK_DELAY_MS -> ok end end, + try_send_binary_fun(SendFun, Rest); + {ok, Remaining} when is_binary(Remaining) -> + %% Partial send - AtomVM may return the remaining (unsent) bytes as a binary. + case try_send_binary_fun(SendFun, Remaining) of + ok -> try_send_binary_fun(SendFun, Rest); + Error -> Error + end; + {ok, SentBytes} when is_integer(SentBytes), SentBytes >= 0 -> + %% Partial send - some implementations return the count of bytes actually sent. + %% Retry sending the unsent tail of this Chunk. + case SentBytes of + 0 -> + %% No progress; treat like wouldblock and retry. + receive after ?SEND_RETRY_DELAY_MS -> ok end, + try_send_binary_fun(SendFun, Packet); + _ when SentBytes < byte_size(Chunk) -> + UnsentBytes = byte_size(Chunk) - SentBytes, + <<_Sent:SentBytes/binary, Unsent:UnsentBytes/binary>> = Chunk, + case try_send_binary_fun(SendFun, Unsent) of + ok -> try_send_binary_fun(SendFun, Rest); + Error -> Error + end; + _ when SentBytes =:= byte_size(Chunk) -> + try_send_binary_fun(SendFun, Rest); + _ -> + io:format("socket:send unexpected partial result: ~p~n", [SentBytes]), + {error, badarg} + end; + {error, eagain} -> + %% Socket buffer full, brief pause and retry + receive after ?SEND_RETRY_DELAY_MS -> ok end, + try_send_binary_fun(SendFun, Packet); + {error, ewouldblock} -> + receive after ?SEND_RETRY_DELAY_MS -> ok end, + try_send_binary_fun(SendFun, Packet); + {error, timeout} -> + receive after ?SEND_RETRY_DELAY_MS -> ok end, + try_send_binary_fun(SendFun, Packet); + {error, closed} -> + io:format("socket:send error: closed~n"), {error, closed}; + {error, ebadf} -> + io:format("socket:send error: ebadf~n"), + {error, ebadf}; {error, Reason} -> - io:format("Send error: ~p (chunk: ~p, total: ~p)~n", - [Reason, ChunkSize, TotalSize]), + io:format("socket:send error: ~p~n", [Reason]), {error, Reason} end. -%% Lightweight yield using receive timeout - works in AtomVM -maybe_yield(<<>>) -> - ok; -maybe_yield(_) -> - receive after 0 -> ok end. - is_string([]) -> true; is_string([H | T]) when is_integer(H) -> @@ -258,6 +305,16 @@ try_close(Socket) -> io:format("Close failed due to error ~p~n", [Error]) end. +%% @private +graceful_close(ControllingProcess, Socket) -> + %% Shutdown write side to signal we're done sending, then close. + %% Give lwIP a moment to flush final data before closing. + _ = socket:shutdown(Socket, write), + receive after 10 -> ok end, + try_close(Socket), + ControllingProcess ! {tcp_closed, Socket}, + ok. + %% @private set_socket_options(Socket, SocketOptions) -> maps:fold( @@ -289,13 +346,32 @@ loop(ControllingProcess, Connection) -> case socket:recv(Connection) of {ok, Data} -> ?TRACE("Received data ~p on connection ~p", [Data, Connection]), - ControllingProcess ! {tcp, Connection, Data}, - loop(ControllingProcess, Connection); + ControllingProcess ! {tcp, Connection, Data, self()}, + %% Wait for response from gen_server + receive + {send, ResponsePacket, Action} -> + case try_send(Connection, ResponsePacket) of + ok -> + case Action of + keep_open -> loop(ControllingProcess, Connection); + close -> graceful_close(ControllingProcess, Connection) + end; + {error, _} -> + try_close(Connection), + ControllingProcess ! {tcp_closed, Connection} + end; + continue -> + loop(ControllingProcess, Connection); + close -> + try_close(Connection), + ControllingProcess ! {tcp_closed, Connection} + end; {error, closed} -> - ?TRACE("Peer closed connection ~p", [Connection]), + try_close(Connection), ControllingProcess ! {tcp_closed, Connection}, ok; - {error, _SomethingElse} -> - ?TRACE("Some other error occurred ~p: ~p", [Connection, _SomethingElse]), - try_close(Connection) + {error, _Reason} -> + try_close(Connection), + ControllingProcess ! {tcp_closed, Connection}, + ok end. diff --git a/src/httpd.erl b/src/httpd.erl index 4d1dc76..a43575e 100644 --- a/src/httpd.erl +++ b/src/httpd.erl @@ -286,27 +286,27 @@ call_http_req_handler(Socket, HttpRequest, State) -> {noreply, NewState}; %% reply {reply, Reply, NewHandlerState} -> - NewState = update_state(Socket, HttpRequest, NewHandlerState, State), - {reply, create_reply(?OK, #{"Content-Type" => "application/octet-stream"}, Reply), NewState}; + _ = NewHandlerState, + {reply, create_reply(?OK, #{"Content-Type" => "application/octet-stream"}, Reply), State}; {reply, ReplyHeaders, Reply, NewHandlerState} -> - NewState = update_state(Socket, HttpRequest, NewHandlerState, State), - {reply, create_reply(?OK, ReplyHeaders, Reply), NewState}; + _ = NewHandlerState, + {reply, create_reply(?OK, ReplyHeaders, Reply), State}; %% close close -> {close, State}; {close, Reply} -> - {close, create_reply(?OK, #{"Content-Type" => "application/octet-stream"}, Reply)}; + {close, create_close_reply(?OK, #{"Content-Type" => "application/octet-stream"}, Reply)}; {close, ReplyHeaders, Reply} -> - {close, create_reply(?OK, ReplyHeaders, Reply)}; + {close, create_close_reply(?OK, ReplyHeaders, Reply)}; %% errors {error, not_found} -> - {close, create_error(?NOT_FOUND, not_found)}; + {close, create_close_reply(?NOT_FOUND, "text/html", error_body(?NOT_FOUND, not_found))}; {error, bad_request} -> - {close, create_error(?BAD_REQUEST, bad_request)}; + {close, create_close_reply(?BAD_REQUEST, "text/html", error_body(?BAD_REQUEST, bad_request))}; {error, internal_server_error} -> - {close, create_error(?INTERNAL_SERVER_ERROR, internal_server_error)}; + {close, create_close_reply(?INTERNAL_SERVER_ERROR, "text/html", error_body(?INTERNAL_SERVER_ERROR, internal_server_error))}; HandlerError -> - {close, create_error(?INTERNAL_SERVER_ERROR, HandlerError)} + {close, create_close_reply(?INTERNAL_SERVER_ERROR, "text/html", error_body(?INTERNAL_SERVER_ERROR, HandlerError))} end. %% @private @@ -575,6 +575,19 @@ create_error(StatusCode, Error) -> io:format("error in httpd. StatusCode=~p Error=~p~n", [StatusCode, Error]), create_reply(StatusCode, "text/html", ErrorString). +%% @private +error_body(StatusCode, Error) -> + io:format("error in httpd. StatusCode=~p Error=~p~n", [StatusCode, Error]), + io_lib:format("Error: ~p", [Error]). + +%% @private +create_close_reply(StatusCode, ContentType, Reply) when is_list(ContentType) orelse is_binary(ContentType) -> + create_close_reply(StatusCode, #{"Content-Type" => ContentType}, Reply); +create_close_reply(StatusCode, Headers, Reply) when is_map(Headers) -> + %% Add Connection: close header to signal the client we're closing + HeadersWithClose = Headers#{"Connection" => "close"}, + create_reply(StatusCode, HeadersWithClose, Reply). + %% @private create_reply(StatusCode, ContentType, Reply) when is_list(ContentType) orelse is_binary(ContentType) -> create_reply(StatusCode, #{"Content-Type" => ContentType}, Reply); diff --git a/src/httpd_file_handler.erl b/src/httpd_file_handler.erl index cdd4c0d..a7b4201 100644 --- a/src/httpd_file_handler.erl +++ b/src/httpd_file_handler.erl @@ -36,7 +36,7 @@ init_handler(PathSuffix, HandlerConfig) -> {ok, #state{path_suffix = PathSuffix, handler_config = HandlerConfig}}. %% @hidden -handle_http_req(#{method := get} = _HttpRequest, State) -> +handle_http_req(#{method := get} = HttpRequest, State) -> HandlerConfig = State#state.handler_config, App = maps:get(app, HandlerConfig), PathSuffix = State#state.path_suffix, @@ -47,15 +47,60 @@ handle_http_req(#{method := get} = _HttpRequest, State) -> end, FullPath = join("/", lists:reverse(ResolvedPath)), ?TRACE("App: ~p PathSuffix: ~p FullPath: ~p", [App, ResolvedPath, FullPath]), + %% Check if client accepts gzip encoding + AcceptEncoding = get_accept_encoding(HttpRequest), + AcceptsGzip = accepts_gzip(AcceptEncoding), + try + serve_file(App, FullPath, ResolvedPath, AcceptsGzip) + catch + _:Reason -> + io:format("httpd_file_handler: error reading file ~p: ~p~n", [FullPath, Reason]), + {error, internal_server_error} + end; +handle_http_req(_HttpRequest, _HandlerConfig) -> + {error, internal_server_error}. + +%% @private +%% Try to serve gzipped version if available and client accepts it +serve_file(App, FullPath, ResolvedPath, true = _AcceptsGzip) -> + GzPath = FullPath ++ ".gz", + case atomvm:read_priv(App, GzPath) of + undefined -> + %% No gzipped version, serve original + serve_file(App, FullPath, ResolvedPath, false); + Data when is_binary(Data) -> + %% Serve gzipped version with Content-Encoding header + ContentType = get_content_type(lists:reverse(ResolvedPath)), + {close, #{"Content-Type" => ContentType, "Content-Encoding" => "gzip"}, Data} + end; +serve_file(App, FullPath, ResolvedPath, false = _AcceptsGzip) -> case atomvm:read_priv(App, FullPath) of undefined -> - io:format("httpd_file_handler: file not found - app=~p path=~p~n", [App, FullPath]), {error, not_found}; - Data -> + Data when is_binary(Data) -> {close, #{"Content-Type" => get_content_type(lists:reverse(ResolvedPath))}, Data} + end. + +%% @private +get_accept_encoding(#{headers := Headers}) -> + %% Try various header name formats + case maps:get(<<"Accept-Encoding">>, Headers, undefined) of + undefined -> + case maps:get(<<"accept-encoding">>, Headers, undefined) of + undefined -> <<>>; + Val -> Val + end; + Val -> Val end; -handle_http_req(_HttpRequest, _HandlerConfig) -> - {error, internal_server_error}. +get_accept_encoding(_) -> + <<>>. + +%% @private +accepts_gzip(AcceptEncoding) when is_binary(AcceptEncoding) -> + %% Simple check - look for "gzip" in the Accept-Encoding header + binary:match(AcceptEncoding, <<"gzip">>) =/= nomatch; +accepts_gzip(_) -> + false. %% @private join(Separator, Path) -> @@ -114,10 +159,23 @@ get_content_type([Filename|_]) -> "application/octet-stream" end. -get_suffix(Filename) -> - case string:split(binary_to_list(Filename), ".") of - [_Basename, Suffix] -> - Suffix; - _ -> - undefined +get_suffix(Filename) when is_binary(Filename) -> + get_suffix(binary_to_list(Filename)); +get_suffix(Filename) when is_list(Filename) -> + %% Find the last '.' and return everything after it + case lists:reverse(Filename) of + [] -> undefined; + Reversed -> + case take_until_dot(Reversed, []) of + undefined -> undefined; + Suffix -> Suffix + end end. + +%% Take characters until we hit a dot, return the suffix +take_until_dot([], _Acc) -> + undefined; +take_until_dot([$. | _Rest], Acc) -> + Acc; +take_until_dot([C | Rest], Acc) -> + take_until_dot(Rest, [C | Acc]). diff --git a/src/json_encoder.erl b/src/json_encoder.erl new file mode 100644 index 0000000..3f015fb --- /dev/null +++ b/src/json_encoder.erl @@ -0,0 +1,108 @@ +% +% This file is part of AtomVM. +% +% Copyright 2018-2022 Davide Bettio +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +% + +% +% This file is part of AtomVM. +% +% Copyright 2019 Davide Bettio +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +% + +-module(json_encoder). +-export([encode/1]). + +encode(false) -> + <<"false">>; +encode(true) -> + <<"true">>; +encode(nil) -> + <<"nil">>; +encode(null) -> + <<"null">>; +encode(undefined) -> + <<"null">>; +encode(Value) when is_atom(Value) -> + [$", erlang:atom_to_binary(Value, utf8), $"]; +encode(Value) when is_binary(Value) -> + [$", Value, $"]; +encode(Value) when is_float(Value) -> + erlang:float_to_binary(Value, [{decimals, 32}, compact]); +encode(Value) when is_integer(Value) -> + erlang:integer_to_binary(Value); +encode(Value) when is_map(Value) -> + encode_map(Value); +encode(V) when is_list(V) -> + encode(V, []); +encode(V) -> + encode(V, []). + +encode([{_K, _V} | _T] = L, []) -> + encode(L, ${); +encode([{Key, Value} | []], Acc) -> + Encoded = [$", encode_key(Key), "\": ", encode(Value), $}], + [Acc | Encoded]; +encode([{Key, Value} | Tail], Acc) -> + Encoded = [$", encode_key(Key), "\": ", encode(Value), $,], + encode(Tail, [Acc | Encoded]); +encode([_V | _T] = L, []) -> + encode(L, $[); +encode([Value | []], Acc) -> + Encoded = [encode(Value), $]], + [Acc | Encoded]; +encode([Value | Tail], Acc) -> + Encoded = [encode(Value), $,], + encode(Tail, [Acc | Encoded]). + +encode_key(Key) when is_atom(Key) -> + erlang:atom_to_binary(Key, utf8); +encode_key(Key) when is_binary(Key) -> + Key. + + + +encode_map(Map) -> + iterate_entries(maps:next(maps:iterator(Map)), 0, "{"). + +%% NB. Output is an iolist, so try to avoid gratuitous copying of data +iterate_entries(none, _K, Accum) -> + [[Accum] | [$}]]; +iterate_entries({Key, Value, Iterator}, K, Accum) -> + MaybeComma = + case K of + 0 -> + ""; + _ -> + ", " + end, + Encoded = [MaybeComma, $", encode_key(Key), "\": ", encode(Value)], + iterate_entries(maps:next(Iterator), K + 1, [[Accum] | Encoded]). \ No newline at end of file diff --git a/test/json_encoder_test.exs b/test/json_encoder_test.exs new file mode 100644 index 0000000..520b2f4 --- /dev/null +++ b/test/json_encoder_test.exs @@ -0,0 +1,62 @@ +defmodule JsonEncoderTest do + use ExUnit.Case + + @moduletag :json_encoder + + test "encode boolean true" do + assert encode_to_binary(true) == "true" + end + + test "encode boolean false" do + assert encode_to_binary(false) == "false" + end + + test "encode null values" do + assert encode_to_binary(nil) == "nil" + assert encode_to_binary(:null) == "null" + assert encode_to_binary(:undefined) == "null" + end + + test "encode atom" do + assert encode_to_binary(:foo) == ~S("foo") + end + + test "encode binary string" do + assert encode_to_binary("bar") == ~S("bar") + end + + test "encode integer" do + assert encode_to_binary(42) == "42" + end + + test "encode float" do + assert encode_to_binary(3.14) |> String.starts_with?("3.14") + end + + test "encode list of integers (array)" do + assert encode_to_binary([1, 2, 3]) == "[1,2,3]" + end + + test "encode map" do + result = encode_to_binary(%{foo: "bar", baz: 42}) + assert result =~ ~S({"foo": "bar", "baz": 42}) + end + + test "encode nested map" do + result = encode_to_binary(%{foo: %{bar: 1, baz: [2, 3]}}) + # Accept any key order in nested map + assert result in [~S({"foo": {"bar": 1,"baz": [2,3]}}), ~S({"foo": {"baz": [2,3], "bar": 1}})] + end + + test "encode deeply nested list" do + result = encode_to_binary([1, [2, [3, %{a: "b"}]]]) + assert result =~ ~S([1,[2,[3,{"a": "b"}]]]) + end + + defp encode_to_binary(val) do + case :json_encoder.encode(val) do + v when is_list(v) -> :erlang.iolist_to_binary(v) + v -> v + end + end +end