commit 29ea1adce4107cf95e2f0c076cac7e94f3822f69
parent 89883a45580779f5ae7f510d8e567ff056682761
Author: Christian Grothoff <grothoff@gnu.org>
Date: Sun, 26 Apr 2026 23:18:04 +0200
test for early response
Diffstat:
4 files changed, 425 insertions(+), 2 deletions(-)
diff --git a/src/tests/README b/src/tests/README
@@ -18,6 +18,19 @@ so that paivana is not exercised only against libmicrohttpd peers:
upstream_py Python (stdlib) (pure interpreter; needs python3
at `make check` time)
+A fifth, special-purpose upstream is also built:
+
+ early_response_upstream
+ C / raw sockets, single-connection. Sends a 413
+ response immediately after reading the request
+ headers, BEFORE consuming the request body — used
+ to exercise paivana's full-duplex forwarding path
+ (early upstream response while client upload is
+ still in flight). Writes the body byte count it
+ observed to a receipt file after the connection
+ closes, so the test can confirm paivana did not
+ abandon the upload mid-stream.
+
They all implement the same canned endpoints (see "Endpoints" below).
The pipelining test client `pipeline_client` is a small C program
that talks directly to the paivana listen socket using BSD sockets.
@@ -89,6 +102,17 @@ Cross-cutting tests (run once):
upstream down with paivana pointed at a closed port,
clients receive 502 Bad Gateway with
the built-in "Bad Gateway" HTML body
+ early upstream response against early_response_upstream, a
+ 768 KiB POST that the upstream answers
+ with a 413 BEFORE reading the body.
+ Verifies that (a) paivana forwards the
+ 413 to the client (no 502 from an
+ aborted forward) and (b) paivana
+ finishes streaming the upload — the
+ upstream's receipt file must record
+ the full 768 KiB. Exercises the
+ full-duplex code path in
+ curl_download_cb / curl_upload_cb.
Environment variables
---------------------
@@ -113,6 +137,7 @@ are already in use.
18402 upstream_go
18403 upstream_py
18404 upstream_rs
+ 18405 early_response_upstream
18499 dead port (for "upstream down" test)
18500 paivana-httpd
diff --git a/src/tests/early_response_upstream.c b/src/tests/early_response_upstream.c
@@ -0,0 +1,305 @@
+/*
+ This file is part of paivana tests.
+ Copyright (C) 2026 Taler Systems SA
+
+ Paivana is free software; you can redistribute it and/or
+ modify it under the terms of the GNU General Public License
+ as published by the Free Software Foundation; either version
+ 3, or (at your option) any later version.
+
+ Paivana is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty
+ of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+ the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with Paivana; see the file COPYING. If not,
+ write to the Free Software Foundation, Inc., 51 Franklin
+ Street, Fifth Floor, Boston, MA 02110-1301, USA.
+*/
+
+/**
+ * @file early_response_upstream.c
+ * @brief Raw-socket HTTP upstream that responds before reading the
+ * request body. Used to exercise paivana's full-duplex
+ * forwarding path: when the upstream begins responding while
+ * the client (paivana) is still uploading, paivana must keep
+ * streaming the upload to completion AND deliver the upstream
+ * response to its own client — neither side may be abandoned.
+ *
+ * After each connection finishes the server writes the number
+ * of body bytes it received (decimal, trailing newline) to the
+ * file given as the second argument, atomically via
+ * rename(2). The test driver reads that file to confirm that
+ * paivana actually forwarded the entire request body even
+ * though the upstream had already responded.
+ */
+#include <errno.h>
+#include <netinet/in.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+
+static const char *receipt_path;
+static volatile sig_atomic_t run_flag = 1;
+
+
+static void
+on_sig (int sig)
+{
+ (void) sig;
+ run_flag = 0;
+}
+
+
+/**
+ * Read from @a fd into @a buf until "\r\n\r\n" appears. Returns the
+ * total number of bytes read (headers + any body bytes carried in the
+ * same recv) on success, with @a *eoh set to the offset of the first
+ * body byte (i.e. just past the CRLFCRLF). Returns -1 on read error
+ * or if @a buf fills up before headers end.
+ */
+static ssize_t
+read_until_eoh (int fd,
+ char *buf,
+ size_t cap,
+ size_t *eoh)
+{
+ size_t pos = 0;
+
+ while (pos < cap)
+ {
+ ssize_t n = read (fd,
+ buf + pos,
+ cap - pos);
+ if (n <= 0)
+ return -1;
+ pos += (size_t) n;
+ if (pos < 4)
+ continue;
+ for (size_t i = 0; i + 3 < pos; i++)
+ {
+ if ( ('\r' == buf[i]) &&
+ ('\n' == buf[i + 1]) &&
+ ('\r' == buf[i + 2]) &&
+ ('\n' == buf[i + 3]) )
+ {
+ *eoh = i + 4;
+ return (ssize_t) pos;
+ }
+ }
+ }
+ return -1;
+}
+
+
+/**
+ * Atomically write @a count to the receipt file (write to <path>.tmp,
+ * fsync, rename). No-op if no receipt path was configured.
+ */
+static void
+write_receipt (size_t count)
+{
+ FILE *f;
+ char tmp[4096];
+ int rc;
+
+ if (NULL == receipt_path)
+ return;
+ rc = snprintf (tmp,
+ sizeof (tmp),
+ "%s.tmp",
+ receipt_path);
+ if ( (rc < 0) ||
+ ((size_t) rc >= sizeof (tmp)) )
+ return;
+ f = fopen (tmp, "w");
+ if (NULL == f)
+ return;
+ fprintf (f, "%zu\n", count);
+ fflush (f);
+ fsync (fileno (f));
+ fclose (f);
+ if (0 != rename (tmp, receipt_path))
+ unlink (tmp);
+}
+
+
+static const char EARLY_RESPONSE[] =
+ "HTTP/1.1 413 Payload Too Large\r\n"
+ "Content-Type: text/plain\r\n"
+ "X-Upstream: early\r\n"
+ "Content-Length: 23\r\n"
+ "\r\n"
+ "early-response-payload\n";
+
+
+/**
+ * Best-effort send the entire @a buf, retrying on partial writes.
+ * Returns 0 on success, -1 if the socket failed.
+ */
+static int
+write_all (int fd,
+ const char *buf,
+ size_t len)
+{
+ size_t off = 0;
+
+ while (off < len)
+ {
+ ssize_t w = write (fd,
+ buf + off,
+ len - off);
+ if (w < 0)
+ {
+ if (EINTR == errno)
+ continue;
+ return -1;
+ }
+ if (0 == w)
+ return -1;
+ off += (size_t) w;
+ }
+ return 0;
+}
+
+
+static void
+handle_client (int fd)
+{
+ char buf[8192];
+ size_t eoh = 0;
+ ssize_t got;
+ size_t body_bytes;
+
+ got = read_until_eoh (fd,
+ buf,
+ sizeof (buf),
+ &eoh);
+ if (got < 0)
+ {
+ close (fd);
+ return;
+ }
+ body_bytes = (size_t) got - eoh;
+
+ /* Send the 413 response immediately. This is the whole point
+ of this upstream: respond before consuming the request body. */
+ if (0 != write_all (fd,
+ EARLY_RESPONSE,
+ sizeof (EARLY_RESPONSE) - 1))
+ {
+ close (fd);
+ /* Receipt still useful for diagnostics: how far did we get? */
+ write_receipt (body_bytes);
+ return;
+ }
+
+ /* Now drain the rest of the request body. We must keep reading
+ until the client (paivana) closes its side — otherwise its
+ CURL would block on send() once the kernel recv buffer fills,
+ which would mask the very behavior we are trying to test. */
+ for (;;)
+ {
+ ssize_t n = read (fd,
+ buf,
+ sizeof (buf));
+ if (n < 0)
+ {
+ if (EINTR == errno)
+ continue;
+ break;
+ }
+ if (0 == n)
+ break;
+ body_bytes += (size_t) n;
+ }
+
+ close (fd);
+ write_receipt (body_bytes);
+}
+
+
+int
+main (int argc,
+ char **argv)
+{
+ int srv;
+ int port;
+ int yes = 1;
+ struct sockaddr_in addr;
+
+ if (argc < 2)
+ {
+ fprintf (stderr,
+ "usage: %s <port> [receipt-file]\n",
+ argv[0]);
+ return 1;
+ }
+ port = atoi (argv[1]);
+ if (argc >= 3)
+ receipt_path = argv[2];
+
+ signal (SIGINT, on_sig);
+ signal (SIGTERM, on_sig);
+ signal (SIGPIPE, SIG_IGN);
+
+ srv = socket (AF_INET, SOCK_STREAM, 0);
+ if (srv < 0)
+ {
+ perror ("socket");
+ return 1;
+ }
+ setsockopt (srv,
+ SOL_SOCKET,
+ SO_REUSEADDR,
+ &yes,
+ sizeof (yes));
+ memset (&addr, 0, sizeof (addr));
+ addr.sin_family = AF_INET;
+ addr.sin_addr.s_addr = htonl (INADDR_LOOPBACK);
+ addr.sin_port = htons ((uint16_t) port);
+ if (0 != bind (srv,
+ (struct sockaddr *) &addr,
+ sizeof (addr)))
+ {
+ perror ("bind");
+ close (srv);
+ return 1;
+ }
+ if (0 != listen (srv, 16))
+ {
+ perror ("listen");
+ close (srv);
+ return 1;
+ }
+ fprintf (stderr,
+ "early_response_upstream listening on port %d\n",
+ port);
+ fflush (stderr);
+
+ while (run_flag)
+ {
+ int cfd = accept (srv, NULL, NULL);
+
+ if (cfd < 0)
+ {
+ if (EINTR == errno)
+ continue;
+ perror ("accept");
+ break;
+ }
+ /* Single-threaded: serialise connections. The reverse-proxy
+ test makes one request per case and Connection: close keeps
+ paivana from reusing the socket. */
+ handle_client (cfd);
+ }
+ close (srv);
+ return 0;
+}
diff --git a/src/tests/meson.build b/src/tests/meson.build
@@ -19,7 +19,19 @@ pipeline_client = executable(
install: false,
)
-test_deps = [upstream_mhd, pipeline_client, paivana_httpd_exe]
+early_response_upstream = executable(
+ 'early_response_upstream',
+ 'early_response_upstream.c',
+ include_directories: [incdir, configuration_inc],
+ install: false,
+)
+
+test_deps = [
+ upstream_mhd,
+ pipeline_client,
+ early_response_upstream,
+ paivana_httpd_exe,
+]
go_bin = find_program('go', required: false)
if go_bin.found()
diff --git a/src/tests/test_reverse_proxy.sh b/src/tests/test_reverse_proxy.sh
@@ -71,6 +71,7 @@ UPSTREAM_MHD="$BUILDDIR/upstream_mhd"
UPSTREAM_GO="$BUILDDIR/upstream_go"
UPSTREAM_RS="$BUILDDIR/upstream_rs"
PIPELINE_CLIENT="$BUILDDIR/pipeline_client"
+EARLY_RESPONSE_UPSTREAM="$BUILDDIR/early_response_upstream"
# Ports (fixed, but we still wait/retry binding; if they're busy the
# test bails out early so the user can rerun.)
@@ -79,6 +80,7 @@ MHD_PORT=18401
GO_PORT=18402
PY_PORT=18403
RS_PORT=18404
+EARLY_PORT=18405
DEAD_PORT=18499 # nothing should be listening here
TMPDIR="$(mktemp -d -t paivana-tests.XXXXXX)"
@@ -165,11 +167,12 @@ function start_bg() {
function start_paivana() {
# $1 = upstream base URL
local dest="$1"
+ local flags="${2:-}"
local cfg="$TMPDIR/paivana.conf"
sed -e "s|@DEST@|$dest|g" -e "s|@PORT@|$PAIVANA_PORT|g" \
"$SRCDIR/test_reverse_proxy.conf.in" > "$cfg"
local log="$LOGDIR/paivana.log"
- ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L WARNING ) >"$log" 2>&1 &
+ ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L INFO "$flags") >"$log" 2>&1 &
PAIVANA_PID=$!
if ! wait_for_port 127.0.0.1 "$PAIVANA_PORT";
then
@@ -569,6 +572,81 @@ function test_upload_too_big_chunked() {
ok
}
+function test_early_response() {
+ # The upstream sends a 413 response immediately after reading the
+ # request headers, before consuming any of the request body — a
+ # legal HTTP/1.1 pattern. Paivana must (a) deliver that response
+ # back to its client (no 502 from an aborted forward) and (b)
+ # still finish streaming the upload to upstream so the upstream
+ # sees the full request body.
+ #
+ # The byte count the upstream observed is written to RECEIPT
+ # after the connection finishes; we read it back to confirm.
+ msg "early upstream response: response forwarded AND upload completed"
+ if [ ! -x "$EARLY_RESPONSE_UPSTREAM" ];
+ then
+ echo "SKIP (early_response_upstream not built)"
+ return
+ fi
+ stop_paivana
+ local receipt="$TMPDIR/early_receipt"
+ rm -f "$receipt"
+ local log="$LOGDIR/early.log"
+ ( exec "$EARLY_RESPONSE_UPSTREAM" "$EARLY_PORT" "$receipt" ) \
+ >"$log" 2>&1 &
+ local early_pid=$!
+ PIDS+=("$early_pid")
+ if ! wait_for_port 127.0.0.1 "$EARLY_PORT";
+ then
+ fail "early_response_upstream did not start on port $EARLY_PORT"
+ fi
+ start_paivana "http://127.0.0.1:$EARLY_PORT" -F
+
+ # 768 KiB body — under paivana's 1 MiB request-buffer cap, but
+ # too large to fit in default kernel TCP buffers, so the response
+ # is guaranteed to start arriving while paivana's curl is still
+ # uploading. Without the full-duplex fix paivana would either
+ # abort the curl handle (502 to client) or silently drop the
+ # remaining upload bytes (receipt < expected).
+ local payload="$TMPDIR/early_body"
+ local expected=786432
+ dd if=/dev/urandom of="$payload" bs=1024 count=768 status=none
+ local got_size
+ got_size="$(wc -c <"$payload" | tr -d ' ')"
+ [ "$got_size" = "$expected" ] || fail "test setup: payload is $got_size bytes, want $expected"
+
+ local status
+ status="$(curl -sS -X POST --data-binary "@$payload" \
+ --max-time 30 \
+ -o "$TMPDIR/body" -w '%{http_code}' \
+ "$(PAIVANA_URL /upload)" 2>"$TMPDIR/err")" \
+ || fail "curl: $(cat "$TMPDIR/err")"
+ [ "$status" = "413" ] || \
+ fail "status=$status want=413 (a 502 here means paivana aborted the forward instead of full-duplexing)"
+ grep -q 'early-response-payload' "$TMPDIR/body" || \
+ fail "response body did not come from upstream: $(head -c 200 "$TMPDIR/body")"
+
+ # Wait briefly for the upstream to finish draining the connection
+ # and write the receipt file (it does so after read() returns 0).
+ local tries=50
+ while [ "$tries" -gt 0 ] && [ ! -s "$receipt" ];
+ do
+ sleep 0.1
+ tries=$((tries - 1))
+ done
+ [ -s "$receipt" ] || fail "upstream did not write receipt file"
+ local got
+ got="$(tr -d '\n ' <"$receipt")"
+ [ "$got" = "$expected" ] || \
+ fail "upstream received $got of $expected body bytes — paivana abandoned the upload mid-stream"
+ ok
+
+ stop_paivana
+ kill -TERM "$early_pid" 2>/dev/null
+ wait "$early_pid" 2>/dev/null
+}
+
+
function test_upstream_down() {
msg "upstream down yields 502 Bad Gateway"
# Re-point paivana at a port with nothing listening.
@@ -697,6 +775,9 @@ then
stop_paivana
fi
+# --- Early-response upstream test (restarts paivana) ------------------
+test_early_response
+
# --- Upstream-down test (runs last because it restarts paivana) -------
test_upstream_down
stop_paivana