Skip to content

Commit 69d376b

Browse files
committed
esp32 tests: serve qemu network tests from the runner, not github.com
Make CI more stable by serving traffic locally with a small escript. Signed-off-by: Paul Guyot <pguyot@kallisys.net>
1 parent f7594af commit 69d376b

5 files changed

Lines changed: 186 additions & 10 deletions

File tree

.github/workflows/esp32-build.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,22 @@ jobs:
257257
idf.py set-target ${{matrix.esp-idf-target}}
258258
idf.py build
259259
260+
- name: Start local servers for qemu network tests
261+
# test_socket and test_ssl connect to the runner (10.0.2.2 from inside
262+
# qemu SLIRP) instead of an external site: CI egress is slow and lossy
263+
# enough that external connections pile up in lwIP until the board OOMs.
264+
if: matrix.esp-idf-target != 'esp32p4'
265+
working-directory: ./src/platforms/esp32/test/
266+
run: |
267+
set -e
268+
nohup escript local_test_servers.escript --certdir /tmp > /tmp/local_test_servers.log 2>&1 &
269+
for i in $(seq 1 20); do
270+
grep -q listening /tmp/local_test_servers.log && break
271+
sleep 0.5
272+
done
273+
cat /tmp/local_test_servers.log
274+
grep -q listening /tmp/local_test_servers.log || { echo "local test servers failed to start"; exit 1; }
275+
260276
- name: Run ESP32 tests using qemu with memory checks build
261277
# TODO: remove the following exclusion when ESP32P4 support is added to espressif/qemu
262278
if: matrix.esp-idf-target != 'esp32p4'

src/platforms/esp32/test/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ AtomVM provides two paths for testing "on device", the locally run QEMU emulator
1212

