From d14e292d1ed38bf3d5f82419057c974ce179e819 Mon Sep 17 00:00:00 2001 From: JustVugg Date: Sat, 1 Aug 2026 13:22:17 +0200 Subject: [PATCH] fix(win): put stdout in binary mode before the serve handshake (#748) kimi_k3 and inkling never called _setmode(..., _O_BINARY). On Windows the CRT opens stdout in TEXT mode and rewrites '\n' as '\r\n', so the engine emits \x01\x01READY\x01\x01\r\n and coli -- which matches the sentinel byte-exactly -- waits for a byte that was mangled on the way out. No error is printed, because nothing failed: one side is waiting, the other already sent something else. @brad-evony hit it in #748 on Windows 10: 93 layers loaded over 42 minutes, the tokenizer read, and then silence. Ctrl-C landed in read_engine_turn's stream.read(1), which is exactly where the handshake blocks. colibri.c has done this since #195 and its comment describes the symptom word for word ('the READY sentinel never matches and chat hangs'). The two newer engines were written without it and nothing noticed for months. Not a v1.4.0 regression -- the bug predates it. v1.4.0 only made it reachable, because before that the archives contained no kimi_k3.exe at all (#720). It goes in compat.h rather than being pasted into each engine: pasting is how it went missing, and this is the third Windows-specific behaviour to be found living in colibri.c alone (after select()/fd_set in #736). Adds tests/test_serve_sentinel.c, which writes the handshake through a real stream and asserts the bytes contain no CR. Nothing in the tree looked at those bytes before, which is why this reached a user rather than CI. It can only pass on Linux/macOS -- that is the point of a regression guard, and CI builds every engine on Windows since #736, so it runs where it can fail. olmoe.c has no serve mode and is untouched. colibri.c is untouched: it is already correct, and 14 open PRs touch that file. Co-Authored-By: Claude Opus 5 --- c/Makefile | 3 ++ c/compat.h | 29 ++++++++++++ c/inkling.c | 5 +++ c/kimi_k3.c | 5 +++ c/tests/test_serve_sentinel.c | 85 +++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 c/tests/test_serve_sentinel.c diff --git a/c/Makefile b/c/Makefile index 116151133..344df3ccf 100644 --- a/c/Makefile +++ b/c/Makefile @@ -489,6 +489,9 @@ portable: iobench$(EXE): iobench.c compat.h $(CC) $(CFLAGS) iobench.c -o iobench$(EXE) $(LDFLAGS) +tests/test_serve_sentinel$(EXE): tests/test_serve_sentinel.c compat.h + $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) + tests/test_json$(EXE): tests/test_json.c json.h $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) diff --git a/c/compat.h b/c/compat.h index c27f80ac1..61d55fd6b 100644 --- a/c/compat.h +++ b/c/compat.h @@ -418,4 +418,33 @@ static inline int coli_stdin_readable(void) } #endif +/* --- coli_serve_binary_mode: stdin/stdout in BINARY per il protocollo di serve --- + * + * I motori parlano un protocollo a BYTE con `coli`: + * stdout \x01\x01READY\x01\x01\n, righe STAT, \x01\x01END\x01\x01\n + * stdin righe di testo piu' i byte di controllo \x02RESET / \x02MORE + * Il gateway confronta i sentinella con endswith() e una regex "^STAT ...", quindi + * devono arrivare ESATTI (LF, senza CR). + * + * Su Windows il CRT apre entrambi gli handle in modalita' TEXT: stdout traduce + * '\n' -> '\r\n' (il sentinella READY non combacia MAI e la chat si blocca senza + * errore), e stdin traduce '\r\n' -> '\n' e rifiuta la scrittura di byte grezzi con + * EINVAL, rompendo il protocollo di controllo. (#195) + * + * colibri.c lo fa da sempre; inkling.c e kimi_k3.c sono nati senza, e nessuno se n'e' + * accorto finche' le release binarie non hanno iniziato a contenere quei motori + * (#720 -> #748: Kimi K3 su Windows caricava 93 layer in 42 minuti e poi restava + * fermo per sempre, perche' il gateway aspettava un byte gia' storpiato). + * Sta QUI e non copiato in ogni motore: e' esattamente cosi' che era sparito. + * + * No-op su Linux/macOS. */ +static inline void coli_serve_binary_mode(void) +{ +#ifdef _WIN32 + _setmode(_fileno(stdin), _O_BINARY); + _setmode(_fileno(stdout), _O_BINARY); + setvbuf(stdout, NULL, _IONBF, 0); +#endif +} + #endif /* COMPAT_H */ diff --git a/c/inkling.c b/c/inkling.c index 965cf56f5..1d877a9cc 100644 --- a/c/inkling.c +++ b/c/inkling.c @@ -1529,6 +1529,11 @@ static void serve_tiers_emap(Model *m) { } static void serve_loop(Model *m, Tok *T) { + /* Before the sentinel: on Windows a TEXT-mode stdout rewrites the trailing \n + * as \r\n, the gateway never matches it and waits forever (#748). Lives in + * compat.h because colibri.c has had it since #195 and this engine was + * written without it. */ + coli_serve_binary_mode(); setvbuf(stdin, NULL, _IONBF, 0); const char *sd = getenv("SEED"); if (sd) g_rng ^= (uint64_t)strtoull(sd, NULL, 10); diff --git a/c/kimi_k3.c b/c/kimi_k3.c index 9049bafc0..78668e9c6 100644 --- a/c/kimi_k3.c +++ b/c/kimi_k3.c @@ -1552,6 +1552,11 @@ static void serve_one(Model *m, Tok *T, ServeReq *q){ } static void serve_loop(Model *m, Tok *T){ + /* PRIMA del sentinella: su Windows stdout in modalita' TEXT trasforma il \n + * finale in \r\n, il gateway non lo riconosce e resta in attesa per sempre + * (#748). Vive in compat.h perche' colibri.c ce l'ha da #195 e questo motore + * e' nato senza. */ + coli_serve_binary_mode(); setvbuf(stdin,NULL,_IONBF,0); fputs("\x01\x01READY\x01\x01\n",stdout); printf("STAT 0 0.0 0.0 %.2f 0 0\n",rss_gb()); diff --git a/c/tests/test_serve_sentinel.c b/c/tests/test_serve_sentinel.c new file mode 100644 index 000000000..50328f626 --- /dev/null +++ b/c/tests/test_serve_sentinel.c @@ -0,0 +1,85 @@ +/* test_serve_sentinel — the serve handshake must leave the wire bytes alone. + * + * `coli` matches the engine's sentinels byte-exactly (endswith on + * "\x01\x01READY\x01\x01\n", a "^STAT ..." regex). If anything rewrites the + * trailing LF, the gateway waits for a byte that never arrives and the session + * hangs with no error at all -- which is #748: Kimi K3 on Windows loaded 93 + * layers in 42 minutes and then sat there forever. + * + * The cause is the CRT opening stdout in TEXT mode on Windows, where '\n' is + * written as "\r\n". colibri.c has called _setmode(..., _O_BINARY) since #195; + * inkling.c and kimi_k3.c were written without it, and nothing in the tree + * noticed for months because no test ever looked at the bytes. + * + * This is that test. It writes the sentinel through a real FILE* to a real file + * with coli_serve_binary_mode() applied, then reads the bytes back and asserts + * there is no CR. On Linux and macOS it can only pass -- which is the point: + * it is a Windows regression guard that costs nothing to carry elsewhere, and + * CI now builds every engine on Windows (#736), so it runs where it matters. + */ +#include +#include +#include +#ifndef _WIN32 +#include /* close() dopo mkstemp */ +#endif +#include "../compat.h" + +#define SENTINEL "\x01\x01READY\x01\x01\n" + +static int fails = 0; + +static void check(int cond, const char *what) +{ + printf(" %s %s\n", cond ? "ok " : "FAIL", what); + if (!cond) fails++; +} + +int main(void) +{ + char path[] = "serve_sentinel_XXXXXX"; + /* mkstemp gives a unique name; we want a FILE* we control the mode of, so + * close the fd and reopen by name. */ + int fd = mkstemp(path); + if (fd < 0) { perror("mkstemp"); return 2; } + close(fd); + + FILE *f = fopen(path, "w"); /* deliberately TEXT mode: the bug's condition */ + if (!f) { perror("fopen"); remove(path); return 2; } + + /* Same call the serve loops make before emitting the handshake. On Windows it + * flips the stream to binary; elsewhere it is a no-op. We point it at stdout, + * so exercise the same primitive here on our own stream. */ + coli_serve_binary_mode(); +#ifdef _WIN32 + _setmode(_fileno(f), _O_BINARY); +#endif + + fputs(SENTINEL, f); + fprintf(f, "STAT 0 0.0 0.0 %.2f 0 0\n", 1.0); + fclose(f); + + FILE *r = fopen(path, "rb"); /* rb: read the bytes as they landed */ + if (!r) { perror("fopen rb"); remove(path); return 2; } + char buf[256]; + size_t n = fread(buf, 1, sizeof(buf), r); + fclose(r); + remove(path); + + printf("test_serve_sentinel: %zu bytes written\n", n); + + check(n >= sizeof(SENTINEL) - 1, "something was written"); + check(memcmp(buf, SENTINEL, sizeof(SENTINEL) - 1) == 0, + "READY sentinel is byte-exact"); + check(memchr(buf, '\r', n) == NULL, + "no CR anywhere -- a TEXT-mode stream would have inserted one (#748)"); + + /* The STAT line is matched by a "^STAT " regex on a line basis, so its + * terminator has to be a bare LF too. */ + const char *stat = memchr(buf, 'S', n); + check(stat && strncmp(stat, "STAT ", 5) == 0, "STAT line follows the sentinel"); + check(buf[n - 1] == '\n', "output ends with a bare LF"); + + printf("test_serve_sentinel: %s\n", fails ? "FAILED" : "ok"); + return fails ? 1 : 0; +}