7 Commits
13 changed files with 201 additions and 60 deletions
+3
View File
@@ -31,6 +31,9 @@ MAX_TERMINAL_CELLS=256000
# Memory budget in MB for the rendered-frame cache (0 disables caching).
RENDER_CACHE_MB=256
# Compress cached frames (flate). Cuts cache RAM ~2x but decompresses on every
# cache hit, so CPU rises with concurrent sessions. Off = zero-copy reads.
RENDER_CACHE_COMPRESS=false
# Go soft memory limit; set below your container limit to avoid OOM kills.
GOMEMLIMIT=1GiB
+3 -3
View File
@@ -3,7 +3,7 @@ name: CI
on:
push:
branches: ["**"]
tags: ["v*"]
tags: ["[0-9]*.[0-9]*.[0-9]*"]
pull_request:
permissions:
@@ -51,7 +51,7 @@ jobs:
- name: Determine push conditions
id: should_push
run: |
if [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == refs/tags/v* ) ]]; then
if [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" =~ ^refs/tags/[0-9]+\.[0-9]+\.[0-9]+$ ) ]]; then
echo "push=true" >> "$GITHUB_OUTPUT"
else
echo "push=false" >> "$GITHUB_OUTPUT"
@@ -64,7 +64,7 @@ jobs:
images: ghcr.io/yuzuzensai/trollssh
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern=v{{version}}
type=semver,pattern={{version}}
type=sha,prefix=sha-,format=short
- name: Log in to GHCR
+1 -1
View File
@@ -25,7 +25,7 @@ Generate a frame set from a video through container image
```sh
docker run --rm -v ./video.mp4:/home/app/video.mp4 -v ./frames:/home/app/frames \
ghcr.io/yuzuzensai/trollssh:v1.1.1 trollssh --generate --video video.mp4 --resolution 512
ghcr.io/yuzuzensai/trollssh:1.2.0 trollssh --generate --video video.mp4 --resolution 512
```
This writes `frames/<name>.tsf`, a simple container of color JPEG frames plus
+1 -1
View File
@@ -1,6 +1,6 @@
services:
trollssh:
image: ghcr.io/yuzuzensai/trollssh:v1.1.1
image: ghcr.io/yuzuzensai/trollssh:v1.1.3
container_name: trollssh
restart: unless-stopped
mem_limit: ${MEMORY_LIMIT:-2g}
+2
View File
@@ -33,6 +33,7 @@ type Config struct {
MaxTerminalCells int
SessionTimeout time.Duration
RenderCacheMB int
RenderCacheCompress bool
BrightnessThreshold int
Charset string
Invert bool
@@ -133,6 +134,7 @@ func Load() Config {
MaxTerminalCells: envInt("MAX_TERMINAL_CELLS", 500*512, 1, maxInt),
SessionTimeout: envDurationMs("SESSION_TIMEOUT", 10*time.Minute),
RenderCacheMB: envInt("RENDER_CACHE_MB", 256, 0, maxInt),
RenderCacheCompress: envBool("RENDER_CACHE_COMPRESS", false),
BrightnessThreshold: envInt("BRIGHTNESS_THRESHOLD", 40, 0, 100),
Charset: envString("CHARSET", "detailed"),
Invert: envBool("INVERT", false),
+61 -15
View File
@@ -2,7 +2,9 @@ package render
import (
"bytes"
"compress/flate"
"container/list"
"io"
"sync"
"sync/atomic"
"time"
@@ -27,6 +29,7 @@ type cacheShard struct {
type Cache struct {
shards []cacheShard
compress bool
size atomic.Int64
hits atomic.Uint64
misses atomic.Uint64
@@ -37,21 +40,56 @@ type Cache struct {
}
type cacheEntry struct {
key cacheKey
ascii []byte
cost int64
key cacheKey
data []byte
origLen int
cost int64
}
func entryCost(_ cacheKey, ascii []byte) int64 {
return int64(cap(ascii)) + 160
func entryCost(_ cacheKey, data []byte) int64 {
return int64(cap(data)) + 160
}
func NewCache(maxBytes int64) *Cache {
var flateWriters = sync.Pool{New: func() any {
w, _ := flate.NewWriter(io.Discard, 1)
return w
}}
type flateReader interface {
io.Reader
flate.Resetter
}
var flateReaders = sync.Pool{New: func() any {
return flate.NewReader(bytes.NewReader(nil)).(flateReader)
}}
func compressAscii(src []byte) []byte {
var buf bytes.Buffer
buf.Grow(len(src)/3 + 64)
w := flateWriters.Get().(*flate.Writer)
w.Reset(&buf)
_, _ = w.Write(src)
_ = w.Close()
flateWriters.Put(w)
return bytes.Clone(buf.Bytes())
}
func decompressAscii(src []byte, origLen int) []byte {
r := flateReaders.Get().(flateReader)
_ = r.Reset(bytes.NewReader(src), nil)
buf := bytes.NewBuffer(make([]byte, 0, origLen))
_, _ = io.Copy(buf, r)
flateReaders.Put(r)
return buf.Bytes()
}
func NewCache(maxBytes int64, compress bool) *Cache {
if maxBytes <= 0 {
return nil
}
shardCount := int(min(int64(16), max(int64(1), maxBytes/(1<<20))))
cache := &Cache{shards: make([]cacheShard, shardCount)}
cache := &Cache{shards: make([]cacheShard, shardCount), compress: compress}
for i := range cache.shards {
cache.shards[i] = cacheShard{
maxBytes: maxBytes / int64(shardCount),
@@ -82,15 +120,21 @@ func (c *Cache) get(key cacheKey) ([]byte, bool) {
}
shard := c.shard(key)
shard.mu.Lock()
defer shard.mu.Unlock()
el, ok := shard.entries[key]
if !ok {
shard.mu.Unlock()
c.misses.Add(1)
return nil, false
}
c.hits.Add(1)
shard.order.MoveToBack(el)
return el.Value.(*cacheEntry).ascii, true
entry := el.Value.(*cacheEntry)
data, origLen := entry.data, entry.origLen
shard.mu.Unlock()
c.hits.Add(1)
if !c.compress {
return data, true
}
return decompressAscii(data, origLen), true
}
func (c *Cache) put(key cacheKey, ascii []byte) {
@@ -98,7 +142,11 @@ func (c *Cache) put(key cacheKey, ascii []byte) {
return
}
shard := c.shard(key)
cost := entryCost(key, ascii)
data, origLen := ascii, 0
if c.compress {
data, origLen = compressAscii(ascii), len(ascii)
}
cost := entryCost(key, data)
if cost > shard.maxBytes {
c.rejections.Add(1)
return
@@ -108,7 +156,8 @@ func (c *Cache) put(key cacheKey, ascii []byte) {
if _, ok := shard.entries[key]; ok {
return
}
shard.entries[key] = shard.order.PushBack(&cacheEntry{key: key, ascii: ascii, cost: cost})
entry := &cacheEntry{key: key, data: data, origLen: origLen, cost: cost}
shard.entries[key] = shard.order.PushBack(entry)
shard.size += cost
c.size.Add(cost)
for shard.size > shard.maxBytes {
@@ -219,9 +268,6 @@ func (r *Renderer) Render(index, width, height int, keepAspectRatio bool, tier C
}
putPixBuf(pix)
if r.cache != nil && cap(ascii) > len(ascii)+len(ascii)/4 {
ascii = bytes.Clone(ascii)
}
r.cache.put(key, ascii)
if r.cache != nil {
r.cache.renders.Add(1)
+29 -21
View File
@@ -43,27 +43,35 @@ func BenchmarkRenderGray(b *testing.B) { benchRender(b, ColorTierNone, 120,
func BenchmarkRenderTrueBig(b *testing.B) { benchRender(b, ColorTierTrueColor, 240, 70) }
func BenchmarkRenderCachedParallel(b *testing.B) {
fc := loadBenchSet(b)
r := NewRenderer(0, fc.ColorFrames, Options{
BrightnessThreshold: 40,
Charset: "detailed",
}, NewCache(8<<20))
frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor)
if err != nil {
b.Fatal(err)
}
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
var failures atomic.Uint64
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := r.Render(0, 120, 40, false, ColorTierTrueColor); err != nil {
failures.Add(1)
}
for _, compress := range []bool{false, true} {
name := "uncompressed"
if compress {
name = "compressed"
}
})
if failures.Load() != 0 {
b.Fatalf("render failures: %d", failures.Load())
b.Run(name, func(b *testing.B) {
fc := loadBenchSet(b)
r := NewRenderer(0, fc.ColorFrames, Options{
BrightnessThreshold: 40,
Charset: "detailed",
}, NewCache(8<<20, compress))
frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor)
if err != nil {
b.Fatal(err)
}
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
var failures atomic.Uint64
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := r.Render(0, 120, 40, false, ColorTierTrueColor); err != nil {
failures.Add(1)
}
}
})
if failures.Load() != 0 {
b.Fatalf("render failures: %d", failures.Load())
}
})
}
}
+41 -13
View File
@@ -64,7 +64,7 @@ func TestRenderConcurrentSameKey(t *testing.T) {
r := NewRenderer(0, [][]byte{jpegBuf.Bytes()}, Options{
BrightnessThreshold: 40,
Charset: "standard",
}, NewCache(1<<20))
}, NewCache(1<<20, false))
var wg sync.WaitGroup
results := make([][]byte, 32)
@@ -88,12 +88,25 @@ func TestRenderConcurrentSameKey(t *testing.T) {
}
}
func incompressible(n int) []byte {
b := make([]byte, n)
x := uint32(0x9e3779b9)
for i := range b {
x ^= x << 13
x ^= x >> 17
x ^= x << 5
b[i] = byte(x)
}
return b
}
func TestRenderCacheEvictsByBytes(t *testing.T) {
key := func(index int) cacheKey { return cacheKey{index: index} }
budget := 3 * entryCost(key(0), bytes.Repeat([]byte("x"), 1000))
c := NewCache(budget)
payload := incompressible(1000)
budget := 3 * entryCost(key(0), compressAscii(payload))
c := NewCache(budget, true)
for i := range 5 {
c.put(key(i), bytes.Repeat([]byte("x"), 1000))
c.put(key(i), payload)
}
if c.size.Load() > budget {
t.Errorf("size %d exceeds budget %d", c.size.Load(), budget)
@@ -106,8 +119,23 @@ func TestRenderCacheEvictsByBytes(t *testing.T) {
}
}
func TestRenderCacheRoundTrips(t *testing.T) {
for _, compress := range []bool{false, true} {
c := NewCache(1<<20, compress)
want := incompressible(4096)
c.put(cacheKey{}, want)
got, ok := c.get(cacheKey{})
if !ok {
t.Fatalf("compress=%v: entry should be cached", compress)
}
if !bytes.Equal(got, want) {
t.Fatalf("compress=%v: entry does not match original", compress)
}
}
}
func TestRenderCacheDisabled(t *testing.T) {
c := NewCache(0)
c := NewCache(0, false)
if c != nil {
t.Fatal("zero budget should disable the cache")
}
@@ -118,8 +146,8 @@ func TestRenderCacheDisabled(t *testing.T) {
}
func TestRenderCacheRejectsOversizedEntry(t *testing.T) {
c := NewCache(256)
c.put(cacheKey{}, bytes.Repeat([]byte("x"), 10_000))
c := NewCache(256, true)
c.put(cacheKey{}, incompressible(10_000))
if _, ok := c.get(cacheKey{}); ok {
t.Error("entry larger than budget should not be cached")
}
@@ -128,15 +156,15 @@ func TestRenderCacheRejectsOversizedEntry(t *testing.T) {
}
}
func TestRenderCacheAccountsRetainedCapacity(t *testing.T) {
cache := NewCache(512)
func TestRenderCacheAccountsCompressedSize(t *testing.T) {
cache := NewCache(512, true)
value := make([]byte, 1, 4096)
cache.put(cacheKey{}, value)
if _, ok := cache.get(cacheKey{}); ok {
t.Fatal("cache accepted an entry whose backing allocation exceeds its budget")
if _, ok := cache.get(cacheKey{}); !ok {
t.Fatal("small value should be cached regardless of its backing capacity")
}
if cache.Stats().Rejections != 1 {
t.Fatalf("rejections = %d, want 1", cache.Stats().Rejections)
if got := cache.size.Load(); got > 512 {
t.Fatalf("size = %d, want <= 512", got)
}
}
+25 -6
View File
@@ -232,7 +232,7 @@ func clampTermSize(cols, rows, maxDimension, maxCells, quantum int) (int, int) {
func New(deps ServerDeps) *Server {
cfg := deps.Config
cache := render.NewCache(int64(cfg.RenderCacheMB) << 20)
cache := render.NewCache(int64(cfg.RenderCacheMB)<<20, cfg.RenderCacheCompress)
sets := make([]frameSet, len(deps.VideoSets))
for i, data := range deps.VideoSets {
sets[i] = frameSet{
@@ -447,17 +447,36 @@ type termSize struct {
width int
height int
updated time.Time
timer *time.Timer
}
func (t *termSize) set(w, h, maxDimension, maxCells int, force bool) {
width, height := clampTermSize(w, h, maxDimension, maxCells, terminalSizeQuantum)
t.mu.Lock()
if !force && time.Since(t.updated) < resizeDebounce {
t.mu.Unlock()
return
defer t.mu.Unlock()
if !force {
if remaining := resizeDebounce - time.Since(t.updated); remaining > 0 {
if t.timer != nil {
t.timer.Stop()
}
t.timer = time.AfterFunc(remaining, func() {
t.mu.Lock()
defer t.mu.Unlock()
t.width, t.height = width, height
t.updated = time.Now()
})
return
}
}
t.width, t.height = clampTermSize(w, h, maxDimension, maxCells, terminalSizeQuantum)
if t.timer != nil {
t.timer.Stop()
t.timer = nil
}
t.width, t.height = width, height
t.updated = time.Now()
t.mu.Unlock()
}
func (t *termSize) get() (int, int) {
+21
View File
@@ -3,6 +3,7 @@ package sshserver
import (
"sync"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
@@ -102,6 +103,26 @@ func TestTermSizeDebouncesResize(t *testing.T) {
}
}
func TestTermSizeAppliesFinalResizeAfterDebounce(t *testing.T) {
size := &termSize{}
size.set(80, 24, 512, 500*512, true)
// Rapid burst of resize events, as happens during an interactive drag-resize.
size.set(100, 40, 512, 500*512, false)
size.set(150, 60, 512, 500*512, false)
size.set(200, 100, 512, 500*512, false)
if w, h := size.get(); w != 80 || h != 24 {
t.Fatalf("size changed before debounce elapsed: %dx%d", w, h)
}
time.Sleep(resizeDebounce + 50*time.Millisecond)
if w, h := size.get(); w != 200 || h != 100 {
t.Fatalf("final resize was not applied after debounce: got %dx%d, want 200x100", w, h)
}
}
func TestClampTermSize(t *testing.T) {
w, h := clampTermSize(1000, 500, 512, 65536, 4)
if w < 1 || h < 1 || w > 512 || h > 512 || w*h > 65536 {
+3
View File
@@ -112,6 +112,9 @@ func Load(filename string) (*FramesContainer, error) {
if off != len(raw) {
return nil, invalid()
}
file.dropResident()
data := &FramesContainer{ColorFrames: colorFrames, FPS: fps}
frameFileOwners.Store(data, file)
owned = true
+2
View File
@@ -8,3 +8,5 @@ func readFrameFile(filename string) (*frameFile, error) {
data, err := os.ReadFile(filename)
return &frameFile{data: data}, err
}
func (f *frameFile) dropResident() {}
+9
View File
@@ -28,6 +28,8 @@ func readFrameFile(filename string) (*frameFile, error) {
data, err := os.ReadFile(filename)
return &frameFile{data: data}, err
}
_ = syscall.Madvise(data, syscall.MADV_RANDOM)
return &frameFile{
data: data,
cleanup: func() error {
@@ -35,3 +37,10 @@ func readFrameFile(filename string) (*frameFile, error) {
},
}, nil
}
func (f *frameFile) dropResident() {
if f == nil || f.cleanup == nil || len(f.data) == 0 {
return
}
_ = syscall.Madvise(f.data, syscall.MADV_DONTNEED)
}