mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
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:
committed by
GitHub
Unverified
parent
2e3d3a735a
commit
10b11ba946
@@ -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"]')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user