1313
Instructions for running the tests [on QEMU are documented here](https://doc.atomvm.org/main/build-instructions.html#running-tests-for-esp32).
1414

15+
The network tests (`test_socket`, `test_ssl`) connect to servers on the QEMU
16+
SLIRP host (10.0.2.2) instead of an external site. Start them before running
17+
pytest:
18+
19+
```shell
20+
escript local_test_servers.escript --certdir /tmp &
21+
```
22+
23+
(They bind ports 80, 443 and 53, so this may require root on Linux.)
24+
1525
# Wokwi CI simulator testing
1626

1727
Wokwi CI is a commercial cloud CI, see [wokwi-ci/getting-started](https://docs.wokwi.com/wokwi-ci/getting-started), running it locally requires you to obtain a `WOKWI_CLI_TOKEN` [Get token](https://wokwi.com/dashboard/ci) and usage fees may apply in the future - AtomVM uses it through the [pytest-embedded-wokwi](https://github.com/espressif/pytest-embedded/tree/main/pytest-embedded-wokwi) integration.
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#!/usr/bin/env escript
2+
%% -*- erlang -*-
3+
%%
4+
%% This file is part of AtomVM.
5+
%%
6+
%% Copyright 2026 Paul Guyot <pguyot@kallisys.net>
7+
%%
8+
%% Licensed under the Apache License, Version 2.0 (the "License");
9+
%% you may not use this file except in compliance with the License.
10+
%% You may obtain a copy of the License at
11+
%%
12+
%% http://www.apache.org/licenses/LICENSE-2.0
13+
%%
14+
%% Unless required by applicable law or agreed to in writing, software
15+
%% distributed under the License is distributed on an "AS IS" BASIS,
16+
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
%% See the License for the specific language governing permissions and
18+
%% limitations under the License.
19+
%%
20+
%% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
21+
%%
22+
23+
%% Local endpoints for the qemu network tests (test_socket, test_ssl).
24+
%%
25+
%% The qemu guest reaches this host at 10.0.2.2 (SLIRP user networking), so
26+
%% test_socket and test_ssl talk to these servers instead of an external site:
27+
%% CI runner egress is slow and lossy enough that external connections pile up
28+
%% in lwIP until the board runs out of memory.
29+
%%
30+
%% - port 80: answers any request with an HTTP/1.1 301, like http://github.com
31+
%% - port 443: TLS (self-signed, clients don't verify), answers HTTP/1.1 200
32+
%% - port 53: minimal UDP DNS responder, so the UDP test needs no real resolver
33+
%%
34+
%% Usage: escript local_test_servers.escript [--certdir DIR]
35+
36+
main(Args) ->
37+
CertDir = cert_dir(Args),
38+
{ok, _} = application:ensure_all_started(ssl),
39+
{ok, HTTPListen} = gen_tcp:listen(80, [binary, {active, false}, {reuseaddr, true}]),
40+
{ok, TLSListen} = ssl:listen(443, [binary, {active, false}, {reuseaddr, true} | tls_opts(CertDir)]),
41+
{ok, DNSSocket} = gen_udp:open(53, [binary, {active, false}, {reuseaddr, true}]),
42+
spawn_link(fun() -> http_loop(HTTPListen) end),
43+
spawn_link(fun() -> tls_loop(TLSListen) end),
44+
io:format("listening on 0.0.0.0:80 (http), 0.0.0.0:443 (tls) and 0.0.0.0:53 (dns)~n"),
45+
dns_loop(DNSSocket).
46+
47+
cert_dir(["--certdir", Dir | _]) -> Dir;
48+
cert_dir([_ | Rest]) -> cert_dir(Rest);
49+
cert_dir([]) -> "/tmp".
50+
51+
%% A self-signed RSA server certificate the client accepts without validation
52+
%% (authmode none). Generated with openssl, like the retired python server.
53+
tls_opts(CertDir) ->
54+
Cert = filename:join(CertDir, "cert.pem"),
55+
Key = filename:join(CertDir, "key.pem"),
56+
case filelib:is_file(Cert) andalso filelib:is_file(Key) of
57+
true ->
58+
ok;
59+
false ->
60+
Command = lists:flatten(io_lib:format(
61+
"openssl req -x509 -newkey rsa:2048 -nodes -keyout ~ts -out ~ts"
62+
" -days 30 -subj /CN=10.0.2.2 2>&1",
63+
[Key, Cert]
64+
)),
65+
Output = os:cmd(Command),
66+
filelib:is_file(Cert) andalso filelib:is_file(Key) orelse
67+
fail("openssl could not generate a certificate: ~ts", [Output])
68+
end,
69+
[{certfile, Cert}, {keyfile, Key}].
70+
71+
http_loop(Listen) ->
72+
{ok, Socket} = gen_tcp:accept(Listen),
73+
try
74+
handle_http(Socket)
75+
catch
76+
_:_ -> ok
77+
end,
78+
http_loop(Listen).
79+
80+
handle_http(Socket) ->
81+
_ = gen_tcp:recv(Socket, 0, 10000),
82+
ok = gen_tcp:send(Socket, [
83+
<<"HTTP/1.1 301 Moved Permanently\r\n">>,
84+
<<"Content-Length: 0\r\n">>,
85+
<<"Location: https://10.0.2.2/\r\n">>,
86+
<<"Connection: close\r\n\r\n">>
87+
]),
88+
gen_tcp:close(Socket).
89+
90+
tls_loop(Listen) ->
91+
case ssl:transport_accept(Listen, 30000) of
92+
{ok, Transport} ->
93+
try
94+
{ok, Socket} = ssl:handshake(Transport, 10000),
95+
handle_tls(Socket)
96+
catch
97+
_:_ -> ok
98+
end;
99+
_ ->
100+
ok
101+
end,
102+
tls_loop(Listen).
103+
104+
handle_tls(Socket) ->
105+
Body = <<"ok">>,
106+
_ = ssl:recv(Socket, 0, 10000),
107+
ok = ssl:send(Socket, [
108+
<<"HTTP/1.1 200 OK\r\n">>,
109+
<<"Content-Type: text/plain\r\n">>,
110+
[<<"Content-Length: ">>, integer_to_binary(byte_size(Body)), <<"\r\n">>],
111+
<<"Connection: close\r\n\r\n">>,
112+
Body
113+
]),
114+
%% Let the client send its close_notify first: closing right after the
115+
%% reply can turn into an RST that races the client's shutdown.
116+
drain_tls(Socket),
117+
ssl:close(Socket).
118+
119+
drain_tls(Socket) ->
120+
case ssl:recv(Socket, 0, 5000) of
121+
{ok, _} -> drain_tls(Socket);
122+
_ -> ok
123+
end.
124+
125+
dns_loop(Socket) ->
126+
case gen_udp:recv(Socket, 0) of
127+
{ok, {Address, Port, <<Id:2/binary, _Flags:2/binary, Rest/binary>>}} ->
128+
%% The guest only checks the transaction id and the QR bit, so echo
129+
%% the id and rewrite the header flags to a response (QR=1, RD=1,
130+
%% RA=1) without synthesizing answer records.
131+
ok = gen_udp:send(Socket, Address, Port, <<Id/binary, 16#81, 16#80, Rest/binary>>);
132+
_ ->
133+
ok
134+
end,
135+
dns_loop(Socket).
136+
137+
fail(Format, Args) ->
138+
io:format(standard_error, Format ++ "~n", Args),
139+
halt(1).

src/platforms/esp32/test/main/test_erl_sources/test_socket.erl

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ test_tcp_client(Active, BinaryOpt) ->
3939
{proto, tcp},
4040
{connect, true},
4141
{controlling_process, self()},
42-
{address, "github.com"},
42+
% The test harness (local_test_servers.escript) answers on the qemu SLIRP
43+
% host with the same 301 an http://github.com request would get:
44+
% these tests only run under qemu (CONFIG_ETH_USE_OPENETH) and CI
45+
% runner egress is too unreliable to reach the real site.
46+
{address, "10.0.2.2"},
4347
{port, 80},
4448
{active, Active},
4549
{buffer, 512},
@@ -148,8 +152,10 @@ test_udp(Active, QueryID) ->
148152
],
149153
ok = call(Socket, {init, Params}, 30000),
150154
{ok, {MyIPAddr, _Port}} = call(Socket, {sockname}),
155+
% local_test_servers.escript answers this DNS query on the SLIRP host
156+
% (10.0.2.2:53), keeping the UDP path off the runner's real resolver.
151157
ok =
152-
case call(Socket, {sendto, {1, 1, 1, 1}, 53, ?UDP_QUERY(QueryID)}) of
158+
case call(Socket, {sendto, {10, 0, 2, 2}, 53, ?UDP_QUERY(QueryID)}) of
153159
% generic_unix socket driver
154160
{ok, _Len} -> ok;
155161
% esp32 socket driver
@@ -162,8 +168,8 @@ test_udp(Active, QueryID) ->
162168
true ->
163169
ok =
164170
receive
165-
% {udp, Socket, {{1,1,1,1}, 53, <<QueryID:16, 1:1, _:7, _/binary>>}} -> ok; % not supported yet
166-
{udp, _WrappedSocket, {1, 1, 1, 1}, 53, <<QueryID:16, B, _/binary>>} when
171+
% {udp, Socket, {{10,0,2,2}, 53, <<QueryID:16, 1:1, _:7, _/binary>>}} -> ok; % not supported yet
172+
{udp, _WrappedSocket, {10, 0, 2, 2}, 53, <<QueryID:16, B, _/binary>>} when
167173
B band 16#80 =:= 16#80
168174
->
169175
ok;
@@ -183,8 +189,8 @@ test_udp(Active, QueryID) ->
183189
false ->
184190
ok =
185191
case call(Socket, {recvfrom, 512, 30000}) of
186-
% {ok, {{1,1,1,1}, 53, <<QueryID:16, 1:1, _:7, _/binary>>}} -> ok;
187-
{ok, {{1, 1, 1, 1}, 53, <<QueryID:16, B, _/binary>>}} when
192+
% {ok, {{10,0,2,2}, 53, <<QueryID:16, 1:1, _:7, _/binary>>}} -> ok;
193+
{ok, {{10, 0, 2, 2}, 53, <<QueryID:16, B, _/binary>>}} when
188194
B band 16#80 =:= 16#80
189195
->
190196
ok;

src/platforms/esp32/test/main/test_erl_sources/test_ssl.erl

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,20 @@ start() ->
2626
Entropy = ssl:nif_entropy_init(),
2727
CtrDrbg = ssl:nif_ctr_drbg_init(),
2828
ok = ssl:nif_ctr_drbg_seed(CtrDrbg, Entropy, <<"AtomVM">>),
29-
% Get address of github.com
29+
% Exercise getaddrinfo through the SLIRP DNS forwarder (10.0.2.3, answered
30+
% by the qemu host's resolver).
3031
{ok, Results} = net:getaddrinfo_nif("github.com", undefined),
31-
[TCPAddr | _] = [
32+
[_TCPAddr | _] = [
3233
Addr
3334
|| #{addr := #{addr := Addr}, type := stream, protocol := tcp, family := inet} <- Results
3435
],
35-
% Connect to github.com:443
36+
% Connect to the TLS server the test harness (local_test_servers.escript) runs
37+
% on the qemu SLIRP host: this test only runs under qemu
38+
% (CONFIG_ETH_USE_OPENETH) and CI runner egress is too unreliable to
39+
% reach an external site. The server answers like https://github.com
40+
% would (HTTP/1.1), with a self-signed certificate (authmode is none).
3641
{ok, Socket} = socket:open(inet, stream, tcp),
37-
ok = socket:connect(Socket, #{family => inet, addr => TCPAddr, port => 443}),
42+
ok = socket:connect(Socket, #{family => inet, addr => {10, 0, 2, 2}, port => 443}),
3843
% Initialize SSL Socket and config
3944
SSLContext = ssl:nif_init(),
4045
ok = ssl:nif_set_bio(SSLContext, Socket),

0 commit comments

Comments
 (0)