fix(app): terminal issues (#14435)

This commit is contained in:
Adam
2026-02-20 07:34:36 -06:00
committed by GitHub
parent 7e0e35af3f
commit 4e9ef3ecc1
5 changed files with 91 additions and 24 deletions

View File

@@ -6,6 +6,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await gotoSession() await gotoSession()
const terminals = page.locator(terminalSelector) const terminals = page.locator(terminalSelector)
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const opened = await terminals.first().isVisible() const opened = await terminals.first().isVisible()
if (!opened) { if (!opened) {
@@ -21,6 +22,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
await page.keyboard.press("Control+Alt+T") await page.keyboard.press("Control+Alt+T")
await expect(terminals).toHaveCount(2) await expect(tabs).toHaveCount(2)
await expect(terminals.nth(1).locator("textarea")).toHaveCount(1) await expect(terminals).toHaveCount(1)
await expect(terminals.first().locator("textarea")).toHaveCount(1)
}) })

View File

@@ -540,7 +540,7 @@ export const Terminal = (props: TerminalProps) => {
disposed = true disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer) if (sizeTimer !== undefined) clearTimeout(sizeTimer)
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close() if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
const finalize = () => { const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })

View File

@@ -67,11 +67,11 @@ export function TerminalPanel() {
on( on(
() => terminal.active(), () => terminal.active(),
(activeId) => { (activeId) => {
if (!activeId || !opened()) return if (!activeId || !open()) return
if (document.activeElement instanceof HTMLElement) { if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur() document.activeElement.blur()
} }
focusTerminalById(activeId) setTimeout(() => focusTerminalById(activeId), 0)
}, },
), ),
) )
@@ -209,21 +209,17 @@ export function TerminalPanel() {
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
<div class="flex-1 min-h-0 relative"> <div class="flex-1 min-h-0 relative">
<For each={all()}> <Show when={terminal.active()} keyed>
{(pty) => ( {(id) => (
<div <Show when={byId().get(id)}>
id={`terminal-wrapper-${pty.id}`} {(pty) => (
class="absolute inset-0" <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
style={{ <Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
display: terminal.active() === pty.id ? "block" : "none", </div>
}} )}
> </Show>
<Show when={pty.id} keyed>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Show>
</div>
)} )}
</For> </Show>
</div> </div>
</div> </div>
<DragOverlay> <DragOverlay>

View File

@@ -41,13 +41,38 @@ export namespace Pty {
const token = (ws: Socket) => { const token = (ws: Socket) => {
const data = ws.data const data = ws.data
if (!data || typeof data !== "object") return if (data === undefined) return
if (data === null) return
if (typeof data !== "object") return data
const events = (data as { events?: unknown }).events const id = (data as { connId?: unknown }).connId
if (events && typeof events === "object") return events if (typeof id === "number" || typeof id === "string") return id
const href = (data as { href?: unknown }).href
if (typeof href === "string") return href
const url = (data as { url?: unknown }).url const url = (data as { url?: unknown }).url
if (url && typeof url === "object") return url if (typeof url === "string") return url
if (url && typeof url === "object") {
const href = (url as { href?: unknown }).href
if (typeof href === "string") return href
return url
}
const events = (data as { events?: unknown }).events
if (typeof events === "number" || typeof events === "string") return events
if (events && typeof events === "object") {
const id = (events as { connId?: unknown }).connId
if (typeof id === "number" || typeof id === "string") return id
const id2 = (events as { connection?: unknown }).connection
if (typeof id2 === "number" || typeof id2 === "string") return id2
const id3 = (events as { id?: unknown }).id
if (typeof id3 === "number" || typeof id3 === "string") return id3
return events
}
return data return data
} }
@@ -210,7 +235,7 @@ export namespace Pty {
continue continue
} }
if (sub.token !== undefined && token(ws) !== sub.token) { if (token(ws) !== sub.token) {
session.subscribers.delete(ws) session.subscribers.delete(ws)
continue continue
} }

View File

@@ -97,4 +97,48 @@ describe("pty", () => {
}, },
}) })
}) })
test("does not leak output when socket data mutates in-place", async () => {
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
try {
const outA: string[] = []
const outB: string[] = []
const ctx = { connId: 1 }
const ws = {
readyState: 1,
data: ctx,
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op
},
}
Pty.connect(a.id, ws as any)
outA.length = 0
// Simulate the runtime mutating per-connection data without
// swapping the reference (ws.data stays the same object).
ctx.connId = 2
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
await Pty.remove(a.id)
}
},
})
})
}) })