Lifecycle and cleanup
The code that creates a resource owns its release until it transfers that ownership. OpenTUI does not supply one universal asynchronous disposer.
Keep cleanup with creation#
Call renderer.destroy() on normal shutdown and on application failure:
import { TextRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
try {
renderer.root.add(new TextRenderable(renderer, { content: "Ready" }))
await renderer.idle()
} finally {
renderer.destroy()
}createCliRenderer() destroys a constructed renderer if asynchronous terminal setup fails. Prefer this factory to direct new CliRenderer(...) construction. After the factory resolves, your code owns the renderer.
If construction itself throws, the caller receives no renderer handle. Internal stages clean known native-feed, native-renderer, and input-setup failures. No public rollback handle exists for an arbitrary constructor failure, so validate configuration before creation.
Track partial initialization explicitly when one resource adopts another:
import {
TextRenderable,
createCliRenderer,
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
type ClipboardService,
type HostClipboardService,
} from "@opentui/core"
const renderer = await createCliRenderer()
let host: HostClipboardService | undefined
let clipboard: ClipboardService | undefined
try {
host = createHostClipboard()
clipboard = createClipboard({
host,
terminal: createRendererClipboardAdapter(renderer),
})
host = undefined // createClipboard() now owns the host service
renderer.root.add(new TextRenderable(renderer, { content: "Ready" }))
await renderer.idle()
} finally {
try {
if (clipboard) await clipboard.dispose()
else if (host) await host.dispose()
} finally {
renderer.destroy()
}
}If host creation fails, no host object exists to release. If later setup fails, the finally block releases the last acquired owner. The nested finally also destroys the renderer when clipboard disposal rejects.
Destroy the renderer#
renderer.destroy() is synchronous and idempotent. It marks the renderer as destroyed and stops input and scheduling.
Output flushing, terminal restoration, and native reclamation can finish asynchronously.
Before you reuse streams or deliberately exit after graceful shutdown, await closed:
renderer.destroy()
await renderer.closedAn active frame or buffer lease can delay reclamation. A failed output endpoint rejects closed. Destruction alone
does not prove that restoration bytes reached the terminal.
Renderer destruction performs these actions:
- Removes renderer signal, process, resize, input, and warning listeners.
- Stops renderer timers, frame scheduling, capability detection, and memory snapshots.
- Disables raw input, removes the data listener, pauses the input stream, and clears parser state.
- Starts ordered split-footer flushing and terminal restoration, and restores the configured
stdout.writemethod. - Emits the renderer
destroyevent. - Releases the renderable tree, console overlay, native resources, and stream ownership as cleanup completes.
- Calls
onDestroyafter host cleanup. Useclosedto wait for output and native teardown. OpenTUI logs callback errors.
The native shutdown sequence resets text attributes and the mouse pointer. It disables mouse, focus, paste, keyboard, and color-scheme modes that OpenTUI enabled. It exits the alternate screen, resets the terminal title, cursor color, and cursor style, and shows the cursor. OpenTUI also sets stdin raw mode to false.
clearOnShutdown: false keeps the renderer-owned main-screen region visible. It does not skip terminal mode restoration.
Cleanup is best effort. OpenTUI logs most renderer cleanup errors, so later cleanup can continue. destroy() does not dispose application services, close a caller-owned transport, or await asynchronous resource release.
During actual process exit, native rendering attempts synchronous restoration on real process.stdout before
termination-capable cleanup callbacks. It cannot replay an uncompleted write or finish an arbitrary asynchronous
transport after the event loop stops. This last-chance path does not replace awaiting closed during normal shutdown.
Signal handling#
By default, each renderer installs handlers that call renderer.destroy() for these signals:
| Signal | Description |
|---|---|
SIGINT |
Ctrl+C |
SIGTERM |
Termination signal |
SIGQUIT |
Ctrl+\ |
SIGABRT |
Abort signal |
SIGHUP |
Hangup (terminal closed) |
SIGBREAK |
Ctrl+Break on Windows |
SIGPIPE |
Broken pipe |
SIGBUS |
Bus error |
Set exitSignals to replace this list. Use an empty list when the application or a server owns process shutdown:
const renderer = await createCliRenderer({
exitSignals: ["SIGINT", "SIGTERM"],
})
const managedRenderer = await createCliRenderer({
exitOnCtrlC: false,
exitSignals: [],
})The built-in signal handlers call renderer.destroy(). With OTUI_DUMP_CAPTURES=true, they also schedule a capture dump. They cannot await an application-owned clipboard service, SSH server, or other asynchronous cleanup. Disable them and install application handlers when shutdown must await those resources.
Ctrl+C also has a separate parsed-key path. Set exitOnCtrlC: false and remove SIGINT from exitSignals when your application handles both paths itself.
SIGKILL cannot be caught or handled. OpenTUI cannot restore terminal state or run service cleanup after it. A direct process.exit() can also bypass application cleanup.
The renderer’s uncaughtException and unhandledRejection listeners report errors and can open the console overlay. They do not destroy the renderer. Keep failure handling in a try and finally, or install an application-level handler that starts the same shutdown path.
Framework roots#
React#
createRoot(renderer) adopts a renderer but does not own it. root.unmount() removes the React tree and runs effect cleanup while the renderer remains usable. It does not call renderer.destroy().
renderer.destroy() emits the event that unmounts every React root created for that renderer. You can call root.unmount() first when React effects must stop before other application resources:
const renderer = await createCliRenderer()
const root = createRoot(renderer)
try {
root.render(<App />)
await waitUntilShutdown()
} finally {
try {
root.unmount()
} finally {
renderer.destroy()
}
}Both operations are safe when renderer destruction already unmounted the root. See React bindings.
Solid#
The high-level Solid render(node, rendererOrConfig?) function resolves after mounting. It returns no root handle and no disposer. Renderer destruction disposes the Solid root and runs onCleanup callbacks.
Create the renderer yourself when startup and failure cleanup need one visible owner:
const renderer = await createCliRenderer()
try {
await render(() => <App />, renderer)
await waitUntilShutdown()
} finally {
renderer.destroy()
}If Solid creates the renderer from a config object, application code can get it through useRenderer() and destroy it during shutdown. See Solid bindings.
Services and registrations#
Clipboard#
createHostClipboard() owns its native backend. createClipboard({ host, terminal }) adopts the host service and calls host.dispose() from its own dispose() method. The renderer adapter has no disposer.
Clipboard disposal is asynchronous. It rejects new operations, aborts active operations, waits for them to settle, and then releases native workers and providers. Await it before renderer and process shutdown. A renderer does not dispose a clipboard service that your application created. See Clipboard.
Audio#
Audio owns streams, capture streams, and recorders created through that instance. audio.dispose() attempts to dispose those children, stop capture and playback, and destroy the native engine. See Audio.
Audio.dispose() is synchronous and can throw the first cleanup error after it attempts the remaining cleanup. If a child or native engine cannot be destroyed, the engine stays attached so a later audio.dispose() call can retry. Put later cleanup in a finally block around this call.
Tree-sitter#
Await client.destroy() for every new TreeSitterClient(...) that your code creates. Destruction rejects pending requests, clears buffers and debounced work, and waits for worker termination. A failed worker termination keeps the worker so another destroy() call can retry.
getTreeSitterClient() returns the process singleton. Release that singleton with await destroyTreeSitterClient(). Destruction of the last tracked renderer starts singleton cleanup, but it does not await that promise. Call destroyTreeSitterClient() before renderer.destroy() when shutdown must wait for the worker. See Tree-sitter.
Keymap and plugin slots#
Keymap layer, token, parser, resolver, intercept, listener, and addon registrations return disposer functions. Keep each disposer for a registration that can outlive its component or feature. Custom addons should return one disposer that releases their registrations in reverse dependency order.
The OpenTUI Keymap host observes renderer destruction and cleans its layers, listeners, and shared addon resources. Other hosts must publish their own destroy lifecycle. See Keymap and Custom keymap addons.
registry.register(plugin) returns an unregister function. Unregistration removes the plugin and calls its optional dispose() hook. A renderer-scoped registry created while its renderer is live clears all plugins during renderer destruction. Do not create one after destruction. Dispose errors become plugin error events and do not keep the plugin registered. See Plugin slots.
Runtime module support has process lifetime. Installing the Bun runtime plugin returns no unload function. Unregister a loaded module’s slot contribution and dispose its application resources, but do not expect the module or Bun plugin installation to unload. See Load plugins and modules at runtime.
Output streams and feeds#
For a socket, pseudo-terminal, or custom stream session, give each active connection its own renderer. The renderer owns exclusive leases on the input and output stream objects. It does not own the underlying transport.
For custom output, renderer destruction writes the remaining frame output and the terminal restoration bytes to your Writable in order, and restores stdout.write. The renderer does not use NativeSpanFeed for this output. await renderer.closed waits until the Writable calls back for those writes. If that takes longer than one second, the Session cancels the output without restoration and closed rejects. A Writable callback does not prove that the underlying transport sent the bytes. Keep the transport open until its own completion API reports that pending writes finished. See Renderer custom streams.
After NativeSpanFeed.create() or NativeSpanFeed.attach() succeeds, the wrapper owns the native feed. Call the unsubscribe functions from onData() and onError(), then call feed.close(). close() is idempotent and defers final destruction while callbacks or asynchronous handlers still hold borrowed chunks.
If native close cannot finish, feed.close() leaves the feed alive and returns without throwing. This can happen while a native reservation is active. Complete or cancel that reservation, then call close() again.
SSH sessions#
@opentui/ssh owns each renderer that it creates. A disconnect or session.end() destroys the session renderer before session.onClose callbacks run. Use onClose for application timers, listeners, and other session resources. Do not add another renderer owner.
Await server.close() during process shutdown. It stops acceptance, destroys live session renderers, performs bounded
transport teardown, and closes the listener. It does not await application promises returned from onClose or
middleware cleanup. Await those owners separately. See
SSH cleanup and shutdown.
Custom renderables#
Use onRemove() for detachment from a parent. A renderable can be removed and later attached again, so removal is not final destruction.
Override destroySelf() for final resource release. destroy() calls it once after detachment, child removal, listener cleanup, and frame-buffer cleanup. After destroySelf() returns, destroy() releases the node’s native scene node. destroyRecursively() destroys descendants first. Call super.destroySelf() when a base class owns resources. See Custom renderables and Renderables.
Next#
- Renderer covers creation, scheduling, and custom streams.
- Console overlay covers global console capture and restoration.
- NativeSpanFeed covers borrowed output memory and feed closure.