From da52245e46da42ae4d122b44a85d63dba7cecf07 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 21:11:15 -0400 Subject: [PATCH 01/37] Claude: Add the native Windows WIN64 platform and win64-debug, win64-optimized, and win64-debug-utest targets to the Makefile. The WIN64 section builds the static library with MSVC (cl and lib) through GNU make, run from Git Bash or PowerShell with the MSVC environment on PATH. It is separate from CYGWIN64, which is unchanged. - New variables CC_OUT, AR_OUT, DBG_FLAGS, and LINK_OBJ carry what differs on MSVC: cl ignores -o and needs -Fo, lib needs -OUT:, debug builds use -Z7 -Od, and obj is a directory junction (mklink /J) because Git Bash ln -s copies instead of linking. - WIN64 uses -nologo -W4, RANLIB is true, and no event system or assembly is defined yet; the ST sources compile with cl only after the portable headers and stubs of the next tasks. - win64-debug-utest builds the library and then runs the utest Makefile with OS=WIN64, which is ported in a later task. - Ignore the WIN64_*_DBG and WIN64_*_OPT build directories. Verified that make -n prints identical commands before and after this change for every POSIX target (linux, darwin, and cygwin64 debug, optimized, utest, and gcov, and clean), and that win64-debug and win64-optimized build, archive, and link obj with a probe source from both Git Bash and PowerShell, and that clean removes the junction. --------- Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 ++ Makefile | 47 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index ab54028..a17bdef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ DARWIN_*_DBG LINUX_*_DBG CYGWIN64_*_DBG +WIN64_*_DBG +WIN64_*_OPT obj st.pc .idea diff --git a/Makefile b/Makefile index 7832b36..acb46dc 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ VERSION = 1.9.1 # #OS = DARWIN #OS = LINUX +#OS = WIN64 # Please see the "Other possible defines" section below for # possible compilation options. @@ -77,6 +78,13 @@ ARFLAGS = -r LNFLAGS = -s DSO_SUFFIX = so +# The output flags of the compiler and archiver, the debug flags, and the +# command that points obj at TARGETDIR. WIN64 (MSVC) overrides them. +CC_OUT = -o $(EMPTY) +AR_OUT = +DBG_FLAGS = -g -O0 +LINK_OBJ = $(LN) $(LNFLAGS) $(TARGETDIR) obj + MAJOR = $(shell echo $(VERSION) | sed 's/^\([^\.]*\).*/\1/') DESC = st.pc @@ -86,11 +94,13 @@ DESC = st.pc TARGETS = darwin-debug darwin-optimized \ linux-debug linux-optimized \ - cygwin64-debug + cygwin64-debug \ + win64-debug win64-optimized UTEST_TARGETS = darwin-debug-utest linux-debug-utest \ darwin-debug-gcov linux-debug-gcov \ - cygwin64-debug-utest + cygwin64-debug-utest \ + win64-debug-utest # # Platform specifics @@ -127,6 +137,22 @@ OTHER_FLAGS = -Wall DEFINES += -DMD_HAVE_SELECT endif +# Native Windows x64 with MSVC (cl, lib), not CYGWIN64. Run GNU make from Git +# Bash or PowerShell with the MSVC environment on PATH. Static library only. +# Git Bash 'ln -s' copies instead of linking, so obj is a directory junction. +ifeq ($(OS), WIN64) +CC = cl +CXX = cl +AR = lib +RANLIB = true +ARFLAGS = -nologo +CC_OUT = -Fo +AR_OUT = -OUT: +DBG_FLAGS = -Z7 -Od +LINK_OBJ = MSYS2_ARG_CONV_EXCL='*' cmd /c mklink /J obj $(TARGETDIR) >/dev/null +OTHER_FLAGS = -nologo -W4 +endif + # # End of platform section. ########################## @@ -135,7 +161,7 @@ endif ifeq ($(BUILD), OPT) OTHER_FLAGS += -O2 else -OTHER_FLAGS += -g -O0 +OTHER_FLAGS += $(DBG_FLAGS) DEFINES += -DDEBUG endif @@ -261,13 +287,13 @@ st.pc: st.pc.in # Point obj to this platform on every build, even when the library is up to date. .PHONY: obj-link obj-link: - rm -f obj; $(LN) $(LNFLAGS) $(TARGETDIR) obj + rm -f obj; $(LINK_OBJ) $(TARGETDIR): if [ ! -d $(TARGETDIR) ]; then mkdir $(TARGETDIR); fi $(SLIBRARY): $(OBJS) - $(AR) $(ARFLAGS) $@ $(OBJS) + $(AR) $(ARFLAGS) $(AR_OUT)$@ $(OBJS) $(RANLIB) $@ $(DLIBRARY): $(OBJS:%.o=%-pic.o) @@ -287,7 +313,7 @@ $(TARGETDIR)/%.o: %.S $(CC) $(CFLAGS) -c $< -o $@ $(TARGETDIR)/%.o: %.c common.h md.h - $(CC) $(CFLAGS) -c $< -o $@ + $(CC) $(CFLAGS) -c $< $(CC_OUT)$@ # Note that we use C++98 standard for the C++ files. $(TARGETDIR)/%.o: %.cc common.h md.h @@ -325,6 +351,11 @@ linux-optimized: cygwin64-debug: $(MAKE) OS="CYGWIN64" BUILD="DBG" +win64-debug: + $(MAKE) OS="WIN64" BUILD="DBG" +win64-optimized: + $(MAKE) OS="WIN64" BUILD="OPT" + darwin-debug-utest: @echo "Build utest for state-threads" $(MAKE) OS="DARWIN" BUILD="DBG" @@ -337,6 +368,10 @@ cygwin64-debug-utest: @echo "Build utest for state-threads" $(MAKE) OS="CYGWIN64" BUILD="DBG" cd utest && $(MAKE) UTEST_FLAGS="-std=gnu++0x" # @see https://www.codenong.com/18784112/ +win64-debug-utest: + @echo "Build utest for state-threads" + $(MAKE) OS="WIN64" BUILD="DBG" + cd utest && $(MAKE) OS="WIN64" darwin-debug-gcov: @echo "Build utest with gcov for state-threads" From 3a8bee694fd7479521e2b696c133b14407dcf9e0 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 21:15:29 -0400 Subject: [PATCH 02/37] Claude: Make public.h portable to native Windows (MSVC) with POSIX names for callers. On _WIN32 or _MSC_VER, st.h includes winsock2.h and ws2tcpip.h instead of the POSIX headers, and defines ssize_t, mode_t, struct iovec, and struct msghdr with the POSIX field names, so SRS and the utest compile unchanged. struct pollfd and the POLL* names come from winsock. Detection uses compiler macros, not WIN64, because programs that include st.h do not pass -DWIN64; Cygwin GCC defines neither, so the POSIX and CYGWIN64 paths are unchanged. The POSIX include block is kept verbatim in the #else branch. --------- Co-Authored-By: Claude Opus 5.5 --- public.h | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/public.h b/public.h index 4b68a0e..9e446a0 100644 --- a/public.h +++ b/public.h @@ -39,6 +39,62 @@ #ifndef __ST_THREAD_H__ #define __ST_THREAD_H__ +#if defined(_WIN32) || defined(_MSC_VER) +/* + * Native Windows with MSVC, which defines _WIN32 and _MSC_VER. Cygwin GCC + * defines neither. + */ + +/* + * st.h includes winsock2.h, but windows.h includes the old winsock.h, and the + * two cannot be used together. So include st.h before windows.h: + * #include + * #include + * Or define WIN32_LEAN_AND_MEAN, so windows.h does not include winsock.h: + * #define WIN32_LEAN_AND_MEAN + * #include + * #include + * In the wrong order, the build fails anyway with many redefinition errors in + * the Windows headers, so fail here first with a clear message. + */ +#if defined(_WINSOCKAPI_) && !defined(_WINSOCK2API_) && defined(IPPROTO_TCP) +#error "winsock.h is already included, by windows.h: include st.h before windows.h, or define WIN32_LEAN_AND_MEAN" +#endif + +#include +#include +#include +#include +#include +#include +#include + +#ifndef _SSIZE_T_DEFINED +#define _SSIZE_T_DEFINED +typedef intptr_t ssize_t; +#endif + +#ifndef _MODE_T_DEFINED +#define _MODE_T_DEFINED +typedef int mode_t; +#endif + +struct iovec { + void *iov_base; + size_t iov_len; +}; + +struct msghdr { + void *msg_name; + socklen_t msg_namelen; + struct iovec *msg_iov; + int msg_iovlen; + void *msg_control; + socklen_t msg_controllen; + int msg_flags; +}; +#else +/* POSIX: Linux, macOS, and Cygwin. */ #include #include #include @@ -46,6 +102,7 @@ #include #include #include +#endif #define ST_VERSION "1.9.1" #define ST_VERSION_MAJOR 1 From 7871288618f8b30910a29a54dc2971b66331efbc Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 21:21:24 -0400 Subject: [PATCH 03/37] Claude: Add the native Windows WIN64 platform branch to md.h and common.h. md.h gets a WIN64 branch next to CYGWIN64, which is unchanged: __thread maps to __declspec(thread), the jmpbuf uses 36 long long slots on WIN64 because MSVC long is 32-bit (LLP64), MD_GET_SP is the 64-bit rsp slot 8 of the Win64 layout, MD_GET_UTIME uses QueryPerformanceCounter, and getpagesize uses GetSystemInfo. common.h and sched.c skip unistd.h, sys/time.h, and fcntl.h on WIN64, and st_thread_create stores the stack pointer through intptr_t instead of long, so it is no longer truncated on Windows. On POSIX the preprocessed sources are identical except for that cast, which keeps the same value. --------- Co-Authored-By: Claude Opus 5.5 --- common.h | 4 ++++ md.h | 42 +++++++++++++++++++++++++++++++++++++++++- sched.c | 4 +++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/common.h b/common.h index f34079f..6fb2a8b 100644 --- a/common.h +++ b/common.h @@ -45,9 +45,13 @@ #define __ST_COMMON_H__ #include +#if !defined(WIN64) #include +#endif #include +#if !defined(WIN64) #include +#endif #include /* Enable assertions only if DEBUG is defined */ diff --git a/md.h b/md.h index a25c008..9767df4 100644 --- a/md.h +++ b/md.h @@ -70,8 +70,14 @@ typedef struct _st_jmp_buf { * Linux __riscv long[14] * Linux __loongarch64 long[12] * Cygwin64 __amd64__/__x86_64__ long[8] + * Win64 _M_X64 long long[36] */ - long __jmpbuf[22]; + /* Pointer-sized slots, because MSVC long is 32-bit (LLP64). */ +#if defined(WIN64) + intptr_t __jmpbuf[36]; +#else + intptr_t __jmpbuf[22]; +#endif } _st_jmp_buf_t[1]; /* Defined in *.S file and implemented by ASM. */ @@ -183,6 +189,40 @@ extern void _st_md_cxt_restore(_st_jmp_buf_t env, int val); (void) gettimeofday(&tv, NULL); \ return (tv.tv_sec * 1000000LL + tv.tv_usec) +#elif defined (WIN64) + + #define MD_ACCEPT_NB_NOT_INHERITED + #define MD_HAVE_SOCKLEN_T + + /* MSVC has no __thread; use its thread-local storage class. */ + #define __thread __declspec(thread) + + #if defined(_M_X64) || defined(_M_AMD64) + #define MD_GET_SP(_t) *((long long *)&((_t)->context[0].__jmpbuf[8])) + #else + #error Unknown CPU architecture + #endif + + /* + * Like CLOCK_MONOTONIC on Linux: QueryPerformanceCounter, the clock that MSVC's + * std::chrono::steady_clock uses. Convert whole seconds and the leftover ticks + * apart, as steady_clock does, because ticks * 1000000 overflows after about + * 10 days of uptime at the common 10 MHz frequency. + */ + #define MD_GET_UTIME() \ + LARGE_INTEGER counter, freq; \ + QueryPerformanceCounter(&counter); \ + QueryPerformanceFrequency(&freq); \ + return (st_utime_t)((counter.QuadPart / freq.QuadPart) * 1000000LL + \ + (counter.QuadPart % freq.QuadPart) * 1000000LL / freq.QuadPart) + + static inline int getpagesize(void) + { + SYSTEM_INFO si; + GetSystemInfo(&si); + return (int)si.dwPageSize; + } + #else #error Unknown OS #endif /* OS */ diff --git a/sched.c b/sched.c index 9734c4d..3c2b3e5 100644 --- a/sched.c +++ b/sched.c @@ -42,8 +42,10 @@ */ #include +#if !defined(WIN64) #include #include +#endif #include #include #include @@ -675,7 +677,7 @@ _st_thread_t *st_thread_create(void *(*start)(void *arg), void *arg, int joinabl if (_st_md_cxt_save(thread->context)) { _st_thread_main(); } - MD_GET_SP(thread) = (long)(stack->sp); + MD_GET_SP(thread) = (intptr_t)(stack->sp); /* If thread is joinable, allocate a termination condition variable */ if (joinable) { From 9ce3fd98e7691fbcb758771d17c0aacb43dfd841 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 21:36:42 -0400 Subject: [PATCH 04/37] Claude: Add native Windows WIN64 stubs so the ST library links with MSVC. make win64-debug and win64-optimized now build libst.a, and a program that uses every public function links against it. The Windows internals are stubs that fail at runtime: md_win64.c has _st_md_cxt_save and _st_md_cxt_restore that abort with "context switch not ported" (replaced by assembly later); event.c has a win64-stub default event system whose operations fail with ENOSYS; io.c stubs ioctl, fcntl, close, read, write, readv, writev, recvmsg, sendmsg, _st_io_init, and st_open with ENOSYS and brings ws2_32.lib for the winsock calls. md.h turns on MALLOC_STACK and MD_NO_PROTECT and maps random/srandom to rand/srand on WIN64. POSIX headers are skipped only on WIN64; the preprocessed LINUX, DARWIN, and CYGWIN64 sources and the POSIX make commands are unchanged. --------- Co-Authored-By: Claude Opus 5.5 --- Makefile | 1 + event.c | 82 ++++++++++++++++++++++++++++++++++++++++- io.c | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++ md.h | 12 ++++++ md_win64.c | 26 +++++++++++++ stk.c | 2 + 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 md_win64.c diff --git a/Makefile b/Makefile index acb46dc..1abf13f 100644 --- a/Makefile +++ b/Makefile @@ -141,6 +141,7 @@ endif # Bash or PowerShell with the MSVC environment on PATH. Static library only. # Git Bash 'ln -s' copies instead of linking, so obj is a directory junction. ifeq ($(OS), WIN64) +EXTRA_OBJS = $(TARGETDIR)/md_win64.o CC = cl CXX = cl AR = lib diff --git a/event.c b/event.c index e7cbdbd..422366c 100644 --- a/event.c +++ b/event.c @@ -35,7 +35,9 @@ */ #include +#if !defined(WIN64) #include +#endif #include #include #include @@ -57,7 +59,7 @@ __thread unsigned long long _st_stat_epoll_shake = 0; __thread unsigned long long _st_stat_epoll_spin = 0; #endif -#if !defined(MD_HAVE_KQUEUE) && !defined(MD_HAVE_EPOLL) && !defined(MD_HAVE_SELECT) +#if !defined(MD_HAVE_KQUEUE) && !defined(MD_HAVE_EPOLL) && !defined(MD_HAVE_SELECT) && !defined(WIN64) #error Only support epoll(for Linux), kqueue(for Darwin) or select(for Cygwin) #endif @@ -1247,6 +1249,77 @@ static _st_eventsys_t _st_epoll_eventsys = { #endif /* MD_HAVE_EPOLL */ +#if defined (WIN64) +/***************************************** + * Native Windows event system stub, so ST links. Every operation fails with + * ENOSYS until a Windows event system is ported. + */ +#include + +ST_HIDDEN int _st_win64_init(void) +{ + errno = ENOSYS; + return -1; +} + +ST_HIDDEN void _st_win64_dispatch(void) +{ + fprintf(stderr, "ST: the Windows event system is not ported yet\n"); + abort(); +} + +ST_HIDDEN int _st_win64_pollset_add(struct pollfd *pds, int npds) +{ + (void) pds; + (void) npds; + errno = ENOSYS; + return -1; +} + +ST_HIDDEN void _st_win64_pollset_del(struct pollfd *pds, int npds) +{ + (void) pds; + (void) npds; +} + +ST_HIDDEN int _st_win64_fd_new(int osfd) +{ + (void) osfd; + errno = ENOSYS; + return -1; +} + +ST_HIDDEN int _st_win64_fd_close(int osfd) +{ + (void) osfd; + errno = ENOSYS; + return -1; +} + +ST_HIDDEN int _st_win64_fd_getlimit(void) +{ + return 0; +} + +ST_HIDDEN void _st_win64_destroy(void) +{ +} + +static _st_eventsys_t _st_win64_eventsys = { + "win64-stub", + ST_EVENTSYS_DEFAULT, + _st_win64_init, + _st_win64_dispatch, + _st_win64_pollset_add, + _st_win64_pollset_del, + _st_win64_fd_new, + _st_win64_fd_close, + _st_win64_fd_getlimit, + _st_win64_destroy +}; +#endif /* WIN64 */ + + /***************************************** * Public functions */ @@ -1258,6 +1331,13 @@ int st_set_eventsys(int eventsys) return -1; } +#if defined (WIN64) + if (eventsys == ST_EVENTSYS_DEFAULT) { + _st_eventsys = &_st_win64_eventsys; + return 0; + } +#endif + if (eventsys == ST_EVENTSYS_SELECT || eventsys == ST_EVENTSYS_DEFAULT) { #if defined (MD_HAVE_SELECT) _st_eventsys = &_st_select_eventsys; diff --git a/io.c b/io.c index 2d2ba1d..894916f 100644 --- a/io.c +++ b/io.c @@ -42,18 +42,106 @@ */ #include +#if !defined(WIN64) #include +#endif #include +#if !defined(WIN64) #include #include #include #include #include +#endif #include #include #include #include "common.h" +#if defined(WIN64) +/* + * Native Windows stubs for the POSIX-only calls, so ST links. They fail with + * ENOSYS until the Windows I/O is ported. Winsock provides accept, connect, + * getsockopt, recvfrom, and sendto, so the library brings ws2_32.lib. + */ +#pragma comment(lib, "ws2_32.lib") + +/* Only for the fcntl stub; the CRT has no such flags. */ +#define F_GETFL 3 +#define F_SETFL 4 +#define O_NONBLOCK 04000 + +static int _st_win64_enosys(void) +{ + errno = ENOSYS; + return -1; +} + +static int _st_win64_ioctl(int fd, unsigned long request, int *arg) +{ + (void) fd; (void) request; (void) arg; + return _st_win64_enosys(); +} + +static int _st_win64_fcntl(int fd, int cmd, int arg) +{ + (void) fd; (void) cmd; (void) arg; + return _st_win64_enosys(); +} + +static int _st_win64_close(int fd) +{ + (void) fd; + return _st_win64_enosys(); +} + +static ssize_t _st_win64_read(int fd, void *buf, size_t nbyte) +{ + (void) fd; (void) buf; (void) nbyte; + return _st_win64_enosys(); +} + +static ssize_t _st_win64_write(int fd, const void *buf, size_t nbyte) +{ + (void) fd; (void) buf; (void) nbyte; + return _st_win64_enosys(); +} + +static ssize_t _st_win64_readv(int fd, const struct iovec *iov, int iov_size) +{ + (void) fd; (void) iov; (void) iov_size; + return _st_win64_enosys(); +} + +static ssize_t _st_win64_writev(int fd, const struct iovec *iov, int iov_size) +{ + (void) fd; (void) iov; (void) iov_size; + return _st_win64_enosys(); +} + +static int _st_win64_recvmsg(int fd, struct msghdr *msg, int flags) +{ + (void) fd; (void) msg; (void) flags; + return _st_win64_enosys(); +} + +static int _st_win64_sendmsg(int fd, const struct msghdr *msg, int flags) +{ + (void) fd; (void) msg; (void) flags; + return _st_win64_enosys(); +} + +#define ioctl _st_win64_ioctl +#define fcntl _st_win64_fcntl +#define close _st_win64_close +#define read _st_win64_read +#define write _st_win64_write +#define readv _st_win64_readv +#define writev _st_win64_writev +#define recvmsg _st_win64_recvmsg +#define sendmsg _st_win64_sendmsg +#endif + // Global stat. #if defined(DEBUG) && defined(DEBUG_STATS) __thread unsigned long long _st_stat_recvfrom = 0; @@ -87,6 +175,13 @@ static int _st_osfd_limit = -1; static void _st_netfd_free_aux_data(_st_netfd_t *fd); +#if defined(WIN64) +/* Windows has no SIGPIPE or rlimit; fail until the Windows I/O is ported. */ +int _st_io_init(void) +{ + return _st_win64_enosys(); +} +#else int _st_io_init(void) { struct sigaction sigact; @@ -126,6 +221,7 @@ int _st_io_init(void) return 0; } +#endif int st_getfdlimit(void) @@ -736,6 +832,15 @@ int st_sendmsg(_st_netfd_t *fd, const struct msghdr *msg, int flags, st_utime_t /* * To open FIFOs or other special files. */ +#if defined(WIN64) +/* Not supported on Windows yet. */ +_st_netfd_t *st_open(const char *path, int oflags, mode_t mode) +{ + (void) path; (void) oflags; (void) mode; + _st_win64_enosys(); + return NULL; +} +#else _st_netfd_t *st_open(const char *path, int oflags, mode_t mode) { int osfd, err; @@ -758,3 +863,4 @@ _st_netfd_t *st_open(const char *path, int oflags, mode_t mode) return newfd; } +#endif diff --git a/md.h b/md.h index 9767df4..55b8e5b 100644 --- a/md.h +++ b/md.h @@ -194,6 +194,18 @@ extern void _st_md_cxt_restore(_st_jmp_buf_t env, int val); #define MD_ACCEPT_NB_NOT_INHERITED #define MD_HAVE_SOCKLEN_T + /* No mmap or mprotect: allocate stacks with malloc, without red zones. */ + #ifndef MALLOC_STACK + #define MALLOC_STACK + #endif + #ifndef MD_NO_PROTECT + #define MD_NO_PROTECT + #endif + + /* The CRT has rand and srand, not random and srandom. */ + #define random rand + #define srandom srand + /* MSVC has no __thread; use its thread-local storage class. */ #define __thread __declspec(thread) diff --git a/md_win64.c b/md_win64.c new file mode 100644 index 0000000..20be3f9 --- /dev/null +++ b/md_win64.c @@ -0,0 +1,26 @@ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) 2013-2026 The SRS Authors */ + +/* + * Native Windows x64 (MSVC) context switch stub, so ST links. It aborts until + * the context switch is ported to md_win64.asm. + */ + +#include +#include +#include "common.h" + +int _st_md_cxt_save(_st_jmp_buf_t env) +{ + (void) env; + fprintf(stderr, "ST: _st_md_cxt_save, context switch not ported to Windows yet\n"); + abort(); +} + +void _st_md_cxt_restore(_st_jmp_buf_t env, int val) +{ + (void) env; + (void) val; + fprintf(stderr, "ST: _st_md_cxt_restore, context switch not ported to Windows yet\n"); + abort(); +} diff --git a/stk.c b/stk.c index 54d78a1..6687c93 100644 --- a/stk.c +++ b/stk.c @@ -45,7 +45,9 @@ #include #include #include +#if !defined(WIN64) #include +#endif #include "common.h" From d5674868aca18f03a1e37623e5deaa837f8d0048 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 21:56:19 -0400 Subject: [PATCH 05/37] Claude: Build the utest with MSVC through utest/Makefile for native Windows WIN64. make win64-debug-utest now builds gtest and every utest source with cl -W4 and links st_utest.exe against libst.a and ws2_32.lib; it starts and fails its first assertion, as expected while the Windows internals are stubs. utest/Makefile gets a WIN64 section (cl, lib, -Fo, -OUT:, -Fe, .exe, NOMINMAX) and output-flag variables whose POSIX values give the same commands as before. st_utest.hpp gets Windows test helpers: the stream pair is a stub that fails with ENOSYS until a loopback TCP pair replaces it, send, recv, close and would-block use winsock, setsockopt and getsockopt take any pointer, and __thread, __builtin_frame_address, getpagesize and usleep map to MSVC and Win32. POSIX-only includes, and the tests about POSIX features (fork, signals, pipes, pthread, mmap, fcntl, getrlimit, st_open, Unix sockets and CMSG), are skipped on _WIN32: 131 of 186 tests build on Windows. Removing the _WIN32 branches gives back the previous sources byte for byte, so POSIX tests and what they assert are unchanged. --------- Co-Authored-By: Claude Opus 5.5 --- utest/Makefile | 36 ++++++++++++++++---- utest/st_utest.hpp | 66 +++++++++++++++++++++++++++++++++++++ utest/st_utest_event.cpp | 14 ++++++++ utest/st_utest_io.cpp | 28 ++++++++++++++++ utest/st_utest_key.cpp | 4 +++ utest/st_utest_learn_kb.cpp | 2 ++ utest/st_utest_sched.cpp | 8 +++++ utest/st_utest_select.cpp | 8 +++++ utest/st_utest_stack.cpp | 14 ++++++++ utest/st_utest_sync.cpp | 8 +++++ utest/st_utest_tcp.cpp | 2 ++ utest/st_utest_udp.cpp | 14 ++++++++ 12 files changed, 197 insertions(+), 7 deletions(-) diff --git a/utest/Makefile b/utest/Makefile index 955157e..27569f9 100644 --- a/utest/Makefile +++ b/utest/Makefile @@ -6,14 +6,36 @@ ST_UTEST = . # The main dir of gtest. GTEST_DIR = $(ST_UTEST)/gtest-fit/googletest +# The output flags of the compiler, archiver and linker, the executable suffix, and the libraries. WIN64 is native +# Windows x64 with MSVC (cl, lib), as in ../Makefile; NOMINMAX keeps windows.h from defining min and max. +ifeq ($(OS), WIN64) +CXX = cl +AR = lib +ARFLAGS = -nologo +CXX_OUT = -Fo +AR_OUT = -OUT: +LINK_OUT = -Fe +EXE = .exe +LIBS = ws2_32.lib +CXXFLAGS += -nologo -Z7 -Od -EHsc -DNOMINMAX +CXXFLAGS += -DGTEST_USE_OWN_TR1_TUPLE=1 +WARNFLAGS += -W4 +else +CXX_OUT = -o $(EMPTY) +AR_OUT = +LINK_OUT = -o $(EMPTY) +EXE = +LIBS = -lpthread -ldl + # Flags passed to the C++ compiler. CXXFLAGS += -g -O0 -std=c++11 CXXFLAGS += -DGTEST_USE_OWN_TR1_TUPLE=1 # Flags for warnings. WARNFLAGS += -Wall -Wno-deprecated-declarations -Wno-unused-private-field -Wno-unused-command-line-argument +endif # House-keeping build targets. -all : $(ST_DIR)/obj/st_utest +all : $(ST_DIR)/obj/st_utest$(EXE) # Auto-setup: symlink gtest-fit from SRS 3rdparty if not present. $(GTEST_DIR)/include/gtest/gtest.h: @@ -39,13 +61,13 @@ GTEST_SRCS_ = $(GTEST_DIR)/src/*.cc $(GTEST_DIR)/src/*.h $(GTEST_DIR)/include/gt # conservative and not optimized. This is fine as Google Test # compiles fast and for ordinary users its source rarely changes. $(ST_DIR)/obj/gtest-all.o : $(GTEST_DIR)/include/gtest/gtest.h $(GTEST_SRCS_) - $(CXX) -c $(GTEST_DIR)/src/gtest-all.cc -o $@ \ + $(CXX) -c $(GTEST_DIR)/src/gtest-all.cc $(CXX_OUT)$@ \ $(CXXFLAGS) $(UTEST_FLAGS) \ $(WARNFLAGS) \ -I$(GTEST_DIR)/include -I$(GTEST_DIR) $(ST_DIR)/obj/gtest.a : $(ST_DIR)/obj/gtest-all.o - $(AR) $(ARFLAGS) $@ $^ + $(AR) $(ARFLAGS) $(AR_OUT)$@ $^ ##################################################################################### ##################################################################################### @@ -74,12 +96,12 @@ OBJECTS = $(addprefix $(ST_DIR)/obj/,$(OBJECTS_FILES)) # Objects, build each object of utest $(ST_DIR)/obj/%.o : %.cpp $(ST_UTEST_DEPS) $(UTEST_DEPS) $(GTEST_DIR)/include/gtest/gtest.h - $(CXX) -c $< -o $@ \ + $(CXX) -c $< $(CXX_OUT)$@ \ $(CXXFLAGS) $(UTEST_FLAGS) \ $(WARNFLAGS) \ -I$(GTEST_DIR)/include -I$(ST_UTEST) -I$(ST_DIR) -I$(ST_DIR)/obj # Generate the utest binary -$(ST_DIR)/obj/st_utest : $(OBJECTS) $(ST_DIR)/obj/gtest.a $(ST_UTEST_DEPS) - $(CXX) -o $@ $(CXXFLAGS) $(UTEST_FLAGS) \ - $^ -lpthread -ldl +$(ST_DIR)/obj/st_utest$(EXE) : $(OBJECTS) $(ST_DIR)/obj/gtest.a $(ST_UTEST_DEPS) + $(CXX) $(LINK_OUT)$@ $(CXXFLAGS) $(UTEST_FLAGS) \ + $^ $(LIBS) diff --git a/utest/st_utest.hpp b/utest/st_utest.hpp index 03f4810..b09cc81 100644 --- a/utest/st_utest.hpp +++ b/utest/st_utest.hpp @@ -15,15 +15,80 @@ #include #include +#ifndef _WIN32 #include #include +#endif +#ifdef _WIN32 +// winnt.h defines VOID as void, which the tests use as an empty prefix. +#undef VOID +// For the tests that declare ST's thread-local variables, as md.h maps it for WIN64. +#define __thread __declspec(thread) +// MSVC has no frame address builtin; the slot of the return address is in the caller's frame, next to it. +#include +#define __builtin_frame_address(level) _AddressOfReturnAddress() +static inline int getpagesize() +{ + SYSTEM_INFO si; + GetSystemInfo(&si); + return (int)si.dwPageSize; +} +// Sleeps the OS thread, rounded up to milliseconds. +static inline int usleep(unsigned int us) +{ + Sleep((us + 999) / 1000); + return 0; +} +#endif #define VOID // Portable descriptors. A test that only needs a connection ST can wait on uses these, not a pipe or read and write, // so it runs unchanged where only sockets can be polled, such as Windows. A test about a POSIX feature itself, such as // a pipe, a signal or a chosen descriptor number, keeps the POSIX calls. +#ifdef _WIN32 +// Native Windows (winsock comes from st.h) has no socketpair, so the pair is a stub that fails until a loopback TCP +// pair replaces it. Descriptors are winsock sockets that fit in an int. +static inline int st_utest_stream_pair(int fds[2]) +{ + fds[0] = fds[1] = -1; + errno = ENOSYS; + return -1; +} + +// Winsock takes the option value as char*, so these overloads let tests pass any pointer as on POSIX. +static inline int setsockopt(int fd, int level, int name, const void* value, socklen_t size) +{ + return ::setsockopt((SOCKET)fd, level, name, (const char*)value, size); +} + +static inline int getsockopt(int fd, int level, int name, void* value, socklen_t* size) +{ + return ::getsockopt((SOCKET)fd, level, name, (char*)value, size); +} + +static inline ssize_t st_utest_send(int fd, const void* buf, size_t size) +{ + return ::send((SOCKET)fd, (const char*)buf, (int)size, 0); +} + +static inline ssize_t st_utest_recv(int fd, void* buf, size_t size) +{ + return ::recv((SOCKET)fd, (char*)buf, (int)size, 0); +} + +static inline int st_utest_close(int fd) +{ + return ::closesocket((SOCKET)fd); +} + +// Whether the last send or recv failed because the socket wasn't ready. +static inline bool st_utest_would_block() +{ + return ::WSAGetLastError() == WSAEWOULDBLOCK; +} +#else // Two connected stream sockets. Returns 0, or -1 with errno set. static inline int st_utest_stream_pair(int fds[2]) { @@ -50,6 +115,7 @@ static inline bool st_utest_would_block() { return errno == EAGAIN || errno == EWOULDBLOCK; } +#endif // A connection whose one end ST waits on, wrapped with st_netfd_open_socket, and whose other end, the peer, the test // reads and writes directly. diff --git a/utest/st_utest_event.cpp b/utest/st_utest_event.cpp index 8e38e7a..325e93b 100644 --- a/utest/st_utest_event.cpp +++ b/utest/st_utest_event.cpp @@ -6,20 +6,26 @@ #include #include #include +#ifndef _WIN32 #include +#endif #include #include #include #include +#ifndef _WIN32 #include +#endif #include +#ifndef _WIN32 #include #include #include #include #include #include +#endif #define ST_UTIME_MILLISECONDS 1000 #define ST_UTEST_TIMEOUT (100 * ST_UTIME_MILLISECONDS) @@ -31,6 +37,7 @@ // on descriptors already in it. //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +#ifndef _WIN32 // POSIX only: pipes at chosen descriptor numbers // A pipe whose read end is moved to a chosen descriptor number, and whose write end stays plain. struct EventTestHighPipe { int reader_; @@ -68,6 +75,7 @@ static bool event_test_high_pipe(EventTestHighPipe& p, int fd) p.reader_ = fd; return true; } +#endif struct EventTestReader { st_netfd_t stfd_; @@ -89,6 +97,7 @@ static void* event_test_reader_coroutine(void* arg) return NULL; } +#ifndef _WIN32 // POSIX only: pipes at chosen descriptor numbers // The server already holds more descriptors than the initial table, so the next connection gets descriptor 5000. // st_netfd_open grows the table for it, a coroutine reading it waits and wakes on its data, and the descriptor then // closes, since no waiter is left counted on it. Locks in current behavior. @@ -160,6 +169,7 @@ VOID TEST(HighFdTest, WaiterKeepsWaitingWhenTableGrows) EXPECT_EQ(0, st_netfd_close(r.stfd_)); ::close(fds[1]); } +#endif //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for a pollset the event system refuses part of, such as a tool that waits with st_poll on its server @@ -168,6 +178,7 @@ VOID TEST(HighFdTest, WaiterKeepsWaitingWhenTableGrows) // at once and leaves every descriptor as it was. kqueue accepts a regular file and reports it ready. //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +#ifndef _WIN32 // POSIX only: regular files and pipes // A regular file holding one command, read from the start, like stdin redirected from a file. static int event_test_command_file() { @@ -324,6 +335,7 @@ VOID TEST(PollRefusedTest, ReaderOnSameConnectionKeepsWaiting) ::close(fds[1]); ::close(file); } +#endif //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for waiting for TCP urgent data with POLLPRI, such as an FTP server whose client aborts a transfer, or a @@ -527,6 +539,7 @@ VOID TEST(PollPriTest, ReaderAndAbortWaiterShareConnection) // list for a large pollset in the same way. //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +#ifndef _WIN32 // POSIX only: dup of a pipe // A server waits on 4097 connections in one st_poll, one more than the initial result list, and data arrives on all of // them at once. Each connection is a dup of one pipe's read end, so one byte makes them all readable. st_poll reports // every connection ready, and the descriptors then close. Locks in current behavior. @@ -560,6 +573,7 @@ VOID TEST(ManyWaitsTest, EveryReadyConnectionReported) ::close(fds[0]); ::close(fds[1]); } +#endif #if defined(__linux__) //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/utest/st_utest_io.cpp b/utest/st_utest_io.cpp index a758a22..ab0b89a 100644 --- a/utest/st_utest_io.cpp +++ b/utest/st_utest_io.cpp @@ -6,23 +6,33 @@ #include #include #include +#ifndef _WIN32 #include #include +#endif #include +#ifndef _WIN32 #include +#endif #include #include #include #include +#ifndef _WIN32 #include +#endif #include +#ifndef _WIN32 #include #include #include +#endif #include +#ifndef _WIN32 #include #include +#endif #define ST_UTIME_MILLISECONDS 1000 #define ST_UTEST_TIMEOUT (100 * ST_UTIME_MILLISECONDS) @@ -481,6 +491,7 @@ VOID TEST(IoReadTest, ReadOnResetConnectionFails) } } +#ifndef _WIN32 // POSIX only: signals, fcntl and pthread static volatile sig_atomic_t io_read_signals = 0; static void io_read_on_signal(int signo) @@ -657,6 +668,7 @@ VOID TEST(IoReadTest, ReadvResidRetriesWhenSignalInterruptsSystemCall) EXPECT_STREQ("hdr", header); EXPECT_STREQ("payload", payload); } +#endif //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for accepting and connecting TCP, the way an SRS listener accepts clients and an SRS edge or forwarder @@ -712,6 +724,7 @@ static void* io_connect_coroutine(void* arg) return NULL; } +#ifndef _WIN32 // POSIX only: fcntl O_NONBLOCK // An SRS listener waits for clients with no timeout. A client connects later: st_accept wakes, reports the client's // address, and returns a non-blocking socket that is ready for ST I/O, whether or not the OS lets an accepted socket // inherit O_NONBLOCK from the listener. On the client side, st_connect waits for the handshake and succeeds. Locks in @@ -757,6 +770,7 @@ VOID TEST(IoAcceptTest, AcceptWaitsForClient) EXPECT_EQ(5, st_read_fully(client, buf, 5, ST_UTEST_TIMEOUT)); EXPECT_STREQ("hello", buf); } +#endif // No client arrives before the timeout, so st_accept fails with ETIME. Locks in current behavior. VOID TEST(IoAcceptTest, AcceptTimesOut) @@ -824,6 +838,7 @@ VOID TEST(IoAcceptTest, AcceptOnSocketNotListeningFails) EXPECT_EQ(EINVAL, errno); } +#ifndef _WIN32 // POSIX only: signals, fcntl and pthread // Another thread of the program signals the accepting thread a few times, then a client connects. struct IoTestAcceptSignaler { pthread_t target_; @@ -900,6 +915,7 @@ VOID TEST(IoAcceptTest, AcceptRetriesWhenSignalInterruptsSystemCall) EXPECT_EQ(5, st_read_fully(client, buf, 5, ST_UTEST_TIMEOUT)); EXPECT_STREQ("hello", buf); } +#endif // Open a TCP socket for st_connect. static st_netfd_t io_tcp_socket(int family) @@ -1297,6 +1313,7 @@ VOID TEST(IoWriteTest, WritevInterruptedWhenConnectionStops) EXPECT_EQ(EINTR, w.errno_); } +#ifndef _WIN32 // POSIX only: signals and pthread // Another thread of the program signals the writing thread a few times, then reads everything as the player. struct IoTestWriteSignaler { pthread_t target_; @@ -1327,6 +1344,7 @@ static void* io_write_signaler_thread(void* arg) } return NULL; } +#endif // Write to the non-blocking socket until the send buffer and the player's receive buffer are full and the kernel takes // no more, and keep what was written. The kernel moves the send buffer to the player in the background, so the first @@ -1355,6 +1373,7 @@ static bool io_fill_buffers(int fd, std::string& filled) } } +#ifndef _WIN32 // POSIX only: signals, fcntl and pthread // A signal interrupts writev while it waits in the kernel for a stalled player, and the handler was installed without // SA_RESTART, so writev fails with EINTR. st_writev retries it instead of failing, because only st_thread_interrupt // means the writer should stop, and every byte arrives once the player reads. A blocking writev fails with EINTR only @@ -1557,6 +1576,7 @@ VOID TEST(IoWriteTest, SendmsgRetriesWhenSignalInterruptsSystemCall) EXPECT_TRUE(s.received_.substr(0, filled.size()) == filled); EXPECT_TRUE(s.received_.substr(filled.size()) == msgs.data_); } +#endif // st_writev_resid keeps the count that st_writev loses. When the stalled player makes it time out, the caller's iovec // array points at what is still unsent: the rest of a partly sent buffer, then the untouched ones. When the player @@ -1688,6 +1708,7 @@ VOID TEST(IoWriteTest, WriteFailsWhenPlayerResetsMidStream) // SRS uses to turn a signal into an event for a coroutine. //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +#ifndef _WIN32 // POSIX only: pipes // A pipe whose read end is wrapped with st_netfd_open, the way SRS wraps its signal pipe. The write end stays a plain // descriptor, like the one a signal handler writes to. struct IoTestPipe { @@ -1713,6 +1734,7 @@ static bool io_pipe(IoTestPipe& p) } return true; } +#endif struct IoTestSignalReader { st_netfd_t stfd_; @@ -1730,6 +1752,7 @@ static void* io_signal_reader_coroutine(void* arg) return NULL; } +#ifndef _WIN32 // POSIX only: pipes and fcntl // SRS turns signals into events with a pipe: the handler writes the signal number to the write end, and a coroutine // waits on the read end, wrapped with st_netfd_open. A pipe is not a socket, so ST makes it non-blocking with fcntl, // and the coroutine waits instead of blocking the whole process. Locks in current behavior. @@ -1757,6 +1780,7 @@ VOID TEST(IoNetfdTest, SignalPipeWakesWaitingCoroutine) EXPECT_EQ((ssize_t)sizeof(signo), r.nread_); EXPECT_EQ(SIGHUP, r.signo_); } +#endif // Closing a descriptor while another coroutine still waits on it fails with EBUSY, and leaves the descriptor open and // the waiter undisturbed, so the event system never holds a stale descriptor. SRS treats this as a bug and asserts @@ -1880,6 +1904,7 @@ VOID TEST(IoNetfdTest, DescriptorDataIsFreedWhenReplacedOrClosed) EXPECT_EQ("second", _io_freed_specifics[1]); } +#ifndef _WIN32 // POSIX only: getrlimit // st_init raises the soft limit of open descriptors as far as it may, so a server can hold many connections, and // st_getfdlimit reports that limit. On Linux the soft limit becomes the hard limit. On macOS an unlimited hard limit // reads as negative, so ST keeps the soft limit. Locks in current behavior. @@ -1894,6 +1919,7 @@ VOID TEST(IoNetfdTest, FdLimitIsRaisedAtInit) EXPECT_EQ(rlim.rlim_max, rlim.rlim_cur); #endif } +#endif // The original ST serialized accept across processes; this fork dropped that, so st_netfd_serialize_accept is a no-op // kept for source compatibility. It succeeds and the listener accepts as before. Locks in current behavior. @@ -1915,6 +1941,7 @@ VOID TEST(IoNetfdTest, SerializeAcceptIsNoOp) EXPECT_EQ(0, st_netfd_close(listener)); } +#ifndef _WIN32 // POSIX only: FIFOs and regular files with st_open //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for st_open: a FIFO or another special file opened non-blocking, so a coroutine waits on it like on a // socket. For example, a server that takes commands from a named pipe, or reads a stream another program writes into @@ -2171,3 +2198,4 @@ VOID TEST(IoOpenTest, RegularFileNeverWaits) EXPECT_EQ(0, st_read(fd, buf, sizeof(buf), ST_UTEST_TIMEOUT)); EXPECT_LT(st_utime() - starttime, ST_UTEST_TIMEOUT); } +#endif diff --git a/utest/st_utest_key.cpp b/utest/st_utest_key.cpp index f1b1532..1645199 100644 --- a/utest/st_utest_key.cpp +++ b/utest/st_utest_key.cpp @@ -5,7 +5,9 @@ #include #include +#ifndef _WIN32 #include +#endif #include #include #include @@ -216,6 +218,7 @@ VOID TEST(KeyTest, InvalidKeyIsRejected) EXPECT_TRUE(st_thread_getspecific(st_key_getlimit()) == NULL); } +#ifndef _WIN32 // POSIX only: an OS thread with pthread struct KeyTestThread { // The keys created, and the one past the limit. std::vector keys_; @@ -263,3 +266,4 @@ VOID TEST(KeyTest, KeyLimitPerThread) EXPECT_EQ(-1, r.create_over_limit_); EXPECT_EQ(EAGAIN, r.errno_over_limit_); } +#endif diff --git a/utest/st_utest_learn_kb.cpp b/utest/st_utest_learn_kb.cpp index 35d0ef5..17e7bc4 100644 --- a/utest/st_utest_learn_kb.cpp +++ b/utest/st_utest_learn_kb.cpp @@ -8,8 +8,10 @@ #include #include +#ifndef _WIN32 #include #include +#endif #define ST_UTIME_MILLISECONDS 1000 #define ST_UTEST_TIMEOUT (100 * ST_UTIME_MILLISECONDS) diff --git a/utest/st_utest_sched.cpp b/utest/st_utest_sched.cpp index e51cae9..1dacc5b 100644 --- a/utest/st_utest_sched.cpp +++ b/utest/st_utest_sched.cpp @@ -5,9 +5,13 @@ #include #include +#ifndef _WIN32 #include +#endif #include +#ifndef _WIN32 #include +#endif #include #include @@ -15,8 +19,10 @@ #include #include +#ifndef _WIN32 #include #include +#endif #define ST_UTIME_MILLISECONDS 1000 #define ST_UTEST_TIMEOUT (100 * ST_UTIME_MILLISECONDS) @@ -1238,6 +1244,7 @@ VOID TEST(InitTest, SecondInitKeepsWaitingCoroutines) EXPECT_EQ('x', r.data_); } +#ifndef _WIN32 // POSIX only: fork, pipes and pthread //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for a program that ends when its last coroutine ends, the classic ST server: main starts the workers, then // calls st_thread_exit instead of returning, and the process exits with status 0 once the last coroutine terminates. @@ -1411,3 +1418,4 @@ VOID TEST(ExitTest, WaitingReaderKeepsProcessAlive) EXPECT_EQ(0, exit_test_run(exit_test_waiting_reader, out)); EXPECT_EQ("got a\n", out); } +#endif diff --git a/utest/st_utest_select.cpp b/utest/st_utest_select.cpp index 56dc337..9325b8e 100644 --- a/utest/st_utest_select.cpp +++ b/utest/st_utest_select.cpp @@ -7,13 +7,17 @@ #include #include #include +#ifndef _WIN32 #include #include +#endif #include #include #include #include +#ifndef _WIN32 #include +#endif #if defined(__APPLE__) #include @@ -21,6 +25,7 @@ #include #endif +#ifndef _WIN32 #include #include #include @@ -28,10 +33,12 @@ #include #include #include +#endif #define ST_UTIME_MILLISECONDS 1000 #define SELECT_TEST_TIMEOUT (1000 * ST_UTIME_MILLISECONDS) +#ifndef _WIN32 // POSIX only: each test runs its own ST in a forked child, with shared memory, pipes and pthread //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for the select event system, the portable one: the only one on Cygwin, and the one an OS thread gets when // it calls st_init without choosing. It watches at most FD_SETSIZE descriptors, so its st_init lowers the descriptor @@ -1448,3 +1455,4 @@ VOID TEST(SelectTest, DestroyFreesEventSystem) EXPECT_GE(r.p_->heap_before_, r.p_->heap_after_ + 3 * sizeof(fd_set)); #endif } +#endif diff --git a/utest/st_utest_stack.cpp b/utest/st_utest_stack.cpp index ce389af..ba106fb 100644 --- a/utest/st_utest_stack.cpp +++ b/utest/st_utest_stack.cpp @@ -4,15 +4,23 @@ #include #include +#ifndef _WIN32 #include +#endif #include +#ifndef _WIN32 #include +#endif #include #include +#ifndef _WIN32 #include +#endif +#ifndef _WIN32 #include #include +#endif #include #define ST_UTIME_MILLISECONDS 1000 @@ -44,6 +52,7 @@ static void* stack_test_record_coroutine(void* arg) return NULL; } +#ifndef _WIN32 // POSIX only: pipes and syscall // Whether the byte at p can be read. write reports EFAULT for an unreadable page instead of faulting. It calls the // system call directly, because ASAN checks the buffer of write, and p may be heap memory just past a stack that // MALLOC_STACK allocated. macOS deprecates syscall, but it still works. @@ -63,6 +72,7 @@ static uintptr_t stack_test_top(const StackTestCoroutine& c) uintptr_t pagesize = (uintptr_t)getpagesize(); return (c.frame_ & ~(pagesize - 1)) + pagesize; } +#endif // Randomization is off by default, and each call returns the previous setting. Locks in current behavior. VOID TEST(RandomizeStacksTest, SwitchReturnsPrevious) @@ -110,6 +120,7 @@ VOID TEST(RandomizeStacksTest, StacksStartAtDifferentOffsets) EXPECT_GT(moved, 0); } +#ifndef _WIN32 // POSIX only: mmap, mprotect interposition with dlsym, pipes // A server turns randomization on while it runs, after some coroutines already exist. One of them exits, and the next // coroutine created frees its stack. The coroutines still running must keep their guard pages, so an overflow of their // stacks still faults. The stacks are mapped next to each other: each new one below the last on Linux, above it on @@ -273,6 +284,7 @@ VOID TEST(RandomizeStacksTest, FreeingRestoresTheGuardPagesCreationProtected) } } } +#endif //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for describing the main thread's stack to AddressSanitizer. ST runs the main thread as its primordial @@ -288,6 +300,7 @@ extern void* _st_primordial_stack_bottom; extern size_t _st_primordial_stack_size; } +#ifndef _WIN32 // POSIX only: pthread stack attributes // The stack of the calling OS thread, from its lowest to its highest address. static void stack_test_thread_stack(char** bottom, char** top) { @@ -353,3 +366,4 @@ VOID TEST(PrimordialStackTest, DescribesTheMainThreadStack) st_set_primordial_stack((char*)saved_bottom + saved_size, saved_bottom); } +#endif diff --git a/utest/st_utest_sync.cpp b/utest/st_utest_sync.cpp index 376a744..f4296eb 100644 --- a/utest/st_utest_sync.cpp +++ b/utest/st_utest_sync.cpp @@ -6,13 +6,19 @@ #include #include #include +#ifndef _WIN32 #include +#endif #include #include +#ifndef _WIN32 #include +#endif +#ifndef _WIN32 #include #include +#endif #define ST_UTIME_MILLISECONDS 1000 @@ -767,6 +773,7 @@ VOID TEST(SleepTest, NegativeSecondsSleepsUntilStopped) EXPECT_EQ(EINTR, sleeper.errno_); } +#ifndef _WIN32 // POSIX only: fork, shared memory, pipes and pthread //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for a custom clock, such as a simulation or a test tool that drives time itself instead of waiting for it. // st_set_utime_function replaces the monotonic clock behind st_utime, and every timeout counts on it: sleeps, condition @@ -1098,3 +1105,4 @@ VOID TEST(CustomClockTest, RefusedOnceStRuns) EXPECT_EQ(EINVAL, r->null_errno_); EXPECT_EQ(1000 * ST_UTIME_SECONDS, r->utime_after_); } +#endif diff --git a/utest/st_utest_tcp.cpp b/utest/st_utest_tcp.cpp index 54af7d4..168d10b 100644 --- a/utest/st_utest_tcp.cpp +++ b/utest/st_utest_tcp.cpp @@ -6,9 +6,11 @@ #include #include +#ifndef _WIN32 #include #include #include +#endif #define ST_UTIME_MILLISECONDS 1000 #define ST_UTEST_TIMEOUT (100 * ST_UTIME_MILLISECONDS) diff --git a/utest/st_utest_udp.cpp b/utest/st_utest_udp.cpp index c697e13..8263b26 100644 --- a/utest/st_utest_udp.cpp +++ b/utest/st_utest_udp.cpp @@ -6,16 +6,22 @@ #include #include #include +#ifndef _WIN32 #include +#endif #include +#ifndef _WIN32 #include +#endif #include #include +#ifndef _WIN32 #include #include #include #include +#endif #define ST_UTIME_MILLISECONDS 1000 #define ST_UTEST_TIMEOUT (100 * ST_UTIME_MILLISECONDS) @@ -240,6 +246,7 @@ VOID TEST(IoUdpTest, RecvfromFailsWhenPeerRefuses) EXPECT_EQ(ECONNREFUSED, errno); } +#ifndef _WIN32 // POSIX only: signals, fcntl and pthread static volatile sig_atomic_t io_udp_signals = 0; static void io_udp_on_signal(int signo) @@ -314,6 +321,7 @@ VOID TEST(IoUdpTest, RecvfromRetriesWhenSignalInterruptsSystemCall) EXPECT_STREQ("hello", buf); EXPECT_EQ(player.addr_.sin_port, from.sin_port); } +#endif //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for st_recvmsg and st_sendmsg: datagram I/O through a msghdr, which adds what st_recvfrom and st_sendto @@ -369,6 +377,7 @@ VOID TEST(IoUdpMsgTest, SendmsgGathersAndRecvmsgScatters) EXPECT_EQ(player.addr_.sin_addr.s_addr, from.sin_addr.s_addr); } +#ifndef _WIN32 // POSIX only: IP_PKTINFO control messages with CMSG // A server bound to every interface must answer from the address the client sent to, or a multi-homed host replies // from the wrong address. With IP_PKTINFO on, st_recvmsg delivers each datagram's destination address as a control // message. Locks in current behavior. @@ -416,6 +425,7 @@ VOID TEST(IoUdpMsgTest, RecvmsgReportsDestinationAddress) memcpy(&info, CMSG_DATA(cmsg), sizeof(info)); EXPECT_EQ(htonl(INADDR_LOOPBACK), info.ipi_addr.s_addr); } +#endif // st_recvfrom drops the tail of a datagram larger than the buffer without saying so. st_recvmsg says so: it returns // the bytes that fit and sets MSG_TRUNC in msg_flags, so a server can count or reject oversized packets. Locks in @@ -538,6 +548,7 @@ VOID TEST(IoUdpMsgTest, RecvmsgFailsWhenPeerRefuses) EXPECT_EQ(ECONNREFUSED, errno); } +#ifndef _WIN32 // POSIX only: signals, fcntl and pthread // The same signal interrupts recvmsg. st_recvmsg retries it, and the retry returns the datagram the player sends // later, with the player's address in msg_name. The test clears O_NONBLOCK so recvmsg waits in the kernel, as for // st_recvfrom. Locks in current behavior. @@ -587,6 +598,7 @@ VOID TEST(IoUdpMsgTest, RecvmsgRetriesWhenSignalInterruptsSystemCall) EXPECT_STREQ("hello", buf); EXPECT_EQ(player.addr_.sin_port, from.sin_port); } +#endif // The buffers of one datagram add up to more than UDP allows: st_sendmsg fails at once with EMSGSIZE instead of // waiting for the socket to become writable. Locks in current behavior. @@ -628,6 +640,7 @@ static void* io_consume_all_coroutine(void* arg) } #endif +#ifndef _WIN32 // POSIX only: Unix-domain datagram socket pairs // Loopback UDP never runs out of send buffer, so this uses a local datagram socket pair, whose sender can't send // while its queued datagrams fill the send buffer, like a producer that outruns its consumer. On Linux, the kernel // reports the socket as not ready: st_sendmsg fails with ETIME when nobody reads, and waits until the consumer catches @@ -718,3 +731,4 @@ VOID TEST(IoUdpTest, SendtoWaitsForFullQueue) EXPECT_EQ(ENOBUFS, errno); #endif } +#endif From d6b2d67ec2cec49b6b2ea4ba7ad5bdbf2f421fe9 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 22:02:32 -0400 Subject: [PATCH 06/37] Claude: Add a native Windows build-only CI job with MSVC and GNU make. The new windows job runs on windows-latest. A pwsh step finds Visual Studio with vswhere, runs vcvars64.bat, and passes INCLUDE, LIB, LIBPATH, and only the PATH directories vcvars64.bat adds to the next steps, so no third-party action is needed. A Git Bash step installs GNU make with choco only if the runner has none, then builds win64-debug-utest and win64-optimized and checks that st_utest.exe and libst.a exist. It does not run st_utest, which needs the context switch and event system that are not ported yet. The final job now also requires the windows job. The utest and coverage jobs are unchanged. --------- Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 47 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c639fb0..6bbc3f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,17 +59,60 @@ jobs: files: coverage.xml disable_search: true + # Native Windows with MSVC, built with GNU make from Git Bash. Build only: st_utest + # cannot run until the context switch and the event system are ported. + windows: + name: actions-test-build-win64 + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + ################################################################ + # Build + - name: Setup MSVC environment + shell: pwsh + run: | + # Pass the vcvars64.bat environment to the next steps: INCLUDE, LIB, LIBPATH, + # and only the PATH directories that vcvars64.bat adds. + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vs = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vs) { throw "No Visual Studio with the MSVC x64 tools" } + $vars = cmd /c "`"$vs\VC\Auxiliary\Build\vcvars64.bat`" >nul && set" + if ($LASTEXITCODE -ne 0) { throw "vcvars64.bat failed" } + $old = $env:PATH -split ';' + foreach ($line in $vars) { + if ($line -match '^(INCLUDE|LIB|LIBPATH)=(.*)$') { + [IO.File]::AppendAllText($env:GITHUB_ENV, "$($Matches[1])=$($Matches[2])`n") + } elseif ($line -match '^PATH=(.*)$') { + foreach ($dir in $Matches[1] -split ';') { + if ($dir -and $old -notcontains $dir) { [IO.File]::AppendAllText($env:GITHUB_PATH, "$dir`n") } + } + } + } + - name: Build ST and utest with MSVC + shell: bash + run: | + command -v make >/dev/null || choco install make --no-progress -y + command -v make cl ml64 lib + make win64-debug-utest + test -f obj/st_utest.exe + make win64-optimized + test -f obj/libst.a + # The single check to require for merging. It runs even when a needed job # fails, because a skipped required check counts as passed. final: name: actions-test-final runs-on: ubuntu-24.04 - needs: [utest, coverage] + needs: [utest, coverage, windows] if: always() steps: - name: Check all jobs succeeded run: | - echo "utest: ${{ needs.utest.result }}, coverage: ${{ needs.coverage.result }}" + echo "utest: ${{ needs.utest.result }}, coverage: ${{ needs.coverage.result }}, windows: ${{ needs.windows.result }}" test "${{ needs.utest.result }}" = success test "${{ needs.coverage.result }}" = success + test "${{ needs.windows.result }}" = success From 58a7bda7750bd3b5ed7f343deb7eed963ce09533 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 22:21:21 -0400 Subject: [PATCH 07/37] Claude: Build the utest with CMake and MSVC for a Visual Studio project on native Windows. cmake/CMakeLists.txt gets an MSVC branch for the WIN64 platform of the Makefile, which stays the main build: no bash, ST_DIR from get_filename_component, -DWIN64 -DDEBUG -DNOMINMAX, st.h copied to the build directory so the obj junction of make is left alone, md_win64.asm with ASM_MASM when it exists and the md_win64.c stub before, Google Test with the shared CRT, ws2_32, and st_utest as the Visual Studio startup project. The tools are not built on Windows yet. On a Windows host the project does not enable the GNU ASM language, and non-MSVC compilers are rejected. The Linux and macOS paths are unchanged: the diff only adds lines, and the MSVC branch returns before them. --------- Co-Authored-By: Claude Opus 5.5 --- cmake/CMakeLists.txt | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index a575a1e..9abccc6 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -5,6 +5,10 @@ cmake_minimum_required(VERSION 3.10) # Language "C" is required for find_package(Threads). if (CMAKE_VERSION VERSION_LESS 3.0) project(st CXX C ASM) +elseif (CMAKE_HOST_WIN32) + # Native Windows uses MASM (ASM_MASM) below, not the GNU assembler (ASM). + cmake_policy(SET CMP0048 NEW) + project(st VERSION 4.0.0 LANGUAGES CXX C) else() cmake_policy(SET CMP0048 NEW) project(st VERSION 4.0.0 LANGUAGES CXX C ASM) @@ -13,6 +17,54 @@ endif() # For utest required C++11. set (CMAKE_CXX_STANDARD 11) +########################################################### +# Native Windows x64 with MSVC, the WIN64 platform of ../Makefile, which stays the main build. This gives a Visual +# Studio project of the utest for debugging, for example: +# cmake -S cmake -B build -G "Visual Studio 17 2022" -A x64 +# The tools are not built on Windows yet. No bash, and the source tree is not written: st.h is copied to the build +# directory, so the obj junction made by make is left alone. +if (CMAKE_HOST_WIN32 AND NOT MSVC) + message(FATAL_ERROR "Only MSVC is supported on Windows, for example the Visual Studio generator") +endif() +if (MSVC) + get_filename_component(ST_DIR ${PROJECT_SOURCE_DIR}/.. ABSOLUTE) + message("ST home is ${ST_DIR}") + + # NOMINMAX keeps windows.h, included by winsock2.h, from breaking std::min and std::max. + ADD_DEFINITIONS("-DWIN64 -DDEBUG -DNOMINMAX") + + configure_file(${ST_DIR}/public.h ${PROJECT_BINARY_DIR}/st/st.h COPYONLY) + INCLUDE_DIRECTORIES(${PROJECT_BINARY_DIR}/st ${ST_DIR}/utest) + + foreach (FILE event.c io.c key.c sched.c stk.c sync.c common.c) + list(APPEND SOURCE_FILES ${ST_DIR}/${FILE}) + endforeach () + # The context switch is md_win64.asm once ported, and a stub in md_win64.c before. + if (EXISTS ${ST_DIR}/md_win64.asm) + enable_language(ASM_MASM) + list(APPEND SOURCE_FILES ${ST_DIR}/md_win64.asm) + else () + list(APPEND SOURCE_FILES ${ST_DIR}/md_win64.c) + endif () + + # Google Test with the same DLL runtime (/MD) as the utest. + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + if (NOT TARGET gtest) + ADD_SUBDIRECTORY(${ST_DIR}/utest/gtest-fit gtest-fit) + endif () + INCLUDE_DIRECTORIES(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR}) + + set(ST_UTEST_SOURCE_FILES ${SOURCE_FILES}) + AUX_SOURCE_DIRECTORY(${ST_DIR}/utest ST_UTEST_SOURCE_FILES) + + ADD_EXECUTABLE(st_utest ${ST_UTEST_SOURCE_FILES}) + TARGET_LINK_LIBRARIES(st_utest gtest gtest_main ws2_32) + set_property(DIRECTORY ${PROJECT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT st_utest) + + MESSAGE(STATUS "@see https://github.com/ossrs/state-threads#usage") + return() +endif() + ########################################################### execute_process( COMMAND bash -c "cd ${PROJECT_SOURCE_DIR}/../ && pwd" From 50e04d5708aa9e411ed80b786ed1e9cfa7a8164e Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 22:39:11 -0400 Subject: [PATCH 08/37] Claude: Port the context switch to native Windows x64 with MASM in md_win64.asm. md_win64.asm replaces the md_win64.c stub that aborted. _st_md_cxt_save and _st_md_cxt_restore keep the contract of the other platforms, and save the whole Win64 callee-saved set: rbx, rbp, rdi, rsi, r12-r15, rsp, pc, xmm6-xmm15, MXCSR and the x87 control word, plus the TIB stack bounds (StackBase, StackLimit, DeallocationStack), so C++ exceptions and stack walks work on coroutine stacks. The layout matches MD_GET_SP (slot 8) and the 288-byte jmpbuf of md.h. The Makefile assembles it with ml64 -nologo -c through a %.asm rule only for WIN64, so POSIX make commands are unchanged. CMake always uses ASM_MASM now. --------- Co-Authored-By: Claude Opus 5.5 --- Makefile | 6 +++ cmake/CMakeLists.txt | 10 ++-- md_win64.asm | 121 +++++++++++++++++++++++++++++++++++++++++++ md_win64.c | 26 ---------- 4 files changed, 130 insertions(+), 33 deletions(-) create mode 100644 md_win64.asm delete mode 100644 md_win64.c diff --git a/Makefile b/Makefile index 1abf13f..175c652 100644 --- a/Makefile +++ b/Makefile @@ -313,6 +313,12 @@ $(HEADER): public.h $(TARGETDIR)/%.o: %.S $(CC) $(CFLAGS) -c $< -o $@ +# The WIN64 context switch is MASM assembly, md_win64.asm. +ifeq ($(OS), WIN64) +$(TARGETDIR)/%.o: %.asm + ml64 -nologo -c -Fo$@ $< +endif + $(TARGETDIR)/%.o: %.c common.h md.h $(CC) $(CFLAGS) -c $< $(CC_OUT)$@ diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 9abccc6..b3a9fce 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -39,13 +39,9 @@ if (MSVC) foreach (FILE event.c io.c key.c sched.c stk.c sync.c common.c) list(APPEND SOURCE_FILES ${ST_DIR}/${FILE}) endforeach () - # The context switch is md_win64.asm once ported, and a stub in md_win64.c before. - if (EXISTS ${ST_DIR}/md_win64.asm) - enable_language(ASM_MASM) - list(APPEND SOURCE_FILES ${ST_DIR}/md_win64.asm) - else () - list(APPEND SOURCE_FILES ${ST_DIR}/md_win64.c) - endif () + # The context switch is MASM assembly. + enable_language(ASM_MASM) + list(APPEND SOURCE_FILES ${ST_DIR}/md_win64.asm) # Google Test with the same DLL runtime (/MD) as the utest. set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) diff --git a/md_win64.asm b/md_win64.asm new file mode 100644 index 0000000..0fe765f --- /dev/null +++ b/md_win64.asm @@ -0,0 +1,121 @@ +; SPDX-License-Identifier: MIT +; Copyright (c) 2013-2026 The SRS Authors + +; Native Windows x64 (MSVC, MASM) context switch, the WIN64 platform, not CYGWIN64. +; Assemble with: ml64 -nologo -c -Fomd_win64.o md_win64.asm +; +; The Win64 ABI callee-saved (nonvolatile) registers are rbx, rbp, rdi, rsi, r12-r15, +; xmm6-xmm15, the MXCSR control bits and the x87 control word, so all are saved. The +; TIB stack bounds (StackBase gs:[8], StackLimit gs:[16], DeallocationStack gs:[1478h]) +; are switched too, because the exception unwinder, __chkstk, and stack walks reject a +; stack outside these bounds. +; @see https://learn.microsoft.com/en-us/cpp/build/x64-software-conventions#register-usage +; +; The jmpbuf layout, in bytes, must match MD_GET_SP and _st_jmp_buf_t in md.h: +; 0 rbx, 8 rbp, 16 rdi, 24 rsi, 32-56 r12-r15, 64 rsp (slot 8), 72 pc (slot 9), +; 80 StackBase, 88 StackLimit, 96 DeallocationStack, 104 mxcsr, 108 x87 control word, +; 128-287 xmm6-xmm15 (unaligned, 16 bytes each). +JB_RBX EQU 0 +JB_RBP EQU 8 +JB_RDI EQU 16 +JB_RSI EQU 24 +JB_R12 EQU 32 +JB_R13 EQU 40 +JB_R14 EQU 48 +JB_R15 EQU 56 +JB_RSP EQU 64 +JB_PC EQU 72 +JB_STKBASE EQU 80 +JB_STKLIMIT EQU 88 +JB_STKDEALL EQU 96 +JB_MXCSR EQU 104 +JB_FPUCW EQU 108 +JB_XMM6 EQU 128 + +; The TIB (NT_TIB of the TEB) offsets from gs. +TIB_STKBASE EQU 8 +TIB_STKLIMIT EQU 16 +TIB_STKDEALL EQU 1478h + +.code + +; int _st_md_cxt_save(_st_jmp_buf_t env), env is rcx. Returns 0. +_st_md_cxt_save PROC + mov [rcx+JB_RBX], rbx + mov [rcx+JB_RBP], rbp + mov [rcx+JB_RDI], rdi + mov [rcx+JB_RSI], rsi + mov [rcx+JB_R12], r12 + mov [rcx+JB_R13], r13 + mov [rcx+JB_R14], r14 + mov [rcx+JB_R15], r15 + ; The SP of the caller, after this function returns. + lea rdx, [rsp+8] + mov [rcx+JB_RSP], rdx + ; The PC this function returns to. + mov rdx, [rsp] + mov [rcx+JB_PC], rdx + ; The TIB stack bounds. + mov rdx, gs:[TIB_STKBASE] + mov [rcx+JB_STKBASE], rdx + mov rdx, gs:[TIB_STKLIMIT] + mov [rcx+JB_STKLIMIT], rdx + mov rdx, gs:[TIB_STKDEALL] + mov [rcx+JB_STKDEALL], rdx + ; The floating-point control state. + stmxcsr dword ptr [rcx+JB_MXCSR] + fnstcw word ptr [rcx+JB_FPUCW] + movdqu [rcx+JB_XMM6+0], xmm6 + movdqu [rcx+JB_XMM6+16], xmm7 + movdqu [rcx+JB_XMM6+32], xmm8 + movdqu [rcx+JB_XMM6+48], xmm9 + movdqu [rcx+JB_XMM6+64], xmm10 + movdqu [rcx+JB_XMM6+80], xmm11 + movdqu [rcx+JB_XMM6+96], xmm12 + movdqu [rcx+JB_XMM6+112], xmm13 + movdqu [rcx+JB_XMM6+128], xmm14 + movdqu [rcx+JB_XMM6+144], xmm15 + xor eax, eax + ret +_st_md_cxt_save ENDP + +; void _st_md_cxt_restore(_st_jmp_buf_t env, int val), env is rcx, val is edx. +; Jumps back to the save of env, which then returns val, or 1 if val is 0. +_st_md_cxt_restore PROC + mov rbx, [rcx+JB_RBX] + mov rbp, [rcx+JB_RBP] + mov rdi, [rcx+JB_RDI] + mov rsi, [rcx+JB_RSI] + mov r12, [rcx+JB_R12] + mov r13, [rcx+JB_R13] + mov r14, [rcx+JB_R14] + mov r15, [rcx+JB_R15] + mov r8, [rcx+JB_STKBASE] + mov gs:[TIB_STKBASE], r8 + mov r8, [rcx+JB_STKLIMIT] + mov gs:[TIB_STKLIMIT], r8 + mov r8, [rcx+JB_STKDEALL] + mov gs:[TIB_STKDEALL], r8 + ldmxcsr dword ptr [rcx+JB_MXCSR] + fldcw word ptr [rcx+JB_FPUCW] + movdqu xmm6, [rcx+JB_XMM6+0] + movdqu xmm7, [rcx+JB_XMM6+16] + movdqu xmm8, [rcx+JB_XMM6+32] + movdqu xmm9, [rcx+JB_XMM6+48] + movdqu xmm10, [rcx+JB_XMM6+64] + movdqu xmm11, [rcx+JB_XMM6+80] + movdqu xmm12, [rcx+JB_XMM6+96] + movdqu xmm13, [rcx+JB_XMM6+112] + movdqu xmm14, [rcx+JB_XMM6+128] + movdqu xmm15, [rcx+JB_XMM6+144] + ; Return val, or 1 if val is 0. + mov eax, 1 + test edx, edx + cmovne eax, edx + ; Restore SP and jump to the saved PC. + mov r8, [rcx+JB_PC] + mov rsp, [rcx+JB_RSP] + jmp r8 +_st_md_cxt_restore ENDP + +END diff --git a/md_win64.c b/md_win64.c deleted file mode 100644 index 20be3f9..0000000 --- a/md_win64.c +++ /dev/null @@ -1,26 +0,0 @@ -/* SPDX-License-Identifier: MIT */ -/* Copyright (c) 2013-2026 The SRS Authors */ - -/* - * Native Windows x64 (MSVC) context switch stub, so ST links. It aborts until - * the context switch is ported to md_win64.asm. - */ - -#include -#include -#include "common.h" - -int _st_md_cxt_save(_st_jmp_buf_t env) -{ - (void) env; - fprintf(stderr, "ST: _st_md_cxt_save, context switch not ported to Windows yet\n"); - abort(); -} - -void _st_md_cxt_restore(_st_jmp_buf_t env, int val) -{ - (void) env; - (void) val; - fprintf(stderr, "ST: _st_md_cxt_restore, context switch not ported to Windows yet\n"); - abort(); -} From 622034641665522543497ce93c224c42ea1de3db Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 22:46:16 -0400 Subject: [PATCH 09/37] Claude: Set the TIB stack bounds of new threads on native Windows x64. st_thread_create now sets the stack bounds the OS keeps for a new thread through MD_INIT_STACK_BOUNDS, defined only for WIN64 in md.h: slots 10-12 of the jmpbuf, which md_win64.asm restores into the TIB, get the stack top as StackBase and the stack bottom as StackLimit and DeallocationStack. Before, a new thread inherited its creator's bounds, so a C++ exception thrown on a coroutine stack was not caught, and __chkstk could probe the wrong stack for frames over a page. Other platforms do not define the macro, so their sched.c is unchanged after preprocessing. --------- Co-Authored-By: Claude Opus 5.5 --- md.h | 11 +++++++++++ sched.c | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/md.h b/md.h index 55b8e5b..70279cc 100644 --- a/md.h +++ b/md.h @@ -211,6 +211,17 @@ extern void _st_md_cxt_restore(_st_jmp_buf_t env, int val); #if defined(_M_X64) || defined(_M_AMD64) #define MD_GET_SP(_t) *((long long *)&((_t)->context[0].__jmpbuf[8])) + /* + * The TIB stack bounds of a new thread, restored from slots 10-12 by md_win64.asm: + * StackBase is the stack top, StackLimit and DeallocationStack are the stack bottom. + * Otherwise the thread runs with its creator's bounds, which breaks C++ exceptions, + * stack walks, and __chkstk for frames over a page. + */ + #define MD_INIT_STACK_BOUNDS(_t, _bottom, _top) do { \ + (_t)->context[0].__jmpbuf[10] = (long long)(intptr_t)(_top); \ + (_t)->context[0].__jmpbuf[11] = (long long)(intptr_t)(_bottom); \ + (_t)->context[0].__jmpbuf[12] = (long long)(intptr_t)(_bottom); \ + } while (0) #else #error Unknown CPU architecture #endif diff --git a/sched.c b/sched.c index 3c2b3e5..29b6d24 100644 --- a/sched.c +++ b/sched.c @@ -678,6 +678,10 @@ _st_thread_t *st_thread_create(void *(*start)(void *arg), void *arg, int joinabl _st_thread_main(); } MD_GET_SP(thread) = (intptr_t)(stack->sp); +#ifdef MD_INIT_STACK_BOUNDS + /* Set the stack bounds the OS keeps for the new thread, such as the TIB on Windows. */ + MD_INIT_STACK_BOUNDS(thread, stack->stk_bottom, stack->stk_top); +#endif /* If thread is joinable, allocate a termination condition variable */ if (joinable) { From ae77fd66c310f1f6f20d4ee9f54f6d152ca5d3d3 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 22:55:23 -0400 Subject: [PATCH 10/37] Claude: Allocate coroutine stacks with VirtualAlloc and red zones on native Windows x64. On WIN64, stacks no longer fall back to MALLOC_STACK. stk.c allocates each stack segment with VirtualAlloc and releases it with VirtualFree, and in a DEBUG build protects the lower and upper red zones with VirtualProtect (PAGE_NOACCESS), restoring them before the segment is freed, the same as mmap and mprotect on POSIX. With MALLOC_STACK, md.h still defines MD_NO_PROTECT on WIN64, because VirtualProtect rounds a heap address down to its page and would protect heap memory next to the stack. Linux, macOS, and Cygwin64 preprocess to the same code. --------- Co-Authored-By: Claude Opus 5.5 --- md.h | 11 ++++++----- stk.c | 29 +++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/md.h b/md.h index 70279cc..f22d681 100644 --- a/md.h +++ b/md.h @@ -194,11 +194,12 @@ extern void _st_md_cxt_restore(_st_jmp_buf_t env, int val); #define MD_ACCEPT_NB_NOT_INHERITED #define MD_HAVE_SOCKLEN_T - /* No mmap or mprotect: allocate stacks with malloc, without red zones. */ - #ifndef MALLOC_STACK - #define MALLOC_STACK - #endif - #ifndef MD_NO_PROTECT + /* + * Stacks come from VirtualAlloc, with VirtualProtect red zones in DEBUG (stk.c). With + * MALLOC_STACK there are no red zones: VirtualProtect rounds a heap address down to its + * page, so it would protect heap memory next to the stack, where mprotect fails instead. + */ + #if defined(MALLOC_STACK) && !defined(MD_NO_PROTECT) #define MD_NO_PROTECT #endif diff --git a/stk.c b/stk.c index 6687c93..6595a69 100644 --- a/stk.c +++ b/stk.c @@ -50,6 +50,22 @@ #endif #include "common.h" +/* Protect the red zones with _st_mprotect, which is the system mprotect by default. */ +#if defined(WIN64) +/* Windows has no mprotect: the red zones use VirtualProtect, on VirtualAlloc pages only. */ +#define PROT_NONE 0x0 +#define PROT_READ 0x1 +#define PROT_WRITE 0x2 +static int _st_win64_mprotect(void *addr, size_t len, int prot) +{ + DWORD old; + return VirtualProtect(addr, len, prot ? PAGE_READWRITE : PAGE_NOACCESS, &old) ? 0 : -1; +} +#define _st_mprotect _st_win64_mprotect +#else +#define _st_mprotect mprotect +#endif + /* How much space to leave between the stacks, at each end */ #define REDZONE _st_this_vp.pagesize @@ -96,9 +112,9 @@ _st_stack_t *_st_stack_new(int stack_size) _st_num_free_stacks--; #if defined(DEBUG) && !defined(MD_NO_PROTECT) - mprotect(ts->vaddr, REDZONE, PROT_READ | PROT_WRITE); + _st_mprotect(ts->vaddr, REDZONE, PROT_READ | PROT_WRITE); /* The upper red zone ends the segment; stk_top may be randomized, and extra may have changed since. */ - mprotect(ts->vaddr + ts->vaddr_size - REDZONE, REDZONE, PROT_READ | PROT_WRITE); + _st_mprotect(ts->vaddr + ts->vaddr_size - REDZONE, REDZONE, PROT_READ | PROT_WRITE); #endif _st_delete_stk_segment(ts->vaddr, ts->vaddr_size); @@ -121,8 +137,8 @@ _st_stack_t *_st_stack_new(int stack_size) /* For example, in OpenWRT, the memory at the begin minus 16B by mprotect is read-only. */ #if defined(DEBUG) && !defined(MD_NO_PROTECT) - mprotect(ts->vaddr, REDZONE, PROT_NONE); - mprotect(ts->stk_top + extra, REDZONE, PROT_NONE); + _st_mprotect(ts->vaddr, REDZONE, PROT_NONE); + _st_mprotect(ts->stk_top + extra, REDZONE, PROT_NONE); #endif if (extra) { @@ -154,6 +170,8 @@ static char *_st_new_stk_segment(int size) { #ifdef MALLOC_STACK void *vaddr = malloc(size); +#elif defined(WIN64) + void *vaddr = VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); #else static int zero_fd = -1; int mmap_flags = MAP_PRIVATE; @@ -185,6 +203,9 @@ void _st_delete_stk_segment(char *vaddr, int size) { #ifdef MALLOC_STACK free(vaddr); +#elif defined(WIN64) + (void) size; + (void) VirtualFree(vaddr, 0, MEM_RELEASE); #else (void) munmap(vaddr, size); #endif From db03ec01ff10ab5fb1c5e8c6ab06af849eace4e2 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 23:00:51 -0400 Subject: [PATCH 11/37] Claude: Add a minimal native Windows event dispatch that waits for the sleep timeout. The WIN64 event system now initializes and its dispatch sleeps until the earliest sleeper is due (rounded up to milliseconds, INFINITE with no sleepers, like select() with no descriptors), because Windows select() with no sockets fails instead of sleeping. ST_EVENTSYS_DEFAULT and SELECT give the select-like backend and ST_EVENTSYS_ALT is accepted with the same dispatch (D8), so st_get_eventsys reports what was asked for. _st_io_init succeeds on WIN64 with no SIGPIPE or rlimit and reports the 2^24 per-process handle limit from st_getfdlimit. Descriptor polling still fails with ENOSYS until the WSAPoll backend. Linux, macOS, and Cygwin code paths are unchanged. st_init now succeeds on Windows, so the utest runs: 69 of 131 pass; the 62 failures are socket I/O, which is not ported yet. --------- Co-Authored-By: Claude Opus 5.5 --- event.c | 55 +++++++++++++++++++++++++++++++++++++++++++++---------- io.c | 11 +++++++++-- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/event.c b/event.c index 422366c..2a8b08a 100644 --- a/event.c +++ b/event.c @@ -1251,21 +1251,34 @@ static _st_eventsys_t _st_epoll_eventsys = { #if defined (WIN64) /***************************************** - * Native Windows event system stub, so ST links. Every operation fails with - * ENOSYS until a Windows event system is ported. + * Native Windows event system, minimal: the dispatch only waits for the sleep + * timeout, so st_usleep, timed waits, and the scheduler work. Windows select() + * with no sockets fails instead of sleeping, so the dispatch sleeps itself. + * Descriptor polling fails with ENOSYS until the WSAPoll backend is ported. */ -#include ST_HIDDEN int _st_win64_init(void) { - errno = ENOSYS; - return -1; + return 0; } ST_HIDDEN void _st_win64_dispatch(void) { - fprintf(stderr, "ST: the Windows event system is not ported yet\n"); - abort(); + st_utime_t min_timeout; + DWORD ms; + + if (_st_this_vp.sleep_q == NULL) { + /* Nothing can wake a thread, like select() with no descriptors and no timeout. */ + ms = INFINITE; + } else { + min_timeout = (_st_this_vp.sleep_q->due <= _st_this_vp.last_clock) ? 0 : + (_st_this_vp.sleep_q->due - _st_this_vp.last_clock); + /* Round up to milliseconds, so the sleeper is due when the dispatch returns. */ + min_timeout = (min_timeout + 999) / 1000; + ms = (min_timeout >= INFINITE) ? INFINITE - 1 : (DWORD) min_timeout; + } + + Sleep(ms); } ST_HIDDEN int _st_win64_pollset_add(struct pollfd *pds, int npds) @@ -1298,6 +1311,7 @@ ST_HIDDEN int _st_win64_fd_close(int osfd) ST_HIDDEN int _st_win64_fd_getlimit(void) { + /* No limit of the event system itself. */ return 0; } @@ -1305,9 +1319,26 @@ ST_HIDDEN void _st_win64_destroy(void) { } +/* + * One backend for both choices (D8): ST_EVENTSYS_DEFAULT and ST_EVENTSYS_SELECT give the + * select-like default, and ST_EVENTSYS_ALT is accepted, so callers that ask for it work. + */ static _st_eventsys_t _st_win64_eventsys = { - "win64-stub", - ST_EVENTSYS_DEFAULT, + "win64", + ST_EVENTSYS_SELECT, + _st_win64_init, + _st_win64_dispatch, + _st_win64_pollset_add, + _st_win64_pollset_del, + _st_win64_fd_new, + _st_win64_fd_close, + _st_win64_fd_getlimit, + _st_win64_destroy +}; + +static _st_eventsys_t _st_win64_alt_eventsys = { + "win64", + ST_EVENTSYS_ALT, _st_win64_init, _st_win64_dispatch, _st_win64_pollset_add, @@ -1332,10 +1363,14 @@ int st_set_eventsys(int eventsys) } #if defined (WIN64) - if (eventsys == ST_EVENTSYS_DEFAULT) { + if (eventsys == ST_EVENTSYS_SELECT || eventsys == ST_EVENTSYS_DEFAULT) { _st_eventsys = &_st_win64_eventsys; return 0; } + if (eventsys == ST_EVENTSYS_ALT) { + _st_eventsys = &_st_win64_alt_eventsys; + return 0; + } #endif if (eventsys == ST_EVENTSYS_SELECT || eventsys == ST_EVENTSYS_DEFAULT) { diff --git a/io.c b/io.c index 894916f..f67a376 100644 --- a/io.c +++ b/io.c @@ -176,10 +176,17 @@ static int _st_osfd_limit = -1; static void _st_netfd_free_aux_data(_st_netfd_t *fd); #if defined(WIN64) -/* Windows has no SIGPIPE or rlimit; fail until the Windows I/O is ported. */ +/* + * Windows has no SIGPIPE and no rlimit. A process may open up to 2^24 handles, sockets + * included, so report that unless the event system has a lower limit. + */ +#define _ST_WIN64_OSFD_LIMIT (1 << 24) + int _st_io_init(void) { - return _st_win64_enosys(); + int fdlim = (*_st_eventsys->fd_getlimit)(); + _st_osfd_limit = (fdlim > 0 && fdlim < _ST_WIN64_OSFD_LIMIT) ? fdlim : _ST_WIN64_OSFD_LIMIT; + return 0; } #else int _st_io_init(void) From fc2bb04537f570a9d9253fb76d9778816eb0ad54 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 23:13:57 -0400 Subject: [PATCH 12/37] Claude: Start new threads in an assembly entry on native Windows x64, not after the context save. st_thread_create saves the context and patches its SP, so a new thread used to resume after _st_md_cxt_save in the frame of st_thread_create on its new stack. It worked under /Od, /O2, and /GL, but only by luck of codegen: at /O2 and with LTCG, MSVC inlines _st_thread_main there and reads the TLS base and _st_this_vp from registers loaded before the save, which survive only because they are callee-saved and so restored from the jmpbuf. On WIN64, MD_INIT_THREAD_ENTRY now points the PC (slot 9) at _st_md_thread_start in md_win64.asm and the SP (slot 8) at a null return address, 8 mod 16 as at a function entry. The entry has unwind info, calls _st_thread_main with the home space, and ends stack walks at address 0. sched.c calls the macro under #ifdef, so Linux, macOS, and Cygwin are unchanged and still use the save-then-patch-SP trick. The utest passes 69 of 131 on Windows with the library built /Od, /O2, and /O2 /GL, the same tests as before; the failures are socket I/O, not ported yet. --------- Co-Authored-By: Claude Opus 5.5 --- docs/win64_coroutine.md | 186 ++++++++++++++++++++++++++++++++++++++++ md.h | 14 +++ md_win64.asm | 18 ++++ sched.c | 9 ++ 4 files changed, 227 insertions(+) create mode 100644 docs/win64_coroutine.md diff --git a/docs/win64_coroutine.md b/docs/win64_coroutine.md new file mode 100644 index 0000000..ce0ad37 --- /dev/null +++ b/docs/win64_coroutine.md @@ -0,0 +1,186 @@ +# How coroutines work on native Windows x64 + +This note explains how State Threads switches and starts threads (coroutines) on native Windows x64 with +MSVC (`WIN64`), and why a new thread starts in an assembly entry, `_st_md_thread_start`, instead of the way +it starts on Linux and macOS. The code is in `md_win64.asm`, the `WIN64` branch of `md.h`, and +`st_thread_create` in `sched.c`. + +## A thread is a saved set of registers + +Running code is described by the CPU registers and the stack that `rsp` points into. To pause a thread, ST +saves the registers into its jmpbuf (`thread->context`); to resume it, ST loads them back. Two assembly +functions in `md_win64.asm` do this: + +- `_st_md_cxt_save(env)`, like `setjmp`, stores the registers into `env` and returns 0. +- `_st_md_cxt_restore(env, val)`, like `longjmp`, loads the registers from `env`, sets `rsp`, and jumps to + the saved PC. The CPU continues right after the `_st_md_cxt_save` call that filled `env`, as if that call + returned a second time, with `val` (1 if `val` is 0). + +The jmpbuf holds every register the Windows x64 ABI calls callee-saved, which a function must give back +unchanged, so the compiler may keep values in them across a call. A context switch is such a call. + +| Slot | Content | Why | +| --- | --- | --- | +| 0-7 | `rbx`, `rbp`, `rdi`, `rsi`, `r12`-`r15` | Callee-saved integer registers. On Linux, `rdi` and `rsi` are not. | +| 8 | `rsp` | The stack the thread runs on (`MD_GET_SP`). | +| 9 | PC | Where the thread continues: the return address of the save. | +| 10-12 | TIB `StackBase`, `StackLimit`, `DeallocationStack` | The bounds Windows keeps for the current stack (see below). | +| after 12 | MXCSR, x87 control word, `xmm6`-`xmm15` | Also callee-saved on Windows. On Linux, no `xmm` register is. | + +## Switching between two threads + +A thread that blocks saves itself and restores the next one: + +```c +if (_st_md_cxt_save(me->context) == 0) { /* save me, returns 0 */ + _st_md_cxt_restore(next->context, 1); /* jump into next */ +} +/* Later, when another thread restores me, the save returns 1 here and I go on. */ +``` + +The next thread was paused the same way, so its jmpbuf holds its own stack, PC, and registers, and it +continues inside a function that really runs on that stack. + +## Starting a new thread + +A new thread has never run, so it has never called `_st_md_cxt_save`; its first jmpbuf must be made up: a +stack pointer into its new, empty stack, and a PC to start at. + +### Linux and macOS: save, then patch the SP + +```c +if (_st_md_cxt_save(thread->context)) { /* 1. save the creator's registers */ + _st_thread_main(); /* 3. the new thread starts here */ +} +MD_GET_SP(thread) = stack->sp; /* 2. point only the SP at the new stack */ +``` + +The first restore of this jmpbuf jumps back into `st_thread_create`, the save returns 1, and the code calls +`_st_thread_main`. But that code now runs on the new stack, where `st_thread_create` was never called: + +``` +creator's stack new thread's stack ++---------------------------+ +---------------------------+ +| st_thread_create locals, | | empty | <- rsp after the restore +| saved registers, return | | | ++---------------------------+ +---------------------------+ +``` + +After the second return, a read of a local from the stack frame gets garbage, and a value the compiler kept +in a register from before the save is right only if it is one of the callee-saved registers the restore +loads back. The trick works only while the compiler emits nothing there but "if 1, call `_st_thread_main`". +The C language does not promise that, and the compiler does not know that `_st_md_cxt_save` returns twice. + +On Linux it holds in practice: an access to a `__thread` variable such as `_st_this_thread` is one +instruction (`mov %fs:offset` on x86-64, `tpidr_el0` on arm64), so there is nothing worth computing before +the save, and GCC and Clang have generated safe code there for decades. + +On Windows it held only by luck. With `/O2` and with `/GL` (LTCG), MSVC inlines `_st_thread_main` into +`st_thread_create`. A Windows TLS access takes several steps (`gs:[58h]`, the TLS array, `_tls_index`, the +offset), so MSVC computes the TLS base and `&_st_this_vp` before the save, keeps them in `r13` and `r14` +(`rbp` and `r15` with LTCG), and uses them after the second return. That is right only because those +registers are callee-saved; a spill to the stack would crash the new thread. + +### Windows: start in an assembly entry + +On `WIN64`, `MD_INIT_THREAD_ENTRY` runs in `st_thread_create` after the SP is patched, and points the +jmpbuf at a small function written in assembly, so no compiler decides what runs first on the new stack: + +```c +sp = align_down(stack->sp, 16) - 8; /* 8 mod 16, as at the entry of a function */ +*(void **)sp = NULL; /* a return address of 0 */ +jmpbuf[8] = sp; /* SP */ +jmpbuf[9] = _st_md_thread_start; /* PC */ +``` + +The new stack is then: + +``` +higher addresses: the thread object and its keys ++----------------------+ +| pad, 128 bytes | ++----------------------+ <- 16-byte aligned +| 0, return address | <- the SP in the jmpbuf, 8 mod 16 ++----------------------+ +| free stack, grows down +v +``` + +The first restore loads the registers, sets `rsp` to that slot, and jumps to `_st_md_thread_start`. To the +CPU this is a normal function call from address 0: a call pushes the return address and jumps, and the +"pushed" return address is the 0 written there. + +```asm +_st_md_thread_start PROC FRAME + sub rsp, 40 ; the callee's 32-byte home space, and 8 bytes to align rsp to 16 + .allocstack 40 ; unwind info: this frame is 40 bytes + .endprolog + call _st_thread_main ; run start(arg), then st_thread_exit + int 3 ; never reached; st_thread_exit never returns +_st_md_thread_start ENDP +``` + +- The Windows x64 ABI wants `rsp` 16-byte aligned at every `call`, so it is 8 mod 16 at the entry of a + function. The entry starts in exactly that state, and `sub rsp, 40` aligns it again for the call. A wrong + alignment breaks aligned SSE instructions such as `movaps`. +- `_st_thread_main` is ordinary C with a normal frame. It runs the thread function and then + `st_thread_exit`, which switches to another thread and never comes back. +- The new thread never runs a line of `st_thread_create`. The only code between the restore and + `_st_thread_main` is the four instructions above, and the registers the creator saved are left over and + never read. So the start no longer depends on how the compiler uses that frame or its registers after the + save returns twice, at `/Od`, `/O2`, or `/GL`. + +The restore itself is the same for every switch; only the first jmpbuf of a new thread differs. + +## Exceptions and stack walks (SEH) + +Structured Exception Handling (SEH) is how Windows handles errors raised while code runs: a hardware fault +such as an access violation is an SEH exception, and MSVC implements a C++ `throw` as an SEH exception too +(code `0xE06D7363`). To find a handler (a `catch` or an `__except`), Windows walks the stack frame by frame, +from the current function to its callers. Debuggers, crash dumps, and `RtlCaptureStackBackTrace` walk the +stack the same way. + +On x64, Windows does not follow `rbp` chains. Every function has an entry in the unwind tables (`.pdata` +and `.xdata`) that the compiler or assembler generates. For a PC, the entry tells how far the function moved +`rsp` and which registers it saved, so Windows finds the return address and steps to the caller. + +Windows also checks every step against the stack bounds in the thread information block (TIB). If `rsp` +leaves `StackLimit`..`StackBase`, it takes the stack as corrupt and ends the process. That is why the +context switch saves and loads the TIB bounds (slots 10-12), and `st_thread_create` sets them for a new +thread (`MD_INIT_STACK_BOUNDS`). Without them, any exception on a thread stack would look like a corrupt +stack. + +With the save-then-patch-SP trick, the outermost frame of a new thread would be code of `st_thread_create` +on a stack it was never called on. Its unwind entry points to a return address above the SP, in the pad or +the thread object, which is not a return address, so a walk would step into a random caller. With the assembly entry, the walk ends +cleanly: + +``` +thread function -> unwind info: returns to _st_thread_main +_st_thread_main -> unwind info: returns to _st_md_thread_start +_st_md_thread_start -> PROC FRAME, .allocstack 40: the return address is at rsp+40, it is 0 +0 -> no caller: the end of the stack +``` + +`PROC FRAME`, `.allocstack`, and `.endprolog` make `ml64` generate the unwind entry of the assembly +function; without them, Windows could not step over it. A return address of 0 is the usual mark of the +outermost frame. So a backtrace in a thread function has exactly three frames: the thread function, +`_st_thread_main`, and `_st_md_thread_start`. A C++ `throw` is caught across any depth of frames, and an +unhandled exception ends the process with its exception code instead of hanging. + +Linux has the same idea with DWARF `.eh_frame` instead of `.pdata`, but with no TIB check, and C++ code on +a thread stack normally catches its exceptions inside the thread function. + +## Summary + +| | Linux and macOS | Windows x64 | +| --- | --- | --- | +| PC of a new thread | In `st_thread_create`, after the save | `_st_md_thread_start`, in assembly | +| SP of a new thread | The new stack top | The new stack top, aligned, with a return address of 0 | +| First code on the new stack | The tail of `st_thread_create`, from the compiler | Four instructions written by hand | +| Depends on the optimizer | Yes, in theory | No | +| Stack walk at the top | Not defined | Ends at the return address of 0 | +| Registers saved | `rbx`, `rbp`, `r12`-`r15`, `rsp`, PC | Also `rdi`, `rsi`, `xmm6`-`xmm15`, MXCSR, x87 control word, TIB bounds | + +Only `WIN64` defines `MD_INIT_THREAD_ENTRY` now; the other platforms keep the save-then-patch-SP trick. A +later change may give every platform and CPU an assembly entry, so all start new threads the same way. diff --git a/md.h b/md.h index f22d681..20bb1de 100644 --- a/md.h +++ b/md.h @@ -223,6 +223,20 @@ extern void _st_md_cxt_restore(_st_jmp_buf_t env, int val); (_t)->context[0].__jmpbuf[11] = (long long)(intptr_t)(_bottom); \ (_t)->context[0].__jmpbuf[12] = (long long)(intptr_t)(_bottom); \ } while (0) + /* + * A new thread starts in _st_md_thread_start (md_win64.asm), which calls _st_thread_main, + * instead of after _st_md_cxt_save in st_thread_create, so it does not depend on how the + * compiler uses that frame and its registers there. The SP (slot 8) moves 16-byte aligned + * minus 8 to a null return address, as at a function entry, and the PC (slot 9) is set. + */ + extern void _st_md_thread_start(void); + #define MD_INIT_THREAD_ENTRY(_t) do { \ + char *_sp = (char *)(intptr_t)MD_GET_SP(_t); \ + _sp = (char *)((intptr_t)_sp & ~(intptr_t)15) - sizeof(void *); \ + *(void **)_sp = NULL; \ + MD_GET_SP(_t) = (long long)(intptr_t)_sp; \ + (_t)->context[0].__jmpbuf[9] = (long long)(intptr_t)_st_md_thread_start; \ + } while (0) #else #error Unknown CPU architecture #endif diff --git a/md_win64.asm b/md_win64.asm index 0fe765f..c64c963 100644 --- a/md_win64.asm +++ b/md_win64.asm @@ -118,4 +118,22 @@ _st_md_cxt_restore PROC jmp r8 _st_md_cxt_restore ENDP +EXTERN _st_thread_main:PROC + +; void _st_md_thread_start(void), the entry of a new thread, never returns. +; MD_INIT_THREAD_ENTRY in md.h points the PC of the new thread's jmpbuf here, and its SP at a +; null return address, 8 mod 16 as at any function entry, so _st_md_cxt_restore enters this +; function as if called from address 0, which ends stack walks. The thread no longer resumes +; after _st_md_cxt_save in st_thread_create, so it does not depend on how the compiler uses +; that frame or its registers after save returns twice (/Od, /O2, /GL). +_st_md_thread_start PROC FRAME + ; The 32-byte home space for the callee, and 8 bytes to align rsp to 16 at the call. + sub rsp, 40 + .allocstack 40 + .endprolog + call _st_thread_main + ; _st_thread_main calls st_thread_exit, which never returns. + int 3 +_st_md_thread_start ENDP + END diff --git a/sched.c b/sched.c index 29b6d24..124e5c6 100644 --- a/sched.c +++ b/sched.c @@ -682,6 +682,15 @@ _st_thread_t *st_thread_create(void *(*start)(void *arg), void *arg, int joinabl /* Set the stack bounds the OS keeps for the new thread, such as the TIB on Windows. */ MD_INIT_STACK_BOUNDS(thread, stack->stk_bottom, stack->stk_top); #endif +#ifdef MD_INIT_THREAD_ENTRY + /* + * Start the new thread in an assembly entry that calls _st_thread_main, not after the save above. + * TODO: Refine the thread entry for all platforms and CPUs, so all start new threads the same way, + * with an assembly entry, and none depends on the save-then-patch-SP trick; only WIN64 has one now. + * See docs/win64_coroutine.md. + */ + MD_INIT_THREAD_ENTRY(thread); +#endif /* If thread is joinable, allocate a termination condition variable */ if (joinable) { From 9aeec720ac2fcc88739f496a8fb0e020167f0c5b Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 23:23:35 -0400 Subject: [PATCH 13/37] Claude: Add utests for live registers across switches and deep recursion on coroutine stacks. CoroutineTest.LiveValuesSurviveSwitches keeps seven integers and six doubles live across st_usleep(0) in four coroutines and compares them to a run without switches. The function is optimized by itself (MSVC #pragma optimize, GCC optimize attribute), so the values live in callee-saved registers such as rdi, rsi and xmm6-xmm15 even in the -O0 utest build. CoroutineTest.DeepRecursionOnDefaultStack and DeepRecursionWithLargeFramesOnLargeStack recurse with a buffer per frame, switch at the deepest frame, and check every buffer on the way back. ExceptionTest.ThrowFromDeepRecursionWithLargeFrames throws from the bottom of such a recursion in three coroutines and checks every destructor runs in order. On Windows the new tests fail when the context switch misses rdi/rsi, xmm6-xmm15 or the TIB stack bounds, or when the requested stack size is ignored. Co-Authored-By: Claude Opus 5.5 --- utest/st_utest_coroutines.cpp | 168 ++++++++++++++++++++++++++++++++++ utest/st_utest_exception.cpp | 77 ++++++++++++++++ 2 files changed, 245 insertions(+) diff --git a/utest/st_utest_coroutines.cpp b/utest/st_utest_coroutines.cpp index ff852bc..0fb68f6 100644 --- a/utest/st_utest_coroutines.cpp +++ b/utest/st_utest_coroutines.cpp @@ -4,6 +4,7 @@ #include #include +#include //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for empty coroutine. @@ -118,3 +119,170 @@ VOID TEST(CoroutineTest, StartCoroutineParams) EXPECT_EQ(110, r0); } +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// The utest for live values across a switch. A compiler keeps loop state in callee-saved registers across a call, so a +// context switch that misses one of them, such as rdi, rsi or xmm6-xmm15 on Windows x64, corrupts the coroutine that +// resumes. The utest is built without optimization, which keeps locals in memory, so the function that holds the values +// is optimized by itself; clang has no such attribute and checks the values in memory only. +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +#if defined(_MSC_VER) +#define COROUTINE_TEST_OPTIMIZE +#elif defined(__GNUC__) && !defined(__clang__) +#define COROUTINE_TEST_OPTIMIZE __attribute__((optimize("O2"))) +#else +#define COROUTINE_TEST_OPTIMIZE +#endif + +#define COROUTINE_TEST_LIVE_ROUNDS 100 +#define COROUTINE_TEST_LIVE_COROUTINES 4 + +// Seven integers and six doubles, more than the volatile registers can hold across a call. +struct CoroutineTestLive { + uint64_t seed_; + uint64_t ints_; + double doubles_; + CoroutineTestLive() : seed_(0), ints_(0), doubles_(0) { + } +}; + +static void coroutine_test_no_yield() +{ +} + +static void coroutine_test_yield() +{ + st_usleep(0); +} + +#if defined(_MSC_VER) +#pragma optimize("gt", on) +#endif +static COROUTINE_TEST_OPTIMIZE void coroutine_test_live_values(CoroutineTestLive* live, void (*yield)()) +{ + uint64_t a = live->seed_, b = a + 1, c = a + 2, d = a + 3, e = a + 5, f = a + 7, g = a + 11; + double x = (double)a, y = x + 0.5, z = x + 1.5, u = x + 2.5, v = x + 3.5, w = x + 4.5; + for (int i = 0; i < COROUTINE_TEST_LIVE_ROUNDS; i++) { + yield(); + a = a * 3 + b; b = b * 5 + c; c = c * 7 + d; d = d * 11 + e; e = e * 13 + f; f = f * 17 + g; g = g * 19 + a; + x = x * 0.5 + y; y = y * 0.25 + z; z = z * 0.5 + u; u = u * 0.25 + v; v = v * 0.5 + w; w = w * 0.25 + x + i; + } + live->ints_ = a ^ b ^ c ^ d ^ e ^ f ^ g; + live->doubles_ = x + y + z + u + v + w; +} +#if defined(_MSC_VER) +#pragma optimize("", on) +#endif + +static void* coroutine_test_live_coroutine(void* arg) +{ + coroutine_test_live_values((CoroutineTestLive*)arg, coroutine_test_yield); + return NULL; +} + +VOID TEST(CoroutineTest, LiveValuesSurviveSwitches) +{ + // Every coroutine holds different values, so a register another coroutine left behind is caught. + CoroutineTestLive lives[COROUTINE_TEST_LIVE_COROUTINES]; + st_thread_t trds[COROUTINE_TEST_LIVE_COROUTINES]; + for (int i = 0; i < COROUTINE_TEST_LIVE_COROUTINES; i++) { + lives[i].seed_ = 1000 * (i + 1); + trds[i] = st_thread_create(coroutine_test_live_coroutine, &lives[i], 1, 0); + ASSERT_TRUE(trds[i] != NULL); + } + for (int i = 0; i < COROUTINE_TEST_LIVE_COROUTINES; i++) { + st_thread_join(trds[i], NULL); + } + + // The same computation without switches gives the expected values. + for (int i = 0; i < COROUTINE_TEST_LIVE_COROUTINES; i++) { + CoroutineTestLive expected; + expected.seed_ = lives[i].seed_; + coroutine_test_live_values(&expected, coroutine_test_no_yield); + EXPECT_EQ(expected.ints_, lives[i].ints_) << "coroutine " << i; + EXPECT_EQ(expected.doubles_, lives[i].doubles_) << "coroutine " << i; + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// The utest for deep recursion on a coroutine stack. Every frame fills a buffer, the deepest frame switches to the other +// coroutines, and every frame checks its buffer on the way back. A frame larger than a page makes Windows probe the +// stack page by page against the stack bounds of the coroutine. +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +#define COROUTINE_TEST_RECURSE_COROUTINES 3 + +struct CoroutineTestRecurse { + int id_; + int depth_; + int frames_; + int corrupted_; + CoroutineTestRecurse() : id_(0), depth_(0), frames_(0), corrupted_(0) { + } +}; + +template +static void coroutine_test_recurse(CoroutineTestRecurse* r, int depth) +{ + volatile unsigned char buf[SIZE]; + unsigned char v = (unsigned char)(r->id_ * 31 + depth); + for (int i = 0; i < SIZE; i++) { + buf[i] = (unsigned char)(v + i); + } + + if (depth < r->depth_) { + coroutine_test_recurse(r, depth + 1); + } else { + st_usleep(0); + st_usleep(0); + } + + for (int i = 0; i < SIZE; i++) { + if (buf[i] != (unsigned char)(v + i)) { + r->corrupted_++; + break; + } + } + r->frames_++; +} + +template +static void* coroutine_test_recurse_coroutine(void* arg) +{ + CoroutineTestRecurse* r = (CoroutineTestRecurse*)arg; + coroutine_test_recurse(r, 1); + return NULL; +} + +template +static void coroutine_test_recurse_coroutines(int depth, int stk_size) +{ + CoroutineTestRecurse rs[COROUTINE_TEST_RECURSE_COROUTINES]; + st_thread_t trds[COROUTINE_TEST_RECURSE_COROUTINES]; + for (int i = 0; i < COROUTINE_TEST_RECURSE_COROUTINES; i++) { + rs[i].id_ = i + 1; + rs[i].depth_ = depth; + trds[i] = st_thread_create(coroutine_test_recurse_coroutine, &rs[i], 1, stk_size); + ASSERT_TRUE(trds[i] != NULL); + } + for (int i = 0; i < COROUTINE_TEST_RECURSE_COROUTINES; i++) { + st_thread_join(trds[i], NULL); + } + + for (int i = 0; i < COROUTINE_TEST_RECURSE_COROUTINES; i++) { + EXPECT_EQ(depth, rs[i].frames_) << "coroutine " << i; + EXPECT_EQ(0, rs[i].corrupted_) << "coroutine " << i; + } +} + +VOID TEST(CoroutineTest, DeepRecursionOnDefaultStack) +{ + // About 70 KB of the default 128 KB stack. + coroutine_test_recurse_coroutines<256>(200, 0); +} + +VOID TEST(CoroutineTest, DeepRecursionWithLargeFramesOnLargeStack) +{ + // About 640 KB of a 1 MB stack, in frames larger than a page. + coroutine_test_recurse_coroutines<5000>(128, 1024 * 1024); +} diff --git a/utest/st_utest_exception.cpp b/utest/st_utest_exception.cpp index 1b40986..e6068b3 100644 --- a/utest/st_utest_exception.cpp +++ b/utest/st_utest_exception.cpp @@ -268,3 +268,80 @@ VOID TEST(ExceptionTest, YieldInDestructorWhileUnwinding) EXPECT_EQ(300, u.call_.caught_); EXPECT_EQ(301, other.caught_); } + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// A throw from the bottom of a deep recursion unwinds frames larger than a page across most of a large stack, while +// other coroutines do the same on their own stacks, so each one runs every destructor of its own frames in order. +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +#define EXCEPTION_TEST_DEEP_DEPTH 128 +#define EXCEPTION_TEST_DEEP_FRAME 5000 + +struct ExceptionTestDeep { + int id_; + int destroyed_; + bool ordered_; + int caught_; + ExceptionTestDeep() : id_(0), destroyed_(0), ordered_(true), caught_(-1) { + } +}; + +class ExceptionTestDeepGuard { + ExceptionTestDeep* deep_; + int depth_; + volatile char buf_[EXCEPTION_TEST_DEEP_FRAME]; +public: + ExceptionTestDeepGuard(ExceptionTestDeep* deep, int depth) : deep_(deep), depth_(depth) { + buf_[0] = buf_[EXCEPTION_TEST_DEEP_FRAME - 1] = (char)(deep->id_ + depth); + } + ~ExceptionTestDeepGuard() { + // The deepest frame unwinds first, and its buffer is still the one it wrote. + if (depth_ != EXCEPTION_TEST_DEEP_DEPTH - deep_->destroyed_) deep_->ordered_ = false; + if (buf_[0] != (char)(deep_->id_ + depth_) || buf_[EXCEPTION_TEST_DEEP_FRAME - 1] != buf_[0]) { + deep_->ordered_ = false; + } + deep_->destroyed_++; + } +}; + +static void exception_test_deep_recurse(ExceptionTestDeep* deep, int depth) +{ + ExceptionTestDeepGuard guard(deep, depth); + if (depth == EXCEPTION_TEST_DEEP_DEPTH) { + st_usleep(0); + exception_test_throw(deep->id_); + } + exception_test_deep_recurse(deep, depth + 1); +} + +static void* exception_test_deep_coroutine(void* arg) +{ + ExceptionTestDeep* deep = (ExceptionTestDeep*)arg; + try { + exception_test_deep_recurse(deep, 1); + } catch (const std::runtime_error& e) { + deep->caught_ = std::stoi(e.what()); + } + return NULL; +} + +VOID TEST(ExceptionTest, ThrowFromDeepRecursionWithLargeFrames) +{ + ExceptionTestDeep deeps[3]; + st_thread_t trds[3]; + for (int i = 0; i < 3; i++) { + deeps[i].id_ = 400 + i; + // About 640 KB of a 1 MB stack. + trds[i] = st_thread_create(exception_test_deep_coroutine, &deeps[i], 1, 1024 * 1024); + ASSERT_TRUE(trds[i] != NULL); + } + for (int i = 0; i < 3; i++) { + st_thread_join(trds[i], NULL); + } + + for (int i = 0; i < 3; i++) { + EXPECT_EQ(400 + i, deeps[i].caught_) << "coroutine " << i; + EXPECT_EQ(EXCEPTION_TEST_DEEP_DEPTH, deeps[i].destroyed_) << "coroutine " << i; + EXPECT_TRUE(deeps[i].ordered_) << "coroutine " << i; + } +} From f3cd2a9a11c1c56ac817a1ca14d16555063daaa4 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 23:27:38 -0400 Subject: [PATCH 14/37] Claude: Run st_utest in the native Windows CI job, without the socket tests. The windows job now runs obj/st_utest.exe after the win64-debug-utest build, in its own step with a 10-minute timeout, and builds win64-optimized in a separate step afterwards, because the obj junction then points to the optimized build. The coroutine, sync, key, sleep, and exception tests pass on Windows; the socket I/O tests do not until the Windows event system and I/O are ported, so a gtest negative filter skips the Io*, Poll, Tcp, and epoll suites and the seven other tests that open sockets. The filter is to be removed when socket I/O is ported. A failing selected test fails the job. The utest, coverage, and final jobs are unchanged. --------- Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6bbc3f0..b4ea55e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,8 +59,8 @@ jobs: files: coverage.xml disable_search: true - # Native Windows with MSVC, built with GNU make from Git Bash. Build only: st_utest - # cannot run until the context switch and the event system are ported. + # Native Windows with MSVC, built with GNU make from Git Bash. st_utest runs without + # the socket tests until the event system and I/O are ported. windows: name: actions-test-build-win64 runs-on: windows-latest @@ -98,6 +98,32 @@ jobs: command -v make cl ml64 lib make win64-debug-utest test -f obj/st_utest.exe + + ################################################################ + # Tests + - name: Run ST utest + shell: bash + timeout-minutes: 10 + run: | + # Socket I/O is not ported to Windows yet, so skip the tests that need sockets. + # Remove this filter when the Windows event system and I/O are ported. + skip=( + 'IoAcceptTest.*' 'IoConnectTest.*' 'IoNetfdTest.*' 'IoReadTest.*' 'IoWriteTest.*' + 'IoUdpTest.*' 'IoUdpMsgTest.*' 'PollTest.*' 'PollPriTest.*' 'TcpTest.*' + 'EpollWorkflowTest.*' + 'InitTest.SecondInitKeepsWaitingCoroutines' + 'InterruptTest.StopReaderWithQueuedData' + 'TimeoutHeapTest.EarlyWakeKeepsOthersOnTime' + 'LearnKB.BasicNetfdReadTimeout' 'LearnKB.BasicNetfdWriteThenRead' + 'LearnKB.NetfdFreeKeepsOsfdOpen' 'LearnKB.NetfdSpecificAndDestructorOnClose' + ) + ./obj/st_utest.exe --gtest_filter="-$(IFS=:; echo "${skip[*]}")" + + ################################################################ + # Build + - name: Build optimized ST with MSVC + shell: bash + run: | make win64-optimized test -f obj/libst.a From bf43bea8e4a0f97b2878b26afe618a2f9e5f6ad9 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 23:37:14 -0400 Subject: [PATCH 15/37] Claude: Map Winsock errors to errno on native Windows. Winsock reports errors by WSAGetLastError(), but ST's I/O loops and callers such as SRS check errno. _st_win64_errno() maps a Winsock error to errno in one place: WSAEWOULDBLOCK is EAGAIN, or EINPROGRESS for connect, WSAETIMEDOUT is ETIMEDOUT, WSAEINTR is EINTR, and so on; an error with no equivalent is EIO. The Winsock calls io.c makes (accept, connect, getsockopt, recvfrom, sendto) go through wrappers that set errno on failure, and accept returns the SOCKET as an int. Only the WIN64 code changes. Adds WinErrnoTest utests for the mapping table, connect, unknown errors, and errors from real non-blocking sockets. --------- Co-Authored-By: Claude Opus 5.5 --- common.h | 3 + io.c | 99 +++++++++++++++++++++++++++++++ utest/st_utest_io.cpp | 133 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+) diff --git a/common.h b/common.h index 6fb2a8b..5c6f9dc 100644 --- a/common.h +++ b/common.h @@ -332,6 +332,9 @@ void _st_del_sleep_q(_st_thread_t *thread); _st_stack_t *_st_stack_new(int stack_size); void _st_stack_free(_st_stack_t *ts); int _st_io_init(void); +#if defined(WIN64) +int _st_win64_errno(int wsaerr, int connecting); +#endif st_utime_t st_utime(void); _st_cond_t *st_cond_new(void); diff --git a/io.c b/io.c index f67a376..2a4f9a1 100644 --- a/io.c +++ b/io.c @@ -66,6 +66,100 @@ */ #pragma comment(lib, "ws2_32.lib") +/* + * Map a Winsock error to errno, which ST and its callers such as SRS check. For + * connect (connecting is set), WSAEWOULDBLOCK means EINPROGRESS. An error with no + * equivalent is EIO; WSAGetLastError() still has the Winsock error. + */ +int _st_win64_errno(int wsaerr, int connecting) +{ + switch (wsaerr) { + case 0: return 0; + case WSAEWOULDBLOCK: return connecting ? EINPROGRESS : EAGAIN; + case WSAEINPROGRESS: return EINPROGRESS; + case WSAEALREADY: return EALREADY; + case WSAEINTR: return EINTR; + case WSAETIMEDOUT: return ETIMEDOUT; + case WSAEBADF: return EBADF; + case WSAEACCES: return EACCES; + case WSAEFAULT: return EFAULT; + case WSAEINVAL: return EINVAL; + case WSAEMFILE: return EMFILE; + case WSAENOTSOCK: return ENOTSOCK; + case WSAEDESTADDRREQ: return EDESTADDRREQ; + case WSAEMSGSIZE: return EMSGSIZE; + case WSAEPROTOTYPE: return EPROTOTYPE; + case WSAENOPROTOOPT: return ENOPROTOOPT; + case WSAEPROTONOSUPPORT: return EPROTONOSUPPORT; + case WSAESOCKTNOSUPPORT: return EPROTONOSUPPORT; + case WSAEOPNOTSUPP: return EOPNOTSUPP; + case WSAEPFNOSUPPORT: return EAFNOSUPPORT; + case WSAEAFNOSUPPORT: return EAFNOSUPPORT; + case WSAEADDRINUSE: return EADDRINUSE; + case WSAEADDRNOTAVAIL: return EADDRNOTAVAIL; + case WSAENETDOWN: return ENETDOWN; + case WSAENETUNREACH: return ENETUNREACH; + case WSAENETRESET: return ENETRESET; + case WSAECONNABORTED: return ECONNABORTED; + case WSAECONNRESET: return ECONNRESET; + case WSAENOBUFS: return ENOBUFS; + case WSAEISCONN: return EISCONN; + case WSAENOTCONN: return ENOTCONN; + case WSAESHUTDOWN: return EPIPE; + case WSAECONNREFUSED: return ECONNREFUSED; + case WSAELOOP: return ELOOP; + case WSAENAMETOOLONG: return ENAMETOOLONG; + case WSAEHOSTDOWN: return EHOSTUNREACH; + case WSAEHOSTUNREACH: return EHOSTUNREACH; + case WSAENOTEMPTY: return ENOTEMPTY; + case WSA_INVALID_HANDLE: return EBADF; + case WSA_NOT_ENOUGH_MEMORY: return ENOMEM; + case WSA_INVALID_PARAMETER: return EINVAL; + case WSA_OPERATION_ABORTED: return ECANCELED; + case WSANOTINITIALISED: return EINVAL; + default: return EIO; + } +} + +/* Set errno from the Winsock error of the failed call, and return -1. */ +static int _st_win64_sock_fail(int connecting) +{ + errno = _st_win64_errno(WSAGetLastError(), connecting); + return -1; +} + +/* + * The Winsock calls ST makes, with errno set on failure. A SOCKET fits in an int + * (only 32 bits are significant), so it is truncated and sign-extended back. + */ +static int _st_win64_accept(int fd, struct sockaddr *addr, socklen_t *addrlen) +{ + SOCKET s = accept((SOCKET)fd, addr, addrlen); + return (s == INVALID_SOCKET) ? _st_win64_sock_fail(0) : (int)s; +} + +static int _st_win64_connect(int fd, const struct sockaddr *addr, int addrlen) +{ + return (connect((SOCKET)fd, addr, addrlen) == SOCKET_ERROR) ? _st_win64_sock_fail(1) : 0; +} + +static int _st_win64_getsockopt(int fd, int level, int name, char *value, socklen_t *size) +{ + return (getsockopt((SOCKET)fd, level, name, value, size) == SOCKET_ERROR) ? _st_win64_sock_fail(0) : 0; +} + +static int _st_win64_recvfrom(int fd, void *buf, int len, int flags, struct sockaddr *from, socklen_t *fromlen) +{ + int n = recvfrom((SOCKET)fd, (char *)buf, len, flags, from, fromlen); + return (n == SOCKET_ERROR) ? _st_win64_sock_fail(0) : n; +} + +static int _st_win64_sendto(int fd, const void *msg, int len, int flags, const struct sockaddr *to, int tolen) +{ + int n = sendto((SOCKET)fd, (const char *)msg, len, flags, to, tolen); + return (n == SOCKET_ERROR) ? _st_win64_sock_fail(0) : n; +} + /* Only for the fcntl stub; the CRT has no such flags. */ #define F_GETFL 3 #define F_SETFL 4 @@ -140,6 +234,11 @@ static int _st_win64_sendmsg(int fd, const struct msghdr *msg, int flags) #define writev _st_win64_writev #define recvmsg _st_win64_recvmsg #define sendmsg _st_win64_sendmsg +#define accept _st_win64_accept +#define connect _st_win64_connect +#define getsockopt _st_win64_getsockopt +#define recvfrom _st_win64_recvfrom +#define sendto _st_win64_sendto #endif // Global stat. diff --git a/utest/st_utest_io.cpp b/utest/st_utest_io.cpp index ab0b89a..e74ad23 100644 --- a/utest/st_utest_io.cpp +++ b/utest/st_utest_io.cpp @@ -2199,3 +2199,136 @@ VOID TEST(IoOpenTest, RegularFileNeverWaits) EXPECT_LT(st_utime() - starttime, ST_UTEST_TIMEOUT); } #endif + +#ifdef _WIN32 // Windows only: Winsock errors +// The Winsock error of a failed socket call, mapped to errno; connecting is set for connect. +extern "C" int _st_win64_errno(int wsaerr, int connecting); + +// ST and its callers, such as SRS, check errno, but Winsock reports errors by WSAGetLastError, so ST maps them. +VOID TEST(WinErrnoTest, MapsWinsockErrors) +{ + EXPECT_EQ(0, _st_win64_errno(0, 0)); + EXPECT_EQ(EAGAIN, _st_win64_errno(WSAEWOULDBLOCK, 0)); + EXPECT_EQ(EINPROGRESS, _st_win64_errno(WSAEINPROGRESS, 0)); + EXPECT_EQ(EALREADY, _st_win64_errno(WSAEALREADY, 0)); + EXPECT_EQ(EINTR, _st_win64_errno(WSAEINTR, 0)); + EXPECT_EQ(ETIMEDOUT, _st_win64_errno(WSAETIMEDOUT, 0)); + EXPECT_EQ(EBADF, _st_win64_errno(WSAEBADF, 0)); + EXPECT_EQ(EACCES, _st_win64_errno(WSAEACCES, 0)); + EXPECT_EQ(EFAULT, _st_win64_errno(WSAEFAULT, 0)); + EXPECT_EQ(EINVAL, _st_win64_errno(WSAEINVAL, 0)); + EXPECT_EQ(EMFILE, _st_win64_errno(WSAEMFILE, 0)); + EXPECT_EQ(ENOTSOCK, _st_win64_errno(WSAENOTSOCK, 0)); + EXPECT_EQ(EDESTADDRREQ, _st_win64_errno(WSAEDESTADDRREQ, 0)); + EXPECT_EQ(EMSGSIZE, _st_win64_errno(WSAEMSGSIZE, 0)); + EXPECT_EQ(EPROTOTYPE, _st_win64_errno(WSAEPROTOTYPE, 0)); + EXPECT_EQ(ENOPROTOOPT, _st_win64_errno(WSAENOPROTOOPT, 0)); + EXPECT_EQ(EPROTONOSUPPORT, _st_win64_errno(WSAEPROTONOSUPPORT, 0)); + EXPECT_EQ(EPROTONOSUPPORT, _st_win64_errno(WSAESOCKTNOSUPPORT, 0)); + EXPECT_EQ(EOPNOTSUPP, _st_win64_errno(WSAEOPNOTSUPP, 0)); + EXPECT_EQ(EAFNOSUPPORT, _st_win64_errno(WSAEPFNOSUPPORT, 0)); + EXPECT_EQ(EAFNOSUPPORT, _st_win64_errno(WSAEAFNOSUPPORT, 0)); + EXPECT_EQ(EADDRINUSE, _st_win64_errno(WSAEADDRINUSE, 0)); + EXPECT_EQ(EADDRNOTAVAIL, _st_win64_errno(WSAEADDRNOTAVAIL, 0)); + EXPECT_EQ(ENETDOWN, _st_win64_errno(WSAENETDOWN, 0)); + EXPECT_EQ(ENETUNREACH, _st_win64_errno(WSAENETUNREACH, 0)); + EXPECT_EQ(ENETRESET, _st_win64_errno(WSAENETRESET, 0)); + EXPECT_EQ(ECONNABORTED, _st_win64_errno(WSAECONNABORTED, 0)); + EXPECT_EQ(ECONNRESET, _st_win64_errno(WSAECONNRESET, 0)); + EXPECT_EQ(ENOBUFS, _st_win64_errno(WSAENOBUFS, 0)); + EXPECT_EQ(EISCONN, _st_win64_errno(WSAEISCONN, 0)); + EXPECT_EQ(ENOTCONN, _st_win64_errno(WSAENOTCONN, 0)); + EXPECT_EQ(EPIPE, _st_win64_errno(WSAESHUTDOWN, 0)); + EXPECT_EQ(ECONNREFUSED, _st_win64_errno(WSAECONNREFUSED, 0)); + EXPECT_EQ(ELOOP, _st_win64_errno(WSAELOOP, 0)); + EXPECT_EQ(ENAMETOOLONG, _st_win64_errno(WSAENAMETOOLONG, 0)); + EXPECT_EQ(EHOSTUNREACH, _st_win64_errno(WSAEHOSTDOWN, 0)); + EXPECT_EQ(EHOSTUNREACH, _st_win64_errno(WSAEHOSTUNREACH, 0)); + EXPECT_EQ(ENOTEMPTY, _st_win64_errno(WSAENOTEMPTY, 0)); + EXPECT_EQ(EBADF, _st_win64_errno(WSA_INVALID_HANDLE, 0)); + EXPECT_EQ(ENOMEM, _st_win64_errno(WSA_NOT_ENOUGH_MEMORY, 0)); + EXPECT_EQ(EINVAL, _st_win64_errno(WSA_INVALID_PARAMETER, 0)); + EXPECT_EQ(ECANCELED, _st_win64_errno(WSA_OPERATION_ABORTED, 0)); + EXPECT_EQ(EINVAL, _st_win64_errno(WSANOTINITIALISED, 0)); +} + +// A non-blocking connect that has not finished yet fails with WSAEWOULDBLOCK, which is EINPROGRESS on POSIX, so +// st_connect waits for it. Other calls keep EAGAIN, and connect maps every other error as usual. +VOID TEST(WinErrnoTest, ConnectWouldBlockIsInProgress) +{ + EXPECT_EQ(EINPROGRESS, _st_win64_errno(WSAEWOULDBLOCK, 1)); + EXPECT_EQ(EINPROGRESS, _st_win64_errno(WSAEINPROGRESS, 1)); + EXPECT_EQ(EALREADY, _st_win64_errno(WSAEALREADY, 1)); + EXPECT_EQ(ECONNREFUSED, _st_win64_errno(WSAECONNREFUSED, 1)); + EXPECT_EQ(ETIMEDOUT, _st_win64_errno(WSAETIMEDOUT, 1)); + EXPECT_EQ(EAGAIN, _st_win64_errno(WSAEWOULDBLOCK, 0)); +} + +// An error with no errno equivalent is EIO, never 0, so the failure is not lost; WSAGetLastError still has it. +VOID TEST(WinErrnoTest, UnknownErrorIsIo) +{ + EXPECT_EQ(EIO, _st_win64_errno(WSASYSNOTREADY, 0)); + EXPECT_EQ(EIO, _st_win64_errno(WSAHOST_NOT_FOUND, 0)); + EXPECT_EQ(EIO, _st_win64_errno(-1, 0)); + EXPECT_EQ(EIO, _st_win64_errno(WSASYSNOTREADY, 1)); +} + +// The errors that real non-blocking sockets report map to what ST's I/O loops expect. +VOID TEST(WinErrnoTest, RealSocketErrors) +{ + WSADATA wsa; + ASSERT_EQ(0, WSAStartup(MAKEWORD(2, 2), &wsa)); + + SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + ASSERT_NE(INVALID_SOCKET, listener); + sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + int addrlen = sizeof(addr); + ASSERT_EQ(0, ::bind(listener, (sockaddr*)&addr, addrlen)); + ASSERT_EQ(0, ::listen(listener, 8)); + ASSERT_EQ(0, ::getsockname(listener, (sockaddr*)&addr, &addrlen)); + u_long nonblock = 1; + ASSERT_EQ(0, ioctlsocket(listener, FIONBIO, &nonblock)); + + // No client yet: accept would block. + EXPECT_EQ(INVALID_SOCKET, ::accept(listener, NULL, NULL)); + EXPECT_EQ(EAGAIN, _st_win64_errno(WSAGetLastError(), 0)); + + // Not connected: send and recv fail with ENOTCONN. + SOCKET client = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + ASSERT_NE(INVALID_SOCKET, client); + char c = 0; + EXPECT_EQ(SOCKET_ERROR, ::recv(client, &c, 1, 0)); + EXPECT_EQ(ENOTCONN, _st_win64_errno(WSAGetLastError(), 0)); + + // A non-blocking connect is in progress. + ASSERT_EQ(0, ioctlsocket(client, FIONBIO, &nonblock)); + int r = ::connect(client, (sockaddr*)&addr, addrlen); + if (r == SOCKET_ERROR) { + EXPECT_EQ(EINPROGRESS, _st_win64_errno(WSAGetLastError(), 1)); + } + + // Wait for the connection, then a recv with no data would block. + fd_set wfds; + FD_ZERO(&wfds); + FD_SET(client, &wfds); + timeval tv = {5, 0}; + ASSERT_EQ(1, ::select(0, NULL, &wfds, NULL, &tv)); + EXPECT_EQ(SOCKET_ERROR, ::recv(client, &c, 1, 0)); + EXPECT_EQ(EAGAIN, _st_win64_errno(WSAGetLastError(), 0)); + + // Connecting again: already connected. + EXPECT_EQ(SOCKET_ERROR, ::connect(client, (sockaddr*)&addr, addrlen)); + EXPECT_EQ(EISCONN, _st_win64_errno(WSAGetLastError(), 1)); + + // A closed socket is not a socket. + closesocket(client); + EXPECT_EQ(SOCKET_ERROR, ::send(client, &c, 1, 0)); + EXPECT_EQ(ENOTSOCK, _st_win64_errno(WSAGetLastError(), 0)); + + closesocket(listener); + WSACleanup(); +} +#endif From 5ebcc4bc8899aa154feb6aa89165a46942fbc636 Mon Sep 17 00:00:00 2001 From: winlin Date: Fri, 2 Oct 2026 23:43:53 -0400 Subject: [PATCH 16/37] Claude: Start Winsock in st_init and clean it up in st_destroy on native Windows. Winsock needs WSAStartup before any socket call, and ST's callers such as SRS do not call it. On WIN64, _st_io_init starts Winsock 2.2 once per OS thread's ST, and st_destroy cleans up that start through _st_io_destroy, so st_init and st_destroy are balanced. A failed start sets errno. Only the WIN64 code changes. Adds WinsockTest utests: st_init starts Winsock; real SOCKET values of TCP, UDP, and IPv6 sockets round-trip through int and still work as the socket; and, in a child process, another OS thread's st_init/st_destroy leaves Winsock running while the last st_destroy stops it. With sockets now created, the tests' CRT close() on a socket hits the CRT invalid parameter handler and kills the suite, so the socket closes that build on Windows go through st_utest_close, which is close() on POSIX. --------- Co-Authored-By: Claude Opus 5.5 --- common.h | 1 + io.c | 34 +++++++- sched.c | 4 + utest/st_utest_event.cpp | 12 +-- utest/st_utest_io.cpp | 172 +++++++++++++++++++++++++++++++++++---- utest/st_utest_udp.cpp | 4 +- 6 files changed, 204 insertions(+), 23 deletions(-) diff --git a/common.h b/common.h index 5c6f9dc..26691e8 100644 --- a/common.h +++ b/common.h @@ -334,6 +334,7 @@ void _st_stack_free(_st_stack_t *ts); int _st_io_init(void); #if defined(WIN64) int _st_win64_errno(int wsaerr, int connecting); +void _st_io_destroy(void); #endif st_utime_t st_utime(void); diff --git a/io.c b/io.c index 2a4f9a1..636cfa6 100644 --- a/io.c +++ b/io.c @@ -281,12 +281,44 @@ static void _st_netfd_free_aux_data(_st_netfd_t *fd); */ #define _ST_WIN64_OSFD_LIMIT (1 << 24) +/* Whether this thread's st_init started Winsock, which st_destroy cleans up. */ +static __thread int _st_wsa_started = 0; + int _st_io_init(void) { - int fdlim = (*_st_eventsys->fd_getlimit)(); + int fdlim; + + /* + * Winsock needs WSAStartup before any socket call. It counts the starts, so each + * thread's ST starts it once and st_destroy cleans up that start. + */ + if (!_st_wsa_started) { + WSADATA wsa; + int err = WSAStartup(MAKEWORD(2, 2), &wsa); + if (err != 0) { + errno = _st_win64_errno(err, 0); + return -1; + } + if (LOBYTE(wsa.wVersion) != 2 || HIBYTE(wsa.wVersion) != 2) { + WSACleanup(); + errno = EINVAL; + return -1; + } + _st_wsa_started = 1; + } + + fdlim = (*_st_eventsys->fd_getlimit)(); _st_osfd_limit = (fdlim > 0 && fdlim < _ST_WIN64_OSFD_LIMIT) ? fdlim : _ST_WIN64_OSFD_LIMIT; return 0; } + +void _st_io_destroy(void) +{ + if (_st_wsa_started) { + WSACleanup(); + _st_wsa_started = 0; + } +} #else int _st_io_init(void) { diff --git a/sched.c b/sched.c index 124e5c6..770ed09 100644 --- a/sched.c +++ b/sched.c @@ -237,6 +237,10 @@ int st_init(void) void st_destroy(void) { (*_st_eventsys->destroy)(); +#if defined(WIN64) + /* Clean up Winsock, which st_init started. */ + _st_io_destroy(); +#endif } diff --git a/utest/st_utest_event.cpp b/utest/st_utest_event.cpp index 325e93b..070cc8b 100644 --- a/utest/st_utest_event.cpp +++ b/utest/st_utest_event.cpp @@ -353,8 +353,8 @@ struct EventTestTcpPair { EventTestTcpPair() : server_(-1), client_(-1) { } ~EventTestTcpPair() { - if (server_ >= 0) ::close(server_); - if (client_ >= 0) ::close(client_); + if (server_ >= 0) st_utest_close(server_); + if (client_ >= 0) st_utest_close(client_); } }; @@ -372,7 +372,7 @@ static int event_test_accept_from(int lfd, int cfd) if (sfd < 0 || (peer.sin_port == local.sin_port && peer.sin_addr.s_addr == local.sin_addr.s_addr)) { return sfd; } - ::close(sfd); + st_utest_close(sfd); } } @@ -390,18 +390,18 @@ static bool event_test_tcp_pair(EventTestTcpPair& pair) socklen_t addrlen = sizeof(addr); if (::bind(lfd, (sockaddr*)&addr, sizeof(addr)) < 0 || ::listen(lfd, 1) < 0 || getsockname(lfd, (sockaddr*)&addr, &addrlen) < 0) { - ::close(lfd); + st_utest_close(lfd); return false; } pair.client_ = socket(AF_INET, SOCK_STREAM, 0); if (pair.client_ < 0 || ::connect(pair.client_, (sockaddr*)&addr, sizeof(addr)) < 0) { - ::close(lfd); + st_utest_close(lfd); return false; } pair.server_ = event_test_accept_from(lfd, pair.client_); - ::close(lfd); + st_utest_close(lfd); return pair.server_ >= 0; } diff --git a/utest/st_utest_io.cpp b/utest/st_utest_io.cpp index e74ad23..80e691f 100644 --- a/utest/st_utest_io.cpp +++ b/utest/st_utest_io.cpp @@ -80,7 +80,7 @@ static int io_accept_from(int lfd, int cfd) if (sfd < 0 || (peer.sin_port == local.sin_port && peer.sin_addr.s_addr == local.sin_addr.s_addr)) { return sfd; } - ::close(sfd); + st_utest_close(sfd); } } @@ -100,21 +100,21 @@ static bool io_tcp_pair(IoTestTcpPair& pair) socklen_t addrlen = sizeof(addr); if (::bind(lfd, (sockaddr*)&addr, sizeof(addr)) < 0 || ::listen(lfd, 1) < 0 || getsockname(lfd, (sockaddr*)&addr, &addrlen) < 0) { - ::close(lfd); + st_utest_close(lfd); return false; } int cfd = socket(AF_INET, SOCK_STREAM, 0); if (cfd < 0 || ::connect(cfd, (sockaddr*)&addr, sizeof(addr)) < 0) { - if (cfd >= 0) ::close(cfd); - ::close(lfd); + if (cfd >= 0) st_utest_close(cfd); + st_utest_close(lfd); return false; } int sfd = io_accept_from(lfd, cfd); - ::close(lfd); + st_utest_close(lfd); if (sfd < 0) { - ::close(cfd); + st_utest_close(cfd); return false; } @@ -689,12 +689,12 @@ static st_netfd_t io_tcp_listen(struct sockaddr_in& addr, int backlog) socklen_t addrlen = sizeof(addr); if (::bind(lfd, (sockaddr*)&addr, sizeof(addr)) < 0 || ::listen(lfd, backlog) < 0 || getsockname(lfd, (sockaddr*)&addr, &addrlen) < 0) { - ::close(lfd); + st_utest_close(lfd); return NULL; } st_netfd_t stfd = st_netfd_open_socket(lfd); - if (!stfd) ::close(lfd); + if (!stfd) st_utest_close(lfd); return stfd; } @@ -714,7 +714,7 @@ static void* io_connect_coroutine(void* arg) int fd = socket(AF_INET, SOCK_STREAM, 0); c->stfd_ = (fd < 0) ? NULL : st_netfd_open_socket(fd); if (!c->stfd_) { - if (fd >= 0) ::close(fd); + if (fd >= 0) st_utest_close(fd); return NULL; } @@ -923,7 +923,7 @@ static st_netfd_t io_tcp_socket(int family) int fd = socket(family, SOCK_STREAM, 0); if (fd < 0) return NULL; st_netfd_t stfd = st_netfd_open_socket(fd); - if (!stfd) ::close(fd); + if (!stfd) st_utest_close(fd); return stfd; } @@ -1850,8 +1850,8 @@ VOID TEST(IoNetfdTest, OpenClosedDescriptorFails) int closed[2]; ASSERT_EQ(0, st_utest_stream_pair(closed)); - ::close(closed[0]); - ::close(closed[1]); + st_utest_close(closed[0]); + st_utest_close(closed[1]); errno = 0; EXPECT_TRUE(st_netfd_open(closed[0]) == NULL); @@ -1866,7 +1866,7 @@ VOID TEST(IoNetfdTest, OpenClosedDescriptorFails) EXPECT_TRUE(reader == first); EXPECT_EQ(0, st_netfd_close(reader)); - ::close(fds[1]); + st_utest_close(fds[1]); } static std::vector _io_freed_specifics; @@ -1937,7 +1937,7 @@ VOID TEST(IoNetfdTest, SerializeAcceptIsNoOp) st_netfd_t client = st_accept(listener, NULL, NULL, ST_UTEST_TIMEOUT); EXPECT_TRUE(client != NULL); if (client) st_netfd_close(client); - ::close(cfd); + st_utest_close(cfd); EXPECT_EQ(0, st_netfd_close(listener)); } @@ -2332,3 +2332,147 @@ VOID TEST(WinErrnoTest, RealSocketErrors) WSACleanup(); } #endif + +#ifdef _WIN32 // Windows only: Winsock startup and SOCKET values +// Whether Winsock is started, so a socket can be created. Sets wsaerr to the error if not. +static bool winsock_test_usable(int* wsaerr = NULL) +{ + SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (wsaerr) *wsaerr = (s == INVALID_SOCKET) ? WSAGetLastError() : 0; + if (s == INVALID_SOCKET) return false; + closesocket(s); + return true; +} + +// Winsock needs WSAStartup before any socket call, and ST's callers, such as SRS, do not call it: st_init does, so the +// main of the utest, which only calls st_init, can create sockets. +VOID TEST(WinsockTest, InitStartsWinsock) +{ + int wsaerr = 0; + EXPECT_TRUE(winsock_test_usable(&wsaerr)); + EXPECT_EQ(0, wsaerr); +} + +// ST keeps descriptors as int (st_netfd_open, st_netfd_fileno), but a Windows SOCKET is a 64-bit handle. Kernel handles +// have only 32 significant bits, so a real SOCKET fits in a non-negative int and comes back unchanged, and the int +// still works as the socket, which is how ST and the tests pass it to Winsock. +VOID TEST(WinsockTest, SocketRoundTripsThroughInt) +{ + // Each socket with its type. + std::vector > sockets; + for (int i = 0; i < 128; i++) { + SOCKET tcp = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + ASSERT_NE(INVALID_SOCKET, tcp); + sockets.push_back(std::make_pair(tcp, (int)SOCK_STREAM)); + + SOCKET udp = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(INVALID_SOCKET, udp); + sockets.push_back(std::make_pair(udp, (int)SOCK_DGRAM)); + + // IPv6 may be unavailable on the host. + SOCKET tcp6 = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP); + if (tcp6 != INVALID_SOCKET) sockets.push_back(std::make_pair(tcp6, (int)SOCK_STREAM)); + } + + for (size_t i = 0; i < sockets.size(); i++) { + SOCKET s = sockets[i].first; + int fd = (int)s; + EXPECT_GE(fd, 0); + EXPECT_EQ(s, (SOCKET)fd); + + // The int is still the socket. + int type = 0; + socklen_t size = sizeof(type); + EXPECT_EQ(0, getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &size)); + EXPECT_EQ(sockets[i].second, type); + } + + for (size_t i = 0; i < sockets.size(); i++) { + EXPECT_EQ(0, st_utest_close((int)sockets[i].first)); + } +} + +#define WINSOCK_TEST_CHILD_OK 42 + +static DWORD WINAPI winsock_test_thread(LPVOID arg) +{ + int* r = (int*)arg; + // A new OS thread has its own ST, so st_init starts Winsock again, and st_destroy cleans up only that start. + r[0] = st_init(); + r[1] = winsock_test_usable(); + st_destroy(); + return 0; +} + +// Runs the utest again as a child process with only the disabled test, and returns its exit code, or -1. +static int winsock_test_run_child(const char* test) +{ + char exe[MAX_PATH]; + DWORD n = GetModuleFileNameA(NULL, exe, sizeof(exe)); + if (n == 0 || n >= sizeof(exe)) return -1; + + std::string cmd = std::string("\"") + exe + "\" --gtest_also_run_disabled_tests --gtest_filter=" + test; + std::vector line(cmd.begin(), cmd.end()); + line.push_back(0); + + fflush(stdout); + fflush(stderr); + + STARTUPINFOA si; + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + PROCESS_INFORMATION pi; + if (!CreateProcessA(NULL, &line[0], NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) return -1; + + // A hang kills the child and fails the test, instead of hanging the suite. + DWORD code = (DWORD)-1; + if (WaitForSingleObject(pi.hProcess, 10000) == WAIT_OBJECT_0) { + GetExitCodeProcess(pi.hProcess, &code); + } else { + TerminateProcess(pi.hProcess, 1); + WaitForSingleObject(pi.hProcess, INFINITE); + } + + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return (int)code; +} + +// Runs only in the child process of InitStartsAndDestroyCleansUp, because st_destroy on the main thread stops Winsock +// for the rest of the process. Exits with WINSOCK_TEST_CHILD_OK if every check passed. +VOID TEST(WinsockTest, DISABLED_ChildInitAndDestroy) +{ + // The main of the utest called st_init, which started Winsock. + EXPECT_TRUE(winsock_test_usable()); + + // Another OS thread starts and destroys its own ST. + int r[2] = {-1, -1}; + HANDLE trd = CreateThread(NULL, 0, winsock_test_thread, r, 0, NULL); + ASSERT_TRUE(trd != NULL); + EXPECT_EQ(WAIT_OBJECT_0, WaitForSingleObject(trd, 10000)); + CloseHandle(trd); + EXPECT_EQ(0, r[0]); + EXPECT_EQ(1, r[1]); + + // That st_destroy cleaned up only its own start, so Winsock still works here. + EXPECT_TRUE(winsock_test_usable()); + + // The last st_destroy cleans up the last start, so Winsock stops: st_init and st_destroy are balanced. + st_destroy(); + int wsaerr = 0; + EXPECT_FALSE(winsock_test_usable(&wsaerr)); + EXPECT_EQ(WSANOTINITIALISED, wsaerr); + + if (!::testing::Test::HasFailure()) { + fflush(stdout); + fflush(stderr); + _exit(WINSOCK_TEST_CHILD_OK); + } +} + +// st_init starts Winsock and st_destroy cleans it up, once per OS thread's ST, like SRS calling srs_st_destroy at exit. +VOID TEST(WinsockTest, InitStartsAndDestroyCleansUp) +{ + EXPECT_EQ(WINSOCK_TEST_CHILD_OK, winsock_test_run_child("WinsockTest.DISABLED_ChildInitAndDestroy")); +} +#endif diff --git a/utest/st_utest_udp.cpp b/utest/st_utest_udp.cpp index 8263b26..0ee1daf 100644 --- a/utest/st_utest_udp.cpp +++ b/utest/st_utest_udp.cpp @@ -54,12 +54,12 @@ static bool io_udp_socket(IoTestUdpSocket& s) socklen_t addrlen = sizeof(s.addr_); if (::bind(fd, (sockaddr*)&s.addr_, sizeof(s.addr_)) < 0 || getsockname(fd, (sockaddr*)&s.addr_, &addrlen) < 0) { - ::close(fd); + st_utest_close(fd); return false; } s.stfd_ = st_netfd_open_socket(fd); - if (!s.stfd_) ::close(fd); + if (!s.stfd_) st_utest_close(fd); return s.stfd_ != NULL; } From 2c6b91f3e2217103db5e0da626b45611b1e0068f Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 00:03:47 -0400 Subject: [PATCH 17/37] Claude: Implement the socket I/O calls with Winsock on native Windows. On WIN64, the POSIX calls of io.c, which were ENOSYS stubs, now use Winsock, for sockets only: ioctl(FIONBIO) is ioctlsocket; fcntl supports F_GETFL (checks for a socket) and F_SETFL with O_NONBLOCK; close is closesocket; read and write are recv and send; readv and writev are WSARecv and WSASend, with each iovec copied to a WSABUF (the field order differs); recvmsg is WSARecvMsg, loaded once with WSAIoctl(SIO_GET_EXTENSION_FUNCTION_POINTER), and sendmsg is WSASendMsg. Both fall back to WSARecv and WSASend on a TCP stream, which POSIX allows and the msg functions do not take. A truncated datagram returns the bytes that fit, with MSG_TRUNC for recvmsg, as on POSIX, where Winsock fails with WSAEMSGSIZE; recvfrom does the same. Control data is Winsock's layout. st_open, SIGPIPE, and rlimits stay unsupported. The calls are external and declared in common.h so the utest can call them. Only the WIN64 code changes. Adds WinIoTest utests with real loopback sockets for each call: non-blocking mode by ioctl and fcntl, stream read and write with end of stream, not connected errors, scatter and gather with more vectors than the stack holds, datagram sendmsg and recvmsg with the sender address, MSG_PEEK, truncated datagrams, an oversized datagram, sendmsg and recvmsg on a stream, and close. --------- Co-Authored-By: Claude Opus 5.5 --- common.h | 11 + io.c | 301 ++++++++++++++++++++++++--- utest/st_utest_io.cpp | 472 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 751 insertions(+), 33 deletions(-) diff --git a/common.h b/common.h index 26691e8..d1c6f9f 100644 --- a/common.h +++ b/common.h @@ -335,6 +335,17 @@ int _st_io_init(void); #if defined(WIN64) int _st_win64_errno(int wsaerr, int connecting); void _st_io_destroy(void); +/* The POSIX calls of io.c, with Winsock. */ +int _st_win64_ioctl(int fd, unsigned long request, int *arg); +int _st_win64_fcntl(int fd, int cmd, int arg); +int _st_win64_close(int fd); +ssize_t _st_win64_read(int fd, void *buf, size_t nbyte); +ssize_t _st_win64_write(int fd, const void *buf, size_t nbyte); +ssize_t _st_win64_readv(int fd, const struct iovec *iov, int iov_size); +ssize_t _st_win64_writev(int fd, const struct iovec *iov, int iov_size); +int _st_win64_recvfrom(int fd, void *buf, int len, int flags, struct sockaddr *from, socklen_t *fromlen); +int _st_win64_recvmsg(int fd, struct msghdr *msg, int flags); +int _st_win64_sendmsg(int fd, const struct msghdr *msg, int flags); #endif st_utime_t st_utime(void); diff --git a/io.c b/io.c index 636cfa6..12ecf29 100644 --- a/io.c +++ b/io.c @@ -60,10 +60,11 @@ #if defined(WIN64) /* - * Native Windows stubs for the POSIX-only calls, so ST links. They fail with - * ENOSYS until the Windows I/O is ported. Winsock provides accept, connect, - * getsockopt, recvfrom, and sendto, so the library brings ws2_32.lib. + * Native Windows implements the POSIX calls of this file with Winsock, for sockets + * only, so the library brings ws2_32.lib. st_open (files and FIFOs) is not supported. */ +#include +#include #pragma comment(lib, "ws2_32.lib") /* @@ -148,10 +149,15 @@ static int _st_win64_getsockopt(int fd, int level, int name, char *value, sockle return (getsockopt((SOCKET)fd, level, name, value, size) == SOCKET_ERROR) ? _st_win64_sock_fail(0) : 0; } -static int _st_win64_recvfrom(int fd, void *buf, int len, int flags, struct sockaddr *from, socklen_t *fromlen) +int _st_win64_recvfrom(int fd, void *buf, int len, int flags, struct sockaddr *from, socklen_t *fromlen) { int n = recvfrom((SOCKET)fd, (char *)buf, len, flags, from, fromlen); - return (n == SOCKET_ERROR) ? _st_win64_sock_fail(0) : n; + if (n != SOCKET_ERROR) + return n; + /* A datagram larger than the buffer: POSIX returns the bytes that fit and drops the rest. */ + if (WSAGetLastError() == WSAEMSGSIZE) + return len; + return _st_win64_sock_fail(0); } static int _st_win64_sendto(int fd, const void *msg, int len, int flags, const struct sockaddr *to, int tolen) @@ -160,7 +166,7 @@ static int _st_win64_sendto(int fd, const void *msg, int len, int flags, const s return (n == SOCKET_ERROR) ? _st_win64_sock_fail(0) : n; } -/* Only for the fcntl stub; the CRT has no such flags. */ +/* The fcntl commands and flag ST uses; the CRT has none for sockets. */ #define F_GETFL 3 #define F_SETFL 4 #define O_NONBLOCK 04000 @@ -171,58 +177,287 @@ static int _st_win64_enosys(void) return -1; } -static int _st_win64_ioctl(int fd, unsigned long request, int *arg) +/* ioctl is ioctlsocket, for FIONBIO. */ +int _st_win64_ioctl(int fd, unsigned long request, int *arg) +{ + u_long v = (u_long)*arg; + if (ioctlsocket((SOCKET)fd, (long)request, &v) == SOCKET_ERROR) + return _st_win64_sock_fail(0); + *arg = (int)v; + return 0; +} + +/* + * fcntl supports only sockets and only the non-blocking flag. Windows cannot read + * the mode of a socket back, so F_GETFL checks that fd is a socket and gives 0. + */ +int _st_win64_fcntl(int fd, int cmd, int arg) +{ + u_long nonblock; + int type; + socklen_t size = sizeof(type); + + switch (cmd) { + case F_GETFL: + if (getsockopt((SOCKET)fd, SOL_SOCKET, SO_TYPE, (char *)&type, &size) == SOCKET_ERROR) + return _st_win64_sock_fail(0); + return 0; + case F_SETFL: + nonblock = (arg & O_NONBLOCK) ? 1 : 0; + if (ioctlsocket((SOCKET)fd, FIONBIO, &nonblock) == SOCKET_ERROR) + return _st_win64_sock_fail(0); + return 0; + default: + errno = EINVAL; + return -1; + } +} + +int _st_win64_close(int fd) { - (void) fd; (void) request; (void) arg; - return _st_win64_enosys(); + return (closesocket((SOCKET)fd) == SOCKET_ERROR) ? _st_win64_sock_fail(0) : 0; } -static int _st_win64_fcntl(int fd, int cmd, int arg) +/* Winsock takes an int length; a larger request reads or writes less, as POSIX allows. */ +static int _st_win64_len(size_t nbyte) { - (void) fd; (void) cmd; (void) arg; - return _st_win64_enosys(); + return (nbyte > INT_MAX) ? INT_MAX : (int)nbyte; } -static int _st_win64_close(int fd) +ssize_t _st_win64_read(int fd, void *buf, size_t nbyte) { - (void) fd; - return _st_win64_enosys(); + int len = _st_win64_len(nbyte); + int n = recv((SOCKET)fd, (char *)buf, len, 0); + if (n != SOCKET_ERROR) + return n; + /* A datagram larger than the buffer: POSIX returns the bytes that fit and drops the rest. */ + if (WSAGetLastError() == WSAEMSGSIZE) + return len; + return _st_win64_sock_fail(0); } -static ssize_t _st_win64_read(int fd, void *buf, size_t nbyte) +ssize_t _st_win64_write(int fd, const void *buf, size_t nbyte) { - (void) fd; (void) buf; (void) nbyte; - return _st_win64_enosys(); + int n = send((SOCKET)fd, (const char *)buf, _st_win64_len(nbyte), 0); + return (n == SOCKET_ERROR) ? _st_win64_sock_fail(0) : n; } -static ssize_t _st_win64_write(int fd, const void *buf, size_t nbyte) +/* The WSABUFs kept on the stack; more are allocated. */ +#define _ST_WIN64_LOCAL_BUFS 16 + +/* + * Copy the iovecs to WSABUFs, which are not the same layout (length first, and a + * 32-bit length), so never cast one to the other. Uses local when it is large + * enough, or allocates the array, which the caller frees with _st_win64_bufs_free. + * A vector longer than a WSABUF holds is cut, with the vectors after it, so the + * call sends or receives less, as POSIX allows. Returns NULL with errno on error. + */ +static WSABUF *_st_win64_bufs(const struct iovec *iov, int iov_size, WSABUF *local, DWORD *count, size_t *total) { - (void) fd; (void) buf; (void) nbyte; - return _st_win64_enosys(); + WSABUF *bufs = local; + int i; + + if (iov_size < 0 || (iov_size > 0 && !iov)) { + errno = EINVAL; + return NULL; + } + if (iov_size > _ST_WIN64_LOCAL_BUFS) { + if ((bufs = (WSABUF *)malloc(iov_size * sizeof(WSABUF))) == NULL) { + errno = ENOMEM; + return NULL; + } + } + + *total = 0; + for (i = 0; i < iov_size; i++) { + int cut = iov[i].iov_len > ULONG_MAX; + bufs[i].len = cut ? ULONG_MAX : (ULONG)iov[i].iov_len; + bufs[i].buf = (char *)iov[i].iov_base; + *total += bufs[i].len; + if (cut) { + i++; + break; + } + } + *count = (DWORD)i; + return bufs; } -static ssize_t _st_win64_readv(int fd, const struct iovec *iov, int iov_size) +static void _st_win64_bufs_free(WSABUF *bufs, WSABUF *local) { - (void) fd; (void) iov; (void) iov_size; - return _st_win64_enosys(); + if (bufs != local) + free(bufs); } -static ssize_t _st_win64_writev(int fd, const struct iovec *iov, int iov_size) +/* The result of a receive that failed: a truncated datagram is the bytes that fit. */ +static ssize_t _st_win64_recv_fail(size_t total) { - (void) fd; (void) iov; (void) iov_size; - return _st_win64_enosys(); + if (WSAGetLastError() == WSAEMSGSIZE) + return (ssize_t)total; + return _st_win64_sock_fail(0); } -static int _st_win64_recvmsg(int fd, struct msghdr *msg, int flags) +ssize_t _st_win64_readv(int fd, const struct iovec *iov, int iov_size) { - (void) fd; (void) msg; (void) flags; - return _st_win64_enosys(); + WSABUF local[_ST_WIN64_LOCAL_BUFS], *bufs; + DWORD count, nbytes = 0, flags = 0; + size_t total; + ssize_t rv; + + if (iov_size == 0) + return 0; + if ((bufs = _st_win64_bufs(iov, iov_size, local, &count, &total)) == NULL) + return -1; + if (WSARecv((SOCKET)fd, bufs, count, &nbytes, &flags, NULL, NULL) == 0) + rv = (ssize_t)nbytes; + else + rv = _st_win64_recv_fail(total); + _st_win64_bufs_free(bufs, local); + return rv; } -static int _st_win64_sendmsg(int fd, const struct msghdr *msg, int flags) +ssize_t _st_win64_writev(int fd, const struct iovec *iov, int iov_size) { - (void) fd; (void) msg; (void) flags; - return _st_win64_enosys(); + WSABUF local[_ST_WIN64_LOCAL_BUFS], *bufs; + DWORD count, nbytes = 0; + size_t total; + ssize_t rv; + + if (iov_size == 0) + return 0; + if ((bufs = _st_win64_bufs(iov, iov_size, local, &count, &total)) == NULL) + return -1; + if (WSASend((SOCKET)fd, bufs, count, &nbytes, 0, NULL, NULL) == 0) + rv = (ssize_t)nbytes; + else + rv = _st_win64_sock_fail(0); + _st_win64_bufs_free(bufs, local); + return rv; +} + +/* + * WSARecvMsg is a Winsock extension, loaded once with WSAIoctl. The Microsoft + * providers all return the same function, so one pointer serves every socket. + */ +static LPFN_WSARECVMSG _st_win64_wsarecvmsg = NULL; + +static LPFN_WSARECVMSG _st_win64_load_wsarecvmsg(int fd) +{ + GUID guid = WSAID_WSARECVMSG; + LPFN_WSARECVMSG fn = NULL; + DWORD n = 0; + + if (_st_win64_wsarecvmsg) + return _st_win64_wsarecvmsg; + if (WSAIoctl((SOCKET)fd, SIO_GET_EXTENSION_FUNCTION_POINTER, &guid, sizeof(guid), &fn, sizeof(fn), &n, + NULL, NULL) == SOCKET_ERROR) { + _st_win64_sock_fail(0); + return NULL; + } + _st_win64_wsarecvmsg = fn; + return fn; +} + +/* + * WSARecvMsg and WSASendMsg take only datagram and raw sockets, while POSIX recvmsg + * and sendmsg take streams too. After such a failure, this tells whether fd is a + * stream, which then uses WSARecv or WSASend. + */ +static int _st_win64_is_stream(int fd, int wsaerr) +{ + int type = 0; + socklen_t size = sizeof(type); + + if (wsaerr != WSAEINVAL && wsaerr != WSAEOPNOTSUPP) + return 0; + if (getsockopt((SOCKET)fd, SOL_SOCKET, SO_TYPE, (char *)&type, &size) == SOCKET_ERROR) + return 0; + WSASetLastError(wsaerr); + return type == SOCK_STREAM; +} + +/* + * recvmsg with WSARecvMsg. The control data is Winsock's (WSA_CMSG_* macros, for + * example IP_PKTINFO), not the POSIX layout. A truncated datagram returns the bytes + * that fit with MSG_TRUNC, as on POSIX, where Winsock fails with WSAEMSGSIZE. + */ +int _st_win64_recvmsg(int fd, struct msghdr *msg, int flags) +{ + WSABUF local[_ST_WIN64_LOCAL_BUFS], *bufs; + LPFN_WSARECVMSG fn; + WSAMSG wmsg; + DWORD count, nbytes = 0, dflags; + size_t total; + int rv, err; + + if ((fn = _st_win64_load_wsarecvmsg(fd)) == NULL) + return -1; + if ((bufs = _st_win64_bufs(msg->msg_iov, msg->msg_iovlen, local, &count, &total)) == NULL) + return -1; + + wmsg.name = (LPSOCKADDR)msg->msg_name; + wmsg.namelen = msg->msg_name ? msg->msg_namelen : 0; + wmsg.lpBuffers = bufs; + wmsg.dwBufferCount = count; + wmsg.Control.buf = (char *)msg->msg_control; + wmsg.Control.len = msg->msg_control ? (ULONG)msg->msg_controllen : 0; + wmsg.dwFlags = (DWORD)flags; + + if ((*fn)((SOCKET)fd, &wmsg, &nbytes, NULL, NULL) == 0) { + rv = (int)nbytes; + } else if ((err = WSAGetLastError()) == WSAEMSGSIZE) { + rv = (int)total; + wmsg.dwFlags |= MSG_TRUNC; + } else if (_st_win64_is_stream(fd, err)) { + /* A stream has no sender address and no control data. */ + dflags = (DWORD)flags; + rv = (WSARecv((SOCKET)fd, bufs, count, &nbytes, &dflags, NULL, NULL) == 0) ? + (int)nbytes : _st_win64_sock_fail(0); + wmsg.namelen = 0; + wmsg.Control.len = 0; + wmsg.dwFlags = 0; + } else { + rv = _st_win64_sock_fail(0); + } + + if (rv >= 0) { + msg->msg_namelen = wmsg.namelen; + msg->msg_controllen = wmsg.Control.len; + msg->msg_flags = (int)wmsg.dwFlags; + } + _st_win64_bufs_free(bufs, local); + return rv; +} + +/* sendmsg with WSASendMsg; the control data is Winsock's, as for recvmsg. */ +int _st_win64_sendmsg(int fd, const struct msghdr *msg, int flags) +{ + WSABUF local[_ST_WIN64_LOCAL_BUFS], *bufs; + WSAMSG wmsg; + DWORD count, nbytes = 0; + size_t total; + int rv; + + if ((bufs = _st_win64_bufs(msg->msg_iov, msg->msg_iovlen, local, &count, &total)) == NULL) + return -1; + + wmsg.name = (LPSOCKADDR)msg->msg_name; + wmsg.namelen = msg->msg_name ? msg->msg_namelen : 0; + wmsg.lpBuffers = bufs; + wmsg.dwBufferCount = count; + wmsg.Control.buf = (char *)msg->msg_control; + wmsg.Control.len = msg->msg_control ? (ULONG)msg->msg_controllen : 0; + wmsg.dwFlags = 0; + + if (WSASendMsg((SOCKET)fd, &wmsg, (DWORD)flags, &nbytes, NULL, NULL) == 0) + rv = (int)nbytes; + else if (_st_win64_is_stream(fd, WSAGetLastError())) + rv = (WSASend((SOCKET)fd, bufs, count, &nbytes, (DWORD)flags, NULL, NULL) == 0) ? + (int)nbytes : _st_win64_sock_fail(0); + else + rv = _st_win64_sock_fail(0); + _st_win64_bufs_free(bufs, local); + return rv; } #define ioctl _st_win64_ioctl diff --git a/utest/st_utest_io.cpp b/utest/st_utest_io.cpp index 80e691f..8b779c4 100644 --- a/utest/st_utest_io.cpp +++ b/utest/st_utest_io.cpp @@ -2476,3 +2476,475 @@ VOID TEST(WinsockTest, InitStartsAndDestroyCleansUp) EXPECT_EQ(WINSOCK_TEST_CHILD_OK, winsock_test_run_child("WinsockTest.DISABLED_ChildInitAndDestroy")); } #endif + +#ifdef _WIN32 // Windows only: Winsock under the POSIX calls of io.c +// The POSIX calls io.c makes, which Windows implements with Winsock. The st_* I/O functions call them, and wait in +// st_netfd_poll while they fail with EAGAIN. +extern "C" int _st_win64_ioctl(int fd, unsigned long request, int *arg); +extern "C" int _st_win64_fcntl(int fd, int cmd, int arg); +extern "C" int _st_win64_close(int fd); +extern "C" ssize_t _st_win64_read(int fd, void *buf, size_t nbyte); +extern "C" ssize_t _st_win64_write(int fd, const void *buf, size_t nbyte); +extern "C" ssize_t _st_win64_readv(int fd, const struct iovec *iov, int iov_size); +extern "C" ssize_t _st_win64_writev(int fd, const struct iovec *iov, int iov_size); +extern "C" int _st_win64_recvmsg(int fd, struct msghdr *msg, int flags); +extern "C" int _st_win64_sendmsg(int fd, const struct msghdr *msg, int flags); +extern "C" int _st_win64_recvfrom(int fd, void *buf, int len, int flags, struct sockaddr *from, socklen_t *fromlen); + +// The fcntl commands and flag that io.c defines on Windows, which has no fcntl for sockets. +#define WIN_IO_F_GETFL 3 +#define WIN_IO_F_SETFL 4 +#define WIN_IO_O_NONBLOCK 04000 + +// A connected loopback TCP pair of blocking sockets, as int descriptors. +static bool win_io_tcp_pair(int fds[2]) +{ + fds[0] = fds[1] = -1; + SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listener == INVALID_SOCKET) return false; + + sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + int addrlen = sizeof(addr); + SOCKET client = INVALID_SOCKET, server = INVALID_SOCKET; + if (::bind(listener, (sockaddr*)&addr, addrlen) == 0 && ::listen(listener, 1) == 0 && + ::getsockname(listener, (sockaddr*)&addr, &addrlen) == 0) { + client = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (client != INVALID_SOCKET && ::connect(client, (sockaddr*)&addr, addrlen) == 0) { + server = ::accept(listener, NULL, NULL); + } + } + closesocket(listener); + + if (server == INVALID_SOCKET) { + if (client != INVALID_SOCKET) closesocket(client); + return false; + } + fds[0] = (int)client; + fds[1] = (int)server; + return true; +} + +// A UDP socket bound to a loopback port, with its address. +static bool win_io_udp_socket(int& fd, sockaddr_in& addr) +{ + SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s == INVALID_SOCKET) return false; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + int addrlen = sizeof(addr); + if (::bind(s, (sockaddr*)&addr, addrlen) != 0 || ::getsockname(s, (sockaddr*)&addr, &addrlen) != 0) { + closesocket(s); + return false; + } + fd = (int)s; + return true; +} + +// Waits until the socket has data to read. +static bool win_io_wait_readable(int fd) +{ + fd_set rfds; + FD_ZERO(&rfds); + FD_SET((SOCKET)fd, &rfds); + timeval tv = {5, 0}; + return ::select(0, &rfds, NULL, NULL, &tv) == 1; +} + +// Reads from a stream until it has n bytes, with readv into the vectors, appending what each read returns. +static std::string win_io_readv_all(int fd, struct iovec* iov, int iov_size, size_t n) +{ + std::string got; + while (got.size() < n) { + ssize_t r = _st_win64_readv(fd, iov, iov_size); + if (r <= 0) break; + for (int i = 0; i < iov_size && r > 0; i++) { + size_t take = std::min((size_t)r, iov[i].iov_len); + got.append((char*)iov[i].iov_base, take); + r -= (ssize_t)take; + } + } + return got; +} + +// st_netfd_open_socket makes a socket non-blocking with ioctl(FIONBIO), which is ioctlsocket on Windows: a read with +// nothing queued then fails with EAGAIN at once, so st_read waits in the event system instead of blocking the thread. +VOID TEST(WinIoTest, IoctlSetsNonBlocking) +{ + int fds[2]; + ASSERT_TRUE(win_io_tcp_pair(fds)); + + int on = 1; + EXPECT_EQ(0, _st_win64_ioctl(fds[1], FIONBIO, &on)); + char buf[8]; + errno = 0; + EXPECT_EQ(-1, _st_win64_read(fds[1], buf, sizeof(buf))); + EXPECT_EQ(EAGAIN, errno); + + // Data queued by the peer is read at once. + EXPECT_EQ(2, _st_win64_write(fds[0], "hi", 2)); + ASSERT_TRUE(win_io_wait_readable(fds[1])); + EXPECT_EQ(2, _st_win64_read(fds[1], buf, sizeof(buf))); + EXPECT_EQ("hi", std::string(buf, 2)); + + EXPECT_EQ(0, _st_win64_close(fds[0])); + EXPECT_EQ(0, _st_win64_close(fds[1])); + + // A closed socket is not a socket. + errno = 0; + EXPECT_EQ(-1, _st_win64_ioctl(fds[1], FIONBIO, &on)); + EXPECT_EQ(ENOTSOCK, errno); +} + +// st_netfd_open makes a descriptor non-blocking the POSIX way, fcntl(F_GETFL) then fcntl(F_SETFL, O_NONBLOCK). On +// Windows, where only sockets are supported, F_SETFL sets the socket's non-blocking mode. +VOID TEST(WinIoTest, FcntlSetsNonBlocking) +{ + int fds[2]; + ASSERT_TRUE(win_io_tcp_pair(fds)); + + int flags = _st_win64_fcntl(fds[1], WIN_IO_F_GETFL, 0); + EXPECT_GE(flags, 0); + EXPECT_EQ(0, _st_win64_fcntl(fds[1], WIN_IO_F_SETFL, flags | WIN_IO_O_NONBLOCK)); + char buf[8]; + errno = 0; + EXPECT_EQ(-1, _st_win64_read(fds[1], buf, sizeof(buf))); + EXPECT_EQ(EAGAIN, errno); + + // Another command is not supported. + errno = 0; + EXPECT_EQ(-1, _st_win64_fcntl(fds[1], 1, 0)); + EXPECT_EQ(EINVAL, errno); + + EXPECT_EQ(0, _st_win64_close(fds[0])); + EXPECT_EQ(0, _st_win64_close(fds[1])); + + // Only sockets: a closed one fails. + errno = 0; + EXPECT_EQ(-1, _st_win64_fcntl(fds[1], WIN_IO_F_SETFL, WIN_IO_O_NONBLOCK)); + EXPECT_EQ(ENOTSOCK, errno); +} + +// read and write are recv and send on a socket: the bytes arrive in order, and a read after the peer closes returns 0, +// the end of stream that st_read reports. +VOID TEST(WinIoTest, ReadWriteStream) +{ + int fds[2]; + ASSERT_TRUE(win_io_tcp_pair(fds)); + + EXPECT_EQ(5, _st_win64_write(fds[0], "hello", 5)); + EXPECT_EQ(6, _st_win64_write(fds[0], " world", 6)); + + std::string got; + char buf[64]; + while (got.size() < 11) { + ssize_t n = _st_win64_read(fds[1], buf, sizeof(buf)); + ASSERT_GT(n, 0); + got.append(buf, (size_t)n); + } + EXPECT_EQ("hello world", got); + + // An empty write sends nothing and succeeds. + EXPECT_EQ(0, _st_win64_write(fds[0], "", 0)); + + EXPECT_EQ(0, _st_win64_close(fds[0])); + EXPECT_EQ(0, _st_win64_read(fds[1], buf, sizeof(buf))); + EXPECT_EQ(0, _st_win64_close(fds[1])); +} + +// A socket that is not connected fails with ENOTCONN, which st_read reports instead of waiting. +VOID TEST(WinIoTest, ReadWriteNotConnected) +{ + SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + ASSERT_NE(INVALID_SOCKET, s); + int fd = (int)s; + char buf[8]; + errno = 0; + EXPECT_EQ(-1, _st_win64_read(fd, buf, sizeof(buf))); + EXPECT_EQ(ENOTCONN, errno); + errno = 0; + EXPECT_EQ(-1, _st_win64_write(fd, "x", 1)); + EXPECT_EQ(ENOTCONN, errno); + EXPECT_EQ(0, _st_win64_close(fd)); +} + +// writev gathers and readv scatters, like SRS sending an RTMP header and payload in one call. iovec is base then +// length while WSABUF is length then base, so io.c copies each one; a cast would put the data in the wrong place. +VOID TEST(WinIoTest, ReadvWritevStream) +{ + int fds[2]; + ASSERT_TRUE(win_io_tcp_pair(fds)); + + char h[] = "head", p[] = "payload", t[] = "!"; + struct iovec out[3] = {{h, 4}, {p, 7}, {t, 1}}; + EXPECT_EQ(12, _st_win64_writev(fds[0], out, 3)); + + // The first buffer holds three bytes, the second the rest. + char a[3], b[32]; + struct iovec in[2] = {{a, sizeof(a)}, {b, sizeof(b)}}; + ASSERT_TRUE(win_io_wait_readable(fds[1])); + EXPECT_EQ("headpayload!", win_io_readv_all(fds[1], in, 2, 12)); + + // No vectors: nothing to do, and no error. + EXPECT_EQ(0, _st_win64_readv(fds[1], in, 0)); + EXPECT_EQ(0, _st_win64_writev(fds[0], out, 0)); + + EXPECT_EQ(0, _st_win64_close(fds[0])); + EXPECT_EQ(0, _st_win64_close(fds[1])); +} + +// More vectors than io.c keeps on its stack, as when SRS sends many small frames in one writev: all are sent, in order. +VOID TEST(WinIoTest, ReadvWritevManyVectors) +{ + int fds[2]; + ASSERT_TRUE(win_io_tcp_pair(fds)); + + const int count = 100; + std::vector parts; + std::string want; + for (int i = 0; i < count; i++) { + parts.push_back(std::string((size_t)(i % 7 + 1), (char)('a' + i % 26))); + want += parts.back(); + } + std::vector out(count); + for (int i = 0; i < count; i++) { + out[i].iov_base = (void*)parts[i].data(); + out[i].iov_len = parts[i].size(); + } + EXPECT_EQ((ssize_t)want.size(), _st_win64_writev(fds[0], &out[0], count)); + + // Read back into one-byte vectors, also more than the stack holds. + std::vector bytes(want.size()); + std::vector in(want.size()); + for (size_t i = 0; i < want.size(); i++) { + in[i].iov_base = &bytes[i]; + in[i].iov_len = 1; + } + size_t done = 0; + while (done < want.size()) { + ssize_t n = _st_win64_readv(fds[1], &in[done], (int)(want.size() - done)); + ASSERT_GT(n, 0); + done += (size_t)n; + } + EXPECT_EQ(want, std::string(bytes.begin(), bytes.end())); + + EXPECT_EQ(0, _st_win64_close(fds[0])); + EXPECT_EQ(0, _st_win64_close(fds[1])); +} + +// sendmsg gathers a header and a payload into one datagram to msg_name, as SRS sends RTP; recvmsg scatters it and +// reports the sender in msg_name, like recvfrom. +VOID TEST(WinIoTest, SendmsgRecvmsgDatagram) +{ + int server, player; + sockaddr_in server_addr, player_addr; + ASSERT_TRUE(win_io_udp_socket(server, server_addr)); + ASSERT_TRUE(win_io_udp_socket(player, player_addr)); + + char h[12], p[100]; + memset(h, 'h', sizeof(h)); + memset(p, 'p', sizeof(p)); + struct iovec out[2] = {{h, sizeof(h)}, {p, sizeof(p)}}; + struct msghdr sent; + memset(&sent, 0, sizeof(sent)); + sent.msg_name = &server_addr; + sent.msg_namelen = sizeof(server_addr); + sent.msg_iov = out; + sent.msg_iovlen = 2; + EXPECT_EQ(112, _st_win64_sendmsg(player, &sent, 0)); + + char rh[12], rp[200]; + struct iovec in[2] = {{rh, sizeof(rh)}, {rp, sizeof(rp)}}; + sockaddr_in from; + memset(&from, 0, sizeof(from)); + struct msghdr received; + memset(&received, 0, sizeof(received)); + received.msg_name = &from; + received.msg_namelen = sizeof(from); + received.msg_iov = in; + received.msg_iovlen = 2; + ASSERT_TRUE(win_io_wait_readable(server)); + EXPECT_EQ(112, _st_win64_recvmsg(server, &received, 0)); + EXPECT_EQ(std::string(12, 'h'), std::string(rh, 12)); + EXPECT_EQ(std::string(100, 'p'), std::string(rp, 100)); + EXPECT_EQ((socklen_t)sizeof(from), received.msg_namelen); + EXPECT_EQ(player_addr.sin_port, from.sin_port); + EXPECT_EQ(htonl(INADDR_LOOPBACK), from.sin_addr.s_addr); + EXPECT_EQ(0, received.msg_flags & MSG_TRUNC); + + EXPECT_EQ(0, _st_win64_close(server)); + EXPECT_EQ(0, _st_win64_close(player)); +} + +// recvmsg passes its flags: MSG_PEEK leaves the datagram queued for the next read. +VOID TEST(WinIoTest, RecvmsgPeekKeepsDatagram) +{ + int server, player; + sockaddr_in server_addr, player_addr; + ASSERT_TRUE(win_io_udp_socket(server, server_addr)); + ASSERT_TRUE(win_io_udp_socket(player, player_addr)); + EXPECT_EQ(5, ::sendto((SOCKET)player, "hello", 5, 0, (sockaddr*)&server_addr, sizeof(server_addr))); + + char first = 0; + struct iovec iov = {&first, 1}; + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + ASSERT_TRUE(win_io_wait_readable(server)); + EXPECT_EQ(1, _st_win64_recvmsg(server, &msg, MSG_PEEK)); + EXPECT_EQ('h', first); + + // Non-blocking, so a peek that dropped the datagram fails here instead of hanging. + int on = 1; + EXPECT_EQ(0, _st_win64_ioctl(server, FIONBIO, &on)); + char buf[64] = {0}; + iov.iov_base = buf; + iov.iov_len = sizeof(buf); + memset(&msg, 0, sizeof(msg)); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + EXPECT_EQ(5, _st_win64_recvmsg(server, &msg, 0)); + EXPECT_STREQ("hello", buf); + + EXPECT_EQ(0, _st_win64_close(server)); + EXPECT_EQ(0, _st_win64_close(player)); +} + +// A datagram larger than the buffers is truncated as on POSIX, where Winsock fails with WSAEMSGSIZE: recvmsg returns +// the bytes that fit and sets MSG_TRUNC, and recvfrom and read return the bytes that fit. The rest is dropped, and the +// next read gets the next datagram. +VOID TEST(WinIoTest, TruncatedDatagramReturnsWhatFits) +{ + int server, player; + sockaddr_in server_addr, player_addr; + ASSERT_TRUE(win_io_udp_socket(server, server_addr)); + ASSERT_TRUE(win_io_udp_socket(player, player_addr)); + + std::string large(1500, 'x'); + for (int i = 0; i < 4; i++) { + EXPECT_EQ(1500, ::sendto((SOCKET)player, large.data(), 1500, 0, (sockaddr*)&server_addr, sizeof(server_addr))); + } + EXPECT_EQ(4, ::sendto((SOCKET)player, "next", 4, 0, (sockaddr*)&server_addr, sizeof(server_addr))); + ASSERT_TRUE(win_io_wait_readable(server)); + + char buf[100]; + struct iovec iov = {buf, sizeof(buf)}; + sockaddr_in from; + memset(&from, 0, sizeof(from)); + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + msg.msg_name = &from; + msg.msg_namelen = sizeof(from); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + EXPECT_EQ(100, _st_win64_recvmsg(server, &msg, 0)); + EXPECT_TRUE((msg.msg_flags & MSG_TRUNC) != 0); + EXPECT_EQ(std::string(100, 'x'), std::string(buf, 100)); + EXPECT_EQ(player_addr.sin_port, from.sin_port); + + memset(&from, 0, sizeof(from)); + socklen_t fromlen = sizeof(from); + EXPECT_EQ(100, _st_win64_recvfrom(server, buf, sizeof(buf), 0, (sockaddr*)&from, &fromlen)); + EXPECT_EQ(player_addr.sin_port, from.sin_port); + + EXPECT_EQ(100, _st_win64_read(server, buf, sizeof(buf))); + + char a[10], b[20]; + struct iovec two[2] = {{a, sizeof(a)}, {b, sizeof(b)}}; + EXPECT_EQ(30, _st_win64_readv(server, two, 2)); + + memset(buf, 0, sizeof(buf)); + EXPECT_EQ(4, _st_win64_read(server, buf, sizeof(buf))); + EXPECT_STREQ("next", buf); + + EXPECT_EQ(0, _st_win64_close(server)); + EXPECT_EQ(0, _st_win64_close(player)); +} + +// A datagram larger than UDP allows fails at once with EMSGSIZE, which st_sendmsg reports instead of waiting. +VOID TEST(WinIoTest, SendmsgOversizedDatagramFails) +{ + int server, player; + sockaddr_in server_addr, player_addr; + ASSERT_TRUE(win_io_udp_socket(server, server_addr)); + ASSERT_TRUE(win_io_udp_socket(player, player_addr)); + + std::string half(32768, 'x'); + struct iovec iov[2] = {{(void*)half.data(), half.size()}, {(void*)half.data(), half.size()}}; + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + msg.msg_name = &server_addr; + msg.msg_namelen = sizeof(server_addr); + msg.msg_iov = iov; + msg.msg_iovlen = 2; + errno = 0; + EXPECT_EQ(-1, _st_win64_sendmsg(player, &msg, 0)); + EXPECT_EQ(EMSGSIZE, errno); + + EXPECT_EQ(0, _st_win64_close(server)); + EXPECT_EQ(0, _st_win64_close(player)); +} + +// On POSIX, sendmsg and recvmsg work on a TCP stream too, and st_sendmsg is used that way, while Winsock's WSASendMsg +// and WSARecvMsg may take only datagrams, so a stream works the same through io.c. +VOID TEST(WinIoTest, SendmsgRecvmsgStream) +{ + int fds[2]; + ASSERT_TRUE(win_io_tcp_pair(fds)); + + char h[] = "head", p[] = "payload"; + struct iovec out[2] = {{h, 4}, {p, 7}}; + struct msghdr sent; + memset(&sent, 0, sizeof(sent)); + sent.msg_iov = out; + sent.msg_iovlen = 2; + EXPECT_EQ(11, _st_win64_sendmsg(fds[0], &sent, 0)); + + char buf[64]; + std::string got; + while (got.size() < 11) { + struct iovec in = {buf, sizeof(buf)}; + struct msghdr received; + memset(&received, 0, sizeof(received)); + received.msg_iov = ∈ + received.msg_iovlen = 1; + int n = _st_win64_recvmsg(fds[1], &received, 0); + ASSERT_GT(n, 0); + got.append(buf, (size_t)n); + EXPECT_EQ(0, received.msg_flags); + } + EXPECT_EQ("headpayload", got); + + // The end of stream is 0. + EXPECT_EQ(0, _st_win64_close(fds[0])); + struct iovec in = {buf, sizeof(buf)}; + struct msghdr received; + memset(&received, 0, sizeof(received)); + received.msg_iov = ∈ + received.msg_iovlen = 1; + EXPECT_EQ(0, _st_win64_recvmsg(fds[1], &received, 0)); + EXPECT_EQ(0, _st_win64_close(fds[1])); +} + +// close is closesocket, which st_netfd_close calls: the socket is gone afterwards, and closing it again fails. +VOID TEST(WinIoTest, CloseClosesSocket) +{ + SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + ASSERT_NE(INVALID_SOCKET, s); + int fd = (int)s; + EXPECT_EQ(0, _st_win64_close(fd)); + + int type = 0; + int size = sizeof(type); + EXPECT_EQ(SOCKET_ERROR, ::getsockopt(s, SOL_SOCKET, SO_TYPE, (char*)&type, &size)); + EXPECT_EQ(WSAENOTSOCK, WSAGetLastError()); + + errno = 0; + EXPECT_EQ(-1, _st_win64_close(fd)); + EXPECT_EQ(ENOTSOCK, errno); +} +#endif From 36ab01b61807014cee044e500d5a92a28909a897 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 00:07:57 -0400 Subject: [PATCH 18/37] Claude: Make the utest stream pair a loopback TCP connection on native Windows. Windows has no socketpair, so st_utest_stream_pair, which the utest uses for two connected sockets ST can wait on, was a stub failing with ENOSYS. It is now a loopback TCP connection of two blocking sockets: a listener on 127.0.0.1 port 0, a connect, and an accept that keeps only the client's own connection (by peer address), with TCP_NODELAY on both ends so small writes are not delayed as on a Unix-domain socketpair. Errors map to errno through _st_win64_errno. The POSIX helper is unchanged. New UtestPairTest tests: data both ways (small and 64 KB) and end of stream on close, 64 pairs open at once each connected to its own end, and on Windows that both ends are loopback TCP peers of each other with Nagle off and blocking. --------- Co-Authored-By: Claude Opus 5.5 --- utest/st_utest.hpp | 61 +++++++++++++++++-- utest/st_utest_io.cpp | 138 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 4 deletions(-) diff --git a/utest/st_utest.hpp b/utest/st_utest.hpp index b09cc81..72e187e 100644 --- a/utest/st_utest.hpp +++ b/utest/st_utest.hpp @@ -48,13 +48,66 @@ static inline int usleep(unsigned int us) // a pipe, a signal or a chosen descriptor number, keeps the POSIX calls. #ifdef _WIN32 -// Native Windows (winsock comes from st.h) has no socketpair, so the pair is a stub that fails until a loopback TCP -// pair replaces it. Descriptors are winsock sockets that fit in an int. +// Maps the last Winsock error to errno, as ST does. +extern "C" int _st_win64_errno(int wsaerr, int connecting); + +// Native Windows (winsock comes from st.h) has no socketpair, so the pair is a loopback TCP connection of two blocking +// sockets, with Nagle off so small writes are not delayed, as on a Unix-domain socketpair. Descriptors are winsock +// sockets that fit in an int. Returns 0, or -1 with errno set. static inline int st_utest_stream_pair(int fds[2]) { fds[0] = fds[1] = -1; - errno = ENOSYS; - return -1; + SOCKET listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + SOCKET client = INVALID_SOCKET, server = INVALID_SOCKET; + int err = 0; + + sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + int addrlen = sizeof(addr); + if (listener == INVALID_SOCKET || ::bind(listener, (sockaddr*)&addr, addrlen) != 0 || + ::listen(listener, SOMAXCONN) != 0 || ::getsockname(listener, (sockaddr*)&addr, &addrlen) != 0 || + (client = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) == INVALID_SOCKET || + ::connect(client, (sockaddr*)&addr, addrlen) != 0) { + err = ::WSAGetLastError(); + } + + // Accept the client's own connection: another process may connect to the port first, so check the peer address. + sockaddr_in self; + memset(&self, 0, sizeof(self)); + int selflen = sizeof(self); + if (!err && ::getsockname(client, (sockaddr*)&self, &selflen) != 0) err = ::WSAGetLastError(); + for (int i = 0; !err && server == INVALID_SOCKET && i < 16; i++) { + sockaddr_in peer; + int peerlen = sizeof(peer); + SOCKET s = ::accept(listener, (sockaddr*)&peer, &peerlen); + if (s == INVALID_SOCKET) { + err = ::WSAGetLastError(); + } else if (peer.sin_port == self.sin_port && peer.sin_addr.s_addr == self.sin_addr.s_addr) { + server = s; + } else { + ::closesocket(s); + } + } + if (!err && server == INVALID_SOCKET) err = WSAECONNREFUSED; + + BOOL nodelay = TRUE; + if (!err && (::setsockopt(client, IPPROTO_TCP, TCP_NODELAY, (const char*)&nodelay, sizeof(nodelay)) != 0 || + ::setsockopt(server, IPPROTO_TCP, TCP_NODELAY, (const char*)&nodelay, sizeof(nodelay)) != 0)) { + err = ::WSAGetLastError(); + } + + if (listener != INVALID_SOCKET) ::closesocket(listener); + if (err) { + if (client != INVALID_SOCKET) ::closesocket(client); + if (server != INVALID_SOCKET) ::closesocket(server); + errno = _st_win64_errno(err, 0); + return -1; + } + fds[0] = (int)client; + fds[1] = (int)server; + return 0; } // Winsock takes the option value as char*, so these overloads let tests pass any pointer as on POSIX. diff --git a/utest/st_utest_io.cpp b/utest/st_utest_io.cpp index 8b779c4..47a3569 100644 --- a/utest/st_utest_io.cpp +++ b/utest/st_utest_io.cpp @@ -2948,3 +2948,141 @@ VOID TEST(WinIoTest, CloseClosesSocket) EXPECT_EQ(ENOTSOCK, errno); } #endif + +// The test helper st_utest_stream_pair gives two connected stream sockets that ST can wait on: a socketpair on POSIX, +// and a loopback TCP connection on Windows, which has no socketpair. + +// Sends all of the data on a blocking socket. +static bool utest_pair_send_all(int fd, const std::string& data) +{ + size_t sent = 0; + while (sent < data.size()) { + ssize_t n = st_utest_send(fd, data.data() + sent, data.size() - sent); + if (n <= 0) return false; + sent += (size_t)n; + } + return true; +} + +// Receives exactly n bytes from a blocking socket, or less at the end of stream or on error. +static std::string utest_pair_recv_n(int fd, size_t n) +{ + std::string got; + char buf[256]; + while (got.size() < n) { + size_t want = std::min(sizeof(buf), n - got.size()); + ssize_t r = st_utest_recv(fd, buf, want); + if (r <= 0) break; + got.append(buf, (size_t)r); + } + return got; +} + +// Data goes both ways between the two ends, and closing one end is the end of stream at the other. +VOID TEST(UtestPairTest, StreamPairCarriesBothWays) +{ + int fds[2] = {-1, -1}; + ASSERT_EQ(0, st_utest_stream_pair(fds)); + EXPECT_GE(fds[0], 0); + EXPECT_GE(fds[1], 0); + EXPECT_NE(fds[0], fds[1]); + + ASSERT_TRUE(utest_pair_send_all(fds[0], "ping")); + EXPECT_EQ("ping", utest_pair_recv_n(fds[1], 4)); + ASSERT_TRUE(utest_pair_send_all(fds[1], "pong!")); + EXPECT_EQ("pong!", utest_pair_recv_n(fds[0], 5)); + + // More than a small buffer, in both directions. + std::string big(64 * 1024, 'x'); + for (size_t i = 0; i < big.size(); i++) big[i] = (char)('a' + i % 26); + ASSERT_TRUE(utest_pair_send_all(fds[0], big)); + EXPECT_TRUE(big == utest_pair_recv_n(fds[1], big.size())); + ASSERT_TRUE(utest_pair_send_all(fds[1], big)); + EXPECT_TRUE(big == utest_pair_recv_n(fds[0], big.size())); + + EXPECT_EQ(0, st_utest_close(fds[0])); + char c; + EXPECT_EQ(0, st_utest_recv(fds[1], &c, 1)); + EXPECT_EQ(0, st_utest_close(fds[1])); +} + +// Many pairs open at once are each connected to their own other end, never to another pair's. +VOID TEST(UtestPairTest, ManyPairsAreEachConnectedToTheirOwnEnd) +{ + const int n = 64; + int fds[n][2]; + for (int i = 0; i < n; i++) { + ASSERT_EQ(0, st_utest_stream_pair(fds[i])) << "pair " << i; + } + + std::vector all; + for (int i = 0; i < n; i++) { + all.push_back(fds[i][0]); + all.push_back(fds[i][1]); + } + std::sort(all.begin(), all.end()); + EXPECT_TRUE(std::unique(all.begin(), all.end()) == all.end()); + + for (int i = 0; i < n; i++) { + char tag[16]; + snprintf(tag, sizeof(tag), "pair-%03d", i); + ASSERT_TRUE(utest_pair_send_all(fds[i][0], tag)); + } + for (int i = 0; i < n; i++) { + char tag[16]; + snprintf(tag, sizeof(tag), "pair-%03d", i); + EXPECT_EQ(std::string(tag), utest_pair_recv_n(fds[i][1], strlen(tag))); + } + + for (int i = 0; i < n; i++) { + EXPECT_EQ(0, st_utest_close(fds[i][0])); + EXPECT_EQ(0, st_utest_close(fds[i][1])); + } +} + +#ifdef _WIN32 // Windows only: the stream pair is a loopback TCP connection +// The two ends are the two sides of one loopback TCP connection, with Nagle off on both, so small writes are not +// delayed, as on a Unix-domain socketpair. Both ends are blocking, as socketpair gives them. +VOID TEST(UtestPairTest, StreamPairIsLoopbackTcpWithoutDelay) +{ + int fds[2] = {-1, -1}; + ASSERT_EQ(0, st_utest_stream_pair(fds)); + + for (int i = 0; i < 2; i++) { + int type = 0; + socklen_t size = sizeof(type); + EXPECT_EQ(0, getsockopt(fds[i], SOL_SOCKET, SO_TYPE, &type, &size)); + EXPECT_EQ(SOCK_STREAM, type); + + int nodelay = 0; + size = sizeof(nodelay); + EXPECT_EQ(0, getsockopt(fds[i], IPPROTO_TCP, TCP_NODELAY, &nodelay, &size)); + EXPECT_NE(0, nodelay) << "end " << i; + } + + // Each end's peer is the other end, on 127.0.0.1. + sockaddr_in local[2], peer[2]; + for (int i = 0; i < 2; i++) { + int size = sizeof(local[i]); + ASSERT_EQ(0, ::getsockname((SOCKET)fds[i], (sockaddr*)&local[i], &size)); + size = sizeof(peer[i]); + ASSERT_EQ(0, ::getpeername((SOCKET)fds[i], (sockaddr*)&peer[i], &size)); + EXPECT_EQ(AF_INET, local[i].sin_family); + EXPECT_EQ(htonl(INADDR_LOOPBACK), local[i].sin_addr.s_addr); + } + EXPECT_EQ(local[0].sin_port, peer[1].sin_port); + EXPECT_EQ(local[1].sin_port, peer[0].sin_port); + EXPECT_EQ(local[0].sin_addr.s_addr, peer[1].sin_addr.s_addr); + EXPECT_EQ(local[1].sin_addr.s_addr, peer[0].sin_addr.s_addr); + + // Blocking: a receive with no data waits instead of failing with WSAEWOULDBLOCK, so it times out. + DWORD timeout = 50; + EXPECT_EQ(0, setsockopt(fds[1], SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout))); + char c; + EXPECT_EQ(-1, st_utest_recv(fds[1], &c, 1)); + EXPECT_EQ(WSAETIMEDOUT, WSAGetLastError()); + + EXPECT_EQ(0, st_utest_close(fds[0])); + EXPECT_EQ(0, st_utest_close(fds[1])); +} +#endif From d6dd1072aa56e3a3f2fa12b598c2293af7032dc3 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 00:18:04 -0400 Subject: [PATCH 19/37] Claude: Add the WSAPoll event system on native Windows. The Windows event system now polls sockets with WSAPoll, for both ST_EVENTSYS_DEFAULT and ST_EVENTSYS_ALT, named "wsapoll". Each dispatch builds the WSAPOLLFD array from the pollsets on the I/O queue, so there are no per-descriptor counts: closing a descriptor fails with EBUSY while a pollset holds it. With nothing to poll it sleeps until the next timer. An error or hang-up wakes every waiter of the descriptor, as epoll does. st_poll refuses a negative descriptor (the Winsock SOCKET is unsigned), POLLPRI (WSAPoll has none, so EINVAL as on kqueue), and a part of Winsock's POLLIN such as POLLRDNORM. Opening anything but an open socket fails with EBADF. io.c: getsockopt(SO_ERROR), which st_connect reads, gives the POSIX errno instead of the Winsock code, and a receive on a datagram socket that got an ICMP port unreachable fails with ECONNREFUSED instead of ECONNRESET. New WsaPollTest tests: the backend name, and a refused connect reported by WSAPoll with ECONNREFUSED before the timeout. The Windows CI job now skips only the 9 tests that Windows TCP still fails: 8 IoWriteTest tests (Winsock takes a whole non-blocking send larger than SO_SNDBUF) and IoConnectTest.ConnectRefusedWhenNobodyListens (SYN retries take about 2 seconds, longer than its 100 ms timeout). --------- Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 20 ++-- event.c | 229 ++++++++++++++++++++++++++++++++----- io.c | 41 +++++-- utest/st_utest_io.cpp | 31 +++++ 4 files changed, 275 insertions(+), 46 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4ea55e..102d2c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ jobs: disable_search: true # Native Windows with MSVC, built with GNU make from Git Bash. st_utest runs without - # the socket tests until the event system and I/O are ported. + # the few tests that Windows TCP behavior still fails. windows: name: actions-test-build-win64 runs-on: windows-latest @@ -105,17 +105,15 @@ jobs: shell: bash timeout-minutes: 10 run: | - # Socket I/O is not ported to Windows yet, so skip the tests that need sockets. - # Remove this filter when the Windows event system and I/O are ported. + # Windows accepts a whole non-blocking send larger than SO_SNDBUF, so these writes + # do not wait for the reader, and it retries the SYN after a refusal for about two + # seconds. Remove this filter when those tests pass on Windows. skip=( - 'IoAcceptTest.*' 'IoConnectTest.*' 'IoNetfdTest.*' 'IoReadTest.*' 'IoWriteTest.*' - 'IoUdpTest.*' 'IoUdpMsgTest.*' 'PollTest.*' 'PollPriTest.*' 'TcpTest.*' - 'EpollWorkflowTest.*' - 'InitTest.SecondInitKeepsWaitingCoroutines' - 'InterruptTest.StopReaderWithQueuedData' - 'TimeoutHeapTest.EarlyWakeKeepsOthersOnTime' - 'LearnKB.BasicNetfdReadTimeout' 'LearnKB.BasicNetfdWriteThenRead' - 'LearnKB.NetfdFreeKeepsOsfdOpen' 'LearnKB.NetfdSpecificAndDestructorOnClose' + 'IoConnectTest.ConnectRefusedWhenNobodyListens' + 'IoWriteTest.WriteWaitsForSlowPlayer' 'IoWriteTest.WriteFailsWhenPlayerResetsMidStream' + 'IoWriteTest.WritevWaitsForSlowPlayer' 'IoWriteTest.WritevWaitsForSlowPlayerWithMergedWrite' + 'IoWriteTest.WritevFailsWhenPlayerResetsMidStream' 'IoWriteTest.WritevTimesOutAndLosesTheSentCount' + 'IoWriteTest.WritevInterruptedWhenConnectionStops' 'IoWriteTest.WritevResidResumesAfterTimeout' ) ./obj/st_utest.exe --gtest_filter="-$(IFS=:; echo "${skip[*]}")" diff --git a/event.c b/event.c index 2a8b08a..32e3e75 100644 --- a/event.c +++ b/event.c @@ -37,6 +37,8 @@ #include #if !defined(WIN64) #include +#else +#include #endif #include #include @@ -1251,62 +1253,228 @@ static _st_eventsys_t _st_epoll_eventsys = { #if defined (WIN64) /***************************************** - * Native Windows event system, minimal: the dispatch only waits for the sleep - * timeout, so st_usleep, timed waits, and the scheduler work. Windows select() - * with no sockets fails instead of sleeping, so the dispatch sleeps itself. - * Descriptor polling fails with ENOSYS until the WSAPoll backend is ported. + * Native Windows event system with WSAPoll, for features, not performance. + * WSAPoll polls sockets only, has no POLLPRI, and fails instead of sleeping when + * given no sockets. Before Windows 10 2004, it does not report a failed connect, + * so such a connect waits until its timeout. */ +static __thread struct _st_wsapolldata { + WSAPOLLFD *fds; + int fds_size; +} *_st_wsapoll_data; + ST_HIDDEN int _st_win64_init(void) { + _st_wsapoll_data = (struct _st_wsapolldata *) calloc(1, sizeof(*_st_wsapoll_data)); + if (!_st_wsapoll_data) { + errno = ENOMEM; + return -1; + } + return 0; } -ST_HIDDEN void _st_win64_dispatch(void) +/* The WSAPoll timeout in milliseconds until the earliest sleeper is due, or -1 for none. */ +static int _st_win64_timeout_ms(void) { st_utime_t min_timeout; - DWORD ms; - if (_st_this_vp.sleep_q == NULL) { - /* Nothing can wake a thread, like select() with no descriptors and no timeout. */ - ms = INFINITE; - } else { - min_timeout = (_st_this_vp.sleep_q->due <= _st_this_vp.last_clock) ? 0 : - (_st_this_vp.sleep_q->due - _st_this_vp.last_clock); - /* Round up to milliseconds, so the sleeper is due when the dispatch returns. */ - min_timeout = (min_timeout + 999) / 1000; - ms = (min_timeout >= INFINITE) ? INFINITE - 1 : (DWORD) min_timeout; + if (_st_this_vp.sleep_q == NULL) + return -1; + + min_timeout = (_st_this_vp.sleep_q->due <= _st_this_vp.last_clock) ? 0 : + (_st_this_vp.sleep_q->due - _st_this_vp.last_clock); + /* Round up to milliseconds, so the sleeper is due when the dispatch returns. */ + min_timeout = (min_timeout + 999) / 1000; + return (min_timeout >= INT_MAX) ? INT_MAX : (int) min_timeout; +} + +/* The ST events of one WSAPoll result, like the epoll backend: an error or hang-up also wakes every waiter. */ +static short _st_win64_revents(short events, short r) +{ + short revents = 0; + + if (r & (POLLERR | POLLHUP | POLLNVAL)) + revents |= events; + if ((events & POLLIN) && (r & POLLIN)) + revents |= POLLIN; + if ((events & POLLOUT) && (r & POLLOUT)) + revents |= POLLOUT; + revents |= r & (POLLERR | POLLHUP | POLLNVAL); + + return revents; +} + +/* Wake the thread of a pollset whose descriptors are ready. */ +static void _st_win64_notify(_st_pollq_t *pq) +{ + st_clist_remove(&pq->links); + pq->on_ioq = 0; + + if (pq->thread->flags & _ST_FL_ON_SLEEPQ) + _st_del_sleep_q(pq->thread); + pq->thread->state = _ST_ST_RUNNABLE; + st_clist_insert_before(&pq->thread->links, &_st_this_vp.run_q); +} + +/* The whole WSAPoll failed, which a closed descriptor may cause: wake the pollsets with one, with POLLNVAL. */ +static void _st_win64_find_bad_fd(void) +{ + _st_clist_t *q; + _st_pollq_t *pq; + struct pollfd *pds, *epds; + int notify, type; + socklen_t len; + + for (q = _st_this_vp.io_q.next; q != &_st_this_vp.io_q; q = q->next) { + pq = _ST_POLLQUEUE_PTR(q); + notify = 0; + epds = pq->pds + pq->npds; + + for (pds = pq->pds; pds < epds; pds++) { + pds->revents = 0; + len = sizeof(type); + if (getsockopt((SOCKET)(intptr_t) pds->fd, SOL_SOCKET, SO_TYPE, (char *) &type, &len) != 0) { + pds->revents = POLLNVAL; + notify = 1; + } + } + + if (notify) + _st_win64_notify(pq); + } +} + +ST_HIDDEN void _st_win64_dispatch(void) +{ + _st_clist_t *q; + _st_pollq_t *pq; + struct pollfd *pds, *epds; + WSAPOLLFD *fds; + int n, i, nfd, notify, ms; + + /* Count the descriptors of every pollset on the I/O queue, and grow the array for them. */ + n = 0; + for (q = _st_this_vp.io_q.next; q != &_st_this_vp.io_q; q = q->next) + n += _ST_POLLQUEUE_PTR(q)->npds; + + if (n > _st_wsapoll_data->fds_size) { + fds = (WSAPOLLFD *) realloc(_st_wsapoll_data->fds, n * sizeof(WSAPOLLFD)); + if (fds) { + _st_wsapoll_data->fds = fds; + _st_wsapoll_data->fds_size = n; + } else { + /* Out of memory: wait for the sleepers only, and try again next time. */ + n = 0; + } + } + + ms = _st_win64_timeout_ms(); + if (n == 0) { + /* Nothing to poll, so only a timeout can wake a thread, like select() with no descriptors. */ + Sleep(ms < 0 ? INFINITE : (DWORD) ms); + return; + } + + fds = _st_wsapoll_data->fds; + i = 0; + for (q = _st_this_vp.io_q.next; q != &_st_this_vp.io_q; q = q->next) { + pq = _ST_POLLQUEUE_PTR(q); + epds = pq->pds + pq->npds; + for (pds = pq->pds; pds < epds; pds++, i++) { + fds[i].fd = (SOCKET)(intptr_t) pds->fd; + fds[i].events = pds->events; + fds[i].revents = 0; + } } - Sleep(ms); + /* Check for I/O operations */ + nfd = WSAPoll(fds, (ULONG) n, ms); + + /* Notify threads that are associated with the selected descriptors, in the order the array was built. */ + if (nfd > 0) { + i = 0; + for (q = _st_this_vp.io_q.next; q != &_st_this_vp.io_q; ) { + pq = _ST_POLLQUEUE_PTR(q); + q = q->next; + notify = 0; + epds = pq->pds + pq->npds; + for (pds = pq->pds; pds < epds; pds++, i++) { + pds->revents = _st_win64_revents(pds->events, fds[i].revents); + if (pds->revents) + notify = 1; + } + if (notify) + _st_win64_notify(pq); + } + } else if (nfd < 0) { + if (WSAGetLastError() == WSAENOTSOCK) + _st_win64_find_bad_fd(); + } } ST_HIDDEN int _st_win64_pollset_add(struct pollfd *pds, int npds) { - (void) pds; - (void) npds; - errno = ENOSYS; - return -1; + struct pollfd *pd; + struct pollfd *epd = pds + npds; + + /* + * Do checks up front, like the select backend. Winsock's pollfd has an unsigned SOCKET, so a negative + * descriptor is checked as the int ST keeps. Winsock's POLLIN is POLLRDNORM | POLLRDBAND, so a part of it, such + * as POLLRDNORM from a program written for poll(2), is refused too, and so is POLLPRI. + */ + for (pd = pds; pd < epd; pd++) { + if ((int) pd->fd < 0 || !pd->events || (pd->events & ~(POLLIN | POLLOUT)) || + ((pd->events & POLLIN) && (pd->events & POLLIN) != POLLIN)) { + errno = EINVAL; + return -1; + } + } + + /* Registered by the pollset on the I/O queue. */ + return 0; } ST_HIDDEN void _st_win64_pollset_del(struct pollfd *pds, int npds) { + /* Unregistered by removing the pollset from the I/O queue. */ (void) pds; (void) npds; } ST_HIDDEN int _st_win64_fd_new(int osfd) { - (void) osfd; - errno = ENOSYS; - return -1; + int type; + socklen_t len = sizeof(type); + + /* WSAPoll polls only sockets, so anything else, such as a closed socket, is a bad descriptor. */ + if (getsockopt((SOCKET)(intptr_t) osfd, SOL_SOCKET, SO_TYPE, (char *) &type, &len) != 0) { + errno = EBADF; + return -1; + } + + return 0; } ST_HIDDEN int _st_win64_fd_close(int osfd) { - (void) osfd; - errno = ENOSYS; - return -1; + _st_clist_t *q; + _st_pollq_t *pq; + struct pollfd *pds, *epds; + + for (q = _st_this_vp.io_q.next; q != &_st_this_vp.io_q; q = q->next) { + pq = _ST_POLLQUEUE_PTR(q); + epds = pq->pds + pq->npds; + for (pds = pq->pds; pds < epds; pds++) { + if ((int) pds->fd == osfd) { + errno = EBUSY; + return -1; + } + } + } + + return 0; } ST_HIDDEN int _st_win64_fd_getlimit(void) @@ -1317,6 +1485,11 @@ ST_HIDDEN int _st_win64_fd_getlimit(void) ST_HIDDEN void _st_win64_destroy(void) { + if (_st_wsapoll_data) { + free(_st_wsapoll_data->fds); + free(_st_wsapoll_data); + _st_wsapoll_data = NULL; + } } /* @@ -1324,7 +1497,7 @@ ST_HIDDEN void _st_win64_destroy(void) * select-like default, and ST_EVENTSYS_ALT is accepted, so callers that ask for it work. */ static _st_eventsys_t _st_win64_eventsys = { - "win64", + "wsapoll", ST_EVENTSYS_SELECT, _st_win64_init, _st_win64_dispatch, @@ -1337,7 +1510,7 @@ static _st_eventsys_t _st_win64_eventsys = { }; static _st_eventsys_t _st_win64_alt_eventsys = { - "win64", + "wsapoll", ST_EVENTSYS_ALT, _st_win64_init, _st_win64_dispatch, diff --git a/io.c b/io.c index 12ecf29..c42b100 100644 --- a/io.c +++ b/io.c @@ -144,9 +144,36 @@ static int _st_win64_connect(int fd, const struct sockaddr *addr, int addrlen) return (connect((SOCKET)fd, addr, addrlen) == SOCKET_ERROR) ? _st_win64_sock_fail(1) : 0; } +/* SO_ERROR, which st_connect reads after the wait, is a Winsock error, so it gives the errno instead. */ static int _st_win64_getsockopt(int fd, int level, int name, char *value, socklen_t *size) { - return (getsockopt((SOCKET)fd, level, name, value, size) == SOCKET_ERROR) ? _st_win64_sock_fail(0) : 0; + if (getsockopt((SOCKET)fd, level, name, value, size) == SOCKET_ERROR) + return _st_win64_sock_fail(0); + if (level == SOL_SOCKET && name == SO_ERROR && *size == sizeof(int) && *(int *)value) + *(int *)value = _st_win64_errno(*(int *)value, 1); + return 0; +} + +/* + * Set errno from the Winsock error of a failed receive, and return -1. Winsock reports + * an ICMP port unreachable on a datagram socket as WSAECONNRESET, where POSIX gives + * ECONNREFUSED. + */ +static int _st_win64_recv_sock_fail(int fd) +{ + int err = WSAGetLastError(); + int type = 0; + socklen_t size = sizeof(type); + + if (err == WSAECONNRESET && getsockopt((SOCKET)fd, SOL_SOCKET, SO_TYPE, (char *)&type, &size) == 0 && + type == SOCK_DGRAM) { + WSASetLastError(err); + errno = ECONNREFUSED; + return -1; + } + WSASetLastError(err); + errno = _st_win64_errno(err, 0); + return -1; } int _st_win64_recvfrom(int fd, void *buf, int len, int flags, struct sockaddr *from, socklen_t *fromlen) @@ -157,7 +184,7 @@ int _st_win64_recvfrom(int fd, void *buf, int len, int flags, struct sockaddr *f /* A datagram larger than the buffer: POSIX returns the bytes that fit and drops the rest. */ if (WSAGetLastError() == WSAEMSGSIZE) return len; - return _st_win64_sock_fail(0); + return _st_win64_recv_sock_fail(fd); } static int _st_win64_sendto(int fd, const void *msg, int len, int flags, const struct sockaddr *to, int tolen) @@ -233,7 +260,7 @@ ssize_t _st_win64_read(int fd, void *buf, size_t nbyte) /* A datagram larger than the buffer: POSIX returns the bytes that fit and drops the rest. */ if (WSAGetLastError() == WSAEMSGSIZE) return len; - return _st_win64_sock_fail(0); + return _st_win64_recv_sock_fail(fd); } ssize_t _st_win64_write(int fd, const void *buf, size_t nbyte) @@ -290,11 +317,11 @@ static void _st_win64_bufs_free(WSABUF *bufs, WSABUF *local) } /* The result of a receive that failed: a truncated datagram is the bytes that fit. */ -static ssize_t _st_win64_recv_fail(size_t total) +static ssize_t _st_win64_recv_fail(int fd, size_t total) { if (WSAGetLastError() == WSAEMSGSIZE) return (ssize_t)total; - return _st_win64_sock_fail(0); + return _st_win64_recv_sock_fail(fd); } ssize_t _st_win64_readv(int fd, const struct iovec *iov, int iov_size) @@ -311,7 +338,7 @@ ssize_t _st_win64_readv(int fd, const struct iovec *iov, int iov_size) if (WSARecv((SOCKET)fd, bufs, count, &nbytes, &flags, NULL, NULL) == 0) rv = (ssize_t)nbytes; else - rv = _st_win64_recv_fail(total); + rv = _st_win64_recv_fail(fd, total); _st_win64_bufs_free(bufs, local); return rv; } @@ -417,7 +444,7 @@ int _st_win64_recvmsg(int fd, struct msghdr *msg, int flags) wmsg.Control.len = 0; wmsg.dwFlags = 0; } else { - rv = _st_win64_sock_fail(0); + rv = _st_win64_recv_sock_fail(fd); } if (rv >= 0) { diff --git a/utest/st_utest_io.cpp b/utest/st_utest_io.cpp index 47a3569..fbc47f0 100644 --- a/utest/st_utest_io.cpp +++ b/utest/st_utest_io.cpp @@ -3086,3 +3086,34 @@ VOID TEST(UtestPairTest, StreamPairIsLoopbackTcpWithoutDelay) EXPECT_EQ(0, st_utest_close(fds[1])); } #endif + +#ifdef _WIN32 // Windows only: the WSAPoll event system +// Both event system choices use the one WSAPoll backend on Windows. +VOID TEST(WsaPollTest, EventSystemIsWsaPoll) +{ + EXPECT_STREQ("wsapoll", st_get_eventsys_name()); +} + +// Windows retries the SYN after a refusal, so a connect to a port where nothing listens fails after about two +// seconds, longer than ST_UTEST_TIMEOUT of IoConnectTest.ConnectRefusedWhenNobodyListens. WSAPoll did not report a +// failed connect before Windows 10 2004; here it must wake st_connect before its timeout, and the SO_ERROR it reads +// is a POSIX errno, ECONNREFUSED, not the Winsock code. +VOID TEST(WsaPollTest, ConnectRefusedIsReportedBeforeTimeout) +{ + struct sockaddr_in addr; + st_netfd_t listener = io_tcp_listen(addr, 8); + ASSERT_TRUE(listener != NULL); + ASSERT_EQ(0, st_netfd_close(listener)); + + st_netfd_t stfd = io_tcp_socket(AF_INET); + ASSERT_TRUE(stfd != NULL); + StStfdCleanup(stfd); + + st_utime_t timeout = 10 * 1000 * ST_UTIME_MILLISECONDS; + st_utime_t starttime = st_utime(); + errno = 0; + EXPECT_EQ(-1, st_connect(stfd, (sockaddr*)&addr, sizeof(addr), timeout)); + EXPECT_EQ(ECONNREFUSED, errno); + EXPECT_LT(st_utime() - starttime, timeout); +} +#endif From 5449429ba1faed96a7acfc27c2e393f0e414c390 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 08:46:52 -0400 Subject: [PATCH 20/37] Claude: Fix or skip the Windows IoWriteTest and refused-connect tests. Winsock takes a whole non-blocking send, however large, while the send buffer holds less than SO_SNDBUF, so a single large write to a slow player never waits. On Windows, three IoWriteTest tests now fill the send and receive buffers first, so the write under test waits and sees the reset or the stop as on POSIX: WriteFailsWhenPlayerResetsMidStream, WritevFailsWhenPlayerResetsMidStream, and WritevInterruptedWhenConnectionStops. ST reads its clock again after the fill, which sleeps outside ST. Winsock never sends part of a non-blocking send: it takes the whole buffer or fails with WSAEWOULDBLOCK and sends nothing. So the five tests that need a partial count skip on Windows: WriteWaitsForSlowPlayer, WritevWaitsForSlowPlayer, WritevWaitsForSlowPlayerWithMergedWrite, WritevResidResumesAfterTimeout, and WritevTimesOutAndLosesTheSentCount. IoConnectTest.ConnectRefusedWhenNobodyListens also skips on Windows, which retries a refused connect for about 2 seconds, longer than its timeout. io.c is unchanged. The Windows CI job now runs the whole st_utest without a filter. --------- Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 17 +++--------- utest/st_utest_io.cpp | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 102d2c3..d842743 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,8 +59,8 @@ jobs: files: coverage.xml disable_search: true - # Native Windows with MSVC, built with GNU make from Git Bash. st_utest runs without - # the few tests that Windows TCP behavior still fails. + # Native Windows with MSVC, built with GNU make from Git Bash. st_utest skips the few + # tests that Windows TCP behavior cannot pass, in the test code itself. windows: name: actions-test-build-win64 runs-on: windows-latest @@ -104,18 +104,7 @@ jobs: - name: Run ST utest shell: bash timeout-minutes: 10 - run: | - # Windows accepts a whole non-blocking send larger than SO_SNDBUF, so these writes - # do not wait for the reader, and it retries the SYN after a refusal for about two - # seconds. Remove this filter when those tests pass on Windows. - skip=( - 'IoConnectTest.ConnectRefusedWhenNobodyListens' - 'IoWriteTest.WriteWaitsForSlowPlayer' 'IoWriteTest.WriteFailsWhenPlayerResetsMidStream' - 'IoWriteTest.WritevWaitsForSlowPlayer' 'IoWriteTest.WritevWaitsForSlowPlayerWithMergedWrite' - 'IoWriteTest.WritevFailsWhenPlayerResetsMidStream' 'IoWriteTest.WritevTimesOutAndLosesTheSentCount' - 'IoWriteTest.WritevInterruptedWhenConnectionStops' 'IoWriteTest.WritevResidResumesAfterTimeout' - ) - ./obj/st_utest.exe --gtest_filter="-$(IFS=:; echo "${skip[*]}")" + run: ./obj/st_utest.exe ################################################################ # Build diff --git a/utest/st_utest_io.cpp b/utest/st_utest_io.cpp index fbc47f0..b2ce82a 100644 --- a/utest/st_utest_io.cpp +++ b/utest/st_utest_io.cpp @@ -37,6 +37,10 @@ #define ST_UTIME_MILLISECONDS 1000 #define ST_UTEST_TIMEOUT (100 * ST_UTIME_MILLISECONDS) +#ifdef _MSC_VER // Windows only: some tests skip with GTEST_SKIP, so the rest of their body is unreachable +#pragma warning(disable: 4702) +#endif + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // The utest for reading a TCP stream, the way SRS reads every RTMP chunk header and payload with st_read_fully. //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -932,6 +936,9 @@ static st_netfd_t io_tcp_socket(int family) // report the same error. The test takes a free port by opening a listener and closing it. Locks in current behavior. VOID TEST(IoConnectTest, ConnectRefusedWhenNobodyListens) { +#ifdef _WIN32 // Windows only: skipped, Windows retries a refused connect for about 2 s, longer than the timeout + GTEST_SKIP() << "Windows retries a refused connect for about 2 seconds"; +#endif struct sockaddr_in addr; st_netfd_t listener = io_tcp_listen(addr, 8); ASSERT_TRUE(listener != NULL); @@ -1104,6 +1111,25 @@ static bool io_shrink_send_buffer(IoTestTcpPair& pair) return setsockopt(st_netfd_fileno(pair.server_), SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)) == 0; } +#ifdef _WIN32 // Windows only: a large send does not wait +// Windows takes a whole non-blocking send, however large, while the send buffer holds less than SO_SNDBUF, so a +// single large write to a slow player never waits; the wait starts from the next write. So on Windows the tests first +// fill the send and receive buffers, and the write under test waits, times out, or sees the reset as on POSIX. +// Winsock never sends part of a non-blocking send: it takes the whole buffer, or fails with WSAEWOULDBLOCK and sends +// nothing. So the tests that need a partial count are skipped on Windows; the wait and retry after a would-block is +// covered by the tests that fill the buffers first. +static bool io_fill_buffers(int fd, std::string& filled); + +// Fill the buffers of the server end. The fill sleeps outside ST, so let ST read its clock again after it, or a timeout +// set right after would start from the old clock and expire early. +static bool io_fill_send_buffer(IoTestTcpPair& pair, std::string& filled) +{ + bool ok = io_fill_buffers(st_netfd_fileno(pair.server_), filled); + st_usleep(0); + return ok; +} +#endif + // Messages as SRS sends them: each one is a 12-byte header iovec and a payload iovec. Both point into one byte // pattern that doesn't repeat at buffer boundaries, so the player can compare what it got with data_, and a byte that // is lost, sent twice or sent out of order shows up. @@ -1189,6 +1215,9 @@ VOID TEST(IoWriteTest, WritevSendsMessageInOneCall) // byte is sent, in order. The player has to start reading before st_writev can return. Locks in current behavior. VOID TEST(IoWriteTest, WritevWaitsForSlowPlayer) { +#ifdef _WIN32 // Windows only: skipped, the test needs a partial send, which Winsock never does + GTEST_SKIP() << "Winsock never sends part of a non-blocking send"; +#endif IoTestTcpPair pair; ASSERT_TRUE(io_tcp_pair(pair)); ASSERT_TRUE(io_shrink_send_buffer(pair)); @@ -1210,6 +1239,9 @@ VOID TEST(IoWriteTest, WritevWaitsForSlowPlayer) // and frees it when done. Every byte still arrives in order. Locks in current behavior. VOID TEST(IoWriteTest, WritevWaitsForSlowPlayerWithMergedWrite) { +#ifdef _WIN32 // Windows only: skipped, the test needs a partial send, which Winsock never does + GTEST_SKIP() << "Winsock never sends part of a non-blocking send"; +#endif IoTestTcpPair pair; ASSERT_TRUE(io_tcp_pair(pair)); ASSERT_TRUE(io_shrink_send_buffer(pair)); @@ -1250,6 +1282,10 @@ VOID TEST(IoWriteTest, WritevFailsWhenPlayerResetsMidStream) IoTestTcpPair pair; ASSERT_TRUE(io_tcp_pair(pair)); ASSERT_TRUE(io_shrink_send_buffer(pair)); +#ifdef _WIN32 // Windows only: fill the buffers, or the write does not wait + std::string filled; + ASSERT_TRUE(io_fill_send_buffer(pair, filled)); +#endif IoTestMessages msgs(4, 256 * 1024); IoTestBlockedWriter w; @@ -1274,6 +1310,9 @@ VOID TEST(IoWriteTest, WritevFailsWhenPlayerResetsMidStream) // behavior. VOID TEST(IoWriteTest, WritevTimesOutAndLosesTheSentCount) { +#ifdef _WIN32 // Windows only: skipped, the test needs a partial send, which Winsock never does + GTEST_SKIP() << "Winsock never sends part of a non-blocking send"; +#endif IoTestTcpPair pair; ASSERT_TRUE(io_tcp_pair(pair)); ASSERT_TRUE(io_shrink_send_buffer(pair)); @@ -1295,6 +1334,10 @@ VOID TEST(IoWriteTest, WritevInterruptedWhenConnectionStops) IoTestTcpPair pair; ASSERT_TRUE(io_tcp_pair(pair)); ASSERT_TRUE(io_shrink_send_buffer(pair)); +#ifdef _WIN32 // Windows only: fill the buffers, or the write does not wait + std::string filled; + ASSERT_TRUE(io_fill_send_buffer(pair, filled)); +#endif IoTestMessages msgs(4, 256 * 1024); IoTestBlockedWriter w; @@ -1584,6 +1627,9 @@ VOID TEST(IoWriteTest, SendmsgRetriesWhenSignalInterruptsSystemCall) // order. Locks in current behavior. VOID TEST(IoWriteTest, WritevResidResumesAfterTimeout) { +#ifdef _WIN32 // Windows only: skipped, the test needs a partial send, which Winsock never does + GTEST_SKIP() << "Winsock never sends part of a non-blocking send"; +#endif IoTestTcpPair pair; ASSERT_TRUE(io_tcp_pair(pair)); ASSERT_TRUE(io_shrink_send_buffer(pair)); @@ -1652,6 +1698,9 @@ VOID TEST(IoWriteTest, WritevWithNothingToSendReturnsAtOnce) // current behavior. VOID TEST(IoWriteTest, WriteWaitsForSlowPlayer) { +#ifdef _WIN32 // Windows only: skipped, the test needs a partial send, which Winsock never does + GTEST_SKIP() << "Winsock never sends part of a non-blocking send"; +#endif IoTestTcpPair pair; ASSERT_TRUE(io_tcp_pair(pair)); ASSERT_TRUE(io_shrink_send_buffer(pair)); @@ -1685,6 +1734,10 @@ VOID TEST(IoWriteTest, WriteFailsWhenPlayerResetsMidStream) IoTestTcpPair pair; ASSERT_TRUE(io_tcp_pair(pair)); ASSERT_TRUE(io_shrink_send_buffer(pair)); +#ifdef _WIN32 // Windows only: fill the buffers, or the write does not wait + std::string filled; + ASSERT_TRUE(io_fill_send_buffer(pair, filled)); +#endif IoTestMessages msgs(1, 1024 * 1024); IoTestBlockedWriter w; From 7b144e547d8107de6c6459691e02ad89eb4da064 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 08:55:24 -0400 Subject: [PATCH 21/37] Claude: Add an echo utest with 1000 concurrent TCP connections. TcpTest.EchoManyConcurrentConnections starts an echo server on loopback and 1000 client coroutines. Every client connects and waits in a read for a go byte, which the server sends only when all clients are connected and every accepted connection has an echo coroutine waiting in a read, so all 2000 sockets are polled at the same time. Each client then sends three messages of its own, each in two writes, and checks every echo; the test checks the counts and the total bytes echoed. It is for correctness, not speed, and is portable. On POSIX it raises the soft descriptor limit toward the hard limit, and runs fewer clients only if the hard limit is too low. On Windows it passes with the WSAPoll backend; a backend that ignores descriptors past 1024 fails it. --------- Co-Authored-By: Claude Opus 5.5 --- utest/st_utest_tcp.cpp | 276 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) diff --git a/utest/st_utest_tcp.cpp b/utest/st_utest_tcp.cpp index 168d10b..5461a90 100644 --- a/utest/st_utest_tcp.cpp +++ b/utest/st_utest_tcp.cpp @@ -5,9 +5,13 @@ #include #include +#include +#include +#include #ifndef _WIN32 #include +#include #include #include #endif @@ -107,3 +111,275 @@ VOID TEST(TcpTest, TcpConnection) ST_EXPECT_SUCCESS(r1); } +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// The utest for an echo server with many concurrent connections, for correctness, not speed. +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +#define TCP_ECHO_CLIENTS 1000 +#define TCP_ECHO_ROUNDS 3 +#define TCP_ECHO_SIZE 100 +// Generous: connecting 1000 clients at once may overflow the listen backlog, and the OS then retries the SYN after +// about a second (Linux) or two (Windows). A correct run takes far less. +#define TCP_ECHO_TIMEOUT (30 * 1000 * ST_UTIME_MILLISECONDS) +// Small stacks, since there is one coroutine per client and one per accepted connection. +#define TCP_ECHO_STACK (64 * 1024) + +struct TcpEchoTest { + int nn_clients_; + st_netfd_t listener_; + sockaddr_in addr_; + + // The barrier: the server sends each client a go byte only when every client is connected and waits for it, and + // every echo coroutine waits for the client's data, so all the sockets are polled at the same time. + int connected_; + int accepted_; + int nn_ready_; + st_cond_t all_connected_; + + // The results of the echo coroutines. + int nn_echoed_; + int64_t bytes_echoed_; + int echo_errors_; + + TcpEchoTest() : nn_clients_(0), listener_(NULL), connected_(0), accepted_(0), nn_ready_(0), all_connected_(NULL), + nn_echoed_(0), bytes_echoed_(0), echo_errors_(0) { + memset(&addr_, 0, sizeof(addr_)); + } +}; + +struct TcpEchoConn { + TcpEchoTest* t_; + st_netfd_t stfd_; + int id_; +}; + +// The message of one client in one round: its id, the round, and a pattern that depends on both. +static void tcp_echo_message(char* buf, int id, int round) +{ + snprintf(buf, TCP_ECHO_SIZE, "client=%d, round=%d, ", id, round); + for (int i = (int)strlen(buf); i < TCP_ECHO_SIZE; i++) { + buf[i] = (char)('a' + (id * 7 + round * 3 + i) % 26); + } +} + +// Echoes whatever arrives on one accepted connection until the client closes it. +static void* tcp_echo_session(void* arg) +{ + TcpEchoConn* c = (TcpEchoConn*)arg; + TcpEchoTest* t = c->t_; + + char buf[TCP_ECHO_SIZE * 2]; + int64_t bytes = 0; + bool ok = true; + for (;;) { + ssize_t nn = st_read(c->stfd_, buf, sizeof(buf), TCP_ECHO_TIMEOUT); + if (nn == 0) break; + if (nn < 0 || st_write(c->stfd_, buf, nn, TCP_ECHO_TIMEOUT) != nn) { + ok = false; + break; + } + bytes += nn; + } + + if (ok) t->nn_echoed_++; + else t->echo_errors_++; + t->bytes_echoed_ += bytes; + + st_netfd_close(c->stfd_); + delete c; + return NULL; +} + +// Accepts every client and starts an echo coroutine for each, then sends every client the go byte once all are +// connected, and waits for the echo coroutines. +static void* tcp_echo_server(void* arg) +{ + TcpEchoTest* t = (TcpEchoTest*)arg; + std::vector sessions; + std::vector conns; + + for (int i = 0; i < t->nn_clients_; i++) { + st_netfd_t client = st_accept(t->listener_, NULL, NULL, TCP_ECHO_TIMEOUT); + if (!client) break; + + TcpEchoConn* c = new TcpEchoConn(); + c->t_ = t; + c->stfd_ = client; + c->id_ = i; + st_thread_t trd = st_thread_create(tcp_echo_session, c, 1, TCP_ECHO_STACK); + if (!trd) { + st_netfd_close(client); + delete c; + break; + } + sessions.push_back(trd); + conns.push_back(client); + t->accepted_++; + } + int accepted = t->accepted_; + + // Each echo coroutine closes its connection only after its client got the go byte and closed, so a connection + // here is still open when its go byte is sent. Without the go byte, the clients time out, and so do the echo + // coroutines. + int r0 = 0; + while (accepted == t->nn_clients_ && t->connected_ < t->nn_clients_ && r0 == 0) { + r0 = st_cond_timedwait(t->all_connected_, TCP_ECHO_TIMEOUT); + } + int nn_go = 0; + for (size_t i = 0; r0 == 0 && accepted == t->nn_clients_ && i < conns.size(); i++) { + if (st_write(conns[i], "G", 1, TCP_ECHO_TIMEOUT) == 1) nn_go++; + } + + for (size_t i = 0; i < sessions.size(); i++) { + st_thread_join(sessions[i], NULL); + } + + ST_ASSERT_ERROR(accepted != t->nn_clients_, accepted, "Accept every client"); + ST_ASSERT_ERROR(r0, r0, "Wait for every client to connect"); + ST_ASSERT_ERROR(nn_go != t->nn_clients_, nn_go, "Send every go byte"); + return NULL; +} + +// Connects, waits in a read for the go byte, then sends its own messages and checks each echo. +static void* tcp_echo_client(void* arg) +{ + TcpEchoConn* c = (TcpEchoConn*)arg; + TcpEchoTest* t = c->t_; + int id = c->id_; + delete c; + + int fd = -1; + st_netfd_t stfd = NULL; + StFdCleanup(fd, stfd); + + fd = (int)socket(AF_INET, SOCK_STREAM, 0); + ST_ASSERT_ERROR(fd < 0, fd, "Create socket"); + + stfd = st_netfd_open_socket(fd); + ST_ASSERT_ERROR(!stfd, fd, "Open ST socket"); + + int r0 = st_connect(stfd, (const sockaddr*)&t->addr_, sizeof(t->addr_), TCP_ECHO_TIMEOUT); + ST_ASSERT_ERROR(r0, r0, "Connect to server"); + + t->connected_++; + if (t->connected_ == t->nn_clients_) st_cond_signal(t->all_connected_); + + char go = 0; + ssize_t nn = st_read_fully(stfd, &go, 1, TCP_ECHO_TIMEOUT); + ST_ASSERT_ERROR(nn != 1 || go != 'G', (int)nn, "Read the go byte"); + t->nn_ready_++; + + char msg[TCP_ECHO_SIZE], echo[TCP_ECHO_SIZE]; + for (int round = 0; round < TCP_ECHO_ROUNDS; round++) { + tcp_echo_message(msg, id, round); + + // Send the message in two parts, so the server may read it split, and check the echo as a whole. + int half = TCP_ECHO_SIZE / 2; + ST_ASSERT_ERROR(st_write(stfd, msg, half, TCP_ECHO_TIMEOUT) != half, round, "Write first half"); + st_usleep(0); + ST_ASSERT_ERROR(st_write(stfd, msg + half, TCP_ECHO_SIZE - half, TCP_ECHO_TIMEOUT) != TCP_ECHO_SIZE - half, + round, "Write second half"); + + nn = st_read_fully(stfd, echo, TCP_ECHO_SIZE, TCP_ECHO_TIMEOUT); + ST_ASSERT_ERROR(nn != TCP_ECHO_SIZE, (int)nn, "Read the echo"); + ST_ASSERT_ERROR(memcmp(msg, echo, TCP_ECHO_SIZE) != 0, round, "Echo matches the message"); + } + + return NULL; +} + +// How many clients the descriptor limit allows, with room for the other descriptors of the process: each client +// takes two descriptors, its own and the server's end. On POSIX, raise the soft limit toward the hard limit first. +static int tcp_echo_max_clients() +{ +#ifndef _WIN32 + rlim_t want = 2 * TCP_ECHO_CLIENTS + 256; + struct rlimit rlim; + if (getrlimit(RLIMIT_NOFILE, &rlim) < 0) return 0; + + if (rlim.rlim_cur != RLIM_INFINITY && rlim.rlim_cur < want) { + rlim.rlim_cur = (rlim.rlim_max == RLIM_INFINITY || rlim.rlim_max > want) ? want : rlim.rlim_max; + setrlimit(RLIMIT_NOFILE, &rlim); + if (getrlimit(RLIMIT_NOFILE, &rlim) < 0) return 0; + } + if (rlim.rlim_cur != RLIM_INFINITY && rlim.rlim_cur < want) { + return rlim.rlim_cur > 256 ? (int)((rlim.rlim_cur - 256) / 2) : 0; + } +#endif + return TCP_ECHO_CLIENTS; +} + +VOID TEST(TcpTest, EchoManyConcurrentConnections) +{ + TcpEchoTest t; + t.nn_clients_ = tcp_echo_max_clients(); + // Only a system with a very low hard descriptor limit runs fewer clients; Windows, Linux, and macOS run them all. + ASSERT_GT(t.nn_clients_, 0); + if (t.nn_clients_ < TCP_ECHO_CLIENTS) { + printf("The descriptor limit allows only %d of %d echo clients\n", t.nn_clients_, TCP_ECHO_CLIENTS); + } + + // The listener, on a loopback port the OS picks, with the largest backlog. + int fd = (int)socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(fd, 0); + t.addr_.sin_family = AF_INET; + t.addr_.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + t.addr_.sin_port = 0; + socklen_t addrlen = sizeof(t.addr_); + if (::bind(fd, (const sockaddr*)&t.addr_, addrlen) != 0 || ::listen(fd, SOMAXCONN) != 0 || + getsockname(fd, (sockaddr*)&t.addr_, &addrlen) != 0) { + st_utest_close(fd); + FAIL() << "Listen on loopback, errno=" << errno; + } + t.listener_ = st_netfd_open_socket(fd); + if (!t.listener_) { + st_utest_close(fd); + FAIL() << "Open ST listener, errno=" << errno; + } + t.all_connected_ = st_cond_new(); + ASSERT_TRUE(t.all_connected_ != NULL); + + // Refresh ST's clock, which an earlier test may leave stale by blocking outside ST. + st_thread_yield(); + + st_thread_t svr = st_thread_create(tcp_echo_server, &t, 1, TCP_ECHO_STACK); + ASSERT_TRUE(svr != NULL); + + std::vector clients; + for (int i = 0; i < t.nn_clients_; i++) { + TcpEchoConn* c = new TcpEchoConn(); + c->t_ = &t; + c->stfd_ = NULL; + c->id_ = i; + st_thread_t trd = st_thread_create(tcp_echo_client, c, 1, TCP_ECHO_STACK); + if (!trd) { + delete c; + break; + } + clients.push_back(trd); + } + EXPECT_EQ(t.nn_clients_, (int)clients.size()); + + int nn_failed = 0; + for (size_t i = 0; i < clients.size(); i++) { + ST_COROUTINE_JOIN(clients[i], r0); + // Print only the first few failures. + if (r0 && nn_failed++ < 5) ADD_FAILURE() << "client " << i << ": " << r0; + } + EXPECT_EQ(0, nn_failed); + + // The server waits for its echo coroutines, which end when their clients close. + ST_COROUTINE_JOIN(svr, r1); + ST_EXPECT_SUCCESS(r1); + + // Every connection was open at the same time, and every byte came back. + EXPECT_EQ(t.nn_clients_, t.connected_); + EXPECT_EQ(t.nn_clients_, t.accepted_); + EXPECT_EQ(t.nn_clients_, t.nn_ready_); + EXPECT_EQ(t.nn_clients_, t.nn_echoed_); + EXPECT_EQ(0, t.echo_errors_); + EXPECT_EQ((int64_t)t.nn_clients_ * TCP_ECHO_ROUNDS * TCP_ECHO_SIZE, t.bytes_echoed_); + + st_cond_destroy(t.all_connected_); + st_netfd_close(t.listener_); +} + From 4f7e35a60c84244f6892525ede9b7e3d39cd715d Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 09:19:39 -0400 Subject: [PATCH 22/37] Claude: Check MSVC AddressSanitizer with MD_ASAN on native Windows. The MSVC asan runtime (clang_rt.asan_dynamic, MSVC 14.44) exports __sanitizer_start_switch_fiber and __sanitizer_finish_switch_fiber, so MD_ASAN works with no library change: the whole utest passes on Windows built with -DMD_ASAN -fsanitize=address, with the library at -Od and -O2. Without MD_ASAN, ASan ignores __asan_handle_no_return on coroutine stacks and the ExceptionTest suite fails with a false stack-buffer-underflow. The Makefile documents the Windows asan command. Use-after-return (-fsanitize-address-use-after-return) is not usable: MSVC 14.44 reports false positives with it even in a small program without ST. PrimordialStackTest.DescribesTheMainThreadStack now runs on Windows too, reading the main thread's stack bounds with GetCurrentThreadStackLimits. --------- Co-Authored-By: Claude Opus 5.5 --- Makefile | 3 +++ utest/st_utest_stack.cpp | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 175c652..7a0a816 100644 --- a/Makefile +++ b/Makefile @@ -226,6 +226,9 @@ endif # or enable support for asan: # make EXTRA_CFLAGS="-DMD_ASAN -fsanitize=address -fno-omit-frame-pointer" # +# or enable support for asan on native Windows, where cl has no -fno-omit-frame-pointer: +# make win64-debug EXTRA_CFLAGS="-DMD_ASAN -fsanitize=address" +# # or to disable the clock_gettime for MacOS before 10.12, see https://github.com/ossrs/srs/issues/3978 # make EXTRA_CFLAGS=-DMD_OSX_NO_CLOCK_GETTIME # diff --git a/utest/st_utest_stack.cpp b/utest/st_utest_stack.cpp index ba106fb..53a04f8 100644 --- a/utest/st_utest_stack.cpp +++ b/utest/st_utest_stack.cpp @@ -300,11 +300,16 @@ extern void* _st_primordial_stack_bottom; extern size_t _st_primordial_stack_size; } -#ifndef _WIN32 // POSIX only: pthread stack attributes // The stack of the calling OS thread, from its lowest to its highest address. static void stack_test_thread_stack(char** bottom, char** top) { -#ifdef __APPLE__ +#if defined(_WIN32) + // The whole reserved stack, from the TIB's DeallocationStack up to its StackBase. + ULONG_PTR low = 0, high = 0; + GetCurrentThreadStackLimits(&low, &high); + *bottom = (char*)low; + *top = (char*)high; +#elif defined(__APPLE__) pthread_t self = pthread_self(); *top = (char*)pthread_get_stackaddr_np(self); *bottom = *top - pthread_get_stacksize_np(self); @@ -366,4 +371,3 @@ VOID TEST(PrimordialStackTest, DescribesTheMainThreadStack) st_set_primordial_stack((char*)saved_bottom + saved_size, saved_bottom); } -#endif From 0507ba7ffc8fa6b5b822249911f2f449152ecebf Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 09:28:05 -0400 Subject: [PATCH 23/37] Claude: Port the porting, helloworld, and verify tools to MSVC. Each tool Makefile detects native Windows by uname -s (MINGW64_NT-* in Git Bash, MSYS_NT-* from PowerShell) and builds with cl -W4, -Fe for the executable, a .exe suffix, and win64-debug for the library. The POSIX commands are unchanged (make -n on Linux and Darwin gives the same output). porting prints _WIN64, _M_X64, _M_ARM64, and _MSC_VER, and includes execinfo.h only off Windows. The CMake MSVC build adds the three tools too. --------- Co-Authored-By: Claude Opus 5.5 --- cmake/CMakeLists.txt | 12 ++++++++++-- tools/helloworld/Makefile | 25 ++++++++++++++++++++++--- tools/porting/Makefile | 23 ++++++++++++++++++++--- tools/porting/porting.c | 14 ++++++++++++++ tools/verify/Makefile | 25 ++++++++++++++++++++++--- 5 files changed, 88 insertions(+), 11 deletions(-) diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index b3a9fce..61188fe 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -21,8 +21,8 @@ set (CMAKE_CXX_STANDARD 11) # Native Windows x64 with MSVC, the WIN64 platform of ../Makefile, which stays the main build. This gives a Visual # Studio project of the utest for debugging, for example: # cmake -S cmake -B build -G "Visual Studio 17 2022" -A x64 -# The tools are not built on Windows yet. No bash, and the source tree is not written: st.h is copied to the build -# directory, so the obj junction made by make is left alone. +# No bash, and the source tree is not written: st.h is copied to the build directory, so the obj junction made by +# make is left alone. if (CMAKE_HOST_WIN32 AND NOT MSVC) message(FATAL_ERROR "Only MSVC is supported on Windows, for example the Visual Studio generator") endif() @@ -57,6 +57,14 @@ if (MSVC) TARGET_LINK_LIBRARIES(st_utest gtest gtest_main ws2_32) set_property(DIRECTORY ${PROJECT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT st_utest) + # The tools ported to MSVC so far. + foreach (TOOL helloworld porting verify) + set(ST_TOOL_SOURCE_FILES ${SOURCE_FILES}) + AUX_SOURCE_DIRECTORY(${ST_DIR}/tools/${TOOL} ST_TOOL_SOURCE_FILES) + ADD_EXECUTABLE(st_${TOOL} ${ST_TOOL_SOURCE_FILES}) + TARGET_LINK_LIBRARIES(st_${TOOL} ws2_32) + endforeach () + MESSAGE(STATUS "@see https://github.com/ossrs/state-threads#usage") return() endif() diff --git a/tools/helloworld/Makefile b/tools/helloworld/Makefile index b4643c6..9c810ea 100644 --- a/tools/helloworld/Makefile +++ b/tools/helloworld/Makefile @@ -12,12 +12,31 @@ CPU_ARCHS += $(shell g++ -dM -E - #include +#ifndef _WIN32 #include +#endif #include void bar() @@ -30,6 +32,9 @@ int main(int argc, char** argv) #ifdef _WIN32 printf("_WIN32: %d\n", _WIN32); #endif +#ifdef _WIN64 + printf("_WIN64: %d\n", _WIN64); +#endif printf("\nCPU specs:\n"); #ifdef __mips__ @@ -54,11 +59,20 @@ int main(int argc, char** argv) #ifdef __aarch64__ printf("__aarch64__: %d\n", __aarch64__); #endif +#ifdef _M_X64 + printf("_M_X64: %d\n", _M_X64); +#endif +#ifdef _M_ARM64 + printf("_M_ARM64: %d\n", _M_ARM64); +#endif printf("\nCompiler specs:\n"); #ifdef __GLIBC__ printf("__GLIBC__: %d\n", __GLIBC__); #endif +#ifdef _MSC_VER + printf("_MSC_VER: %d\n", _MSC_VER); +#endif printf("\nCalling conventions:\n"); foo(); diff --git a/tools/verify/Makefile b/tools/verify/Makefile index e5acc4d..de59b30 100644 --- a/tools/verify/Makefile +++ b/tools/verify/Makefile @@ -12,12 +12,31 @@ CPU_ARCHS += $(shell g++ -dM -E - Date: Sat, 3 Oct 2026 09:33:45 -0400 Subject: [PATCH 24/37] Claude: Document native Windows x64 with MSVC. v1.9.2 README.md: build native Windows x64 with GNU make in the MSVC environment (x64 Native Tools Command Prompt, then Git Bash), with the win64-debug and win64-optimized targets, and with MSVC ASAN and MD_ASAN; run the utest with win64-debug-utest, and generate a Visual Studio solution with CMake. The old "Windows native build is unsupported" remark is gone, and the Cygwin build is replaced by a remark that Cygwin64 is deprecated, use native Windows. docs/notes.html: native Windows porting notes: the WIN64 platform, the MASM context switch with the TIB stack bounds, VirtualAlloc stacks, QueryPerformanceCounter, the WSAPoll event system, Winsock I/O with int descriptors and errno mapping, and what is unsupported or differs (st_open, SIGPIPE, rlimit, all-or-nothing sends, refused connect delay, no /CETCOMPAT, ASAN use-after-return). Bump the version to 1.9.2 in Makefile, public.h, and st.spec, with a CHANGELOG.md entry for the Windows port. --------- Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + Makefile | 2 +- README.md | 59 +++++++++++++++++++++++++++++++++++++++--- docs/notes.html | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ public.h | 4 +-- st.spec | 2 +- 6 files changed, 129 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e9526..6b8402e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ The changelog for the [srs](https://github.com/ossrs/state-threads/tree/srs) bra ## ST 1.9 Changelog +* 2026-10-03, Windows: Support native Windows x64 with MSVC, MASM, and WSAPoll, built by GNU make. v1.9.2 * 2026-10-03, Tools: Add integration tools for every public API, run by auto/tools.sh in CI, [#41](https://github.com/ossrs/state-threads/pull/41). v1.9.1 * 2026-09-30, Changelog: Move the change list from README to CHANGELOG.md, and add the patch version. v1.9.0 diff --git a/Makefile b/Makefile index 7a0a816..6f81dc2 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ # GPL. # This is the full version of the libst library - modify carefully -VERSION = 1.9.1 +VERSION = 1.9.2 ########################## # Supported OSes: diff --git a/README.md b/README.md index dd25054..bc877b5 100644 --- a/README.md +++ b/README.md @@ -86,13 +86,42 @@ Get code: git clone -b srs https://github.com/ossrs/state-threads.git ``` -For Cygwin(Windows): +Native Windows x64 builds with the Visual Studio (MSVC) toolchain, `cl`, `ml64`, and `lib`, through GNU make +and the same Makefile. There is no Cygwin, MinGW, WSL, or POSIX emulation layer: GNU make and Git Bash only +run the build, and the output is the static library `obj/libst.a` built by MSVC. It needs Windows 10 1809 or +Windows Server 2019 or later, x64. +Install the build tools: + +* Visual Studio 2022, or its Build Tools, with the MSVC x64 tools (the "Desktop development with C++" workload). +* [Git for Windows](https://git-scm.com/download/win), for Git Bash. +* GNU make, for example `winget install ezwinports.make` or `choco install make`. + +Open the `x64 Native Tools Command Prompt for VS 2022`, which runs `vcvars64.bat` and puts `cl`, `ml64`, and +`lib` on `PATH`, then start Git Bash in it: + +``` +"C:\Program Files\Git\bin\bash.exe" ``` -make cygwin64-debug + +For Windows, build the debug or the optimized library: + +```bash +make win64-debug +make win64-optimized ``` -> Remark: Windows native build is unsupported right now. +> Note: Programs that include `st.h` must include it before `windows.h`, or define `WIN32_LEAN_AND_MEAN`. The +> library links `ws2_32.lib` by itself. For what works differently on Windows, read the +> [porting notes](docs/notes.html#windows). + +Windows with ASAN(Google Address Sanitizer), where cl has no `-fno-omit-frame-pointer`: + +```bash +make win64-debug EXTRA_CFLAGS="-DMD_ASAN -fsanitize=address" +``` + +> Remark: Cygwin64 is deprecated, do not use it. Use native Windows, as above. ## Branch SRS @@ -148,6 +177,30 @@ To make ST with utest and run it: make darwin-debug-utest && ./obj/st_utest ``` +## Windows: UTest + +> Note: We use [Google test](https://github.com/google/googletest/releases/tag/release-1.11.0) in `utest/gtest-fit`. + +To make ST with utest and run it, in Git Bash with the MSVC environment (see [Windows: Usage](#windows-usage)): + +```bash +make win64-debug-utest && ./obj/st_utest.exe +``` + +The tests that need POSIX features (fork, signals, pipes, files) are not built on Windows, and a few are +skipped with a reason, for example the write tests that need a partial send, which Winsock never does. + +To debug in Visual Studio, generate a solution with CMake, which comes with Visual Studio, from the +`x64 Native Tools Command Prompt for VS 2022`, then open `C:\st-build\st.sln`: + +``` +cmake -S cmake -B C:\st-build -G "Visual Studio 17 2022" -A x64 +cmake --build C:\st-build --config Debug --target st_utest +``` + +> Note: Use a short build directory, because MSBuild fails when its paths are too long. The Makefile stays the +> main build, and CMake also builds the `helloworld`, `porting`, and `verify` tools. + ## Linux: Coverage > Note: We use [Google test](https://github.com/google/googletest/releases/tag/release-1.11.0) in `utest/gtest-fit`. diff --git a/docs/notes.html b/docs/notes.html index 5a24369..64549cb 100644 --- a/docs/notes.html +++ b/docs/notes.html @@ -102,6 +102,74 @@

Porting

  • NetBSD on x86, Alpha, SPARC, and VAX
  • MacOS X (Darwin) on PowerPC (32 bit) and Intel (both 32 and 64 bit) [universal]
  • Cygwin
  • +
  • Windows x64 (native, with MSVC; see Native Windows)
  • + +

    + + +

    Native Windows

    +The library builds on Windows 10 1809 or Windows Server 2019 and later, x64, +with the Visual Studio (MSVC) toolchain and no POSIX emulation layer: the +win64-debug, win64-optimized, and win64-debug-utest +Makefile targets run cl, ml64, and lib and build a +static library only. The platform is WIN64 (OS=WIN64 in the +Makefile), separate from CYGWIN64. The public API is unchanged, and +the platform-specific parts are: +

    +

      +
    • Thread context: md_win64.asm (MASM) saves every register +the Win64 ABI calls callee-saved: rbx, rbp, rdi, rsi, r12-r15, and xmm6-xmm15, +plus MXCSR and the x87 control word. It also switches the stack bounds in the +thread information block (TIB), without which C++ exceptions, crash dumps, and +debugger stack walks fail on a thread stack. MSVC long is 32 bits, so +the jmp_buf slots are long long. A new thread starts in the +assembly function _st_md_thread_start, which does not depend on what +the compiler keeps in registers. Do not link programs with +/CETCOMPAT: the context switch is not compatible with CET shadow +stacks.
    • +

      +

    • Stacks: VirtualAlloc, with PAGE_NOACCESS red zones +in DEBUG builds. With MALLOC_STACK there are no red +zones.
    • +

      +

    • Time: QueryPerformanceCounter.
    • +

      +

    • Event system: WSAPoll, for ST_EVENTSYS_DEFAULT, +ST_EVENTSYS_SELECT, and ST_EVENTSYS_ALT. It does not support +POLLPRI, so st_poll() fails with EINVAL for it, as +with kqueue. Timeouts have millisecond resolution and follow the system timer +tick (about 15.6 ms by default).
    • +

      +

    • I/O: sockets only, through Winsock. st_init() calls +WSAStartup(), and st_destroy() calls +WSACleanup(), once per OS thread. File descriptors stay int +in the API: a SOCKET has only 32 significant bits, so it can be cast +to int and back. Winsock errors are mapped to errno, for +example WSAEWOULDBLOCK to EAGAIN. Control data of +st_sendmsg() and st_recvmsg() uses the Winsock layout +(WSA_CMSG_*).
    • +
    +

    +Some things are not supported or behave differently on Windows: +

    +

      +
    • st_open() fails with ENOSYS: there is no polling of +files or FIFOs. There is no SIGPIPE to ignore, and no +RLIMIT_NOFILE: st_getfdlimit() returns 2^24, the per-process +handle limit.
    • +
    • A non-blocking send on a TCP socket is all or nothing: Winsock takes a whole +buffer while the send buffer is not full, even one larger than +SO_SNDBUF, and never sends part of it. So a large write may complete +at once, and the next write waits.
    • +
    • A connect to a loopback port with no listener fails with +ECONNREFUSED after about 2 seconds, because Windows retries the +connection; a shorter timeout gives ETIME.
    • +
    • On a UDP socket, an ICMP port unreachable makes the next receive fail with +ECONNREFUSED. A datagram larger than the buffer returns the bytes that +fit, as on POSIX.
    • +
    • MSVC AddressSanitizer works with MD_ASAN, but not with +-fsanitize-address-use-after-return, which gives false reports with +MSVC 14.44.

    diff --git a/public.h b/public.h index 9e446a0..b3b8c31 100644 --- a/public.h +++ b/public.h @@ -104,10 +104,10 @@ struct msghdr { #include #endif -#define ST_VERSION "1.9.1" +#define ST_VERSION "1.9.2" #define ST_VERSION_MAJOR 1 #define ST_VERSION_MINOR 9 -#define ST_VERSION_PATCH 1 +#define ST_VERSION_PATCH 2 /* Undefine this to remove the context switch callback feature. */ #define ST_SWITCH_CB diff --git a/st.spec b/st.spec index 662f4c8..92426f5 100644 --- a/st.spec +++ b/st.spec @@ -1,6 +1,6 @@ Summary: State Threads Library Name: st -Version: 1.9.1 +Version: 1.9.2 Release: 1 Copyright: MPL 1.2 or GPL 2+ Packager: Wesley W. Terpstra From 5c54e1c9ee1b79e45a104920f6b4dc5997cba418 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 15:15:43 -0400 Subject: [PATCH 25/37] Claude: Run the integration tools on native Windows with auto/tools.sh. auto/tools.sh builds win64-debug and runs .exe under Git Bash on Windows (uname -s MINGW* or MSYS*), and takes the tools to run as arguments, every tool by default. Every tool Makefile gets the MSVC block of helloworld and verify, tool.h builds with cl (Winsock from st.h, closesocket, wsapoll names), porting accepts Windows x64, and verify.c drops a C4312 cast. The MSVC tool outputs are ignored, and .sh files keep LF so WSL runs a Windows checkout. On Windows, helloworld, porting, verify, sync, and thread pass; the others do not build yet. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 ++ .gitignore | 4 ++++ auto/tools.sh | 27 +++++++++++++++++++++++---- tools/backtrace/Makefile | 26 +++++++++++++++++++++++--- tools/key/Makefile | 25 ++++++++++++++++++++++--- tools/lifecycle/Makefile | 26 +++++++++++++++++++++++--- tools/pipe/Makefile | 25 ++++++++++++++++++++++--- tools/poll/Makefile | 25 ++++++++++++++++++++++--- tools/porting/porting.c | 4 ++++ tools/stress/Makefile | 25 ++++++++++++++++++++++--- tools/sync/Makefile | 25 ++++++++++++++++++++++--- tools/tcp/Makefile | 25 ++++++++++++++++++++++--- tools/thread/Makefile | 25 ++++++++++++++++++++++--- tools/time/Makefile | 25 ++++++++++++++++++++++--- tools/tool.h | 36 +++++++++++++++++++++++++++++------- tools/udp/Makefile | 25 ++++++++++++++++++++++--- tools/unix/Makefile | 25 ++++++++++++++++++++++--- tools/verify/verify.c | 3 ++- 18 files changed, 330 insertions(+), 48 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b4ebd3f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts keep LF on every checkout, so bash in WSL can run a Windows checkout. +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index a17bdef..ee41297 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ LINUX_*_DBG CYGWIN64_*_DBG WIN64_*_DBG WIN64_*_OPT +tools/*/*.exe +tools/*/*.obj +tools/*/*.ilk +tools/*/*.pdb obj st.pc .idea diff --git a/auto/tools.sh b/auto/tools.sh index f2a644e..f46be02 100755 --- a/auto/tools.sh +++ b/auto/tools.sh @@ -8,18 +8,37 @@ # # Examples: # ./auto/tools.sh +# ./auto/tools.sh tcp udp # EXTRA_CFLAGS=-DMALLOC_STACK ./auto/tools.sh # EXTRA_CFLAGS="-DMALLOC_STACK -DMD_ASAN -fsanitize=address -fno-omit-frame-pointer" \ # LDFLAGS=-fsanitize=address ./auto/tools.sh # +# The arguments name the tools to run, every folder in tools/ by default. # Each tool reads ST_TOOL_EVENTSYS, select or alt. It prints " OK" # per run, or "FAILED " with the output, and exits 1 at the first failure. +# +# On native Windows, run it from Git Bash with the MSVC environment on PATH, where +# uname -s is MINGW64_NT-* or MSYS_NT-*: it builds win64-debug and runs .exe. cd "$(dirname "$0")/.." || exit 1 ST_TARGET=linux-debug -if [[ $(uname -s) == Darwin ]]; then - ST_TARGET=darwin-debug +EXE= +case $(uname -s) in + Darwin) ST_TARGET=darwin-debug ;; + MINGW*|MSYS*) ST_TARGET=win64-debug; EXE=.exe ;; +esac + +tool_dirs=() +for name in "$@"; do + if [[ ! -f tools/$name/Makefile ]]; then + echo "FAILED no tool $name in tools/" + exit 1 + fi + tool_dirs+=("tools/$name/") +done +if [[ ${#tool_dirs[@]} == 0 ]]; then + tool_dirs=(tools/*/) fi echo "Build ST with make -B $ST_TARGET EXTRA_CFLAGS=\"$EXTRA_CFLAGS\"" @@ -30,7 +49,7 @@ if ! out=$(make -B $ST_TARGET EXTRA_CFLAGS="$EXTRA_CFLAGS" 2>&1); then exit 1 fi -for dir in tools/*/; do +for dir in "${tool_dirs[@]}"; do name=$(basename "$dir") # -W relinks the tool, because the binaries are shared across platforms and builds. if ! out=$(make -C "$dir" -W "$name.c" LDFLAGS="$LDFLAGS" 2>&1); then @@ -40,7 +59,7 @@ for dir in tools/*/; do fi for eventsys in select alt; do - if ! out=$(cd "$dir" && ST_TOOL_EVENTSYS=$eventsys "./$name" 2>&1); then + if ! out=$(cd "$dir" && ST_TOOL_EVENTSYS=$eventsys "./$name$EXE" 2>&1); then echo "$out" echo "FAILED $name $eventsys" exit 1 diff --git a/tools/backtrace/Makefile b/tools/backtrace/Makefile index 4c9aca3..af249a5 100644 --- a/tools/backtrace/Makefile +++ b/tools/backtrace/Makefile @@ -12,12 +12,32 @@ CPU_ARCHS += $(shell g++ -dM -E - #include #include +#ifndef _WIN32 #include #include #include #include +#endif +/* On native Windows, st.h brings the Winsock 2 headers. */ #include /* Print the failing check and its line, then return 1 from the caller. */ @@ -34,6 +37,9 @@ */ static inline int tool_eventsys(void) { +#ifdef _MSC_VER +#pragma warning(suppress: 4996) /* getenv is not deprecated for this use. */ +#endif const char *v = getenv("ST_TOOL_EVENTSYS"); if (!v || !strcmp(v, "select")) { return ST_EVENTSYS_SELECT; @@ -47,6 +53,11 @@ static inline int tool_eventsys(void) /* The name st_get_eventsys_name() reports for an event system. */ static inline const char *tool_eventsys_name(int eventsys) { +#if defined(_WIN32) + /* Both event systems are WSAPoll on native Windows. */ + (void)eventsys; + return "wsapoll"; +#else if (eventsys == ST_EVENTSYS_SELECT) { return "select"; } @@ -55,6 +66,7 @@ static inline const char *tool_eventsys_name(int eventsys) #else return "epoll"; #endif +#endif } /* @@ -72,6 +84,16 @@ static inline int tool_init(void) return 0; } +/* Close a socket that no st_netfd_t owns: closesocket on native Windows, close elsewhere. */ +static inline void tool_close_socket(int fd) +{ +#if defined(_WIN32) + closesocket(fd); +#else + close(fd); +#endif +} + /* Fill addr with the loopback address of family, 127.0.0.1 or ::1, and port. */ static inline socklen_t tool_loopback(int family, int port, struct sockaddr_storage *addr) { @@ -79,14 +101,14 @@ static inline socklen_t tool_loopback(int family, int port, struct sockaddr_stor if (family == AF_INET6) { struct sockaddr_in6 *a = (struct sockaddr_in6 *)addr; a->sin6_family = AF_INET6; - a->sin6_port = htons(port); + a->sin6_port = htons((unsigned short)port); a->sin6_addr = in6addr_loopback; return sizeof(*a); } struct sockaddr_in *a = (struct sockaddr_in *)addr; a->sin_family = AF_INET; - a->sin_port = htons(port); + a->sin_port = htons((unsigned short)port); a->sin_addr.s_addr = htonl(INADDR_LOOPBACK); return sizeof(*a); } @@ -101,7 +123,7 @@ static inline st_netfd_t tool_listen(int family, int backlog, int *port) struct sockaddr_storage addr; socklen_t len = tool_loopback(family, 0, &addr); - int fd = socket(family, SOCK_STREAM, 0); + int fd = (int)socket(family, SOCK_STREAM, 0); if (fd < 0) { return NULL; } @@ -109,7 +131,7 @@ static inline st_netfd_t tool_listen(int family, int backlog, int *port) if (bind(fd, (struct sockaddr *)&addr, len) < 0 || listen(fd, backlog) < 0 || getsockname(fd, (struct sockaddr *)&addr, &len) < 0) { int err = errno; - close(fd); + tool_close_socket(fd); errno = err; return NULL; } @@ -117,7 +139,7 @@ static inline st_netfd_t tool_listen(int family, int backlog, int *port) st_netfd_t stfd = st_netfd_open_socket(fd); if (!stfd) { int err = errno; - close(fd); + tool_close_socket(fd); errno = err; return NULL; } @@ -139,7 +161,7 @@ static inline st_netfd_t tool_connect(int family, int port, st_utime_t timeout) struct sockaddr_storage addr; socklen_t len = tool_loopback(family, port, &addr); - int fd = socket(family, SOCK_STREAM, 0); + int fd = (int)socket(family, SOCK_STREAM, 0); if (fd < 0) { return NULL; } @@ -147,7 +169,7 @@ static inline st_netfd_t tool_connect(int family, int port, st_utime_t timeout) st_netfd_t stfd = st_netfd_open_socket(fd); if (!stfd) { int err = errno; - close(fd); + tool_close_socket(fd); errno = err; return NULL; } diff --git a/tools/udp/Makefile b/tools/udp/Makefile index d6dcc56..2986733 100644 --- a/tools/udp/Makefile +++ b/tools/udp/Makefile @@ -12,12 +12,31 @@ CPU_ARCHS += $(shell g++ -dM -E - #include #include @@ -37,7 +38,7 @@ void* start(void* arg) r0 = st_mutex_unlock(lock); printf("ST: thread unlock\n"); - return (void*)(long)r0; + return (void*)(intptr_t)r0; } int main(int argc, char** argv) From 4572045e107d2136fd274bdb1fa8f7b696e88753 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 15:24:14 -0400 Subject: [PATCH 26/37] Claude: Run the time, key, and lifecycle tools on native Windows. time: Windows has no fork, so the custom clock child is the tool run again with the argument custom-clock (_spawnl, waited for its exit code), and the real clock is QueryPerformanceCounter instead of clock_gettime. key: the key array is on the heap, because MSVC has no variable length arrays. lifecycle: the primordial stack comes from GetCurrentThreadStackLimits, and as Windows has no RLIMIT_NOFILE, the limit check is that st_getfdlimit reports the per-process handle limit 2^24, as documented. POSIX code is unchanged except the heap key array. On Windows, time, key, lifecycle, thread, and sync pass with select and alt. --------- Co-Authored-By: Claude Opus 5.5 --- tools/key/key.c | 6 ++++-- tools/lifecycle/lifecycle.c | 31 ++++++++++++++++++++++++++++++- tools/time/time.c | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/tools/key/key.c b/tools/key/key.c index e696dc9..36256a4 100644 --- a/tools/key/key.c +++ b/tools/key/key.c @@ -305,8 +305,9 @@ static int limit(void) int limit = st_key_getlimit(); CHECK(limit > 2); - /* The keys made so far are key and plain. */ - int keys[limit]; + /* The keys made so far are key and plain. On the heap, because MSVC has no variable length arrays. */ + int *keys = (int *)malloc(limit * sizeof(int)); + CHECK(keys); keys[0] = key; keys[1] = plain; for (int i = 2; i < limit; i++) { @@ -332,6 +333,7 @@ static int limit(void) CHECK(st_thread_getspecific(keys[i]) == &keys[i]); CHECK(st_thread_setspecific(keys[i], NULL) == 0); } + free(keys); return 0; } diff --git a/tools/lifecycle/lifecycle.c b/tools/lifecycle/lifecycle.c index 7d8a4b2..7824a34 100644 --- a/tools/lifecycle/lifecycle.c +++ b/tools/lifecycle/lifecycle.c @@ -10,9 +10,11 @@ /* For pthread_getattr_np on Linux. */ #define _GNU_SOURCE +#ifndef _WIN32 #include #include #include +#endif #include "tool.h" @@ -66,7 +68,13 @@ static int set_primordial_stack(void) { char *top = NULL; size_t size = 0; -#ifdef __APPLE__ +#if defined(_WIN32) + /* The whole reserved stack, from the TIB's DeallocationStack up to its StackBase. */ + ULONG_PTR low = 0, high = 0; + GetCurrentThreadStackLimits(&low, &high); + top = (char *)high; + size = (size_t)(high - low); +#elif defined(__APPLE__) pthread_t self = pthread_self(); top = (char *)pthread_get_stackaddr_np(self); size = pthread_get_stacksize_np(self); @@ -83,6 +91,20 @@ static int set_primordial_stack(void) return 0; } +#if defined(_WIN32) +/* + * Windows has no RLIMIT_NOFILE to read or raise: _st_io_init reports the + * per-process handle limit, 2^24, as WSAPoll has no limit of its own. + */ +static int check_fdlimit(void) +{ + int limit = st_getfdlimit(); + CHECK(limit == (1 << 24)); + + printf("ST: fdlimit=%d\n", limit); + return 0; +} +#else /* The descriptor limit _st_io_init sets from the limit before st_init. */ static int check_fdlimit(int eventsys, const struct rlimit *before) { @@ -111,6 +133,7 @@ static int check_fdlimit(int eventsys, const struct rlimit *before) (unsigned long long)before->rlim_cur, (unsigned long long)before->rlim_max); return 0; } +#endif /* Block four coroutines, then interrupt and join them; each must fail with EINTR at once. */ static int interrupt_all(void) @@ -182,8 +205,10 @@ int main(int argc, char **argv) CHECK(set_primordial_stack() == 0); +#ifndef _WIN32 struct rlimit before; CHECK(getrlimit(RLIMIT_NOFILE, &before) == 0); +#endif CHECK(st_init() == 0); printf("ST: init ok, eventsys=%s\n", st_get_eventsys_name()); @@ -198,7 +223,11 @@ int main(int argc, char **argv) CHECK(st_init() == 0); CHECK(st_get_eventsys() == eventsys); +#if defined(_WIN32) + CHECK(check_fdlimit() == 0); +#else CHECK(check_fdlimit(eventsys, &before) == 0); +#endif CHECK(interrupt_all() == 0); st_destroy(); diff --git a/tools/time/time.c b/tools/time/time.c index 5c1a1b1..17ce4d3 100644 --- a/tools/time/time.c +++ b/tools/time/time.c @@ -7,13 +7,18 @@ * scheduler's last clock moves after a switch, and st_time follows time(NULL) * with the time cache on and off. A child process, forked before ST is set * up, replaces the clock with st_set_utime_function and checks that sleeps - * follow it. + * follow it. Windows has no fork, so the child is this program run again with + * the argument custom-clock. */ #include "tool.h" #include +#ifdef _WIN32 +#include +#else #include +#endif /* Long enough that a sleep still blocked here is a failure, not a slow run. */ #define BLOCK_US (5 * 1000 * 1000) @@ -21,9 +26,18 @@ /* The real monotonic clock, independent of ST. */ static st_utime_t real_us(void) { +#ifdef _WIN32 + /* Windows has no clock_gettime; the performance counter is its monotonic clock. */ + LARGE_INTEGER freq, now; + QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&now); + return (st_utime_t)(now.QuadPart / freq.QuadPart) * 1000000LL + + (st_utime_t)(now.QuadPart % freq.QuadPart) * 1000000LL / freq.QuadPart; +#else struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (st_utime_t)ts.tv_sec * 1000000LL + ts.tv_nsec / 1000; +#endif } /* @@ -90,6 +104,14 @@ static int custom_clock(void) static int fork_custom_clock(void) { fflush(stdout); +#ifdef _WIN32 + /* Run this program again as the child, with the same environment, and wait for its exit code. */ + char path[MAX_PATH]; + DWORD n = GetModuleFileNameA(NULL, path, sizeof(path)); + CHECK(n > 0 && n < sizeof(path)); + CHECK(_spawnl(_P_WAIT, path, "time", "custom-clock", NULL) == 0); + return 0; +#else pid_t pid = fork(); CHECK(pid >= 0); if (pid == 0) { @@ -102,6 +124,7 @@ static int fork_custom_clock(void) CHECK(waitpid(pid, &status, 0) == pid); CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0); return 0; +#endif } /* The clock never goes back, and moves across a sleep. */ @@ -238,6 +261,14 @@ static int timecache(void) int main(int argc, char **argv) { +#ifdef _WIN32 + /* The child of fork_custom_clock. */ + if (argc == 2 && !strcmp(argv[1], "custom-clock")) { + int r = custom_clock(); + fflush(stdout); + return r; + } +#endif CHECK(fork_custom_clock() == 0); CHECK(tool_init() == 0); From 63c1052945b3dc5cd3a66e42793aa35d350f5b78 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 16:23:10 -0400 Subject: [PATCH 27/37] Claude: Run the tcp tool on native Windows. The POSIX headers are only included off Windows, the raw socket is cast to int, and is_open asks getsockopt(SO_TYPE) on Windows, where a closed socket fails with WSAENOTSOCK, reported as EBADF like fcntl(F_GETFD) on POSIX. Winsock never sends part of a non-blocking send: it takes the whole buffer or fails with WSAEWOULDBLOCK. So on Windows, resid() skips only its three partial-write-on-timeout checks (st_write_resid, st_writev_resid, then st_write failing with ETIME), as the utest skips its partial-send tests. Every other check runs, including the residual counts at EOF. On Windows, tcp passes with select and alt. Co-Authored-By: Claude Opus 5.5 --- tools/tcp/tcp.c | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tools/tcp/tcp.c b/tools/tcp/tcp.c index 3417de1..372d6b0 100644 --- a/tools/tcp/tcp.c +++ b/tools/tcp/tcp.c @@ -14,9 +14,11 @@ #include "tool.h" +#ifndef _WIN32 #include #include #include +#endif /* Long enough that a coroutine still blocked here is a failure, not a slow run. */ #define BLOCK_US (5 * 1000 * 1000) @@ -447,6 +449,7 @@ static int pair(int family, st_netfd_t *c, st_netfd_t *s) return 0; } +#ifndef _WIN32 /* Read exactly n bytes, and then nothing more is there. */ static int drain(st_netfd_t fd, size_t n) { @@ -462,6 +465,7 @@ static int drain(st_netfd_t fd, size_t n) CHECK(more == -1 && err == ETIME); return 0; } +#endif /* The residual counts: short reads at EOF, and partial writes on a timeout. */ static int resid(int family) @@ -496,7 +500,12 @@ static int resid(int family) CHECK(!memcmp(buf, "0123456789", 10)); CHECK(st_netfd_close(s) == 0); - /* The peer does not read, so a big write times out part way. */ +#ifndef _WIN32 + /* + * The peer does not read, so a big write times out part way. Not on native + * Windows: Winsock never sends part of a non-blocking send, it takes the whole + * buffer or fails with WSAEWOULDBLOCK, so a write never ends part way. + */ size_t big = 1024 * 1024; char *data = calloc(1, big); CHECK(data != NULL); @@ -536,6 +545,7 @@ static int resid(int family) free(data); CHECK(st_netfd_close(c) == 0); CHECK(st_netfd_close(s) == 0); +#endif return 0; } @@ -564,7 +574,7 @@ static int timeouts(int family) for (; n < 16; n++) { struct sockaddr_storage addr; socklen_t len = tool_loopback(family, port, &addr); - int fd = socket(family, SOCK_STREAM, 0); + int fd = (int)socket(family, SOCK_STREAM, 0); CHECK(fd >= 0); CHECK((clients[n] = st_netfd_open_socket(fd)) != NULL); errno = 0; @@ -593,9 +603,24 @@ static void dtor(void *v) dtor_last = v; } +/* + * Whether fd is open; if not, errno is EBADF. On native Windows a socket is + * not a file descriptor, so getsockopt asks for its type, and a closed socket + * fails with WSAENOTSOCK, the Winsock form of EBADF. + */ static int is_open(int fd) { +#if defined(_WIN32) + int type; + int len = sizeof(type); + if (getsockopt((SOCKET)fd, SOL_SOCKET, SO_TYPE, (char *)&type, &len) == 0) { + return 1; + } + errno = (WSAGetLastError() == WSAENOTSOCK) ? EBADF : EINVAL; + return 0; +#else return fcntl(fd, F_GETFD) != -1; +#endif } /* The netfd specific data, and st_netfd_free against st_netfd_close. */ From 0b7f9540f7ca3ed4ba37c39320a7095b5c937731 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 16:29:33 -0400 Subject: [PATCH 28/37] Claude: Skip the lifecycle fd-limit check on native Windows. Windows has no rlimit, so it does not support the check that ST raises RLIMIT_NOFILE in st_init. Skip check_fdlimit on Windows with a comment, instead of the Windows-only st_getfdlimit() == 1<<24 check from the previous commit. Every other lifecycle check stays. --------- Co-Authored-By: Claude Opus 5.5 --- tools/lifecycle/lifecycle.c | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/tools/lifecycle/lifecycle.c b/tools/lifecycle/lifecycle.c index 7824a34..59420e6 100644 --- a/tools/lifecycle/lifecycle.c +++ b/tools/lifecycle/lifecycle.c @@ -91,20 +91,8 @@ static int set_primordial_stack(void) return 0; } -#if defined(_WIN32) -/* - * Windows has no RLIMIT_NOFILE to read or raise: _st_io_init reports the - * per-process handle limit, 2^24, as WSAPoll has no limit of its own. - */ -static int check_fdlimit(void) -{ - int limit = st_getfdlimit(); - CHECK(limit == (1 << 24)); - - printf("ST: fdlimit=%d\n", limit); - return 0; -} -#else +/* Windows has no rlimit, so it does not support this check. */ +#ifndef _WIN32 /* The descriptor limit _st_io_init sets from the limit before st_init. */ static int check_fdlimit(int eventsys, const struct rlimit *before) { @@ -223,9 +211,8 @@ int main(int argc, char **argv) CHECK(st_init() == 0); CHECK(st_get_eventsys() == eventsys); -#if defined(_WIN32) - CHECK(check_fdlimit() == 0); -#else + /* Windows has no rlimit, so it does not support this check. */ +#ifndef _WIN32 CHECK(check_fdlimit(eventsys, &before) == 0); #endif CHECK(interrupt_all() == 0); From 3e09641e0d36b6709f8bf2de8c38532b5486e463 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 16:43:00 -0400 Subject: [PATCH 29/37] Claude: Run the udp tool on native Windows. Include sys/uio.h only off _WIN32, since st.h brings Winsock 2 and the iovec and msghdr types on Windows. Cast the SOCKET from socket() to int, and close a raw socket with tool_close_socket, which is closesocket on Windows. Every udp check runs on Windows, over IPv4 and IPv6, with both event systems; no check changed and no ST library change. --------- Co-Authored-By: Claude Opus 5.5 --- tools/udp/udp.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/udp/udp.c b/tools/udp/udp.c index d90aa43..3cd5cd4 100644 --- a/tools/udp/udp.c +++ b/tools/udp/udp.c @@ -11,7 +11,9 @@ #include "tool.h" +#ifndef _WIN32 #include +#endif /* Long enough that a coroutine still blocked here is a failure, not a slow run. */ #define BLOCK_US (5 * 1000 * 1000) @@ -35,14 +37,14 @@ static st_netfd_t udp_bind(int family, struct sockaddr_storage *addr, int *addrlen) { socklen_t len = tool_loopback(family, 0, addr); - int fd = socket(family, SOCK_DGRAM, 0); + int fd = (int)socket(family, SOCK_DGRAM, 0); if (fd < 0) { return NULL; } if (bind(fd, (struct sockaddr *)addr, len) < 0 || getsockname(fd, (struct sockaddr *)addr, &len) < 0) { int err = errno; - close(fd); + tool_close_socket(fd); errno = err; return NULL; } @@ -50,7 +52,7 @@ static st_netfd_t udp_bind(int family, struct sockaddr_storage *addr, int *addrl st_netfd_t stfd = st_netfd_open_socket(fd); if (!stfd) { int err = errno; - close(fd); + tool_close_socket(fd); errno = err; return NULL; } From e7405d804dfbad29e7ab43a52b603d2313061931 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 16:57:45 -0400 Subject: [PATCH 30/37] Claude: Run the poll tool on native Windows. ST on native Windows takes sockets only, so pipes and socketpair in the tools become connected socket pairs there (D26). tools/tool.h adds tool_pipe (pipe, or a loopback TCP pair on Windows), tool_socketpair (socketpair, or an AF_UNIX stream pair through a path in the temp folder on Windows), and tool_write (write, or send with EAGAIN on Windows). On POSIX each is the real call. poll.c uses them, includes fcntl.h and poll.h only off _WIN32, and skips only the O_NONBLOCK read-back with fcntl(F_GETFL) on Windows, which cannot read back a socket's mode. Every other check runs unchanged, with both event systems; no ST library change. --------- Co-Authored-By: Claude Opus 5.5 --- tools/poll/poll.c | 12 ++++-- tools/tool.h | 108 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/tools/poll/poll.c b/tools/poll/poll.c index 91a165f..6c60f2d 100644 --- a/tools/poll/poll.c +++ b/tools/poll/poll.c @@ -10,8 +10,11 @@ #include "tool.h" +/* On native Windows, st.h brings struct pollfd from Winsock 2, and fcntl is only for a check skipped there. */ +#ifndef _WIN32 #include #include +#endif #include /* Long enough that a coroutine still blocked here is a failure, not a slow run. */ @@ -33,7 +36,7 @@ static struct channel channels[NB_CHANNELS]; static int open_pipe(struct channel *c) { int fds[2]; - CHECK(pipe(fds) == 0); + CHECK(tool_pipe(fds) == 0); CHECK((c->rd = st_netfd_open(fds[0])) != NULL); CHECK((c->wr = st_netfd_open(fds[1])) != NULL); return 0; @@ -42,7 +45,7 @@ static int open_pipe(struct channel *c) static int open_socketpair(struct channel *c) { int fds[2]; - CHECK(socketpair(AF_UNIX, SOCK_STREAM, 0, fds) == 0); + CHECK(tool_socketpair(AF_UNIX, SOCK_STREAM, 0, fds) == 0); CHECK((c->rd = st_netfd_open_socket(fds[0])) != NULL); CHECK((c->wr = st_netfd_open_socket(fds[1])) != NULL); return 0; @@ -390,12 +393,15 @@ static int netfd_poll(void) struct channel c; CHECK(open_pipe(&c) == 0); int wfd = st_netfd_fileno(c.wr); +#ifndef _WIN32 + /* Windows does not support this check: it cannot read back whether a socket is non-blocking. */ CHECK(fcntl(wfd, F_GETFL) & O_NONBLOCK); +#endif char buf[4096]; memset(buf, 'f', sizeof(buf)); int filled = 0; for (;;) { - ssize_t n = write(wfd, buf, sizeof(buf)); + ssize_t n = tool_write(wfd, buf, sizeof(buf)); if (n < 0) { CHECK(errno == EAGAIN || errno == EWOULDBLOCK); break; diff --git a/tools/tool.h b/tools/tool.h index efbfef8..b8d4ce0 100644 --- a/tools/tool.h +++ b/tools/tool.h @@ -22,6 +22,9 @@ /* On native Windows, st.h brings the Winsock 2 headers. */ #include +#if defined(_WIN32) +#include +#endif /* Print the failing check and its line, then return 1 from the caller. */ #define CHECK(cond) do { \ @@ -183,4 +186,109 @@ static inline st_netfd_t tool_connect(int family, int port, st_utime_t timeout) return stfd; } +#if defined(_WIN32) +/* + * A connected pair of stream sockets of family, AF_INET over 127.0.0.1 or + * AF_UNIX at a path in the temp folder, made with bind, listen, connect and + * accept. fds[0] is the accepted end and fds[1] the client. Returns -1 with + * errno set on failure. + */ +static inline int tool_stream_pair(int family, int fds[2]) +{ + struct sockaddr_storage addr; + int len; + if (family == AF_UNIX) { + static int seq = 0; + char dir[MAX_PATH]; + struct sockaddr_un *a = (struct sockaddr_un *)&addr; + memset(&addr, 0, sizeof(addr)); + a->sun_family = AF_UNIX; + if (!GetTempPathA(sizeof(dir), dir)) { + errno = ENOENT; + return -1; + } + snprintf(a->sun_path, sizeof(a->sun_path), "%sst-tool-%lu-%d.sock", dir, GetCurrentProcessId(), seq++); + DeleteFileA(a->sun_path); + len = (int)sizeof(*a); + } else { + len = (int)tool_loopback(AF_INET, 0, &addr); + } + + SOCKET l = INVALID_SOCKET, c = INVALID_SOCKET, s = INVALID_SOCKET; + if ((l = socket(family, SOCK_STREAM, 0)) == INVALID_SOCKET + || bind(l, (struct sockaddr *)&addr, len) || listen(l, 1) + || getsockname(l, (struct sockaddr *)&addr, &len) + || (c = socket(family, SOCK_STREAM, 0)) == INVALID_SOCKET + || connect(c, (struct sockaddr *)&addr, len) + || (s = accept(l, NULL, NULL)) == INVALID_SOCKET) { + if (c != INVALID_SOCKET) { + closesocket(c); + } + s = INVALID_SOCKET; + } + if (l != INVALID_SOCKET) { + closesocket(l); + } + if (family == AF_UNIX) { + DeleteFileA(((struct sockaddr_un *)&addr)->sun_path); + } + if (s == INVALID_SOCKET) { + errno = EIO; + return -1; + } + + fds[0] = (int)s; + fds[1] = (int)c; + return 0; +} +#endif + +/* + * A one-way channel, fds[0] to read and fds[1] to write: pipe(2), or on + * native Windows, where ST takes sockets only, a loopback TCP pair (D26). + */ +static inline int tool_pipe(int fds[2]) +{ +#if defined(_WIN32) + return tool_stream_pair(AF_INET, fds); +#else + return pipe(fds); +#endif +} + +/* + * socketpair(2). Native Windows has none, so there an AF_UNIX stream pair is + * connected through a path in the temp folder, the only kind it makes (D26). + */ +static inline int tool_socketpair(int domain, int type, int protocol, int fds[2]) +{ +#if defined(_WIN32) + if (domain != AF_UNIX || type != SOCK_STREAM || protocol != 0) { + errno = EINVAL; + return -1; + } + return tool_stream_pair(AF_UNIX, fds); +#else + return socketpair(domain, type, protocol, fds); +#endif +} + +/* + * write(2) to a descriptor from tool_pipe or tool_socketpair. On native + * Windows it is a socket: send, with EAGAIN when it would block. + */ +static inline ssize_t tool_write(int fd, const void *buf, size_t n) +{ +#if defined(_WIN32) + int r = send((SOCKET)fd, (const char *)buf, (int)n, 0); + if (r == SOCKET_ERROR) { + errno = (WSAGetLastError() == WSAEWOULDBLOCK) ? EAGAIN : EIO; + return -1; + } + return r; +#else + return write(fd, buf, n); +#endif +} + #endif /* ST_TOOL_H */ From ae9a9c355dc77d7d184d330da0d30ee8cd89f7d9 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 17:07:33 -0400 Subject: [PATCH 31/37] Claude: Run the stress tool on native Windows. stress.c includes netinet/tcp.h, sys/resource.h, and sys/uio.h only off _WIN32; on Windows st.h brings TCP_NODELAY and struct iovec from Winsock 2. Windows has no rlimit, so the getrlimit and setrlimit setup that raises the descriptor limit before st_init is under #ifndef _WIN32 with a comment (D25); the check that st_getfdlimit() covers the count still runs everywhere. Every other check is unchanged and passes with both event systems, also with a count of 1000; no ST library change. --------- Co-Authored-By: Claude Opus 5.5 --- tools/stress/stress.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/stress/stress.c b/tools/stress/stress.c index fd7e7fb..25333b0 100644 --- a/tools/stress/stress.c +++ b/tools/stress/stress.c @@ -15,9 +15,12 @@ #include "tool.h" +/* On native Windows, st.h brings TCP_NODELAY and struct iovec from Winsock 2, and there is no rlimit. */ +#ifndef _WIN32 #include #include #include +#endif /* Long enough that a coroutine still blocked here is a failure, not a slow run. */ #define BLOCK_US (5 * 1000 * 1000) @@ -405,12 +408,15 @@ int main(int argc, char **argv) * limit for a large count before st_init, which reads it. */ int need = 2 * count + 32; +#ifndef _WIN32 + /* Windows does not support this: it has no rlimit, and ST allows 2^24 sockets there. */ struct rlimit rl; CHECK(getrlimit(RLIMIT_NOFILE, &rl) == 0); if (rl.rlim_cur < (rlim_t)need) { rl.rlim_cur = need; setrlimit(RLIMIT_NOFILE, &rl); } +#endif CHECK(tool_init() == 0); if (st_getfdlimit() < need) { From cfb07abad463376eade744b5f53cfeb03343d551 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 17:14:17 -0400 Subject: [PATCH 32/37] Claude: Run the backtrace tool on native Windows. Native Windows has no execinfo.h, so the backtrace tool gets the frames with CaptureStackBackTrace, which walks a coroutine stack with the unwind data and the TIB stack bounds ST sets, and names them with DbgHelp SymFromAddr in the glibc form "address (name+0xoffset)". __builtin_return_address maps to _ReturnAddress for level 0, all the builtin path requires. The checks that bar, foo and start are on the coroutine stack are unchanged; no ST library change. --------- Co-Authored-By: Claude Opus 5.5 --- tools/backtrace/backtrace.c | 53 +++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tools/backtrace/backtrace.c b/tools/backtrace/backtrace.c index 4b07285..961f2ac 100644 --- a/tools/backtrace/backtrace.c +++ b/tools/backtrace/backtrace.c @@ -5,13 +5,66 @@ #define _GNU_SOURCE #endif +#ifndef _WIN32 #include +#endif #include #include #include #include +#if defined(_WIN32) +#include +#include +#include +#pragma comment(lib, "dbghelp.lib") + +/* + * Native Windows has no execinfo.h. backtrace walks the stack with CaptureStackBackTrace, which uses the + * unwind data and the TIB stack bounds that ST sets for each coroutine, and starts at the caller, as glibc. + */ +#define backtrace(buffer, size) ((int)CaptureStackBackTrace(0, (DWORD)(size), (buffer), NULL)) + +/* MSVC gives only the return address of the current function, level 0, which is all bar requires. */ +#define __builtin_return_address(level) ((level) == 0 ? _ReturnAddress() : NULL) + +/* + * backtrace_symbols with DbgHelp: each frame as "address (name+0xoffset)", the glibc form, or the address + * alone with no symbol. One malloc holds the array and the strings, so the caller frees it once. + */ +#define BT_SYMBOL_SIZE 256 +char** backtrace_symbols(void* const* addresses, int nn_addresses) +{ + static int initialized = 0; + HANDLE process = GetCurrentProcess(); + if (!initialized) { + SymInitialize(process, NULL, TRUE); + initialized = 1; + } + + char** symbols = (char**)malloc(nn_addresses * (sizeof(char*) + BT_SYMBOL_SIZE) + 1); + if (!symbols) return NULL; + char* p = (char*)(symbols + nn_addresses); + + char info_buffer[sizeof(SYMBOL_INFO) + BT_SYMBOL_SIZE]; + SYMBOL_INFO* info = (SYMBOL_INFO*)info_buffer; + for (int i = 0; i < nn_addresses; i++, p += BT_SYMBOL_SIZE) { + memset(info_buffer, 0, sizeof(info_buffer)); + info->SizeOfStruct = sizeof(SYMBOL_INFO); + info->MaxNameLen = BT_SYMBOL_SIZE - 1; + DWORD64 offset = 0; + if (SymFromAddr(process, (DWORD64)addresses[i], &offset, info)) { + snprintf(p, BT_SYMBOL_SIZE, "%p (%s+0x%llx)", addresses[i], info->Name, (unsigned long long)offset); + } else { + snprintf(p, BT_SYMBOL_SIZE, "%p", addresses[i]); + } + symbols[i] = p; + } + return symbols; +} +#endif + #ifdef __linux__ #include #include From 892d68199b8c085a0e02bdc8e4d642e8549104b2 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 18:10:42 -0400 Subject: [PATCH 33/37] Claude: Skip the pipe tool on native Windows. ST on native Windows takes sockets only, with no pipes, FIFOs, files (st_open), or signals, so pipe.c is skipped there as a whole (D27): on _WIN32 it builds a main that prints "SKIP pipe: " and exits 0, with a comment; the POSIX tool is unchanged under #else. Its socket fill-then-drain is covered by the poll and unix tools. auto/tools.sh shows a tool that prints only "SKIP : " as that line with its event system, not as OK. No tool prints it on Linux or macOS, so their output is unchanged. --------- Co-Authored-By: Claude Opus 5.5 --- auto/tools.sh | 6 ++++++ tools/pipe/pipe.c | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/auto/tools.sh b/auto/tools.sh index f46be02..1d02929 100755 --- a/auto/tools.sh +++ b/auto/tools.sh @@ -16,6 +16,8 @@ # The arguments name the tools to run, every folder in tools/ by default. # Each tool reads ST_TOOL_EVENTSYS, select or alt. It prints " OK" # per run, or "FAILED " with the output, and exits 1 at the first failure. +# A tool that this platform does not support prints only "SKIP : " and exits 0, +# which shows as "SKIP : ()". # # On native Windows, run it from Git Bash with the MSVC environment on PATH, where # uname -s is MINGW64_NT-* or MSYS_NT-*: it builds win64-debug and runs .exe. @@ -64,6 +66,10 @@ for dir in "${tool_dirs[@]}"; do echo "FAILED $name $eventsys" exit 1 fi + if [[ $out == "SKIP $name:"* ]]; then + echo "$out ($eventsys)" + continue + fi echo "$name $eventsys OK" done done diff --git a/tools/pipe/pipe.c b/tools/pipe/pipe.c index 85cba99..6e63470 100644 --- a/tools/pipe/pipe.c +++ b/tools/pipe/pipe.c @@ -10,6 +10,21 @@ #include "tool.h" +#ifdef _WIN32 +/* + * Windows does not support this tool: ST on native Windows takes sockets only, + * with no pipes, FIFOs, files (st_open), or signals. Its socket fill-then-drain + * is covered by the poll and unix tools. + */ +int main(int argc, char **argv) +{ + (void)argc; + (void)argv; + printf("SKIP pipe: Windows does not support pipes, FIFOs, st_open files, or signals\n"); + return 0; +} +#else + #include #include #include @@ -392,3 +407,4 @@ int main(int argc, char **argv) printf("pipe OK\n"); return 0; } +#endif /* _WIN32 */ From e0f8dcaed0402fe3ebcfd503a52edd8874d62d2d Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 18:16:59 -0400 Subject: [PATCH 34/37] Claude: Run the unix tool on native Windows. Windows AF_UNIX is stream only (D28). unix.c includes sys/uio.h and sys/un.h only off _WIN32; on Windows the stream paths are in the temp folder, and unlink and access are the MSVC CRT calls. stream_echo and pair_stream run unchanged; pair_dgram runs over a connected loopback UDP pair, which tool_socketpair in tools/tool.h now makes for SOCK_DGRAM on Windows, a channel swap like D26, with every check unchanged. The dgram_echo test (AF_UNIX datagram paths) and the connect-to-missing-path ENOENT check (Windows gives ECONNREFUSED) are skipped on Windows with a comment (D25). socket() is cast to int and closed with tool_close_socket, which is close() off Windows. No ST library change. --------- Co-Authored-By: Claude Opus 5.5 --- tools/tool.h | 40 +++++++++++++++++++++++++++--- tools/unix/unix.c | 63 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/tools/tool.h b/tools/tool.h index b8d4ce0..eb26b07 100644 --- a/tools/tool.h +++ b/tools/tool.h @@ -256,17 +256,51 @@ static inline int tool_pipe(int fds[2]) #endif } +#if defined(_WIN32) /* - * socketpair(2). Native Windows has none, so there an AF_UNIX stream pair is - * connected through a path in the temp folder, the only kind it makes (D26). + * A pair of UDP sockets on 127.0.0.1, each connected to the other, so send + * and recv carry datagrams between them. Returns -1 with errno set on failure. + */ +static inline int tool_dgram_pair(int fds[2]) +{ + struct sockaddr_storage a0, a1; + int l0 = (int)tool_loopback(AF_INET, 0, &a0), l1 = (int)tool_loopback(AF_INET, 0, &a1); + SOCKET s0 = socket(AF_INET, SOCK_DGRAM, 0), s1 = socket(AF_INET, SOCK_DGRAM, 0); + if (s0 == INVALID_SOCKET || s1 == INVALID_SOCKET + || bind(s0, (struct sockaddr *)&a0, l0) || bind(s1, (struct sockaddr *)&a1, l1) + || getsockname(s0, (struct sockaddr *)&a0, &l0) || getsockname(s1, (struct sockaddr *)&a1, &l1) + || connect(s0, (struct sockaddr *)&a1, l1) || connect(s1, (struct sockaddr *)&a0, l0)) { + if (s0 != INVALID_SOCKET) { + closesocket(s0); + } + if (s1 != INVALID_SOCKET) { + closesocket(s1); + } + errno = EIO; + return -1; + } + + fds[0] = (int)s0; + fds[1] = (int)s1; + return 0; +} +#endif + +/* + * socketpair(2). Native Windows has none, and its AF_UNIX is stream only, so + * there an AF_UNIX stream pair is connected through a path in the temp folder + * (D26), and a datagram pair is a connected loopback UDP pair (D28). */ static inline int tool_socketpair(int domain, int type, int protocol, int fds[2]) { #if defined(_WIN32) - if (domain != AF_UNIX || type != SOCK_STREAM || protocol != 0) { + if (domain != AF_UNIX || (type != SOCK_STREAM && type != SOCK_DGRAM) || protocol != 0) { errno = EINVAL; return -1; } + if (type == SOCK_DGRAM) { + return tool_dgram_pair(fds); + } return tool_stream_pair(AF_UNIX, fds); #else return socketpair(domain, type, protocol, fds); diff --git a/tools/unix/unix.c b/tools/unix/unix.c index 53f5ad7..0cec057 100644 --- a/tools/unix/unix.c +++ b/tools/unix/unix.c @@ -12,8 +12,17 @@ #include "tool.h" #include +/* On native Windows, st.h brings struct iovec and tool.h struct sockaddr_un from afunix.h. */ +#ifndef _WIN32 #include #include +#else +#include +/* The POSIX names of the MSVC CRT calls, for the socket paths. */ +#define access _access +#define unlink _unlink +#define F_OK 0 +#endif /* Long enough that a coroutine still blocked here is a failure, not a slow run. */ #define BLOCK_US (5 * 1000 * 1000) @@ -21,6 +30,24 @@ /* A timeout that is expected to expire. */ #define SHORT_US (5 * 1000) +#ifdef _WIN32 +/* + * Windows has no /tmp, so the stream paths are in the temp folder, set by + * temp_paths before use. Its AF_UNIX is stream only, with no datagram paths. + */ +static char stream_path[MAX_PATH], missing_path[MAX_PATH]; +#define STREAM_PATH stream_path +#define MISSING_PATH missing_path + +static int temp_paths(void) +{ + char dir[MAX_PATH]; + CHECK(GetTempPathA(sizeof(dir), dir) != 0); + snprintf(stream_path, sizeof(stream_path), "%sst-tool-unix.sock", dir); + snprintf(missing_path, sizeof(missing_path), "%sst-tool-unix-missing.sock", dir); + return 0; +} +#else /* The paths, removed by these literal paths before and after use. */ #define STREAM_PATH "/tmp/st-tool-unix.sock" #define DGRAM_PATH "/tmp/st-tool-unix-dgram.sock" @@ -32,6 +59,7 @@ static const char *dgram_client_paths[DGRAM_CLIENTS] = { "/tmp/st-tool-unix-dgram-2.sock", "/tmp/st-tool-unix-dgram-3.sock", }; +#endif /* The stream echo: clients, messages per client, and the largest payload. */ #define CLIENTS 8 @@ -49,12 +77,14 @@ static const char *dgram_client_paths[DGRAM_CLIENTS] = { static void remove_paths(void) { unlink(STREAM_PATH); - unlink(DGRAM_PATH); unlink(MISSING_PATH); +#ifndef _WIN32 + unlink(DGRAM_PATH); unlink("/tmp/st-tool-unix-dgram-0.sock"); unlink("/tmp/st-tool-unix-dgram-1.sock"); unlink("/tmp/st-tool-unix-dgram-2.sock"); unlink("/tmp/st-tool-unix-dgram-3.sock"); +#endif } static socklen_t unix_addr(const char *path, struct sockaddr_un *addr) @@ -70,7 +100,7 @@ static st_netfd_t unix_bind(int type, const char *path) { struct sockaddr_un addr; socklen_t len = unix_addr(path, &addr); - int fd = socket(AF_UNIX, type, 0); + int fd = (int)socket(AF_UNIX, type, 0); if (fd < 0) { return NULL; } @@ -84,7 +114,7 @@ static st_netfd_t unix_bind(int type, const char *path) if ((type == SOCK_DGRAM && setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf)) < 0) || bind(fd, (struct sockaddr *)&addr, len) < 0 || (type == SOCK_STREAM && listen(fd, CLIENTS) < 0)) { int err = errno; - close(fd); + tool_close_socket(fd); errno = err; return NULL; } @@ -92,7 +122,7 @@ static st_netfd_t unix_bind(int type, const char *path) st_netfd_t stfd = st_netfd_open_socket(fd); if (!stfd) { int err = errno; - close(fd); + tool_close_socket(fd); errno = err; } return stfd; @@ -103,7 +133,7 @@ static st_netfd_t unix_connect(const char *path) { struct sockaddr_un addr; socklen_t len = unix_addr(path, &addr); - int fd = socket(AF_UNIX, SOCK_STREAM, 0); + int fd = (int)socket(AF_UNIX, SOCK_STREAM, 0); if (fd < 0) { return NULL; } @@ -111,7 +141,7 @@ static st_netfd_t unix_connect(const char *path) st_netfd_t stfd = st_netfd_open_socket(fd); if (!stfd) { int err = errno; - close(fd); + tool_close_socket(fd); errno = err; return NULL; } @@ -416,8 +446,11 @@ static int stream_echo(void) /* A path with no socket, and a path whose listener is gone. */ st_netfd_t fd; unlink(MISSING_PATH); +#ifndef _WIN32 + /* Windows does not support this check: a connect to a missing path fails with ECONNREFUSED there. */ errno = 0; CHECK(unix_connect(MISSING_PATH) == NULL && errno == ENOENT); +#endif CHECK((fd = unix_bind(SOCK_STREAM, MISSING_PATH)) != NULL); CHECK(st_netfd_close(fd) == 0); errno = 0; @@ -426,6 +459,8 @@ static int stream_echo(void) return 0; } +#ifndef _WIN32 +/* Windows does not support the datagram echo: its AF_UNIX is stream only, with no datagram paths. */ static struct { st_netfd_t server; int server_msgs; @@ -584,6 +619,7 @@ static int dgram_echo(void) CHECK(unlink(DGRAM_PATH) == 0); return 0; } +#endif /* The socketpair bulk transfer: the size and the largest write. */ #define PAIR_BYTES (512 * 1024) @@ -621,7 +657,7 @@ static int pair_stream(void) { int sv[2]; st_netfd_t a, b; - CHECK(socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0); + CHECK(tool_socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0); CHECK((a = st_netfd_open_socket(sv[0])) != NULL); CHECK((b = st_netfd_open_socket(sv[1])) != NULL); CHECK(st_netfd_fileno(a) == sv[0] && st_netfd_fileno(b) == sv[1]); @@ -676,12 +712,15 @@ static void *pair_read_forever(void *arg) return NULL; } -/* A datagram socketpair: st_write and st_read keep the boundaries. */ +/* + * A datagram socketpair: st_write and st_read keep the boundaries. On native + * Windows it is a connected loopback UDP pair (D28). + */ static int pair_dgram(void) { int sv[2]; st_netfd_t a, b; - CHECK(socketpair(AF_UNIX, SOCK_DGRAM, 0, sv) == 0); + CHECK(tool_socketpair(AF_UNIX, SOCK_DGRAM, 0, sv) == 0); CHECK((a = st_netfd_open_socket(sv[0])) != NULL); CHECK((b = st_netfd_open_socket(sv[1])) != NULL); @@ -711,7 +750,10 @@ static int pair_dgram(void) static int run(void) { CHECK(stream_echo() == 0); +#ifndef _WIN32 + /* Windows does not support it: its AF_UNIX is stream only. */ CHECK(dgram_echo() == 0); +#endif CHECK(pair_stream() == 0); CHECK(pair_dgram() == 0); return 0; @@ -721,6 +763,9 @@ int main(int argc, char **argv) { CHECK(tool_init() == 0); +#ifdef _WIN32 + CHECK(temp_paths() == 0); +#endif remove_paths(); int r = run(); remove_paths(); From 156d325c18210eb57472558a3188a12cc0dd137a Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 18:27:15 -0400 Subject: [PATCH 35/37] Claude: Run the integration tools in the Windows CI job, and document them for Windows. The windows job in .github/workflows/test.yml runs ./auto/tools.sh after the utest, in the ST default build and with EXTRA_CFLAGS=-DMALLOC_STACK (the SRS build), as the Linux and macOS jobs do. Each step uses Git Bash in the MSVC environment and fails on any tool failure; the utest, coverage, and final jobs are unchanged. README.md gets a Windows: Tools section: how to run auto/tools.sh, the pipe and socketpair channels as sockets, the pipe tool SKIP lines, and the checks Windows does not support. --------- Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 12 ++++++++++-- README.md | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d842743..02145ff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,8 +59,8 @@ jobs: files: coverage.xml disable_search: true - # Native Windows with MSVC, built with GNU make from Git Bash. st_utest skips the few - # tests that Windows TCP behavior cannot pass, in the test code itself. + # Native Windows with MSVC, built with GNU make from Git Bash. Runs st_utest, then + # auto/tools.sh in the ST default and SRS builds, as on Linux and macOS. windows: name: actions-test-build-win64 runs-on: windows-latest @@ -105,6 +105,14 @@ jobs: shell: bash timeout-minutes: 10 run: ./obj/st_utest.exe + - name: Run ST tools + shell: bash + timeout-minutes: 10 + run: ./auto/tools.sh + - name: Run ST tools in the SRS build + shell: bash + timeout-minutes: 10 + run: EXTRA_CFLAGS=-DMALLOC_STACK ./auto/tools.sh ################################################################ # Build diff --git a/README.md b/README.md index bc877b5..f699e91 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,29 @@ cmake --build C:\st-build --config Debug --target st_utest > Note: Use a short build directory, because MSBuild fails when its paths are too long. The Makefile stays the > main build, and CMake also builds the `helloworld`, `porting`, and `verify` tools. +## Windows: Tools + +The integration tools in `tools/` build with MSVC and run with the same runner as on Linux and macOS, in Git +Bash with the MSVC environment (see [Windows: Usage](#windows-usage)). It builds the library with +`make -B win64-debug`, then builds each tool and runs `.exe` with `ST_TOOL_EVENTSYS=select` and `alt` +(both are WSAPoll on Windows), and prints `All tools OK`. Run it in the ST default build and in the SRS +build, as CI does: + +```bash +./auto/tools.sh +EXTRA_CFLAGS=-DMALLOC_STACK ./auto/tools.sh +``` + +The arguments name the tools to run, for example `./auto/tools.sh tcp udp`. The runner leaves the library +built with `EXTRA_CFLAGS`, so rebuild with `make -B win64-debug-utest` before the utest. + +On Windows, ST is sockets only, so the tools make each pipe a loopback TCP pair and each `socketpair` an +`AF_UNIX` stream pair, or a loopback UDP pair for datagrams. The `pipe` tool, which needs pipes, FIFOs, +`st_open` files, and signals, prints `SKIP pipe: ` instead of `OK`. A few checks that Windows does not +support are skipped there, each with a comment: the partial writes on timeout in `tcp` (Winsock never sends +part of a non-blocking send), the `RLIMIT_NOFILE` checks in `lifecycle` and `stress`, reading back +`O_NONBLOCK` in `poll`, and the `AF_UNIX` datagram echo and the `ENOENT` of a missing path in `unix`. + ## Linux: Coverage > Note: We use [Google test](https://github.com/google/googletest/releases/tag/release-1.11.0) in `utest/gtest-fit`. From 580eb638f46108a792e5ccec6e4859defdf4ba41 Mon Sep 17 00:00:00 2001 From: winlin Date: Sat, 3 Oct 2026 21:01:55 -0400 Subject: [PATCH 36/37] Claude: Add the C++ exception tool for C++ exceptions and SEH on coroutine stacks. The 15 integration tools are C, so none threw, and only the utest ExceptionTest covered the TIB stack bounds that the Windows context switch must restore for SEH (D30). tools/exception/exception.cpp throws and catches on coroutine stacks: a throw in a coroutine, unwinding many frames in order after a switch, coroutines that switch before they throw on the default and a 32 KB stack, frames larger than a page down most of a 1 MB stack, a destructor that switches while unwound, an exception_ptr rethrown by another coroutine, and a throw in main while coroutines wait deep in their frames. On Windows it also raises and catches SEH itself: RaiseException with __finally and __except, and an access violation. auto/tools.sh relinks a C++ tool through .cpp. README.md describes the tool in Windows: Tools. No ST library change. Without the TIB restore in _st_md_cxt_restore, the tool dies with 0xE06D7363, and its SEH checks alone with 0xE0535431. --------- Co-Authored-By: Claude Opus 5.5 --- README.md | 4 + auto/tools.sh | 7 +- tools/exception/.gitignore | 1 + tools/exception/Makefile | 43 +++ tools/exception/exception.cpp | 494 ++++++++++++++++++++++++++++++++++ 5 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 tools/exception/.gitignore create mode 100644 tools/exception/Makefile create mode 100644 tools/exception/exception.cpp diff --git a/README.md b/README.md index f699e91..38a4892 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,10 @@ support are skipped there, each with a comment: the partial writes on timeout in part of a non-blocking send), the `RLIMIT_NOFILE` checks in `lifecycle` and `stress`, reading back `O_NONBLOCK` in `poll`, and the `AF_UNIX` datagram echo and the `ENOENT` of a missing path in `unix`. +The `exception` tool is C++: it throws and catches C++ exceptions on coroutine stacks, and on Windows also +raises and catches SEH exceptions there. Windows builds C++ exceptions on SEH, which rejects frames outside +the stack bounds in the TIB, so this works only because ST switches those bounds with each coroutine stack. + ## Linux: Coverage > Note: We use [Google test](https://github.com/google/googletest/releases/tag/release-1.11.0) in `utest/gtest-fit`. diff --git a/auto/tools.sh b/auto/tools.sh index 1d02929..af3289a 100755 --- a/auto/tools.sh +++ b/auto/tools.sh @@ -54,7 +54,12 @@ fi for dir in "${tool_dirs[@]}"; do name=$(basename "$dir") # -W relinks the tool, because the binaries are shared across platforms and builds. - if ! out=$(make -C "$dir" -W "$name.c" LDFLAGS="$LDFLAGS" 2>&1); then + # A tool is C, .c, or C++, .cpp. + src=$name.c + if [[ -f $dir$name.cpp ]]; then + src=$name.cpp + fi + if ! out=$(make -C "$dir" -W "$src" LDFLAGS="$LDFLAGS" 2>&1); then echo "$out" echo "FAILED build $name" exit 1 diff --git a/tools/exception/.gitignore b/tools/exception/.gitignore new file mode 100644 index 0000000..38c54e3 --- /dev/null +++ b/tools/exception/.gitignore @@ -0,0 +1 @@ +exception diff --git a/tools/exception/Makefile b/tools/exception/Makefile new file mode 100644 index 0000000..166d69f --- /dev/null +++ b/tools/exception/Makefile @@ -0,0 +1,43 @@ +.PHONY: clean + +LDLIBS=../../obj/libst.a +CXXFLAGS=-g -O0 -I../../obj -I.. -DDEBUG + +OS_NAME = $(shell uname -s) +ST_TARGET = linux-debug +ifeq ($(OS_NAME), Darwin) +ST_TARGET = darwin-debug +CPU_ARCHS = $(shell g++ -dM -E - +#include +#include + +/* Throw a runtime_error whose what() is v. */ +static void throw_value(int v) +{ + throw std::runtime_error(std::to_string(v)); +} + +/* A coroutine's id, and the value it caught, or -1. */ +struct Call { + int id; + int caught; +}; + +/* Throw the id after switches switches, and catch it. */ +static void *switch_then_catch(void *arg) +{ + Call *call = (Call *)arg; + int switches = call->id % 3; + try { + for (int i = 0; i < switches; i++) { + st_usleep(0); + } + throw_value(call->id); + } catch (const std::runtime_error &e) { + call->caught = atoi(e.what()); + } + return NULL; +} + +/* A throw caught in the coroutine that threw it. */ +static int catch_in_coroutine(void) +{ + Call call = {7, -1}; + st_thread_t t; + CHECK((t = st_thread_create(switch_then_catch, &call, 1, 0)) != NULL); + CHECK(st_thread_join(t, NULL) == 0); + CHECK(call.caught == 7); + return 0; +} + +/* + * Unwinding runs the destructor of every frame, deepest first, after the + * deepest frame switched to main and back. + */ +#define FRAMES_DEPTH 32 + +struct Frames { + int destroyed; + int ordered; + int caught; +}; + +class FrameGuard { +public: + FrameGuard(Frames *f, int depth) : f_(f), depth_(depth) { + } + ~FrameGuard() { + if (depth_ != FRAMES_DEPTH - f_->destroyed) { + f_->ordered = 0; + } + f_->destroyed++; + } +private: + Frames *f_; + int depth_; +}; + +static void frames_recurse(Frames *f, int depth) +{ + FrameGuard guard(f, depth); + if (depth < FRAMES_DEPTH) { + frames_recurse(f, depth + 1); + return; + } + st_usleep(0); + throw_value(depth); +} + +static void *frames_coroutine(void *arg) +{ + Frames *f = (Frames *)arg; + try { + frames_recurse(f, 1); + } catch (const std::runtime_error &e) { + f->caught = atoi(e.what()); + } + return NULL; +} + +static int unwind_frames(void) +{ + Frames f = {0, 1, -1}; + st_thread_t t; + CHECK((t = st_thread_create(frames_coroutine, &f, 1, 0)) != NULL); + CHECK(st_thread_join(t, NULL) == 0); + CHECK(f.caught == FRAMES_DEPTH); + CHECK(f.destroyed == FRAMES_DEPTH); + CHECK(f.ordered); + return 0; +} + +/* + * Coroutines switch a different number of times before they throw, so the + * throws interleave, and each catches its own value, on a stack of stk_size. + */ +#define INTERLEAVED 8 + +static int interleaved(int stk_size) +{ + Call calls[INTERLEAVED]; + st_thread_t t[INTERLEAVED]; + for (int i = 0; i < INTERLEAVED; i++) { + calls[i].id = 100 + i; + calls[i].caught = -1; + CHECK((t[i] = st_thread_create(switch_then_catch, &calls[i], 1, stk_size)) != NULL); + } + for (int i = 0; i < INTERLEAVED; i++) { + CHECK(st_thread_join(t[i], NULL) == 0); + CHECK(calls[i].caught == 100 + i); + } + return 0; +} + +/* + * Frames larger than a page, which MSVC probes with __chkstk, down most of a + * 1 MB stack in three coroutines, each switching at the bottom before it + * throws. Each destructor checks that its buffer still holds what it wrote. + */ +#define LARGE_DEPTH 128 +#define LARGE_FRAME 5000 +#define LARGE_STACK (1024 * 1024) +#define LARGE_COROUTINES 3 + +struct Large { + int id; + int destroyed; + int ordered; + int intact; + int caught; +}; + +class LargeGuard { +public: + LargeGuard(Large *l, int depth) : l_(l), depth_(depth) { + buf_[0] = buf_[LARGE_FRAME - 1] = (char)(l->id + depth); + } + ~LargeGuard() { + if (depth_ != LARGE_DEPTH - l_->destroyed) { + l_->ordered = 0; + } + if (buf_[0] != (char)(l_->id + depth_) || buf_[LARGE_FRAME - 1] != buf_[0]) { + l_->intact = 0; + } + l_->destroyed++; + } +private: + Large *l_; + int depth_; + volatile char buf_[LARGE_FRAME]; +}; + +static void large_recurse(Large *l, int depth) +{ + LargeGuard guard(l, depth); + if (depth < LARGE_DEPTH) { + large_recurse(l, depth + 1); + return; + } + st_usleep(0); + throw_value(l->id); +} + +static void *large_coroutine(void *arg) +{ + Large *l = (Large *)arg; + try { + large_recurse(l, 1); + } catch (const std::runtime_error &e) { + l->caught = atoi(e.what()); + } + return NULL; +} + +static int large_frames(void) +{ + Large l[LARGE_COROUTINES]; + st_thread_t t[LARGE_COROUTINES]; + for (int i = 0; i < LARGE_COROUTINES; i++) { + l[i].id = 10 + i; + l[i].destroyed = 0; + l[i].ordered = l[i].intact = 1; + l[i].caught = -1; + CHECK((t[i] = st_thread_create(large_coroutine, &l[i], 1, LARGE_STACK)) != NULL); + } + for (int i = 0; i < LARGE_COROUTINES; i++) { + CHECK(st_thread_join(t[i], NULL) == 0); + CHECK(l[i].caught == 10 + i); + CHECK(l[i].destroyed == LARGE_DEPTH); + CHECK(l[i].ordered && l[i].intact); + } + return 0; +} + +/* + * A destructor switches while its frame is unwound, and another coroutine + * throws and catches before the unwind resumes. + */ +class YieldingGuard { +public: + YieldingGuard(int *destroyed) : destroyed_(destroyed) { + } + ~YieldingGuard() { + st_usleep(0); + st_usleep(0); + (*destroyed_)++; + } +private: + int *destroyed_; +}; + +struct Yielding { + int destroyed; + int caught; +}; + +static void throw_with_yielding_guard(Yielding *y) +{ + YieldingGuard guard(&y->destroyed); + throw_value(300); +} + +static void *yielding_coroutine(void *arg) +{ + Yielding *y = (Yielding *)arg; + try { + throw_with_yielding_guard(y); + } catch (const std::runtime_error &e) { + y->caught = atoi(e.what()); + } + return NULL; +} + +static int yield_while_unwinding(void) +{ + Yielding y = {0, -1}; + Call other = {301, -1}; + st_thread_t t, t2; + CHECK((t = st_thread_create(yielding_coroutine, &y, 1, 0)) != NULL); + CHECK((t2 = st_thread_create(switch_then_catch, &other, 1, 0)) != NULL); + CHECK(st_thread_join(t, NULL) == 0); + CHECK(st_thread_join(t2, NULL) == 0); + CHECK(y.destroyed == 1 && y.caught == 300); + CHECK(other.caught == 301); + return 0; +} + +/* + * One coroutine keeps the exception it caught as an exception_ptr, leaves the + * catch block and switches; another rethrows it and catches the same value. + */ +static std::exception_ptr kept; + +static void *keep_coroutine(void *arg) +{ + (void)arg; + try { + throw_value(400); + } catch (...) { + kept = std::current_exception(); + } + st_usleep(0); + return NULL; +} + +static void *rethrow_coroutine(void *arg) +{ + Call *call = (Call *)arg; + try { + std::rethrow_exception(kept); + } catch (const std::runtime_error &e) { + call->caught = atoi(e.what()); + } + return NULL; +} + +static int exception_ptr_across(void) +{ + Call call = {0, -1}; + st_thread_t t, t2; + CHECK((t = st_thread_create(keep_coroutine, NULL, 1, 0)) != NULL); + st_usleep(0); + CHECK(kept != NULL); + CHECK((t2 = st_thread_create(rethrow_coroutine, &call, 1, 0)) != NULL); + CHECK(st_thread_join(t2, NULL) == 0); + CHECK(st_thread_join(t, NULL) == 0); + CHECK(call.caught == 400); + kept = NULL; + return 0; +} + +/* + * main throws and catches on its own stack while coroutines wait deep in + * their frames, then wakes them and they unwind theirs. + */ +#define PARKED 4 + +static st_cond_t parked_cond; + +static void parked_recurse(Frames *f, int depth) +{ + FrameGuard guard(f, depth); + if (depth < FRAMES_DEPTH) { + parked_recurse(f, depth + 1); + return; + } + st_cond_wait(parked_cond); + throw_value(depth); +} + +static void *parked_coroutine(void *arg) +{ + Frames *f = (Frames *)arg; + try { + parked_recurse(f, 1); + } catch (const std::runtime_error &e) { + f->caught = atoi(e.what()); + } + return NULL; +} + +static int main_while_parked(void) +{ + Frames f[PARKED]; + st_thread_t t[PARKED]; + CHECK((parked_cond = st_cond_new()) != NULL); + for (int i = 0; i < PARKED; i++) { + f[i].destroyed = 0; + f[i].ordered = 1; + f[i].caught = -1; + CHECK((t[i] = st_thread_create(parked_coroutine, &f[i], 1, 0)) != NULL); + } + /* Every coroutine runs down to its st_cond_wait. */ + st_usleep(0); + CHECK(f[0].destroyed == 0); + + int caught = -1; + try { + throw_value(500); + } catch (const std::runtime_error &e) { + caught = atoi(e.what()); + } + CHECK(caught == 500); + + CHECK(st_cond_broadcast(parked_cond) == 0); + for (int i = 0; i < PARKED; i++) { + CHECK(st_thread_join(t[i], NULL) == 0); + CHECK(f[i].caught == FRAMES_DEPTH); + CHECK(f[i].destroyed == FRAMES_DEPTH && f[i].ordered); + } + CHECK(st_cond_destroy(parked_cond) == 0); + return 0; +} + +#if defined(_WIN32) +/* + * SEH itself, which the C++ exceptions above are built on. These functions + * hold no C++ objects, as __try requires. + */ +#define SEH_CODE 0xE0535431u + +static int seh_filter(DWORD code, DWORD want) +{ + return code == want ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH; +} + +/* RaiseException caught by __except, after the __finally between them ran. */ +static DWORD seh_raise(int *finally_ran) +{ + DWORD caught = 0; + __try { + __try { + RaiseException(SEH_CODE, 0, 0, NULL); + } __finally { + (*finally_ran)++; + } + } __except (seh_filter(GetExceptionCode(), SEH_CODE)) { + caught = GetExceptionCode(); + } + return caught; +} + +/* An access violation, a hardware fault, caught by __except. */ +static DWORD seh_fault(void) +{ + int *volatile p = NULL; + DWORD caught = 0; + __try { + *p = 1; + } __except (seh_filter(GetExceptionCode(), EXCEPTION_ACCESS_VIOLATION)) { + caught = GetExceptionCode(); + } + return caught; +} + +struct Seh { + int id; + int finally_ran; + DWORD raised; + DWORD faulted; +}; + +static void *seh_coroutine(void *arg) +{ + Seh *s = (Seh *)arg; + for (int i = 0; i < s->id % 3; i++) { + st_usleep(0); + } + s->raised = seh_raise(&s->finally_ran); + st_usleep(0); + s->faulted = seh_fault(); + return NULL; +} + +/* Coroutines that switch between their SEH exceptions, on the default and a small stack. */ +static int seh(void) +{ + Seh s[INTERLEAVED]; + st_thread_t t[INTERLEAVED]; + for (int i = 0; i < INTERLEAVED; i++) { + memset(&s[i], 0, sizeof(s[i])); + s[i].id = i; + CHECK((t[i] = st_thread_create(seh_coroutine, &s[i], 1, i % 2 ? 32 * 1024 : 0)) != NULL); + } + for (int i = 0; i < INTERLEAVED; i++) { + CHECK(st_thread_join(t[i], NULL) == 0); + CHECK(s[i].raised == SEH_CODE && s[i].finally_ran == 1); + CHECK(s[i].faulted == EXCEPTION_ACCESS_VIOLATION); + } + return 0; +} +#endif + +int main(int argc, char **argv) +{ + (void)argc; + (void)argv; + CHECK(tool_init() == 0); + + CHECK(catch_in_coroutine() == 0); + CHECK(unwind_frames() == 0); + CHECK(interleaved(0) == 0); + CHECK(interleaved(32 * 1024) == 0); + CHECK(large_frames() == 0); + CHECK(yield_while_unwinding() == 0); + CHECK(exception_ptr_across() == 0); + CHECK(main_while_parked() == 0); +#if defined(_WIN32) + CHECK(seh() == 0); +#endif + + printf("exception OK\n"); + return 0; +} From 19933021788c8e9825108e3baa8532822a9b65af Mon Sep 17 00:00:00 2001 From: winlin Date: Sun, 4 Oct 2026 15:59:05 -0400 Subject: [PATCH 37/37] Claude: Fix the UtestPairTest hang on macOS by sending the 64 KB in pieces. StreamPairCarriesBothWays sent 64 KB on a blocking end before reading the other end, in one thread. A macOS socketpair holds only 8 KB each way (net.local.stream.sendspace and recvspace), so send blocked forever and the darwin-arm64 CI job hung; Linux socketpair buffers and the Windows loopback TCP pair are large enough to hide it. The test now sends the 64 KB in 4 KB pieces, each read at the other end before the next, in both directions, so it still carries more than a buffer each way. Verified on macOS and Linux, each with the utest and auto/tools.sh in the default, MALLOC_STACK, and ASAN builds. No ST library change. --------- Co-Authored-By: Claude Opus 5.5 --- utest/st_utest_io.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/utest/st_utest_io.cpp b/utest/st_utest_io.cpp index b2ce82a..2322da5 100644 --- a/utest/st_utest_io.cpp +++ b/utest/st_utest_io.cpp @@ -3045,13 +3045,17 @@ VOID TEST(UtestPairTest, StreamPairCarriesBothWays) ASSERT_TRUE(utest_pair_send_all(fds[1], "pong!")); EXPECT_EQ("pong!", utest_pair_recv_n(fds[0], 5)); - // More than a small buffer, in both directions. + // More than a socket buffer, in both directions. The ends block and this test is the only reader, so send it in + // pieces that each fit in the buffer: a macOS socketpair holds only 8 KB each way. std::string big(64 * 1024, 'x'); for (size_t i = 0; i < big.size(); i++) big[i] = (char)('a' + i % 26); - ASSERT_TRUE(utest_pair_send_all(fds[0], big)); - EXPECT_TRUE(big == utest_pair_recv_n(fds[1], big.size())); - ASSERT_TRUE(utest_pair_send_all(fds[1], big)); - EXPECT_TRUE(big == utest_pair_recv_n(fds[0], big.size())); + for (size_t off = 0; off < big.size(); off += 4096) { + std::string piece = big.substr(off, 4096); + ASSERT_TRUE(utest_pair_send_all(fds[0], piece)); + ASSERT_TRUE(piece == utest_pair_recv_n(fds[1], piece.size())) << "offset " << off; + ASSERT_TRUE(utest_pair_send_all(fds[1], piece)); + ASSERT_TRUE(piece == utest_pair_recv_n(fds[0], piece.size())) << "offset " << off; + } EXPECT_EQ(0, st_utest_close(fds[0])); char c;