Skip to content

Commit 4e7d268

Browse files
committed
feat: improve detached session retention and session UX
1 parent 87de92d commit 4e7d268

File tree

11 files changed

+399
-38
lines changed

11 files changed

+399
-38
lines changed

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,10 @@ Session controls:
119119
- click the `Session` badge in the status bar to open the session menu.
120120
- `New session`: start a fresh session in the current tab.
121121
- `Resume last`: reattach the last session id seen in this browser.
122-
- session list merges live server sessions (`/api/sessions`) and local history entries.
122+
- session menu separates `Live sessions` (running on server) from `Recent session ids` (browser memory only).
123123
- sessions already controlled in another tab/operator are shown as `Watch` (read-only attach).
124124
- resumable sessions are shown as `Resume` (full control attach).
125+
- recent ids that are not running are shown as unavailable.
125126
- tabs do not implicitly steal active sessions from each other.
126127
- terminal font starts at minimum (`11px`) by default and can be changed from controls/shortcuts.
127128

@@ -131,14 +132,40 @@ Session controls:
131132
| --- | --- | --- |
132133
| `WOOTTY_HOST` | `0.0.0.0` | Bind address |
133134
| `WOOTTY_PORT` | `8080` | HTTP/WebSocket port |
134-
| `WOOTTY_RECONNECT_GRACE_MS` | `30000` | Session retention window while reconnecting |
135+
| `WOOTTY_RECONNECT_GRACE_MS` | `0` | Legacy detached-session cleanup timeout in ms (used only when `WOOTTY_DETACHED_TTL_MS=0`) |
136+
| `WOOTTY_DETACHED_TTL_MS` | `86400000` | Hard TTL for running detached sessions (24h). `0` disables this TTL |
135137
| `WOOTTY_HISTORY_BYTES` | `5242880` | Buffered output bytes for replay |
136138
| `WOOTTY_COMMAND` | `$SHELL` or `bash` | Executed command |
137139
| `WOOTTY_COMMAND_ARGS` | _empty_ | Space-separated command args |
138140
| `WOOTTY_CWD` | current directory | Process working directory |
139141
| `WOOTTY_STATIC_DIR` | auto-detected | Directory with built web assets |
140142
| `WOOTTY_FAKE_PTY` | `0` | Set to `1` for deterministic fake PTY mode |
141143

