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; +}