Skip to content

a plain GET on the exec path is the session list, not a failed upgrade - #19

Merged
lex00 merged 1 commit into
mainfrom
feat/exec-session-list
Aug 3, 2026
Merged

a plain GET on the exec path is the session list, not a failed upgrade#19
lex00 merged 1 commit into
mainfrom
feat/exec-session-list

Conversation

@lex00

@lex00 lex00 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fixes the 426 in #18.

The exec path serves two protocols

sprites-ex's Session.list_by_name/2 is literally:

Req.get(client.req, url: "/v1/sprites/#{URI.encode(name)}/exec")
# ... (Map.get(body, "sessions") || [])

spritzer registered that path as a WebSocket handler only, so an unupgraded GET fell through to websocket.Accept and came back 426 with a protocol-violation body:

level=WARN msg="exec websocket accept failed" id=fountain-0f8c1cbe-334db529
  err="failed to accept WebSocket connection: WebSocket protocol violation:
       Connection header \"\" does not contain Upgrade"

fountain asks for the session list before deciding whether to reattach or start fresh, so the 426 aborted that decision and orphaned the turn before anything ran.

An idle sprite has no sessions, so the honest answer is {"sessions": []} — which is also the answer that lets a client get on with it. A missing sprite is still a 404: "no sessions" and "no sprite" are different answers, and a client reattaching should be able to tell them apart.

The upgrade check reads both headers as the lists they are. Connection: keep-alive, Upgrade is what proxies routinely send, and an equality check would route it to the session list and break exec behind any proxy — that case is a table test.

It fixes the 426 and does not make a fountain turn complete

Verified against a real fountain deployment on k3d, with this build swapped in. The failure moves and does not go away:

before:  reattach interrupted  {"reason":"list_sessions_failed"}   # the 426
after:   reattach interrupted  {"reason":"no_active_session"}

and the actual root cause is now legible in fountain's log:

[error] GenServer {Fountain.ConversationRegistry, "..."} terminating
** (stop) exited in: GenServer.call(#PID<...>, {:write_stdin, "say hello"}, 5000)
    ** (EXIT) normal
    (fountain 0.3.0) .../conversation_server.ex:1228 ConversationServer.kick_turn/4

fountain opens the exec socket, then writes the prompt to it as stdin. spritzer's interpreter runs the command and exits immediately, so the process is already gone by the time the write lands and the conversation server crashes.

That is a separate and larger question than this PR — whether exec should hold a session open until stdin EOF rather than being one-shot. I have written it up on #18 rather than deciding it here, because it changes exec semantics for every client and is the kind of thing that should be a deliberate choice about how far the emulator goes.

go test ./... and go vet ./... green. The health endpoint now advertises both halves of the path, and a test asserts it does.

The exec path serves two protocols in the real API. sprites-ex's
Session.list_by_name/2 is literally

    Req.get(client.req, url: "/v1/sprites/#{name}/exec")

and reads body["sessions"]. spritzer registered that path as a WebSocket
handler only, so an unupgraded GET fell through to websocket.Accept and came
back 426 with a protocol-violation body.

Found from fountain, which asks for the session list before deciding whether to
reattach or start fresh. The 426 aborted that decision and orphaned the turn
before anything ran — #18.

An idle sprite has no sessions and the honest answer is an empty list, which is
also the answer that lets a client get on with it. A missing sprite is still a
404: "no sessions" and "no sprite" are different answers and a client
reattaching should be able to tell them apart.

The upgrade check reads both headers as the lists they are — Connection is
commonly "keep-alive, Upgrade" from a proxy, and an equality check would route
that to the session list and break exec behind any proxy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPvVBaE97cfmvzytEvJCBr
@lex00
lex00 merged commit 95718e2 into main Aug 3, 2026
2 checks passed
@lex00
lex00 deleted the feat/exec-session-list branch August 3, 2026 21:02
jhgaylor added a commit that referenced this pull request Aug 7, 2026
#19 and #20 both merged after v0.4.1 was tagged, so no released image
carries either. Neither had a changelog entry, and `just release` refuses
without a `## [0.5.0]` section — this adds it so the tag can be cut.

Minor rather than patch: an unupgraded GET on the exec path is new
behavior, and holding an unrecognised command open changes exec's
lifetime for the clients that hit that branch.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant