Documentation
¶
Overview ¶
Package streamhub provides the single-owner terminal output hub: one StreamHub per tmux session fans output out to N attached Subscribers over a Transport-agnostic interface, and is the sole caller of that session's resize/quiescence/capture-pane surface, eliminating the multi-connection resize/capture race that per-connection ownership allowed.
Index ¶
- Constants
- Variables
- func ActiveHubs() int64
- func BatchFlushFramesCoalesced() int64
- func OverlapInvariant(sessionName string, ownerCount int)
- func OverlapInvariantViolationsTotal() int64
- func RegisterMetrics() error
- func ResizeNegotiationsTotal() int64
- func SetOverlapInvariantHookForTest(hook func(sessionName string, ownerCount int))
- func SetSessionOverrideLookup(lookup func(sessionName string) (forceHub bool, ok bool))
- func SubscribersPerHub() int64
- type BatchWindow
- type BatchWindowOption
- type BroadcastUnit
- type FlushReason
- type HubLifecycleState
- type HubOption
- func WithBatchMaxWindow(d time.Duration) HubOption
- func WithQuiescenceQuietPeriod(d time.Duration) HubOption
- func WithQuiescenceTimeout(d time.Duration) HubOption
- func WithSlowSubscriberGrace(d time.Duration) HubOption
- func WithSubscriberBufferSize(n int) HubOption
- func WithTeardownGrace(d time.Duration) HubOption
- type HubSequenceNumber
- type MemoryTransport
- type MemoryTransportOption
- type ResizeVote
- type SessionController
- type StreamHub
- func (h *StreamHub) AttachSubscriber(transport Transport, capability SubscriberCapability) SubscriberID
- func (h *StreamHub) Broadcast(data []byte)
- func (h *StreamHub) DetachSubscriber(id SubscriberID)
- func (h *StreamHub) ForceTeardown() error
- func (h *StreamHub) NegotiatedSize() TerminalSize
- func (h *StreamHub) OnRawOutput(data []byte)
- func (h *StreamHub) RequestResize(id SubscriberID, size TerminalSize)
- func (h *StreamHub) SlowSubscriberDropsTotal() int64
- func (h *StreamHub) State() HubLifecycleState
- func (h *StreamHub) SubscriberCount() int
- func (h *StreamHub) TryFlush()
- type StreamOwnershipLock
- func (l *StreamOwnershipLock) AcquireAndResolve(flagValue bool, fn func(StreamPath) error) error
- func (l *StreamOwnershipLock) AcquireAndResolveExpecting(flagValue bool, want StreamPath, fn func() error) error
- func (l *StreamOwnershipLock) Resolve(flagValue bool) StreamPath
- func (l *StreamOwnershipLock) ResolveExpecting(flagValue bool, want StreamPath) (StreamPath, error)
- type StreamPath
- type SubscriberCapability
- type SubscriberID
- type TerminalSize
- type Transport
Constants ¶
const ( // DefaultHubTeardownGrace is how long a StreamHub with zero subscribers // waits before tearing down control mode, so a brief reconnect doesn't // kill it (Story 1.2.2). DefaultHubTeardownGrace = 5 * time.Second )
const MaxBatchWindow = 20 * time.Millisecond
MaxBatchWindow bounds how long a BatchWindow accumulates raw output bytes before flushing automatically with FlushCeiling — Story 2.1.1's AC. It is deliberately close to the latency ceiling server/services/connectrpc_websocket.go's existing per-connection coalesce loop already tolerates today (a 32-frame batch cap at typical control-mode output rates), so batching never regresses today's perceived latency.
Variables ¶
var ErrOwnershipResolvedToOtherPath = errors.New("streamhub: session ownership already resolved to a different StreamPath")
ErrOwnershipResolvedToOtherPath is returned by ResolveExpecting when the session's sticky StreamPath resolution does not match the caller's own intended path — i.e. the other side of the race won. Callers must treat this as an explicit signal to join the winning path (attach as a subscriber to its existing hub, or proceed as a legacy per-connection stream) rather than silently reinterpreting their own attempt as having succeeded (Task 3.1.2b).
Functions ¶
func ActiveHubs ¶
func ActiveHubs() int64
ActiveHubs returns the current count of live StreamHubs in this process — the same value streamhub_active_hubs' gauge callback observes.
func BatchFlushFramesCoalesced ¶
func BatchFlushFramesCoalesced() int64
BatchFlushFramesCoalesced returns the frames-coalesced count from the most recent batch flush across every hub in this process — the same value streamhub_batch_flush_frames_coalesced's histogram most recently recorded.
func OverlapInvariant ¶
OverlapInvariant is the production-reachable, load-bearing regression check named in plan.md's Domain Glossary: no two owners (legacy connection or hub) should ever hold resize/capture authority for one tmux session concurrently. StreamOwnershipLock.Resolve's mutex-guarded resolve-once-and-cache behavior already makes this structurally impossible under correct usage — this function is the defense-in-depth check that would surface a future regression in that guarantee immediately (e.g. HubRegistry.GetOrCreate calls it on every hub creation/lookup, Task 3.2's real production call site) rather than relying solely on code review to keep Resolve/GetOrCreate correct forever. It always emits slog.Error with full context and increments streamhub_overlap_invariant_violations_total — it never panics, since a panic in this single-operator daily-driver process would be worse than the bug it would catch. This is the one real implementation: earlier phases' overlapInvariantViolated/assertOverlapInvariant test-local helpers (Epic 1.4) have been retired in favor of calling this directly, via SetOverlapInvariantHookForTest for the t.Fatal-on-first-occurrence behavior every -race test in this plan requires.
func OverlapInvariantViolationsTotal ¶
func OverlapInvariantViolationsTotal() int64
OverlapInvariantViolationsTotal returns the process-wide count of OverlapInvariant violations detected so far — the same value streamhub_overlap_invariant_violations_total's counter observes. Must stay 0 across the whole dark-launch window (plan.md's Observability Plan / Success Metric).
func RegisterMetrics ¶
func RegisterMetrics() error
RegisterMetrics registers the six streamhub_* instruments named in plan.md's Observability Plan against the process's OTel MeterProvider (telemetry.GetMeter(), a delegating proxy safe to call before telemetry.Initialize — see session/unfinished.RegisterMetrics's identical pattern). Idempotent via sync.Once: package init already calls this once; it is exported so a caller (or a smoke test, matching session/unfinished/metrics_test.go) can call it again safely.
func ResizeNegotiationsTotal ¶
func ResizeNegotiationsTotal() int64
ResizeNegotiationsTotal returns the process-wide count of resize negotiations recorded so far (every RequestResize call from a CanResize-eligible subscriber, regardless of whether it actually changed the negotiated size) — the same total streamhub_resize_negotiations_total accumulates.
func SetOverlapInvariantHookForTest ¶
SetOverlapInvariantHookForTest installs hook to run, in addition to OverlapInvariant's normal slog.Error+metric behavior, whenever a violation is detected. Pass nil to clear it. Tests should always clear it via t.Cleanup so a hook installed by one test can't leak into another running in the same package binary.
func SetSessionOverrideLookup ¶
SetSessionOverrideLookup installs lookup as the per-session override source consulted by every StreamOwnershipLock's Resolve call. lookup should return (forceHub, true) when sessionName has an explicit override recorded, or (_, false) when it does not (falls back to the global flagValue). Pass nil to clear it — tests should always do so via t.Cleanup so a hook installed by one test can't leak into another running in the same package binary.
func SubscribersPerHub ¶
func SubscribersPerHub() int64
SubscribersPerHub returns the subscriber count observed on the most recent AttachSubscriber call across every hub in this process — the same value streamhub_subscribers_per_hub's histogram most recently recorded.
Types ¶
type BatchWindow ¶
type BatchWindow struct {
// contains filtered or unexported fields
}
BatchWindow is StreamHub's single, hub-owned opportunistic-with-ceiling accumulation buffer and timer (Story 2.1.1): every raw output event for one hub is appended here as an opaque byte range — concatenated verbatim, never truncated, reordered, or content-sniffed — and the accumulated bytes are handed to onFlush exactly once per flush regardless of how many subscribers are attached, either at the caller's next TryFlush (opportunistic) or after maxWindow elapses since the first buffered byte (ceiling), whichever comes first. Exactly one BatchWindow exists per hub, so exactly one accumulation buffer and one *time.Timer exist per hub too, never one per subscriber.
func NewBatchWindow ¶
func NewBatchWindow(onFlush func(BroadcastUnit), opts ...BatchWindowOption) *BatchWindow
NewBatchWindow constructs a BatchWindow that hands each flushed BroadcastUnit to onFlush. onFlush must not block indefinitely — it runs synchronously on whichever goroutine triggered the flush (an Add/TryFlush caller, or the ceiling timer's own goroutine).
func (*BatchWindow) Add ¶
func (b *BatchWindow) Add(data []byte)
Add appends data to the pending accumulation buffer as an opaque byte range. Arms the ceiling timer exactly once per window, the moment the buffer transitions from empty to non-empty; every subsequent Add call within the same still-pending window never re-arms it — the invariant that keeps "exactly one timer per hub" true across an arbitrarily long burst of events.
func (*BatchWindow) Bypass ¶
func (b *BatchWindow) Bypass(data []byte)
Bypass immediately delivers data as its own BroadcastUnit, stamped with the next HubSequenceNumber, without touching the pending accumulation buffer at all (Story 2.1.2) — ResizeQuiescence signals and the post-resize CatchUpSnapshot must never wait behind a pending batch's opportunistic-or-ceiling flush (research/pitfalls.md §2c/§2d). Any already-pending batch still flushes later on its own normal schedule; Bypass never cancels or folds into it.
func (*BatchWindow) FlushCount ¶
func (b *BatchWindow) FlushCount() int64
FlushCount reports how many times the accumulation/coalesce step (flushLocked) has executed — test-only instrumentation for Task 2.1.1e's call-counting AC (must be 1 for an N-subscriber burst, not N).
func (*BatchWindow) TimersArmed ¶
func (b *BatchWindow) TimersArmed() int64
TimersArmed reports how many times the ceiling timer has been armed — test-only instrumentation proving at most one timer exists per window regardless of how many Add calls or subscribers are involved.
func (*BatchWindow) TryFlush ¶
func (b *BatchWindow) TryFlush()
TryFlush flushes any pending accumulated bytes immediately, with reason FlushOpportunistic. This is the hook for "the next subscriber-write opportunity" (Story 2.1.1's AC): a caller feeding raw output into Add calls TryFlush once it has drained every immediately-available event, mirroring the `default: break coalesce` point in server/services/connectrpc_websocket.go's existing per-connection coalesce loop. A no-op if nothing is buffered.
type BatchWindowOption ¶
type BatchWindowOption func(*BatchWindow)
BatchWindowOption configures a BatchWindow at construction time.
func WithMaxBatchWindow ¶
func WithMaxBatchWindow(d time.Duration) BatchWindowOption
WithMaxBatchWindow overrides MaxBatchWindow — tests use this to shrink the ceiling so ceiling-flush assertions run in milliseconds.
type BroadcastUnit ¶
type BroadcastUnit struct {
Seq HubSequenceNumber
Data []byte
Reason FlushReason
// FramesCoalesced is the number of underlying Add calls folded into this
// unit (Task 3.2.2a/Story 3.2.2's streamhub_batch_flush_frames_coalesced
// metric) — 1 for a Bypass unit, since it never touches accumulation at
// all and represents exactly one control/quiescence message.
FramesCoalesced int
}
BroadcastUnit is one flushed batch or bypassed control message, stamped with the hub's HubSequenceNumber at the moment it is actually handed to onFlush (broadcast time) — never at Add/Bypass call time, so a unit that spent longer accumulating never receives a lower sequence number than one that bypassed it later (Story 2.1.2's AC).
type FlushReason ¶
type FlushReason int
FlushReason records why a BatchWindow flushed a batch, distinguishing an opportunistic flush (the caller found a subscriber-write opportunity before the ceiling elapsed) from one forced by MaxBatchWindow, and from a Bypass call that never went through accumulation at all. Every switch over FlushReason must include a default: panic("unhandled FlushReason") case, matching HubLifecycleState/StreamPath in types.go (enforced by the `exhaustive` linter).
const ( // FlushOpportunistic fires when the caller signals a subscriber-write // opportunity (TryFlush) while data is pending. FlushOpportunistic FlushReason = iota // FlushCeiling fires when MaxBatchWindow elapses since the first byte // buffered in the current window, with no opportunistic flush beating it. FlushCeiling // FlushBypass marks a unit that never touched the accumulation buffer at // all (Story 2.1.2) — a control/quiescence message sent via Bypass. FlushBypass )
func (FlushReason) String ¶
func (r FlushReason) String() string
String renders FlushReason for logging.
type HubLifecycleState ¶
type HubLifecycleState int
HubLifecycleState is the exhaustive set of states a StreamHub can be in. Every switch over it must include a default: panic("unhandled HubLifecycleState") case so a new state can't silently fall through unhandled logic (enforced by the `exhaustive` linter).
const ( // HubStarting is the state between hub creation and its first // successful subscriber attach / control-mode start. HubStarting HubLifecycleState = iota // HubActive is the normal operating state: at least one subscriber is // attached and the hub is forwarding output. HubActive // HubDraining is entered when the last subscriber detaches; the hub // waits out a grace period before tearing down, so a brief reconnect // doesn't kill control mode. HubDraining // HubTornDown is the terminal state: control mode has been stopped and // the hub is no longer usable. HubTornDown )
type HubOption ¶
type HubOption func(*StreamHub)
HubOption configures a StreamHub at construction time. Tests use these to shrink buffer sizes and grace periods so lifecycle/eviction assertions run in milliseconds instead of seconds; production callers can rely on the zero-value defaults below.
func WithBatchMaxWindow ¶
WithBatchMaxWindow overrides the hub's BatchWindow ceiling (default: MaxBatchWindow, 20ms) — tests use this to shrink it so ceiling-flush assertions run in milliseconds.
func WithQuiescenceQuietPeriod ¶
WithQuiescenceQuietPeriod overrides how long a resize's quiescence-wait must see no update before declaring the reflow settled (default: 200ms).
func WithQuiescenceTimeout ¶
WithQuiescenceTimeout overrides the hard deadline a resize's quiescence-wait gives up at (default: 500ms, matching the pre-hub per-connection behavior). Tests use this to shrink the deadline so timeout-path assertions run in milliseconds.
func WithSlowSubscriberGrace ¶
WithSlowSubscriberGrace overrides how long a full outbound queue is tolerated before the subscriber is evicted.
func WithSubscriberBufferSize ¶
WithSubscriberBufferSize overrides the outbound queue depth for every subscriber attached after this option is applied.
func WithTeardownGrace ¶
WithTeardownGrace overrides how long the hub waits after its last subscriber detaches before tearing down.
type HubSequenceNumber ¶
type HubSequenceNumber uint64
HubSequenceNumber is a monotonic, per-hub ordinal stamped on every broadcast unit (a flushed BatchWindow frame or a Bypass control message) at the moment it is actually broadcast, not when it was first buffered — giving every Subscriber the same total order regardless of its own flush cadence (plan.md's Domain Glossary; Story 2.1.2's AC).
type MemoryTransport ¶
type MemoryTransport struct {
// contains filtered or unexported fields
}
MemoryTransport is an in-process Transport implementation (Story 1.4.1): it lets hub tests exercise attach/broadcast/eviction behavior without a real tmux process or network socket, the required Testability NFR. It records every frame it receives and can be configured, via With* options, to block or error on Send so tests can deterministically drive the slow-subscriber and Transport.Send-error eviction paths (Story 1.2.1, Story 1.4.2) instead of relying on real network timing.
func NewMemoryTransport ¶
func NewMemoryTransport(opts ...MemoryTransportOption) *MemoryTransport
NewMemoryTransport returns a MemoryTransport configured by opts. With no options, Send always succeeds immediately and records the frame.
func (*MemoryTransport) Close ¶
func (m *MemoryTransport) Close() error
Close implements Transport. It releases any Send call blocked by WithBlockingSend (treated the same as an explicit Unblock) and is safe to call more than once.
func (*MemoryTransport) IsClosed ¶
func (m *MemoryTransport) IsClosed() bool
IsClosed reports whether Close has been called.
func (*MemoryTransport) ReceivedFrames ¶
func (m *MemoryTransport) ReceivedFrames() [][]byte
ReceivedFrames returns a copy of every frame successfully delivered via Send so far, in delivery order.
func (*MemoryTransport) Send ¶
func (m *MemoryTransport) Send(data []byte) error
Send implements Transport. Behavior depends on how the transport was constructed: WithErrorSend returns its configured error immediately; WithBlockingSend blocks until Unblock or Close is called; otherwise the frame is recorded and Send returns nil.
func (*MemoryTransport) Unblock ¶
func (m *MemoryTransport) Unblock()
Unblock releases any Send call currently blocked by WithBlockingSend. It is safe to call even if no Send is currently blocked, and safe to call more than once.
type MemoryTransportOption ¶
type MemoryTransportOption func(*MemoryTransport)
MemoryTransportOption configures a MemoryTransport at construction time.
func WithBlockingSend ¶
func WithBlockingSend() MemoryTransportOption
WithBlockingSend makes every Send call block until Unblock is called (or the transport is closed), simulating a stalled writer — e.g. a dead network connection whose write never returns — so tests can deterministically exercise the slow-subscriber eviction path.
func WithErrorSend ¶
func WithErrorSend(err error) MemoryTransportOption
WithErrorSend makes every Send call return err instead of recording the frame, so tests can exercise the Transport.Send-error eviction path.
type ResizeVote ¶
type ResizeVote struct {
SubscriberID SubscriberID
Size TerminalSize
}
ResizeVote is a {SubscriberID, TerminalSize} tuple submitted by a capability-eligible Subscriber toward the hub's NegotiatedSize (Task 1.3.1b).
type SessionController ¶
type SessionController interface {
// SetWindowSize propagates a negotiated resize to the underlying tmux
// session. StreamHub.applyNegotiatedSize is its sole caller.
SetWindowSize(cols, rows int) error
// ResizePTY resizes the terminal dimensions, mirroring
// *session.Instance.ResizePTY's cols/rows nudge pattern. Part of the
// interface per Task 1.3.2a; Epic 1.3's resize pipeline does not call it
// (that call site is the attach-time handshake nudge in
// server/services/connectrpc_websocket.go, left in place until a later
// phase migrates it onto the hub).
ResizePTY(cols, rows int) error
// CapturePaneContent captures the current visible pane content. The hub
// calls this exactly once per resize, after quiescence is reached, and
// broadcasts the result to every attached subscriber.
CapturePaneContent() (string, error)
// StopControlMode stops the control-mode stream. Called exactly once by
// StreamHub.ForceTeardown.
StopControlMode() error
// SubscribeControlModeUpdates registers a consumer of raw control-mode
// output for this session, returning a subscriber ID and a read-only
// channel of raw frames. StreamHub.applyNegotiatedSize uses this to
// detect quiescence after a resize.
SubscribeControlModeUpdates() (string, <-chan []byte)
// UnsubscribeControlModeUpdates removes a subscription by the ID
// returned from SubscribeControlModeUpdates. Implementations must close
// the corresponding channel so a range loop reading from it can exit
// without leaking.
UnsubscribeControlModeUpdates(id string)
}
SessionController is the narrow interface StreamHub depends on for resize/quiescence/capture/teardown, instead of the concrete *session.Instance type (Task 1.3.2a). session/streamhub never imports package session; *session.Instance satisfies SessionController structurally, which is what keeps package session's own dependency on session/streamhub (for StreamOwnershipLock, Story 3.1.2) a safe one-way edge rather than an import cycle — see plan.md's SessionController Pattern Decisions entry.
Scoped to exactly the six *session.Instance methods it mirrors: session/instance_tmux.go's ResizePTY (:587), CapturePaneContent (:600), StopControlMode (:727), SubscribeControlModeUpdates (:733), UnsubscribeControlModeUpdates (:738), and SetWindowSize (:752).
type StreamHub ¶
type StreamHub struct {
// contains filtered or unexported fields
}
StreamHub is the single-owner runtime object for one tmux session's output stream: it fans output out to every attached subscriber over a Transport-agnostic interface, and is the sole caller of that session's resize/quiescence/capture-pane surface (Epic 1.3) via SessionController. Epic 1.2 implements the subscriber registry, fan-out, and lifecycle/teardown that this builds on.
func NewStreamHub ¶
func NewStreamHub(sessionName string, controller SessionController, opts ...HubOption) *StreamHub
NewStreamHub constructs a StreamHub for one tmux session, starting in HubStarting state with zero subscribers. controller is used by ForceTeardown and the resize/quiescence/capture pipeline; it may be nil in tests that never exercise either.
func (*StreamHub) AttachSubscriber ¶
func (h *StreamHub) AttachSubscriber(transport Transport, capability SubscriberCapability) SubscriberID
AttachSubscriber registers a new subscriber backed by transport, starts its writer goroutine, and returns its SubscriberID. Attaching during HubDraining cancels the pending teardown (Task 1.2.2a) and returns the hub to HubActive; attaching to a HubTornDown hub also reactivates it, since streamhub itself has no opinion on whether that's a legitimate reattach — that policy belongs to HubRegistry (Epic 3).
func (*StreamHub) Broadcast ¶
Broadcast fans data out to every attached subscriber via a non-blocking send. A subscriber whose outbound queue is observed full is never blocked on: the first observation arms a one-shot grace-period timer, independent of whether further frames are broadcast, and eviction (Task 1.2.1e) fires only if the queue is still full when that timer expires — a subscriber that merely had a momentary burst and drained in time is never evicted or re-armed for the same stall.
func (*StreamHub) DetachSubscriber ¶
func (h *StreamHub) DetachSubscriber(id SubscriberID)
DetachSubscriber removes the subscriber and stops its writer goroutine without leaking it. If this was the last subscriber, the hub schedules teardown after its grace period rather than tearing down immediately (Story 1.2.2). Detaching an unknown SubscriberID is a no-op.
func (*StreamHub) ForceTeardown ¶
ForceTeardown tears the hub down unconditionally: every remaining subscriber is closed, the hub transitions to HubTornDown, and SessionController.StopControlMode() is invoked exactly once. It is the single teardown code path reached both by grace-period expiry (onTeardownGraceExpired) and by an external trigger such as the flag-flipped-back-to-legacy case Story 3.1.2 wires up. Calling it more than once (including concurrently) is safe — StopControlMode fires only for the caller that wins the HubTornDown transition.
func (*StreamHub) NegotiatedSize ¶
func (h *StreamHub) NegotiatedSize() TerminalSize
NegotiatedSize returns the hub's current resolved TerminalSize: the component-wise minimum across every CanResize subscriber that has ever called RequestResize (ADR-002's smallest-common-size model). It is the zero TerminalSize until the first accepted vote.
func (*StreamHub) OnRawOutput ¶
OnRawOutput is StreamHub's gate for raw tmux output once a caller (the production wiring that connects a StreamHub to its tmux session, outside Epic 1.3's scope) drives it with SessionController.SubscribeControlModeUpdates frames. It always counts the arrival toward quiescence via the resizing flag's readers, but suppresses broadcasting the frame itself while a resize is in progress (Task 1.3.2d) — mirroring server/services/connectrpc_websocket.go's existing resizeSettling-gated forwarding loop, which also drops rather than replays suppressed frames, relying on the post-quiescence capture-pane snapshot (applyNegotiatedSize) to bring every subscriber back in sync instead. Frames that pass the resize gate are fed into the hub's single BatchWindow (Epic 2.1) rather than broadcast directly, so N attached subscribers share one accumulation/coalesce pass per burst instead of each running their own.
func (*StreamHub) RequestResize ¶
func (h *StreamHub) RequestResize(id SubscriberID, size TerminalSize)
RequestResize records subscriber id's vote for size and re-runs negotiation, but only if that subscriber was attached with SubscriberCapability.CanResize == true (Task 1.3.1c) — a read-only sink's vote is rejected and logged, never applied. Requesting resize for an unknown SubscriberID is a no-op.
func (*StreamHub) SlowSubscriberDropsTotal ¶
SlowSubscriberDropsTotal returns the number of times a subscriber has been evicted for staying slow past its grace period — incremented exactly once per eviction, never once per dropped frame. See the field doc comment on StreamHub for scoping notes (Epic 3.2 owns wiring this to a real metrics backend).
func (*StreamHub) State ¶
func (h *StreamHub) State() HubLifecycleState
State returns the hub's current HubLifecycleState.
func (*StreamHub) SubscriberCount ¶
SubscriberCount returns the number of currently attached subscribers.
func (*StreamHub) TryFlush ¶
func (h *StreamHub) TryFlush()
TryFlush flushes the hub's BatchWindow immediately if anything is pending, with FlushOpportunistic as the reason. This is the hub-level hook a raw output feed (pumpControlModeOutputIntoHub) calls once it has drained every frame immediately available on its subscription channel — mirroring the `default: break coalesce` point in server/services/connectrpc_websocket.go's legacy per-connection coalesce loop, so a hub-owned burst is not forced to always pay MaxBatchWindow's full ceiling latency when the feed momentarily has nothing left to drain. A no-op if nothing is buffered (e.g. a resize is in progress and OnRawOutput has been suppressing every frame).
type StreamOwnershipLock ¶
type StreamOwnershipLock struct {
// contains filtered or unexported fields
}
StreamOwnershipLock resolves STAPLER_SQUAD_USE_STREAM_HUB (plus any future per-session override, Story 3.3.1) into a StreamPath exactly once per tmux session, at the first connection's attach time, and sticks that resolution for the rest of the session's lifetime — see ADR-003. A per-connection re-read (Epic 2.2's useStreamHub placeholder) would let a flag flip mid-rollout split one session across two owners; this type closes that window by construction rather than by convention, the same safety property session/tmux.TmuxSession.controlModeStartMu gives the legacy 0->1 refcount transition.
Story 3.1.2 widens this into the shared mutual-exclusion primitive both legacy StartControlMode and hub creation acquire; today it only owns the resolve-once-and-cache behavior (Story 3.1.1).
func AcquireOwnershipLock ¶
func AcquireOwnershipLock(sessionName string) *StreamOwnershipLock
AcquireOwnershipLock returns the StreamOwnershipLock for sessionName, creating it if this is the first call for that name. Every caller passing the same sessionName gets the same *StreamOwnershipLock instance, which is what makes the mutual exclusion in Story 3.1.2 possible.
func (*StreamOwnershipLock) AcquireAndResolve ¶
func (l *StreamOwnershipLock) AcquireAndResolve(flagValue bool, fn func(StreamPath) error) error
AcquireAndResolve holds this lock's mutex for the full duration of fn, resolving flagValue into this session's sticky StreamPath first (identical semantics to Resolve) and passing it to fn before releasing. Unlike Resolve/ResolveExpecting — which only hold the mutex across the cheap resolve-and-cache step, then let the caller act on the result unsynchronized — AcquireAndResolve is Story 3.1.2's actual mutual-exclusion primitive: Instance.StartControlMode and HubRegistry.GetOrCreate both call it (not just Resolve) around their own side effect (starting the control-mode subprocess, or creating the hub), so a concurrent GetOrCreate genuinely blocks on an in-flight StartControlMode for the same session (and vice versa) instead of both racing to resolve first and only checking the outcome afterward. This closes the gap where mutual exclusion held only for callers that remembered to check ownership before proceeding — StartControlMode now enforces it unconditionally for every caller, present or future.
func (*StreamOwnershipLock) AcquireAndResolveExpecting ¶
func (l *StreamOwnershipLock) AcquireAndResolveExpecting(flagValue bool, want StreamPath, fn func() error) error
AcquireAndResolveExpecting is AcquireAndResolve plus ResolveExpecting's want-path assertion: fn only runs if the resolved path matches want: otherwise AcquireAndResolveExpecting returns ErrOwnershipResolvedToOtherPath without running fn, exactly like ResolveExpecting's error contract, but with the same held-lock guarantee AcquireAndResolve provides.
func (*StreamOwnershipLock) Resolve ¶
func (l *StreamOwnershipLock) Resolve(flagValue bool) StreamPath
Resolve turns flagValue into a StreamPath the first time it is called on this lock, caches that result, and returns the cached StreamPath on every subsequent call — regardless of what flagValue later callers pass. This is what makes a flag flip mid-rollout safe: the first connection's resolution sticks for the session's lifetime, so a second connection arriving after the flag changed in the environment still observes the original decision.
Story 3.3.1: before falling back to flagValue (the global default), Resolve consults sessionOverrideLookup for this lock's own session name. A recorded override forcing PathHubOwned wins regardless of flagValue — the per-session canary mechanism — but never overrides an already-sticky resolution, same as the global flag.
func (*StreamOwnershipLock) ResolveExpecting ¶
func (l *StreamOwnershipLock) ResolveExpecting(flagValue bool, want StreamPath) (StreamPath, error)
ResolveExpecting resolves the lock exactly like Resolve, but additionally asserts that the resolved StreamPath matches want — the caller's own intended role (hub creation expects PathHubOwned; legacy StartControlMode expects PathLegacyPerConnection). If another caller's resolution already won and it disagrees with want, ResolveExpecting returns the actual resolved path plus ErrOwnershipResolvedToOtherPath instead of proceeding, so HubRegistry.GetOrCreate and the legacy per-connection entry point (both in package server/services, which cannot itself hold this lock's internal state) can refuse to create a competing owner and instead join whichever path actually won (Story 3.1.2 / Task 3.1.2b).
type StreamPath ¶
type StreamPath int
StreamPath identifies which of the two mutually-exclusive ownership models a tmux session's stream is resolved to. Exactly two values exist; adding a third without updating every switch over StreamPath fails the `exhaustive` linter check.
const ( // PathLegacyPerConnection is the pre-existing model where each // connection owns its own resize/quiescence/capture pipeline. PathLegacyPerConnection StreamPath = iota // PathHubOwned is the new model where a single StreamHub owns // resize/quiescence/capture for all of a session's subscribers. PathHubOwned )
type SubscriberCapability ¶
type SubscriberCapability struct {
// CanResize allows the subscriber's RequestResize votes to count toward
// NegotiatedSize. A read-only sink (e.g. a passive log tailer) sets this
// false so it can never influence the pane's dimensions.
CanResize bool
// CanWrite allows the subscriber to send input to the session.
CanWrite bool
}
SubscriberCapability describes what a Subscriber is permitted to do against the hub, independent of its Transport.
type SubscriberID ¶
type SubscriberID string
SubscriberID uniquely identifies one attached Subscriber. It is a newtype over string so a raw string can't be passed where a SubscriberID is expected without an explicit conversion.
func NewSubscriberID ¶
func NewSubscriberID() SubscriberID
NewSubscriberID returns a new, globally-unique SubscriberID.
type TerminalSize ¶
type TerminalSize struct {
// contains filtered or unexported fields
}
TerminalSize is a validated {cols, rows} pane-dimension pair — the single shared representation used by RequestResize, ResizeVote, and NegotiatedSize instead of three independently-inlined shapes (or a bare (cols, rows int) pair a caller could silently transpose). Construct only via NewTerminalSize; the zero value is never returned by it and is not a valid size (per primitive-obsession-checklist.md and type-driven-design).
func NewTerminalSize ¶
func NewTerminalSize(cols, rows int) (TerminalSize, error)
NewTerminalSize validates that cols and rows are both positive and returns the resulting TerminalSize, or a non-nil error and the zero TerminalSize if either dimension is non-positive.
func (TerminalSize) Cols ¶
func (t TerminalSize) Cols() int
Cols returns the terminal's column count.
type Transport ¶
type Transport interface {
// Send delivers a frame of output bytes to the subscriber. A non-nil
// error is treated as a delivery failure and results in the subscriber
// being evicted, the same as a slow/blocked subscriber.
Send(data []byte) error
// Close releases any resources held by the transport.
Close() error
}
Transport is the delivery mechanism a Subscriber uses to receive hub output and be torn down. Implementations decouple the hub from any specific wire protocol (WebSocket, ssq-mux, in-memory test double).