Documentation
¶
Overview ¶
Package session manages persistent PTY shell sessions that survive SSH disconnects.
Two diagrams — one per direction — because they are almost independent paths.
Input: user keystrokes → shell stdin
┌──────┐ SSH ┌────────┐ WriteInput() ┌────────┐ ┌────────┐ │ user │ channel │ Client │ ──(writer only)─► │ PTY │ ──► │ shell │ │ term │ ─────────► │ │ → s.ptmx.Write │ master │ │ stdin │ └──────┘ └────────┘ └────────┘ └────────┘
Only the Client currently marked as writer gets to push bytes into the PTY. Non-writer clients are read-only while they remain non-writers. If the writer detaches, the most recently attached remaining client is promoted.
Output: shell stdout → user terminal (+ VTE side channel)
┌────────┐ ┌────────┐ ┌──────────────┐ deliver() ┌─────────┐ SSH ┌──────┐
│ shell │ ─► │ PTY │ ──► │ pump() │ ─── fan-out ───► │ Client₁ │ ─────► │ user │
│ stdout │ │ master │ │ ptmx.Read() │ (N clients) │ Client₂ │ chan │ term │
└────────┘ └────────┘ └──────┬───────┘ │ ... │ └──────┘
│ └─────────┘
│ under s.mu
▼
┌─────────────┐ response pipe ┌──────────┐
│ vte.Write() │ ────────────────► │ drainVTE │ (discarded,
│ updates: │ (DSR/DA/CPR etc) └──────────┘ safety valve)
│ • screen │
│ • cursor │
│ • scrollbk │
│ • alt-scr │
└─────▲───────┘
│
│ snapshot on Attach() / re-attach
│ • renderVTEScrollback()
│ • vte.Render() (visible screen)
│ • vte.CursorPosition()
▼
┌─────────────┐
│ replay blob │ → prepended to the new client's
│ ESC c + │ output stream in normal-screen
│ scrollback +│ mode, so its terminal is
│ screen + │ restored across reconnects
│ cursor pos │
└─────────────┘
Three things worth calling out:
The VTE is on a side branch of the output pump, not in series. Live clients get the raw PTY bytes unmodified. The VTE is fed on every pump read and is continuously drained by drainVTE, but its rendered screen, cursor position, and scrollback are only consulted on Attach() to synthesize replay state. If nobody ever re-attaches, the VTE is mostly just absorbing output to preserve future replay state.
The normal-screen replay path uses the blob shown above: terminal reset, rendered scrollback, visible screen, and cursor position. Alternate-screen sessions take a different path. When vte.IsAltScreen() is true, Attach() sends enter-alt-screen + clear to the new client, writes Ctrl-L directly to the PTY, and relies on the running application to redraw.
Terminal queries get answered by the writer client's real terminal, not by the VTE. When an app writes ESC[6n, those bytes flow to the VTE and to every attached client's SSH channel. A real terminal emulator can respond on its input stream, but only the writer client's response can pass WriteInput() and reach the PTY; non-writer responses are rejected as read-only. The VTE's own response goes into drainVTE and is thrown away. With no client attached, TUI queries go unanswered. The drain exists to keep vte.Write from blocking on a full response pipe.
Index ¶
- func MergeEnv(sessionEnv []string) []string
- func ToCRLF(b []byte) []byte
- type Client
- type DetachReason
- type Manager
- type Session
- func (s *Session) Attach(cols, rows uint16) *Client
- func (s *Session) Detach(c *Client)
- func (s *Session) Exited() bool
- func (s *Session) ID() string
- func (s *Session) Info() SessionInfo
- func (s *Session) Resize(c *Client, cols, rows uint16)
- func (s *Session) TakeOver(c *Client, cols, rows uint16)
- func (s *Session) WriteInput(c *Client, p []byte) (int, error)
- type SessionInfo
- type Status
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func MergeEnv ¶
MergeEnv returns the container's environment with session-provided vars overlaid. Filters out vibed-internal config variables (VIBEPIT_SSH_PUBKEY).
func ToCRLF ¶ added in v0.4.0
ToCRLF normalizes bare \n to \r\n without doubling carriage returns on content that already uses \r\n. It is the single source of truth for cooking vibed-generated output bound for a raw-mode terminal that does not translate \n itself: the reattach replay below, and sshd's selector/stderr writers (which wrap it in an io.Writer). Live PTY bytes are deliberately never run through it — a raw-mode TUI's bare \n must reach the client intact.
Note: this is a whole-buffer transform. A streaming caller that can split a \r\n across two Write calls would double the CR; current callers emit bare \n (the collapse step is defensive), so that boundary case never arises.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client represents a connection to a session. It implements io.ReadWriteCloser. Reads return PTY output; writes send PTY input (only if this client is the session's writer).
func (*Client) Close ¶
Close detaches the client from the session, recording the detach as an ordinary connection teardown.
func (*Client) CloseWithReason ¶ added in v0.3.0
func (c *Client) CloseWithReason(reason DetachReason) error
CloseWithReason detaches the client and records why. Only the first call takes effect (Close is once), so the reason reflects the cause that won the race to close. The reason is written before c.done is closed, so any reader that observes the done/output close (and reads DetachReason afterward) sees it without a data race.
func (*Client) DetachReason ¶ added in v0.3.0
func (c *Client) DetachReason() DetachReason
DetachReason reports why the client detached. It is meaningful only after the client has closed; callers read it after observing EOF from Read.
func (*Client) Read ¶
Read returns the next chunk of PTY output. It blocks until data is available or the client is closed. Supports partial reads — if the caller's buffer is smaller than the available data, the remainder is preserved for the next Read call.
Read is not safe for concurrent use. It assumes a single reader goroutine (typically io.Copy in the SSH handler).
type DetachReason ¶ added in v0.3.0
type DetachReason int
DetachReason explains why a client stopped being attached to its session. It lets the SSH handler tell a genuine shell exit apart from a forced detach: a genuine exit closes the session's output channel (leaving the reason DetachNone), whereas any Close-with-reason marks why the client was dropped. The handler reports a clean exit status only for DetachNone, so a client that merely lost its connection is never told the shell exited.
const ( // DetachNone is the zero value: the client was not closed with a reason. // When a client's Read ends while still DetachNone, the session's output // channel was closed because the shell process exited. DetachNone DetachReason = iota // DetachDisconnect: the SSH connection went away (channel/context closed). DetachDisconnect // DetachKeepalive: the server's keepalive declared the client dead, e.g. // after a laptop suspend/resume stalled the connection past the timeout. DetachKeepalive // DetachSlowConsumer: the client could not keep up with PTY output and was // dropped to protect the pump. DetachSlowConsumer )
func (DetachReason) Label ¶ added in v0.3.0
func (r DetachReason) Label() string
Label returns a short description of an abnormal detach reason worth surfacing in UIs, or "" for reasons that aren't — a normal disconnect (the expected case) or none. The empty string is the signal to omit the annotation entirely.
type Manager ¶
type Manager struct {
// Command is the shell command and arguments used for new sessions.
// Defaults to ["/bin/bash", "--login"] when empty.
Command []string
// contains filtered or unexported fields
}
Manager owns all sessions and enforces the concurrency limit.
func NewManager ¶
NewManager creates a session manager with the given maximum number of concurrent active sessions.
func (*Manager) Create ¶
Create starts a new shell session with the given terminal dimensions. The env parameter provides additional environment variables (e.g., from the SSH session) that are merged with the container's environment.
func (*Manager) List ¶
func (m *Manager) List() []SessionInfo
List returns info snapshots for all current sessions.
func (*Manager) SetStateFilePath ¶
SetStateFilePath sets the path where session state is written on changes.
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session represents a persistent PTY shell session that survives client disconnects. It manages the shell process, PTY, attached clients, and output fan-out.
func (*Session) Attach ¶
Attach creates a new client and attaches it to this session. The first client to attach becomes the writer. If cols/rows are provided and this client becomes the writer, the PTY is resized.
On attach, the client receives a replay of scrollback history plus the current VTE screen state so the terminal appears restored.
func (*Session) Detach ¶
Detach removes a client from the session. If the detached client was the writer, the most recently attached remaining client is promoted.
func (*Session) Info ¶
func (s *Session) Info() SessionInfo
Info returns a snapshot of the session's current state.
func (*Session) Resize ¶
Resize changes the PTY dimensions. Only the writer client may resize. Resizes that don't change the dimensions are dropped to avoid redundant ioctls and VTE allocations from a chatty client.
type SessionInfo ¶
type SessionInfo struct {
ID string
Command string
ClientCount int
Status Status
ExitCode int
CreatedAt time.Time
ExitedAt time.Time
DetachedAt time.Time
// LastDetachReason records why the most recent client detached. It is
// DetachNone until a client closes with a reason.
LastDetachReason DetachReason
}
SessionInfo holds a snapshot of session state for display purposes.