Skip to content

Commit 0c44147

Browse files
authored
fix(app): show failed terminal lists with retry and reject stale answers (#61650)
1 parent b562a00 commit 0c44147

11 files changed

Lines changed: 972 additions & 185 deletions

File tree

‎packages/app/e2e/regression/terminal.spec.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,35 @@ test("a dropped connection says the terminal disconnected and keeps its tab", as
217217
expect(await terminalText(page, screen)).toBe("still-here")
218218
})
219219

220+
test("a terminal list that fails says why, and Try again lists the terminals", async ({ page }) => {
221+
const { persistentPty } = await openSession(page, {
222+
name: "Terminals",
223+
persistentPty: { initial: [{ id: "pty_build", sessionID: SESSION, title: "Build", output: "still-here\r\n" }] },
224+
})
225+
226+
await openSide(page)
227+
await side(page).getByRole("tab", { name: "Build", exact: true }).click()
228+
const screen = terminalScreen(page, "pty_build")
229+
await expect.poll(() => terminalText(page, screen)).toBe("still-here")
230+
231+
// The restored session's list fails with an error other than the daemon's 503.
232+
persistentPty.listFails = true
233+
await page.reload()
234+
235+
const failure = side(page).getByRole("alert")
236+
await expect(failure.getByText("Couldn't load terminals", { exact: true })).toBeVisible()
237+
await expect(failure.getByText("UnexpectedStatus: 500", { exact: true })).toBeVisible()
238+
// Without the list the restored tab has no title yet, but it shows and stays selected.
239+
await expect(side(page).getByRole("tab", { name: "Terminal", exact: true })).toHaveAttribute("aria-selected", "true")
240+
241+
persistentPty.listFails = false
242+
await failure.getByRole("button", { name: "Try again", exact: true }).click()
243+
244+
await expect(side(page).getByRole("tab", { name: "Build", exact: true })).toHaveAttribute("aria-selected", "true")
245+
await expect.poll(() => terminalText(page, screen)).toBe("still-here")
246+
await expect(side(page).getByText("Couldn't load terminals", { exact: true })).toHaveCount(0)
247+
})
248+
220249
test("after a server restart, showing the terminal again restores it from a new snapshot", async ({ page }) => {
221250
const output = "before-restart\r\n"
222251

‎packages/app/e2e/utils/mock-api.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ const Group = HttpApiGroup.make("mock")
277277
HttpApiEndpoint.get("persistentPtyList", "/api/experimental/session/:sessionID/terminal", {
278278
params: SessionParams,
279279
success: Json,
280-
error: PersistentPtyUnavailable,
280+
error: [PersistentPtyUnavailable, MockInternal.pipe(HttpApiSchema.status(500))],
281281
}),
282282
)
283283
.add(

‎packages/app/e2e/utils/mock-server.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,13 @@ function mockHandlers(
10471047
// Like the server, which also answers 204 for a shell that already ended.
10481048
shellRemove: () => noContent,
10491049
persistentPtyList: (ctx) =>
1050-
persistentPtyEnabled.pipe(Effect.map(() => ({ data: state.persistentPty.list(ctx.params.sessionID) }))),
1050+
persistentPtyEnabled.pipe(
1051+
Effect.andThen(() =>
1052+
state.persistentPty.listFails
1053+
? Effect.fail(new MockInternal({ message: "Listing terminals failed" }))
1054+
: Effect.succeed({ data: state.persistentPty.list(ctx.params.sessionID) }),
1055+
),
1056+
),
10511057
persistentPtyCreate: (ctx) =>
10521058
persistentPtyEnabled.pipe(
10531059
Effect.map(() => {

‎packages/app/e2e/utils/persistent-pty.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ export type MockPersistentPty = {
108108
// Read by `/api/info`; tests may change both mid-test.
109109
capability: boolean | undefined
110110
renameSupported: boolean
111+
// Whether listing a session's terminals answers 500, an error other than the daemon's 503. Starts false; tests may
112+
// change it mid-test.
113+
listFails: boolean
111114
// Live terminals in creation order.
112115
terminals: MockPersistentPtyInfo[]
113116
created: MockPersistentPtyInfo[]
@@ -336,6 +339,7 @@ export function createPersistentPty(
336339
const pty: MockPersistentPty = {
337340
capability: input.config?.capability,
338341
renameSupported: input.config?.renameSupported ?? true,
342+
listFails: false,
339343
terminals: [],
340344
created: [],
341345
removed: [],

‎packages/gui-plugins/src/terminal/i18n/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export default {
55
"command.new.description": "Create a new terminal tab",
66
"location.open": "Open in terminal",
77
loading: "Loading terminal…",
8+
"list.failed": "Couldn't load terminals",
9+
unavailable: "Terminals aren't available on this server",
810
"title.numbered": "Terminal {{number}}",
911
close: "Close terminal",
1012
"create.failed": "Couldn't create a terminal",
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { PersistentPtyInfo } from "@opencode/client"
3+
import {
4+
initialState,
5+
serverAvailable,
6+
sessionView,
7+
tabHidden,
8+
transition,
9+
viewTabs,
10+
type Event,
11+
type State,
12+
type Transition,
13+
} from "./machine"
14+
15+
const info = (id: string, title = id): PersistentPtyInfo => ({
16+
id,
17+
title,
18+
userTitle: null,
19+
command: "/bin/zsh",
20+
args: [],
21+
cwd: "/repo",
22+
status: "running",
23+
pid: 1,
24+
sessionID: "ses_1",
25+
foregroundProcess: null,
26+
size: { cols: 80, rows: 24 },
27+
output: { head: 0, tail: 0 },
28+
})
29+
30+
// Folds the events in order and returns the last transition.
31+
const replay = (events: readonly Event[], state: State = initialState) =>
32+
events.reduce<Transition>((last, event) => transition(last.state, event), { state, commands: [] })
33+
34+
// A supported server whose ses_1 listed pty_1 with attempt 2.
35+
const listed = () =>
36+
replay([
37+
{ type: "server.connected" },
38+
{ type: "session.loaded", sessionID: "ses_1" },
39+
{ type: "capability.checked", attempt: 1, supported: true },
40+
{ type: "list.loaded", sessionID: "ses_1", attempt: 2, items: [info("pty_1")] },
41+
]).state
42+
43+
describe("terminal machine", () => {
44+
test("a list that fails with an error other than 503 shows its error and its tabs, and retries on request", () => {
45+
const failed = replay([
46+
{ type: "session.loaded", sessionID: "ses_1" },
47+
{ type: "capability.checked", attempt: 1, supported: true },
48+
{ type: "list.failed", sessionID: "ses_1", attempt: 2, error: "boom" },
49+
])
50+
51+
const view = sessionView(failed.state, "ses_1")
52+
53+
expect(view).toEqual({ type: "failed", error: "boom" })
54+
expect(tabHidden(view)).toBe(false)
55+
expect(viewTabs(view, ["pty_restored"])).toEqual(["pty_restored"])
56+
expect(serverAvailable(failed.state)).toBe(true)
57+
58+
const retried = transition(failed.state, { type: "session.retry", sessionID: "ses_1" })
59+
60+
expect(sessionView(retried.state, "ses_1")).toEqual({ type: "loading" })
61+
expect(retried.commands).toEqual([{ type: "load-list", sessionID: "ses_1", attempt: 3 }])
62+
63+
const loaded = transition(retried.state, {
64+
type: "list.loaded",
65+
sessionID: "ses_1",
66+
attempt: 3,
67+
items: [info("pty_1")],
68+
})
69+
70+
expect(sessionView(loaded.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1")] })
71+
expect(loaded.commands).toEqual([{ type: "sync-tabs", sessionID: "ses_1", items: [info("pty_1")] }])
72+
})
73+
74+
test("loading a session again changes nothing, so reactive reads never retry a failed list", () => {
75+
const loading = replay([
76+
{ type: "session.loaded", sessionID: "ses_1" },
77+
{ type: "capability.checked", attempt: 1, supported: true },
78+
]).state
79+
80+
const failed = transition(loading, { type: "list.failed", sessionID: "ses_1", attempt: 2, error: "boom" }).state
81+
82+
const states = [loading, failed, listed()]
83+
84+
states.forEach((state) => {
85+
const again = transition(state, { type: "session.loaded", sessionID: "ses_1" })
86+
87+
expect(again.state).toBe(state)
88+
expect(again.commands).toEqual([])
89+
})
90+
91+
// Retrying anything but a failed list repeats nothing.
92+
expect(transition(loading, { type: "session.retry", sessionID: "ses_1" }).state).toBe(loading)
93+
})
94+
95+
test("a support answer from before a reconnect is ignored", () => {
96+
const rechecked = replay([
97+
{ type: "server.connected" },
98+
{ type: "session.loaded", sessionID: "ses_1" },
99+
{ type: "server.connected" },
100+
])
101+
102+
expect(rechecked.commands).toEqual([{ type: "check-capability", attempt: 2 }])
103+
104+
const stale = replay(
105+
[
106+
{ type: "capability.checked", attempt: 1, supported: false },
107+
{ type: "capability.failed", attempt: 1, error: "offline" },
108+
],
109+
rechecked.state,
110+
)
111+
112+
expect(stale.state).toBe(rechecked.state)
113+
114+
const supported = transition(rechecked.state, { type: "capability.checked", attempt: 2, supported: true })
115+
116+
expect(supported.state.status).toBe("supported")
117+
expect(supported.commands).toEqual([{ type: "load-list", sessionID: "ses_1", attempt: 3 }])
118+
})
119+
120+
test("only the latest list request settles a session", () => {
121+
const refreshing = transition(listed(), { type: "session.refresh", sessionID: "ses_1" })
122+
123+
expect(refreshing.commands).toEqual([{ type: "load-list", sessionID: "ses_1", attempt: 3 }])
124+
125+
const newer = transition(refreshing.state, { type: "session.refresh", sessionID: "ses_1" })
126+
127+
const stale = replay(
128+
[
129+
{ type: "list.loaded", sessionID: "ses_1", attempt: 3, items: [] },
130+
{ type: "list.failed", sessionID: "ses_1", attempt: 3, error: "old" },
131+
{ type: "list.unavailable", sessionID: "ses_1", attempt: 3 },
132+
],
133+
newer.state,
134+
)
135+
136+
expect(stale.state).toBe(newer.state)
137+
expect(sessionView(newer.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1")] })
138+
139+
const settled = transition(newer.state, {
140+
type: "list.loaded",
141+
sessionID: "ses_1",
142+
attempt: 4,
143+
items: [info("pty_1"), info("pty_2")],
144+
})
145+
146+
expect(sessionView(settled.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1"), info("pty_2")] })
147+
})
148+
149+
test("a 503 for the latest request marks the server unavailable until a reconnect finds support", () => {
150+
const loading = transition(listed(), { type: "session.refresh", sessionID: "ses_1" }).state
151+
const unavailable = transition(loading, { type: "list.unavailable", sessionID: "ses_1", attempt: 3 }).state
152+
const view = sessionView(unavailable, "ses_1")
153+
154+
expect(serverAvailable(unavailable)).toBe(false)
155+
expect(view).toEqual({ type: "unsupported" })
156+
expect(tabHidden(view)).toBe(true)
157+
expect(viewTabs(view, ["pty_1"])).toEqual(["pty_1"])
158+
159+
const reconnected = transition(unavailable, { type: "server.connected" })
160+
161+
expect(reconnected.commands).toEqual([{ type: "check-capability", attempt: 4 }])
162+
163+
const supported = transition(reconnected.state, { type: "capability.checked", attempt: 4, supported: true })
164+
165+
expect(supported.commands).toEqual([{ type: "load-list", sessionID: "ses_1", attempt: 5 }])
166+
})
167+
168+
test("a failed refresh keeps the terminals already shown", () => {
169+
const refreshing = transition(listed(), { type: "session.refresh", sessionID: "ses_1" }).state
170+
171+
expect(sessionView(refreshing, "ses_1")).toEqual({ type: "ready", items: [info("pty_1")] })
172+
173+
const failed = transition(refreshing, { type: "list.failed", sessionID: "ses_1", attempt: 3, error: "boom" })
174+
175+
expect(sessionView(failed.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1")] })
176+
})
177+
178+
test("a reconnect keeps listed terminals shown, drops answers from before it, and lists every session again", () => {
179+
const supported = transition(listed(), { type: "session.loaded", sessionID: "ses_2" })
180+
181+
expect(supported.commands).toEqual([{ type: "load-list", sessionID: "ses_2", attempt: 3 }])
182+
183+
const rechecking = transition(supported.state, { type: "server.connected" })
184+
185+
expect(rechecking.commands).toEqual([{ type: "check-capability", attempt: 4 }])
186+
expect(sessionView(rechecking.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1")] })
187+
expect(sessionView(rechecking.state, "ses_2")).toEqual({ type: "loading" })
188+
189+
const late = transition(rechecking.state, { type: "list.unavailable", sessionID: "ses_2", attempt: 3 })
190+
191+
expect(late.state).toBe(rechecking.state)
192+
193+
const relisted = transition(rechecking.state, { type: "capability.checked", attempt: 4, supported: true })
194+
195+
expect(relisted.commands).toEqual([
196+
{ type: "load-list", sessionID: "ses_1", attempt: 5 },
197+
{ type: "load-list", sessionID: "ses_2", attempt: 6 },
198+
])
199+
expect(sessionView(relisted.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1")] })
200+
})
201+
202+
test("a terminal update replaces a listed terminal and lists again for an unknown one", () => {
203+
const state = listed()
204+
205+
const renamed = transition(state, {
206+
type: "terminal.updated",
207+
sessionID: "ses_1",
208+
terminal: info("pty_1", "build"),
209+
})
210+
211+
expect(renamed.commands).toEqual([])
212+
expect(sessionView(renamed.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1", "build")] })
213+
214+
const unknown = transition(state, { type: "terminal.updated", sessionID: "ses_1", terminal: info("pty_9") })
215+
216+
expect(unknown.commands).toEqual([{ type: "load-list", sessionID: "ses_1", attempt: 3 }])
217+
218+
// A local change to a terminal the list doesn't have, or an update for a session never loaded, changes nothing.
219+
expect(transition(state, { type: "terminal.replaced", sessionID: "ses_1", terminal: info("pty_9") }).state).toBe(
220+
state,
221+
)
222+
expect(transition(state, { type: "terminal.updated", sessionID: "ses_9", terminal: info("pty_9") }).state).toBe(
223+
state,
224+
)
225+
})
226+
227+
test("answers for a removed session are ignored", () => {
228+
const loading = replay([
229+
{ type: "session.loaded", sessionID: "ses_1" },
230+
{ type: "capability.checked", attempt: 1, supported: true },
231+
{ type: "sessions.removed", sessionIDs: ["ses_1"] },
232+
]).state
233+
234+
const late = replay(
235+
[
236+
{ type: "list.loaded", sessionID: "ses_1", attempt: 2, items: [info("pty_1")] },
237+
{ type: "list.unavailable", sessionID: "ses_1", attempt: 2 },
238+
{ type: "list.failed", sessionID: "ses_1", attempt: 2, error: "boom" },
239+
],
240+
loading,
241+
)
242+
243+
expect(late.state).toBe(loading)
244+
245+
// Loading it again takes a new attempt rather than reusing the abandoned one.
246+
expect(transition(loading, { type: "session.loaded", sessionID: "ses_1" }).commands).toEqual([
247+
{ type: "load-list", sessionID: "ses_1", attempt: 3 },
248+
])
249+
})
250+
251+
test("a failed support check shows its error with a retry that checks again", () => {
252+
const failed = replay([
253+
{ type: "server.connected" },
254+
{ type: "session.loaded", sessionID: "ses_1" },
255+
{ type: "capability.failed", attempt: 1, error: "offline" },
256+
]).state
257+
258+
const view = sessionView(failed, "ses_1")
259+
260+
expect(view).toEqual({ type: "failed", error: "offline" })
261+
expect(tabHidden(view)).toBe(false)
262+
expect(serverAvailable(failed)).toBe(false)
263+
264+
const retried = transition(failed, { type: "session.retry", sessionID: "ses_1" })
265+
266+
expect(retried.commands).toEqual([{ type: "check-capability", attempt: 2 }])
267+
expect(sessionView(retried.state, "ses_1")).toEqual({ type: "loading" })
268+
})
269+
})

0 commit comments

Comments
 (0)