fix(app): keep live terminals through transient connect failures (#52232)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-07 10:04:03 +00:00
committed by GitHub
Unverified
parent 2e3d3a735a
commit 10b11ba946
4 changed files with 58 additions and 7 deletions
@@ -106,6 +106,37 @@ for (const mount of ["cached", "explicit"] as const) {
})
}
test("reconnects the same terminal after a transient connection failure", async ({ page }) => {
const { pty } = await openSession(page, workspace)
let tickets = 0
await page.route(/\/api\/pty\/[^/]+\/connect-token/, (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
tickets += 1
if (tickets !== 2) return route.fallback()
return route.fulfill({
status: 503,
headers: { "access-control-allow-origin": "*" },
body: "Service Unavailable",
})
})
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal.locator("textarea")).toHaveCount(1)
await expect.poll(() => pty.sockets.length).toBe(1)
const id = pty.sockets[0]!.id
await terminal.evaluate((element) => element.setAttribute("data-connection-probe", "original"))
await pty.sockets[0]!.close(1011, "Temporary disconnection")
await expect.poll(() => tickets).toBe(3)
await expect.poll(() => pty.sockets.length).toBe(2)
expect(pty.sockets[1]!.id).toBe(id)
expect(pty.rejected).toEqual([])
await expect(terminal).toHaveAttribute("data-connection-probe", "original")
expect(pty.created.map((item) => item.id)).toEqual([id])
})
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const { editor } = await openSession(page, workspace)
const terminal = page.locator('[data-component="terminal"]')
+12 -4
View File
@@ -63,6 +63,12 @@ export class MockShellNotFound extends Schema.TaggedError<MockShellNotFound>()("
message: Schema.String,
}) {}
// The server's error for an unknown PTY, or one owned by another workspace.
export class MockPtyNotFound extends Schema.TaggedError<MockPtyNotFound>()("PtyNotFoundError", {
ptyID: Schema.String,
message: Schema.String,
}) {}
// The server's error for a request without its password.
export class MockUnauthorized extends Schema.TaggedError<MockUnauthorized>()("UnauthorizedError", {
message: Schema.String,
@@ -75,6 +81,8 @@ export class MockUnsupported extends Schema.TaggedError<MockUnsupported>()("Mock
const Unsupported = MockUnsupported.pipe(HttpApiSchema.status(501))
const PtyMissing = [MockNotFound.pipe(HttpApiSchema.status(404)), MockPtyNotFound.pipe(HttpApiSchema.status(404))]
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
.add(
@@ -242,7 +250,7 @@ const Group = HttpApiGroup.make("mock")
HttpApiEndpoint.get("ptyGet", "/api/pty/:ptyID", {
params: PtyParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
@@ -250,21 +258,21 @@ const Group = HttpApiGroup.make("mock")
params: PtyParams,
payload: Pty.UpdateInput,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
HttpApiEndpoint.delete("ptyRemove", "/api/pty/:ptyID", {
params: PtyParams,
success: NoContent,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
HttpApiEndpoint.post("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
params: PtyParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
+13 -2
View File
@@ -11,6 +11,7 @@ import {
MockBadRequest,
MockInternal,
MockNotFound,
MockPtyNotFound,
MockShellNotFound,
MockUnauthorized,
MockUnsupported,
@@ -149,7 +150,14 @@ export type MockPtyInfo = {
pid: number
}
export type MockPtySocket = { id: string; url: URL; input: string[]; closed: boolean; send(data: string): void }
export type MockPtySocket = {
id: string
url: URL
input: string[]
closed: boolean
send(data: string): void
close(code: number, reason: string): Promise<void>
}
export type MockPty = {
list: MockPtyInfo[]
@@ -447,6 +455,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
input: [],
closed: false,
send: (data) => ws.send(data),
close: (code, reason) => ws.close({ code, reason }),
}
ws.onMessage((message) => socket.input.push(message.toString()))
@@ -697,7 +706,9 @@ function mockHandlers(
const directory = requestDirectory(config, request)
const found = state.pty.find(id, directory)
return found ? Effect.succeed(found) : Effect.fail(new MockNotFound({ message: "PTY not found" }))
return found
? Effect.succeed(found)
: Effect.fail(new MockPtyNotFound({ ptyID: id, message: `PTY not found: ${id}` }))
}),
),
)
@@ -648,7 +648,8 @@ export const Terminal = (props: TerminalProps) => {
cursor: seek,
})
.catch((err) => {
fail(err)
// A failed connection does not mean the PTY exited; retry checks its status first.
retry(err)
return undefined
})