144+
CLI equivalents are available for key timing controls: `--reconnect-grace-ms` and `--detached-ttl-ms`.
145+
146+
### Session Retention Model
147+
148+
- Session metadata and PTY state are in-memory only.
149+
- If a terminal process exits, the session is removed immediately.
150+
- If a terminal process is still running but no client is attached, the session is retained for `WOOTTY_DETACHED_TTL_MS`.
151+
- If `WOOTTY_DETACHED_TTL_MS=0`, cleanup falls back to `WOOTTY_RECONNECT_GRACE_MS` behavior.
152+
- Server restart clears all sessions because there is no persistent session store.
153+
154+
Recommended for long-running jobs with occasional reconnects:
155+
156+
```bash
157+
WOOTTY_RECONNECT_GRACE_MS=0
158+
WOOTTY_DETACHED_TTL_MS=259200000 # 72h
159+
```
160+
161+
Example in Compose:
162+
163+
```yaml
164+
environment:
165+
WOOTTY_RECONNECT_GRACE_MS: "0"
166+
WOOTTY_DETACHED_TTL_MS: "259200000"
167+
```
168+
142169
## Architecture
143170
144171
```mermaid

apps/server/internal/config/config.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ import (
1212
const (
1313
DefaultPort = 8080
1414
DefaultHistoryBytes = 5 * 1024 * 1024
15-
DefaultReconnectGraceMS = 30_000
15+
DefaultReconnectGraceMS = 0
16+
DefaultDetachedTTLMS = 86_400_000
1617
)
1718

1819
type RuntimeConfig struct {
1920
Host string
2021
Port int
2122
ReconnectGraceMS int
23+
DetachedTTLMS int
2224
HistoryBytes int
2325
FakePTY bool
2426
Command string
@@ -36,7 +38,8 @@ func ParseRunConfig(argv []string, env map[string]string, cwd string) (RuntimeCo
3638

3739
host := getOrDefault(env["WOOTTY_HOST"], "0.0.0.0")
3840
port := parsePositiveInt(env["WOOTTY_PORT"], DefaultPort)
39-
reconnectGraceMS := parsePositiveInt(env["WOOTTY_RECONNECT_GRACE_MS"], DefaultReconnectGraceMS)
41+
reconnectGraceMS := parseNonNegativeInt(env["WOOTTY_RECONNECT_GRACE_MS"], DefaultReconnectGraceMS)
42+
detachedTTLMS := parseNonNegativeInt(env["WOOTTY_DETACHED_TTL_MS"], DefaultDetachedTTLMS)
4043
historyBytes := parsePositiveInt(env["WOOTTY_HISTORY_BYTES"], DefaultHistoryBytes)
4144

4245
commandParts := make([]string, 0)
@@ -62,7 +65,13 @@ func ParseRunConfig(argv []string, env map[string]string, cwd string) (RuntimeCo
6265
case "--reconnect-grace-ms":
6366
i++
6467
if i < len(args) {
65-
reconnectGraceMS = parsePositiveInt(args[i], reconnectGraceMS)
68+
reconnectGraceMS = parseNonNegativeInt(args[i], reconnectGraceMS)
69+
}
70+
continue
71+
case "--detached-ttl-ms":
72+
i++
73+
if i < len(args) {
74+
detachedTTLMS = parseNonNegativeInt(args[i], detachedTTLMS)
6675
}
6776
continue
6877
case "--history-bytes":
@@ -111,6 +120,7 @@ func ParseRunConfig(argv []string, env map[string]string, cwd string) (RuntimeCo
111120
Host: host,
112121
Port: port,
113122
ReconnectGraceMS: reconnectGraceMS,
123+
DetachedTTLMS: detachedTTLMS,
114124
HistoryBytes: historyBytes,
115125
FakePTY: env["WOOTTY_FAKE_PTY"] == "1",
116126
Command: commandParts[0],
@@ -143,6 +153,19 @@ func parsePositiveInt(value string, fallback int) int {
143153
return parsed
144154
}
145155

156+
func parseNonNegativeInt(value string, fallback int) int {
157+
if strings.TrimSpace(value) == "" {
158+
return fallback
159+
}
160+
161+
parsed, err := strconv.Atoi(value)
162+
if err != nil || parsed < 0 {
163+
return fallback
164+
}
165+
166+
return parsed
167+
}
168+
146169
func splitArgs(value string) []string {
147170
parts := strings.Fields(value)
148171
if len(parts) == 0 {

apps/server/internal/config/config_test.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ func TestParseRunConfigDefaults(t *testing.T) {
1616
if cfg.Port != DefaultPort {
1717
t.Fatalf("expected default port %d, got %d", DefaultPort, cfg.Port)
1818
}
19+
if cfg.DetachedTTLMS != DefaultDetachedTTLMS {
20+
t.Fatalf("expected default detached ttl %d, got %d", DefaultDetachedTTLMS, cfg.DetachedTTLMS)
21+
}
1922
if cfg.Command != "bash" {
2023
t.Fatalf("expected default shell command bash, got %q", cfg.Command)
2124
}
@@ -27,7 +30,7 @@ func TestParseRunConfigWithFlagsAndCommand(t *testing.T) {
2730
"SHELL": "/bin/zsh",
2831
}
2932
cfg, err := ParseRunConfig(
30-
[]string{"run", "-p", "4444", "--host", "127.0.0.1", "sh", "-lc", "echo ok"},
33+
[]string{"run", "-p", "4444", "--host", "127.0.0.1", "--detached-ttl-ms", "0", "sh", "-lc", "echo ok"},
3134
env,
3235
"/tmp/wootty/apps/server",
3336
)
@@ -44,6 +47,9 @@ func TestParseRunConfigWithFlagsAndCommand(t *testing.T) {
4447
if cfg.Command != "sh" {
4548
t.Fatalf("expected command sh, got %q", cfg.Command)
4649
}
50+
if cfg.DetachedTTLMS != 0 {
51+
t.Fatalf("expected detached ttl override 0, got %d", cfg.DetachedTTLMS)
52+
}
4753
if len(cfg.Args) != 2 {
4854
t.Fatalf("expected 2 args, got %d", len(cfg.Args))
4955
}
@@ -60,9 +66,10 @@ func TestParseRunConfigUsesEnvCommandArgsAndFakePTY(t *testing.T) {
6066
cfg, err := ParseRunConfig(
6167
[]string{"run"},
6268
map[string]string{
63-
"WOOTTY_COMMAND": "/bin/bash",
64-
"WOOTTY_COMMAND_ARGS": "-lc echo-ok",
65-
"WOOTTY_FAKE_PTY": "1",
69+
"WOOTTY_COMMAND": "/bin/bash",
70+
"WOOTTY_COMMAND_ARGS": "-lc echo-ok",
71+
"WOOTTY_FAKE_PTY": "1",
72+
"WOOTTY_DETACHED_TTL_MS": "0",
6673
},
6774
"/tmp/wootty/apps/server",
6875
)
@@ -82,4 +89,7 @@ func TestParseRunConfigUsesEnvCommandArgsAndFakePTY(t *testing.T) {
8289
if !cfg.FakePTY {
8390
t.Fatal("expected fake PTY to be enabled from env")
8491
}
92+
if cfg.DetachedTTLMS != 0 {
93+
t.Fatalf("expected detached ttl from env 0, got %d", cfg.DetachedTTLMS)
94+
}
8595
}

apps/server/internal/server/server.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package server
22

33
import (
44
"encoding/json"
5+
"errors"
56
"io/fs"
67
"log/slog"
78
"mime"
@@ -33,6 +34,7 @@ func New(cfg config.RuntimeConfig) *Server {
3334

3435
manager := session.NewManager(session.ManagerOptions{
3536
ReconnectGrace: time.Duration(cfg.ReconnectGraceMS) * time.Millisecond,
37+
DetachedTTL: time.Duration(cfg.DetachedTTLMS) * time.Millisecond,
3638
HistoryBytes: cfg.HistoryBytes,
3739
FakePTY: cfg.FakePTY,
3840
ProcessOptions: session.ProcessOptions{
@@ -138,8 +140,16 @@ func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
138140
message.Watch,
139141
)
140142
if attachErr != nil {
143+
errorCode := "attach_failed"
144+
switch {
145+
case errors.Is(attachErr, session.ErrSessionNotFound):
146+
errorCode = "session_not_found"
147+
case errors.Is(attachErr, session.ErrSessionAlreadyAttached):
148+
errorCode = "session_busy"
149+
}
141150
s.log.Error("failed to attach terminal session", "err", attachErr)
142151
send(map[string]string{
152+
"code": errorCode,
143153
"type": "error",
144154
"message": "Terminal attach failed: " + attachErr.Error(),
145155
})

apps/server/internal/server/server_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,44 @@ func TestWatchModeIsReadOnly(t *testing.T) {
262262
}
263263
}
264264

265+
func TestAttachMissingSessionReturnsSessionNotFoundCode(t *testing.T) {
266+
cfg := testRuntimeConfig(t, true, "sh", filepath.Join(t.TempDir(), "missing-static"))
267+
server := New(cfg)
268+
defer server.Shutdown()
269+
270+
httpServer := httptest.NewServer(server.http.Handler)
271+
defer httpServer.Close()
272+
273+
wsConnA := dialTerminalWebsocket(t, httpServer.URL)
274+
if err := wsConnA.WriteJSON(map[string]any{"type": "attach", "cols": 80, "rows": 24}); err != nil {
275+
t.Fatalf("failed writing initial attach payload: %v", err)
276+
}
277+
readyMessage := waitForWSMessageType(t, wsConnA, "ready", 2*time.Second)
278+
sessionID := stringField(readyMessage, "sessionId")
279+
if sessionID == "" {
280+
t.Fatalf("expected session id in ready payload, got %#v", readyMessage)
281+
}
282+
wsConnA.Close()
283+
284+
time.Sleep(150 * time.Millisecond)
285+
286+
wsConnB := dialTerminalWebsocket(t, httpServer.URL)
287+
defer wsConnB.Close()
288+
if err := wsConnB.WriteJSON(map[string]any{
289+
"type": "attach",
290+
"sessionId": sessionID,
291+
"cols": 80,
292+
"rows": 24,
293+
}); err != nil {
294+
t.Fatalf("failed writing stale attach payload: %v", err)
295+
}
296+
297+
errorMessage := waitForWSMessageType(t, wsConnB, "error", 2*time.Second)
298+
if stringField(errorMessage, "code") != "session_not_found" {
299+
t.Fatalf("expected session_not_found code, got %#v", errorMessage)
300+
}
301+
}
302+
265303
func TestWebsocketAttachFailureSurfacesError(t *testing.T) {
266304
cfg := testRuntimeConfig(t, false, "/definitely/missing-command", filepath.Join(t.TempDir(), "missing-static"))
267305
server := New(cfg)
@@ -299,6 +337,7 @@ func testRuntimeConfig(t *testing.T, fakePTY bool, command string, staticDir str
299337
Host: "127.0.0.1",
300338
Port: 0,
301339
ReconnectGraceMS: 100,
340+
DetachedTTLMS: 0,
302341
HistoryBytes: 4096,
303342
FakePTY: fakePTY,
304343
Command: command,

apps/server/internal/session/manager.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var (
1919

2020
type ManagerOptions struct {
2121
ReconnectGrace time.Duration
22+
DetachedTTL time.Duration
2223
HistoryBytes int
2324
FakePTY bool
2425
ProcessOptions ProcessOptions
@@ -119,6 +120,9 @@ func (m *Manager) Attach(sessionID string, conn *websocket.Conn, cols, rows int,
119120
m.mu.Unlock()
120121
return AttachResult{}, ErrSessionNotFound
121122
}
123+
124+
m.mu.Unlock()
125+
return AttachResult{}, ErrSessionNotFound
122126
}
123127

124128
id := uuid.NewString()
@@ -228,11 +232,26 @@ func (m *Manager) Detach(sessionID string, conn *websocket.Conn) {
228232
}
229233
delete(session.watchers, conn)
230234

231-
delay := m.options.ReconnectGrace
235+
if session.reconnectTimer != nil {
236+
session.reconnectTimer.Stop()
237+
session.reconnectTimer = nil
238+
}
239+
232240
if session.exitInfo != nil {
233-
delay = time.Millisecond
241+
m.scheduleCleanupLocked(sessionID, time.Millisecond)
242+
m.mu.Unlock()
243+
return
244+
}
245+
246+
// Running detached sessions are retained for DetachedTTL.
247+
// ReconnectGrace acts as a legacy fallback when DetachedTTL is disabled.
248+
delay := m.options.DetachedTTL
249+
if delay <= 0 {
250+
delay = m.options.ReconnectGrace
251+
}
252+
if delay > 0 {
253+
m.scheduleCleanupLocked(sessionID, delay)
234254
}
235-
m.scheduleCleanupLocked(sessionID, delay)
236255
m.mu.Unlock()
237256
}
238257

0 commit comments

Comments
 (0)