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:
@@ -99,6 +99,12 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
|
||||
window.clearTimeout(retry)
|
||||
observer.disconnect()
|
||||
inputSub.dispose()
|
||||
// Tell the host the owning tab is gone so it releases the terminal
|
||||
// quota immediately (best effort; a dropped socket gets the grace
|
||||
// period on the host side instead).
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'close' }))
|
||||
}
|
||||
socket?.close()
|
||||
term.dispose()
|
||||
connectRef.current = null
|
||||
|
||||
+44
-5
@@ -1,17 +1,56 @@
|
||||
/**
|
||||
* Client half of dsh-better-sidebar: mounts the right sidebar portal and
|
||||
* registers the turn-tail interception. Requires the runtime's slots and
|
||||
* sessions services; the bundle itself is a module-table consumer only
|
||||
* (react + ui-primitives + xterm, all provided or inlined).
|
||||
* Client half of dsh-better-sidebar: mounts the right sidebar portal (inside
|
||||
* an error boundary so a rendering failure shows an error strip instead of a
|
||||
* blank panel) and registers the turn-tail interception. Requires the
|
||||
* runtime's slots and sessions services; the bundle itself is a module-table
|
||||
* consumer only (react + ui-primitives + xterm, all provided or inlined).
|
||||
*/
|
||||
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Services required before mounting (provided by the client runtime). */
|
||||
export const inject = ['slots', 'sessions']
|
||||
|
||||
/**
|
||||
* Error boundary over the sidebar tree: a render error must never blank the
|
||||
* whole panel silently — it shows a dismissible error strip and logs the
|
||||
* stack for diagnosis.
|
||||
*/
|
||||
class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
|
||||
state = { error: null as string | null }
|
||||
|
||||
static getDerivedStateFromError(error: unknown): { error: string } {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<div className={css.boundaryError}>
|
||||
<span>dsh-better-sidebar: {this.state.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { this.setState({ error: null }) }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body.
|
||||
* @param ctx - the client cordis context (slots, sessions).
|
||||
@@ -22,7 +61,7 @@ export function apply(ctx: Context): void {
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
root.render(<Sidebar ctx={ctx} />)
|
||||
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx })))
|
||||
return () => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
|
||||
@@ -609,6 +609,24 @@
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.boundaryError {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.terminalRetry {
|
||||
flex: none;
|
||||
padding: 1px 8px;
|
||||
|
||||
Reference in New Issue
Block a user