tymux

package
v1.53.0 Latest Latest
Warning

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

Go to latest
Published: Sep 9, 2026 License: AGPL-3.0 Imports: 28 Imported by: 0

Documentation

Overview

Package tymux contains the TymuxBackend implementation of session.ProcessManager (session/backend_tymux.go), which delegates to a tymuxd instance over gRPC via the generated Connect-Go client in github.com/tstapler/tymux/clients/go.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotSupportedOnTymuxBackend = errors.New("not supported by the tymux backend")

ErrNotSupportedOnTymuxBackend is returned by GetPanePID: tymux has no OS pane PID to hand back, only a remote gRPC process. GetPTY() no longer returns this — see its own doc comment.

View Source
var ErrTymuxdPortSquatted = errors.New("tymux: tymuxd port squatted by another process")

ErrTymuxdPortSquatted indicates EnsureDaemonRunning (session/tymux/supervise.go) found something already listening at a DaemonConfig.Addr that never answers a ListSessions RPC correctly, even after every spawn-and-retry attempt — i.e. a non-tymuxd process (or an incompatible/misbehaving tymuxd) has taken the port. Task 2.1.2d: this is a distinct failure mode from "nothing is listening yet, so spawn one" and is surfaced loudly rather than silently proceeding with a session pointed at an unverified daemon (research/pitfalls.md §2/§4's shared mitigation).

View Source
var ErrTymuxdUnreachable = errors.New("tymux: tymuxd unreachable")

ErrTymuxdUnreachable indicates an RPC could not reach a tymuxd daemon at all — connection refused, or nothing listening on the configured socket/address — as opposed to a request that reached tymuxd but was rejected there (a live daemon telling us "no" is a different failure mode than no daemon to ask).

Story 2.2.6's original doc comment here claimed stapler-squad does not start or supervise tymuxd itself; that scope decision is superseded by project_plans/tymux-bundled-integration/decisions/ADR-003-supersede-story-2-2-6-no-supervision.md — stapler-squad now supervises tymuxd (start-if-not-running, health-check) whenever the tymux backend is in use. This sentinel's own scope is unchanged by that: it is produced in exactly one place, classifyRPCError below, and means "an RPC against an already-in-use session's transport hit a transport-level Unavailable" -- e.g. a previously-healthy tymuxd that has since crashed or become unreachable mid-session. It is NOT produced by session/tymux/supervise.go's EnsureDaemonRunning: that function's own failure paths (ErrTymuxdPortSquatted below, or a plain "did not become healthy" error for a supervision-start failure before any session ever attaches) are distinct error values -- errors.Is(err, ErrTymuxdUnreachable) is false for both. Callers match THIS sentinel with errors.Is to distinguish "an in-flight session's daemon connection dropped" from "daemon rejected the request" — research/ux.md:218-224's "should not present as the same 'reconnecting' transient state." A caller that wants to detect a supervision-start failure specifically should check EnsureDaemonRunning's returned error (or errors.Is(err, ErrTymuxdPortSquatted) for the port-squat case) instead.

Functions

func CellsToSGR

func CellsToSGR(grid []*v1.Row) (string, error)

CellsToSGR renders a PaneSnapshot's cell grid (PaneSnapshot.grid) into an ANSI/SGR-encoded string matching `tmux capture-pane -p -e`'s attribute-preserving output shape — the renderer CapturePaneContent() (Task 2.2.2d) needs and CapturePaneContentRaw() deliberately doesn't (that method only joins Cell.text, no SGR).

Rows are newline-joined (mirroring rowsToPlainText's join). Within a row, an SGR escape sequence is emitted only when a cell's fg/bg/attrs differ from the previous cell's (Task 2.6.1a/Story 2.6.1) — an unbroken attribute run gets exactly one sequence before it, not one per cell.

func NewRealTransport

func NewRealTransport(addr string) rpcTransport

NewRealTransport constructs an rpcTransport backed by a real Connect-Go gRPC client against a live tymuxd. addr is the tymuxd base URL (e.g. "http://127.0.0.1:7419"); pass "" to use tymuxdAddr()'s TYMUXD_ADDR-or-default resolution.

func ReconnectMetricsSnapshot

func ReconnectMetricsSnapshot() map[string]int64

ReconnectMetricsSnapshot returns a point-in-time copy of tymux_attach_stream_reconnects_total, keyed by cause — exported so a future Observability Plan wiring (e.g. an HTTP /metrics handler) can read it without reaching into package-private state, mirroring session/tmux's own ForkPressureSnapshot() convention.

func StopTymuxd added in v1.49.0

func StopTymuxd() error

StopTymuxd kills whatever process is recorded in $configDir/tymuxd.pid and removes the PID file; no-op (returns nil) if the PID file doesn't exist -- mirrors daemon/daemon.go's StopDaemon's idempotent-stop contract exactly.

This is idempotent, NOT ownership-aware: it has no way to tell whether the recorded PID belongs to a tymuxd this process itself started, or one a different process (sharing the same configDir -- see TymuxdReady.Spawned's doc comment) started and is still relying on. A caller registering this as an App.OnStop hook MUST gate that registration on EnsureDaemonRunning's TymuxdReady.Spawned being true for a spawn this process performed -- calling it unconditionally risks killing another process's daemon out from under it.

func TymuxdBinary added in v1.49.0

func TymuxdBinary() string

TymuxdBinary returns the tymuxd executable path. TYMUXD_BIN env var overrides the default "tymuxd" — set it to use a specific binary (e.g. TYMUXD_BIN=$(pwd)/bin/tymuxd go test or the fetched embed copy).

To bundle tymuxd directly into the stapler-squad binary instead, build with:

go build -tags embed_tymux .

after running: make build-tymuxd-embed

Types

type ClientFanout

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

ClientFanout is a local, in-process multi-subscriber broadcast for one standing Attach stream's output events (Story 2.3.2): the GoF Observer pattern per Pattern Decisions, satisfying SubscribeToControlModeUpdates' multi-subscriber contract from a single upstream stream instead of opening a second Attach call per subscriber.

Broadcast is non-blocking (drop-if-full) so one slow subscriber can never stall the standing stream's reader goroutine — matching the existing lossy-broadcast precedent tymuxd's own output_gap semantics already establish server-side (ADR-003-adjacent design, see Attach's proto doc).

func NewClientFanout

func NewClientFanout() *ClientFanout

NewClientFanout constructs an empty ClientFanout.

func (*ClientFanout) Broadcast

func (f *ClientFanout) Broadcast(data []byte)

Broadcast sends data to every current subscriber's channel with a non-blocking send — a subscriber whose buffer is full drops this frame rather than blocking the caller (the standing stream's reader goroutine).

func (*ClientFanout) Subscribe

func (f *ClientFanout) Subscribe() (string, chan []byte)

Subscribe registers a new subscriber and returns its id (for Unsubscribe) and a channel that receives every subsequent Broadcast call's data. The channel is closed by Unsubscribe, never by Broadcast.

func (*ClientFanout) Unsubscribe

func (f *ClientFanout) Unsubscribe(id string)

Unsubscribe removes and closes the subscriber channel for id. A no-op if id is unknown or was already unsubscribed.

type DaemonConfig added in v1.49.0

type DaemonConfig struct {
	Addr       string
	BinaryPath string
}

DaemonConfig bundles the two string concepts every tymuxd supervision function needs: where to reach the daemon (Addr) and which binary to spawn it from (BinaryPath). Per the `primitive-obsession-checklist` skill, this exists so no later supervision function signature grows a second bare string parameter that could be silently swapped with the first — every such function should take a DaemonConfig, not separate addr/binaryPath strings.

func ResolveDaemonConfig added in v1.49.0

func ResolveDaemonConfig() DaemonConfig

ResolveDaemonConfig is the single choke point for producing a DaemonConfig, called both from main.go (startup) and from session.TymuxBackend wiring. It must be exported for that cross-package use.

Addr resolution mirrors tymuxdAddr()'s existing TYMUXD_ADDR-or-default precedence (transport.go), extended with an instance-scoped default so a named STAPLER_SQUAD_INSTANCE (e.g. a manual/isolated dev instance) doesn't collide with the default instance's tymuxd on 127.0.0.1:7419:

  • TYMUXD_ADDR set: always wins, regardless of STAPLER_SQUAD_INSTANCE.
  • STAPLER_SQUAD_INSTANCE unset, "", or "shared" (the default/live instance — matches config.IsNamedInstance()'s inverse condition and GetConfigDirForDir's "shared" backward-compatibility carve-out): defaultTymuxdAddr, unchanged from today.
  • STAPLER_SQUAD_INSTANCE set to anything else: a distinct port derived deterministically from the instance name, so the same instance name always resolves to the same port and different instance names resolve to different (with overwhelming probability) ports.

BinaryPath is always TymuxdBinary() (Epic 1.2), which already applies its own TYMUXD_BIN override independently of Addr resolution.

type TymuxManager

type TymuxManager interface {
	// Lifecycle
	Start(dir string) error
	RestoreWithWorkDir(workDir string) error
	Close() error
	IsAlive() bool

	// Identification
	GetSessionIdentifier() string

	// Existence / state
	HasSession() bool

	// Working directory (via pane introspection)
	GetCurrentWorkingDirectory() (string, error)

	// Terminal I/O
	GetPTY() (*os.File, error)
	SendKeys(keys string) (int, error)
	TapEnter() error
	SendPromptWithEnter(prompt string) error
	SendInputViaControlMode(ctx context.Context, data []byte) error

	// Terminal state
	CapturePaneContent() (string, error)
	CapturePaneContentRaw() (string, error)
	CapturePaneContentWithOptions(startLine, endLine string) (string, error)
	CaptureViewport(lines int) (string, error)
	GetCursorPosition() (x, y int, err error)
	GetPaneDimensions() (width, height int, err error)
	SetWindowSize(cols, rows int) error
	SetDetachedSize(width, height int, instanceTitle string) error
	RefreshClient() error

	// Process metadata
	GetPanePID() (int32, error)

	// Content helpers
	HasUpdated() (updated bool, hasPrompt bool, content string)
	FilterBanners(content string) (string, int)
	HasMeaningfulContent(content string) bool

	// Streaming (control mode)
	StartControlMode() error
	StopControlMode() error
	// SubscribeToControlModeUpdates returns a subscription ID and a bidirectional channel.
	// The channel must be bidirectional (chan []byte, not <-chan []byte) because some callers
	// write synthetic frames for testing. Implementations must not write to the channel themselves.
	SubscribeToControlModeUpdates() (string, chan []byte)
	UnsubscribeFromControlModeUpdates(id string)

	// Attach (interactive TUI)
	Attach() (chan struct{}, error)
	DetachSafely() error

	// Exit notifications
	SetOnExitCallback(fn func(string))
	ResetExitOnce()

	// Reconnect state (Task 2.5.2e): exposes ReconnectLoop's live
	// progress — whether this session's standing stream is currently
	// reconnecting, which attempt it's on, and what triggered it — so a
	// future UI (ux.md Surface 2) doesn't have to reverse-engineer it
	// from the aggregate tymux_attach_stream_reconnects_total metric.
	// The one deliberate addition beyond ProcessManager's mirrored
	// method set (requirements.md's constraint keeps ProcessManager
	// itself untouched); state exposure only, no UI here.
	ReconnectState() (reconnecting bool, attempt int, cause string)

	// BackendRestarted reports whether tymuxd was detected to have restarted
	// out from under this session (Story 2.5.3's daemon-restart contract) —
	// the pane's original process is orphaned, not reattached, per
	// Engine::revive_session's always-spawn-fresh behavior on the tymux
	// side. Exposed on the interface (not just the concrete type) so a real
	// caller holding only a TymuxManager/ProcessManager reference can
	// observe this state, not just tests asserting on the unexported type.
	BackendRestarted() (restarted bool, since time.Time)
}

TymuxManager is the interface satisfied by the concrete tymux gRPC session implementation (tymuxGRPCSession, session.go). It mirrors session.ProcessManager's exact method set so TymuxBackend (session/backend_tymux.go) can forward every call one-to-one, the same shape TmuxBackend/TmuxManager use for the tmux backend (session/tmux_backend.go, session/tmux_process_manager.go).

Defined as its own interface here (rather than the session package's ProcessManager directly) because session/tymux is imported BY the session package — depending on session.ProcessManager here would create an import cycle. Duplicating the method set also gives tests a seam to substitute a fake TymuxManager without a live tymuxd daemon.

func NewTymuxGRPCSession

func NewTymuxGRPCSession(transport rpcTransport) TymuxManager

NewTymuxGRPCSession constructs a tymuxGRPCSession using the given rpcTransport, returned as a TymuxManager since tymuxGRPCSession itself is unexported.

type TymuxdReady added in v1.49.0

type TymuxdReady struct {
	Spawned bool
}

TymuxdReady is a proof token returned by EnsureDaemonRunning, mirroring session/tmux/tmux.go's TmuxServerReady -- a caller holding one has confirmation that a healthy tymuxd was reachable (already running, or just spawned and verified) at the moment EnsureDaemonRunning returned.

Spawned distinguishes which case it was: true only when THIS call actually started a new tymuxd process (the reuse path -- an already-healthy daemon answered checkDaemonHealthy before any spawn was attempted -- always leaves this false). This matters because StopTymuxd() kills whatever PID is recorded in the shared, per-configDir tymuxd.pid file: two processes sharing the same STAPLER_SQUAD_INSTANCE (including the default/"shared" instance, or a named instance started twice) can observe the SAME already-running daemon via the reuse path. A caller that registers a stop-on-shutdown hook unconditionally -- rather than gating it on Spawned -- risks killing a daemon a DIFFERENT, still-running process depends on. This is the exact "isolated by config dir but sharing the daemon/socket underneath" hazard config.IsNamedInstance's doc comment documents for tmux (an orphan sweep once killed 5 unrelated production tmux sessions); do not reintroduce it here unguarded (see session/backend_tymux.go's Story 2.1.3 callers and main.go's Epic 2.2 OnStop registration, both of which must check Spawned before registering a stop hook).

func EnsureDaemonRunning added in v1.49.0

func EnsureDaemonRunning(ctx context.Context, cfg DaemonConfig) (TymuxdReady, error)

EnsureDaemonRunning reuses an already-healthy tymuxd at cfg.Addr, or spawns one and retries-verify until it becomes healthy. Concurrent callers that both observe a cold daemon for the same cfg.Addr coalesce onto exactly one spawn attempt via spawnSF (Task 2.1.2g, ADR-004) rather than racing to bind the port. Returns a TymuxdReady proof token on success; returns ErrTymuxdPortSquatted (wrapped) if something is listening at cfg.Addr but never answers ListSessions correctly after every retry -- this never silently proceeds with a session pointed at an unverified daemon (research/pitfalls.md §2/§4).

The initial reuse check uses the caller's own ctx (each caller's own budget applies to its own check). The coalesced spawn-and-retry closure deliberately does NOT reuse whichever caller happened to become the singleflight leader's ctx for its retry health-checks -- if the leader's own ctx were cancelled partway through (e.g. its own 15s per-call budget, ADR-004), every coalesced follower would see spurious failures for the rest of the retry budget even though its own ctx might still be valid. Mirrors session/tmux/tmux.go's existsSF/noCacheSF precedent, which builds a fresh context.Background()-derived timeout inside the closure rather than depending on any one caller's context.

Jump to

Keyboard shortcuts

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