Close memory holes, adjust tests
This commit is contained in:
@@ -546,6 +546,10 @@ func ensureSlotConnected(s *UserMasterSession, slotIdx int, background bool, cli
|
|||||||
IsBound: isBound,
|
IsBound: isBound,
|
||||||
MaxBufferSize: maxBuf,
|
MaxBufferSize: maxBuf,
|
||||||
}
|
}
|
||||||
|
if GlobalConfig != nil {
|
||||||
|
slot.AutoHoldDiscardLimit = GlobalConfig.Server.AutoHoldDiscardLimit
|
||||||
|
slot.AutoHoldBurstLimit = GlobalConfig.Server.AutoHoldBurstLimit
|
||||||
|
}
|
||||||
s.Slots[slotIdx] = slot
|
s.Slots[slotIdx] = slot
|
||||||
go upstreamReader(s, slot, slotIdx)
|
go upstreamReader(s, slot, slotIdx)
|
||||||
return slot
|
return slot
|
||||||
@@ -575,8 +579,7 @@ func connectOrResumeSlot(s *UserMasterSession, slotIdx int, clientConn net.Conn)
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
slot.Mu.Lock()
|
slot.Mu.Lock()
|
||||||
slot.ScreenChanged = false
|
slot.resetHoldAndLoopCountersLocked()
|
||||||
slot.IsHeld = false
|
|
||||||
if len(slot.LastScreen) > 0 {
|
if len(slot.LastScreen) > 0 {
|
||||||
_, _ = clientConn.Write(slot.LastScreen)
|
_, _ = 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
|
// Cap buffer at 64KB to avoid unbounded memory use from constant ticks
|
||||||
if len(recBuf) > 65536 {
|
if len(recBuf) > 65536 {
|
||||||
recBuf = recBuf[len(recBuf)-16384:]
|
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...)
|
recBuf = append(recBuf, data...)
|
||||||
for {
|
for {
|
||||||
@@ -870,8 +888,41 @@ func upstreamReader(s *UserMasterSession, slot *SessionSlot, slotIdx int) {
|
|||||||
continue
|
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 {
|
if isEraseWrite || len(slot.LastScreen) == 0 {
|
||||||
slot.LastScreen = bytes.Clone(screenData)
|
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 {
|
} else if len(slot.LastScreen)+len(screenData) <= maxBuf {
|
||||||
slot.LastScreen = append(slot.LastScreen, screenData...)
|
slot.LastScreen = append(slot.LastScreen, screenData...)
|
||||||
} else {
|
} else {
|
||||||
@@ -886,6 +937,33 @@ func upstreamReader(s *UserMasterSession, slot *SessionSlot, slotIdx int) {
|
|||||||
newBuf = append(newBuf, slot.LastScreen[:baseLen]...)
|
newBuf = append(newBuf, slot.LastScreen[:baseLen]...)
|
||||||
newBuf = append(newBuf, screenData...)
|
newBuf = append(newBuf, screenData...)
|
||||||
slot.LastScreen = newBuf
|
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 {
|
if !isActive {
|
||||||
slot.ScreenChanged = true
|
slot.ScreenChanged = true
|
||||||
|
|||||||
+256
@@ -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
@@ -309,9 +309,11 @@ func runMenuLoop(s *UserMasterSession, clientConn net.Conn) {
|
|||||||
if idx < len(s.Slots) && s.Slots[idx] != nil {
|
if idx < len(s.Slots) && s.Slots[idx] != nil {
|
||||||
slot := s.Slots[idx]
|
slot := s.Slots[idx]
|
||||||
slot.Mu.Lock()
|
slot.Mu.Lock()
|
||||||
slot.IsHeld = hold
|
|
||||||
if hold {
|
if hold {
|
||||||
|
slot.IsHeld = true
|
||||||
slot.ScreenChanged = false
|
slot.ScreenChanged = false
|
||||||
|
} else {
|
||||||
|
slot.resetHoldAndLoopCountersLocked()
|
||||||
}
|
}
|
||||||
slot.Mu.Unlock()
|
slot.Mu.Unlock()
|
||||||
if hold {
|
if hold {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/racingmars/go3270"
|
"github.com/racingmars/go3270"
|
||||||
)
|
)
|
||||||
@@ -114,6 +115,8 @@ type ServerConfig struct {
|
|||||||
CertFile string `toml:"cert_file"`
|
CertFile string `toml:"cert_file"`
|
||||||
KeyFile string `toml:"key_file"`
|
KeyFile string `toml:"key_file"`
|
||||||
Public bool `toml:"public"`
|
Public bool `toml:"public"`
|
||||||
|
AutoHoldDiscardLimit int `toml:"auto_hold_discard_limit"`
|
||||||
|
AutoHoldBurstLimit int `toml:"auto_hold_burst_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginConfig struct {
|
type LoginConfig struct {
|
||||||
@@ -146,6 +149,13 @@ type Config struct {
|
|||||||
Users []UserConfig `toml:"users"`
|
Users []UserConfig `toml:"users"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAutoHoldDiscardLimit = 5
|
||||||
|
autoHoldDiscardWindow = 5 * time.Second
|
||||||
|
defaultAutoHoldBurstLimit = 20
|
||||||
|
autoHoldBurstWindow = 1 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type SessionSlot struct {
|
type SessionSlot struct {
|
||||||
System TargetSystem
|
System TargetSystem
|
||||||
Upstream net.Conn
|
Upstream net.Conn
|
||||||
@@ -156,9 +166,24 @@ type SessionSlot struct {
|
|||||||
SeqNumber uint16
|
SeqNumber uint16
|
||||||
IsHeld bool
|
IsHeld bool
|
||||||
MaxBufferSize int
|
MaxBufferSize int
|
||||||
|
DiscardCount int
|
||||||
|
DiscardWindow time.Time
|
||||||
|
WriteBurstCount int
|
||||||
|
WriteBurstWindow time.Time
|
||||||
|
AutoHoldDiscardLimit int
|
||||||
|
AutoHoldBurstLimit int
|
||||||
Mu sync.Mutex
|
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 {
|
type slotRenderInfo struct {
|
||||||
status string
|
status string
|
||||||
color go3270.Color
|
color go3270.Color
|
||||||
|
|||||||
Reference in New Issue
Block a user