session

package
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 21, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

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:

  1. 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.

  2. 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.

  3. 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

Constants

This section is empty.

Variables

This section is empty.

Functions

func MergeEnv

func MergeEnv(sessionEnv []string) []string

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

func ToCRLF(b []byte) []byte

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

func (c *Client) Close() error

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

func (c *Client) Read(p []byte) (int, error)

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).

func (*Client) Write

func (c *Client) Write(p []byte) (int, error)

Write sends input to the session's PTY. Returns an error if this client is not the writer.

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

func NewManager(limit int) *Manager

NewManager creates a session manager with the given maximum number of concurrent active sessions.

func (*Manager) Create

func (m *Manager) Create(cols, rows uint16, env []string) (*Session, error)

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) Get

func (m *Manager) Get(id string) *Session

Get returns the session with the given ID, or nil if not found.

func (*Manager) List

func (m *Manager) List() []SessionInfo

List returns info snapshots for all current sessions.

func (*Manager) SetStateFilePath

func (m *Manager) SetStateFilePath(path string)

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

func (s *Session) Attach(cols, rows uint16) *Client

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

func (s *Session) Detach(c *Client)

Detach removes a client from the session. If the detached client was the writer, the most recently attached remaining client is promoted.

func (*Session) Exited

func (s *Session) Exited() bool

Exited returns true if the shell process has exited.

func (*Session) ID

func (s *Session) ID() string

ID returns the session identifier.

func (*Session) Info

func (s *Session) Info() SessionInfo

Info returns a snapshot of the session's current state.

func (*Session) Resize

func (s *Session) Resize(c *Client, cols, rows uint16)

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.

func (*Session) TakeOver

func (s *Session) TakeOver(c *Client, cols, rows uint16)

TakeOver promotes the given client to writer, replacing the current writer. If cols/rows are non-zero, the PTY and VTE are resized to match the new writer's terminal dimensions.

func (*Session) WriteInput

func (s *Session) WriteInput(c *Client, p []byte) (int, error)

WriteInput sends input to the PTY. Only the writer client may write.

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.

type Status

type Status string
const (
	Attached Status = "attached"
	Detached Status = "detached"
	Exited   Status = "exited"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL