142 lines
6.7 KiB
JavaScript
142 lines
6.7 KiB
JavaScript
/**
|
|
* @file Prüft Antwortstreaming, Zeitlimits, Protokollfehler und Abbrüche bei getrennten Verbindungen.
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createServer } from "node:http";
|
|
import { createModelService } from "../src/services/modelService.js";
|
|
import { createApp } from "../src/app.js";
|
|
import { stream } from "../public/js/api.js";
|
|
|
|
const config = {
|
|
modelApiBaseUrl: "http://fixture", modelRequestTimeoutMs: 20,
|
|
modelConnectTimeoutMs: 1000, modelInactivityTimeoutMs: 1000,
|
|
modelMaxGenerationMs: 3000, modelUnloadAfterMs: 60000
|
|
};
|
|
const line = data => new TextEncoder().encode(JSON.stringify(data) + "\n");
|
|
|
|
test("active tokens outlive the legacy total timeout and arrive before completion", async () => {
|
|
let finish;
|
|
let first;
|
|
const firstChunk = new Promise(resolve => { first = resolve; });
|
|
let interval;
|
|
const service = createModelService(config, {
|
|
fetch: async () => new Response(new ReadableStream({
|
|
start(controller) {
|
|
interval = setInterval(() => controller.enqueue(line({ message: { content: "x" } })), 15);
|
|
finish = () => { clearInterval(interval); controller.enqueue(line({ done: true })); controller.close(); };
|
|
},
|
|
cancel() { clearInterval(interval); }
|
|
}))
|
|
});
|
|
let completed = false;
|
|
const pending = service.streamReply("qwen3:14b", [], () => first()).then(reply => { completed = true; return reply; });
|
|
try {
|
|
await firstChunk;
|
|
assert.equal(completed, false);
|
|
await new Promise(resolve => setTimeout(resolve, 80));
|
|
assert.equal(completed, false);
|
|
finish();
|
|
assert.ok((await pending).length >= 2);
|
|
} finally { clearInterval(interval); }
|
|
});
|
|
|
|
test("connection, inactivity and overall deadlines abort generation", async (t) => {
|
|
for (const kind of ["connection", "inactivity", "overall"]) await t.test(kind, async () => {
|
|
let cancelled = false;
|
|
let interval;
|
|
const service = createModelService({
|
|
...config,
|
|
modelConnectTimeoutMs: kind === "connection" ? 25 : 1000,
|
|
modelInactivityTimeoutMs: kind === "inactivity" ? 25 : 1000,
|
|
modelMaxGenerationMs: kind === "overall" ? 70 : 1000
|
|
}, {
|
|
fetch: async (_url, { signal }) => {
|
|
if (kind === "connection") return new Promise((_resolve, reject) => {
|
|
signal.addEventListener("abort", () => { cancelled = true; reject(new DOMException("Aborted", "AbortError")); });
|
|
});
|
|
return new Response(new ReadableStream({
|
|
start(controller) {
|
|
if (kind === "overall") interval = setInterval(() => controller.enqueue(line({ message: { content: "x" } })), 10);
|
|
},
|
|
cancel() { cancelled = true; clearInterval(interval); }
|
|
}));
|
|
}
|
|
});
|
|
await assert.rejects(service.streamReply("qwen3:14b", [], () => { }), { code: "MODEL_TIMEOUT" });
|
|
assert.equal(cancelled, true);
|
|
});
|
|
});
|
|
|
|
test("stream validation rejects truncated output, upstream errors and invalid fields", async () => {
|
|
for (const body of [
|
|
'{"message":{"content":"partial"}}\n',
|
|
'{"error":"internal upstream details"}\n',
|
|
'{"message":{"content":42},"done":true}\n',
|
|
'{broken}\n'
|
|
]) {
|
|
const service = createModelService(config, { fetch: async () => new Response(body) });
|
|
await assert.rejects(service.generateReply("qwen3:14b", []));
|
|
}
|
|
const service = createModelService(config, { fetch: async () => { throw new Error("Connection refused"); } });
|
|
await assert.rejects(service.generateReply("qwen3:14b", []), { code: "MODEL_UNAVAILABLE" });
|
|
});
|
|
|
|
test("SSE EOF is a failure rather than successful completion", async (t) => {
|
|
const original = globalThis.fetch;
|
|
t.after(() => { globalThis.fetch = original; });
|
|
globalThis.fetch = async () => new Response('event: chunk\ndata: {"fullReply":"partial"}\n\n');
|
|
const chunks = [];
|
|
await assert.rejects(stream("/fixture", {}, { onChunk: data => chunks.push(data.fullReply) }), /interrupted/);
|
|
assert.deepEqual(chunks, ["partial"]);
|
|
});
|
|
|
|
test("browser disconnect aborts Ollama and incomplete output is not persisted", async (t) => {
|
|
let signal;
|
|
let releaseAbort;
|
|
const aborted = new Promise(resolve => { releaseAbort = resolve; });
|
|
const app = createApp({
|
|
config: {
|
|
dbPath: ":memory:", testMode: true, sessionSecret: "test-only",
|
|
chatSummaryUpdateEveryMessages: 0, structuredRequestLoggingEnabled: false,
|
|
eventLoopLagMonitorEnabled: false, slowRequestLoggingEnabled: false
|
|
}, modelOverrides: {
|
|
fetch: async (_url, options) => {
|
|
signal = options.signal;
|
|
signal.addEventListener("abort", releaseAbort, { once: true });
|
|
return new Response(new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(line({ message: { content: "partial" } }));
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
});
|
|
const server = createServer(app);
|
|
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
t.after(async () => {
|
|
await new Promise(resolve => { server.closeAllConnections(); server.close(resolve); });
|
|
app.locals.close();
|
|
});
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
const registered = await fetch(base + "/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "disconnect", password: "test-password" }) });
|
|
const cookie = registered.headers.get("set-cookie").split(";")[0];
|
|
const headers = { "Content-Type": "application/json", Cookie: cookie };
|
|
const chat = await (await fetch(base + "/chats", { method: "POST", headers, body: '{"title":"Disconnect"}' })).json();
|
|
const controller = new AbortController();
|
|
const response = await fetch(base + "/chat/stream", {
|
|
method: "POST", headers, signal: controller.signal,
|
|
body: JSON.stringify({ chatId: chat.chat.id, message: "Hello" })
|
|
});
|
|
const reader = response.body.getReader();
|
|
let received = "";
|
|
while (!received.includes("event: chunk")) received += new TextDecoder().decode((await reader.read()).value);
|
|
controller.abort();
|
|
await Promise.race([aborted, new Promise((_resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error("Upstream was not aborted")), 1000);
|
|
timer.unref();
|
|
})]);
|
|
assert.equal(signal.aborted, true);
|
|
assert.equal(app.locals.db.prepare("SELECT COUNT(*) AS count FROM chat_messages WHERE role = 'bot'").get().count, 0);
|
|
});
|