summaryrefslogtreecommitdiff
path: root/lib9p/tests
diff options
context:
space:
mode:
Diffstat (limited to 'lib9p/tests')
-rw-r--r--lib9p/tests/client_config/config.h40
-rwxr-xr-xlib9p/tests/runtest81
-rw-r--r--lib9p/tests/test_server/CMakeLists.txt1
-rw-r--r--lib9p/tests/test_server/fs_slowread.c101
-rw-r--r--lib9p/tests/test_server/fs_slowread.h22
-rw-r--r--lib9p/tests/test_server/main.c63
-rwxr-xr-xlib9p/tests/testclient-p9p61
-rw-r--r--lib9p/tests/testclient-p9p.explog106
-rw-r--r--lib9p/tests/testclient-sess.c144
-rw-r--r--lib9p/tests/testclient-sess.explog58
10 files changed, 612 insertions, 65 deletions
diff --git a/lib9p/tests/client_config/config.h b/lib9p/tests/client_config/config.h
new file mode 100644
index 0000000..65ee9de
--- /dev/null
+++ b/lib9p/tests/client_config/config.h
@@ -0,0 +1,40 @@
+/* config.h - Compile-time configuration for lib9p test clients
+ *
+ * Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#ifndef _CONFIG_H_
+#define _CONFIG_H_
+
+/* 9P *************************************************************************/
+
+#define CONFIG_9P_MAX_ERR_SIZE 128
+#define CONFIG_9P_MAX_9P2000_e_WELEM 16
+
+#define CONFIG_9P_ENABLE_9P2000 1 /* bool */
+#define CONFIG_9P_ENABLE_9P2000_u 1 /* bool */
+#define CONFIG_9P_ENABLE_9P2000_e 1 /* bool */
+#define CONFIG_9P_ENABLE_9P2000_L 1 /* bool */
+#define CONFIG_9P_ENABLE_9P2000_p9p 1 /* bool */
+
+/* 9P server (unused) *********************************************************/
+
+#define CONFIG_9P_SRV_MAX_MSG_SIZE ((4*1024)+24)
+#define CONFIG_9P_SRV_MAX_HOSTMSG_SIZE CONFIG_9P_SRV_MAX_MSG_SIZE+16
+#define CONFIG_9P_SRV_MAX_FIDS 16
+#define CONFIG_9P_SRV_MAX_REQS 2
+#define CONFIG_9P_SRV_MAX_DEPTH 3
+
+/* COROUTINE (unused) *********************************************************/
+
+#define CONFIG_COROUTINE_STACK_SIZE_DEFAULT (32*1024)
+#define CONFIG_COROUTINE_NAME_LEN 16
+#define CONFIG_COROUTINE_MEASURE_STACK 1 /* bool */
+#define CONFIG_COROUTINE_PROTECT_STACK 1 /* bool */
+#define CONFIG_COROUTINE_DEBUG 0 /* bool */
+#define CONFIG_COROUTINE_VALGRIND 1 /* bool */
+#define CONFIG_COROUTINE_GDB 1 /* bool */
+#define CONFIG_COROUTINE_NUM 2
+
+#endif /* _CONFIG_H_ */
diff --git a/lib9p/tests/runtest b/lib9p/tests/runtest
index fb66a43..6883391 100755
--- a/lib9p/tests/runtest
+++ b/lib9p/tests/runtest
@@ -1,67 +1,50 @@
#!/usr/bin/env bash
-# lib9p/tests/runtest - Simple tests for the 9P `test_server`
+# lib9p/tests/runtest - Test harness for the 9P `test_server`
#
# Copyright (C) 2025 Luke T. Shumaker <lukeshu@lukeshu.com>
# SPDX-License-Identifier: AGPL-3.0-or-later
set -euE -o pipefail
-set -x
build_aux=$(realpath --canonicalize-missing -- "${BASH_SOURCE[0]}/../../../build-aux")
-port=$(python -c 'import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()')
-"${build_aux}/valgrind" ./tests/test_server/test_server "$port" &
-server_pid=$!
-# shellcheck disable=SC2064
-trap "kill $server_pid || true; wait $server_pid || true" EXIT
-server_addr="localhost:${port}"
-
-client=(9p -a "$server_addr")
+if [[ $# != 2 ]]; then
+ echo >&2 "Usage: $0 CLIENTSCRIPT EXPLOG"
+ exit 2
+fi
+clientscript="$1"
+explog="$2"
-expect_lines() (
+cleanup=()
+cleanup() {
{ set +x; } &>/dev/null
- printf >&2 '+ diff -u expected.txt actual.txt\n'
- diff -u <(printf '%s\n' "$@") <(printf '%s\n' "$out")
-)
-
-while [[ -d /proc/$server_pid ]] && ! (readlink /proc/$server_pid/fd/* 2>/dev/null | grep -q ^socket:); do sleep 0.1; done
+ local i
+ for ((i = ${#cleanup[@]} - 1; i >= 0; i--)); do
+ eval "set -x; ${cleanup[$i]}"
+ { set +x; } &>/dev/null
+ done
+}
+trap cleanup EXIT
-out=$("${client[@]}" ls -l '')
-expect_lines \
- 'd-r-xr-xr-x M 0 root root 0 Oct 7 2024 Documentation' \
- '--r--r--r-- M 0 root root 166 Oct 7 2024 README.md' \
- '---w--w--w- M 0 root root 0 Oct 7 2024 shutdown'
+logfile=$(mktemp -t lib9p-log.XXXXXXXXXX)
+cleanup+=("rm -f -- ${logfile@Q}")
-out=$("${client[@]}" ls -l 'Documentation/')
-expect_lines \
- '--r--r--r-- M 0 root root 166 Oct 7 2024 x'
-
-out=$("${client[@]}" read 'README.md')
-expect_lines \
- '<!--' \
- ' README.md - test static file' \
- '' \
- ' Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>' \
- ' SPDX-License-Identifier: AGPL-3.0-or-later' \
- '-->' \
- 'Hello, world!'
+port=$(python -c 'import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()')
-out=$("${client[@]}" read 'Documentation/x')
-expect_lines \
- '<!--' \
- ' Documentation/x.txt - test static file' \
- '' \
- ' Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>' \
- ' SPDX-License-Identifier: AGPL-3.0-or-later' \
- '-->' \
- 'foo'
+set -x
-out=$("${client[@]}" stat 'Documentation/x')
-expect_lines \
- "'x' 'root' 'root' 'root' q (0000000000000001 1 ) m 0444 at 1728337905 mt 1728337904 l 166 t 0 d 0"
+"${build_aux}/valgrind" ./tests/test_server/test_server "$port" "$logfile" &
+server_pid=$!
+cleanup+=("kill $server_pid || true; wait $server_pid || true")
+while [[ -d /proc/$server_pid ]] && ! (readlink /proc/$server_pid/fd/* 2>/dev/null | grep -q ^socket:); do sleep 0.1; done
-out=$("${client[@]}" write 'shutdown' <<<1)
-expect_lines ''
+if [[ "$(head -c2 -- "$clientscript")" == '#!' ]]; then
+ "$clientscript" "$port"
+else
+ "${build_aux}/valgrind" "$clientscript" "$port"
+fi
wait "$server_pid"
-trap - EXIT
+cleanup=("${cleanup[@]::1}")
+
+diff -u -- <(grep -e '^[<>]' -- "$explog") "$logfile"
diff --git a/lib9p/tests/test_server/CMakeLists.txt b/lib9p/tests/test_server/CMakeLists.txt
index 19c8edb..eb16165 100644
--- a/lib9p/tests/test_server/CMakeLists.txt
+++ b/lib9p/tests/test_server/CMakeLists.txt
@@ -10,6 +10,7 @@ if (PICO_PLATFORM STREQUAL "host")
add_library(test_server_objs OBJECT
main.c
fs_shutdown.c
+ fs_slowread.c
)
target_include_directories(test_server_objs PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/config)
target_include_directories(test_server_objs PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
diff --git a/lib9p/tests/test_server/fs_slowread.c b/lib9p/tests/test_server/fs_slowread.c
new file mode 100644
index 0000000..520edd2
--- /dev/null
+++ b/lib9p/tests/test_server/fs_slowread.c
@@ -0,0 +1,101 @@
+/* lib9p/tests/test_server/fs_slowread.c - slowread API endpoint
+ *
+ * Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include "fs_slowread.h"
+
+LO_IMPLEMENTATION_C(lib9p_srv_file, struct slowread_file, slowread_file, static);
+
+LO_IMPLEMENTATION_H(lib9p_srv_fio, struct slowread_file, slowread_file);
+LO_IMPLEMENTATION_C(lib9p_srv_fio, struct slowread_file, slowread_file, static);
+
+/* srv_file *******************************************************************/
+
+static void slowread_file_free(struct slowread_file *self) {
+ assert(self);
+}
+static struct lib9p_qid slowread_file_qid(struct slowread_file *self) {
+ assert(self);
+ return (struct lib9p_qid){
+ .type = LIB9P_QT_FILE,
+ .vers = 1,
+ .path = self->pathnum,
+ };
+}
+
+static struct lib9p_stat slowread_file_stat(struct slowread_file *self, struct lib9p_srv_ctx *ctx) {
+ assert(self);
+ assert(ctx);
+ return (struct lib9p_stat){
+ .kern_type = 0,
+ .kern_dev = 0,
+ .file_qid = slowread_file_qid(self),
+ .file_mode = 0444,
+ .file_atime = UTIL9P_ATIME,
+ .file_mtime = UTIL9P_MTIME,
+ .file_size = 6,
+ .file_name = lib9p_str(self->name),
+ .file_owner_uid = lib9p_str("root"),
+ .file_owner_gid = lib9p_str("root"),
+ .file_last_modified_uid = lib9p_str("root"),
+ .file_extension = lib9p_str(NULL),
+ .file_owner_n_uid = 0,
+ .file_owner_n_gid = 0,
+ .file_last_modified_n_uid = 0,
+ };
+}
+static void slowread_file_wstat(struct slowread_file *self, struct lib9p_srv_ctx *ctx, struct lib9p_stat) {
+ assert(self);
+ assert(ctx);
+ lib9p_error(&ctx->basectx, LINUX_EROFS, "cannot wstat API file");
+}
+static void slowread_file_remove(struct slowread_file *self, struct lib9p_srv_ctx *ctx) {
+ assert(self);
+ assert(ctx);
+ lib9p_error(&ctx->basectx, LINUX_EROFS, "cannot remove API file");
+}
+
+LIB9P_SRV_NOTDIR(struct slowread_file, slowread_file)
+
+static lo_interface lib9p_srv_fio slowread_file_fopen(struct slowread_file *self, struct lib9p_srv_ctx *ctx, bool, bool, bool) {
+ assert(self);
+ assert(ctx);
+ return lo_box_slowread_file_as_lib9p_srv_fio(self);
+}
+
+/* srv_fio ********************************************************************/
+
+static void slowread_file_iofree(struct slowread_file *self) {
+ assert(self);
+}
+
+static uint32_t slowread_file_iounit(struct slowread_file *self) {
+ assert(self);
+ return 0;
+}
+
+static uint32_t slowread_file_pwrite(struct slowread_file *LM_UNUSED(self),
+ struct lib9p_srv_ctx *LM_UNUSED(ctx),
+ void *LM_UNUSED(buf), uint32_t LM_UNUSED(byte_count),
+ uint64_t LM_UNUSED(offset)) {
+ assert_notreached("not writable");
+}
+static void slowread_file_pread(struct slowread_file *self, struct lib9p_srv_ctx *ctx,
+ uint32_t byte_count, uint64_t LM_UNUSED(byte_offset),
+ struct iovec *ret) {
+ assert(self);
+ assert(ctx);
+ assert(ret);
+
+ while (!lib9p_srv_flush_requested(ctx))
+ cr_yield();
+ if (self->flushable)
+ lib9p_srv_acknowledge_flush(ctx);
+ else
+ *ret = (struct iovec){
+ .iov_base = "Sloth\n",
+ .iov_len = 6 < byte_count ? 6 : byte_count,
+ };
+}
diff --git a/lib9p/tests/test_server/fs_slowread.h b/lib9p/tests/test_server/fs_slowread.h
new file mode 100644
index 0000000..ef4b65f
--- /dev/null
+++ b/lib9p/tests/test_server/fs_slowread.h
@@ -0,0 +1,22 @@
+/* lib9p/tests/test_server/fs_slowread.h - slowread API endpoint
+ *
+ * Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#ifndef _LIB9P_TESTS_TEST_SERVER_FS_SLOWREAD_H_
+#define _LIB9P_TESTS_TEST_SERVER_FS_SLOWREAD_H_
+
+#include <util9p/static.h>
+#include <libhw/host_net.h>
+
+struct slowread_file {
+ char *name;
+ uint64_t pathnum;
+
+ bool flushable;
+};
+LO_IMPLEMENTATION_H(lib9p_srv_file, struct slowread_file, slowread_file);
+#define lo_box_slowread_file_as_lib9p_srv_file(obj) util9p_box(slowread_file, obj)
+
+#endif /* _LIB9P_TESTS_TEST_SERVER_FS_SLOWREAD_H_ */
diff --git a/lib9p/tests/test_server/main.c b/lib9p/tests/test_server/main.c
index 8d22a04..2743629 100644
--- a/lib9p/tests/test_server/main.c
+++ b/lib9p/tests/test_server/main.c
@@ -5,6 +5,8 @@
*/
#include <error.h>
+#include <errno.h>
+#include <stdio.h>
#include <stdlib.h> /* for atoi() */
#include <lib9p/srv.h>
@@ -18,6 +20,7 @@
#include "static.h"
#include "fs_shutdown.h"
+#include "fs_slowread.h"
/* configuration **************************************************************/
@@ -37,6 +40,7 @@ struct {
uint16_t port;
struct hostnet_tcp_listener listeners[CONFIG_SRV9P_NUM_CONNS];
struct lib9p_srv srv;
+ FILE *logstream;
} globals = {
.srv = (struct lib9p_srv){
.rootdir = get_root,
@@ -45,32 +49,33 @@ struct {
/* file tree ******************************************************************/
-enum { PATH_BASE = __COUNTER__ };
-#define PATH_COUNTER __COUNTER__ - PATH_BASE
-
-#define STATIC_FILE(STRNAME, SYMNAME) \
- UTIL9P_STATIC_FILE(PATH_COUNTER, STRNAME, \
+#define STATIC_FILE(N, STRNAME, SYMNAME) \
+ UTIL9P_STATIC_FILE(N, STRNAME, \
.data_start = _binary_static_##SYMNAME##_start, \
.data_end = _binary_static_##SYMNAME##_end)
-#define STATIC_DIR(STRNAME, ...) \
- UTIL9P_STATIC_DIR(PATH_COUNTER, STRNAME, __VA_ARGS__)
+#define STATIC_DIR(N, STRNAME, ...) \
+ UTIL9P_STATIC_DIR(N, STRNAME, __VA_ARGS__)
-#define API_FILE(STRNAME, SYMNAME, ...) \
+#define API_FILE(N, STRNAME, SYMNAME, ...) \
lo_box_##SYMNAME##_file_as_lib9p_srv_file(&((struct SYMNAME##_file){ \
.name = STRNAME, \
- .pathnum = PATH_COUNTER \
+ .pathnum = N \
__VA_OPT__(,) __VA_ARGS__ \
}))
struct lib9p_srv_file root =
- STATIC_DIR("",
- STATIC_DIR("Documentation",
- STATIC_FILE("x", Documentation_x_txt),
+ STATIC_DIR(1, "",
+ STATIC_DIR(2, "Documentation",
+ STATIC_FILE(3, "x", Documentation_x_txt),
),
- STATIC_FILE("README.md", README_md),
- API_FILE("shutdown", shutdown,
+ STATIC_FILE(4, "README.md", README_md),
+ API_FILE(5, "shutdown", shutdown,
.listeners = globals.listeners,
.nlisteners = LM_ARRAY_LEN(globals.listeners)),
+ API_FILE(6, "slowread", slowread,
+ .flushable = false),
+ API_FILE(7, "slowread-flushable", slowread,
+ .flushable = true),
);
static lo_interface lib9p_srv_file get_root(struct lib9p_srv_ctx *LM_UNUSED(ctx), struct lib9p_s LM_UNUSED(treename)) {
@@ -109,15 +114,41 @@ static COROUTINE init_cr(void *) {
cr_exit();
}
+static void log_fct(char character, void *_stream) {
+ FILE *stream = _stream;
+ putc(character, stream);
+ putchar(character);
+}
+
+static void log_msg(struct lib9p_srv_ctx *ctx, enum lib9p_msg_type typ, void *hostmsg) {
+ /* It sucks that %v trips -Wformat and -Wformat-extra-args
+ * https://gcc.gnu.org/bugzilla/show_bug.cgi?id=47781 */
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wformat"
+#pragma GCC diagnostic ignored "-Wformat-extra-args"
+ fmt_fctprintf(log_fct, globals.logstream,
+ "%c %v\n", typ % 2 ? '<' : '>',
+ lo_box_lib9p_msg_as_fmt_formatter(&ctx->basectx, typ, hostmsg));
+#pragma GCC diagnostic pop
+ fflush(globals.logstream);
+}
+
int main(int argc, char *argv[]) {
- if (argc != 2)
- error(2, 0, "usage: %s PORT_NUMBER", argv[0]);
+ if (argc != 3)
+ error(2, 0, "usage: %s PORT_NUMBER LOGFILE", argv[0]);
+
globals.port = atoi(argv[1]);
+ globals.logstream = fopen(argv[2], "w");
+ if (!globals.logstream)
+ error(2, errno, "fopen");
+ globals.srv.msglog = log_msg;
+
struct hostclock clock_monotonic = {
.clock_id = CLOCK_MONOTONIC,
};
bootclock = lo_box_hostclock_as_alarmclock(&clock_monotonic);
coroutine_add("init", init_cr, NULL);
coroutine_main();
+ fclose(globals.logstream);
return 0;
}
diff --git a/lib9p/tests/testclient-p9p b/lib9p/tests/testclient-p9p
new file mode 100755
index 0000000..81a7e50
--- /dev/null
+++ b/lib9p/tests/testclient-p9p
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# lib9p/tests/testclient-p9p - Test the 9P `test_server` against Plan 9 Port's `9p` utility
+#
+# Copyright (C) 2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+set -euE -o pipefail
+
+if [[ $# != 1 ]]; then
+ echo >&2 "Usage: $0 SERVER_PORT"
+ echo >&2 "Usage: ./runtest $0 EXPLOG"
+ exit 2
+fi
+
+expect_lines() (
+ { set +x; } &>/dev/null
+ printf >&2 '+ diff -u expected.txt actual.txt\n'
+ diff -u <(printf '%s\n' "$@") <(printf '%s\n' "$out")
+)
+
+set -x
+client=(9p -a "localhost:${1}")
+
+out=$("${client[@]}" ls -l '')
+expect_lines \
+ 'd-r-xr-xr-x M 0 root root 0 Oct 7 2024 Documentation' \
+ '--r--r--r-- M 0 root root 166 Oct 7 2024 README.md' \
+ '---w--w--w- M 0 root root 0 Oct 7 2024 shutdown' \
+ '--r--r--r-- M 0 root root 6 Oct 7 2024 slowread' \
+ '--r--r--r-- M 0 root root 6 Oct 7 2024 slowread-flushable'
+
+out=$("${client[@]}" ls -l 'Documentation/')
+expect_lines \
+ '--r--r--r-- M 0 root root 166 Oct 7 2024 x'
+
+out=$("${client[@]}" read 'README.md')
+expect_lines \
+ '<!--' \
+ ' README.md - test static file' \
+ '' \
+ ' Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>' \
+ ' SPDX-License-Identifier: AGPL-3.0-or-later' \
+ '-->' \
+ 'Hello, world!'
+
+out=$("${client[@]}" read 'Documentation/x')
+expect_lines \
+ '<!--' \
+ ' Documentation/x.txt - test static file' \
+ '' \
+ ' Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>' \
+ ' SPDX-License-Identifier: AGPL-3.0-or-later' \
+ '-->' \
+ 'foo'
+
+out=$("${client[@]}" stat 'Documentation/x')
+expect_lines \
+ "'x' 'root' 'root' 'root' q (0000000000000003 1 ) m 0444 at 1728337905 mt 1728337904 l 166 t 0 d 0"
+
+out=$("${client[@]}" write 'shutdown' <<<1)
+expect_lines ''
diff --git a/lib9p/tests/testclient-p9p.explog b/lib9p/tests/testclient-p9p.explog
new file mode 100644
index 0000000..3bfb0b0
--- /dev/null
+++ b/lib9p/tests/testclient-p9p.explog
@@ -0,0 +1,106 @@
+# lib9p/tests/testclient-p9p.explog - Expected 9P logfile of testclient-p9p
+#
+# Copyright (C) 2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+> Tversion { tag=NOTAG max_msg_size=8192 version="9P2000" }
+< Rversion { tag=NOTAG max_msg_size=4120 version="9P2000" }
+> Tauth { tag=0 afid=0 uname="lukeshu" aname="" n_uid=0 }
+< Rerror { tag=0 errstr="authentication not required" errnum=95 }
+> Tattach { tag=0 fid=0 afid=NOFID uname="lukeshu" aname="" n_uid=0 }
+< Rattach { tag=0 qid={ type=(DIR) vers=1 path=1 } }
+> Twalk { tag=0 fid=0 newfid=1 nwname=0 wname=[ ] }
+< Rwalk { tag=0 nwqid=0 wqid=[ ] }
+> Tstat { tag=0 fid=1 }
+< Rstat { tag=0 stat={ kern_type=0 kern_dev=0 file_qid={ type=(DIR) vers=1 path=1 } file_mode=(DIR|0555) file_atime=1728337905 file_mtime=1728337904 file_size=0 file_name="" file_owner_uid="root" file_owner_gid="root" file_last_modified_uid="root" file_extension="" file_owner_n_uid=0 file_owner_n_gid=0 file_last_modified_n_uid=0 } }
+> Tclunk { tag=0 fid=1 }
+< Rclunk { tag=0 }
+> Twalk { tag=0 fid=0 newfid=1 nwname=0 wname=[ ] }
+< Rwalk { tag=0 nwqid=0 wqid=[ ] }
+> Topen { tag=0 fid=1 mode=(MODE_READ) }
+< Ropen { tag=0 qid={ type=(DIR) vers=1 path=1 } iounit=0 }
+> Tread { tag=0 fid=1 offset=0 count=4096 }
+< Rread { tag=0 count=361 data=<bytedata> }
+> Tread { tag=0 fid=1 offset=361 count=4096 }
+< Rread { tag=0 count=0 data="" }
+> Tclunk { tag=0 fid=1 }
+< Rclunk { tag=0 }
+> Tversion { tag=NOTAG max_msg_size=8192 version="9P2000" }
+< Rversion { tag=NOTAG max_msg_size=4120 version="9P2000" }
+> Tauth { tag=0 afid=0 uname="lukeshu" aname="" n_uid=0 }
+< Rerror { tag=0 errstr="authentication not required" errnum=95 }
+> Tattach { tag=0 fid=0 afid=NOFID uname="lukeshu" aname="" n_uid=0 }
+< Rattach { tag=0 qid={ type=(DIR) vers=1 path=1 } }
+> Twalk { tag=0 fid=0 newfid=1 nwname=1 wname=["Documentation" ] }
+< Rwalk { tag=0 nwqid=1 wqid=[{ type=(DIR) vers=1 path=2 } ] }
+> Tstat { tag=0 fid=1 }
+< Rstat { tag=0 stat={ kern_type=0 kern_dev=0 file_qid={ type=(DIR) vers=1 path=2 } file_mode=(DIR|0555) file_atime=1728337905 file_mtime=1728337904 file_size=0 file_name="Documentation" file_owner_uid="root" file_owner_gid="root" file_last_modified_uid="root" file_extension="" file_owner_n_uid=0 file_owner_n_gid=0 file_last_modified_n_uid=0 } }
+> Tclunk { tag=0 fid=1 }
+< Rclunk { tag=0 }
+> Twalk { tag=0 fid=0 newfid=1 nwname=1 wname=["Documentation" ] }
+< Rwalk { tag=0 nwqid=1 wqid=[{ type=(DIR) vers=1 path=2 } ] }
+> Topen { tag=0 fid=1 mode=(MODE_READ) }
+< Ropen { tag=0 qid={ type=(DIR) vers=1 path=2 } iounit=0 }
+> Tread { tag=0 fid=1 offset=0 count=4096 }
+< Rread { tag=0 count=62 data=<bytedata> }
+> Tread { tag=0 fid=1 offset=62 count=4096 }
+< Rread { tag=0 count=0 data="" }
+> Tclunk { tag=0 fid=1 }
+< Rclunk { tag=0 }
+> Tversion { tag=NOTAG max_msg_size=8192 version="9P2000" }
+< Rversion { tag=NOTAG max_msg_size=4120 version="9P2000" }
+> Tauth { tag=0 afid=0 uname="lukeshu" aname="" n_uid=0 }
+< Rerror { tag=0 errstr="authentication not required" errnum=95 }
+> Tattach { tag=0 fid=0 afid=NOFID uname="lukeshu" aname="" n_uid=0 }
+< Rattach { tag=0 qid={ type=(DIR) vers=1 path=1 } }
+> Twalk { tag=0 fid=0 newfid=1 nwname=1 wname=["README.md" ] }
+< Rwalk { tag=0 nwqid=1 wqid=[{ type=(0) vers=1 path=4 } ] }
+> Topen { tag=0 fid=1 mode=(MODE_READ) }
+< Ropen { tag=0 qid={ type=(0) vers=1 path=4 } iounit=0 }
+> Tread { tag=0 fid=1 offset=0 count=4096 }
+< Rread { tag=0 count=166 data="<!--\n README.md - test static file\n\n Copyright ("... }
+> Tread { tag=0 fid=1 offset=166 count=4096 }
+< Rread { tag=0 count=0 data="" }
+> Tclunk { tag=0 fid=1 }
+< Rclunk { tag=0 }
+> Tversion { tag=NOTAG max_msg_size=8192 version="9P2000" }
+< Rversion { tag=NOTAG max_msg_size=4120 version="9P2000" }
+> Tauth { tag=0 afid=0 uname="lukeshu" aname="" n_uid=0 }
+< Rerror { tag=0 errstr="authentication not required" errnum=95 }
+> Tattach { tag=0 fid=0 afid=NOFID uname="lukeshu" aname="" n_uid=0 }
+< Rattach { tag=0 qid={ type=(DIR) vers=1 path=1 } }
+> Twalk { tag=0 fid=0 newfid=1 nwname=2 wname=["Documentation", "x" ] }
+< Rwalk { tag=0 nwqid=2 wqid=[{ type=(DIR) vers=1 path=2 }, { type=(0) vers=1 path=3 } ] }
+> Topen { tag=0 fid=1 mode=(MODE_READ) }
+< Ropen { tag=0 qid={ type=(0) vers=1 path=3 } iounit=0 }
+> Tread { tag=0 fid=1 offset=0 count=4096 }
+< Rread { tag=0 count=166 data="<!--\n Documentation/x.txt - test static file\n\n C"... }
+> Tread { tag=0 fid=1 offset=166 count=4096 }
+< Rread { tag=0 count=0 data="" }
+> Tclunk { tag=0 fid=1 }
+< Rclunk { tag=0 }
+> Tversion { tag=NOTAG max_msg_size=8192 version="9P2000" }
+< Rversion { tag=NOTAG max_msg_size=4120 version="9P2000" }
+> Tauth { tag=0 afid=0 uname="lukeshu" aname="" n_uid=0 }
+< Rerror { tag=0 errstr="authentication not required" errnum=95 }
+> Tattach { tag=0 fid=0 afid=NOFID uname="lukeshu" aname="" n_uid=0 }
+< Rattach { tag=0 qid={ type=(DIR) vers=1 path=1 } }
+> Twalk { tag=0 fid=0 newfid=1 nwname=2 wname=["Documentation", "x" ] }
+< Rwalk { tag=0 nwqid=2 wqid=[{ type=(DIR) vers=1 path=2 }, { type=(0) vers=1 path=3 } ] }
+> Tstat { tag=0 fid=1 }
+< Rstat { tag=0 stat={ kern_type=0 kern_dev=0 file_qid={ type=(0) vers=1 path=3 } file_mode=(0444) file_atime=1728337905 file_mtime=1728337904 file_size=166 file_name="x" file_owner_uid="root" file_owner_gid="root" file_last_modified_uid="root" file_extension="" file_owner_n_uid=0 file_owner_n_gid=0 file_last_modified_n_uid=0 } }
+> Tclunk { tag=0 fid=1 }
+< Rclunk { tag=0 }
+> Tversion { tag=NOTAG max_msg_size=8192 version="9P2000" }
+< Rversion { tag=NOTAG max_msg_size=4120 version="9P2000" }
+> Tauth { tag=0 afid=0 uname="lukeshu" aname="" n_uid=0 }
+< Rerror { tag=0 errstr="authentication not required" errnum=95 }
+> Tattach { tag=0 fid=0 afid=NOFID uname="lukeshu" aname="" n_uid=0 }
+< Rattach { tag=0 qid={ type=(DIR) vers=1 path=1 } }
+> Twalk { tag=0 fid=0 newfid=1 nwname=1 wname=["shutdown" ] }
+< Rwalk { tag=0 nwqid=1 wqid=[{ type=(0) vers=1 path=5 } ] }
+> Topen { tag=0 fid=1 mode=(TRUNC|MODE_WRITE) }
+< Ropen { tag=0 qid={ type=(0) vers=1 path=5 } iounit=0 }
+> Twrite { tag=0 fid=1 offset=0 count=2 data="1\n" }
+< Rwrite { tag=0 count=2 }
+> Tclunk { tag=0 fid=1 }
+< Rclunk { tag=0 }
diff --git a/lib9p/tests/testclient-sess.c b/lib9p/tests/testclient-sess.c
new file mode 100644
index 0000000..423dc2c
--- /dev/null
+++ b/lib9p/tests/testclient-sess.c
@@ -0,0 +1,144 @@
+/* lib9p/tests/testclient-sess.c - Test the 9P `test_server`'s sessions
+ *
+ * Copyright (C) 2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <arpa/inet.h> /* for htons(), inet_addr() */
+#include <errno.h>
+#include <error.h>
+#include <netinet/in.h> /* for struct sockaddr{,_in} */
+#include <stdlib.h> /* for atoi() */
+#include <sys/socket.h> /* for socket(), connect() */
+#include <sys/uio.h> /* for writev() */
+#include <unistd.h> /* for read() */
+
+#include <libmisc/assert.h>
+#include <libmisc/endian.h>
+#include <lib9p/9p.h>
+
+#define MAX_MSG_SIZE (8*1024)
+
+static void _send9p(int fd, struct lib9p_ctx *ctx, enum lib9p_msg_type typ, void *body) {
+ struct lib9p_Tmsg_send_buf buf;
+ bool err = lib9p_Tmsg_marshal(ctx, typ, body, &buf);
+ assert(!err);
+ size_t exp = 0;
+ for (size_t i = 0; i < buf.iov_cnt; i++)
+ exp += buf.iov[i].iov_len;
+ ssize_t act = writev(fd, buf.iov, buf.iov_cnt);
+ if (act < 0)
+ error(1, errno, "writev");
+ assert((size_t)act == exp);
+}
+
+#define send9p(typ, ...) _send9p(fd, &ctx, LIB9P_TYP_##typ, &((struct lib9p_msg_##typ){ __VA_ARGS__ }))
+
+static void _recv9p(int fd) {
+ uint8_t buf[MAX_MSG_SIZE];
+ size_t goal = 4;
+ size_t done = 0;
+ while (done < goal) {
+ ssize_t n = read(fd, &buf[done], goal-done);
+ if (n < 0)
+ error(1, errno, "read");
+ done += n;
+ }
+ goal = uint32le_decode(buf);
+ assert(goal <= MAX_MSG_SIZE);
+ while (done < goal) {
+ ssize_t n = read(fd, &buf[done], goal-done);
+ if (n < 0)
+ error(1, errno, "read");
+ done += n;
+ }
+}
+
+#define recv9p() _recv9p(fd)
+
+int main(int argc, char *argv[]) {
+ if (argc != 2)
+ error(2, 0, "Usage: %s SERVER_PORT", argv[0]);
+ uint16_t server_port = atoi(argv[1]);
+
+ union {
+ struct sockaddr gen;
+ struct sockaddr_in in;
+ } server_addr = {};
+ server_addr.in.sin_family = AF_INET;
+ server_addr.in.sin_addr.s_addr = inet_addr("127.0.0.1");
+ server_addr.in.sin_port = htons(server_port);
+
+ int fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0)
+ error(1, errno, "socket");
+ if (connect(fd, &server_addr.gen, sizeof(server_addr)) < 0)
+ error(1, errno, "connect");
+
+ struct lib9p_ctx ctx = {
+ .max_msg_size = 16*1024,
+ };
+
+ struct lib9p_s wname[1];
+
+ /* numeric downgrade, unknown ext *************************************/
+ send9p(Tversion, .tag=0, .max_msg_size=57, .version=lib9p_str("9P2025.x"));
+ recv9p(); /* Rversion */
+ ctx.version = LIB9P_VER_9P2000;
+
+ /* numeric downgrade, known ext ***************************************/
+ send9p(Tversion, .tag=0, .max_msg_size=57, .version=lib9p_str("9P2025.u"));
+ recv9p(); /* Rversion */
+ ctx.version = LIB9P_VER_9P2000_u;
+
+ /* ext version ********************************************************/
+ send9p(Tversion, .tag=0, .max_msg_size=57, .version=lib9p_str("9P2000.u"));
+ recv9p(); /* Rversion */
+ ctx.version = LIB9P_VER_9P2000_u;
+
+ /* main session *******************************************************/
+ send9p(Tversion, .tag=0, .max_msg_size=(8*1024), .version=lib9p_str("9P2000"));
+ recv9p(); /* Rversion */
+ ctx.version = LIB9P_VER_9P2000;
+ send9p(Tattach, .tag=0, .fid=0, .afid=LIB9P_FID_NOFID, .uname=lib9p_str("nobody"), .aname=lib9p_str(""));
+ recv9p(); /* Rattach */
+
+ /* flush, but original response comes back first */
+ wname[0] = lib9p_str("slowread"); send9p(Twalk, .tag=0, .fid=0, .newfid=1, .nwname=1, .wname=wname);
+ recv9p(); /* Rwalk */
+ send9p(Topen, .tag=0, .fid=1, .mode=LIB9P_O_MODE_READ);
+ recv9p(); /* Ropen */
+ send9p(Tread, .tag=1, .fid=1, .offset=0, .count=6);
+ send9p(Tflush, .tag=2, .oldtag=1);
+ recv9p(); /* Rread */
+ recv9p(); /* Rflush */
+
+ /* flush, original request is aborted with error */
+ wname[0] = lib9p_str("slowread-flushable"); send9p(Twalk, .tag=1, .fid=0, .newfid=2, .nwname=1, .wname=wname);
+ recv9p(); /* Rwalk */
+ send9p(Topen, .tag=0, .fid=2, .mode=LIB9P_O_MODE_READ);
+ recv9p(); /* Ropen */
+ send9p(Tread, .tag=1, .fid=2, .offset=0, .count=6);
+ send9p(Tflush, .tag=2, .oldtag=1);
+ recv9p(); /* Rerror */
+ recv9p(); /* Rflush */
+
+ /* flush, unknown tag */
+ send9p(Tflush, .tag=0, .oldtag=99);
+ recv9p(); /* Rflush */
+
+ /* shutdown ***********************************************************/
+ send9p(Tversion, .tag=0, .max_msg_size=(8*1024), .version=lib9p_str("9P2000"));
+ recv9p(); /* Rversion */
+ ctx.version = LIB9P_VER_9P2000;
+ send9p(Tattach, .tag=0, .fid=0, .afid=LIB9P_FID_NOFID, .uname=lib9p_str("nobody"), .aname=lib9p_str(""));
+ recv9p(); /* Rattach */
+ /* check the newfid==fid case */
+ wname[0] = lib9p_str("shutdown"); send9p(Twalk, .tag=0, .fid=0, .newfid=0, .nwname=1, .wname=wname);
+ recv9p(); /* Rwalk */
+ send9p(Topen, .tag=0, .fid=0, .mode=LIB9P_O_MODE_WRITE);
+ recv9p(); /* Ropen */
+ send9p(Twrite, .tag=0, .fid=0, .offset=0, .count=2, .data="1\n");
+ recv9p(); /* Rwrite */
+ return 0;
+}
diff --git a/lib9p/tests/testclient-sess.explog b/lib9p/tests/testclient-sess.explog
new file mode 100644
index 0000000..b1f3085
--- /dev/null
+++ b/lib9p/tests/testclient-sess.explog
@@ -0,0 +1,58 @@
+# lib9p/tests/testclient-sess.explog - Expected 9P logfile of testclient-sess.c
+#
+# Copyright (C) 2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# numeric downgrade, unknown ext ###############################################
+> Tversion { tag=0 max_msg_size=57 version="9P2025.x" }
+< Rversion { tag=0 max_msg_size=57 version="9P2000" }
+
+# numeric downgrade, known ext #################################################
+> Tversion { tag=0 max_msg_size=57 version="9P2025.u" }
+< Rversion { tag=0 max_msg_size=57 version="9P2000.u" }
+
+# ext version ##################################################################
+> Tversion { tag=0 max_msg_size=57 version="9P2000.u" }
+< Rversion { tag=0 max_msg_size=57 version="9P2000.u" }
+
+# main session #################################################################
+> Tversion { tag=0 max_msg_size=8192 version="9P2000" }
+< Rversion { tag=0 max_msg_size=4120 version="9P2000" }
+> Tattach { tag=0 fid=0 afid=NOFID uname="nobody" aname="" n_uid=0 }
+< Rattach { tag=0 qid={ type=(DIR) vers=1 path=1 } }
+
+# flush, but original response comes back first
+> Twalk { tag=0 fid=0 newfid=1 nwname=1 wname=["slowread" ] }
+< Rwalk { tag=0 nwqid=1 wqid=[{ type=(0) vers=1 path=6 } ] }
+> Topen { tag=0 fid=1 mode=(MODE_READ) }
+< Ropen { tag=0 qid={ type=(0) vers=1 path=6 } iounit=0 }
+> Tread { tag=1 fid=1 offset=0 count=6 }
+> Tflush { tag=2 oldtag=1 }
+< Rread { tag=1 count=6 data="Sloth\n" }
+< Rflush { tag=2 }
+
+# flush, succeeds
+> Twalk { tag=1 fid=0 newfid=2 nwname=1 wname=["slowread-flushable" ] }
+< Rwalk { tag=1 nwqid=1 wqid=[{ type=(0) vers=1 path=7 } ] }
+> Topen { tag=0 fid=2 mode=(MODE_READ) }
+< Ropen { tag=0 qid={ type=(0) vers=1 path=7 } iounit=0 }
+> Tread { tag=1 fid=2 offset=0 count=6 }
+> Tflush { tag=2 oldtag=1 }
+< Rflush { tag=2 }
+< Rerror { tag=1 errstr="request canceled by flush" errnum=125 }
+
+# flush, unknown tag
+> Tflush { tag=0 oldtag=99 }
+< Rflush { tag=0 }
+
+# shutdown #####################################################################
+> Tversion { tag=0 max_msg_size=8192 version="9P2000" }
+< Rversion { tag=0 max_msg_size=4120 version="9P2000" }
+> Tattach { tag=0 fid=0 afid=NOFID uname="nobody" aname="" n_uid=0 }
+< Rattach { tag=0 qid={ type=(DIR) vers=1 path=1 } }
+> Twalk { tag=0 fid=0 newfid=0 nwname=1 wname=["shutdown" ] }
+< Rwalk { tag=0 nwqid=1 wqid=[{ type=(0) vers=1 path=5 } ] }
+> Topen { tag=0 fid=0 mode=(MODE_WRITE) }
+< Ropen { tag=0 qid={ type=(0) vers=1 path=5 } iounit=0 }
+> Twrite { tag=0 fid=0 offset=0 count=2 data="1\n" }
+< Rwrite { tag=0 count=2 }