3 Commits
Author SHA1 Message Date
yetoneandClaude 55209c5529 plugin tests: TestHostStreamCreditBoundsTheQueue waits for the given-up call to leave the host, which it does once its abort's write is known, after the child may already have read it (red on ubuntu CI at 2b5f19fc; a 50ms pause before the write is known fails the old check every time)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 00:23:17 +08:00
Snowyy c38728145b plugin: a slow reader pauses its stream instead of racing a watchdog (#812)
* plugin: a slow reader pauses its stream instead of racing a watchdog

#664 gave a plugin reply a soft watermark (softQueuedBytes 4 MiB, softQueuedEvents 4096) and a 10-second idle watchdog: past the watermark, a reader that made no progress for stallIdle had its call failed with errStalledReader, and hardQueuedBytes/hardQueuedEvents (64 MiB / 65536) cut the reply outright. Both are guesses at how long a reader may take, and both end a reply that reader was still consuming.

The host now hands each fetch a credit window instead. Go grants streamWindow (512 KiB) up front and returns credit as the reader takes bytes, so the child stops producing at the window instead of being timed out; the window is charged per delivered byte plus frameOverhead (2048), so a run of tiny frames cannot fill it with metadata. streamBudget (64 MiB) and maxFrame (64 KiB) bound a whole reply and a single frame, and a child that sends past its window is a protocol violation (errProtocolOverrun) rather than a slow reader.

The plugin side grew the matching gate (makeGate, gate.take/spend/add, waitDrain): a fetch holds at the window until Go credits it, and its pending writes are cancellable. Cancellation and shutdown now have one owner per call - a five-state lifecycle (sending, active, draining, aborting, released) whose single transition helper disposes the queue, detaches the watcher, commits the terminal answer, creates the abort and returns capacity exactly once - so a host failure, a Close, a cancelled context and an already-delivered reply can no longer race each other over who cleans up. A reply the host already delivered keeps its bytes and its vendor outcome when the host dies.

Tests: the JS cancellation cases run the production sections with controlled stdout backpressure (stopped gate, split chunk, terminal suppression, acquired-body cancel, 30,000 cancelled bootstrap waits with no retained waiter or ready reaction); the Go cases drive lifecycle orderings directly (terminal-before-failure, failure-before-terminal, abort acknowledged before capacity returns, blocked enqueue), and the shutdown, body-close and mid-setup-cancel cases block on explicit channels instead of sleeping. TestFlowBigReplyProd reads 100 MiB non-SSE and SSE through the real host and checks lengths and SHA-256, including a 12 s reader pause. CI installs Bun 1.4.2 on Linux and runs that case in its own step, since the plugin sandbox skips when bun is not on PATH.

* plugin: the stream-head credit test waits for the credits before the reply ends (CI, macos-latest: TestHostStreamHeadChunksFinal failed on the macOS runner with "the credits were given back didn't happen", while the same package passed on ubuntu-latest and locally) — the test sent the child's final answer together with its 50 chunks, so the call could be marked stopped and its pending credits dropped before waitFor saw them; a completed fetch needs no credit, so waiting longer could not recover them and the expectation was a race, not a real accounting bug. The final answer is now withheld until io.ReadFull has taken the 140 payload bytes in order and exactly 140 + 49*frameOverhead credits have been observed with frameOverhead still owed, and only then does the child answer and the test assert a zero-byte EOF read. No production code changed.

Tests: go test -run '^TestHostStreamHeadChunksFinal$' -count=500 ./internal/plugin/ and -count=200 -race pass; go test -count=1 and -race -count=1 ./internal/plugin/... pass; gofmt -l and git diff --check clean.
2026-10-05 01:36:20 +08:00
Snowyy fa1e3947f9 plugin: isolate stalled streams and guard GUI refreshes (#664)
* 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
2026-10-04 11:22:31 +08:00