You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The interactive terminal shipped in #241 is non-functional. Every /terminal/ws connection completes its HTTP handshake and then closes immediately, with no frames and no WebSocket close frame, so the browser dialog goes straight from Connected — interactive shell to Disconnected. Restart opens a new shell....
This is not a partial failure or an edge case — no terminal session can be established at all, for any user, in any host. It reproduces against main with no local modifications.
The regression arrived with the kernal-api migration (d5afb46, #242). The terminal landed in 22ea046 against direct axum + tokio-tungstenite, and docs/interactive-terminal.md records a passing Chromium/WebKit browser validation on 2026-09-12 — before the migration moved the upgrade onto the kernal_api::http_server facade. Nothing has exercised a live session since.
Evidence
Raw WebSocket probe against an unmodified build. Server started via fastled --internal-serve-dir-headless <dir>; a Python client connects with the correct Origin/Host:
WS OPEN
WS ERROR: ConnectionClosedError no close frame received or sent
Zero frames received. The handshake succeeds; the connection then dies.
Not caused by the caller. Forcing the PTY spawn to fail with SHELL=/nonexistent/shell produces the same result, which is the diagnostic detail: terminal.rs sends {"error":"Terminal failed"} on a spawn failure, and even that frame never arrives. The application callback is not running at all.
Not the working tree.git stash of all local changes, rebuild, same failure. The browser fixture fails identically on pristine main.
Blast radius./terminal/ws is the only WebSocket route, so the terminal is the sole victim — but it is fully broken rather than degraded.
With with_upgrades(), hyper's connection future resolves as soon as the 101 Switching Protocols response has been written — the socket is handed off, and driving the connection is over. Line 528 therefore breaks the loop the instant an upgrade succeeds, and line 539 aborts every task in the JoinSet unconditionally. Any task not yet drained from upgrades_rx is dropped with the receiver instead.
The upgrade callback is cancelled microseconds after the handshake, on every connection. abort_all() reads as intended cleanup for the timeout/error paths, but it fires on the success path too.
Why the upstream test suite misses it.websocket.rs:336 — server_emits_a_real_switching_protocols_response — passes |_socket| async {} as the callback and asserts only the 101 response bytes. A callback that does nothing observable cannot detect a callback that never runs. The only other upgrade test (upgrade_rejects_non_websocket_before_claiming_transport) covers the rejection path.
Proposal
Fix upstream in kernal-api. On a successful upgrade the connection future resolving is the expected transition, not a termination condition: it must not tear down the upgrade tasks. The correct shape is to drain the JoinSet to completion (or keep serving until all upgrade tasks finish) rather than abort_all() on the normal path. Aborting is only right for the timeout and error arms.
Pin the fixed tag in this repo (Cargo.toml:16).
Add a regression test here that would have caught it, because the upstream test cannot: a Rust integration test that connects a real WebSocket client to a running server::start_server and asserts a server→client frame is received. Asserting the 101 is not sufficient — that is the exact blind spot that let this ship.
Correct the record in docs/interactive-terminal.md: the 2026-09-12 validation predates the migration and is currently misleading as evidence that the terminal works.
Acceptance criteria
RED first. A new Rust test opens a real WebSocket to the server and asserts a frame arrives from the PTY (e.g. the shell's prompt, or a marker from an injected command). Written against current main, this must fail — reproducing the silent close — before the fix.
After the fix, that test passes and the browser fixture passes: FASTLED_TERMINAL_BINARY=... FASTLED_ESBUILD=... uv run --with playwright pytest tests/frontend/test_terminal.py -v -s (Chromium and WebKit).
A terminal session survives more than the handshake in a real browser: the dialog reaches Connected — interactive shell, a typed command produces output, and the session stays up until explicitly closed.
soldr cargo test --workspace, bash lint, and bash test pass.
The failure mode is no longer silent: if the upgrade callback cannot run, the client sees an error rather than a connection that closes with no close frame. A dead terminal must not look like a normal disconnect.
Safari remains a required target: no JSPI flags, WebAssembly.Suspending, or WebAssembly.promising.
Filed here rather than in zackees/kernal-api, because this repo owns the user-visible regression, the reproduce commands, and the validation record. The code fix belongs upstream and this issue should track it.
The upstream test is not the fix. Passing |_socket| async {} and asserting 101 would go green without the terminal working, so the acceptance criteria are anchored on an observed frame from a real PTY.
No local vendor/patch of kernal-api as the primary fix — this repo's convention is native, fail-loud behavior and no shims. If an interim unblock is needed before an upstream release, that should be an explicit, separately-decided stopgap rather than the default path. Raised in Open questions.
The docs/interactive-terminal.md validation record is left in place but must be annotated rather than deleted — it is accurate for the commit it names, and misleading only by omission of the migration that followed.
Open questions
Interim unblock. v0.1.7 is the newest tag (git ls-remote --tags confirms), so there is no fixed version to pin today. Do we wait on an upstream release, or take a temporary patch/pin (a [patch] override or a fork) so the terminal is not dead for the duration? I have not assumed either.
Scope of the fix. Whether the upstream change is a narrow reorder (don't abort_all() on the success path) or a restructure of the connection-task lifecycle. This determines whether it is a point release or a larger change.
Other consumers.http_server's WebSocket facade is generic; any other service on kernal-api with a WebSocket route has the same defect. Worth fixing upstream regardless of who reports it next.
zackees/kernal-api#219 — feat(terminal): own PTY-backed WebSocket sessions for rebased app servers (closed). Introduced the facade path that carries the defect.
Context
The interactive terminal shipped in #241 is non-functional. Every
/terminal/wsconnection completes its HTTP handshake and then closes immediately, with no frames and no WebSocket close frame, so the browser dialog goes straight fromConnected — interactive shelltoDisconnected. Restart opens a new shell....This is not a partial failure or an edge case — no terminal session can be established at all, for any user, in any host. It reproduces against
mainwith no local modifications.The regression arrived with the
kernal-apimigration (d5afb46, #242). The terminal landed in22ea046against direct axum +tokio-tungstenite, anddocs/interactive-terminal.mdrecords a passing Chromium/WebKit browser validation on 2026-09-12 — before the migration moved the upgrade onto thekernal_api::http_serverfacade. Nothing has exercised a live session since.Evidence
Raw WebSocket probe against an unmodified build. Server started via
fastled --internal-serve-dir-headless <dir>; a Python client connects with the correctOrigin/Host:Zero frames received. The handshake succeeds; the connection then dies.
Not caused by the caller. Forcing the PTY spawn to fail with
SHELL=/nonexistent/shellproduces the same result, which is the diagnostic detail:terminal.rssends{"error":"Terminal failed"}on a spawn failure, and even that frame never arrives. The application callback is not running at all.Not the working tree.
git stashof all local changes, rebuild, same failure. The browser fixture fails identically on pristinemain.Blast radius.
/terminal/wsis the only WebSocket route, so the terminal is the sole victim — but it is fully broken rather than degraded.Root cause
kernal-apiv0.1.7 (6ca1c16),src/http_server.rs:With
with_upgrades(), hyper's connection future resolves as soon as the101 Switching Protocolsresponse has been written — the socket is handed off, and driving the connection is over. Line 528 therefore breaks the loop the instant an upgrade succeeds, and line 539 aborts every task in the JoinSet unconditionally. Any task not yet drained fromupgrades_rxis dropped with the receiver instead.The upgrade callback is cancelled microseconds after the handshake, on every connection.
abort_all()reads as intended cleanup for the timeout/error paths, but it fires on the success path too.Why the upstream test suite misses it.
websocket.rs:336—server_emits_a_real_switching_protocols_response— passes|_socket| async {}as the callback and asserts only the101response bytes. A callback that does nothing observable cannot detect a callback that never runs. The only other upgrade test (upgrade_rejects_non_websocket_before_claiming_transport) covers the rejection path.Proposal
kernal-api. On a successful upgrade the connection future resolving is the expected transition, not a termination condition: it must not tear down the upgrade tasks. The correct shape is to drain the JoinSet to completion (or keep serving until all upgrade tasks finish) rather thanabort_all()on the normal path. Aborting is only right for the timeout and error arms.Cargo.toml:16).server::start_serverand asserts a server→client frame is received. Asserting the101is not sufficient — that is the exact blind spot that let this ship.docs/interactive-terminal.md: the 2026-09-12 validation predates the migration and is currently misleading as evidence that the terminal works.Acceptance criteria
main, this must fail — reproducing the silent close — before the fix.FASTLED_TERMINAL_BINARY=... FASTLED_ESBUILD=... uv run --with playwright pytest tests/frontend/test_terminal.py -v -s(Chromium and WebKit).Connected — interactive shell, a typed command produces output, and the session stays up until explicitly closed.soldr cargo test --workspace,bash lint, andbash testpass.WebAssembly.Suspending, orWebAssembly.promising.Decisions
feat(terminal)feature from feat: add interactive PTY terminal with launch cwd and clud access #241 does not function; this is a total feature outage, not a rough edge.zackees/kernal-api, because this repo owns the user-visible regression, the reproduce commands, and the validation record. The code fix belongs upstream and this issue should track it.|_socket| async {}and asserting101would go green without the terminal working, so the acceptance criteria are anchored on an observed frame from a real PTY.kernal-apias the primary fix — this repo's convention is native, fail-loud behavior and no shims. If an interim unblock is needed before an upstream release, that should be an explicit, separately-decided stopgap rather than the default path. Raised in Open questions.docs/interactive-terminal.mdvalidation record is left in place but must be annotated rather than deleted — it is accurate for the commit it names, and misleading only by omission of the migration that followed.Open questions
git ls-remote --tagsconfirms), so there is no fixed version to pin today. Do we wait on an upstream release, or take a temporary patch/pin (a[patch]override or a fork) so the terminal is not dead for the duration? I have not assumed either.abort_all()on the success path) or a restructure of the connection-task lifecycle. This determines whether it is a point release or a larger change.http_server's WebSocket facade is generic; any other service onkernal-apiwith a WebSocket route has the same defect. Worth fixing upstream regardless of who reports it next.Related issues
feat(terminal): launch an agent in the browser terminal and preserve its session(open). Both of its phases assume a working transport; this blocks them.feat: add xterm.js terminal to CLI web app(closed). The feature this breaks.zackees/kernal-api#219—feat(terminal): own PTY-backed WebSocket sessions for rebased app servers(closed). Introduced the facade path that carries the defect.