* plugin,gui: a stalled stream can't wedge the host, stale reads can't draw, and the browser suite runs in CI internal/plugin: the host has one reader of the child's stdout, and it handed each message to its call by sending on that call's channel. A caller that stopped draining a streamed body (a slow HTTP client on a plugin-backed chat stream) filled the channel; the reader then blocked, and every other call through the host -- all plugin providers and accounts, moved built-ins included -- stopped being dispatched until the stalled stream was drained. A second call's answer arrived only after the first was drained. Each call now queues its head and chunks in a bounded backlog (4 MiB of chunks, 4096 of them) drained by its own reader, so the host's reader never waits on one consumer; a call whose consumer stops reading is given up on alone, with the limit as why. Aborts go through a goroutine too (abortLoop), so a child that stopped reading its stdin can't make an abort wait either. host_test.go covers the stalled consumer, the bounds and the abort path. internal/gui: loadUsage() and refreshUsage() both read the Overview's usage?period=, and neither checked after the await that its answer was still the newest: a 24 h answer landing after a 7 d one drew the old period's figures under the new period's name, and leaving the tab could still write the Overview's cost into the shared header. Both now claim one shared read generation before the request and draw only when it is still the newest and the page, tab and period are unchanged. providersWhileFetching() also stopped for good after one failed poll or reload; it now asks again. usage-stale-answer.test.cjs and providers-fetching-retry.test.cjs lift the real functions out of app.js and drive them with a faked api/page/timers, so they need no browser; usage-refresh-flight.test.cjs gains the browser case for the same race. Makefile/.github: `make test-ui` runs every internal/gui/tests/*.test.cjs through Node's test runner -- auto-discovered, UI_TEST_CONCURRENCY files at a time (default 2) -- and a new `ui` job in Test runs it on ubuntu-latest with Playwright's Chromium and WebKit. A missing browser fails the job; it never skips to a pass. internal/gui/tests/README.md points at `make test-ui` instead of a hand-kept command line. Verified: node --test on the two new files 21/21; go test -count=1 ./internal/plugin/ ok; go test -race -count=1 ./internal/plugin/ ok; go vet ./internal/plugin/ and go build ./... clean; node --check on the changed JS; actionlint on the workflow. Not verified: the full browser suite is not green yet. `make test-ui` is 1641 passed / 70 failed of 1711 across 225 files, and 12 of the 19 failing files fail the same way on a pristine main (a known baseline: a fixture boots the page in web mode while asserting the desktop window's narrow behaviour). The `ui` job will be red until those baselines are dealt with -- nothing is skipped or masked -- and the job has not yet run on a GitHub-hosted runner. * plugin: distinguish stalled readers from large responses * plugin: clarify best-effort abort delivery
292 lines
8.9 KiB
Go
292 lines
8.9 KiB
Go
package gateway
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const pluginReplyChunk = 8 << 10
|
|
|
|
var pluginReplyText = strings.Repeat("0123456789abcdef", pluginReplyChunk/16)
|
|
|
|
func pluginReplyFrame() string {
|
|
return `data: {"id":"large","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"` +
|
|
pluginReplyText + `"},"finish_reason":null}]}` + "\n\n"
|
|
}
|
|
|
|
// The downstream is a real HTTP client, so its pace backpressures the
|
|
// gateway, not just a fake plugin reader. All upstream traffic stays local.
|
|
func pluginReplyGateway(t *testing.T, vendor http.Handler) (string, string, *http.Client) {
|
|
t.Helper()
|
|
pid := besideFake(t, "stream-regression", vendor)
|
|
gw := httptest.NewUnstartedServer(New().Handler())
|
|
gw.Config.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
|
|
if c, ok := c.(*net.TCPConn); ok {
|
|
_ = c.SetWriteBuffer(32 << 10)
|
|
}
|
|
return ctx
|
|
}
|
|
gw.Start()
|
|
t.Cleanup(func() {
|
|
gw.CloseClientConnections()
|
|
gw.Close()
|
|
})
|
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
|
tr.Proxy = nil
|
|
tr.DisableCompression = true
|
|
tr.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
c, err := (&net.Dialer{}).DialContext(ctx, network, address)
|
|
if err == nil {
|
|
if tcp, ok := c.(*net.TCPConn); ok {
|
|
_ = tcp.SetReadBuffer(32 << 10)
|
|
}
|
|
}
|
|
return c, err
|
|
}
|
|
t.Cleanup(tr.CloseIdleConnections)
|
|
return gw.URL, pid + "/fake-1", &http.Client{Transport: tr, Timeout: 75 * time.Second}
|
|
}
|
|
|
|
func pluginReplyRequest(model string, stream bool) string {
|
|
return fmt.Sprintf(`{"model":%q,"stream":%t,"messages":[{"role":"user","content":"local fixture"}]}`, model, stream)
|
|
}
|
|
|
|
type pluginPacedReader struct {
|
|
r io.Reader
|
|
rate int64
|
|
read int64
|
|
started time.Time
|
|
}
|
|
|
|
func (p *pluginPacedReader) Read(b []byte) (int, error) {
|
|
if p.started.IsZero() {
|
|
p.started = time.Now()
|
|
}
|
|
n, err := p.r.Read(b)
|
|
p.read += int64(n)
|
|
if n > 0 && p.rate > 0 {
|
|
due := p.started.Add(time.Duration(p.read * int64(time.Second) / p.rate))
|
|
if wait := time.Until(due); wait > 0 {
|
|
time.Sleep(wait)
|
|
}
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func pluginReplyComplete(t *testing.T, size int, stream bool, rate int64) time.Duration {
|
|
t.Helper()
|
|
produced := make(chan time.Time, 1)
|
|
up := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Plugin accounts require an upstream stream even for a downstream
|
|
// JSON reply; the gateway assembles that reply from these events.
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
frame := pluginReplyFrame()
|
|
for i := 0; i < size/pluginReplyChunk; i++ {
|
|
if _, err := io.WriteString(w, frame); err != nil {
|
|
return
|
|
}
|
|
w.(http.Flusher).Flush()
|
|
}
|
|
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
|
select {
|
|
case produced <- time.Now():
|
|
default:
|
|
}
|
|
})
|
|
url, model, client := pluginReplyGateway(t, up)
|
|
start := time.Now()
|
|
res, err := client.Post(url+"/v1/chat/completions", "application/json", strings.NewReader(pluginReplyRequest(model, stream)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(io.LimitReader(res.Body, 1024))
|
|
t.Fatalf("status %d: %s", res.StatusCode, b)
|
|
}
|
|
reader := &pluginPacedReader{r: res.Body, rate: rate}
|
|
got := sha256.New()
|
|
total := 0
|
|
if !stream {
|
|
var reply struct {
|
|
Choices []struct {
|
|
Message struct{ Content string }
|
|
}
|
|
}
|
|
if err := json.NewDecoder(reader).Decode(&reply); err != nil {
|
|
t.Fatalf("JSON reply: %v", err)
|
|
}
|
|
if len(reply.Choices) != 1 {
|
|
t.Fatalf("JSON choices = %d, want 1", len(reply.Choices))
|
|
}
|
|
text := reply.Choices[0].Message.Content
|
|
total = len(text)
|
|
_, _ = io.WriteString(got, text)
|
|
} else {
|
|
scanner := bufio.NewScanner(reader)
|
|
scanner.Buffer(make([]byte, 16<<10), 256<<10)
|
|
done := false
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, "data:") {
|
|
continue
|
|
}
|
|
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
if data == "[DONE]" {
|
|
done = true
|
|
continue
|
|
}
|
|
var event struct {
|
|
Choices []struct {
|
|
Delta struct{ Content string }
|
|
}
|
|
Error json.RawMessage
|
|
}
|
|
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
|
t.Fatalf("SSE event after %d content bytes: %v; tail %q", total, err, data[max(0, len(data)-512):])
|
|
}
|
|
if len(event.Error) > 0 {
|
|
t.Fatalf("SSE error after %d content bytes: %s", total, event.Error)
|
|
}
|
|
for _, choice := range event.Choices {
|
|
total += len(choice.Delta.Content)
|
|
_, _ = io.WriteString(got, choice.Delta.Content)
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
t.Fatalf("SSE read after %d content bytes: %v", total, err)
|
|
}
|
|
if !done {
|
|
t.Fatalf("SSE ended without [DONE] after %d content bytes", total)
|
|
}
|
|
}
|
|
want := sha256.New()
|
|
for i := 0; i < size/pluginReplyChunk; i++ {
|
|
_, _ = io.WriteString(want, pluginReplyText)
|
|
}
|
|
if total != size || string(got.Sum(nil)) != string(want.Sum(nil)) {
|
|
t.Fatalf("reply bytes/hash = %d/%x, want %d/%x", total, got.Sum(nil), size, want.Sum(nil))
|
|
}
|
|
elapsed := time.Since(start)
|
|
select {
|
|
case at := <-produced:
|
|
t.Logf("content=%d wire_read=%d rate=%dB/s elapsed=%v vendor_finished_after=%v", total, reader.read, rate, elapsed, at.Sub(start))
|
|
case <-time.After(time.Second):
|
|
t.Fatal("the vendor did not finish its reply")
|
|
}
|
|
return elapsed
|
|
}
|
|
|
|
func TestPluginStreamLargeReplies(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
stream bool
|
|
rate int64
|
|
}{
|
|
{"JSON fast", false, 0},
|
|
{"JSON throttled", false, 3 << 20},
|
|
{"SSE fast", true, 0},
|
|
{"SSE throttled", true, 3 << 20},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
pluginReplyComplete(t, 24<<20, tc.stream, tc.rate)
|
|
})
|
|
}
|
|
}
|
|
|
|
// The downstream keeps reading for longer than the ten-second stall window.
|
|
// Its duration is observable here; the private plugin queue's watermark is
|
|
// not. The host's focused tests cover progress while above that mark.
|
|
func TestPluginStreamLongActiveReader(t *testing.T) {
|
|
elapsed := pluginReplyComplete(t, 40<<20, true, 5<<19)
|
|
if elapsed < 12*time.Second {
|
|
t.Fatalf("long throttled reader ran for %v, want more than the stall window", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestPluginStreamStalledClientKeepsOtherRequestsMoving(t *testing.T) {
|
|
sent := make(chan struct{}, 1)
|
|
up := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var q struct {
|
|
Messages []struct{ Content string }
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&q); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
if len(q.Messages) == 1 && q.Messages[0].Content == "second fixture" {
|
|
_, _ = io.WriteString(w, `data: {"id":"small","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}`+"\n\ndata: [DONE]\n\n")
|
|
return
|
|
}
|
|
frame := pluginReplyFrame()
|
|
for i := 0; i < (8<<20)/pluginReplyChunk; i++ {
|
|
if _, err := io.WriteString(w, frame); err != nil {
|
|
return
|
|
}
|
|
w.(http.Flusher).Flush()
|
|
if i == (1<<20)/pluginReplyChunk-1 {
|
|
select {
|
|
case sent <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
<-r.Context().Done()
|
|
})
|
|
url, model, client := pluginReplyGateway(t, up)
|
|
conn, err := net.DialTimeout("tcp", strings.TrimPrefix(url, "http://"), 3*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
if tcp, ok := conn.(*net.TCPConn); ok {
|
|
_ = tcp.SetReadBuffer(16 << 10)
|
|
}
|
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
body := pluginReplyRequest(model, true)
|
|
if _, err := fmt.Fprintf(conn, "POST /v1/chat/completions HTTP/1.1\r\nHost: fixture\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
line, err := bufio.NewReader(conn).ReadString('\n')
|
|
if err != nil || !strings.Contains(line, " 200 ") {
|
|
t.Fatalf("stalled client's status = %q, %v", line, err)
|
|
}
|
|
select {
|
|
case <-sent:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the vendor did not stream a backlog")
|
|
}
|
|
// Only the status line was consumed. Let the finite socket buffers fill.
|
|
time.Sleep(250 * time.Millisecond)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
second := strings.Replace(pluginReplyRequest(model, false), "local fixture", "second fixture", 1)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url+"/v1/chat/completions", strings.NewReader(second))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
start := time.Now()
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("second plugin request with a stalled reader: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
b, err := io.ReadAll(res.Body)
|
|
if err != nil || res.StatusCode != http.StatusOK || !strings.Contains(string(b), `"content":"ok"`) {
|
|
t.Fatalf("second plugin reply = %d %q, %v", res.StatusCode, b, err)
|
|
}
|
|
t.Logf("second plugin request completed in %v while the first client stopped reading", time.Since(start))
|
|
}
|