Documentation
¶
Index ¶
- Constants
- Variables
- func NewContextReader(ctx context.Context, r io.Reader) io.Reader
- func PlainText(s string) string
- type AsyncWriter
- func (a *AsyncWriter) AbortIfNoProgress(since uint64, err error) bool
- func (a *AsyncWriter) Backlog() Backlog
- func (a *AsyncWriter) Close() error
- func (a *AsyncWriter) Closed() bool
- func (a *AsyncWriter) Drained() (<-chan struct{}, bool)
- func (a *AsyncWriter) Err() error
- func (a *AsyncWriter) Flush(ctx context.Context) error
- func (a *AsyncWriter) Write(p []byte) (int, error)
- type Backlog
- type Capture
- type Emulation
- type Flusher
- type ModeTracker
- func (m *ModeTracker) AtGround() bool
- func (m *ModeTracker) Clone() *ModeTracker
- func (m *ModeTracker) InSequence() bool
- func (m *ModeTracker) NonASCIICharset() bool
- func (m *ModeTracker) RawC1() byte
- func (m *ModeTracker) ReplayParts() ReplayParts
- func (m *ModeTracker) Resized(size termsize.Size)
- func (m *ModeTracker) Restore() []byte
- func (m *ModeTracker) Snapshot() []byte
- func (m *ModeTracker) UTF8Pending() int
- func (m *ModeTracker) UTF8Prefix() string
- func (m *ModeTracker) Write(p []byte) (int, error)
- type MultiWriter
- func (t *MultiWriter) Append(writers ...io.Writer) error
- func (t *MultiWriter) AppendSized(ctx context.Context, deadline time.Time, size termsize.Size, w io.Writer) error
- func (t *MultiWriter) Pinned(size termsize.Size)
- func (t *MultiWriter) Remove(writers ...io.Writer)
- func (t *MultiWriter) ResizeBegin(size termsize.Size) uint64
- func (t *MultiWriter) ResizeEnd(seq uint64, applied bool)
- func (t *MultiWriter) SetReplayer(r Replayer, fallback func(error))
- func (t *MultiWriter) Shutdown(ctx context.Context) error
- func (t *MultiWriter) Write(p []byte) (int, error)
- type ReplayPanic
- type ReplayParts
- type Replayer
- type ResetTarget
- type SizeEvent
- type StallWriter
- type TerminalQueryFilter
Constants ¶
const ( // DefaultGuestBufferSize bounds how much output may sit in pending before a // guest is dropped; the worst case for undelivered output is this plus one // in-flight maxDrainChunk. A guest merely slower than whatever paces the // session — the primary host terminal, or with none the earliest guest — // rather than stalled, accrues backlog at the difference between that pace // and its own drain rate, and a sustained mismatch exhausts any finite cap: // this number sets how long a slow guest survives, not whether. The pacing // guest adds no backlog while it paces, since the pty is held to its // drain, but it can inherit one of up to the cap when it takes over from // a primary or an earlier pacer, and output after pacing is released at // exit is unpaced. Total slack is roughly 5 MiB, the cap plus a 2 MiB // SSH window on each of the two legs between host and guest. The buffer // exists to decouple the fan-out from a blocking write, not to store the // session. DefaultGuestBufferSize = 1 << 20 // 1 MiB )
const DefaultReplayBytes = 256 << 10
DefaultReplayBytes bounds the replay ring handed to a joining writer.
It must stay well under DefaultGuestBufferSize: Append replays into a joining writer before attaching it, so a ring at or above a guest's sink size would overflow that guest with its own replay and drop it at the door.
const ResetFlushTimeout = time.Second
ResetFlushTimeout bounds how long Shutdown waits for a member's reset to be delivered once its tail has been. Shutdown can therefore return this much past its context's deadline. A reset is a few hundred bytes at most, but the tail may have used up the guest's SSH window, and then the reset waits a round trip for the window to open again: so a whole guest flush's bound, the one the host gives a guest's tail, not a guess at how fast a small write is.
Variables ¶
var ( // ErrOverflow is returned once a sink has passed its cap. A guest that // cannot receive output is not attached in any useful sense, so the sink // fails permanently rather than dropping bytes out of the middle of a // terminal stream, which the guest could not detect. ErrOverflow = errors.New("asyncwriter: buffer overflow") // ErrWriterClosed is returned by Write after Close. ErrWriterClosed = errors.New("asyncwriter: closed") // ErrStalled is the error a caller enforcing a delivery deadline passes to // AbortIfNoProgress. It becomes Err() and reaches onDrop like any other // failure. ErrStalled = errors.New("asyncwriter: no progress within the stall timeout") )
var ( ErrReplayGapEvicted = errors.New("multiwriter: the output since the handoff was evicted") ErrReplayMarginsUncertain = errors.New("multiwriter: the tracker's margins are uncertain") ErrReplayDeadline = errors.New("multiwriter: the replay ran out of time") )
Why a join that could have been emulated fell back to the raw ring, besides ErrReplayCoverage and whatever the replayer itself reports. ErrReplayDeadline is the emulation running out of time; a join that ran out of time waiting for something else reports it wrapped, with what it waited for.
var ErrClosed = errors.New("multiwriter: closed to new writers")
ErrClosed is returned by Append once Shutdown has run. A guest that reaches the door as the session is ending is refused rather than attached to a fan-out nothing will flush again.
var ErrReplayCoverage = errors.New("multiwriter: the ring's sizes aren't all known")
ErrReplayCoverage is why a join can't emulate the ring: the size log can't name the size some of its bytes were written at, and no size is ever assumed.
var ReplayBudget = time.Second
ReplayBudget is how long a join may spend reconstructing the screen before it falls back to the raw ring: the doors take a join's deadline this far from when it begins. This is startup configuration: set it before serving sessions; each server copies it once. Non-positive values allow no emulation.
Functions ¶
func PlainText ¶ added in v0.39.0
PlainText is s as text a terminal draws and never acts on: every control character (0x00-0x1F, DEL and 0x80-0x9F) and every byte that isn't UTF-8 comes out as '?'. It is for text someone else chose that is printed into a terminal, such as a guest's SSH version, which its client sends as it likes: written raw, an escape sequence in it would be the terminal's to act on.
Types ¶
type AsyncWriter ¶ added in v0.27.0
type AsyncWriter struct {
// contains filtered or unexported fields
}
AsyncWriter delivers to one writer from one goroutine, so writing to it never blocks on that writer's I/O.
It exists because the host fans its pty output out to every guest serially: a guest that stops reading its SSH channel blocks in Write once the channel window fills, and holds up every writer behind it, including the host's own terminal. Handing off into a bounded buffer means the slowest viewer no longer sets the pace. See owenthereal/upterm#524.
The buffer is bounded in bytes rather than in writes, because pty writes run from one byte to io.Copy's 32 KiB and a count of writes is therefore not a bound on memory at all.
func NewAsyncWriter ¶ added in v0.27.0
func NewAsyncWriter(w io.Writer, max int, onDrop func(error)) *AsyncWriter
NewAsyncWriter starts delivery to w immediately. max bounds the payload held undelivered; onDrop, which may be nil, is called once if the sink dies, always on its own goroutine so the caller of Write is never held up by it.
The caller owns w. Close stops the goroutine but does not close w.
func (*AsyncWriter) AbortIfNoProgress ¶ added in v0.33.0
func (a *AsyncWriter) AbortIfNoProgress(since uint64, err error) bool
AbortIfNoProgress fails the writer, exactly as an overflow does — sticky error, onDrop once, on its own goroutine — but only if Delivered still equals since. It reports whether it aborted. The check and the failure happen under one lock, so a piece delivered after the caller's last snapshot always wins.
A nil err aborts with ErrStalled: a writer counts as failed only once it holds an error, so failing it with nil would discard the queue and spend onDrop while leaving it live.
func (*AsyncWriter) Backlog ¶ added in v0.33.0
func (a *AsyncWriter) Backlog() Backlog
Backlog reports how much output this writer is carrying and how far delivery has gotten, for a caller pacing on it rather than merely waiting for it to catch up.
func (*AsyncWriter) Close ¶ added in v0.27.0
func (a *AsyncWriter) Close() error
Close stops accepting writes and signals the drain. It deliberately does not wait for the goroutine to exit: in the case this type exists for, that goroutine is blocked in a write that only session teardown will release, and waiting here would deadlock exactly then. The goroutine exits when its in-flight write returns.
Close always returns nil and is safe to call repeatedly. Pending bytes are discarded; MultiWriter.Shutdown is the path that delivers a tail.
func (*AsyncWriter) Closed ¶ added in v0.30.0
func (a *AsyncWriter) Closed() bool
Closed reports whether Close has been called.
Like Err, it is the other half of what Flush does not say: Flush returns nil for a closed sink too, and a caller that needs to know whether what it wrote was delivered rather than discarded has to ask.
func (*AsyncWriter) Drained ¶ added in v0.30.0
func (a *AsyncWriter) Drained() (<-chan struct{}, bool)
Drained returns a channel closed the next time delivery catches up: the buffer empties and the last write returns. It is Flush for a caller that wants to be told rather than to wait — one holding a lock, or one with nothing to do until the news arrives.
The second return is false for a writer that has already failed or been closed, and there is nothing else it could be: the signal those publish is the one the writer had at the time, and the channel that replaces it is never closed by anything. Answered under the lock the two of them take, so a caller that is told true holds a channel that failing and closing will still close.
func (*AsyncWriter) Err ¶ added in v0.30.0
func (a *AsyncWriter) Err() error
Err reports the error that ended delivery, if any.
Flush cannot answer this: it reports nil for a sink that has already failed, because a guest that is gone is not a shutdown error. A caller that needs to know whether everything written was actually delivered — rather than merely that nothing more is coming — has to ask here as well.
func (*AsyncWriter) Flush ¶ added in v0.27.0
func (a *AsyncWriter) Flush(ctx context.Context) error
Flush waits until everything written so far has been delivered, or until ctx is done.
It waits on the idle signal the drain publishes once it has emptied the buffer and its last write has returned, rather than polling, and it honours ctx so that one stuck guest cannot hold up the host's exit. A dead or closed sink flushes to nil: there is nothing left to deliver and a guest that is already gone is not a shutdown error.
func (*AsyncWriter) Write ¶ added in v0.27.0
func (a *AsyncWriter) Write(p []byte) (int, error)
Write copies p into the pending buffer and returns. It never performs I/O and never blocks on the underlying writer.
It reports overflow as an error so that MultiWriter, which already drops a writer that fails, removes this one without needing to know anything about buffering.
type Backlog ¶ added in v0.33.0
type Backlog struct {
Bytes int // pending plus the undelivered rest of the chunk in flight
Delivered uint64 // total bytes delivered; advances with every piece
Progress <-chan struct{} // closed after the next delivered piece, or on fail or close
Live bool // false once failed or closed
}
Backlog reports what a caller pacing on this writer needs to know: how much output is not yet delivered, how far delivery has gotten, and a channel to wait on for the next change.
type Capture ¶ added in v0.39.0
type Capture struct {
Modes *ModeTracker // a clone: the state as of the ring's first byte
Chunks [][]byte // the stream from Start to End, immutable once handed out
Sizes []SizeEvent // the committed events covering Chunks, in Seq order, the first in effect at Start
Start, End uint64 // stream offsets of Chunks
Pinned bool // the pty keeps its size for good
}
Capture is what a join takes of the fan-out, under one hold of writeMu, to emulate outside it.
type Emulation ¶ added in v0.39.0
type Emulation interface {
// Feed continues the emulation over more of the stream: chunks run on
// from the end of everything offered so far, and sizes are the committed
// events after the last one offered.
Feed(chunks [][]byte, sizes []SizeEvent) error
// Handoff is the point the render reconstructs: the stream offset of the
// end of what the emulator consumed, where both it and the tracker clone
// are outside every sequence, and the Seq of the last event it has
// passed, applied or skipped as the size it already had. Bytes offered
// after offset were never consumed, nor the events among them.
Handoff() (offset, lastSeq uint64)
// Render is the reconstruction as of Handoff.
Render() ([]byte, error)
}
Emulation is one joiner's reconstruction, in progress.
type Flusher ¶ added in v0.27.0
Flusher is implemented by attached writers that deliver asynchronously and so can still be holding output when the producer stops.
type ModeTracker ¶ added in v0.30.0
type ModeTracker struct {
// contains filtered or unexported fields
}
ModeTracker watches terminal output and remembers the modes set on it, so a reader joining later can be put into the same modes before it is handed the replay ring.
It is deliberately not a terminal emulator: it reconstructs no screen and no cursor position. Anything it does not recognise passes through unrecorded.
Not safe for concurrent use, Clone included. MultiWriter owns one and only touches it under writeMu, which is where a capture clones it too.
func NewModeTracker ¶ added in v0.30.0
func NewModeTracker() *ModeTracker
func (*ModeTracker) AtGround ¶ added in v0.39.0
func (m *ModeTracker) AtGround() bool
AtGround reports that the tracker is outside every sequence: no partial, and no sequence overflowed past its bound either.
func (*ModeTracker) Clone ¶ added in v0.39.0
func (m *ModeTracker) Clone() *ModeTracker
Clone returns an independent copy: writing to either leaves the other's Snapshot and Restore unchanged. What RawC1 reports starts afresh: it is of the bytes written to the clone.
A caller that needs the snapshot as of a later point in the ring than its first byte gets it by feeding a clone the ring's bytes up to there. The tracker itself has to go on describing the ring's first byte, so the copy is deep: the parser appends to its slices in place and rewrites a key stack's top entry in place, the margin candidates are deleted from and rewritten in place, and a shared backing array or map would carry the clone's writes back into the original, and the original's into the clone.
func (*ModeTracker) InSequence ¶ added in v0.39.0
func (m *ModeTracker) InSequence() bool
InSequence reports whether the output seen so far may have left the terminal inside an escape sequence, where text written now would be read as part of it: after ESC; in an ESC sequence still waiting for its final byte (any intermediate 0x20-0x2F: ESC ( ) * + - . / # % SP, of which the parser completes only "("; or a control character or DEL, which a terminal executes or ignores there without ending the sequence); in a CSI; in an OSC, DCS, PM, APC or SOS string; in a CSI or string begun after such a control, which a DEC parser begins there and the tracker's own parser reads as text; after a string the program ended ambiguously -- by CAN, SUB or an ESC that isn't ST -- which a DEC parser ends there but kitty and tmux keep open (see TerminalQueryFilter); in a window title, ESC k, which tmux and GNU screen read as a string; inside a UTF-8 character whose continuation bytes haven't all come; or after a single shift, SS2 (ESC N) or SS3 (ESC O), whose character hasn't come, which would draw the first character written now from G2 or G3. An ambiguous string, and a title, stay "inside" until a terminator every terminal shares: ST (ESC \), or BEL for an OSC. An ESC \ whose ESC is the second of a pair is no ST: tmux escapes an ESC inside its passthrough by doubling it.
It is AtGround's cautious form, for a writer that must not land inside the program's sequence on any terminal. The tracker's own mode bookkeeping keeps the DEC reading: only this is conservative.
func (*ModeTracker) NonASCIICharset ¶ added in v0.39.0
func (m *ModeTracker) NonASCIICharset() bool
NonASCIICharset reports whether text written now would be drawn in a character set other than ASCII: G0 designated as anything but ESC ( B as written (ESC ( 0, DEC Special Graphics, draws letters as line pieces, and a national set swaps some of them; a set with an intermediate of its own, a multibyte one, or ESC ( B with a control inside it is taken as not ASCII too), or a locking shift that put G1, G2 or G3 in use -- SO, LS2 (ESC n) or LS3 (ESC o) -- and no shift-in (SI) since, whatever those sets hold. A RIS straight after its ESC puts both back; a DECSTR isn't taken as doing so, since tmux ignores it. A cursor restore can only raise it, from one copy of the saved sets that both screens share. Text written there is not inside a sequence, but it isn't read as the text it is either.
SO and SI are taken wherever a terminal executes them, which is everywhere but inside a string: an OSC, PM, APC or SOS ignores them, and a DCS hands them to its handler. A string ended ambiguously counts as one until it is over, for whatever would put ASCII back -- an SI, an ESC ( B or a RIS: a terminal that keeps the string open takes it as the string's, so the graphics are kept through it. What would leave ASCII is taken there all the same.
func (*ModeTracker) RawC1 ¶ added in v0.39.0
func (m *ModeTracker) RawC1() byte
RawC1 reports the first byte from 0x80 to 0x9F written to this tracker, since it was made or cloned, that doesn't continue a UTF-8 character, in any state, or 0 for none. A terminal that reads UTF-8 draws such a byte as a replacement character, and one that reads 8-bit controls takes it for a C1 control, an 8-bit introducer or ST among them; the tracker follows neither, and only notes it.
func (*ModeTracker) ReplayParts ¶ added in v0.39.0
func (m *ModeTracker) ReplayParts() ReplayParts
ReplayParts returns Snapshot's pieces but for the region, the charset and the partial, which a reconstruction gives in places of its own: Modes, with DECAWM put back in its place when it is off, then MainKeys, then either the switch through AltVia and AltKeys, or HiddenAltKeys, which Snapshot pushes on a trip to the alternate screen and back. The render builds its own trip, so the pushes come alone.
It doesn't carry the normal screen's saved G0 either, which Snapshot designates and saves ahead of the switch to the alternate screen, with a ?1049h or an ESC 7, nor DECLRMM (?69), which Snapshot replays after the modes.
func (*ModeTracker) Resized ¶ added in v0.39.0
func (m *ModeTracker) Resized(size termsize.Size)
Resized records the terminal's new size and resets the margin region, as a terminal resets it on a resize. The size is what DECSTBM is decided against. Exported: the replay package's clone hears resizes too.
It is a resize the terminal is known to have made, heard where it fell in the stream, so it settles the candidates: whichever crossed resize committed last, this one came after it, and their outcomes change nothing from here.
func (*ModeTracker) Restore ¶ added in v0.30.0
func (m *ModeTracker) Restore() []byte
Restore returns the bytes that put a terminal this tracker has been watching back where it started. It is Snapshot's opposite: Snapshot is for the terminal joining a session, this is for the terminal leaving one.
A terminal is left in whatever modes the session put it in, and restoring the termios settings says nothing about them: a full-screen program that was on screen when its viewer detached leaves that viewer's shell drawing on the alternate screen, with no cursor, with the mouse reporting clicks as input, and confined to the rows the program had chosen for itself. None of that is the shell's doing and none of it goes away on its own.
Only what was actually changed is undone, so a session that never touched a mode produces nothing at all — the same property Snapshot has, and the reason this is derived from the stream rather than being a fixed list of resets sent on the way out. Attributes are not tracked and so are not reset here.
"Where it started" means the terminal's power-on defaults, not the state the caller's own terminal was in before the attachment. That state is not knowable without asking the terminal for it — a DECRQM round-trip per mode, before attaching, racing the user's keystrokes on the same stdin, and unanswered by terminals that do not implement it. tmux, which has the same problem on detach, resets to defaults too rather than asking.
It costs less than it sounds like. At a shell prompt — which is what launches a CLI — every mode in restorable is already at its default except the ones the shell re-asserts on every prompt: measured here, zsh holds DECCKM and bracketed paste and emits both again at the next prompt, bash holds bracketed paste and does the same. So for the modes a caller actually holds, a reset is undone within one keystroke, and for the rest the default is what the caller had. What is left is a caller that is not a shell and keeps the mouse or the alternate screen while running a child, which is not how a program that shells out behaves.
Being inside a sequence is state too. Output can stop anywhere — between the chunks a suspend falls between, or at a session's last byte — and a terminal left inside a sequence reads whatever the shell prints next as the rest of it: an unfinished OSC swallows the prompt whole. CAN cancels a sequence from any state on the DEC parser terminals implement, and does nothing outside one.
The keyboard is state too. A program that asked for the kitty keyboard protocol or modifyOtherKeys and never put them back leaves the shell it returns to receiving Ctrl-letter keys, and with kitty's flags most others, as escape sequences it does not read. The kitty stacks are popped whole, in one pop each: a pop past the bottom empties a stack, which is where a terminal starts, so the depth this tracker recorded is safe to pop even on a terminal that was replayed fewer entries than that.
The order is the order a terminal has to receive it in: out of any sequence first, so the rest is not read as part of it; then the alternate screen's keyboard stack, popped while that screen is still the one showing, since a pop applies to the current screen's stack; then off the alternate screen, through the mode that entered it, since everything after that applies to the screen the terminal is going back to, the main screen's stack included.
func (*ModeTracker) Snapshot ¶ added in v0.30.0
func (m *ModeTracker) Snapshot() []byte
Snapshot returns the bytes that put a fresh terminal into the recorded modes. State already at the terminal's default is left out, so a session that never changed anything replays nothing. Its length is bounded by len(restorable) plus DECLRMM, the screen switch and a save ahead of it, the region, the charset (twice, on either side of the switch) and modifyOtherKeys, each one sequence of at most maxSequenceBytes parameters, two kitty keyboard stacks of kittyStackDepth entries, and one partial sequence. Only the partial is sized by the stream rather than by this file, and its own bound is maxSequenceBytes for a mode sequence or maxStringBytes for a string: the ceiling is a few kilobytes, not the hundreds of bytes everything else comes to.
The order is the order the stream would have had to use to reach this state: the margins, then the modes, then the normal screen's kitty keyboard stack and modifyOtherKeys, then the switch to the alternate screen and that screen's own keyboard stack, only while it is the one showing. While the normal screen is showing, the alternate screen's stack is replayed on a trip there and back, because a push applies only to the screen that is showing. The margins are the terminal's, so no switch moves them.
The region is defined while it is uncertain too, for the raw fallback, which replays it then; a capture never does, as it fails while the region is uncertain. It is the candidates' common region when they agree, which is the newest's; the newest surviving candidate's when they disagree, the likeliest, since a resize's ioctl rarely fails and so the newest crossed resize most likely committed last; and the fallback's while overflowed, the only one kept.
The state as of the ring's first byte includes being partway through a sequence, so the partial goes last, after the charset. The ring's first bytes then complete it on the joiner's terminal instead of printing as text -- which is what they did when the trim boundary fell inside a sequence and the snapshot said nothing about it.
Of the saved cursors, the normal screen's is replayed while the alternate screen shows: its G0 is designated and saved ahead of the switch, by the ?1049h replayed when one entered and by an ESC 7 otherwise. That save brackets the alternate screen's whole stay -- ?1049h to ?1049l, or ESC 7 ?47h to ?47l ESC 8 -- so it is routinely evicted while the restore is still to come. Either save also takes the joiner's cursor position and pen of the moment, which a snapshot can't set to the session's; a replayed ?1049h always did.
Every other save is a limit: a restore in the ring whose save was evicted -- ESC 8, ?1048l or CSI u after ESC 7, ?1048h or CSI s, on the screen showing -- reads a slot on the joiner that nothing filled, and gives ASCII where the session's gives what it saved. Those saves bracket a stretch of drawing on one screen, so it takes a ring's start falling inside one.
func (*ModeTracker) UTF8Pending ¶ added in v0.39.0
func (m *ModeTracker) UTF8Pending() int
UTF8Pending reports how many continuation bytes the last UTF-8 lead byte still waits for: the rest of a character the output so far has begun, which a terminal reads as that character's.
func (*ModeTracker) UTF8Prefix ¶ added in v0.39.0
func (m *ModeTracker) UTF8Prefix() string
UTF8Prefix returns the bytes already read of the unfinished character, or an empty string if none is pending. These bytes may be invalid UTF-8: UTF8Pending counts continuation-shaped bytes, without validating them.
type MultiWriter ¶
type MultiWriter struct {
// contains filtered or unexported fields
}
MultiWriter is a concurrent safe writer that allows appending/removing writers. Newly appended writers get the last write to preserve last output.
Attached writers are tracked by identity, so they must be comparable; see checkRemovable, which is how Append enforces it. NewMultiWriter does not, because it cannot report the error, so prefer Append.
func NewMultiWriter ¶
func NewMultiWriter(replayBytes int, writers ...io.Writer) *MultiWriter
NewMultiWriter returns a fan-out whose replay ring holds the most recent replayBytes bytes of output.
func (*MultiWriter) Append ¶
func (t *MultiWriter) Append(writers ...io.Writer) error
Append attaches writers, handing each the replay buffer first so it starts from recent output rather than mid-screen.
Both steps happen under writeMu, the fan-out lock, so attaching is atomic with respect to a Write: a joining writer gets the replay and every write after it, never a write that is also in its replay and never a gap between them. Holding the fan-out lock here is only safe because attached guest writers no longer block on I/O; under the old design it would have reintroduced the deadlock #523 removed.
func (*MultiWriter) AppendSized ¶ added in v0.39.0
func (t *MultiWriter) AppendSized(ctx context.Context, deadline time.Time, size termsize.Size, w io.Writer) error
AppendSized attaches w, a joiner whose terminal is size, handing it a reconstruction of the screen the ring drew rather than the ring itself, once the deadline allows and a replayer is installed. A viewer keeps Append's raw ring, and so does a join with no replayer, no time, or nothing in the ring to reconstruct.
The emulation runs outside every fan-out lock, so a Write never waits on it, and only the capture and the handoff hold writeMu. The capture takes the ring, a clone of the tracker and the sizes the ring was written at; while the emulation runs, the stream moves on. The handoff, under one hold of writeMu, then writes the render, the ring from the emulation's handoff point on, which it never consumed, and Pending, and attaches w: every byte written since the capture reaches w once. Until a hold finds the emulation has reached every resize logged, the join catches it up on what it missed, or waits outside the lock for what it is still missing.
A join that can't be emulated, for any of the reasons fallback is told, is attached by Append's path instead, and AppendSized returns nil. It returns ErrClosed once Shutdown has run, and ctx's error once the joiner has left, and then attaches nothing: on every path, the raw ones included, the hold of writeMu that would attach w is the one that finds ctx done. The check waits for nothing, so a joiner with nothing to emulate is attached as promptly as Append would.
func (*MultiWriter) Pinned ¶ added in v0.39.0
func (t *MultiWriter) Pinned(size termsize.Size)
Pinned records that the pty keeps size for good. Called once, at start, instead of any resize report.
Its event is placed at offset 0, before any byte, rather than wherever the next apply happens to find the ring. A second call changes nothing.
func (*MultiWriter) Remove ¶
func (t *MultiWriter) Remove(writers ...io.Writer)
func (*MultiWriter) ResizeBegin ¶ added in v0.39.0
func (t *MultiWriter) ResizeBegin(size termsize.Size) uint64
ResizeBegin records a tentative resize to size, before the pty's ioctl. It never waits on writeMu. It returns the event's Seq.
It returns 0 once the pty is Pinned, and a Seq that ResizeEnd ignores when the queue had no room for the report, which then counts as a loss.
func (*MultiWriter) ResizeEnd ¶ added in v0.39.0
func (t *MultiWriter) ResizeEnd(seq uint64, applied bool)
ResizeEnd records the outcome: applied commits the event, !applied deletes it. It never waits on writeMu.
The outcome reaches the log at the next hold of writeMu, which every capture and release begins by applying. Until then the event stays tentative, which is what keeps a capture from using it either way.
func (*MultiWriter) SetReplayer ¶ added in v0.39.0
func (t *MultiWriter) SetReplayer(r Replayer, fallback func(error))
SetReplayer installs r, and fallback, which is told why each join that fell back did (never called with the fan-out's locks held). Once, before any join.
func (*MultiWriter) Shutdown ¶ added in v0.27.0
func (t *MultiWriter) Shutdown(ctx context.Context) error
Shutdown closes the fan-out to new writers and then waits for everything already accepted to be delivered.
The two halves are inseparable. Flushing a snapshot alone would leave a window: the SSH server keeps serving while the flush runs, so a guest attaching after the snapshot has its replay queued into a sink this call will never flush, and teardown closes it before delivery. Quiescing under writeMu, the same lock Append takes, leaves no such window — an attach is either inside the snapshot or refused.
Members that do not buffer are skipped: a synchronous writer is delivered by definition. A sink that has already failed flushes to nil, because a guest that is already gone is not a shutdown error.
Then each member that wants one (ResetTarget) is sent what puts its terminal back where it started -- the modes the session left set, undone -- and flushed again. A session can end with its command still holding the terminal, killed on the alternate screen with the mouse on, and a guest's terminal is not one anything else will put back. A member's reset waits for its own first flush: queued behind a tail, it could take a sink that was going to drain past its bound, and a sink that overflows is a guest dropped. It waits for nothing else, so a guest stuck until the deadline costs the others nothing; and one still behind at the deadline loses the reset with its tail, as it would have lost the tail anyway. The reset's own flush gets ResetFlushTimeout, which may run past ctx's deadline.
func (*MultiWriter) Write ¶
func (t *MultiWriter) Write(p []byte) (int, error)
Write fans p out to every attached writer. It always reports success.
Two things here are deliberate, and both were bugs.
The membership lock is released before any writer is written to. Holding one lock for both jobs made Append and Remove wait on whatever the slowest attached writer was doing, and a guest that stops reading its SSH channel blocks in Write indefinitely once the channel window fills. That wedged the host: HandleSession removes its writer on the way out, so Remove blocked, HandleSession never returned, and the client-left event it emits on the way out was never sent. Writes are still serialized among themselves, by writeMu, because concurrent producers must not interleave in a writer or race one that is not concurrency safe.
A failing writer is dropped rather than reported. This is a broadcast to whoever is attached, and the producer is the host's pty: returning an error aborted the io.Copy feeding it, which ended the command and tore down the whole session. One guest losing its connection at the wrong moment must not take the session with it. Errors used to abandon the rest of the slice too, so a broken guest silenced everyone attached after it.
A writer removed between the snapshot and the write still receives this one write. That is harmless: it is a session on its way out.
Writers that buffer are what keep this serial loop honest: each attached guest is an AsyncWriter, so its Write is a copy and a signal rather than SSH I/O, and a guest that cannot keep up overflows and is dropped instead of pacing everyone else. The host's own stdout is attached unwrapped only when it is a terminal: the pty should not run ahead of the screen that owns it. A non-terminal stdout is wrapped, because a pipe nobody drains would otherwise block here with writeMu held and wedge the whole session. With no primary, a pacer in front of Write holds the pty to the earliest guest's drain instead; Write itself still never blocks on a guest.
type ReplayPanic ¶ added in v0.39.0
ReplayPanic is a panic recovered from a Replayer, reported as an error.
func (*ReplayPanic) Error ¶ added in v0.39.0
func (p *ReplayPanic) Error() string
type ReplayParts ¶ added in v0.39.0
type ReplayParts struct {
DECModes map[int]bool // each tracked DEC private mode, the alternate screen's aside, with its value
Modes []byte // those not at their default as CSI ? n h/l, in Snapshot's order, DECAWM (7) excluded
Autowrap bool // DECAWM
MainKeys []byte // the main kitty stack's pushes, then modifyOtherKeys
HiddenAltKeys []byte // the hidden alternate stack's pushes, while the main screen shows, or nil
AltVia int // 47, 1047 or 1049 while the alternate screen shows, else 0
AltKeys []byte // the alternate stack's pushes, while it shows
}
ReplayParts is the tracked state a reconstruction needs, in the order Snapshot emits it, split where the reconstruction interleaves drawing.
type Replayer ¶ added in v0.39.0
type Replayer interface {
// Start emulates c for a joiner whose terminal is size. The Emulation it
// returns stops, within the emulator's meter interval, once ctx is done
// or the deadline passes, in Start and in every later call.
Start(ctx context.Context, deadline time.Time, c *Capture, size termsize.Size) (Emulation, error)
}
Replayer reconstructs the screen the ring's bytes drew, for a joining terminal, so the joiner is handed that screen rather than the raw ring. io has no emulator of its own; the host installs one with SetReplayer.
type ResetTarget ¶ added in v0.37.0
type ResetTarget interface {
WantsReset() bool
}
ResetTarget is implemented by an attached writer that wants the session's reset when the fan-out shuts down: one whose far end is a terminal nothing else will put back. A guest's is the one -- behind a plain ssh client, which restores termios on the way out and nothing more. Every other member is sent none: a viewer capturing the output to a file gets what the command wrote, and a local client puts its own terminal back. Its Write must not block, as an AsyncWriter's does not: Shutdown writes the reset with writeMu held.
type SizeEvent ¶ added in v0.39.0
type SizeEvent struct {
Seq uint64 // ordinal: several events can share an offset
Offset uint64 // applies before the byte at Offset
Size termsize.Size
}
SizeEvent is a resize of the pty, placed in the stream.
type StallWriter ¶ added in v0.30.0
type StallWriter struct {
// contains filtered or unexported fields
}
StallWriter writes synchronously to w in chunks, stamping progress after each one, and calls onStall once if a write has been outstanding with no chunk completed for timeout.
It exists for the primary client's pacing. The primary's writer is attached to the fan-out synchronously, so the command cannot outrun the terminal, and MultiWriter.Write holds writeMu across it: a primary that stops reading its SSH channel therefore blocks every other writer and every new attach. The bound has to measure progress rather than duration — "a write took 5 s" and "no chunk has completed for 5 s" treat a slow-but-working terminal differently, and only the second is worth acting on — and it has to be armed only while a write is outstanding, or a healthy shell at a prompt would be judged stalled for producing nothing.
A single blocking Write is unobservable from outside, which is why the writer chunks: the chunk is the unit of progress. The progress clock starts with each write, so the first chunk after a quiet period is not judged against a stale stamp.
onStall runs on the watchdog's goroutine and must not take writeMu: the lock is held by the write it is there to release. Closing the client's SSH connection is what it is for; see host/internal.
func NewStallWriter ¶ added in v0.30.0
NewStallWriter starts the watchdog. chunk bounds the bytes handed to w per call; onStall may be nil.
func (*StallWriter) Close ¶ added in v0.30.0
func (s *StallWriter) Close() error
Close stops the watchdog. It does not close w and does not release a write already blocked in it; only onStall's action can do that.
type TerminalQueryFilter ¶ added in v0.21.0
type TerminalQueryFilter struct {
// contains filtered or unexported fields
}
TerminalQueryFilter wraps an io.Writer and filters out terminal query sequences from the output. This prevents queries sent by the host's shell from reaching connected clients, whose terminals would otherwise respond and pollute the PTY input.
What counts as a query is decided by family, in query_classify.go. The filter itself only follows the stream the way a terminal's DEC parser does -- where each sequence starts, where it ends, and what the terminal does with the bytes in between -- and holds a sequence until its classifier can decide it: an ESC sequence or a CSI to its final byte, an OSC to its terminator, a DCS to its header's final byte, and a kitty graphics command (an APC whose first byte is G) to the end of its control data, or to a BEL before it that settles it as a query. A DCS or APC that is a query is then dropped as it streams, to the string's end (drop mode), and one that isn't is passed as it streams, as a PM or SOS is. So a sixel or an image is never buffered, nor is a query's payload.
A tmux passthrough wrapper, ESC P tmux; then a sequence with each ESC doubled, then ESC \, is held while an inner instance of this machine, fed the wrapped stream decoded, decides the first sequence in it. If that is no query, the wrapper passes byte for byte. If it is, the wrapper's head and the query are dropped, and the rest of the wrapper goes out as ordinary output, filtered from ground: a DEC terminal ends the wrapper's DCS at its first ESC and reads the rest live, so that removes exactly the query.
Terminals disagree on where a malformed string ends. A DEC parser ends one at a CAN, a SUB or an ESC that isn't ST; kitty keeps any string open there, and tmux a DCS, and each takes what follows as more of it. So an OSC, a DCS header or an APC the filter is still holding when it ends one of those ways is dropped whole, query or not: passed through, "ESC ]4;0; CAN ;1;? BEL" is a palette query to kitty. What that costs is a malformed OSC's effect on a DEC terminal, such as a title ended by a stray ESC.
A sequence the filter has emitted before its end -- a string it passes, a tmux wrapper among them, a CSI a terminal ignores, or one it gave up on past its bound -- is always closed. The terminal stays inside it until it sees the ESC that ended it in the input, and that ESC starts a sequence the filter holds. If the held sequence passes, its ESC is the close; if it is dropped, the filter sends ESC CAN in its place, whose ESC ends the emitted sequence the way the input's did and whose CAN cancels the escape it starts. Without the close, whatever came after a dropped query landed inside the emitted sequence.
func NewTerminalQueryFilter ¶ added in v0.21.0
func NewTerminalQueryFilter(w io.Writer) *TerminalQueryFilter
NewTerminalQueryFilter creates a filter that removes terminal query sequences from output before writing to the underlying writer.
func (*TerminalQueryFilter) Dropping ¶ added in v0.38.0
func (f *TerminalQueryFilter) Dropping() bool
Dropping reports whether the filter is discarding a query string, at top level or inside a tmux wrapper, that hasn't ended yet. Pending then holds the head that decided the drop, which must not reach a terminal that would answer it, so a sink promoted to primary now settles the drop with SettleDrop rather than handing Pending over.
func (*TerminalQueryFilter) Pending ¶ added in v0.30.0
func (f *TerminalQueryFilter) Pending() []byte
Pending returns the bytes that put a fresh filter in this one's state. Fed everything this filter has written, then Pending(), a fresh filter ends in the state this one is in: where it is in a sequence, the match so far against "tmux;" at the start of a DCS, where it is in a tmux wrapper and in the sequence the wrapper carries, whether it is dropping a string or passing a sequence it gave up on, and whether it owes a close. A joiner's filter takes its state from the result (MultiWriter.Append), and a newly promoted primary is handed it raw (hostSink.promote), unless the filter is Dropping, where SettleDrop takes its place. What it returns depends on the state:
- While deciding, the held bytes: a head, an OSC to its terminator, a DCS header to its final byte, an APC's ESC _ before its first byte, or a graphics command with its control data so far, BELs that asked nothing among them.
- In drop mode, the head that decided the drop, and the ESC held to see whether a '\' follows, if there is one. The head is a DCS header, or a graphics command's control data with the ';' or BEL that decided it. A joiner's filter fed the head drops the rest of the string as this one does, and one that joins between a terminator's ESC and its '\' still ends the string there.
- While a tmux wrapper is being decided, its raw bytes as held: the framing, the bytes before its first inner ESC, and the inner sequence's head with its ESCs still doubled, a raw ESC whose pair is still to come included. A joiner's filter re-decodes them to the same state, the control bytes the inner head forwarded included, which have not gone out yet. While a DCS whose header ended in 't' is matching "tmux;", the header and the match so far.
- While a wrapper's first inner sequence is dropped, the wrapper's framing, the inner head that decided the drop with its ESC doubled, and then, in that order, the inner parser's pending ESC as ESC ESC, if it holds one, and a raw ESC whose pair is still to come, if there is one. The pending ESC goes in though its raw pair is complete and leaves no raw ESC unpaired, so a joiner still ends the inner string where this filter does: without it, the joiner would drop what follows as payload. The control bytes the inner head forwarded have gone out, and aren't in it.
- In a pass state, where the head went out with the stream, an ESC held to see what the next byte makes of it, or nothing. A DCS, a DCS-ignore, an APC past its ';' or one that isn't a graphics command, a PM or SOS, a tmux wrapper passing, a CSI the terminal ignores and a sequence past its bound are all in this state.
- With a close owed, the held sequence that will settle it. It begins with the ESC that ended the emitted sequence, so the joiner's filter owes the same close.
The result restores a parse state; it does not recover history. It never contains a byte the filter discarded, a dropped string's payload among them, since those are gone and are not coming back, nor a C0 it forwarded, which went out ahead of the hold and would be sent twice. A dropped string's head is in it only as state, which a joiner's filter needs to drop the rest. It is a copy, and never longer than maxPendingBytes.
func (*TerminalQueryFilter) SettleDrop ¶ added in v0.38.0
func (f *TerminalQueryFilter) SettleDrop(p []byte) (n int, raw []byte, done bool)
SettleDrop is the handoff for a sink promoted to primary while Dropping. The bytes already dropped can't be recovered, so the rest of that string is no use to the new primary; everything after it is. SettleDrop consumes from p only the rest of the string being dropped, and parses nothing after the string's end: the byte after a restarting ESC is not looked at as part of a new sequence. Nothing goes to the filter's writer.
- done reports whether the string ended within p. If not, n == len(p) and raw is nil: all of p was part of the drop, and the filter is still Dropping, for the next call.
- When it did, the caller writes raw, then p[n:], unfiltered, and never uses the filter again.
- The general rule: the stream resumes at a raw resume point. If that point is in p, it is p[n] and raw is nil. If it lies in bytes the filter carried from an earlier write, raw holds the carried raw bytes from that point on, and n is 0. Those are one or two raw ESCs, in a new slice the caller owns.
Where it ended fixes the resume point:
- at ST: just after the '\', so p[:n] ends with it;
- at CAN or SUB: at that byte;
- at an ESC that isn't ST: at that ESC. It's the carried byte when the filter held it from the last write, so raw is "\x1b" and p[0] is the byte after it.
- inside a wrapper: after an inner ST or the outer ST, at a CAN or SUB, and at a lone raw ESC x, which ends the wrapper. An inner ESC that isn't ST resumes at the second raw ESC of its pair, which is carried when the pair completed in the last write. For example, promoted after "\x1bPtmux;\x1b\x1bP+q544e\x1b\x1b" and given "[6nhello\x1b\\", SettleDrop returns raw "\x1b", n 0, done true. An inner ESC that ends the string by following a pending one resumes at the pending one's second raw ESC, so raw can be "\x1b\x1b".
A filter that isn't Dropping, a fresh one or one SettleDrop has already returned done for among them, has no string to settle: SettleDrop returns 0, nil and true, and the stream resumes at p[0]. It hands over nothing the filter holds, though: Pending, if there is any, is still the caller's to give the new primary raw, as hostSink.promote does when the filter isn't Dropping, since it may be the lead-in of a query the primary should answer.
func (*TerminalQueryFilter) Write ¶ added in v0.21.0
func (f *TerminalQueryFilter) Write(p []byte) (int, error)
Write filters terminal query sequences from p and writes the result to the underlying writer. Returns len(p) on success to indicate all input bytes were processed. On error, returns 0 because the filtered output is written atomically (all or nothing) and input bytes don't map 1:1 to output bytes due to filtering.