Status: Accepted Date: 2026-04-13
Context: TerminalSession needs to work with both local PTY and Docker exec backends. The backend must be swappable at runtime (a session is either local or Docker, decided at creation time). The trait needs async read for non-blocking I/O.
Decision: Define SessionBackend as a regular trait (not async_trait) with the read method returning Pin<Box<dyn Future<Output = Result<usize, io::Error>> + Send + 'a>>. Store backends as Box<dyn SessionBackend>.
pub trait SessionBackend: Send + Sync {
fn read<'a>(&'a self, buf: &'a mut [u8])
-> Pin<Box<dyn Future<Output = Result<usize, io::Error>> + Send + 'a>>;
fn write_stdin(&self, data: &[u8]) -> Result<usize, io::Error>;
fn resize(&self, cols: u16, rows: u16) -> Result<(), io::Error>;
fn is_alive(&self) -> bool;
fn kill(&self) -> Result<(), io::Error>;
}Alternatives considered:
async_traitcrate — adds the same boxing under the hood but hides it. ExplicitPin<Box>makes the cost visible and avoids a macro dependency.- Generics (
TerminalSession<B: SessionBackend>) — avoids boxing but makesTerminalSessiongeneric, complicating storage in collections (e.g.,DashMap<String, Arc<TerminalSession>>) and requiring monomorphization. - Enum dispatch —
enum Backend { Local(LocalPty), Docker(DockerExec) }avoids trait objects but requires updating the enum for every new backend. Less extensible.
Consequences:
- Dynamic dispatch via
Box<dyn SessionBackend>— one allocation per session, negligible readboxing cost: one heap allocation per PTY read (~8 KiB chunks), not per bytewrite_stdin,resize,is_alive,killare synchronous — no boxing neededSend + Syncbounds enableArc<TerminalSession>sharing across tokio tasks- Adding a new backend (e.g., SSH, Kubernetes exec) requires only implementing the trait