fix: terminal quota leak and dead-terminal reuse; error boundary against blank panel
- Terminals were never destroyed when their tab closed (socket drop only
detached listeners), so the per-session quota (3) filled up and new
terminals failed to mount with 'terminal limit reached'. Now the client
sends a {type:'close'} control frame on tab teardown and the host releases
the pty immediately; a bare socket drop (refresh/switch) keeps the process
for a 30s grace period, and a reconnect within it cancels the pending
close. open() also respawns a process whose shell already exited instead
of reusing a dead handle (input sink).
- The sidebar portal renders inside an error boundary: a render failure now
shows a dismissible error strip with the message (console.error with the
component stack) instead of a silent blank panel.
- Tests: +2 real-pty smoke specs (close releases quota + respawn after
exit; reconnect cancels pending close).
This commit is contained in:
+13
-2
@@ -36,6 +36,8 @@ const MEDIA_LIMIT = 20 * 1024 * 1024
|
||||
const LIST_LIMIT = 1000
|
||||
/** Terminals per session. */
|
||||
const TERMINALS_PER_SESSION = 3
|
||||
/** How long a disconnected terminal process survives awaiting a reconnect. */
|
||||
const TERMINAL_RECONNECT_GRACE_MS = 30_000
|
||||
|
||||
/** Content types for the media route, by extension. */
|
||||
const MEDIA_TYPES: Record<string, string> = {
|
||||
@@ -325,7 +327,6 @@ async function attachTerminal(
|
||||
const exitSub = handle.pty.onExit(onExit)
|
||||
ws.on('message', (data) => {
|
||||
const text = data.toString('utf8')
|
||||
if (handle.exited) return
|
||||
// Control frames are JSON with a known shape; anything else (including
|
||||
// JSON that is not a recognized control) is terminal input, verbatim.
|
||||
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
|
||||
@@ -337,8 +338,14 @@ async function attachTerminal(
|
||||
} catch {
|
||||
// Not JSON: terminal input.
|
||||
}
|
||||
if (control !== null && control.type === 'close') {
|
||||
// The owning tab was closed: release the quota immediately.
|
||||
ptyManager.scheduleClose(handle.key, 0)
|
||||
return
|
||||
}
|
||||
if (handle.exited) return
|
||||
if (
|
||||
control !== null && typeof control === 'object'
|
||||
control !== null
|
||||
&& control.type === 'resize'
|
||||
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
||||
) {
|
||||
@@ -350,6 +357,10 @@ async function attachTerminal(
|
||||
ws.on('close', () => {
|
||||
dataSub.dispose()
|
||||
exitSub.dispose()
|
||||
// A bare socket drop (refresh, tab switch) leaves the process alive
|
||||
// for a grace period so a quick reconnect keeps it; the reconnect's
|
||||
// open() cancels the pending close.
|
||||
ptyManager.scheduleClose(handle.key, TERMINAL_RECONNECT_GRACE_MS)
|
||||
})
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
|
||||
Reference in New Issue
Block a user