From 00dc20f689ac88dc83f8b24927b3ce8f0a314b55 Mon Sep 17 00:00:00 2001 From: Eunyoung Jeong Date: Fri, 30 Oct 2026 16:32:41 -0700 Subject: [PATCH 1/7] feat(websh): warn before an idle close and name it on close Show a local-time warning on stderr before an idle close, ignore invalid or repeated warning deadlines, and explain how to reopen a session closed for inactivity. --- api/websh/idle_warning_test.go | 135 +++++++++++++++++++++++++++++++++ api/websh/types.go | 19 +++-- api/websh/websh.go | 42 ++++++++-- api/websh/websh_test.go | 4 +- 4 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 api/websh/idle_warning_test.go diff --git a/api/websh/idle_warning_test.go b/api/websh/idle_warning_test.go new file mode 100644 index 00000000..b9395346 --- /dev/null +++ b/api/websh/idle_warning_test.go @@ -0,0 +1,135 @@ +package websh + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/alpacax/alpacon-cli/pkg/testutil" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadFromServer_IdleWarningUsesStderrAndLocalTime(t *testing.T) { + local := time.Local + time.Local = time.FixedZone("test", 9*60*60) + t.Cleanup(func() { time.Local = local }) + closesAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + + _, stdout, stderr := readIdleFrames(t, idleWarningFrame(closesAt)) + + assert.Empty(t, stdout) + assert.Equal(t, idleNotice(closesAt), stderr) +} + +func TestReadFromServer_IdleWarningPreservesReplay(t *testing.T) { + closesAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + warning := idleWarningFrame(closesAt) + for _, tc := range []struct { + name string + frames []replayFrame + epoch string + offset uint64 + output bool + }{ + {"before origin", []replayFrame{warning, {websocket.TextMessage, `{"type":"replay_origin","epoch":"epoch-1","offset":100}`}, {websocket.BinaryMessage, "hello"}}, "epoch-1", 105, true}, + {"after origin", []replayFrame{{websocket.TextMessage, `{"type":"replay_origin","epoch":"epoch-1","offset":100}`}, warning, {websocket.BinaryMessage, "hello"}}, "epoch-1", 105, true}, + {"without binary", []replayFrame{warning}, "", 0, false}, + {"legacy", []replayFrame{{websocket.BinaryMessage, "hello"}, warning, {websocket.TextMessage, `{"type":"replay_origin","epoch":"too-late","offset":999}`}}, "", 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + conn, stdout, stderr := readIdleFrames(t, tc.frames...) + assert.Equal(t, tc.epoch, conn.replay.epoch) + assert.Equal(t, tc.offset, conn.replay.offset) + assert.Equal(t, tc.output, conn.replay.outputWritten) + if tc.output { + assert.Equal(t, "hello", stdout) + } else { + assert.Empty(t, stdout) + } + assert.Equal(t, idleNotice(closesAt), stderr) + }) + } +} + +func TestReadFromServer_IdleWarningIgnoresInvalidDeadlines(t *testing.T) { + closesAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + for _, tc := range []struct { + name string + body string + }{ + {"missing", `{"type":"idle_warning"}`}, + {"invalid", `{"type":"idle_warning","closes_at":"not-a-time"}`}, + {"past", idleWarningFrame(time.Now().Add(-time.Hour)).body}, + {"unknown type", `{"type":"future_control","closes_at":"` + closesAt.Format(time.RFC3339) + `"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + _, stdout, stderr := readIdleFrames(t, replayFrame{websocket.TextMessage, tc.body}) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + // A valid frame proves the reader is handling warnings rather than dropping all text. + _, stdout, stderr = readIdleFrames(t, idleWarningFrame(closesAt)) + assert.Empty(t, stdout) + assert.Equal(t, idleNotice(closesAt), stderr) + }) + } +} + +func TestReadFromServer_IdleWarningPrintsOncePerDeadline(t *testing.T) { + first := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + second := first.Add(time.Minute) + _, stdout, stderr := readIdleFrames(t, + idleWarningFrame(first), idleWarningFrame(first), idleWarningFrame(second), idleWarningFrame(first), + ) + assert.Empty(t, stdout) + assert.Equal(t, idleNotice(first)+idleNotice(second), stderr) +} + +func TestReadFromServer_IdleWarningStaysDeduplicatedOnReconnect(t *testing.T) { + closesAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + _, stdout, notices := runReplaySession(t, + replayRound{frames: []replayFrame{idleWarningFrame(closesAt)}, closeCode: websocket.CloseServiceRestart}, + replayRound{frames: []replayFrame{idleWarningFrame(closesAt)}}, + ) + assert.Empty(t, stdout) + assert.Equal(t, idleNotice(closesAt)+"\r\nConnection lost, reconnecting...\r\n", notices) +} + +func idleWarningFrame(closesAt time.Time) replayFrame { + return replayFrame{websocket.TextMessage, fmt.Sprintf(`{"type":"idle_warning","closes_at":%q,"message":"untrusted server text"}`, closesAt.Format(time.RFC3339))} +} + +func idleNotice(closesAt time.Time) string { + return "[Idle: this session closes at " + closesAt.Local().Format("15:04:05") + " unless you type something.]\r\n" +} + +func readIdleFrames(t *testing.T, frames ...replayFrame) (*connection, string, string) { + t.Helper() + upgrader := websocket.Upgrader{} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ws, err := upgrader.Upgrade(w, r, nil) + if !assert.NoError(t, err) { + return + } + defer func() { _ = ws.Close() }() + for _, frame := range frames { + if !assert.NoError(t, ws.WriteMessage(frame.kind, []byte(frame.body))) { + return + } + } + _ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + })) + t.Cleanup(ts.Close) + ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ts.URL, "http"), nil) + require.NoError(t, err) + conn := newConnection(ws) + t.Cleanup(conn.close) + stdout, stderr := testutil.CaptureOutput(t, func() { + awaitReturn(t, "warning reader did not finish", conn.readFromServer) + }) + return conn, stdout, stderr +} diff --git a/api/websh/types.go b/api/websh/types.go index 119d2ee8..362e3a7f 100644 --- a/api/websh/types.go +++ b/api/websh/types.go @@ -23,6 +23,8 @@ type WebsocketClient struct { reconnect *reconnector // The reader owns replay state until its pumps stop before the next dial. replay replayState + // The reader keeps warning deadlines across connections, like replay state. + idleWarnings map[time.Time]struct{} } type replayState struct { @@ -35,14 +37,15 @@ type replayState struct { // connections—a dropped link is re-dialed onto a new user channel—so each one // records its own ending and the session decides what that ending means. type connection struct { - ws *websocket.Conn - ended chan struct{} // closed once the first ending is recorded - err error - endOnce sync.Once - pumps sync.WaitGroup - replay *replayState - reconnected bool - notice io.Writer + ws *websocket.Conn + ended chan struct{} // closed once the first ending is recorded + err error + endOnce sync.Once + pumps sync.WaitGroup + replay *replayState + reconnected bool + notice io.Writer + idleWarnings map[time.Time]struct{} } // reconnector is what an interactive session needs to survive a dropped diff --git a/api/websh/websh.go b/api/websh/websh.go index 5bd97ad1..f7f3fe4b 100644 --- a/api/websh/websh.go +++ b/api/websh/websh.go @@ -66,7 +66,7 @@ var ( closeReasonMessages = map[string]string{ "user_request": "the session was closed", "force_close": "closed by an administrator", - "idle_timeout": "the session reached its time limit", + "idle_timeout": "closed for inactivity; run the command again to reopen", "agent_disconnected": "Alpamon on the server disconnected", "server_shutdown": "the service restarted", "work_session_expired": "the work session expired", @@ -316,8 +316,9 @@ func shareCreatedSession(ac *client.AlpaconClient, responseBody []byte, share, r func newWebsocketClient(header http.Header) *WebsocketClient { return &WebsocketClient{ - header: header, - done: make(chan struct{}), + header: header, + done: make(chan struct{}), + idleWarnings: make(map[time.Time]struct{}), } } @@ -340,6 +341,7 @@ func (wsClient *WebsocketClient) dial(websocketURL string) error { } wsClient.conn = newConnection(conn) wsClient.conn.replay = &wsClient.replay + wsClient.conn.idleWarnings = wsClient.idleWarnings if wsClient.reconnect != nil { wsClient.conn.reconnected = reconnected wsClient.conn.notice = wsClient.reconnect.notice @@ -706,12 +708,36 @@ func (c *connection) readFromServer() { switch kind { case websocket.TextMessage: var origin struct { - Type string `json:"type"` - Epoch string `json:"epoch"` - Offset uint64 `json:"offset"` - Truncated bool `json:"truncated"` + Type string `json:"type"` + Epoch string `json:"epoch"` + Offset uint64 `json:"offset"` + Truncated bool `json:"truncated"` + ClosesAt json.RawMessage `json:"closes_at"` } - if legacy || json.Unmarshal(message, &origin) != nil || origin.Type != "replay_origin" || origin.Epoch == "" { + if json.Unmarshal(message, &origin) != nil { + continue + } + if origin.Type == "idle_warning" { + var closesAt time.Time + if err := json.Unmarshal(origin.ClosesAt, &closesAt); err != nil || !closesAt.After(time.Now()) { + continue + } + closesAt = closesAt.UTC() + if _, seen := c.idleWarnings[closesAt]; seen { + continue + } + if c.idleWarnings == nil { + c.idleWarnings = make(map[time.Time]struct{}) + } + c.idleWarnings[closesAt] = struct{}{} + notice := c.notice + if notice == nil { + notice = os.Stderr + } + _, _ = fmt.Fprintf(notice, "[Idle: this session closes at %s unless you type something.]\r\n", closesAt.Local().Format("15:04:05")) + continue + } + if legacy || origin.Type != "replay_origin" || origin.Epoch == "" { continue } originSeen = true diff --git a/api/websh/websh_test.go b/api/websh/websh_test.go index dfef4cd3..661df273 100644 --- a/api/websh/websh_test.go +++ b/api/websh/websh_test.go @@ -588,7 +588,7 @@ func TestFinish_SettlesASingleCloseByItsCode(t *testing.T) { { name: "session end with a reason", reported: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_timeout"}, - wantStderr: "\r\nsession closed: the session reached its time limit\r\n", + wantStderr: "\r\nsession closed: closed for inactivity; run the command again to reopen\r\n", }, { name: "session end with no reason", @@ -732,7 +732,7 @@ func TestFinish_KeepsTheFirstOutcome(t *testing.T) { name: "duplicate session end prints once", first: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_timeout"}, second: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_timeout"}, - wantStderr: "\r\nsession closed: the session reached its time limit\r\n", + wantStderr: "\r\nsession closed: closed for inactivity; run the command again to reopen\r\n", }, } From d356e0e1fa693848de7d5fd6630dd24d902232ee Mon Sep 17 00:00:00 2001 From: Eunyoung Jeong Date: Fri, 30 Oct 2026 16:35:56 -0700 Subject: [PATCH 2/7] test(websh): pin idle warning state and wording --- api/websh/idle_warning_test.go | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/api/websh/idle_warning_test.go b/api/websh/idle_warning_test.go index b9395346..363fe542 100644 --- a/api/websh/idle_warning_test.go +++ b/api/websh/idle_warning_test.go @@ -18,16 +18,16 @@ func TestReadFromServer_IdleWarningUsesStderrAndLocalTime(t *testing.T) { local := time.Local time.Local = time.FixedZone("test", 9*60*60) t.Cleanup(func() { time.Local = local }) - closesAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + closesAt := time.Date(time.Now().Year()+1, time.January, 2, 6, 4, 5, 0, time.UTC) _, stdout, stderr := readIdleFrames(t, idleWarningFrame(closesAt)) assert.Empty(t, stdout) - assert.Equal(t, idleNotice(closesAt), stderr) + assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n", stderr) } func TestReadFromServer_IdleWarningPreservesReplay(t *testing.T) { - closesAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + closesAt := time.Date(time.Now().Year()+1, time.January, 2, 15, 4, 5, 0, time.Local) warning := idleWarningFrame(closesAt) for _, tc := range []struct { name string @@ -51,62 +51,58 @@ func TestReadFromServer_IdleWarningPreservesReplay(t *testing.T) { } else { assert.Empty(t, stdout) } - assert.Equal(t, idleNotice(closesAt), stderr) + assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n", stderr) }) } } func TestReadFromServer_IdleWarningIgnoresInvalidDeadlines(t *testing.T) { - closesAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + closesAt := time.Date(time.Now().Year()+1, time.January, 2, 15, 4, 5, 0, time.Local) for _, tc := range []struct { name string body string }{ {"missing", `{"type":"idle_warning"}`}, + {"null", `{"type":"idle_warning","closes_at":null}`}, {"invalid", `{"type":"idle_warning","closes_at":"not-a-time"}`}, {"past", idleWarningFrame(time.Now().Add(-time.Hour)).body}, {"unknown type", `{"type":"future_control","closes_at":"` + closesAt.Format(time.RFC3339) + `"}`}, } { t.Run(tc.name, func(t *testing.T) { - _, stdout, stderr := readIdleFrames(t, replayFrame{websocket.TextMessage, tc.body}) + _, stdout, stderr := readIdleFrames(t, + replayFrame{websocket.TextMessage, tc.body}, idleWarningFrame(closesAt), + ) assert.Empty(t, stdout) - assert.Empty(t, stderr) - // A valid frame proves the reader is handling warnings rather than dropping all text. - _, stdout, stderr = readIdleFrames(t, idleWarningFrame(closesAt)) - assert.Empty(t, stdout) - assert.Equal(t, idleNotice(closesAt), stderr) + assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n", stderr) }) } } func TestReadFromServer_IdleWarningPrintsOncePerDeadline(t *testing.T) { - first := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + first := time.Date(time.Now().Year()+1, time.January, 2, 15, 4, 5, 0, time.Local) second := first.Add(time.Minute) _, stdout, stderr := readIdleFrames(t, idleWarningFrame(first), idleWarningFrame(first), idleWarningFrame(second), idleWarningFrame(first), ) assert.Empty(t, stdout) - assert.Equal(t, idleNotice(first)+idleNotice(second), stderr) + assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n"+ + "[Idle: this session closes at 15:05:05 unless you type something.]\r\n", stderr) } func TestReadFromServer_IdleWarningStaysDeduplicatedOnReconnect(t *testing.T) { - closesAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + closesAt := time.Date(time.Now().Year()+1, time.January, 2, 15, 4, 5, 0, time.Local) _, stdout, notices := runReplaySession(t, replayRound{frames: []replayFrame{idleWarningFrame(closesAt)}, closeCode: websocket.CloseServiceRestart}, replayRound{frames: []replayFrame{idleWarningFrame(closesAt)}}, ) assert.Empty(t, stdout) - assert.Equal(t, idleNotice(closesAt)+"\r\nConnection lost, reconnecting...\r\n", notices) + assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n\r\nConnection lost, reconnecting...\r\n", notices) } func idleWarningFrame(closesAt time.Time) replayFrame { return replayFrame{websocket.TextMessage, fmt.Sprintf(`{"type":"idle_warning","closes_at":%q,"message":"untrusted server text"}`, closesAt.Format(time.RFC3339))} } -func idleNotice(closesAt time.Time) string { - return "[Idle: this session closes at " + closesAt.Local().Format("15:04:05") + " unless you type something.]\r\n" -} - func readIdleFrames(t *testing.T, frames ...replayFrame) (*connection, string, string) { t.Helper() upgrader := websocket.Upgrader{} From 8e03c878bfc9c8495dd734224f52d552812a6fa1 Mon Sep 17 00:00:00 2001 From: Eunyoung Jeong Date: Fri, 30 Oct 2026 16:45:05 -0700 Subject: [PATCH 3/7] fix(websh): prune elapsed idle warning deadlines --- api/websh/idle_warning_test.go | 24 ++++++++++++++++++++++++ api/websh/websh.go | 8 +++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/api/websh/idle_warning_test.go b/api/websh/idle_warning_test.go index 363fe542..d16af3fd 100644 --- a/api/websh/idle_warning_test.go +++ b/api/websh/idle_warning_test.go @@ -99,11 +99,34 @@ func TestReadFromServer_IdleWarningStaysDeduplicatedOnReconnect(t *testing.T) { assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n\r\nConnection lost, reconnecting...\r\n", notices) } +func TestReadFromServer_IdleWarningPrunesExpiredHistory(t *testing.T) { + expired := time.Now().Add(-time.Hour).UTC() + retained := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + next := retained.Add(time.Minute) + history := map[time.Time]struct{}{expired: {}, retained: {}} + + conn, stdout, stderr := readIdleFramesWithHistory(t, history, + idleWarningFrame(retained), idleWarningFrame(next), idleWarningFrame(expired), + ) + + assert.Empty(t, stdout) + assert.Equal(t, fmt.Sprintf("[Idle: this session closes at %s unless you type something.]\r\n", next.Local().Format("15:04:05")), stderr) + assert.NotContains(t, conn.idleWarnings, expired) + assert.Contains(t, conn.idleWarnings, retained) + assert.Contains(t, conn.idleWarnings, next) + assert.Len(t, conn.idleWarnings, 2) +} + func idleWarningFrame(closesAt time.Time) replayFrame { return replayFrame{websocket.TextMessage, fmt.Sprintf(`{"type":"idle_warning","closes_at":%q,"message":"untrusted server text"}`, closesAt.Format(time.RFC3339))} } func readIdleFrames(t *testing.T, frames ...replayFrame) (*connection, string, string) { + t.Helper() + return readIdleFramesWithHistory(t, nil, frames...) +} + +func readIdleFramesWithHistory(t *testing.T, history map[time.Time]struct{}, frames ...replayFrame) (*connection, string, string) { t.Helper() upgrader := websocket.Upgrader{} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -123,6 +146,7 @@ func readIdleFrames(t *testing.T, frames ...replayFrame) (*connection, string, s ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ts.URL, "http"), nil) require.NoError(t, err) conn := newConnection(ws) + conn.idleWarnings = history t.Cleanup(conn.close) stdout, stderr := testutil.CaptureOutput(t, func() { awaitReturn(t, "warning reader did not finish", conn.readFromServer) diff --git a/api/websh/websh.go b/api/websh/websh.go index f7f3fe4b..ddf62f92 100644 --- a/api/websh/websh.go +++ b/api/websh/websh.go @@ -719,9 +719,15 @@ func (c *connection) readFromServer() { } if origin.Type == "idle_warning" { var closesAt time.Time - if err := json.Unmarshal(origin.ClosesAt, &closesAt); err != nil || !closesAt.After(time.Now()) { + now := time.Now() + if err := json.Unmarshal(origin.ClosesAt, &closesAt); err != nil || !closesAt.After(now) { continue } + for deadline := range c.idleWarnings { + if !deadline.After(now) { + delete(c.idleWarnings, deadline) + } + } closesAt = closesAt.UTC() if _, seen := c.idleWarnings[closesAt]; seen { continue From 6e2843c8dcbcfcfcc5a4284289fa385acd1addf8 Mon Sep 17 00:00:00 2001 From: Eunyoung Jeong Date: Fri, 30 Oct 2026 16:58:30 -0700 Subject: [PATCH 4/7] fix(websh): omit typing advice for read-only viewers --- api/websh/idle_warning_test.go | 17 ++++++++++++++--- api/websh/types.go | 1 + api/websh/websh.go | 7 ++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/api/websh/idle_warning_test.go b/api/websh/idle_warning_test.go index d16af3fd..a191429f 100644 --- a/api/websh/idle_warning_test.go +++ b/api/websh/idle_warning_test.go @@ -99,13 +99,23 @@ func TestReadFromServer_IdleWarningStaysDeduplicatedOnReconnect(t *testing.T) { assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n\r\nConnection lost, reconnecting...\r\n", notices) } +func TestReadFromServer_IdleWarningReadOnlyOmitsTypingAdvice(t *testing.T) { + closesAt := time.Date(time.Now().Year()+1, time.January, 2, 15, 4, 5, 0, time.Local) + _, stdout, stderr := readIdleFramesWithState(t, nil, true, + idleWarningFrame(closesAt), idleWarningFrame(closesAt), + ) + + assert.Empty(t, stdout) + assert.Equal(t, "[Idle: this session closes at 15:04:05.]\r\n", stderr) +} + func TestReadFromServer_IdleWarningPrunesExpiredHistory(t *testing.T) { expired := time.Now().Add(-time.Hour).UTC() retained := time.Now().Add(time.Hour).UTC().Truncate(time.Second) next := retained.Add(time.Minute) history := map[time.Time]struct{}{expired: {}, retained: {}} - conn, stdout, stderr := readIdleFramesWithHistory(t, history, + conn, stdout, stderr := readIdleFramesWithState(t, history, false, idleWarningFrame(retained), idleWarningFrame(next), idleWarningFrame(expired), ) @@ -123,10 +133,10 @@ func idleWarningFrame(closesAt time.Time) replayFrame { func readIdleFrames(t *testing.T, frames ...replayFrame) (*connection, string, string) { t.Helper() - return readIdleFramesWithHistory(t, nil, frames...) + return readIdleFramesWithState(t, nil, false, frames...) } -func readIdleFramesWithHistory(t *testing.T, history map[time.Time]struct{}, frames ...replayFrame) (*connection, string, string) { +func readIdleFramesWithState(t *testing.T, history map[time.Time]struct{}, readOnly bool, frames ...replayFrame) (*connection, string, string) { t.Helper() upgrader := websocket.Upgrader{} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -147,6 +157,7 @@ func readIdleFramesWithHistory(t *testing.T, history map[time.Time]struct{}, fra require.NoError(t, err) conn := newConnection(ws) conn.idleWarnings = history + conn.readOnly = readOnly t.Cleanup(conn.close) stdout, stderr := testutil.CaptureOutput(t, func() { awaitReturn(t, "warning reader did not finish", conn.readFromServer) diff --git a/api/websh/types.go b/api/websh/types.go index 362e3a7f..0d7e4b13 100644 --- a/api/websh/types.go +++ b/api/websh/types.go @@ -44,6 +44,7 @@ type connection struct { pumps sync.WaitGroup replay *replayState reconnected bool + readOnly bool notice io.Writer idleWarnings map[time.Time]struct{} } diff --git a/api/websh/websh.go b/api/websh/websh.go index ddf62f92..1e3952fe 100644 --- a/api/websh/websh.go +++ b/api/websh/websh.go @@ -469,6 +469,7 @@ func OpenReadOnlyTerminal(ac *client.AlpaconClient, sessionResponse SessionRespo defer restore() conn := wsClient.conn + conn.readOnly = true go wsClient.watchInterrupt(sigChan) go wsClient.readCtrlC() conn.startReader() @@ -740,7 +741,11 @@ func (c *connection) readFromServer() { if notice == nil { notice = os.Stderr } - _, _ = fmt.Fprintf(notice, "[Idle: this session closes at %s unless you type something.]\r\n", closesAt.Local().Format("15:04:05")) + if c.readOnly { + _, _ = fmt.Fprintf(notice, "[Idle: this session closes at %s.]\r\n", closesAt.Local().Format("15:04:05")) + } else { + _, _ = fmt.Fprintf(notice, "[Idle: this session closes at %s unless you type something.]\r\n", closesAt.Local().Format("15:04:05")) + } continue } if legacy || origin.Type != "replay_origin" || origin.Epoch == "" { From 73359e41579e212fa47e7391f5b93dbc88369dd0 Mon Sep 17 00:00:00 2001 From: Eunyoung Jeong Date: Fri, 30 Oct 2026 17:24:10 -0700 Subject: [PATCH 5/7] feat(websh): announce idle-warning and name an idle detach --- api/websh/idle_warning_test.go | 16 ++++---- api/websh/websh.go | 35 ++++++++-------- api/websh/websh_test.go | 75 ++++++++++++++++++++++++++++------ client/client.go | 3 ++ 4 files changed, 89 insertions(+), 40 deletions(-) diff --git a/api/websh/idle_warning_test.go b/api/websh/idle_warning_test.go index a191429f..438e15ad 100644 --- a/api/websh/idle_warning_test.go +++ b/api/websh/idle_warning_test.go @@ -23,7 +23,7 @@ func TestReadFromServer_IdleWarningUsesStderrAndLocalTime(t *testing.T) { _, stdout, stderr := readIdleFrames(t, idleWarningFrame(closesAt)) assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n", stderr) } func TestReadFromServer_IdleWarningPreservesReplay(t *testing.T) { @@ -51,7 +51,7 @@ func TestReadFromServer_IdleWarningPreservesReplay(t *testing.T) { } else { assert.Empty(t, stdout) } - assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n", stderr) }) } } @@ -73,7 +73,7 @@ func TestReadFromServer_IdleWarningIgnoresInvalidDeadlines(t *testing.T) { replayFrame{websocket.TextMessage, tc.body}, idleWarningFrame(closesAt), ) assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n", stderr) }) } } @@ -85,8 +85,8 @@ func TestReadFromServer_IdleWarningPrintsOncePerDeadline(t *testing.T) { idleWarningFrame(first), idleWarningFrame(first), idleWarningFrame(second), idleWarningFrame(first), ) assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n"+ - "[Idle: this session closes at 15:05:05 unless you type something.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n"+ + "[Idle: this session disconnects at 15:05:05 unless you type something.]\r\n", stderr) } func TestReadFromServer_IdleWarningStaysDeduplicatedOnReconnect(t *testing.T) { @@ -96,7 +96,7 @@ func TestReadFromServer_IdleWarningStaysDeduplicatedOnReconnect(t *testing.T) { replayRound{frames: []replayFrame{idleWarningFrame(closesAt)}}, ) assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session closes at 15:04:05 unless you type something.]\r\n\r\nConnection lost, reconnecting...\r\n", notices) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n\r\nConnection lost, reconnecting...\r\n", notices) } func TestReadFromServer_IdleWarningReadOnlyOmitsTypingAdvice(t *testing.T) { @@ -106,7 +106,7 @@ func TestReadFromServer_IdleWarningReadOnlyOmitsTypingAdvice(t *testing.T) { ) assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session closes at 15:04:05.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05.]\r\n", stderr) } func TestReadFromServer_IdleWarningPrunesExpiredHistory(t *testing.T) { @@ -120,7 +120,7 @@ func TestReadFromServer_IdleWarningPrunesExpiredHistory(t *testing.T) { ) assert.Empty(t, stdout) - assert.Equal(t, fmt.Sprintf("[Idle: this session closes at %s unless you type something.]\r\n", next.Local().Format("15:04:05")), stderr) + assert.Equal(t, fmt.Sprintf("[Idle: this session disconnects at %s unless you type something.]\r\n", next.Local().Format("15:04:05")), stderr) assert.NotContains(t, conn.idleWarnings, expired) assert.Contains(t, conn.idleWarnings, retained) assert.Contains(t, conn.idleWarnings, next) diff --git a/api/websh/websh.go b/api/websh/websh.go index 1e3952fe..a7b11c78 100644 --- a/api/websh/websh.go +++ b/api/websh/websh.go @@ -30,15 +30,11 @@ const ( ctrlC = 0x03 writeFlushInterval = 5 * time.Millisecond - // sessionEndCloseCode is how the proxy, and Alpamon, close a user channel at the - // end of a session. A websh session never ends with 1000, so without it every - // normal end would be reported as a failure. + // sessionEndCloseCode ends the client without auto-reconnecting, including an idle detach. sessionEndCloseCode = 4000 - // A close that does not end the session is a dropped link (a restarted service, a - // network interruption); the session still runs on the server, so the client dials - // a new channel onto it. The budget, about 30s of waiting, bounds a server that - // keeps accepting a channel and dropping it. + // Dropped links get a new channel unless the close ends the client. + // The retry budget bounds servers that repeatedly accept and drop channels. maxReconnectAttempts = 5 reconnectBaseDelay = 1 * time.Second reconnectMaxDelay = 15 * time.Second @@ -61,12 +57,12 @@ var ( // closed while the connection was down, so retrying only asks again. ErrSessionGone = errors.New("the session is no longer open") - // closeReasonMessages maps the proxy's session-end reason tokens; an empty message stays + // closeReasonMessages maps the proxy's close reason tokens; an empty message stays // silent, since shell_exited means the user ended the shell and closed says nothing. closeReasonMessages = map[string]string{ "user_request": "the session was closed", "force_close": "closed by an administrator", - "idle_timeout": "closed for inactivity; run the command again to reopen", + "idle_detached": "disconnected for inactivity; the shell keeps running on the server until the work session ends", "agent_disconnected": "Alpamon on the server disconnected", "server_shutdown": "the service restarted", "work_session_expired": "the work session expired", @@ -421,14 +417,15 @@ func printCloseReason(err error) { if message == "" { return } - _, _ = fmt.Fprintf(os.Stderr, "\r\nsession closed: %s\r\n", message) + status := "closed" + if reason == "idle_detached" { + status = "disconnected" + } + _, _ = fmt.Fprintf(os.Stderr, "\r\nsession %s: %s\r\n", status, message) } } -// endsSession reports whether a connection error means the session itself ended: -// the proxy's session-end code or a normal WebSocket close. Any other ending (1012, -// 1006, a transport error with no close frame) leaves the session running on the -// server, so the connection is re-dialed. +// endsSession reports whether a close ends the client, even if the shell survives an idle detach. func endsSession(err error) bool { if err == nil { return true @@ -453,7 +450,7 @@ func reconnectDelay(failures int, base time.Duration) time.Duration { // Input is not forwarded to the server. Terminal echo is suppressed via raw mode. // Ends cleanly on the remote close, on Ctrl+C, or on a signal. func OpenReadOnlyTerminal(ac *client.AlpaconClient, sessionResponse SessionResponse) error { - wsClient := newWebsocketClient(ac.SetWebsocketHeader()) + wsClient := newWebsocketClient(ac.SetWebsocketHeaderWithCapabilities(client.CapabilityIdleWarning)) if err := wsClient.dial(sessionResponse.WebsocketURL); err != nil { return err } @@ -531,9 +528,9 @@ func OpenSharedTerminal(ac *client.AlpaconClient, sessionResponse SessionRespons func openInteractiveTerminal(ac *client.AlpaconClient, sessionResponse SessionResponse, reconnect *reconnector) error { var header http.Header if reconnect != nil { - header = ac.SetWebsocketHeaderWithCapabilities(client.CapabilityWebsocketReconnect) + header = ac.SetWebsocketHeaderWithCapabilities(client.CapabilityWebsocketReconnect, client.CapabilityIdleWarning) } else { - header = ac.SetWebsocketHeader() + header = ac.SetWebsocketHeaderWithCapabilities(client.CapabilityIdleWarning) } wsClient := newWebsocketClient(header) @@ -742,9 +739,9 @@ func (c *connection) readFromServer() { notice = os.Stderr } if c.readOnly { - _, _ = fmt.Fprintf(notice, "[Idle: this session closes at %s.]\r\n", closesAt.Local().Format("15:04:05")) + _, _ = fmt.Fprintf(notice, "[Idle: this session disconnects at %s.]\r\n", closesAt.Local().Format("15:04:05")) } else { - _, _ = fmt.Fprintf(notice, "[Idle: this session closes at %s unless you type something.]\r\n", closesAt.Local().Format("15:04:05")) + _, _ = fmt.Fprintf(notice, "[Idle: this session disconnects at %s unless you type something.]\r\n", closesAt.Local().Format("15:04:05")) } continue } diff --git a/api/websh/websh_test.go b/api/websh/websh_test.go index 661df273..74a01a2a 100644 --- a/api/websh/websh_test.go +++ b/api/websh/websh_test.go @@ -586,9 +586,9 @@ func TestFinish_SettlesASingleCloseByItsCode(t *testing.T) { keepAsErr bool }{ { - name: "session end with a reason", - reported: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_timeout"}, - wantStderr: "\r\nsession closed: closed for inactivity; run the command again to reopen\r\n", + name: "idle detach keeps the shell running", + reported: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_detached"}, + wantStderr: "\r\nsession disconnected: disconnected for inactivity; the shell keeps running on the server until the work session ends\r\n", }, { name: "session end with no reason", @@ -725,14 +725,14 @@ func TestFinish_KeepsTheFirstOutcome(t *testing.T) { { name: "earlier failure over a later session end", first: errors.New("write failed on the closed connection"), - second: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_timeout"}, + second: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_detached"}, wantErr: errors.New("write failed on the closed connection"), }, { name: "duplicate session end prints once", - first: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_timeout"}, - second: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_timeout"}, - wantStderr: "\r\nsession closed: closed for inactivity; run the command again to reopen\r\n", + first: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_detached"}, + second: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_detached"}, + wantStderr: "\r\nsession disconnected: disconnected for inactivity; the shell keeps running on the server until the work session ends\r\n", }, } @@ -1104,7 +1104,7 @@ func TestServeConnections_ReconnectsAfterAServiceRestart(t *testing.T) { t.Cleanup(ts.Close) ac := &client.AlpaconClient{HTTPClient: ts.Client(), BaseURL: ts.URL, UserAgent: "alpacon-cli/test"} - wsClient := newWebsocketClient(ac.SetWebsocketHeaderWithCapabilities(client.CapabilityWebsocketReconnect)) + wsClient := newWebsocketClient(ac.SetWebsocketHeaderWithCapabilities(client.CapabilityWebsocketReconnect, client.CapabilityIdleWarning)) wsClient.reconnect = newReconnector(ac, "sess-1") wsClient.reconnect.baseDelay = time.Millisecond wsClient.reconnect.notice = io.Discard @@ -1143,7 +1143,7 @@ func TestServeConnections_ReconnectsAfterAServiceRestart(t *testing.T) { for dial := 1; dial <= 2; dial++ { select { case header := <-dialHeaders: - assert.Equal(t, client.CapabilityWebsocketReconnect, + assert.Equal(t, "websocket-reconnect, idle-warning", header.Get(client.ClientCapabilitiesHeader), "dial %d", dial) assert.Equal(t, "alpacon-cli/test", header.Get("User-Agent"), "dial %d", dial) default: @@ -1158,6 +1158,49 @@ func TestServeConnections_ReconnectsAfterAServiceRestart(t *testing.T) { } } +func TestServeConnections_IdleDetachDoesNotReconnect(t *testing.T) { + upgrader := websocket.Upgrader{} + var dials atomic.Int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dials.Add(1) + ws, err := upgrader.Upgrade(w, r, nil) + if !assert.NoError(t, err) { + return + } + defer func() { _ = ws.Close() }() + assert.NoError(t, ws.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(sessionEndCloseCode, "idle_detached"))) + })) + t.Cleanup(ts.Close) + + wsClient := newWebsocketClient(nil) + var attempts int + var notices strings.Builder + wsClient.reconnect = &reconnector{ + provision: func() (string, error) { + attempts++ + return "", errors.New("unexpected reconnect after idle detach") + }, + notice: ¬ices, baseDelay: time.Millisecond, maxAttempts: 1, + } + require.NoError(t, wsClient.dial("ws"+strings.TrimPrefix(ts.URL, "http"))) + t.Cleanup(wsClient.closeConn) + + stdout, stderr := testutil.CaptureOutput(t, func() { + awaitReturn(t, "idle detach did not end the client", func() { + wsClient.serveConnections(make(chan string)) + }) + }) + + assertReported(t, wsClient) + require.NoError(t, wsClient.err) + assert.Zero(t, attempts) + assert.Equal(t, int32(1), dials.Load()) + assert.Empty(t, stdout) + assert.Empty(t, notices.String()) + assert.Equal(t, "\r\nsession disconnected: disconnected for inactivity; the shell keeps running on the server until the work session ends\r\n", stderr) +} + func TestRedial_StopsWhenTheServerRefusesANewChannel(t *testing.T) { t.Parallel() var attempts atomic.Int32 @@ -1245,7 +1288,7 @@ func pinTerminalSize(t *testing.T, rows, cols int) { // What reaches the server on the real entry points' first dial. Raw mode fails on // a pipe, so each call returns right after the handshake the assertions read. -func TestOpenTerminal_AdvertisesTheCapabilityOnlyWhereItReconnects(t *testing.T) { +func TestOpenTerminal_AdvertisesIdleWarningAndReconnectCapabilities(t *testing.T) { tests := []struct { name string open func(*client.AlpaconClient, SessionResponse) error @@ -1254,13 +1297,19 @@ func TestOpenTerminal_AdvertisesTheCapabilityOnlyWhereItReconnects(t *testing.T) { name: "an interactive session reconnects", open: OpenNewTerminal, - want: client.CapabilityWebsocketReconnect, + want: "websocket-reconnect, idle-warning", }, { // The joiner may not open a channel on someone else's session, so - // claiming the capability would promise a recovery it cannot make. - name: "a joined session does not", + // claiming the reconnect capability would promise a recovery it cannot make. + name: "a joined session warns without reconnecting", open: OpenSharedTerminal, + want: "idle-warning", + }, + { + name: "a watched session warns without reconnecting", + open: OpenReadOnlyTerminal, + want: "idle-warning", }, } diff --git a/client/client.go b/client/client.go index d0a71bb9..bd270b77 100644 --- a/client/client.go +++ b/client/client.go @@ -37,6 +37,9 @@ const ( // for a transient reason does not take the session with it. CapabilityWebsocketReconnect = "websocket-reconnect" + // CapabilityIdleWarning says the client handles idle warnings without printing raw frames. + CapabilityIdleWarning = "idle-warning" + // serverGateTokenScope is the "gate" of a token-scope refusal, the only gate whose "missing" is // a scope string rather than a permission or role. serverGateTokenScope = "token_scope" From 072fb94f9e19733ec960ea80174a25c59aaca742 Mon Sep 17 00:00:00 2001 From: Eunyoung Jeong Date: Fri, 30 Oct 2026 17:30:59 -0700 Subject: [PATCH 6/7] fix(websh): neutral idle warning and one detach line --- api/websh/idle_warning_test.go | 33 +++++++++++---------------------- api/websh/types.go | 1 - api/websh/websh.go | 22 ++++++++-------------- api/websh/websh_test.go | 6 +++--- 4 files changed, 22 insertions(+), 40 deletions(-) diff --git a/api/websh/idle_warning_test.go b/api/websh/idle_warning_test.go index 438e15ad..1ab853c9 100644 --- a/api/websh/idle_warning_test.go +++ b/api/websh/idle_warning_test.go @@ -23,7 +23,7 @@ func TestReadFromServer_IdleWarningUsesStderrAndLocalTime(t *testing.T) { _, stdout, stderr := readIdleFrames(t, idleWarningFrame(closesAt)) assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 without input.]\r\n", stderr) } func TestReadFromServer_IdleWarningPreservesReplay(t *testing.T) { @@ -51,7 +51,7 @@ func TestReadFromServer_IdleWarningPreservesReplay(t *testing.T) { } else { assert.Empty(t, stdout) } - assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 without input.]\r\n", stderr) }) } } @@ -73,7 +73,7 @@ func TestReadFromServer_IdleWarningIgnoresInvalidDeadlines(t *testing.T) { replayFrame{websocket.TextMessage, tc.body}, idleWarningFrame(closesAt), ) assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 without input.]\r\n", stderr) }) } } @@ -85,8 +85,8 @@ func TestReadFromServer_IdleWarningPrintsOncePerDeadline(t *testing.T) { idleWarningFrame(first), idleWarningFrame(first), idleWarningFrame(second), idleWarningFrame(first), ) assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n"+ - "[Idle: this session disconnects at 15:05:05 unless you type something.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 without input.]\r\n"+ + "[Idle: this session disconnects at 15:05:05 without input.]\r\n", stderr) } func TestReadFromServer_IdleWarningStaysDeduplicatedOnReconnect(t *testing.T) { @@ -96,17 +96,7 @@ func TestReadFromServer_IdleWarningStaysDeduplicatedOnReconnect(t *testing.T) { replayRound{frames: []replayFrame{idleWarningFrame(closesAt)}}, ) assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session disconnects at 15:04:05 unless you type something.]\r\n\r\nConnection lost, reconnecting...\r\n", notices) -} - -func TestReadFromServer_IdleWarningReadOnlyOmitsTypingAdvice(t *testing.T) { - closesAt := time.Date(time.Now().Year()+1, time.January, 2, 15, 4, 5, 0, time.Local) - _, stdout, stderr := readIdleFramesWithState(t, nil, true, - idleWarningFrame(closesAt), idleWarningFrame(closesAt), - ) - - assert.Empty(t, stdout) - assert.Equal(t, "[Idle: this session disconnects at 15:04:05.]\r\n", stderr) + assert.Equal(t, "[Idle: this session disconnects at 15:04:05 without input.]\r\n\r\nConnection lost, reconnecting...\r\n", notices) } func TestReadFromServer_IdleWarningPrunesExpiredHistory(t *testing.T) { @@ -115,12 +105,12 @@ func TestReadFromServer_IdleWarningPrunesExpiredHistory(t *testing.T) { next := retained.Add(time.Minute) history := map[time.Time]struct{}{expired: {}, retained: {}} - conn, stdout, stderr := readIdleFramesWithState(t, history, false, - idleWarningFrame(retained), idleWarningFrame(next), idleWarningFrame(expired), + conn, stdout, stderr := readIdleFramesWithHistory(t, history, + idleWarningFrame(retained), idleWarningFrame(next), ) assert.Empty(t, stdout) - assert.Equal(t, fmt.Sprintf("[Idle: this session disconnects at %s unless you type something.]\r\n", next.Local().Format("15:04:05")), stderr) + assert.Equal(t, fmt.Sprintf("[Idle: this session disconnects at %s without input.]\r\n", next.Local().Format("15:04:05")), stderr) assert.NotContains(t, conn.idleWarnings, expired) assert.Contains(t, conn.idleWarnings, retained) assert.Contains(t, conn.idleWarnings, next) @@ -133,10 +123,10 @@ func idleWarningFrame(closesAt time.Time) replayFrame { func readIdleFrames(t *testing.T, frames ...replayFrame) (*connection, string, string) { t.Helper() - return readIdleFramesWithState(t, nil, false, frames...) + return readIdleFramesWithHistory(t, nil, frames...) } -func readIdleFramesWithState(t *testing.T, history map[time.Time]struct{}, readOnly bool, frames ...replayFrame) (*connection, string, string) { +func readIdleFramesWithHistory(t *testing.T, history map[time.Time]struct{}, frames ...replayFrame) (*connection, string, string) { t.Helper() upgrader := websocket.Upgrader{} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -157,7 +147,6 @@ func readIdleFramesWithState(t *testing.T, history map[time.Time]struct{}, readO require.NoError(t, err) conn := newConnection(ws) conn.idleWarnings = history - conn.readOnly = readOnly t.Cleanup(conn.close) stdout, stderr := testutil.CaptureOutput(t, func() { awaitReturn(t, "warning reader did not finish", conn.readFromServer) diff --git a/api/websh/types.go b/api/websh/types.go index 0d7e4b13..362e3a7f 100644 --- a/api/websh/types.go +++ b/api/websh/types.go @@ -44,7 +44,6 @@ type connection struct { pumps sync.WaitGroup replay *replayState reconnected bool - readOnly bool notice io.Writer idleWarnings map[time.Time]struct{} } diff --git a/api/websh/websh.go b/api/websh/websh.go index a7b11c78..58a77561 100644 --- a/api/websh/websh.go +++ b/api/websh/websh.go @@ -30,11 +30,10 @@ const ( ctrlC = 0x03 writeFlushInterval = 5 * time.Millisecond - // sessionEndCloseCode ends the client without auto-reconnecting, including an idle detach. + // sessionEndCloseCode treats 4000 as normal; the server never ends sessions with 1000. sessionEndCloseCode = 4000 - // Dropped links get a new channel unless the close ends the client. - // The retry budget bounds servers that repeatedly accept and drop channels. + // 1012 and 1006 leave the session running, so re-dial; cap repeated drops. maxReconnectAttempts = 5 reconnectBaseDelay = 1 * time.Second reconnectMaxDelay = 15 * time.Second @@ -62,7 +61,7 @@ var ( closeReasonMessages = map[string]string{ "user_request": "the session was closed", "force_close": "closed by an administrator", - "idle_detached": "disconnected for inactivity; the shell keeps running on the server until the work session ends", + "idle_detached": "after inactivity; the shell keeps running on the server until the work session ends", "agent_disconnected": "Alpamon on the server disconnected", "server_shutdown": "the service restarted", "work_session_expired": "the work session expired", @@ -417,15 +416,15 @@ func printCloseReason(err error) { if message == "" { return } - status := "closed" + prefix := "session closed: " if reason == "idle_detached" { - status = "disconnected" + prefix = "session disconnected " } - _, _ = fmt.Fprintf(os.Stderr, "\r\nsession %s: %s\r\n", status, message) + _, _ = fmt.Fprintf(os.Stderr, "\r\n%s%s\r\n", prefix, message) } } -// endsSession reports whether a close ends the client, even if the shell survives an idle detach. +// endsSession treats 4000 as final without reconnecting, even when idle detach leaves the shell running. func endsSession(err error) bool { if err == nil { return true @@ -466,7 +465,6 @@ func OpenReadOnlyTerminal(ac *client.AlpaconClient, sessionResponse SessionRespo defer restore() conn := wsClient.conn - conn.readOnly = true go wsClient.watchInterrupt(sigChan) go wsClient.readCtrlC() conn.startReader() @@ -738,11 +736,7 @@ func (c *connection) readFromServer() { if notice == nil { notice = os.Stderr } - if c.readOnly { - _, _ = fmt.Fprintf(notice, "[Idle: this session disconnects at %s.]\r\n", closesAt.Local().Format("15:04:05")) - } else { - _, _ = fmt.Fprintf(notice, "[Idle: this session disconnects at %s unless you type something.]\r\n", closesAt.Local().Format("15:04:05")) - } + _, _ = fmt.Fprintf(notice, "[Idle: this session disconnects at %s without input.]\r\n", closesAt.Local().Format("15:04:05")) continue } if legacy || origin.Type != "replay_origin" || origin.Epoch == "" { diff --git a/api/websh/websh_test.go b/api/websh/websh_test.go index 74a01a2a..4698fe11 100644 --- a/api/websh/websh_test.go +++ b/api/websh/websh_test.go @@ -588,7 +588,7 @@ func TestFinish_SettlesASingleCloseByItsCode(t *testing.T) { { name: "idle detach keeps the shell running", reported: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_detached"}, - wantStderr: "\r\nsession disconnected: disconnected for inactivity; the shell keeps running on the server until the work session ends\r\n", + wantStderr: "\r\nsession disconnected after inactivity; the shell keeps running on the server until the work session ends\r\n", }, { name: "session end with no reason", @@ -732,7 +732,7 @@ func TestFinish_KeepsTheFirstOutcome(t *testing.T) { name: "duplicate session end prints once", first: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_detached"}, second: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_detached"}, - wantStderr: "\r\nsession disconnected: disconnected for inactivity; the shell keeps running on the server until the work session ends\r\n", + wantStderr: "\r\nsession disconnected after inactivity; the shell keeps running on the server until the work session ends\r\n", }, } @@ -1198,7 +1198,7 @@ func TestServeConnections_IdleDetachDoesNotReconnect(t *testing.T) { assert.Equal(t, int32(1), dials.Load()) assert.Empty(t, stdout) assert.Empty(t, notices.String()) - assert.Equal(t, "\r\nsession disconnected: disconnected for inactivity; the shell keeps running on the server until the work session ends\r\n", stderr) + assert.Equal(t, "\r\nsession disconnected after inactivity; the shell keeps running on the server until the work session ends\r\n", stderr) } func TestRedial_StopsWhenTheServerRefusesANewChannel(t *testing.T) { From 07f14f1a3e1c829a73a013f73524b01af96cd8fb Mon Sep 17 00:00:00 2001 From: Eunyoung Jeong Date: Fri, 30 Oct 2026 17:55:21 -0700 Subject: [PATCH 7/7] feat(websh): name an idle close for sessions without a work session --- api/websh/websh.go | 6 +++++- api/websh/websh_test.go | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/api/websh/websh.go b/api/websh/websh.go index 58a77561..5d74e906 100644 --- a/api/websh/websh.go +++ b/api/websh/websh.go @@ -61,6 +61,7 @@ var ( closeReasonMessages = map[string]string{ "user_request": "the session was closed", "force_close": "closed by an administrator", + "idle_timeout": "after inactivity", "idle_detached": "after inactivity; the shell keeps running on the server until the work session ends", "agent_disconnected": "Alpamon on the server disconnected", "server_shutdown": "the service restarted", @@ -417,7 +418,10 @@ func printCloseReason(err error) { return } prefix := "session closed: " - if reason == "idle_detached" { + switch reason { + case "idle_timeout": + prefix = "session closed " + case "idle_detached": prefix = "session disconnected " } _, _ = fmt.Fprintf(os.Stderr, "\r\n%s%s\r\n", prefix, message) diff --git a/api/websh/websh_test.go b/api/websh/websh_test.go index 4698fe11..c50025c1 100644 --- a/api/websh/websh_test.go +++ b/api/websh/websh_test.go @@ -585,6 +585,11 @@ func TestFinish_SettlesASingleCloseByItsCode(t *testing.T) { wantStderr string keepAsErr bool }{ + { + name: "idle timeout closes the shell", + reported: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_timeout"}, + wantStderr: "\r\nsession closed after inactivity\r\n", + }, { name: "idle detach keeps the shell running", reported: &websocket.CloseError{Code: sessionEndCloseCode, Text: "idle_detached"},