Close memory holes, adjust tests

This commit is contained in:
2026-10-19 00:42:19 +00:00
parent 466290d316
commit a81044322b
4 changed files with 380 additions and 19 deletions
+80 -2
View File
@@ -546,6 +546,10 @@ func ensureSlotConnected(s *UserMasterSession, slotIdx int, background bool, cli
IsBound: isBound,
MaxBufferSize: maxBuf,
}
if GlobalConfig != nil {
slot.AutoHoldDiscardLimit = GlobalConfig.Server.AutoHoldDiscardLimit
slot.AutoHoldBurstLimit = GlobalConfig.Server.AutoHoldBurstLimit
}
s.Slots[slotIdx] = slot
go upstreamReader(s, slot, slotIdx)
return slot
@@ -575,8 +579,7 @@ func connectOrResumeSlot(s *UserMasterSession, slotIdx int, clientConn net.Conn)
}()
slot.Mu.Lock()
slot.ScreenChanged = false
slot.IsHeld = false
slot.resetHoldAndLoopCountersLocked()
if len(slot.LastScreen) > 0 {
_, _ = clientConn.Write(slot.LastScreen)
}
@@ -781,6 +784,21 @@ func upstreamReader(s *UserMasterSession, slot *SessionSlot, slotIdx int) {
// Cap buffer at 64KB to avoid unbounded memory use from constant ticks
if len(recBuf) > 65536 {
recBuf = recBuf[len(recBuf)-16384:]
if !isActive {
slot.Mu.Lock()
slot.DiscardCount++
discardLimit := slot.AutoHoldDiscardLimit
if discardLimit == 0 {
discardLimit = defaultAutoHoldDiscardLimit
}
if discardLimit > 0 && slot.DiscardCount >= discardLimit {
slot.IsHeld = true
slot.ScreenChanged = false
slot.DiscardCount = 0
log.Printf("[%s] '%s' System [%d] %s placed on automatic HOLD (raw receive buffer discard loop detected)", displayTag, username, slotIdx+1, slot.System.Name)
}
slot.Mu.Unlock()
}
}
recBuf = append(recBuf, data...)
for {
@@ -870,8 +888,41 @@ func upstreamReader(s *UserMasterSession, slot *SessionSlot, slotIdx int) {
continue
}
now := time.Now()
// Auto-hold loop detection for background sessions:
// 1. High frequency write updates (excessive update rate loop)
if !isActive {
burstLimit := slot.AutoHoldBurstLimit
if burstLimit == 0 {
burstLimit = defaultAutoHoldBurstLimit
}
if burstLimit > 0 {
if slot.WriteBurstWindow.IsZero() || now.Sub(slot.WriteBurstWindow) > autoHoldBurstWindow {
slot.WriteBurstCount = 1
slot.WriteBurstWindow = now
} else {
slot.WriteBurstCount++
}
if slot.WriteBurstCount >= burstLimit {
slot.IsHeld = true
slot.ScreenChanged = false
slot.WriteBurstCount = 0
slot.DiscardCount = 0
log.Printf("[%s] '%s' System [%d] %s placed on automatic HOLD (excessive update rate detected)", displayTag, username, slotIdx+1, slot.System.Name)
if isEraseWrite || len(slot.LastScreen) == 0 {
slot.LastScreen = bytes.Clone(screenData)
}
slot.Mu.Unlock()
continue
}
}
}
if isEraseWrite || len(slot.LastScreen) == 0 {
slot.LastScreen = bytes.Clone(screenData)
slot.DiscardCount = 0
} else if len(slot.LastScreen)+len(screenData) <= maxBuf {
slot.LastScreen = append(slot.LastScreen, screenData...)
} else {
@@ -886,6 +937,33 @@ func upstreamReader(s *UserMasterSession, slot *SessionSlot, slotIdx int) {
newBuf = append(newBuf, slot.LastScreen[:baseLen]...)
newBuf = append(newBuf, screenData...)
slot.LastScreen = newBuf
// Auto-hold loop detection for background sessions:
// 2. Buffer overflow discard loop (repeated discards of older intermediate writes)
if !isActive {
discardLimit := slot.AutoHoldDiscardLimit
if discardLimit == 0 {
discardLimit = defaultAutoHoldDiscardLimit
}
if discardLimit > 0 {
if slot.DiscardWindow.IsZero() || now.Sub(slot.DiscardWindow) > autoHoldDiscardWindow {
slot.DiscardCount = 1
slot.DiscardWindow = now
} else {
slot.DiscardCount++
}
if slot.DiscardCount >= discardLimit {
slot.IsHeld = true
slot.ScreenChanged = false
slot.DiscardCount = 0
slot.WriteBurstCount = 0
log.Printf("[%s] '%s' System [%d] %s placed on automatic HOLD (discard loop detected)", displayTag, username, slotIdx+1, slot.System.Name)
slot.Mu.Unlock()
continue
}
}
}
}
if !isActive {
slot.ScreenChanged = true
+256
View File
@@ -3635,3 +3635,259 @@ func TestCleanupSessionIfEmpty(t *testing.T) {
}
}
func TestAutoHoldOnDiscardLoopBufferOverflow(t *testing.T) {
origSystems := ConfiguredSystem
defer func() {
ConfiguredSystem = origSystems
}()
ConfiguredSystem = []TargetSystem{
{Name: "DISCARD_SYS", Description: "Discard loop system"},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'E', IAC, EOR}
slot := &SessionSlot{
System: ConfiguredSystem[0],
Upstream: upClient,
LastScreen: initialScreen,
MaxBufferSize: 200, // Small buffer so it easily overflows
AutoHoldBurstLimit: -1, // Disable burst rate check to test discard check specifically
AutoHoldDiscardLimit: 3, // 3 discards triggers auto-hold
}
s := &UserMasterSession{
Username: "DISCARDUSER",
DisplayTag: "disc1234",
TerminalType: "IBM-3278-2",
ClientConn: clientServer,
ActiveSlotIdx: -1, // Background
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(s, slot, 0)
// Send 10 writes of 60 bytes each (each > 200/2, quickly overflowing 200-byte MaxBufferSize)
writePayload := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x60}
for i := 0; i < 50; i++ {
writePayload = append(writePayload, 'X')
}
writePayload = append(writePayload, IAC, EOR)
for i := 0; i < 10; i++ {
_, err := upServer.Write(writePayload)
if err != nil {
t.Fatalf("Failed to write payload %d: %v", i, err)
}
time.Sleep(10 * time.Millisecond)
}
time.Sleep(150 * time.Millisecond)
slot.Mu.Lock()
isHeld := slot.IsHeld
screenChanged := slot.ScreenChanged
slot.Mu.Unlock()
if !isHeld {
t.Fatal("Expected slot.IsHeld == true after buffer overflow discard loop")
}
if screenChanged {
t.Fatal("Expected slot.ScreenChanged == false once placed on automatic HOLD")
}
// Verify status in main menu is rendered as "HELD" with color Red
screen := buildMainMenuScreen(s)
var rowHeld *go3270.Field
for i := range screen {
f := &screen[i]
if f.Row == 7 && strings.Contains(f.Content, "DISCARD_SYS") {
rowHeld = f
break
}
}
if rowHeld == nil {
t.Fatal("Expected row for DISCARD_SYS on row 7")
}
if !strings.Contains(rowHeld.Content, "HELD") {
t.Fatalf("Expected row content to contain 'HELD', got: %s", rowHeld.Content)
}
if rowHeld.Color != go3270.Red {
t.Fatalf("Expected HELD row color to be Red, got: %v", rowHeld.Color)
}
// Releasing the hold resets counters
slot.Mu.Lock()
slot.resetHoldAndLoopCountersLocked()
isHeldAfterReset := slot.IsHeld
discardCountAfterReset := slot.DiscardCount
slot.Mu.Unlock()
if isHeldAfterReset {
t.Fatal("Expected IsHeld == false after resetHoldAndLoopCountersLocked")
}
if discardCountAfterReset != 0 {
t.Fatalf("Expected DiscardCount == 0, got %d", discardCountAfterReset)
}
}
func TestAutoHoldOnExcessiveUpdateRate(t *testing.T) {
origSystems := ConfiguredSystem
defer func() {
ConfiguredSystem = origSystems
}()
ConfiguredSystem = []TargetSystem{
{Name: "BURST_SYS", Description: "Burst loop system"},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'E', IAC, EOR}
slot := &SessionSlot{
System: ConfiguredSystem[0],
Upstream: upClient,
LastScreen: initialScreen,
MaxBufferSize: 32768, // Huge buffer, will NOT overflow
AutoHoldBurstLimit: 5, // 5 rapid updates triggers auto-hold
AutoHoldDiscardLimit: -1, // Disable discard check
}
s := &UserMasterSession{
Username: "BURSTUSER",
DisplayTag: "brst1234",
TerminalType: "IBM-3278-2",
ClientConn: clientServer,
ActiveSlotIdx: -1, // Background
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(s, slot, 0)
// Send 6 small updates in rapid succession
smallUpdate := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x50, '1', IAC, EOR}
for i := 0; i < 6; i++ {
_, err := upServer.Write(smallUpdate)
if err != nil {
t.Fatalf("Failed to write small update %d: %v", i, err)
}
}
time.Sleep(100 * time.Millisecond)
slot.Mu.Lock()
isHeld := slot.IsHeld
screenChanged := slot.ScreenChanged
slot.Mu.Unlock()
if !isHeld {
t.Fatal("Expected slot.IsHeld == true after excessive update rate loop")
}
if screenChanged {
t.Fatal("Expected slot.ScreenChanged == false once placed on automatic HOLD")
}
// Menu renders HELD
screen := buildMainMenuScreen(s)
var rowHeld *go3270.Field
for i := range screen {
f := &screen[i]
if f.Row == 7 && strings.Contains(f.Content, "BURST_SYS") {
rowHeld = f
break
}
}
if rowHeld == nil {
t.Fatal("Expected row for BURST_SYS on row 7")
}
if !strings.Contains(rowHeld.Content, "HELD") {
t.Fatalf("Expected row content to contain 'HELD', got: %s", rowHeld.Content)
}
}
func TestActiveSlotDoesNotAutoHold(t *testing.T) {
origSystems := ConfiguredSystem
defer func() {
ConfiguredSystem = origSystems
}()
ConfiguredSystem = []TargetSystem{
{Name: "ACTIVE_SYS", Description: "Active interactive system"},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
// Drain clientUser in background
go func() {
buf := make([]byte, 4096)
for {
_, err := clientUser.Read(buf)
if err != nil {
return
}
}
}()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'E', IAC, EOR}
slot := &SessionSlot{
System: ConfiguredSystem[0],
Upstream: upClient,
LastScreen: initialScreen,
MaxBufferSize: 200,
AutoHoldBurstLimit: 5,
}
s := &UserMasterSession{
Username: "ACTIVEUSER",
DisplayTag: "actv1234",
TerminalType: "IBM-3278-2",
ClientConn: clientServer,
ActiveSlotIdx: 0, // ACTIVE (user is viewing slot 0)
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(s, slot, 0)
// Send 10 writes rapidly
smallUpdate := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x50, 'Z', IAC, EOR}
for i := 0; i < 10; i++ {
_, err := upServer.Write(smallUpdate)
if err != nil {
t.Fatalf("Failed to write update %d: %v", i, err)
}
}
time.Sleep(100 * time.Millisecond)
slot.Mu.Lock()
isHeld := slot.IsHeld
slot.Mu.Unlock()
if isHeld {
t.Fatal("Expected active slot (user watching) to NOT be placed on automatic HOLD")
}
}
+3 -1
View File
@@ -309,9 +309,11 @@ func runMenuLoop(s *UserMasterSession, clientConn net.Conn) {
if idx < len(s.Slots) && s.Slots[idx] != nil {
slot := s.Slots[idx]
slot.Mu.Lock()
slot.IsHeld = hold
if hold {
slot.IsHeld = true
slot.ScreenChanged = false
} else {
slot.resetHoldAndLoopCountersLocked()
}
slot.Mu.Unlock()
if hold {
+25
View File
@@ -3,6 +3,7 @@ package main
import (
"net"
"sync"
"time"
"github.com/racingmars/go3270"
)
@@ -114,6 +115,8 @@ type ServerConfig struct {
CertFile string `toml:"cert_file"`
KeyFile string `toml:"key_file"`
Public bool `toml:"public"`
AutoHoldDiscardLimit int `toml:"auto_hold_discard_limit"`
AutoHoldBurstLimit int `toml:"auto_hold_burst_limit"`
}
type LoginConfig struct {
@@ -146,6 +149,13 @@ type Config struct {
Users []UserConfig `toml:"users"`
}
const (
defaultAutoHoldDiscardLimit = 5
autoHoldDiscardWindow = 5 * time.Second
defaultAutoHoldBurstLimit = 20
autoHoldBurstWindow = 1 * time.Second
)
type SessionSlot struct {
System TargetSystem
Upstream net.Conn
@@ -156,9 +166,24 @@ type SessionSlot struct {
SeqNumber uint16
IsHeld bool
MaxBufferSize int
DiscardCount int
DiscardWindow time.Time
WriteBurstCount int
WriteBurstWindow time.Time
AutoHoldDiscardLimit int
AutoHoldBurstLimit int
Mu sync.Mutex
}
func (s *SessionSlot) resetHoldAndLoopCountersLocked() {
s.IsHeld = false
s.ScreenChanged = false
s.DiscardCount = 0
s.DiscardWindow = time.Time{}
s.WriteBurstCount = 0
s.WriteBurstWindow = time.Time{}
}
type slotRenderInfo struct {
status string
color go3270.Color