Skip to content

ansi: fix margin writer closures capturing stale block-stack buffer - #594

Open
lfaoro wants to merge 1 commit into
charmbracelet:mainfrom
lfaoro:fix/margin-writer-closure-heap-corruption
Open

ansi: fix margin writer closures capturing stale block-stack buffer#594
lfaoro wants to merge 1 commit into
charmbracelet:mainfrom
lfaoro:fix/margin-writer-closure-heap-corruption

Conversation

@lfaoro

@lfaoro lfaoro commented Jul 30, 2026

Copy link
Copy Markdown

Fix: margin writer closures capture stale block-stack buffer

Problem

NewMarginWriter in ansi/margin.go creates PadFunc and IndentFunc closures that capture the constructor's w argument (a *bytes.Buffer from the renderer's block stack) and ignore their io.Writer parameter. When PaddingWriter.Write and IndentWriter.Write call these functions, padding and indent characters are written directly to the block-stack buffer, bypassing the lipgloss.WrapWriter chain.

The block-stack buffer is reused across renders. Under GC pressure, stale pointers from closed-over captures collide with buffer reuse, producing:

  • runtime: pointer ... to unallocated span heap corruption
  • goroutine hangs (writes deadlock on a reused buffer)

Trigger

Both conditions must be true:

  1. WithPreservedNewLines() β€” newlines reach the margin writers instead of being collapsed to spaces
  2. A StyleBlock with Margin != nil β€” engages the margin/indent/padding writer chain

When both are active, every \n in paragraph text triggers PaddingWriter.Write's padding branch, which calls PadFunc on the bypass path.

The default DarkStyleConfig and all built-in styles set Document.Margin = uintPtr(2), so the margin writers are always engaged. WithPreservedNewLines is the additional trigger.

Reproduction

Happens probabilistically (~50% of runs without -race; never with -race or single-package execution). Stack always traces through:

ansi.renderText            β†’ baseelement.go:58
ansi.NewMarginWriter.func1 β†’ margin.go:36
ansi.PaddingWriter.Write   β†’ margin.go:104
ansi.IndentWriter.Write    β†’ margin.go:208
ansi.MarginWriter.Write    β†’ margin.go:55
ansi.ANSIRenderer.renderNode β†’ renderer.go:127

A minimal repro run 50 times:

style.Document.Margin = uintPtr(2)
r, _ := glamour.NewTermRenderer(
    glamour.WithStyles(style),
    glamour.WithWordWrap(78),
    glamour.WithPreservedNewLines(),
)
for i := 0; i < 100000; i++ {
    r.Render("# title\n\npara one\n\npara two\n\n```go\nfmt.Println()\n```\n")
}

Fix

Change both closures in NewMarginWriter to use their io.Writer parameter:

- pw := NewPaddingWriter(w, int(bs.Width(ctx)), func(_ io.Writer) {
-     _, _ = renderText(w, rules.StylePrimitive, " ")
+ pw := NewPaddingWriter(w, int(bs.Width(ctx)), func(wr io.Writer) {
+     _, _ = renderText(wr, rules.StylePrimitive, " ")
 })

- iw := NewIndentWriter(pw, int(indentation+margin), func(_ io.Writer) {
-     _, _ = renderText(w, bs.Parent().Style.StylePrimitive, ic)
+ iw := NewIndentWriter(pw, int(indentation+margin), func(wr io.Writer) {
+     _, _ = renderText(wr, bs.Parent().Style.StylePrimitive, ic)
 })

Now padding and indent writes pass through the proper WrapWriter chain, which correctly manages ANSI state and cursor position.

Changes

  • ansi/margin.go: 4-line closure fix
  • glamour_test.go: regression test TestMarginWriterHeapCorruption
  • 27 golden files: updated to reflect corrected output (2-space margin now properly applied through the full writer chain)

Testing

  • go test -count=20 -run=TestMarginWriterHeapCorruption β€” passes
  • go test ./... β€” passes
  • go test -race ./... β€” passes

The PadFunc and IndentFunc closures in NewMarginWriter captured the
constructor's w argument (a *bytes.Buffer from the renderer's block
stack) and ignored their io.Writer parameter. This caused padding and
indent writes to bypass the WrapWriter chain and land directly on the
block-stack buffer, which is reused across renders.

Under concurrent GC pressure this produced heap corruption (pointer to
unallocated span) and/or goroutine hangs.

Fix: route padding/indent writes through the io.Writer parameter so
they pass through the proper WrapWriter chain.

Add regression test TestMarginWriterHeapCorruption that exercises the
WithPreservedNewLines + Document.Margin trigger with 5000 iterations
(reproduces the crash in ~50% of runs without the fix).

Update golden files to reflect corrected output (2-space margin now
properly applied through the writer chain).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant