diff --git a/tests/client.c b/tests/client.c
index 73b225dc..56407811 100644
--- a/tests/client.c
+++ b/tests/client.c
@@ -1,3 +1,12 @@
+/*
+ * A simple demonstration client for rustls-ffi.
+ *
+ * This client connects to an HTTPS server, sends an HTTP GET request, and
+ * prints the response to stdout.
+ *
+ * Notably it _does not_ attempt to implement the semantics of HTTP 1.1 by
+ * parsing the response and processing content-length or chunked encoding.
+ */
 #ifdef _WIN32
 #define WIN32_LEAN_AND_MEAN
 #include <windows.h>
@@ -12,388 +21,220 @@
 #endif
 
 #include <sys/types.h>
+#include <stdbool.h>
 #include <stdio.h>
 #include <string.h>
 #include <stdlib.h>
 #include <errno.h>
 
-/* rustls.h is autogenerated in the Makefile using cbindgen. */
 #include "rustls.h"
 #include "common.h"
+#include "client.h"
 
-/*
- * Connect to the given hostname on the given port and return the file
- * descriptor of the socket. Tries to connect up to 10 times. On error,
- * print the error and return 1. Caller is responsible for closing socket.
- */
 int
-make_conn(const char *hostname, const char *port)
+main(const int argc, const char **argv)
 {
-  int sockfd = 0;
-  struct addrinfo *getaddrinfo_output = NULL, hints = { 0 };
-  hints.ai_family = AF_INET;
-  hints.ai_socktype = SOCK_STREAM; /* looking for TCP */
+#ifdef _WIN32
+  WSADATA wsa;
+  WSAStartup(MAKEWORD(1, 1), &wsa);
+  setmode(STDOUT_FILENO, O_BINARY);
+#endif
+  const rustls_client_config *tls_config = NULL;
 
-  LOG("connecting to %s:%s", hostname, port);
-  const int getaddrinfo_result =
-    getaddrinfo(hostname, port, &hints, &getaddrinfo_output);
-  if(getaddrinfo_result != 0) {
-    LOG("getaddrinfo: %s", gai_strerror(getaddrinfo_result));
-    goto cleanup;
-  }
+  // Set the program name global variable for logging purposes.
+  programname = "client";
 
-  int connect_result = -1;
-  for(int attempts = 0; attempts < 10; attempts++) {
-    LOG("connect attempt %d", attempts);
-    sockfd = socket(getaddrinfo_output->ai_family,
-                    getaddrinfo_output->ai_socktype,
-                    getaddrinfo_output->ai_protocol);
-    if(sockfd < 0) {
-      perror("client: making socket");
-      sleep(1);
-      continue;
-    }
-    connect_result = connect(
-      sockfd, getaddrinfo_output->ai_addr, getaddrinfo_output->ai_addrlen);
-    if(connect_result < 0) {
-      if(sockfd > 0) {
-        close(sockfd);
-      }
-      perror("client: connecting");
-      sleep(1);
-      continue;
-    }
-    break;
-  }
-  if(connect_result < 0) {
-    perror("client: connecting");
+  // Process command line arguments.
+  int ret = 1;
+  if(argc <= 3) {
+    fprintf(
+      stderr,
+      "usage: %s hostname port path [numreqs]\n\n"
+      "Connect to a host via HTTPS on the provided port, make [numreqs] \n"
+      "requests for the given path, and emit each response to stdout.\n",
+      argv[0]);
     goto cleanup;
   }
-  const demo_result dr = nonblock(sockfd);
-  if(dr != DEMO_OK) {
-    return 1;
-  }
-
-  freeaddrinfo(getaddrinfo_output);
-  return sockfd;
+  const char *hostname = argv[1];
+  const char *port = argv[2];
+  const char *path = argv[3];
+  const char *numreqs = argc > 4 ? argv[4] : "3";
 
-cleanup:
-  if(getaddrinfo_output != NULL) {
-    freeaddrinfo(getaddrinfo_output);
+  char *end;
+  const long int numreqs_int = strtol(numreqs, &end, 10);
+  if(end == numreqs || *end != '\0') {
+    fprintf(stderr, "numreqs must be a positive integer\n");
+    goto cleanup;
   }
-  if(sockfd > 0) {
-    close(sockfd);
+  if(numreqs_int <= 0) {
+    fprintf(stderr, "numreqs must be a positive integer\n");
+    goto cleanup;
   }
-  return -1;
-}
-
-static const char *CONTENT_LENGTH = "Content-Length";
-
-/*
- * Given an established TCP connection, and a rustls_connection, send an
- * HTTP request and read the response. On success, return 0. On error, print
- * the message and return 1.
- */
-int
-send_request_and_read_response(conndata *conn, rustls_connection *rconn,
-                               const char *hostname, const char *path)
-{
-  const int sockfd = conn->fd;
-  int ret = 1;
-  char buf[2048];
-  fd_set read_fds;
-  fd_set write_fds;
-  size_t n = 0;
-  const char *content_length_end;
-  unsigned long content_length = 0;
-  size_t headers_len = 0;
 
-  const rustls_str version = rustls_version();
-  memset(buf, '\0', sizeof(buf));
-  snprintf(buf,
-           sizeof(buf),
-           "GET %s HTTP/1.1\r\n"
-           "Host: %s\r\n"
-           "User-Agent: %.*s\r\n"
-           "Accept: carcinization/inevitable, text/html\r\n"
-           "Connection: close\r\n"
-           "\r\n",
-           path,
-           hostname,
-           (int)version.len,
-           version.data);
-  /* First we write the plaintext - the data that we want rustls to encrypt for
-   * us- to the rustls connection. */
-  const rustls_result rr =
-    rustls_connection_write(rconn, (uint8_t *)buf, strlen(buf), &n);
-  if(rr != RUSTLS_RESULT_OK) {
-    LOG_SIMPLE("error writing plaintext bytes to rustls_connection");
+  // Build a demo client options struct based on the environment.
+  demo_client_options opts = { 0 };
+  if(options_from_env(&opts)) {
     goto cleanup;
   }
-  if(n != strlen(buf)) {
-    LOG_SIMPLE("short write writing plaintext bytes to rustls_connection");
+
+  // Build a rustls TLS client config with our client options.
+  tls_config = new_tls_config(&opts);
+  if(tls_config == NULL) {
     goto cleanup;
   }
 
-  for(;;) {
-    FD_ZERO(&read_fds);
-    /* These two calls just inspect the state of the connection - if it's time
-    for us to write more, or to read more. */
-    if(rustls_connection_wants_read(rconn)) {
-      FD_SET(sockfd, &read_fds);
-    }
-    FD_ZERO(&write_fds);
-    if(rustls_connection_wants_write(rconn)) {
-      FD_SET(sockfd, &write_fds);
-    }
+  // Describe the connection we're about to make.
+  const demo_client_request_options req_opts = {
+    .tls_config = tls_config,
+    .hostname = hostname,
+    .port = port,
+    .path = path,
+    .use_vectored_io = opts.use_vectored_io,
+  };
 
-    if(!rustls_connection_wants_read(rconn) &&
-       !rustls_connection_wants_write(rconn)) {
-      LOG_SIMPLE(
-        "rustls wants neither read nor write. drain plaintext and exit");
-      goto drain_plaintext;
-    }
+  // Make GET requests with the rustls client config.
+  for(int i = 0; i < numreqs_int; i++) {
+    LOG("request %d of %ld", i + 1, numreqs_int);
 
-    const int select_result =
-      select(sockfd + 1, &read_fds, &write_fds, NULL, NULL);
-    if(select_result == -1) {
-      perror("client: select");
+    if(do_get_request(&req_opts)) {
+      LOG("request %d of %ld FAILED", i + 1, numreqs_int);
       goto cleanup;
     }
 
-    demo_result dr = DEMO_ERROR;
-    if(FD_ISSET(sockfd, &read_fds)) {
-      /* Read all bytes until we get EAGAIN. Then loop again to wind up in
-         select awaiting the next bit of data. */
-      for(;;) {
-        dr = do_read(conn, rconn);
-        if(dr == DEMO_AGAIN) {
-          break;
-        }
-        else if(dr == DEMO_EOF) {
-          goto drain_plaintext;
-        }
-        else if(dr != DEMO_OK) {
-          goto cleanup;
-        }
-        if(headers_len == 0) {
-          const char *body = body_beginning(&conn->data);
-          if(body != NULL) {
-            headers_len = body - conn->data.data;
-            LOG("body began at %zu", headers_len);
-            const char *content_length_str =
-              get_first_header_value(conn->data.data,
-                                     headers_len,
-                                     CONTENT_LENGTH,
-                                     strlen(CONTENT_LENGTH),
-                                     &n);
-            if(content_length_str == NULL) {
-              LOG_SIMPLE("content length header not found");
-              goto cleanup;
-            }
-            content_length =
-              strtoul(content_length_str, (char **)&content_length_end, 10);
-            if(content_length_end == content_length_str) {
-              LOG("invalid Content-Length '%.*s'", (int)n, content_length_str);
-              goto cleanup;
-            }
-            LOG("content length %lu", content_length);
-          }
-        }
-        if(headers_len != 0 &&
-           conn->data.len >= headers_len + content_length) {
-          goto drain_plaintext;
-        }
-      }
-    }
-    if(FD_ISSET(sockfd, &write_fds)) {
-      for(;;) {
-        /* This invokes rustls_connection_write_tls. We pass a callback to
-         * that function. Rustls will pass a buffer to that callback with
-         * encrypted bytes, that we will write to `conn`. */
-        const rustls_io_result err = write_tls(rconn, conn, &n);
-        if(err != 0) {
-          LOG("error in rustls_connection_write_tls: errno %d", err);
-          goto cleanup;
-        }
-        if(dr == DEMO_AGAIN) {
-          break;
-        }
-        if(n == 0) {
-          // Writing was successful, but we wrote 0 bytes.
-          break;
-        }
-        LOG("wrote %zu bytes of data to socket", n);
-      }
-    }
+    LOG("request %d of %ld successful", i + 1, numreqs_int);
   }
+  ret = 0; // Success.
 
-  LOG_SIMPLE("send_request_and_read_response: loop fell through");
-
-drain_plaintext:; // NOTE: empty statement after label to allow var decls.
-  log_connection_info(rconn);
+cleanup:
+  // Free the rustls TLS config.
+  rustls_client_config_free(tls_config);
 
-  const demo_result dr = copy_plaintext_to_buffer(conn);
-  if(dr != DEMO_OK && dr != DEMO_EOF) {
-    goto cleanup;
-  }
-  LOG("writing %zu bytes to stdout", conn->data.len);
-  if(write(STDOUT_FILENO, conn->data.data, conn->data.len) < 0) {
-    LOG_SIMPLE("error writing to stderr");
-    goto cleanup;
-  }
-  ret = 0;
+#ifdef _WIN32
+  WSACleanup();
+#endif
 
-cleanup:
-  if(sockfd > 0) {
-    close(sockfd);
-  }
   return ret;
 }
 
-demo_result
-do_request(const rustls_client_config *client_config, const char *hostname,
-           const char *port,
-           const char *path) // NOLINT(bugprone-easily-swappable-parameters)
+int
+options_from_env(demo_client_options *opts)
 {
-  rustls_connection *rconn = NULL;
-  conndata *conn = NULL;
-  demo_result dr = DEMO_ERROR;
-  const int sockfd = make_conn(hostname, port);
-  if(sockfd < 0) {
-    // No perror because make_conn printed error already.
-    goto cleanup;
-  }
+  // Consider verifier options.
+  const char *use_platform_verifier = getenv("RUSTLS_PLATFORM_VERIFIER");
+  const char *use_ca_certificate_verifier = getenv("CA_FILE");
+  const char *use_no_verifier = getenv("NO_CHECK_CERTIFICATE");
 
-  const rustls_result rr =
-    rustls_client_connection_new(client_config, hostname, &rconn);
-  if(rr != RUSTLS_RESULT_OK) {
-    print_error("client_connection_new", rr);
-    goto cleanup;
+  if(use_platform_verifier) {
+    LOG_SIMPLE("using the platform verifier for certificate verification.");
+    opts->use_platform_verifier = true;
   }
-
-  conn = calloc(1, sizeof(conndata));
-  if(conn == NULL) {
-    goto cleanup;
+  else if(use_ca_certificate_verifier) {
+    LOG("using the CA file '%s' for certificate verification.",
+        use_ca_certificate_verifier);
+    opts->use_ca_certificate_verifier = use_ca_certificate_verifier;
   }
-  conn->rconn = rconn;
-  conn->fd = sockfd;
-  conn->verify_arg = "verify_arg";
-
-  rustls_connection_set_userdata(rconn, conn);
-  rustls_connection_set_log_callback(rconn, log_cb);
-
-  dr = send_request_and_read_response(conn, rconn, hostname, path);
-  if(dr != DEMO_OK) {
-    goto cleanup;
+  else if(use_no_verifier) {
+    LOG_SIMPLE("skipping certificate verification (DANGER!).");
+    opts->use_no_verifier = true;
+  }
+  else {
+    LOG_SIMPLE("must set RUSTLS_PLATFORM_VERIFIER, CA_FILE or "
+               "NO_CHECK_CERTIFICATE env var");
+    return 1;
   }
 
-  dr = DEMO_OK;
-
-cleanup:
-  rustls_connection_free(rconn);
-  if(sockfd > 0) {
-    close(sockfd);
+  // Consider client auth options.
+  const char *auth_cert = getenv("AUTH_CERT");
+  const char *auth_key = getenv("AUTH_KEY");
+  if(auth_cert && auth_key) {
+    LOG("using client auth with cert '%s' and key '%s'", auth_cert, auth_key);
+    opts->use_auth_cert_file = auth_cert;
+    opts->use_auth_cert_key_file = auth_key;
   }
-  if(conn != NULL) {
-    if(conn->data.data != NULL) {
-      free(conn->data.data);
-    }
-    free(conn);
+  else if(auth_cert || auth_key) {
+    LOG_SIMPLE("must set both or neither of AUTH_CERT and AUTH_KEY env vars");
+    return 1;
   }
-  return dr;
-}
-
-uint32_t
-verify(void *userdata, const rustls_verify_server_cert_params *params)
-{
-  size_t i = 0;
-  const rustls_slice_slice_bytes *intermediates =
-    params->intermediate_certs_der;
-  const size_t intermediates_len = rustls_slice_slice_bytes_len(intermediates);
-  const conndata *conn = (struct conndata *)userdata;
 
-  LOG("custom certificate verifier called for %.*s",
-      (int)params->server_name.len,
-      params->server_name.data);
-  LOG("end entity len: %zu", params->end_entity_cert_der.len);
-  LOG_SIMPLE("intermediates:");
-  for(i = 0; i < intermediates_len; i++) {
-    const rustls_slice_bytes bytes =
-      rustls_slice_slice_bytes_get(intermediates, i);
-    if(bytes.data != NULL) {
-      LOG("   intermediate, len = %zu", bytes.len);
-    }
+  // Consider ECH options.
+  const char *ech_grease = getenv("ECH_GREASE");
+  const char *ech_config_lists = getenv("ECH_CONFIG_LIST");
+  if(ech_grease && ech_config_lists) {
+    LOG_SIMPLE(
+      "must set at most one of ECH_GREASE or ECH_CONFIG_LIST env vars");
+    return 1;
   }
-  LOG("ocsp response len: %zu", params->ocsp_response.len);
-  if(0 != strcmp(conn->verify_arg, "verify_arg")) {
-    LOG("invalid argument to verify: %p", userdata);
-    return RUSTLS_RESULT_GENERAL;
+  else if(ech_grease) {
+    LOG_SIMPLE("using ECH grease");
+    opts->use_ech_grease = true;
+  }
+  else if(ech_config_lists) {
+    LOG("using ECH config lists '%s'", ech_config_lists);
+    opts->use_ech_config_list_files = ech_config_lists;
   }
-  return RUSTLS_RESULT_OK;
-}
-
-int
-main(const int argc, const char **argv)
-{
-  int ret = 1;
 
-  if(argc <= 3) {
-    fprintf(
-      stderr,
-      "usage: %s hostname port path [numreqs]\n\n"
-      "Connect to a host via HTTPS on the provided port, make [numreqs] \n"
-      "requests for the given path, and emit each response to stdout.\n",
-      argv[0]);
+  // Consider SSLKEYLOGFILE options.
+  const char *sslkeylogfile = getenv("SSLKEYLOGFILE");
+  const char *stderrkeylog = getenv("STDERRKEYLOG");
+  if(sslkeylogfile && stderrkeylog) {
+    LOG_SIMPLE(
+      "must set at most one of SSLKEYLOGFILE or STDERRKEYLOG env vars");
     return 1;
   }
-  const char *hostname = argv[1];
-  const char *port = argv[2];
-  const char *path = argv[3];
-  const char *numreqs = argc > 4 ? argv[4] : "3";
+  if(sslkeylogfile) {
+    opts->use_ssl_keylog_file = sslkeylogfile;
+    LOG("using SSLKEYLOGFILE '%s'", opts->use_ssl_keylog_file);
+  }
+  else if(stderrkeylog) {
+    opts->use_stderr_keylog = true;
+    LOG_SIMPLE("using stderr for keylog output");
+  }
 
-  char *end;
-  const long int numreqs_int = strtol(numreqs, &end, 10);
-  if(end == numreqs || *end != '\0') {
-    fprintf(stderr, "numreqs must be a positive integer\n");
-    return 1;
+  // Consider custom ciphersuite name option.
+  const char *custom_ciphersuite_name = getenv("RUSTLS_CIPHERSUITE");
+  if(custom_ciphersuite_name) {
+    opts->custom_ciphersuite_name = custom_ciphersuite_name;
+    LOG("using custom ciphersuite '%s'", opts->custom_ciphersuite_name);
   }
-  if(numreqs_int <= 0) {
-    fprintf(stderr, "numreqs must be a positive integer\n");
-    return 1;
+
+  // Consider vectored I/O (if supported)
+#if !defined(_WIN32)
+  if(getenv("USE_VECTORED_IO")) {
+    LOG_SIMPLE("using vectored I/O");
+    opts->use_vectored_io = true;
   }
+#endif
 
-  /* Set this global variable for logging purposes. */
-  programname = "client";
+  return 0;
+}
+
+const rustls_client_config *
+new_tls_config(const demo_client_options *opts)
+{
+  const rustls_client_config *result = NULL;
+  if(opts == NULL) {
+    return result;
+  }
 
+  // Initialize things we may need to clean up.
   const rustls_crypto_provider *custom_provider = NULL;
   rustls_client_config_builder *config_builder = NULL;
-  rustls_root_cert_store_builder *server_cert_root_store_builder = NULL;
-  const rustls_root_cert_store *server_cert_root_store = NULL;
-  const rustls_client_config *client_config = NULL;
   rustls_web_pki_server_cert_verifier_builder *server_cert_verifier_builder =
     NULL;
   rustls_server_cert_verifier *server_cert_verifier = NULL;
-  rustls_slice_bytes alpn_http11;
-  const rustls_certified_key *certified_key = NULL;
-
-  alpn_http11.data = (unsigned char *)"http/1.1";
-  alpn_http11.len = 8;
-
-#ifdef _WIN32
-  WSADATA wsa;
-  WSAStartup(MAKEWORD(1, 1), &wsa);
-  setmode(STDOUT_FILENO, O_BINARY);
-#endif
+  rustls_root_cert_store_builder *server_cert_root_store_builder = NULL;
+  const rustls_root_cert_store *server_cert_root_store = NULL;
 
-  const char *custom_ciphersuite_name = getenv("RUSTLS_CIPHERSUITE");
-  if(custom_ciphersuite_name != NULL) {
+  // First, construct the client config builder. If the user has requested
+  // a custom ciphersuite, we first build a custom crypto provider that
+  // has only that suite, and then build the config builder with that.
+  if(opts->custom_ciphersuite_name != NULL) {
     custom_provider =
-      default_provider_with_custom_ciphersuite(custom_ciphersuite_name);
+      default_provider_with_custom_ciphersuite(opts->custom_ciphersuite_name);
     if(custom_provider == NULL) {
       goto cleanup;
     }
-    printf("customized to use ciphersuite: %s\n", custom_ciphersuite_name);
+    LOG("customized to use ciphersuite: %s", opts->custom_ciphersuite_name);
 
     const rustls_result rr =
       rustls_client_config_builder_new_custom(custom_provider,
@@ -401,7 +242,7 @@ main(const int argc, const char **argv)
                                               default_tls_versions_len,
                                               &config_builder);
     if(rr != RUSTLS_RESULT_OK) {
-      print_error("creating client config builder", rr);
+      print_error("creating custom client config builder", rr);
       goto cleanup;
     }
   }
@@ -409,33 +250,70 @@ main(const int argc, const char **argv)
     config_builder = rustls_client_config_builder_new();
   }
 
-  const char *rustls_ech_grease = getenv("RUSTLS_ECH_GREASE");
-  const char *rustls_ech_config_list = getenv("RUSTLS_ECH_CONFIG_LIST");
-  if(rustls_ech_grease) {
+  // Then configure a verifier for the client config builder.
+  if(opts->use_platform_verifier) {
+    const rustls_result rr =
+      rustls_platform_server_cert_verifier(&server_cert_verifier);
+    if(rr != RUSTLS_RESULT_OK) {
+      print_error("failed to construct platform verifier", rr);
+      goto cleanup;
+    }
+    rustls_client_config_builder_set_server_verifier(config_builder,
+                                                     server_cert_verifier);
+  }
+  else if(opts->use_ca_certificate_verifier != NULL) {
+    server_cert_root_store_builder = rustls_root_cert_store_builder_new();
+    rustls_result rr = rustls_root_cert_store_builder_load_roots_from_file(
+      server_cert_root_store_builder, opts->use_ca_certificate_verifier, true);
+    if(rr != RUSTLS_RESULT_OK) {
+      print_error("loading trusted certificates", rr);
+      goto cleanup;
+    }
+    rr = rustls_root_cert_store_builder_build(server_cert_root_store_builder,
+                                              &server_cert_root_store);
+    if(rr != RUSTLS_RESULT_OK) {
+      goto cleanup;
+    }
+    server_cert_verifier_builder =
+      rustls_web_pki_server_cert_verifier_builder_new(server_cert_root_store);
+
+    rr = rustls_web_pki_server_cert_verifier_builder_build(
+      server_cert_verifier_builder, &server_cert_verifier);
+    if(rr != RUSTLS_RESULT_OK) {
+      goto cleanup;
+    }
+    rustls_client_config_builder_set_server_verifier(config_builder,
+                                                     server_cert_verifier);
+  }
+  else if(opts->use_no_verifier) {
+    rustls_client_config_builder_dangerous_set_certificate_verifier(
+      config_builder, unsafe_skip_verify);
+  }
+
+  // Then configure ECH if required.
+  if(opts->use_ech_grease) {
     const rustls_hpke *hpke = rustls_supported_hpke();
     if(hpke == NULL) {
-      fprintf(stderr, "client: no HPKE suites for ECH available\n");
+      LOG_SIMPLE("client: no HPKE suites for ECH available");
       goto cleanup;
     }
-
-    result =
+    const rustls_result rr =
       rustls_client_config_builder_enable_ech_grease(config_builder, hpke);
-    if(result != RUSTLS_RESULT_OK) {
-      fprintf(stderr, "client: failed to configure ECH GREASE\n");
+    if(rr != RUSTLS_RESULT_OK) {
+      print_error("enabling ECH GREASE", rr);
       goto cleanup;
     }
-    fprintf(stderr, "configured for ECH GREASE\n");
   }
-  else if(rustls_ech_config_list) {
+  else if(opts->use_ech_config_list_files) {
     const rustls_hpke *hpke = rustls_supported_hpke();
     if(hpke == NULL) {
-      fprintf(stderr, "client: no HPKE suites for ECH available\n");
+      LOG_SIMPLE("client: no HPKE suites for ECH available");
       goto cleanup;
     }
 
-    // Duplicate the ENV var value - calling STRTOK_R will modify the string
-    // to add null terminators between tokens.
-    char *ech_config_list_copy = strdup(rustls_ech_config_list);
+    // Duplicate the config lists var value - calling STRTOK_R will modify the
+    // string to add null terminators between tokens.
+    char *ech_config_list_copy = strdup(opts->use_ech_config_list_files);
     if(!ech_config_list_copy) {
       LOG_SIMPLE("failed to allocate memory for ECH config list");
       goto cleanup;
@@ -476,14 +354,14 @@ main(const int argc, const char **argv)
 
       // Try to enable ECH with the config list. This may error if none
       // of the ECH configs are valid/compatible.
-      result =
+      const rustls_result rr =
         rustls_client_config_builder_enable_ech(config_builder,
                                                 (uint8_t *)ech_config_list_buf,
                                                 ech_config_list_len,
                                                 hpke);
 
       // If we successfully configured ECH with the config list then break.
-      if(result == RUSTLS_RESULT_OK) {
+      if(rr == RUSTLS_RESULT_OK) {
         LOG("using ECH with config list from '%s'", ech_config_list_path);
         ech_configured = true;
         break;
@@ -504,51 +382,23 @@ main(const int argc, const char **argv)
     }
   }
 
-  if(getenv("RUSTLS_PLATFORM_VERIFIER")) {
-    const rustls_result rr =
-      rustls_platform_server_cert_verifier(&server_cert_verifier);
-    if(rr != RUSTLS_RESULT_OK) {
-      print_error("client: failed to construct platform verifier", rr);
+  // Then configure client authentication if required.
+  if(opts->use_auth_cert_file != NULL &&
+     opts->use_auth_cert_key_file != NULL) {
+    const rustls_certified_key *certified_key = load_cert_and_key(
+      opts->use_auth_cert_file, opts->use_auth_cert_key_file);
+    if(certified_key == NULL) {
       goto cleanup;
     }
-    rustls_client_config_builder_set_server_verifier(config_builder,
-                                                     server_cert_verifier);
-  }
-  else if(getenv("CA_FILE")) {
-    server_cert_root_store_builder = rustls_root_cert_store_builder_new();
-    rustls_result rr = rustls_root_cert_store_builder_load_roots_from_file(
-      server_cert_root_store_builder, getenv("CA_FILE"), true);
-    if(rr != RUSTLS_RESULT_OK) {
-      print_error("loading trusted certificates", rr);
-      goto cleanup;
-    }
-    rr = rustls_root_cert_store_builder_build(server_cert_root_store_builder,
-                                              &server_cert_root_store);
-    if(rr != RUSTLS_RESULT_OK) {
-      goto cleanup;
-    }
-    server_cert_verifier_builder =
-      rustls_web_pki_server_cert_verifier_builder_new(server_cert_root_store);
-
-    rr = rustls_web_pki_server_cert_verifier_builder_build(
-      server_cert_verifier_builder, &server_cert_verifier);
-    if(rr != RUSTLS_RESULT_OK) {
-      goto cleanup;
-    }
-    rustls_client_config_builder_set_server_verifier(config_builder,
-                                                     server_cert_verifier);
-  }
-  else if(getenv("NO_CHECK_CERTIFICATE")) {
-    rustls_client_config_builder_dangerous_set_certificate_verifier(
-      config_builder, verify);
-  }
-  else {
-    LOG_SIMPLE("must set either RUSTLS_PLATFORM_VERIFIER or CA_FILE or "
-               "NO_CHECK_CERTIFICATE env var");
-    goto cleanup;
+    rustls_client_config_builder_set_certified_key(
+      config_builder, &certified_key, 1);
+    // Per docs we are allowed to free the certified key after giving it to the
+    // builder.
+    rustls_certified_key_free(certified_key);
   }
 
-  if(getenv("SSLKEYLOGFILE")) {
+  // Then configure SSLKEYLOG as required
+  if(opts->use_ssl_keylog_file != NULL) {
     const rustls_result rr =
       rustls_client_config_builder_set_key_log_file(config_builder);
     if(rr != RUSTLS_RESULT_OK) {
@@ -556,7 +406,7 @@ main(const int argc, const char **argv)
       goto cleanup;
     }
   }
-  else if(getenv("STDERRKEYLOG")) {
+  else if(opts->use_stderr_keylog) {
     const rustls_result rr = rustls_client_config_builder_set_key_log(
       config_builder, stderr_key_log_cb, NULL);
     if(rr != RUSTLS_RESULT_OK) {
@@ -565,54 +415,481 @@ main(const int argc, const char **argv)
     }
   }
 
-  char *auth_cert = getenv("AUTH_CERT");
-  char *auth_key = getenv("AUTH_KEY");
-  if((auth_cert && !auth_key) || (!auth_cert && auth_key)) {
-    LOG_SIMPLE("must set both AUTH_CERT and AUTH_KEY env vars, or neither");
+  // Then configure ALPN.
+  rustls_slice_bytes alpn_http11 = { .data = (unsigned char *)"http/1.1",
+                                     .len = 8 };
+  rustls_result rr = rustls_client_config_builder_set_alpn_protocols(
+    config_builder, &alpn_http11, 1);
+  if(rr != RUSTLS_RESULT_OK) {
+    print_error("setting ALPN", rr);
     goto cleanup;
   }
-  else if(auth_cert && auth_key) {
-    certified_key = load_cert_and_key(auth_cert, auth_key);
-    if(certified_key == NULL) {
+
+  // Finally consume the config_builder by trying to build it into a client
+  // config. We can't use the config_builder (even to free it!) after this
+  // point.
+  rr = rustls_client_config_builder_build(config_builder, &result);
+  config_builder = NULL;
+  if(rr != RUSTLS_RESULT_OK) {
+    print_error("building client config builder", rr);
+    goto cleanup;
+  }
+
+cleanup:
+  rustls_root_cert_store_builder_free(server_cert_root_store_builder);
+  rustls_root_cert_store_free(server_cert_root_store);
+  rustls_web_pki_server_cert_verifier_builder_free(
+    server_cert_verifier_builder);
+  rustls_server_cert_verifier_free(server_cert_verifier);
+  rustls_crypto_provider_free(custom_provider);
+  rustls_client_config_builder_free(config_builder);
+  return result;
+}
+
+int
+do_get_request(const demo_client_request_options *options)
+{
+  if(options == NULL || options->tls_config == NULL ||
+     options->hostname == NULL || options->port == NULL ||
+     options->path == NULL) {
+    return 1;
+  }
+
+  int ret = 1;
+  LOG("making GET request to https://%s:%s%s",
+      options->hostname,
+      options->port,
+      options->path);
+
+  // Construct a new connection to the server.
+  demo_client_connection *demo_conn = demo_client_connect(options);
+
+  // Write a plaintext HTTP GET request.
+  if(demo_client_connection_write_get(demo_conn)) {
+    goto cleanup;
+  }
+
+  // Process I/O with select().
+  struct timeval timeout;
+  timeout.tv_sec = 4; // Picked arbitrarily.
+  timeout.tv_usec = 0;
+  fd_set read_fds;
+  fd_set write_fds;
+  for(;;) {
+    FD_ZERO(&read_fds);
+    FD_ZERO(&write_fds);
+
+    if(rustls_connection_wants_read(demo_conn->rconn)) {
+      FD_SET(demo_conn->sockfd, &read_fds);
+    }
+    if(rustls_connection_wants_write(demo_conn->rconn)) {
+      FD_SET(demo_conn->sockfd, &write_fds);
+    }
+
+    if(!rustls_connection_wants_read(demo_conn->rconn) &&
+       !rustls_connection_wants_write(demo_conn->rconn)) {
+      LOG_SIMPLE("rustls wants neither read nor write. Breaking i/o loop.");
+      break;
+    }
+
+    const int select_result =
+      select(demo_conn->sockfd + 1, &read_fds, &write_fds, NULL, &timeout);
+    if(select_result == -1) {
+      perror("client: select");
       goto cleanup;
     }
-    rustls_client_config_builder_set_certified_key(
-      config_builder, &certified_key, 1);
+    else if(select_result == 0) {
+      LOG_SIMPLE("select timed out");
+      break;
+    }
+
+    // If we can read data from the socket, read it and pass it to rustls.
+    if(FD_ISSET(demo_conn->sockfd, &read_fds)) {
+      LOG_SIMPLE("doing TLS reads");
+      const demo_result dr = demo_client_connection_read_tls(demo_conn);
+      if(dr == DEMO_ERROR || dr == DEMO_EOF) {
+        demo_conn->closing = true;
+      }
+      if(dr == DEMO_AGAIN) {
+        LOG_SIMPLE("reading from socket: EAGAIN or EWOULDBLOCK");
+        continue;
+      }
+    }
+
+    // If we can write data to the socket, write whatever rustls has queued.
+    if(FD_ISSET(demo_conn->sockfd, &write_fds)) {
+      LOG_SIMPLE("doing TLS writes");
+      const demo_result dr = demo_client_connection_write_tls(demo_conn);
+      if(dr == DEMO_ERROR) {
+        demo_conn->closing = true;
+      }
+      if(dr == DEMO_AGAIN) {
+        LOG_SIMPLE("writing to socket: EAGAIN or EWOULDBLOCK");
+        continue;
+      }
+    }
+
+    // Handle closure.
+    if(demo_conn->closing) {
+      LOG("Connection closed. Clean? %s",
+          demo_conn->clean_closure ? "yes" : "no");
+      // fail result if it wasn't a clean closure.
+      ret = !demo_conn->clean_closure;
+      break;
+    }
+  }
+  LOG_SIMPLE("I/O loop fell through");
+  log_connection_info(demo_conn->rconn);
+
+  // Print whatever is in the user data buffer.
+  // TODO(@cpu): refactor conndata struct to avoid "data data data" naming
+  //  when digging in to the conndata's bytevec's data.
+  const char *data = demo_conn->data->data.data;
+  const size_t data_len = demo_conn->data->data.len;
+  if(data_len > 0) {
+    LOG("writing %zu plaintext response bytes to stdout", data_len);
+    if(write(STDOUT_FILENO, data, data_len) < 0) {
+      LOG_SIMPLE("error writing to stderr");
+      goto cleanup;
+    }
+  }
+  else if(ret == 0) {
+    LOG_SIMPLE("no plaintext response data was read");
+    ret = 1;
   }
 
-  rustls_client_config_builder_set_alpn_protocols(
-    config_builder, &alpn_http11, 1);
+cleanup:
+  // Free connection resources and return.
+  demo_client_connection_free(demo_conn);
+  return ret;
+}
+
+demo_client_connection *
+demo_client_connect(const demo_client_request_options *options)
+{
+  if(options == NULL) {
+    return NULL;
+  }
+
+  conndata *data = NULL;
+  demo_client_connection *demo_conn = NULL;
+
+  demo_conn = calloc(1, sizeof(demo_client_connection));
+  if(demo_conn == NULL) {
+    perror("demo_client_connection calloc");
+    goto cleanup;
+  }
+  demo_conn->options = options;
+
+  // Connect the TCP socket.
+  const int sockfd = connect_socket(demo_conn->options);
+  if(sockfd <= 0) {
+    perror("client: connect_socket");
+    goto cleanup;
+  }
+  LOG_SIMPLE("socket connected");
+  demo_conn->sockfd = sockfd;
 
-  const rustls_result rr =
-    rustls_client_config_builder_build(config_builder, &client_config);
+  // Construct the rustls request with the client config.
+  const rustls_result rr = rustls_client_connection_new(
+    options->tls_config, options->hostname, &demo_conn->rconn);
   if(rr != RUSTLS_RESULT_OK) {
-    print_error("building client config", rr);
+    print_error("client_connection_new", rr);
     goto cleanup;
   }
 
-  for(int i = 0; i < numreqs_int; i++) {
-    const demo_result dr = do_request(client_config, hostname, port, path);
-    if(dr != DEMO_OK) {
-      goto cleanup;
+  data = calloc(1, sizeof(conndata));
+  if(data == NULL) {
+    perror("client: conndata calloc");
+    goto cleanup;
+  }
+  data->rconn = demo_conn->rconn;
+  data->fd = demo_conn->sockfd;
+  data->verify_arg = "verify_arg";
+  demo_conn->data = data;
+
+  rustls_connection_set_userdata(demo_conn->rconn, data);
+  rustls_connection_set_log_callback(demo_conn->rconn, log_cb);
+
+  return demo_conn;
+
+cleanup:
+  if(demo_conn != NULL) {
+    demo_client_connection_free(demo_conn);
+  }
+
+  return NULL;
+}
+
+int
+connect_socket(const demo_client_request_options *options)
+{
+  if(options == NULL) {
+    return -1;
+  }
+
+  int sockfd = 0;
+  struct addrinfo *getaddrinfo_output = NULL, hints = { 0 };
+  hints.ai_family = AF_INET;
+  hints.ai_socktype = SOCK_STREAM; /* looking for TCP */
+
+  const int getaddrinfo_result =
+    getaddrinfo(options->hostname, options->port, &hints, &getaddrinfo_output);
+  if(getaddrinfo_result != 0) {
+    LOG("getaddrinfo: %s", gai_strerror(getaddrinfo_result));
+    goto cleanup;
+  }
+
+  int connect_result = -1;
+  for(int attempts = 0; attempts < MAX_CONNECT_ATTEMPTS; attempts++) {
+    LOG("connect attempt %d of %d", attempts + 1, MAX_CONNECT_ATTEMPTS);
+    sockfd = socket(getaddrinfo_output->ai_family,
+                    getaddrinfo_output->ai_socktype,
+                    getaddrinfo_output->ai_protocol);
+    if(sockfd < 0) {
+      perror("client: making socket");
+      sleep(1);
+      continue;
     }
+    connect_result = connect(
+      sockfd, getaddrinfo_output->ai_addr, getaddrinfo_output->ai_addrlen);
+    if(connect_result < 0) {
+      if(sockfd > 0) {
+        close(sockfd);
+      }
+      perror("client: connecting");
+      sleep(1);
+      continue;
+    }
+    break;
+  }
+  if(connect_result < 0) {
+    perror("client: connecting");
+    goto cleanup;
+  }
+  const demo_result dr = nonblock(sockfd);
+  if(dr != DEMO_OK) {
+    // no need to perror() - nonblock() already did.
+    return -1;
   }
 
-  // Success!
-  ret = 0;
+  freeaddrinfo(getaddrinfo_output);
+  return sockfd; // Success
 
 cleanup:
-  rustls_root_cert_store_builder_free(server_cert_root_store_builder);
-  rustls_root_cert_store_free(server_cert_root_store);
-  rustls_web_pki_server_cert_verifier_builder_free(
-    server_cert_verifier_builder);
-  rustls_server_cert_verifier_free(server_cert_verifier);
-  rustls_certified_key_free(certified_key);
-  rustls_client_config_free(client_config);
-  rustls_crypto_provider_free(custom_provider);
+  if(getaddrinfo_output != NULL) {
+    freeaddrinfo(getaddrinfo_output);
+  }
+  if(sockfd > 0) {
+    close(sockfd);
+  }
+  return -1;
+}
 
-#ifdef _WIN32
-  WSACleanup();
+int
+demo_client_connection_write_get(const demo_client_connection *demo_conn)
+{
+  if(demo_conn == NULL || demo_conn->options == NULL) {
+    return 1;
+  }
+
+  // Construct a plaintext HTTP request buffer.
+  const rustls_str version = rustls_version();
+  char get_request_buf[2048];
+  int get_request_size =
+    snprintf(get_request_buf,
+             sizeof(get_request_buf),
+             "GET %s HTTP/1.1\r\n"
+             "Host: %s\r\n"
+             "User-Agent: %.*s\r\n"
+             "Accept: carcinization/inevitable, text/html\r\n"
+             "Connection: close\r\n"
+             "\r\n",
+             demo_conn->options->path,
+             demo_conn->options->hostname,
+             (int)version.len,
+             version.data);
+
+  // Write the plaintext to the rustls connection.
+  size_t n = 0;
+  const rustls_result rr = rustls_connection_write(
+    demo_conn->rconn, (uint8_t *)get_request_buf, get_request_size, &n);
+  if(rr != RUSTLS_RESULT_OK) {
+    LOG_SIMPLE(
+      "error writing plaintext GET request bytes to rustls_connection");
+    return 1;
+  }
+  if(n != (size_t)get_request_size) {
+    LOG_SIMPLE(
+      "short write writing plaintext GET request bytes to rustls_connection");
+    return 1;
+  }
+
+  return 0; // Success.
+}
+
+demo_result
+demo_client_connection_read_tls(demo_client_connection *demo_conn)
+{
+  size_t n = 0;
+
+  const rustls_io_result io_res =
+    rustls_connection_read_tls(demo_conn->rconn, read_cb, demo_conn->data, &n);
+
+  if(io_res == EAGAIN) {
+    return DEMO_AGAIN;
+  }
+  else if(io_res != 0) {
+    LOG("reading from socket failed: errno %d", io_res);
+    return DEMO_ERROR;
+  }
+
+  if(n == 0) {
+    LOG_SIMPLE("read 0 bytes from socket, connection closed");
+    demo_conn->closing = true;
+    demo_conn->clean_closure = true;
+    return DEMO_EOF;
+  }
+
+  rustls_result rr = rustls_connection_process_new_packets(demo_conn->rconn);
+  if(rr != RUSTLS_RESULT_OK) {
+    print_error("processing new TLS packets", rr);
+    return DEMO_ERROR;
+  }
+  LOG("read %zu TLS bytes from socket", n);
+
+  bytevec *bv = &demo_conn->data->data;
+  if(bytevec_ensure_available(bv, 1024) != DEMO_OK) {
+    return DEMO_ERROR;
+  }
+
+  for(;;) {
+    char *buf = bytevec_writeable(bv);
+    const size_t avail = bytevec_available(bv);
+
+    rr = rustls_connection_read(demo_conn->rconn, (uint8_t *)buf, avail, &n);
+    if(rr == RUSTLS_RESULT_PLAINTEXT_EMPTY) {
+      /* This is expected. It just means "no more bytes for now." */
+      return DEMO_OK;
+    }
+    if(rr != RUSTLS_RESULT_OK) {
+      print_error("error in rustls_connection_read", rr);
+      return DEMO_ERROR;
+    }
+    if(n == 0) {
+      break;
+    }
+    bytevec_consume(bv, n);
+    if(bytevec_ensure_available(bv, 1024) != DEMO_OK) {
+      return DEMO_ERROR;
+    }
+  }
+
+  /* If we got an EOF on the plaintext stream (peer closed connection cleanly),
+   * verify that the sender then closed the TCP connection. */
+  char buf[1];
+  const ssize_t signed_n = read(demo_conn->sockfd, buf, sizeof(buf));
+  if(signed_n > 0) {
+    LOG("error: read returned %zu bytes after receiving close_notify", n);
+    return DEMO_ERROR;
+  }
+  else if(signed_n < 0 && errno != EWOULDBLOCK) {
+    LOG("wrong error after receiving close_notify: %s", strerror(errno));
+    return DEMO_ERROR;
+  }
+  demo_conn->closing = true;
+  demo_conn->clean_closure = true;
+  return DEMO_EOF;
+}
+
+demo_result
+demo_client_connection_write_tls(const demo_client_connection *demo_conn)
+{
+  if(demo_conn == NULL || demo_conn->options == NULL) {
+    return DEMO_ERROR;
+  }
+
+  size_t n;
+  rustls_io_result io_res;
+
+#if !defined(_WIN32)
+  if(demo_conn->options->use_vectored_io) {
+    io_res = rustls_connection_write_tls_vectored(
+      demo_conn->rconn, write_vectored_cb, demo_conn->data, &n);
+  }
+  else {
+    io_res = rustls_connection_write_tls(
+      demo_conn->rconn, write_cb, demo_conn->data, &n);
+  }
+#else
+  io_res = rustls_connection_write_tls(
+    demo_conn->rconn, write_cb, demo_conn->data, &n);
 #endif
 
-  return ret;
+  if(io_res == EAGAIN) {
+    return DEMO_AGAIN;
+  }
+  else if(io_res != 0) {
+    LOG("writing to socket failed: errno %d", io_res);
+    return DEMO_ERROR;
+  }
+
+  LOG("wrote %zu bytes of data to socket", n);
+  return DEMO_OK;
+}
+
+void
+demo_client_connection_free(demo_client_connection *conn)
+{
+  if(conn == NULL) {
+    return;
+  }
+
+  if(conn->rconn != NULL) {
+    rustls_connection_free(conn->rconn);
+  }
+
+  if(conn->data != NULL) {
+    conndata *data = conn->data;
+    if(data->data.data != NULL) {
+      free(data->data.data);
+    }
+    free(data);
+  }
+
+  if(conn->sockfd != 0) {
+    close(conn->sockfd);
+  }
+
+  free(conn);
+}
+
+uint32_t
+unsafe_skip_verify(void *userdata,
+                   const rustls_verify_server_cert_params *params)
+{
+  size_t i = 0;
+  const rustls_slice_slice_bytes *intermediates =
+    params->intermediate_certs_der;
+  const size_t intermediates_len = rustls_slice_slice_bytes_len(intermediates);
+  const conndata *conn = (struct conndata *)userdata;
+
+  LOG("custom certificate verifier called for %.*s",
+      (int)params->server_name.len,
+      params->server_name.data);
+  LOG("end entity len: %zu", params->end_entity_cert_der.len);
+  LOG_SIMPLE("intermediates:");
+  for(i = 0; i < intermediates_len; i++) {
+    const rustls_slice_bytes bytes =
+      rustls_slice_slice_bytes_get(intermediates, i);
+    if(bytes.data != NULL) {
+      LOG("   intermediate, len = %zu", bytes.len);
+    }
+  }
+  LOG("ocsp response len: %zu", params->ocsp_response.len);
+  if(0 != strcmp(conn->verify_arg, "verify_arg")) {
+    LOG("invalid argument to verify: %p", userdata);
+    return RUSTLS_RESULT_GENERAL;
+  }
+  return RUSTLS_RESULT_OK;
 }
diff --git a/tests/client.h b/tests/client.h
new file mode 100644
index 00000000..259b4dfe
--- /dev/null
+++ b/tests/client.h
@@ -0,0 +1,132 @@
+#ifndef CLIENT_H
+#define CLIENT_H
+
+#include <stdbool.h>
+
+#include "rustls.h"
+#include "common.h"
+
+// A structure to hold client demo option state.
+typedef struct demo_client_options
+{
+  // Options for certificate verification. Only one should be set.
+  bool use_no_verifier;
+  bool use_platform_verifier;
+  const char *use_ca_certificate_verifier;
+
+  // Optional client authentication using a certificate/key. None, or both
+  // must be set.
+  const char *use_auth_cert_file;
+  const char *use_auth_cert_key_file;
+
+  // Optional encrypted client hello (ECH) settings. Only one should be set.
+  bool use_ech_grease;
+  const char *use_ech_config_list_files;
+
+  // Optional SSL keylog support. At most one should be set.
+  const char *use_ssl_keylog_file;
+  bool use_stderr_keylog;
+
+  // Optional custom ciphersuite name. If set _only_ this ciphersuite
+  // will be used.
+  const char *custom_ciphersuite_name;
+
+  // Optional vectored IO support.
+  bool use_vectored_io;
+} demo_client_options;
+
+// Populate the provided options with values from the environment.
+// Returns 0 on success, or non-zero on failure after printing error
+// messages to stderr.
+int options_from_env(demo_client_options *opts);
+
+// Construct a rustls_client_config based on the provided demo_client_options.
+//
+// Caller owns the returned rustls_client_config and must free it with
+// rustls_client_config_free. The rustls_client_config must out-live any
+// demo_client_request_options made referencing it.
+//
+// Returns NULL on failure after printing error messages to stderr.
+const rustls_client_config *new_tls_config(const demo_client_options *opts);
+
+// Options for an HTTPS GET request.
+typedef struct demo_client_request_options
+{
+  const rustls_client_config *tls_config;
+  const char *hostname;
+  const char *port;
+  const char *path;
+  bool use_vectored_io;
+} demo_client_request_options;
+
+// Make an HTTP request based on the provided options. The resulting
+// plaintext is printed to STDOUT.
+//
+// Returns 0 on success, or non-zero on failure after printing error
+// messages to stderr.
+int do_get_request(const demo_client_request_options *options);
+
+// State related to a demo HTTPS client connection.
+typedef struct demo_client_connection
+{
+  // the options used to create the connection.
+  const demo_client_request_options *options;
+  // the socket file descriptor for the connection.
+  int sockfd;
+  // the rustls_connection for TLS.
+  rustls_connection *rconn;
+  // the connection data for the rustls_connection.
+  conndata *data;
+  // whether the connection is closing
+  bool closing;
+  // whether the connection was closed cleanly.
+  bool clean_closure;
+} demo_client_connection;
+
+// Free a demo_client_connection.
+void demo_client_connection_free(demo_client_connection *conn);
+
+// Create a new demo_client_connection by connecting to the server
+// specified by the options.
+//
+// The caller owns the resulting demo_client_connection and must free it
+// with demo_client_connection_free. The provided
+// options must outlive the connection.
+//
+// Returns NULL on failure after printing error messages to stderr.
+demo_client_connection *demo_client_connect(
+  const demo_client_request_options *options);
+
+// Number of attempts to make in connect_socket.
+#define MAX_CONNECT_ATTEMPTS 10
+
+// Connect a socket to the hostname/port specified in the
+// demo_client_request_options. Tries up to MAX_CONNECT_ATTEMPTS times before
+// giving up.
+//
+// Returns a non-zero FD for the connected socket if successful, or 0 on
+// failure after printing error messages to stderr.
+int connect_socket(const demo_client_request_options *options);
+
+// Write a GET request to the provided demo_client_connection.
+int demo_client_connection_write_get(const demo_client_connection *demo_conn);
+
+// Read TLS data from the provided demo_client_connection's socket, updating
+// the rustls connection and putting any available plaintext in the demo
+// connection's data.
+//
+// Returns a demo_result indicating the result of the read.
+demo_result demo_client_connection_read_tls(demo_client_connection *demo_conn);
+
+// Write queued TLS data to the provided demo_client_connection's socket.
+//
+// Returns a demo_result indicating the result of the write.
+demo_result demo_client_connection_write_tls(
+  const demo_client_connection *demo_conn);
+
+// A callback for rustls certificate validation that *unsafely* allows all
+// presented certificate chains, printing their contents to stderr.
+uint32_t unsafe_skip_verify(void *userdata,
+                            const rustls_verify_server_cert_params *params);
+
+#endif // CLIENT_H
diff --git a/tests/server.c b/tests/server.c
index cd058329..28b5ae27 100644
--- a/tests/server.c
+++ b/tests/server.c
@@ -22,6 +22,7 @@
 /* rustls.h is autogenerated in the Makefile using cbindgen. */
 #include "rustls.h"
 #include "common.h"
+#include "server.h"
 
 typedef enum exchange_state
 {
@@ -83,6 +84,12 @@ handle_conn(conndata *conn)
       FD_SET(sockfd, &write_fds);
     }
 
+    if(state == SENT_RESPONSE && !rustls_connection_wants_write(rconn)) {
+      LOG_SIMPLE(
+        "sent response and rustls doesn't want write. closing connection");
+      goto cleanup;
+    }
+
     if(!rustls_connection_wants_read(rconn) &&
        !rustls_connection_wants_write(rconn)) {
       LOG_SIMPLE("rustls wants neither read nor write. closing connection");
@@ -117,6 +124,10 @@ handle_conn(conndata *conn)
     }
     if(FD_ISSET(sockfd, &write_fds)) {
       const rustls_io_result err = write_tls(rconn, conn, &n);
+      if(err == EAGAIN) {
+        LOG_SIMPLE("writing to socket: EAGAIN or EWOULDBLOCK");
+        continue;
+      }
       if(err != 0) {
         LOG("error in write_tls: errno %d", err);
         goto cleanup;
diff --git a/tests/server.h b/tests/server.h
new file mode 100644
index 00000000..44c4a867
--- /dev/null
+++ b/tests/server.h
@@ -0,0 +1,7 @@
+#ifndef SERVER_H
+#define SERVER_H
+
+// Placeholder so that Makefile compilation rule for client/server
+// can remain symmetric.
+
+#endif // SERVER_H