Skip to content

Commit c081228

Browse files
fix(session): a web session whose server stopped is ended, not reattachable
`_get_full_state()` cleared a dead CLI (tmux) session but had no rule for a web-owned one, and read_session_state() drops tmux keys for pty/acp files, so a browser-terminal or ACP session whose server died kept reporting `mode=focus` forever. /api/session/state held no slot, served that file verbatim with no reattach_url, and the console adopted it into its "unavailable" fallback. - session_state.web_claim_is_orphaned(): the read-side twin of claim_blocks_web_start's crash-then-restart cell -- a pty/acp claim whose pid is neither this process nor alive. CLI sessions (pid is not their liveness), this process's own in-flight reservation, and pid-less claims are never judged orphaned. - _get_full_state(): an orphaned web claim is reported `mode=ended`, the verdict /session/start already reached. Nothing is deleted: an unended session never flushed its parking lot. Covers /api/session/state and the SSE dashboard stream, which share the reader. - live-agent-console.js: a known transport (pty/acp) with no connection URL now reads "The server did not return a connection for this session"; the "cannot render" sentence is kept for transports the view does not render. That sentence is what docs/troubleshooting.md already promised for this case; every caller passes a transport, so it was unreachable. - docs/troubleshooting.md: the stopped-server case. CHANGELOG Unreleased/Fixed.
1 parent 4953b52 commit c081228

5 files changed

Lines changed: 58 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ experience may change before `1.0.0`.
9393
`[all]`, and the nightly install job and `scripts/smoke-uv-tool-install.sh`
9494
import the runtime in that venv. Existing installs pick it up with
9595
`./scripts/install.sh --tools-only`.
96+
- Returning to the Study view after `studyloop web` restarted no longer offers
97+
the session the old server was running and then reports *This session
98+
reports transport "pty", which this view cannot render*. A browser-terminal
99+
or ACP session runs inside the server that started it; when that server
100+
stops without ending it, `/api/session/state` now reports the session ended
101+
(the verdict `/session/start` already reached) instead of live with nothing
102+
to connect to, and deletes nothing, because an unended session's parking lot
103+
holds unflushed notes. The console's fallback also stops blaming the
104+
renderer when only the connection is missing: a known transport with no
105+
connection URL now reads *The server did not return a connection for this
106+
session*.
96107

97108
## [0.5.0] - 2026-09-21
98109

‎docs/troubleshooting.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,13 @@ If the transport is already `pty` and the panel still reports no terminal, the
148148
server did not return a connection for the session. Ending and restarting the
149149
session clears this; the message says so rather than leaving a blank pane.
150150

151+
A session whose server stopped while it ran (`studyloop web` was restarted,
152+
crashed, or its terminal was closed) is not offered for reattach. A browser
153+
terminal or ACP session runs inside the server that started it, so once that
154+
process is gone `/api/session/state` reports the session ended and the Study
155+
view shows the picker. Start a new session; the old one's parking-lot file is
156+
left in place.
157+
151158
## The Terminal Is Empty After A Page Refresh
152159

153160
The panel should reattach on its own. What holds today:

‎packages/studyloop/src/studyloop/session_state.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,29 @@ def _reservation_pid_is_live_and_foreign(state: dict) -> bool:
446446
return _pid_is_alive(pid)
447447

448448

449+
def web_claim_is_orphaned(state: dict) -> bool:
450+
"""Whether ``state`` names a web session (PTY/ACP) whose server has gone.
451+
452+
A web-owned session lives inside the server process that started it, and
453+
``pid`` records that process. When the pid is neither this process nor
454+
alive, nothing can be serving the session, whatever ``mode`` says: the
455+
crash-then-restart cell :func:`claim_blocks_web_start` already treats as
456+
stale on the start side. The read side needs the same verdict, or
457+
``/api/session/state`` offers a session for reattach that nothing serves.
458+
459+
A CLI session is never judged here -- its ``pid`` is the CLI process,
460+
which can exit while its multiplexer session lives on. This process's own
461+
pid is never an orphan (a start in flight writes its reservation before
462+
the slot is acquired), and a claim with no ``pid`` cannot be proven dead.
463+
"""
464+
if not _claim_exists(state) or state.get("transport") not in ("pty", "acp"):
465+
return False
466+
pid = state.get("pid")
467+
if not isinstance(pid, int) or pid == os.getpid():
468+
return False
469+
return not _pid_is_alive(pid)
470+
471+
449472
def claim_blocks_web_start(state: dict) -> bool:
450473
"""Whether a file claim should block a new web PTY/ACP session start.
451474

‎packages/studyloop/src/studyloop/web/routes/session/_ipc.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import logging
66

7-
from studyloop.session_state import PARKING_FILE, STATE_FILE, TOPICS_FILE
7+
from studyloop.session_state import PARKING_FILE, STATE_FILE, TOPICS_FILE, web_claim_is_orphaned
88

99
logger = logging.getLogger(__name__)
1010

@@ -43,6 +43,13 @@ def _get_full_state() -> dict:
4343
f.unlink(missing_ok=True)
4444
return {"topics": [], "parking": []}
4545

46+
if web_claim_is_orphaned(state):
47+
# A PTY/ACP session lives inside the server that started it, and that
48+
# process is gone, so the session is too: report it ended, the verdict
49+
# /session/start already reaches. Delete nothing -- a session that
50+
# never ended never flushed its parking lot, and that is learner data.
51+
state = {**state, "mode": "ended"}
52+
4653
topics = session_pkg.parse_topics_file()
4754
parking = session_pkg.parse_parking_file()
4855
return {

‎packages/studyloop/src/studyloop/web/static/js/components/live-agent-console.js‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -812,16 +812,20 @@ export function liveAgentConsole(origin = 'study') {
812812
The ttyd iframe was retired once the pty path survived a page reload, and
813813
ttyd retirement stage 3 removed the server-side transport axis entirely —
814814
the server now rejects transport=ttyd/STUDYLOOP_TRANSPORT=ttyd outright.
815-
This method is the generic "the server reported a transport this view
816-
cannot render" fallback for any future unrecognised value, not a
817-
ttyd-specific branch. */
815+
Two different failures land here, and the message must name the right
816+
one: a KNOWN transport with no connection URL (the server gave nothing
817+
to connect to), or a transport this view genuinely cannot render.
818+
Telling a learner "cannot render pty" sent them looking for a renderer
819+
problem that was not there. */
818820
_mountUnavailable(detail) {
819821
this.terminalMode = 'unavailable';
820822
this.connected = false;
821823
this.statusDot = 'error';
822824
this.status = 'No terminal available';
823-
this.statusMessage = detail && detail.transport
824-
? `This session reports transport "${detail.transport}", which this view `
825+
const transport = detail && detail.transport;
826+
const renderable = transport === 'pty' || transport === 'acp';
827+
this.statusMessage = transport && !renderable
828+
? `This session reports transport "${transport}", which this view `
825829
+ 'cannot render. End the session and start it again with the browser '
826830
+ 'terminal or ACP.'
827831
: 'The server did not return a connection for this session. Ending and '

0 commit comments

Comments
 (0)