diff --git a/formatter.go b/formatter.go index 77da9f5..a9a6f03 100644 --- a/formatter.go +++ b/formatter.go @@ -2,6 +2,7 @@ package libghostty /* #include +#include // Helper to create a properly initialized GhosttyFormatterTerminalOptions (sized struct). static inline GhosttyFormatterTerminalOptions init_formatter_terminal_options() { @@ -10,6 +11,85 @@ static inline GhosttyFormatterTerminalOptions init_formatter_terminal_options() opts.extra.screen.size = sizeof(GhosttyFormatterScreenExtra); return opts; } + +// Buffer small formatter writes before forwarding them to Go. Styled output +// can contain hundreds of small writes, and crossing into Go for each one is +// expensive. The buffer is flushed when full and when formatting completes; +// large writes bypass it. libghostty formats synchronously and is fast, so +// buffering is not expected to add noticeable latency. +typedef struct { + GhosttyWriter downstream; + uint8_t* buffer; + size_t len; + size_t capacity; +} ghostty_go_formatter_writer; + +static bool ghostty_go_formatter_writer_flush( + ghostty_go_formatter_writer* writer +) { + if (writer->len == 0) return true; + if (!writer->downstream.write( + writer->downstream.userdata, + writer->buffer, + writer->len)) { + return false; + } + writer->len = 0; + return true; +} + +static bool ghostty_go_formatter_writer_write( + void* userdata, + const uint8_t* data, + size_t len +) { + ghostty_go_formatter_writer* writer = userdata; + while (len > 0) { + if (writer->len == 0 && len >= writer->capacity) { + return writer->downstream.write( + writer->downstream.userdata, + data, + len); + } + + size_t available = writer->capacity - writer->len; + size_t count = len < available ? len : available; + memcpy(writer->buffer + writer->len, data, count); + writer->len += count; + data += count; + len -= count; + + if (writer->len == writer->capacity && + !ghostty_go_formatter_writer_flush(writer)) { + return false; + } + } + return true; +} + +static inline GhosttyResult ghostty_go_formatter_format( + GhosttyFormatter formatter, + GhosttyWriter downstream +) { + uint8_t buffer[16 * 1024]; + ghostty_go_formatter_writer context = { + .downstream = downstream, + .buffer = buffer, + .len = 0, + .capacity = sizeof(buffer), + }; + GhosttyWriter writer = { + .write = ghostty_go_formatter_writer_write, + .userdata = &context, + }; + + GhosttyResult result = ghostty_formatter_format(formatter, writer); + if (result != GHOSTTY_SUCCESS) return result; + if (!ghostty_go_formatter_writer_flush(&context)) { + return GHOSTTY_IO_ERROR; + } + return GHOSTTY_SUCCESS; +} */ import "C" @@ -210,6 +290,9 @@ func (o *formatterOpts) prepare() (func(), error) { // C: GhosttyFormatter type Formatter struct { ptr C.GhosttyFormatter + + // writer is reused across WriteTo calls to avoid allocating cgo handles. + writer ghosttyWriterBridge } // NewFormatter creates a formatter for the given terminal's active screen. @@ -241,6 +324,7 @@ func NewFormatter(t *Terminal, opts ...FormatterOption) (*Formatter, error) { // Close frees the formatter handle. After this call, the formatter // must not be used. func (f *Formatter) Close() { + f.writer.close() C.ghostty_formatter_free(f.ptr) } @@ -290,19 +374,25 @@ func (f *Formatter) FormatString() (string, error) { return string(b), nil } -// WriteTo implements io.WriterTo. It formats the current terminal state and -// streams the output directly to w without first allocating the complete -// formatted result. The writer is called synchronously and must not call -// methods on f or its terminal. The returned count includes bytes accepted -// before an error. +// WriteTo implements io.WriterTo by formatting the current terminal state and +// writing it to w. It does not allocate a buffer for the complete result. The +// returned count includes bytes accepted before an error. +// +// WriteTo buffers small formatter writes to reduce calls into Go. The buffer +// is flushed when full and when formatting completes. Since libghostty formats +// quickly, buffering is not expected to add noticeable latency. WriteTo may +// block if w blocks. The writer must not call methods on f or its terminal. // C: ghostty_formatter_format func (f *Formatter) WriteTo(w io.Writer) (int64, error) { - bridge, writer, err := newGhosttyWriter(w) + bridge := &f.writer + writer, err := bridge.reset(w) if err != nil { return 0, err } - defer bridge.close() - result := C.ghostty_formatter_format(f.ptr, writer) - return bridge.written, resultErrorWithCallback(result, bridge.err) + result := C.ghostty_go_formatter_format(f.ptr, writer) + written := bridge.written + callbackErr := bridge.err + bridge.finish() + return written, resultErrorWithCallback(result, callbackErr) } diff --git a/formatter_benchmark_test.go b/formatter_benchmark_test.go new file mode 100644 index 0000000..a604c61 --- /dev/null +++ b/formatter_benchmark_test.go @@ -0,0 +1,121 @@ +package libghostty + +import ( + "io" + "testing" +) + +// formatterBenchmarkCase describes an active-screen shape and cell mix. +type formatterBenchmarkCase struct { + name string + cols uint16 + rows uint16 + line string +} + +var formatterBenchmarkCases = []formatterBenchmarkCase{ + { + name: "Empty80x24", + cols: 80, + rows: 24, + }, + { + name: "Plain80x24", + cols: 80, + rows: 24, + line: "plain ASCII terminal content with words, numbers 0123456789, and punctuation", + }, + { + name: "Mixed80x24", + cols: 80, + rows: 24, + line: "\x1b[1;38;5;33mstatus\x1b[0m plain 日本語 e\u0301 👩🏽‍💻 \x1b[4munderlined\x1b[0m", + }, + { + name: "Mixed240x80", + cols: 240, + rows: 80, + line: "\x1b[1;38;5;33mstatus\x1b[0m plain 日本語 e\u0301 👩🏽‍💻 \x1b[4munderlined\x1b[0m", + }, +} + +// formatterBenchmarkProbe counts bytes and calls outside the timed loop. +type formatterBenchmarkProbe struct { + bytes int64 + calls int64 +} + +func (w *formatterBenchmarkProbe) Write(p []byte) (int, error) { + w.bytes += int64(len(p)) + w.calls++ + return len(p), nil +} + +// BenchmarkFormatterVTWriteTo measures VT formatting through WriteTo. It +// excludes terminal setup, output allocation, and sink-side copying. +func BenchmarkFormatterVTWriteTo(b *testing.B) { + for _, test := range formatterBenchmarkCases { + b.Run(test.name, func(b *testing.B) { + formatter := newFormatterBenchmark(b, test) + + var probe formatterBenchmarkProbe + written, err := formatter.WriteTo(&probe) + if err != nil { + b.Fatal(err) + } + if written != probe.bytes { + b.Fatalf("WriteTo reported %d bytes after sink accepted %d", written, probe.bytes) + } + + b.ReportAllocs() + b.SetBytes(probe.bytes) + b.ResetTimer() + + for b.Loop() { + written, err = formatter.WriteTo(io.Discard) + if err != nil { + b.Fatal(err) + } + } + b.StopTimer() + b.ReportMetric(float64(probe.bytes), "output-B") + b.ReportMetric(float64(probe.calls), "callbacks/op") + formatterBenchmarkWritten = written + }) + } +} + +// newFormatterBenchmark constructs a full active screen without scrollback. +// The last row has no newline so the first row remains on screen. +func newFormatterBenchmark(b *testing.B, test formatterBenchmarkCase) *Formatter { + b.Helper() + + term, err := NewTerminal( + WithSize(test.cols, test.rows), + WithMaxScrollbackLines(0), + ) + if err != nil { + b.Fatal(err) + } + b.Cleanup(term.Close) + + if test.line != "" { + input := make([]byte, 0, int(test.rows)*(len(test.line)+2)) + for row := range int(test.rows) { + input = append(input, test.line...) + if row+1 < int(test.rows) { + input = append(input, '\r', '\n') + } + } + term.VTWrite(input) + } + + formatter, err := NewFormatter(term, WithFormatterFormat(FormatterFormatVT)) + if err != nil { + b.Fatal(err) + } + b.Cleanup(formatter.Close) + return formatter +} + +var formatterBenchmarkWritten int64 diff --git a/formatter_test.go b/formatter_test.go index cd6dcfd..eee5fe5 100644 --- a/formatter_test.go +++ b/formatter_test.go @@ -249,6 +249,54 @@ func TestFormatterWriteTo(t *testing.T) { } } +func TestFormatterWriteToLargerThanBridgeBuffer(t *testing.T) { + const ( + cols = 240 + rows = 80 + ) + term, err := NewTerminal(WithSize(cols, rows), WithMaxScrollbackLines(0)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + line := bytes.Repeat([]byte{'x'}, cols-1) + input := make([]byte, 0, rows*(len(line)+2)) + for row := range rows { + input = append(input, line...) + if row+1 < rows { + input = append(input, '\r', '\n') + } + } + term.VTWrite(input) + + f, err := NewFormatter(term, WithFormatterFormat(FormatterFormatVT)) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + want, err := f.Format() + if err != nil { + t.Fatal(err) + } + if len(want) <= 16<<10 { + t.Fatalf("test output is only %d bytes; expected multiple bridge-buffer chunks", len(want)) + } + + var got bytes.Buffer + written, err := f.WriteTo(&got) + if err != nil { + t.Fatal(err) + } + if written != int64(len(want)) { + t.Fatalf("WriteTo returned %d bytes, want %d", written, len(want)) + } + if !bytes.Equal(got.Bytes(), want) { + t.Fatal("buffered WriteTo output differs from Format output") + } +} + func TestFormatterWriteToShortWrite(t *testing.T) { term, err := NewTerminal(WithSize(4, 2)) if err != nil { @@ -300,6 +348,16 @@ func TestFormatterWriteToError(t *testing.T) { t.Fatalf("expected original writer error, got %v", err) } assertResultError(t, err, ResultIOError) + + // A later call must not retain the previous writer error. + var recovered bytes.Buffer + written, err = f.WriteTo(&recovered) + if err != nil { + t.Fatal(err) + } + if written != int64(recovered.Len()) || !strings.Contains(recovered.String(), "formatter writer error") { + t.Fatalf("unexpected output after writer recovery: written=%d output=%q", written, recovered.String()) + } } func TestFormatterFormatBuf(t *testing.T) { diff --git a/io.go b/io.go index adc8c41..bd424c5 100644 --- a/io.go +++ b/io.go @@ -60,16 +60,16 @@ type ghosttyReaderBridge struct { eof bool } -// newGhosttyReader builds a C reader backed by r. The returned bridge must be -// closed after the C object that retains the reader has been freed. -func newGhosttyReader(r io.Reader) (*ghosttyReaderBridge, C.GhosttyReader, error) { +// init initializes b to read from r and returns its C descriptor. The caller +// must close b after the descriptor is no longer in use. +func (b *ghosttyReaderBridge) init(r io.Reader) (C.GhosttyReader, error) { if r == nil { - return nil, C.GhosttyReader{}, &Error{Result: ResultInvalidValue} + return C.GhosttyReader{}, &Error{Result: ResultInvalidValue} } - b := &ghosttyReaderBridge{reader: r} + b.reader = r b.handle = cgo.NewHandle(b) - return b, C.ghostty_go_reader(C.uintptr_t(b.handle)), nil + return C.ghostty_go_reader(C.uintptr_t(b.handle)), nil } // close releases the reader's cgo handle. It must be called only after C can @@ -80,28 +80,50 @@ func (b *ghosttyReaderBridge) close() { } b.handle.Delete() b.handle = 0 + b.reader = nil } -// ghosttyWriterBridge owns the cgo handle used by one synchronous writer -// operation and records both the accepted byte count and the original Go -// error, if any. +// ghosttyWriterBridge adapts an io.Writer to GhosttyWriter. It records the +// accepted byte count and the original Go error, if any. type ghosttyWriterBridge struct { - writer io.Writer - handle cgo.Handle - written int64 - err error + writer io.Writer + handle cgo.Handle + descriptor C.GhosttyWriter + written int64 + err error } // newGhosttyWriter builds a C writer backed by w. The caller must close the // bridge after the synchronous libghostty operation returns. func newGhosttyWriter(w io.Writer) (*ghosttyWriterBridge, C.GhosttyWriter, error) { + b := &ghosttyWriterBridge{} + descriptor, err := b.reset(w) + if err != nil { + return nil, C.GhosttyWriter{}, err + } + return b, descriptor, nil +} + +// reset prepares b to write to w. It reuses the existing handle and descriptor +// when possible. Calls using the same bridge must be serialized. +func (b *ghosttyWriterBridge) reset(w io.Writer) (C.GhosttyWriter, error) { if w == nil { - return nil, C.GhosttyWriter{}, &Error{Result: ResultInvalidValue} + return C.GhosttyWriter{}, &Error{Result: ResultInvalidValue} } - b := &ghosttyWriterBridge{writer: w} - b.handle = cgo.NewHandle(b) - return b, C.ghostty_go_writer(C.uintptr_t(b.handle)), nil + b.writer = w + b.written = 0 + b.err = nil + if b.handle == 0 { + b.handle = cgo.NewHandle(b) + b.descriptor = C.ghostty_go_writer(C.uintptr_t(b.handle)) + } + return b.descriptor, nil +} + +// finish releases w but retains the C descriptor for reuse. +func (b *ghosttyWriterBridge) finish() { + b.writer = nil } // close releases the writer's cgo handle. @@ -111,6 +133,8 @@ func (b *ghosttyWriterBridge) close() { } b.handle.Delete() b.handle = 0 + b.descriptor = C.GhosttyWriter{} + b.writer = nil } // resultErrorWithCallback preserves libghostty's I/O result while also diff --git a/snapshot.go b/snapshot.go index 4bc95e2..0558f7d 100644 --- a/snapshot.go +++ b/snapshot.go @@ -188,6 +188,14 @@ type SnapshotDecoder struct { sourceLen uintptr } +// snapshotReaderDecoder stores a decoder and its reader bridge in one +// allocation. The bridge remains separately addressable for use as C callback +// data. +type snapshotReaderDecoder struct { + decoder SnapshotDecoder + reader ghosttyReaderBridge +} + // Snapshot encodes a complete terminal snapshot and returns a Go-owned copy. // Calls must be serialized with every other operation on t. Callers taking // repeated snapshots can use [Terminal.SnapshotBuf] with a reusable buffer to @@ -255,18 +263,21 @@ func (t *Terminal) SnapshotWriteTo(w io.Writer) (int64, error) { // zero-byte read is permanent EOF; nonblocking readers must wait internally. // C: ghostty_snapshot_decoder_new func NewSnapshotDecoder(r io.Reader) (*SnapshotDecoder, error) { - bridge, reader, err := newGhosttyReader(r) + holder := &snapshotReaderDecoder{} + reader, err := holder.reader.init(r) if err != nil { return nil, err } result := C.ghostty_go_snapshot_decoder_new(reader) if err := resultError(result.result); err != nil { - bridge.close() + holder.reader.close() return nil, err } - return &SnapshotDecoder{ptr: result.decoder, reader: bridge}, nil + holder.decoder.ptr = result.decoder + holder.decoder.reader = &holder.reader + return &holder.decoder, nil } // NewSnapshotDecoderBytes creates a zero-copy snapshot decoder over data. The diff --git a/snapshot_benchmark_test.go b/snapshot_benchmark_test.go index be2ec0e..2b12d62 100644 --- a/snapshot_benchmark_test.go +++ b/snapshot_benchmark_test.go @@ -1,6 +1,7 @@ package libghostty import ( + "bytes" "strconv" "testing" ) @@ -102,11 +103,9 @@ func BenchmarkSnapshotEncode(b *testing.B) { } } -// BenchmarkSnapshotDecode measures complete restoration and the READY-prefix -// latency exposed for applications that can show a terminal before its older -// history pages have been restored. Each mode compares the normal zero-copy -// byte source with the mutation-safe copying constructor so the input-copy -// cost remains visible as snapshots grow. +// BenchmarkSnapshotDecode measures complete restoration and the latency to a +// renderable terminal. It compares zero-copy and copying byte sources with a +// callback-backed reader. func BenchmarkSnapshotDecode(b *testing.B) { for _, test := range snapshotBenchmarkCases { b.Run(test.name, func(b *testing.B) { @@ -118,6 +117,12 @@ func BenchmarkSnapshotDecode(b *testing.B) { }{ {name: "ZeroCopy", new: NewSnapshotDecoderBytes}, {name: "Copy", new: NewSnapshotDecoderBytesCopy}, + { + name: "Reader", + new: func(snapshot []byte) (*SnapshotDecoder, error) { + return NewSnapshotDecoder(bytes.NewReader(snapshot)) + }, + }, } { b.Run("Full/"+source.name, func(b *testing.B) { benchmarkSnapshotDecode(b, snapshot, source.new, false) @@ -130,8 +135,8 @@ func BenchmarkSnapshotDecode(b *testing.B) { } } -// benchmarkSnapshotDecode owns the complete per-iteration lifecycle. READY -// terminals must outlive their decoders until the decoder is closed. +// benchmarkSnapshotDecode runs the complete decoder lifecycle. A terminal +// returned by Ready remains alive until its decoder is closed. func benchmarkSnapshotDecode( b *testing.B, snapshot []byte, diff --git a/snapshot_test.go b/snapshot_test.go index 96988bb..6abdf5f 100644 --- a/snapshot_test.go +++ b/snapshot_test.go @@ -305,6 +305,41 @@ func TestSnapshotDecoderBytesPinLifetime(t *testing.T) { } } +func TestSnapshotRestoredTerminalRegistersEffectLazily(t *testing.T) { + term := newSnapshotTerminal(t) + snapshot, err := term.Snapshot() + term.Close() + if err != nil { + t.Fatal(err) + } + + decoder, err := NewSnapshotDecoderBytes(snapshot) + if err != nil { + t.Fatal(err) + } + defer decoder.Close() + restored, err := decoder.Decode() + if err != nil { + t.Fatal(err) + } + defer restored.Close() + + if restored.handle != 0 { + t.Fatal("restored terminal allocated an effect handle before registration") + } + bellCount := 0 + restored.SetEffectBell(func(*Terminal) { + bellCount++ + }) + if restored.handle == 0 { + t.Fatal("restored terminal did not allocate an effect handle on demand") + } + restored.VTWrite([]byte{'\a'}) + if bellCount != 1 { + t.Fatalf("expected one restored-terminal bell callback, got %d", bellCount) + } +} + func TestSnapshotReaderAndTrailingBytes(t *testing.T) { term := newSnapshotTerminal(t) defer term.Close() diff --git a/terminal.go b/terminal.go index 60e3330..a2f4847 100644 --- a/terminal.go +++ b/terminal.go @@ -23,10 +23,8 @@ import ( type Terminal struct { ptr C.GhosttyTerminal - // handle is a cgo.Handle pointing back to this Terminal. It is - // stored as the C-side userdata (GHOSTTY_TERMINAL_OPT_USERDATA) - // so that C effect trampolines can recover the *Terminal and - // dispatch to the appropriate Go effect handler. + // handle identifies the terminal to effect callbacks. It is initialized + // lazily because terminals without effects do not need C userdata. handle cgo.Handle onWritePty WritePtyFn @@ -626,12 +624,11 @@ func NewTerminal(opts ...TerminalOption) (*Terminal, error) { return t, nil } -// terminalFromC wraps a caller-owned C terminal and installs the Go userdata -// handle required by all terminal effect callbacks. Snapshot decoding uses -// this path so restored terminals have the same lifecycle and effect support -// as terminals created by NewTerminal. +// terminalFromC wraps a caller-owned C terminal. Restored terminals use the +// same lifecycle and effect support as terminals created by NewTerminal. +// Effect callback data is initialized lazily by syncEffects. func terminalFromC(cterm C.GhosttyTerminal, cfg TerminalConfig) *Terminal { - t := &Terminal{ + return &Terminal{ ptr: cterm, onWritePty: cfg.onWritePty, onBell: cfg.onBell, @@ -647,21 +644,15 @@ func terminalFromC(cterm C.GhosttyTerminal, cfg TerminalConfig) *Terminal { onDeviceAttributes: cfg.onDeviceAttributes, onUnknownSequence: cfg.onUnknownSequence, } - - // Always set userdata to our handle so trampolines can find us. - t.handle = cgo.NewHandle(t) - C.ghostty_terminal_set( - t.ptr, - C.GHOSTTY_TERMINAL_OPT_USERDATA, - handleToPointer(t.handle), - ) - return t } // Close frees the underlying terminal handle and releases the cgo.Handle. // After this call, the terminal must not be used. func (t *Terminal) Close() { - t.handle.Delete() + if t.handle != 0 { + t.handle.Delete() + t.handle = 0 + } C.ghostty_terminal_free(t.ptr) if t.effectBuf != nil { Free(t.effectBuf, t.effectBufLen) diff --git a/terminal_effect.go b/terminal_effect.go index d637a95..bb7627b 100644 --- a/terminal_effect.go +++ b/terminal_effect.go @@ -91,6 +91,17 @@ import ( // syncEffects registers or clears each C effect based on whether // the corresponding Go effect handler is set. func (t *Terminal) syncEffects() { + // Install userdata before registering the first callback. Terminals without + // callbacks do not need a handle. + if t.handle == 0 && t.hasEffects() { + t.handle = cgo.NewHandle(t) + C.ghostty_terminal_set( + t.ptr, + C.GHOSTTY_TERMINAL_OPT_USERDATA, + handleToPointer(t.handle), + ) + } + if t.onWritePty != nil { C.set_write_pty(t.ptr) } else { @@ -158,6 +169,24 @@ func (t *Terminal) syncEffects() { } } +// hasEffects reports whether any native effect trampoline needs to recover +// this Terminal through userdata. +func (t *Terminal) hasEffects() bool { + return t.onWritePty != nil || + t.onBell != nil || + t.onClipboardWrite != nil || + t.onDesktopNotification != nil || + t.onTitleChanged != nil || + t.onPwdChanged != nil || + t.onProgressReport != nil || + t.onEnquiry != nil || + t.onXtversion != nil || + t.onSize != nil || + t.onColorScheme != nil || + t.onDeviceAttributes != nil || + t.onUnknownSequence != nil +} + // terminalFromUserdata recovers a *Terminal from the C userdata pointer. func terminalFromUserdata(userdata unsafe.Pointer) *Terminal { return cgo.Handle(userdata).Value().(*Terminal)