Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions tool/shelltool/localshell.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,14 +398,17 @@ func runStateless(ctx context.Context, opts LocalConfig, command string) (Result
// --------------------------------------------------------------------------

// headTailBuffer keeps up to cap bytes: the first half as head and the most
// recent half as a rolling tail. When the total exceeds cap, the middle is
// dropped and [Result.Truncated] is set. This mirrors the .NET HeadTailBuffer.
// recent half as a rolling tail. Once a complete rune no longer fits in the
// head, it and all later runes stay in the tail so the final output keeps the
// original UTF-8 ordering. When the total exceeds cap, the middle is dropped
// and [Result.Truncated] is set. This mirrors the .NET HeadTailBuffer.
type headTailBuffer struct {
cap int
head []byte
tail [][]byte // queue of complete rune-byte slices
tailBytes int
totalBytes int
headSealed bool
truncated bool
}

Expand All @@ -430,15 +433,16 @@ func (b *headTailBuffer) Write(p []byte) (int, error) {

b.totalBytes += size

if len(b.head)+len(encoded) <= headCap {
if !b.headSealed && len(b.head)+len(encoded) <= headCap {
b.head = append(b.head, encoded...)
continue
}
// Head full — append to tail.
// Once a complete rune cannot fit in the head, all later runes stay in the tail.
b.headSealed = true
b.tail = append(b.tail, encoded)
b.tailBytes += len(encoded)
// Evict oldest rune-chunk from tail until within budget.
for b.tailBytes > tailCap && len(b.tail) > 0 {
for b.totalBytes > b.cap && b.tailBytes > tailCap && len(b.tail) > 0 {
b.tailBytes -= len(b.tail[0])
b.tail = b.tail[1:]
b.truncated = true
Expand Down
49 changes: 48 additions & 1 deletion tool/shelltool/localshell_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

package shelltool

import "testing"
import (
"strings"
"testing"
)

func TestPreserveEnvironmentValuesKeepsOnlyAllowlist(t *testing.T) {
source := []string{
Expand All @@ -28,3 +31,47 @@ func TestPreserveEnvironmentValuesKeepsOnlyAllowlist(t *testing.T) {
t.Fatal("unexpected non-preserved variable copied")
}
}

func TestHeadTailBuffer_MultiByteUTF8AtCapPreservesOrder(t *testing.T) {
t.Parallel()

const input = "aaaaaaa🔥🔥🔥\n"

buf := &headTailBuffer{cap: 20}
if _, err := buf.Write([]byte(input)); err != nil {
t.Fatalf("write: %v", err)
}

if buf.truncated {
t.Fatalf("truncated = true, want false")
}
if got := buf.String(); got != input {
t.Fatalf("String() = %q, want %q", got, input)
}
}

func TestHeadTailBuffer_MultiByteUTF8OverflowPreservesHeadTailOrder(t *testing.T) {
t.Parallel()

buf := &headTailBuffer{cap: 20}
if _, err := buf.Write([]byte("aaaaaaa🔥🔥🔥x\n")); err != nil {
t.Fatalf("write: %v", err)
}

got := buf.String()
if !buf.truncated {
t.Fatalf("truncated = false, want true")
}
if !strings.HasPrefix(got, "aaaaaaa\n") {
t.Fatalf("String() = %q, want prefix %q", got, "aaaaaaa\\n")
}
if !strings.Contains(got, "[... truncated 4 bytes ...]") {
t.Fatalf("String() = %q, want truncated marker", got)
}
if !strings.HasSuffix(got, "🔥🔥x\n") {
t.Fatalf("String() = %q, want suffix %q", got, "🔥🔥x\\n")
}
if strings.ContainsRune(got, '\uFFFD') {
t.Fatalf("String() = %q, should not contain replacement rune", got)
}
}