Skip to content

Commit 4953b52

Browse files
test(session): RED -- a web session whose server stopped is offered for reattach
A PTY/ACP session lives inside the `studyloop web` process that started it, and the state file records that process as `pid`. When the server stops without ending the session, the file keeps `mode=focus`. /session/start already treats such a claim as stale (claim_blocks_web_start: the crash-then-restart cell), but /api/session/state held no slot, fell back to the file verbatim and reported the session live with no reattach_url. The console's load-time adoption then called start() with transport "pty" and no URL, and _mountUnavailable told the learner: 'This session reports transport "pty", which this view cannot render' -- about the transport it renders with xterm. Seen live after restarting `studyloop web --lan`; the sessions DB still holds the dead session open (ended_at NULL since 23 Sep). RED, each for the stated reason: - /api/session/state reports a dead-pid pty session, and an acp one, as `focus` (2 tests); - the console says it "cannot render" pty and acp when only the URL is missing (2 tests). Guards that pass now and must keep passing: a live foreign server's session, this process's own in-flight reservation, and a CLI session (whose pid is not its liveness) are left alone; nothing is deleted (an unended session's parking lot is unflushed learner notes); an unrecognised transport still names what the view cannot render.
1 parent 8b3e3a7 commit 4953b52

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* The live console's "No terminal available" fallback must say what actually
3+
* went wrong. A KNOWN transport ('pty', 'acp') arriving without a connection
4+
* URL is a missing connection, not a transport this view cannot render; only
5+
* an unrecognised transport gets the "cannot render" sentence.
6+
*
7+
* Found live: after `studyloop web` was restarted, a session whose server had
8+
* stopped was adopted with transport "pty" and no URL, and the learner read
9+
* 'This session reports transport "pty", which this view cannot render' —
10+
* about the one transport the view renders with xterm. The "did not return a
11+
* connection" sentence the troubleshooting guide describes for exactly this
12+
* case was unreachable, because every caller passes a transport.
13+
*/
14+
// Run with: node --test 'packages/studyloop/tests/js/**/*.test.js'
15+
16+
import { test } from 'node:test';
17+
import assert from 'node:assert/strict';
18+
19+
import { liveAgentConsole } from
20+
'../../src/studyloop/web/static/js/components/live-agent-console.js';
21+
22+
for (const transport of ['pty', 'acp']) {
23+
test(`a known transport (${transport}) with no URL is reported as a missing connection`, () => {
24+
const view = liveAgentConsole('study');
25+
26+
view.start({ transport, wsUrl: null, topic: 'Decorators', studySessionId: 's-1' });
27+
28+
assert.equal(view.terminalMode, 'unavailable');
29+
assert.doesNotMatch(view.statusMessage, /cannot render/);
30+
assert.match(view.statusMessage, /did not return a connection/);
31+
});
32+
}
33+
34+
test('an unrecognised transport still names the transport it cannot render', () => {
35+
const view = liveAgentConsole('study');
36+
37+
view.start({ transport: 'carrier-pigeon', wsUrl: '/api/session/ws?study_session_id=s-1' });
38+
39+
assert.equal(view.terminalMode, 'unavailable');
40+
assert.match(view.statusMessage, /transport "carrier-pigeon", which this view cannot render/);
41+
});

‎packages/studyloop/tests/test_session_slot_reconcile.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from __future__ import annotations
2929

3030
import asyncio
31+
import os
3132
import sys
3233
from pathlib import Path
3334

@@ -559,3 +560,81 @@ async def test_an_existing_session_state_is_still_marked_ended(self) -> None:
559560
_signal_dashboard_ended()
560561

561562
assert session_state.read_session_state()["mode"] == "ended"
563+
564+
565+
# ---------------------------------------------------------------------------
566+
# Defect 4 — a web session whose server stopped is offered for reattach
567+
# ---------------------------------------------------------------------------
568+
569+
_DEAD_PID = 999_999_999 # beyond any pid the kernel hands out
570+
571+
572+
class TestAWebSessionWhoseServerStoppedIsNotLive:
573+
"""A PTY/ACP session lives inside the server process that started it.
574+
575+
When that server stops without ending the session, the state file still
576+
says ``mode=focus`` and names the dead server's ``pid``. ``/session/start``
577+
already treats that claim as stale (``claim_blocks_web_start``), but this
578+
endpoint reported it as live with no ``reattach_url``. After a restart of
579+
``studyloop web`` the console adopted it and told the learner it "cannot
580+
render" transport pty, the one transport it renders with xterm.
581+
"""
582+
583+
def _write_orphan(self, **overrides: object) -> None:
584+
session_state.write_session_state(
585+
{
586+
"study_session_id": "orphan-1",
587+
"topic": "Decorators",
588+
"mode": "focus",
589+
"transport": "pty",
590+
"origin": "study",
591+
"pid": _DEAD_PID,
592+
**overrides,
593+
}
594+
)
595+
596+
def test_state_reports_it_ended(self, client: TestClient) -> None:
597+
self._write_orphan()
598+
599+
state = client.get("/api/session/state").json()
600+
601+
assert state.get("mode") == "ended", "a session whose server is gone was offered as live"
602+
assert "reattach_url" not in state
603+
604+
def test_an_acp_orphan_is_reported_ended_too(self, client: TestClient) -> None:
605+
self._write_orphan(transport="acp")
606+
607+
assert client.get("/api/session/state").json().get("mode") == "ended"
608+
609+
def test_nothing_is_deleted(self, client: TestClient) -> None:
610+
"""A session that never ended never flushed its parking lot; those are
611+
the learner's notes, so reporting it ended must not destroy them."""
612+
self._write_orphan()
613+
session_state.PARKING_FILE.write_text("- a parked question\n")
614+
615+
client.get("/api/session/state")
616+
617+
assert session_state.STATE_FILE.exists()
618+
assert session_state.PARKING_FILE.read_text() == "- a parked question\n"
619+
620+
def test_a_live_foreign_server_s_session_is_left_alone(self, client: TestClient) -> None:
621+
# pid 1 always exists and is foreign to the test process.
622+
self._write_orphan(pid=1)
623+
624+
assert client.get("/api/session/state").json().get("mode") == "focus"
625+
626+
def test_this_process_s_own_claim_is_left_alone(self, client: TestClient) -> None:
627+
"""A start in flight writes its reservation with this process's pid
628+
before the slot is acquired; that is not an orphan."""
629+
self._write_orphan(pid=os.getpid(), mode="starting")
630+
631+
assert client.get("/api/session/state").json().get("mode") == "starting"
632+
633+
def test_a_cli_session_is_left_alone(self, client: TestClient) -> None:
634+
"""A CLI session's pid is the CLI process, which can exit while its
635+
multiplexer session lives on; the zombie rule judges CLI sessions."""
636+
session_state.write_session_state(
637+
{"study_session_id": "cli-1", "topic": "tmux", "mode": "focus", "pid": _DEAD_PID}
638+
)
639+
640+
assert client.get("/api/session/state").json().get("mode") == "focus"

0 commit comments

Comments
 (0)