term

package
v0.19.0 Latest Latest
Warning

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

Go to latest
Published: Aug 31, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package term owns the PTY sessions `gadak serve` runs next to the mirror — one session core for all three surfaces (the web pane, the desktop app, and a phone over the paired network). Only renderers differ; the shell, its lifetime, and its byte pump live here (GDK-862). Nothing in this package speaks HTTP: the WebSocket that carries these bytes is internal/server's, and the VT/render half is web-side.

The contracts, in one place, because every one of them is pinned by a test in this package:

  • Shell. $SHELL, else /bin/sh, started under a PTY in its own session (Setsid + Setctty) so the child is a session leader and this PTY is its controlling terminal. A job-control shell puts background jobs in a new process group, so a signal to -pgid does not reach them. cwd is the workspace directory unless Options.Dir names another. Env is the parent's plus TERM=xterm-256color, GADAK_TERMINAL=1, and GADAK_TERMINAL_SESSION=<session id> (GDK-1158) — the id a `gadak claim` typed in the pane reflects itself into.

  • Close. SIGHUP every process on the shell's controlling terminal, then wait; a SIGKILL to whoever is still on that terminal after CloseGrace (2s). No zombies and no orphaned grandchildren — TestCloseKillsHUPImmuneGrandchild pins a grandchild that ignores SIGHUP, which bash's own HUP-to-jobs cannot hide.

  • Ring. Each session keeps the last DefaultRingBytes (256 KiB) of output. Attach replays that buffer as the first chunk a reader sees, then live bytes: a client that reconnects inside the grace picks up its scrollback instead of a blank screen.

  • Backpressure. Every attachment carries its own backlog, bounded in bytes (DefaultAttachBytes, 4 MiB), not in chunks: pending chunks are coalesced into it, because a terminal stream is a byte stream and N pending chunks are exactly their concatenation (GDK-1042 — the chunk-count bound this replaces dropped a client that was 256 tiny reads behind, all of 59 KB). When the backlog would exceed the bound the attachment is dropped — closed with a reason — and the PTY read loop keeps going. A slow client never stalls the shell and never delays another client. The one thing that is never dropped is the PTY.

  • Reconnect. A session survives its last attachment leaving for DefaultGrace (60s). Reattaching by session id inside the grace cancels the reap and replays the ring. A session that still has an attachment never reaps, and an attached idle session is not timed out.

  • Idle, and what it is not. When the grace elapses, Session .idleForReap decides — one owner, three signals: an attachment, output since the grace was armed, or a process besides the shell on this controlling terminal. Any of them and the grace re-arms instead of reaping, because a shell with an agent or a build under it is not idle just because the tab closed. Attachment count alone was the whole answer until GDK-994, and it killed running work 60 seconds after a phone went to the background. The ceiling is DefaultMaxDetachedLife (24h) measured from the last detach: past it an unattended session is closed however busy it looks. Info carries DetachedAt and GraceExtensions so the decision is visible from `gadak terminal list` rather than inferred.

  • Ids are 128 bits of crypto/rand, hex. Never sequential: a session id is the only thing a socket URL carries.

  • Revocation. Sessions record the pairing token id they were opened with (empty for a loopback client, which needs no token). Manager .CloseByToken is how `gadak pairing revoke` reaches a live shell — see internal/server's watchdog for who calls it.

  • Windows returns ErrUnsupportedPlatform from Create, naming GDK-861 (the ConPTY shape). An honest stub beats a silent one.

