409 lines
18 KiB
C
409 lines
18 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/*
|
|
* test_backend_guard.c — the silent-deploy class, in four parts.
|
|
*
|
|
* The incident: a CPU-only `pantheon` binary sat at the path the K80 fleet
|
|
* manifest names, served a 35B-A3B MoE on a 2-core no-AVX Pentium with all 8
|
|
* K80 dies idle at 0 MiB for an unknown period, advertised "max_concurrent":2
|
|
* while actually serving 1, and left nothing in any log that said so.
|
|
* Diagnosing it took `ldd` and `strings` on a remote box.
|
|
*
|
|
* Part A — BACKEND IDENTITY. The process can state its own compiled backend.
|
|
* Compile-time constants, so this arm is proved by the preprocessor:
|
|
* on a CUDA build it asserts CUDA, on this Mac build it asserts CPU.
|
|
*
|
|
* Part B — THE REFUSAL. pn_backend_guard_check's full truth table, including
|
|
* the two rows that matter most: `--dies` on a binary with no
|
|
* accelerator backend must REFUSE, and no `--dies` at all must stay OK
|
|
* on every backend (that is how a legitimate CPU serve is asked for).
|
|
* Also pins the "no accelerator" substring model_admin.c's
|
|
* status_for_reason matches on, so the 400 mapping cannot rot.
|
|
*
|
|
* Part C — mr_load REFUSES a die list it cannot honour, which is the runtime
|
|
* POST /admin/models/load path (it reaches mr_load directly and never
|
|
* passes through pn_serve_main's pre-flight).
|
|
*
|
|
* Part D — THE ADVERTISED NUMBER. mr_snapshot must report SERVED concurrency,
|
|
* and the two JSON writers must emit it. Driven through the real
|
|
* oai_handle_models / ma_handle_models over a socketpair, so what is
|
|
* asserted is the exact bytes a client and an operator receive — not a
|
|
* restatement of the struct.
|
|
*
|
|
* No GPU, no model file, no network: builds and runs anywhere.
|
|
*/
|
|
|
|
#include "../src/accel/backend_identity.h"
|
|
#include "../src/serve/serve_backend_guard.h"
|
|
#include "../src/serve/model_admin.h"
|
|
#include "../src/serve/model_registry.h"
|
|
#include "../src/serve/openai_chat.h"
|
|
#include "../src/serve/serve_generation.h"
|
|
#include "../src/core/engine.h"
|
|
|
|
#include <ctype.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/socket.h>
|
|
#include <unistd.h>
|
|
|
|
static int g_failed = 0;
|
|
|
|
#define CHECK(cond, msg) do { \
|
|
if (!(cond)) { \
|
|
fprintf(stderr, " FAIL: %s (%s:%d)\n", (msg), __FILE__, __LINE__); \
|
|
g_failed++; \
|
|
} \
|
|
} while (0)
|
|
|
|
/* --- Part A: backend identity -------------------------------------------- */
|
|
|
|
static void test_backend_identity(void)
|
|
{
|
|
printf("backend_identity:\n");
|
|
const char *name = pn_backend_name();
|
|
const char *token = pn_backend_token();
|
|
const char *build = pn_backend_build_commit();
|
|
|
|
CHECK(name && *name, "name is non-empty");
|
|
CHECK(token && *token, "token is non-empty");
|
|
CHECK(build && *build, "build commit is non-empty");
|
|
|
|
/* The two spellings are one enumeration. A drift here is a binary that
|
|
* logs one backend and asserts another at deploy time. */
|
|
CHECK(strlen(name) == strlen(token), "name and token are the same value");
|
|
for (size_t i = 0; name[i] && token[i]; i++)
|
|
CHECK(tolower((unsigned char)name[i]) == (unsigned char)token[i],
|
|
"token is the lowercase name");
|
|
|
|
#if defined(USE_CUDA)
|
|
CHECK(strncmp(token, "cuda", 4) == 0, "CUDA build reports cuda");
|
|
CHECK(pn_backend_has_accelerator(), "CUDA build has an accelerator");
|
|
CHECK(pn_backend_supports_die_placement(), "CUDA build places on dies");
|
|
#elif defined(USE_TT)
|
|
CHECK(strncmp(token, "tt", 2) == 0, "TT build reports tt");
|
|
CHECK(pn_backend_has_accelerator(), "TT build has an accelerator");
|
|
CHECK(pn_backend_supports_die_placement(), "TT build places on dies");
|
|
#elif defined(USE_METAL)
|
|
CHECK(strcmp(token, "metal") == 0, "Metal build reports metal");
|
|
CHECK(pn_backend_has_accelerator(), "Metal build has an accelerator");
|
|
/* Metal has no pn_dm_init_* path, so --dies can never resolve on it. */
|
|
CHECK(!pn_backend_supports_die_placement(), "Metal build places on no dies");
|
|
#else
|
|
CHECK(strcmp(token, "cpu") == 0, "plain build reports cpu");
|
|
CHECK(!pn_backend_has_accelerator(), "plain build has no accelerator");
|
|
CHECK(!pn_backend_supports_die_placement(), "plain build places on no dies");
|
|
#endif
|
|
printf(" backend=%s build=%s\n", name, build);
|
|
}
|
|
|
|
/* --- Part B: the refusal -------------------------------------------------- */
|
|
|
|
static void test_guard_truth_table(void)
|
|
{
|
|
printf("guard_truth_table:\n");
|
|
|
|
/* THE LEGITIMATE CPU SERVE: no --dies. Must stay OK on every backend, or
|
|
* the guard becomes something operators route around. */
|
|
CHECK(pn_backend_guard_check(0, false, 0) == PN_BACKEND_GUARD_OK,
|
|
"no dies on a CPU build is OK (the deliberate CPU serve)");
|
|
CHECK(pn_backend_guard_check(0, true, 8) == PN_BACKEND_GUARD_OK,
|
|
"no dies on a CUDA build is OK");
|
|
CHECK(pn_backend_guard_check(0, true, 0) == PN_BACKEND_GUARD_OK,
|
|
"no dies with no dies visible is OK");
|
|
|
|
/* THE INCIDENT: `--dies 0,1` accepted by a binary with no CUDA at all. */
|
|
CHECK(pn_backend_guard_check(2, false, 0) == PN_BACKEND_GUARD_NO_DIE_BACKEND,
|
|
"dies on a binary with no die backend REFUSED");
|
|
/* Logical dies (pn_dm_init_logical) are bookkeeping, not silicon: a
|
|
* non-zero die count must not launder a CPU binary into an accelerator. */
|
|
CHECK(pn_backend_guard_check(2, false, 4) == PN_BACKEND_GUARD_NO_DIE_BACKEND,
|
|
"dies on a CPU build REFUSED even with logical dies registered");
|
|
|
|
/* Right binary, no card / no driver / empty CUDA_VISIBLE_DEVICES. */
|
|
CHECK(pn_backend_guard_check(2, true, 0) == PN_BACKEND_GUARD_NO_DIES_VISIBLE,
|
|
"dies with zero dies visible REFUSED");
|
|
|
|
/* The working configuration. */
|
|
CHECK(pn_backend_guard_check(2, true, 8) == PN_BACKEND_GUARD_OK,
|
|
"dies on a die-capable build with dies visible is OK");
|
|
CHECK(pn_backend_guard_check(1, true, 1) == PN_BACKEND_GUARD_OK,
|
|
"one die, one visible, is OK");
|
|
}
|
|
|
|
static void test_guard_message(void)
|
|
{
|
|
printf("guard_message:\n");
|
|
const int dies[2] = { 0, 1 };
|
|
char msg[640];
|
|
|
|
/* OK writes nothing — a caller that prints unconditionally must not emit a
|
|
* scary empty refusal on the happy path. */
|
|
memset(msg, 'x', sizeof(msg));
|
|
CHECK(pn_backend_guard_message(PN_BACKEND_GUARD_OK, "saber-orch", dies, 2,
|
|
msg, sizeof(msg)) == 0,
|
|
"OK verdict writes nothing");
|
|
CHECK(msg[0] == '\0', "OK verdict leaves an empty string");
|
|
|
|
int n = pn_backend_guard_message(PN_BACKEND_GUARD_NO_DIE_BACKEND,
|
|
"saber-orch", dies, 2, msg, sizeof(msg));
|
|
CHECK(n > 0 && (size_t)n < sizeof(msg), "message fits the buffer");
|
|
CHECK(strstr(msg, "REFUSED") != NULL, "message says REFUSED");
|
|
CHECK(strstr(msg, "saber-orch") != NULL, "message names the model");
|
|
CHECK(strstr(msg, "0,1") != NULL, "message names the dies asked for");
|
|
/* Cause AND fix, both required: an operator reading a fleet log has neither
|
|
* the source nor the build command in front of them. */
|
|
CHECK(strstr(msg, "no accelerator") != NULL ||
|
|
strstr(msg, "NO accelerator") != NULL, "message names the cause");
|
|
CHECK(strstr(msg, "make USE_CUDA=1") != NULL, "message names the fix");
|
|
CHECK(strstr(msg, "--dies") != NULL, "message names the CPU-serve escape");
|
|
CHECK(strchr(msg, '\n') == NULL, "message is ONE greppable line");
|
|
|
|
n = pn_backend_guard_message(PN_BACKEND_GUARD_NO_DIES_VISIBLE, "saber-orch",
|
|
dies, 2, msg, sizeof(msg));
|
|
CHECK(n > 0, "no-dies-visible message is written");
|
|
CHECK(strstr(msg, "0 dies") != NULL, "no-dies-visible message says 0 dies");
|
|
|
|
/* Truncation must not overrun: a 16-byte buffer still NUL-terminates. */
|
|
char tiny[16];
|
|
pn_backend_guard_message(PN_BACKEND_GUARD_NO_DIE_BACKEND, "x", dies, 2,
|
|
tiny, sizeof(tiny));
|
|
CHECK(tiny[sizeof(tiny) - 1] == '\0', "truncated message stays terminated");
|
|
|
|
/* status_for_reason() in model_admin.c matches this substring to answer 400
|
|
* instead of the catch-all 500. Pin it here so the two cannot drift. */
|
|
const char *r1 = pn_backend_guard_short_reason(PN_BACKEND_GUARD_NO_DIE_BACKEND);
|
|
const char *r2 = pn_backend_guard_short_reason(PN_BACKEND_GUARD_NO_DIES_VISIBLE);
|
|
CHECK(r1 && strstr(r1, "no accelerator") != NULL,
|
|
"short reason carries the 'no accelerator' marker");
|
|
CHECK(r2 && strstr(r2, "no accelerator") != NULL,
|
|
"short reason (no dies visible) carries the marker");
|
|
CHECK(pn_backend_guard_short_reason(PN_BACKEND_GUARD_OK) == NULL,
|
|
"OK has no short reason");
|
|
}
|
|
|
|
/* --- Part C: mr_load refuses a die list it cannot honour ------------------- */
|
|
|
|
static void test_mr_load_refuses_dies(void)
|
|
{
|
|
printf("mr_load_refuses_dies:\n");
|
|
mr_registry reg;
|
|
int dies[2] = { 0, 1 };
|
|
char err[192] = "";
|
|
|
|
CHECK(mr_init(®) == 0, "init");
|
|
|
|
/* The guard runs BEFORE the stat(), so a bogus path still exercises it —
|
|
* and proves the refusal is about the dies, not about the file. */
|
|
CHECK(mr_load(®, "/no/such/file.gguf", "saber-orch", NULL, dies, 2,
|
|
0, 0, 0, err, sizeof(err)) == -1,
|
|
"die list refused on this build");
|
|
/* On a die-capable build with no dies probed the refusal is the OTHER arm;
|
|
* either way it must be the backend guard talking, not the missing file. */
|
|
CHECK(strstr(err, "no accelerator") != NULL,
|
|
"refusal is the backend guard, not the file check");
|
|
CHECK(mr_count(®) == 0, "refused load leaves the registry empty");
|
|
|
|
/* And the CPU path is untouched: with no dies the same call gets as far as
|
|
* the file check, which is the ONLY thing that should stop it. */
|
|
err[0] = '\0';
|
|
CHECK(mr_load(®, "/no/such/file.gguf", "saber-orch", NULL, NULL, 0,
|
|
0, 0, 0, err, sizeof(err)) == -1, "missing file still refused");
|
|
CHECK(strstr(err, "not found") != NULL,
|
|
"no-dies load is refused by the FILE check, not the backend guard");
|
|
|
|
mr_free(®);
|
|
}
|
|
|
|
/* --- Part D: the advertised number ---------------------------------------- */
|
|
|
|
/* A zeroed pn_model: mr_snapshot reads only config fields off it
|
|
* (pn_effective_ctx_window / pn_session_ctx_cap) and the residency gauges,
|
|
* all of which handle an all-zero config. No load, no weights, no GPU. */
|
|
static pn_model g_fake_model;
|
|
|
|
/* Hand-build one resident entry: max_concurrent CONFIGURED at `configured`,
|
|
* and no scheduler — precisely the incident's state (bs_create is a
|
|
* `#ifndef USE_CUDA` stub returning NULL, so entry->sched was NULL while the
|
|
* entry kept the cap the operator asked for). */
|
|
static void seed_entry(mr_registry *reg, const char *name, int configured)
|
|
{
|
|
mr_entry *e = ®->entries[0];
|
|
memset(e, 0, sizeof(*e));
|
|
e->used = true;
|
|
snprintf(e->name, sizeof(e->name), "%s", name);
|
|
e->model = &g_fake_model;
|
|
e->state = NULL;
|
|
e->sched = NULL;
|
|
e->max_sessions = configured;
|
|
e->max_concurrent = configured;
|
|
reg->n = 1;
|
|
}
|
|
|
|
/* Run one handler against a socketpair and return its whole response body.
|
|
* Caller frees. This is the real writer over a real fd — what is asserted is
|
|
* the bytes a client receives, not a restatement of the struct. */
|
|
static char *capture_response(void (*handler)(int, mr_registry *),
|
|
mr_registry *reg)
|
|
{
|
|
int sv[2];
|
|
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0)
|
|
return NULL;
|
|
handler(sv[1], reg);
|
|
close(sv[1]);
|
|
|
|
size_t cap = 65536, len = 0;
|
|
char *buf = malloc(cap);
|
|
if (!buf) { close(sv[0]); return NULL; }
|
|
for (;;) {
|
|
ssize_t r = read(sv[0], buf + len, cap - len - 1);
|
|
if (r <= 0) break;
|
|
len += (size_t)r;
|
|
if (len + 1 >= cap) break;
|
|
}
|
|
buf[len] = '\0';
|
|
close(sv[0]);
|
|
return buf;
|
|
}
|
|
|
|
static void test_served_concurrency(void)
|
|
{
|
|
printf("served_concurrency:\n");
|
|
mr_registry reg;
|
|
mr_info info[MR_MAX_MODELS];
|
|
|
|
CHECK(mr_init(®) == 0, "init");
|
|
seed_entry(®, "saber-orch", 4);
|
|
|
|
CHECK(mr_snapshot(®, info, MR_MAX_MODELS) == 1, "one model in snapshot");
|
|
CHECK(info[0].max_concurrent == 4, "configured cap is preserved");
|
|
/* THE BUG: the entry says 4, the serve delivers 1. bs_get_load already
|
|
* knew this (capacity 1 for a NULL scheduler) — only the two JSON writers
|
|
* were not asking it. */
|
|
CHECK(info[0].served_concurrency == 1,
|
|
"served concurrency is 1 with no scheduler");
|
|
|
|
/* /v1/models — what a CLIENT plans against. */
|
|
char *body = capture_response(oai_handle_models, ®);
|
|
CHECK(body != NULL, "/v1/models responded");
|
|
if (body) {
|
|
CHECK(strstr(body, "\"max_concurrent\":1") != NULL,
|
|
"/v1/models advertises the SERVED concurrency (1)");
|
|
CHECK(strstr(body, "\"max_concurrent\":4") == NULL,
|
|
"/v1/models does NOT advertise the unserviceable cap (4)");
|
|
CHECK(strstr(body, "\"backend\":") != NULL,
|
|
"/v1/models states the compiled backend");
|
|
CHECK(strstr(body, pn_backend_token()) != NULL,
|
|
"/v1/models states THIS backend");
|
|
CHECK(strstr(body, "\"build\":") != NULL,
|
|
"/v1/models states the build commit");
|
|
free(body);
|
|
}
|
|
|
|
/* /admin/models — what an OPERATOR debugs with: both numbers. */
|
|
body = capture_response(ma_handle_models, ®);
|
|
CHECK(body != NULL, "/admin/models responded");
|
|
if (body) {
|
|
CHECK(strstr(body, "\"max_concurrent\":1") != NULL,
|
|
"/admin/models reports the served concurrency (1)");
|
|
CHECK(strstr(body, "\"max_concurrent_configured\":4") != NULL,
|
|
"/admin/models also reports the configured cap (4)");
|
|
free(body);
|
|
}
|
|
|
|
/* Clear the hand-built entry before mr_free: its model pointer is not a
|
|
* loaded model and must never reach pn_model_free. */
|
|
memset(®.entries[0], 0, sizeof(reg.entries[0]));
|
|
reg.n = 0;
|
|
mr_free(®);
|
|
}
|
|
|
|
/* tool_choice "required" with thinking: the grammar arming policy, and the
|
|
* /v1/models field a client reads before it sends "required" with thinking on.
|
|
* The field NAME is a client contract (clients match it exactly). */
|
|
static void test_required_with_thinking_policy(void)
|
|
{
|
|
printf("required_with_thinking_policy:\n");
|
|
int dummy_tools = 1;
|
|
sg_gen_params p;
|
|
memset(&p, 0, sizeof(p));
|
|
p.tools = &dummy_tools;
|
|
|
|
unsetenv("PANTHEON_TOOL_GRAMMAR");
|
|
p.tool_choice = SG_TOOL_CHOICE_REQUIRED;
|
|
CHECK(sg_tool_grammar_arms(&p), "unset: \"required\" arms the grammar");
|
|
p.tool_choice = SG_TOOL_CHOICE_AUTO;
|
|
CHECK(!sg_tool_grammar_arms(&p), "unset: \"auto\" stays unconstrained");
|
|
p.tool_choice = SG_TOOL_CHOICE_FUNCTION;
|
|
CHECK(!sg_tool_grammar_arms(&p), "unset: a named function stays unconstrained");
|
|
setenv("PANTHEON_TOOL_GRAMMAR", "1", 1);
|
|
CHECK(sg_tool_grammar_arms(&p), "=1: a named function arms");
|
|
p.tool_choice = SG_TOOL_CHOICE_AUTO;
|
|
CHECK(sg_tool_grammar_arms(&p), "=1: \"auto\" arms");
|
|
setenv("PANTHEON_TOOL_GRAMMAR", "0", 1);
|
|
p.tool_choice = SG_TOOL_CHOICE_REQUIRED;
|
|
CHECK(!sg_tool_grammar_arms(&p), "=0: even \"required\" stays unconstrained");
|
|
unsetenv("PANTHEON_TOOL_GRAMMAR");
|
|
p.tools = NULL;
|
|
CHECK(!sg_tool_grammar_arms(&p), "no tools: never arms");
|
|
|
|
mr_registry reg;
|
|
CHECK(mr_init(®) == 0, "init");
|
|
seed_entry(®, "ornith", 1);
|
|
char *body = capture_response(oai_handle_models, ®);
|
|
CHECK(body && strstr(body, "\"tool_choice_required_with_thinking\":true"),
|
|
"/v1/models: ornith advertises required-with-thinking");
|
|
free(body);
|
|
setenv("PANTHEON_TOOL_GRAMMAR", "0", 1);
|
|
body = capture_response(oai_handle_models, ®);
|
|
CHECK(body && strstr(body, "\"tool_choice_required_with_thinking\":false"),
|
|
"/v1/models: =0 withdraws it");
|
|
free(body);
|
|
unsetenv("PANTHEON_TOOL_GRAMMAR");
|
|
seed_entry(®, "saber-orch", 1);
|
|
body = capture_response(oai_handle_models, ®);
|
|
CHECK(body && strstr(body, "\"tool_choice_required_with_thinking\":true") &&
|
|
!strstr(body, "\"tool_choice_required_with_thinking\":false"),
|
|
"/v1/models: an XML-dialect model advertises it too");
|
|
free(body);
|
|
/* MiniCPM5's attribute dialect is not modelled by any grammar: a "required"
|
|
* turn there has no guarantee, so the field must stay false. */
|
|
pn_model minicpm_model;
|
|
memset(&minicpm_model, 0, sizeof(minicpm_model));
|
|
minicpm_model.config.arch = PN_ARCH_LLAMA;
|
|
minicpm_model.chat_add_bos = 1;
|
|
seed_entry(®, "minicpm5-orch", 1);
|
|
reg.entries[0].model = &minicpm_model;
|
|
body = capture_response(oai_handle_models, ®);
|
|
CHECK(body && strstr(body, "\"tool_choice_required_with_thinking\":false") &&
|
|
!strstr(body, "\"tool_choice_required_with_thinking\":true"),
|
|
"/v1/models: a MiniCPM-dialect model does not advertise it");
|
|
free(body);
|
|
CHECK(!sg_tool_choice_required_with_thinking(TC_DIALECT_MINICPM_XML),
|
|
"MiniCPM dialect: no required-with-thinking guarantee");
|
|
CHECK(sg_tool_choice_required_with_thinking(TC_DIALECT_QWEN_XML) &&
|
|
sg_tool_choice_required_with_thinking(TC_DIALECT_ORNITH_JSON),
|
|
"modelled dialects keep it");
|
|
memset(®.entries[0], 0, sizeof(reg.entries[0]));
|
|
reg.n = 0;
|
|
mr_free(®);
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
test_backend_identity();
|
|
test_guard_truth_table();
|
|
test_guard_message();
|
|
test_mr_load_refuses_dies();
|
|
test_served_concurrency();
|
|
test_required_with_thinking_policy();
|
|
|
|
if (g_failed) {
|
|
printf("\nFAILED: %d check(s)\n", g_failed);
|
|
return 1;
|
|
}
|
|
printf("\nAll checks passed.\n");
|
|
return 0;
|
|
}
|