Snapshot() is the debug surface: per-session id, pid, pids (every process on the session's controlling terminal), size, attachment count, createdAt, lastOutputAt, bytesOut, droppedAttachments, and — since GDK-1042 — backlogMaxBytes and coalescedChunks, which say how close a session came to the drop bound and how often coalescing did work. It carries no output bytes and no token id, so it is safe to serve.

Parsers for the Linux /proc/<pid>/stat line. Deliberately without a //go:build linux tag, and named without a platform suffix while its callers (members_linux.go) carry one: these are pure functions over a string, so tagging them would only stop them being tested anywhere but Linux. `go test ./...` runs on ubuntu in CI, but a change made on a mac would then have no local gate at all.

Index

Constants

View Source
const (
	// DefaultRingBytes is the scrollback a session replays on reattach.
	DefaultRingBytes = 256 << 10
	// DefaultAttachBytes is how far behind, in bytes, one attachment may
	// fall before it is dropped. On a loopback socket, four megabytes of
	// unread output is a client that is gone, not one that is busy — and
	// it is 16x the ring, so a drop can never be caused by the replay
	// alone.
	DefaultAttachBytes = 4 << 20
	// DefaultGrace is how long a session outlives its last attachment
	// with nothing running under it.
	DefaultGrace = 60 * time.Second
	// DefaultMaxDetachedLife is the ceiling the grace may not raise: a
	// session that keeps re-arming because work is still on its terminal
	// is closed once it has been unattached this long. Without it, one
	// `sleep infinity` would hold a shell for the life of the serve.
	DefaultMaxDetachedLife = 24 * time.Hour
	// CloseGrace is how long Close waits after SIGHUP before SIGKILL.
	CloseGrace = 2 * time.Second
)

Defaults named in doc.go. They are package constants so the contract has one owner; Config overrides them for tests (a 60-second grace is not something a test may sleep through).

View Source
const (
	// ReasonSlow: the client's pending backlog grew past AttachBytes.
	// The attachment is dropped; the PTY is never stalled for it.
	ReasonSlow = "slow_client"
	// ReasonRevoked: the pairing token this session was opened with is no
	// longer active.
	ReasonRevoked = "token_revoked"
	// ReasonReaped: nothing was attached for the reconnect grace.
	ReasonReaped = "idle_timeout"
	// ReasonShutdown: the serve is going away.
	ReasonShutdown = "server_shutdown"
	// ReasonClosed: an explicit DELETE or Close.
	ReasonClosed = "closed"
)

Why an attachment ended, as the socket reports it to its client.

Variables

View Source
var ErrNotFound = errors.New("term: no such session")

ErrNotFound is Get/Close for a session id the manager does not hold — including one already reaped after its grace.

View Source
var ErrSessionClosed = errors.New("term: session closed")

ErrSessionClosed is a write or resize on a session whose shell is gone.

View Source
var ErrUnsupportedPlatform = errors.New("term: no PTY on this platform")

ErrUnsupportedPlatform is Create's answer where gadak has no PTY yet.

Functions

This section is empty.

Types

type Attachment

type Attachment struct {
	// contains filtered or unexported fields
}

Attachment is one client's view of a session's output: the ring replayed as the first backlog, then live bytes, then an End. The backlog is a byte quantity — N pending chunks are exactly their concatenation (GDK-1042) — so a client that is many small PTY reads behind is handed fewer, larger writes, never cut off for it.

Wake() is the edge signal that the backlog is non-empty. Take() is not gated on the attachment ending — Done() is that signal, and a backlog that was pending when the end arrived stays readable through Take(), so a socket can flush what it has before sending its close frame.

func (*Attachment) Detach

func (a *Attachment) Detach()

Detach lets this client go without touching the session. The reconnect grace starts if it was the last one.

func (*Attachment) Done

func (a *Attachment) Done() <-chan struct{}

Done closes when this attachment ends, for any of the four reasons.

func (*Attachment) End

func (a *Attachment) End() End

End is why it ended. Read it after Done.

func (*Attachment) Take added in v0.19.0

func (a *Attachment) Take() []byte

Take returns the whole pending backlog, oldest byte first, and clears it; nil when nothing is pending. The returned slice is the caller's — Take keeps no alias to it.

func (*Attachment) Wake added in v0.19.0

func (a *Attachment) Wake() <-chan struct{}

Wake yields when Take has something to return. Edge-triggered: one pending token covers any amount of backlog, and a token may outlive the bytes it announced — a Take that returns nil is the reader's cue to go back to sleep, not an error.

type Config

type Config struct {
	// WorkDir is the cwd every session starts in unless Options.Dir names
	// another — the workspace directory in `gadak serve`.
	WorkDir string
	// Grace is how long a session outlives its last attachment.
	Grace time.Duration
	// MaxDetachedLife caps how long re-arming may keep an unattached
	// session alive. Zero takes DefaultMaxDetachedLife.
	MaxDetachedLife time.Duration
	// RingBytes is the per-session scrollback.
	RingBytes int
	// AttachBytes is the per-attachment backlog bound, in bytes. Zero
	// takes DefaultAttachBytes.
	AttachBytes int
	// AfterFunc is time.AfterFunc, injectable so a test can pin the
	// reconnect grace without sleeping a minute.
	AfterFunc func(time.Duration, func()) *time.Timer
	// Now is time.Now, injectable for the same reason.
	Now func() time.Time
}

Config tunes a Manager. Zero values take the package defaults, so New(Config{}) is the production shape.

type End

type End struct {
	Kind   EndKind
	Code   int
	Reason string
}

End is the terminal event of one attachment.

type EndKind

type EndKind int

EndKind is why an Attachment stopped.

const (
	// EndDetached: this client let go. The session may still be running.
	EndDetached EndKind = iota
	// EndExited: the shell exited; Code is its status.
	EndExited
	// EndDropped: backpressure. Reason says which.
	EndDropped
	// EndClosed: the session was closed out from under the client.
	// Reason says why (revoked, reaped, shutdown, explicit close).
	EndClosed
)

type Info

type Info struct {
	ID                 string    `json:"id"`
	PID                int       `json:"pid"`
	Cols               uint16    `json:"cols"`
	Rows               uint16    `json:"rows"`
	Attached           int       `json:"attached"`
	CreatedAt          time.Time `json:"created_at"`
	LastOutputAt       time.Time `json:"last_output_at"`
	BytesOut           int64     `json:"bytes_out"`
	DroppedAttachments int       `json:"dropped_attachments"`
	Exited             bool      `json:"exited"`
	ExitCode           int       `json:"exit_code"`
	// BacklogMaxBytes is the high-water mark of any one attachment's
	// pending backlog over the session's life — the number that answers
	// "was this anywhere near the drop bound?". CoalescedChunks counts
	// how many emits landed on a non-empty backlog, i.e. how often
	// merging actually did work (GDK-1042).
	BacklogMaxBytes int64 `json:"backlog_max_bytes"`
	CoalescedChunks int64 `json:"coalesced_chunks"`
	// PIDs is every process currently on this session's controlling
	// terminal, including the shell. Empty when the enumerator cannot
	// see a tty (Windows, or a pid with no controlling terminal).
	// Filled only by Snapshot — the list path; the create path does not
	// walk the process table (GDK-988).
	PIDs []int `json:"pids,omitempty"`
	// DetachedAt is when this session last lost its final attachment,
	// zero while anything is attached. GraceExtensions counts how many
	// times the reconnect grace re-armed instead of reaping because work
	// was still on the terminal. The pair answers "why is this shell
	// still here" from `gadak terminal list`, without a debugger.
	DetachedAt      time.Time `json:"detached_at"`
	GraceExtensions int       `json:"grace_extensions,omitempty"`
	// Resizes counts the size changes this session has actually applied
	// since it was created — not attempts, and not the create size.
	// Cols/Rows say what the PTY believes; this says whether any client
	// ever corrected it. A pane whose layout settles after it opened its
	// socket has to send one, and "0 here with a pane clearly wider than
	// Cols" is the whole diagnosis of GDK-1154, which otherwise needs a
	// debugger attached to a phone.
	Resizes int `json:"resizes"`
	// NeedsAttention is "this session rang for a person" (GDK-1163): a BEL
	// byte went through the ring and nobody has attached since. It is the
	// only honest answer to "is this blocked or is it working" that the
	// terminal already carries — idleForReap deliberately answers a
	// different question ("may I reap you"), and an agent waiting at a
	// prompt with a child on the tty reads as busy to that one.
	NeedsAttention bool `json:"needs_attention,omitempty"`
	// IssueKey is the issue this session was claimed for, empty when none.
	// It is runtime state and is not persisted anywhere: the binding dies
	// with the session, and the durable record of "who claimed what when"
	// is origin's own claim history (GDK-1158).
	IssueKey string `json:"issue_key,omitempty"`
}

Info is one row of Snapshot: everything a `gadak terminal list` needs and nothing a socket carries. No output bytes, no token id.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager owns every live session in this process.

func New

func New(cfg Config) *Manager

New returns a Manager. It starts no goroutines: a Manager with no sessions costs nothing, which is what every `gadak serve` that never opens a terminal should pay.

func (*Manager) CloseAll

func (m *Manager) CloseAll()

CloseAll reaps every session. Called on server shutdown: an exiting serve must not leave shells behind.

func (*Manager) CloseByToken

func (m *Manager) CloseByToken(tokenID string) int

CloseByToken reaps every session opened with tokenID. This is what `gadak pairing revoke` reaches through: a revoked terminal token must lose the shell it opened, not just the next request. An empty tokenID matches nothing — loopback sessions are not token-bound and revoking a token must not cut the local pane.

func (*Manager) Create

func (m *Manager) Create(opts Options) (*Session, error)

Create spawns a shell under a PTY and returns its session.

func (*Manager) Get

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

Get returns a live session by id.

func (*Manager) List

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

List returns every live session, oldest first.

func (*Manager) Snapshot

func (m *Manager) Snapshot() []Info

Snapshot is the introspection surface — see doc.go. It is the one place PIDs is filled: Info stays a pure field copy so the create path never walks the process table (GDK-988).

func (*Manager) TokenIDs

func (m *Manager) TokenIDs() []string

TokenIDs is the distinct set of non-empty token ids live sessions were opened with — what the revoke watchdog re-checks against the store.

type Options

type Options struct {
	// Dir overrides Config.WorkDir for this session.
	Dir string
	// Cols and Rows are the initial PTY size. Zero takes 80x24 — a shell
	// with a zero-sized terminal draws nothing and is a support ticket.
	Cols, Rows uint16
	// Env is appended after the inherited environment and the variables
	// this package always sets. Create appends GADAK_TERMINAL_SESSION
	// after it in turn (last duplicate wins), so a session's own id is
	// never maskable from here.
	Env []string
	// Shell overrides $SHELL. The settings catalog's terminal.shell
	// reaches here through handleTerminalCreate (GDK-896); tests use it
	// too.
	Shell string
	// Args are the shell's arguments.
	Args []string
	// TokenID is the pairing token this session was opened with — empty
	// for a loopback client, which needs none. CloseByToken reads it.
	TokenID string
}

Options is one Create call.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is one shell under one PTY.

func (*Session) Attach

func (s *Session) Attach() (*Attachment, error)

Attach returns a reader that first yields the ring, then live output. Attaching cancels a pending reap.

func (*Session) Close

func (s *Session) Close() error

Close reaps the session: SIGHUP every process on the shell's controlling terminal, then SIGKILL whoever is still there after CloseGrace if the pump has not finished.

func (*Session) Done

func (s *Session) Done() <-chan struct{}

Done closes when the shell is gone and every attachment has been told.

func (*Session) ID

func (s *Session) ID() string

ID is the session id a socket URL carries.

func (*Session) Info

func (s *Session) Info() Info

Info is this session's Snapshot row. PIDs stays empty here — only Snapshot (the list path) fills it, so the create path never walks the process table (GDK-988).

func (*Session) IssueKey added in v0.19.0

func (s *Session) IssueKey() string

IssueKey is the issue this session is bound to, empty when none.

func (*Session) PID

func (s *Session) PID() int

PID is the shell's process id — also its process-group id, because the child is started with Setsid.

func (*Session) Resize

func (s *Session) Resize(cols, rows uint16) error

Resize sets the PTY window size; on unix the child receives SIGWINCH.

func (*Session) SetIssueKey added in v0.19.0

func (s *Session) SetIssueKey(key string)

SetIssueKey binds this session to the issue a claim in its shell took. One session holds one issue at a time: a new key replaces the old, and an empty key clears the binding. Runtime state only — see Info.IssueKey for why nothing persists it.

func (*Session) TokenID

func (s *Session) TokenID() string

TokenID is the pairing token this session was opened with, empty for a loopback client. Not exposed by Snapshot.

func (*Session) Write

func (s *Session) Write(p []byte) (int, error)

Write sends bytes to the shell's stdin.

Jump to

Keyboard shortcuts

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