Initial commit

This commit is contained in:
eleksir
2026-04-02 23:56:28 +03:00
commit 9e30243533
34 changed files with 8413 additions and 0 deletions

125
internal/server/conn.go Normal file
View File

@@ -0,0 +1,125 @@
package server
import (
"bufio"
"fmt"
"net"
"github.com/eleksir/pekoe/internal/protocol"
)
// Command represents a parsed Redis command with arguments.
type Command struct {
Args [][]byte
}
// ConnReader handles reading and parsing RESP commands from a connection.
// Uses bufio.Reader for efficient I/O with appropriate buffer size.
type ConnReader struct {
reader *bufio.Reader
maxBytes int
}
// NewConnReader creates a new connection reader with the specified max message size.
func NewConnReader(conn net.Conn, maxBytes int) *ConnReader {
return &ConnReader{
reader: bufio.NewReaderSize(conn, 16*1024),
maxBytes: maxBytes,
}
}
// ReadCommand reads and parses a complete Redis command from the connection.
// Returns a Command ready for processing, or an error if parsing fails.
// nolint: cyclop
func (r *ConnReader) ReadCommand() (*Command, error) {
val, err := protocol.Parse(r.reader)
if err != nil {
return nil, err
}
if val == nil {
return nil, fmt.Errorf("unexpected null value")
}
// RESP arrays represent commands - the first element is the command name
array, ok := val.(protocol.Array)
if !ok {
return nil, fmt.Errorf("expected array, got %T", val)
}
if len(array) == 0 {
return nil, fmt.Errorf("empty command array")
}
// Convert array elements to byte slices for command processing
cmd := &Command{
Args: make([][]byte, len(array)),
}
for i, v := range array {
var bytes []byte
switch item := v.(type) {
case protocol.Bulk:
bytes = item
case []byte:
bytes = item
case string:
bytes = []byte(item)
default:
return nil, fmt.Errorf("invalid argument type: %T", v)
}
// Enforce max message size to prevent memory exhaustion
if len(bytes) > r.maxBytes {
return nil, fmt.Errorf("argument too large: %d bytes (max: %d)", len(bytes), r.maxBytes)
}
cmd.Args[i] = bytes
}
return cmd, nil
}
// ConnWriter handles writing RESP responses to a connection.
// Uses buffered writer for efficiency - reduces syscalls.
type ConnWriter struct {
conn net.Conn
writer *bufio.Writer
}
// NewConnWriter creates a new connection writer with buffering.
func NewConnWriter(conn net.Conn) *ConnWriter {
return &ConnWriter{
conn: conn,
writer: bufio.NewWriterSize(conn, 16*1024),
}
}
// WriteStatus writes a simple status response (+OK).
func (w *ConnWriter) WriteStatus(status string) {
_, _ = w.writer.Write(protocol.MakeStatus(status))
}
// WriteError writes an error response (-ERR message).
func (w *ConnWriter) WriteError(err string) {
_, _ = w.writer.Write(protocol.MakeError(err))
}
// WriteInt writes an integer response (:12345).
func (w *ConnWriter) WriteInt(n int64) {
_, _ = w.writer.Write(protocol.MakeInt(n))
}
// WriteBulk writes a bulk string response ($<len>\r\n<data>).
func (w *ConnWriter) WriteBulk(data []byte) {
_, _ = w.writer.Write(protocol.MakeBulk(data))
}
// WriteArray writes a RESP array response - used for pub/sub confirmations.
func (w *ConnWriter) WriteArray(values []protocol.Value) {
_, _ = w.writer.Write(protocol.MakeMultiBulk(values))
}
// Flush writes all buffered data to the underlying connection.
func (w *ConnWriter) Flush() error {
return w.writer.Flush()
}

479
internal/server/handlers.go Normal file
View File

@@ -0,0 +1,479 @@
// @tag server
// @tag handlers:pubsub
package server
import (
"fmt"
"strings"
"github.com/eleksir/pekoe/internal/logger"
"github.com/eleksir/pekoe/internal/protocol"
"github.com/eleksir/pekoe/internal/pubsub"
)
// handleSubscribe processes SUBSCRIBE command.
// SUBSCRIBE channel [channel ...] - Subscribe to one or more channels.
// Returns immediately after subscription (doesn't block like PSUBSCRIBE).
func (s *Server) handleSubscribe(client *pubsub.Client, channels [][]byte, writer *ConnWriter) error {
if len(channels) == 0 {
writer.WriteError("ERR wrong number of arguments for 'subscribe' command")
return nil
}
// Subscribe to each channel specified
for _, ch := range channels {
channel := string(ch)
if s.hub.Subscribe(client, channel) {
logger.Debug("client=%s event=subscribe channel=%s", client.RemoteAddr(), channel)
// Send confirmation in format: ["subscribe", channel, count]
writer.WriteArray([]protocol.Value{
protocol.Bulk("subscribe"),
protocol.Bulk(channel),
protocol.Int(int64(client.SubsCount())),
})
}
}
return nil
}
// handleUnsubscribe processes UNSUBSCRIBE command.
// UNSUBSCRIBE [channel [channel ...]] - Unsubscribe from channels.
// If no channels specified, unsubscribe from all.
func (s *Server) handleUnsubscribe(client *pubsub.Client, channels [][]byte, writer *ConnWriter) error {
if len(channels) == 0 {
// Unsubscribe from all channels
chans := client.GetChannels()
if len(chans) == 0 {
// No subscriptions - send single unsubscribe message with -1 (Redis behavior)
writer.WriteArray([]protocol.Value{
protocol.Bulk("unsubscribe"),
protocol.Bulk("-1"),
protocol.Int(0),
})
return nil
}
for _, ch := range chans {
s.hub.Unsubscribe(client, ch)
writer.WriteArray([]protocol.Value{
protocol.Bulk("unsubscribe"),
protocol.Bulk(ch),
protocol.Int(int64(client.SubsCount())),
})
}
} else {
// Unsubscribe from specified channels - only send response if actually subscribed
for _, ch := range channels {
channel := string(ch)
removed := s.hub.Unsubscribe(client, channel)
if removed {
logger.Debug("client=%s event=unsubscribe channel=%s", client.RemoteAddr(), channel)
writer.WriteArray([]protocol.Value{
protocol.Bulk("unsubscribe"),
protocol.Bulk(channel),
protocol.Int(int64(client.SubsCount())),
})
}
}
}
return nil
}
// handlePublish processes PUBLISH command.
// PUBLISH channel message - Publish a message to a channel.
// Returns the number of subscribers who received the message.
func (s *Server) handlePublish(args [][]byte, writer *ConnWriter) error {
if len(args) != 2 {
writer.WriteError("ERR wrong number of arguments for 'publish' command")
return nil
}
channel := string(args[0])
message := args[1]
regularCount := s.hub.Publish(channel, message)
patternCount := s.hub.PublishToPattern(channel, message)
totalCount := regularCount + patternCount
logger.Debug("event=publish channel=%s regular=%d pattern=%d", channel, regularCount, patternCount)
writer.WriteInt(int64(totalCount))
return nil
}
// handlePubsub processes PUBSUB subcommands for introspection.
// nolint: cyclop
func (s *Server) handlePubsub(args [][]byte, writer *ConnWriter) error {
if len(args) == 0 {
writer.WriteError("ERR wrong number of arguments for 'pubsub' command")
return nil
}
subcmd := strings.ToLower(string(args[0]))
switch subcmd {
case "numsub":
// PUBSUB NUMSUB [channel ...] - Get subscriber count for channels
if len(args) < 2 {
writer.WriteError("ERR wrong number of arguments for 'pubsub numsub' command")
return nil
}
// Build response: [[channel, count], ...]
channels := args[1:]
result := make([]protocol.Value, 0, len(channels)*2)
for _, ch := range channels {
channel := string(ch)
count := s.hub.ChannelCount(channel)
result = append(result, protocol.Bulk(ch), protocol.Int(int64(count)))
}
writer.WriteArray(result)
case "channels":
// PUBSUB CHANNELS [pattern] - List active channels, optionally filtered
var pattern string
if len(args) > 1 {
pattern = string(args[1])
}
channels := s.channelList(pattern)
result := make([]protocol.Value, len(channels))
for i, ch := range channels {
result[i] = protocol.Bulk(ch)
}
writer.WriteArray(result)
case "numpat":
// PUBSUB NUMPAT - Count of unique pattern subscriptions
writer.WriteInt(int64(s.hub.PatternCount()))
case "help":
// PUBSUB HELP - Returns helpful text about pubsub subcommands
helpText := []string{
"PUBSUB <subcommand> [<argument> [<argument> ...]]>",
" PUBSUB CHANNELS [pattern] - List active channels (optionally filtered by pattern)",
" PUBSUB NUMSUB [channel ...] - Get subscriber count for channels",
" PUBSUB NUMPAT - Get the number of pattern subscriptions",
" PUBSUB HELP - Show this help message",
}
result := make([]protocol.Value, len(helpText))
for i, line := range helpText {
result[i] = protocol.Bulk(line)
}
writer.WriteArray(result)
default:
writer.WriteError(fmt.Sprintf("ERR unknown pubsub subcommand '%s'", subcmd))
}
return nil
}
// channelList returns all active channel names, optionally filtered by pattern.
func (s *Server) channelList(pattern string) []string {
channels := s.hub.ChannelList(pattern)
if channels == nil {
return []string{}
}
return channels
}
// handlePing processes PING command.
// PING [message] - Returns PONG or the optional message.
// Redis returns simple string for message, not bulk string.
func (s *Server) handlePing(args [][]byte, writer *ConnWriter) error {
if len(args) > 0 {
writer.WriteStatus(string(args[0]))
} else {
writer.WriteStatus("PONG")
}
return nil
}
// handleQuit processes QUIT command.
// QUIT - Close the connection gracefully.
func (s *Server) handleQuit(client *pubsub.Client, writer *ConnWriter) error {
writer.WriteStatus("OK")
return ErrQuit
}
// handleClient processes CLIENT command.
// CLIENT subcommand [argument [argument ...]]
// nolint: cyclop
func (s *Server) handleClient(client *pubsub.Client, args [][]byte, writer *ConnWriter) error {
if len(args) == 0 {
writer.WriteError("ERR wrong number of arguments for 'client' command")
return nil
}
subcmd := strings.ToLower(string(args[0]))
switch subcmd {
case "id":
// CLIENT ID - return the connection ID
//nolint: gosec // client ID is sequential, overflow is theoretical
writer.WriteInt(int64(client.ID()))
case "setname":
// CLIENT SETNAME connection-name
if len(args) < 2 {
writer.WriteError("ERR wrong number of arguments for 'client setname' command")
return nil
}
name := string(args[1])
// Validate name - must be printable characters and no spaces
if !isValidClientName(name) {
writer.WriteError("ERR Client names cannot contain spaces, null characters or names longer than 255 characters")
return nil
}
client.SetName(name)
writer.WriteStatus("OK")
case "getname":
// CLIENT GETNAME - return the connection name
name := client.GetName()
if name == "" {
writer.WriteBulk(nil)
} else {
writer.WriteBulk([]byte(name))
}
case "list":
// CLIENT LIST [TYPE normal|master|slave|pubsub] [ID min-id max-id]
// For pekoe, we only support pubsub clients
clientList := s.buildClientList(args[1:])
writer.WriteBulk([]byte(clientList))
case "info":
// CLIENT INFO - return information about the client
writer.WriteBulk([]byte(client.InfoString()))
case "kill":
// CLIENT KILL [ID min-id max-id] [ADDR ip:port] [LADDR ip:port] [TYPE normal|master|slave|pubsub] [USER username] [SKIPME yes|no]
count, err := s.handleClientKill(client, args[1:])
if err != nil {
writer.WriteError(err.Error())
return nil
}
writer.WriteInt(int64(count))
case "pause":
// CLIENT PAUSE timeout [WRITE|ALL]
// Acknowledge but don't actually implement - pubsub doesn't need this
writer.WriteStatus("OK")
case "unpause":
// CLIENT UNPAUSE
writer.WriteStatus("OK")
case "reply":
// CLIENT REPLY ON|OFF|SKIP
if len(args) < 2 {
writer.WriteError("ERR wrong number of arguments for 'client reply' command")
return nil
}
mode := strings.ToUpper(string(args[1]))
switch mode {
case "ON":
client.SetReplyMode(pubsub.ReplyOn)
writer.WriteStatus("OK")
case "OFF":
client.SetReplyMode(pubsub.ReplyOff)
// Don't send reply when setting to OFF
_ = writer.Flush()
case "SKIP":
client.SetReplyMode(pubsub.ReplySkip)
// Send one skip reply, then next will be skipped
writer.WriteStatus("OK")
default:
writer.WriteError("ERR wrong argument for 'client reply' command")
}
case "tracking":
// CLIENT TRACKING ON/OFF [REDIRECT client-id] [PREFIX prefix ...] [BCAST] [OPTIN] [OPTOUT] [NOLOOP]
// Acknowledge but don't implement - pekoe is pub/sub only
writer.WriteStatus("OK")
case "no-evict":
// CLIENT NO-EVICT ON|OFF
writer.WriteStatus("OK")
case "no-touch":
// CLIENT NO-TOUCH ON|OFF
writer.WriteStatus("OK")
case "caching":
// CLIENT CACHING YES|NO
writer.WriteStatus("OK")
default:
// Unknown client subcommand
writer.WriteError(fmt.Sprintf("ERR Unknown subcommand or wrong number of arguments for '%s'. Try CLIENT HELP.", subcmd))
}
return nil
}
// isValidClientName checks if a client name is valid per Redis spec
func isValidClientName(name string) bool {
if len(name) == 0 || len(name) > 255 {
return false
}
for _, c := range name {
if c < 32 || c > 126 || c == ' ' {
return false
}
}
return true
}
// buildClientList builds CLIENT LIST response for all or filtered clients
func (s *Server) buildClientList(args [][]byte) string {
clients := s.hub.GetAllClients()
// Parse optional filter arguments
var filterType string
for i := 0; i < len(args); i++ {
arg := strings.ToLower(string(args[i]))
if arg == "type" && i+1 < len(args) {
filterType = strings.ToLower(string(args[i+1]))
i++
}
}
var result string
for _, c := range clients {
// For pekoe, all clients are "pubsub" type
if filterType != "" && filterType != "pubsub" {
continue
}
result += c.ListString() + "\n"
}
return result
}
// handleClientKill handles CLIENT KILL subcommand
// nolint: cyclop
func (s *Server) handleClientKill(currentClient *pubsub.Client, args [][]byte) (int, error) {
clients := s.hub.GetAllClients()
killed := 0
// Parse arguments
var killID uint64
var killAddr string
var skipMe = true
for i := 0; i < len(args); i++ {
arg := strings.ToUpper(string(args[i]))
switch arg {
case "ID":
if i+2 < len(args) {
// ID min max format - kill all IDs in range
i += 2
} else if i+1 < len(args) {
// Single ID
if _, err := fmt.Sscanf(string(args[i+1]), "%d", &killID); err != nil {
return 0, fmt.Errorf("invalid client ID: %s", args[i+1])
}
i++
}
case "ADDR":
if i+1 < len(args) {
killAddr = string(args[i+1])
i++
}
case "SKIPME":
if i+1 < len(args) {
skipMe = strings.ToUpper(string(args[i+1])) == "YES"
i++
}
}
}
for _, c := range clients {
// Skip current client if requested
if skipMe && c.ID() == currentClient.ID() {
continue
}
shouldKill := false
if killID > 0 && c.ID() == killID {
shouldKill = true
}
if killAddr != "" && c.RemoteAddr() == killAddr {
shouldKill = true
}
if shouldKill {
// Unsubscribe and close
s.hub.UnsubscribeAll(c)
s.hub.UnregisterClient(c)
_ = c.Close()
killed++
}
}
return killed, nil
}
// handlePsubscribe processes PSUBSCRIBE command.
// PSUBSCRIBE pattern [pattern ...] - Subscribe to channels matching patterns.
func (s *Server) handlePsubscribe(client *pubsub.Client, patterns [][]byte, writer *ConnWriter) error {
if len(patterns) == 0 {
writer.WriteError("ERR wrong number of arguments for 'psubscribe' command")
return nil
}
for _, p := range patterns {
pattern := string(p)
ok, err := s.hub.PatternSubscribe(client, pattern)
if err != nil {
writer.WriteError(fmt.Sprintf("ERR invalid pattern: %v", err))
return nil
}
if ok {
logger.Debug("client=%s event=psubscribe pattern=%s", client.RemoteAddr(), pattern)
// Send confirmation in format: ["psubscribe", pattern, count]
writer.WriteArray([]protocol.Value{
protocol.Bulk("psubscribe"),
protocol.Bulk(pattern),
protocol.Int(int64(client.PatternSubsCount())),
})
}
}
return nil
}
// handlePunsubscribe processes PUNSUBSCRIBE command.
// PUNSUBSCRIBE [pattern [pattern ...]] - Unsubscribe from patterns.
func (s *Server) handlePunsubscribe(client *pubsub.Client, patterns [][]byte, writer *ConnWriter) error {
if len(patterns) == 0 {
// Unsubscribe from all patterns
pats := client.GetPatterns()
if len(pats) == 0 {
// No pattern subscriptions - send single punsubscribe message with -1 (Redis behavior)
writer.WriteArray([]protocol.Value{
protocol.Bulk("punsubscribe"),
protocol.Bulk("-1"),
protocol.Int(0),
})
return nil
}
for _, pattern := range pats {
s.hub.PatternUnsubscribe(client, pattern)
writer.WriteArray([]protocol.Value{
protocol.Bulk("punsubscribe"),
protocol.Bulk(pattern),
protocol.Int(int64(client.PatternSubsCount())),
})
}
} else {
// Unsubscribe from specified patterns - only send response if actually subscribed
for _, p := range patterns {
pattern := string(p)
removed := s.hub.PatternUnsubscribe(client, pattern)
if removed {
logger.Debug("client=%s event=punsubscribe pattern=%s", client.RemoteAddr(), pattern)
writer.WriteArray([]protocol.Value{
protocol.Bulk("punsubscribe"),
protocol.Bulk(pattern),
protocol.Int(int64(client.PatternSubsCount())),
})
}
}
}
return nil
}

325
internal/server/server.go Normal file
View File

@@ -0,0 +1,325 @@
// @tag server
// @tag core
package server
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/eleksir/pekoe/internal/config"
"github.com/eleksir/pekoe/internal/logger"
"github.com/eleksir/pekoe/internal/pubsub"
)
// Sentinel errors for connection handling
var (
ErrQuit = errors.New("client quit")
)
// Server is the main Pekoe server instance.
// It manages the TCP listener, connection lifecycle, and delegates to the pubsub hub.
type Server struct {
cfg *config.Config
hub pubsubHub
ln net.Listener
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
active atomic.Int64 // atomic counter for active connections
}
// PubSubHub defines the interface our server expects from the pubsub implementation.
// This allows for cleaner dependency injection and testing.
type pubsubHub interface {
Subscribe(client *pubsub.Client, channel string) bool
Unsubscribe(client *pubsub.Client, channel string) bool
UnsubscribeAll(client *pubsub.Client)
Publish(channel string, message []byte) int
PublishToPattern(channel string, message []byte) int
ChannelCount(channel string) int
ChannelList(pattern string) []string
TotalChannels() int
PatternSubscribe(client *pubsub.Client, pattern string) (bool, error)
PatternUnsubscribe(client *pubsub.Client, pattern string) bool
PatternCount() int
RegisterClient(client *pubsub.Client)
UnregisterClient(client *pubsub.Client)
GetClient(id uint64) *pubsub.Client
GetAllClients() []*pubsub.Client
ClientCount() int
}
// New creates a new Pekoe server with the given config and pubsub hub.
func New(cfg *config.Config, hub pubsubHub) *Server {
ctx, cancel := context.WithCancel(context.Background())
return &Server{
cfg: cfg,
hub: hub,
ctx: ctx,
cancel: cancel,
}
}
// Run starts the server and blocks until shutdown.
// Listens on the configured address, accepts connections, and handles them.
// Returns an error only if the initial listen fails.
func (s *Server) Run() error {
ln, err := net.Listen("tcp", s.cfg.Addr())
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", s.cfg.Addr(), err)
}
s.ln = ln
logger.Info("event=startup addr=%s max_clients=%d", s.cfg.Addr(), s.cfg.MaxClients)
// Create PID file if configured
if s.cfg.PidFile != "" {
if err := s.createPidFile(); err != nil {
logger.Error("event=pid_file_error error=%v", err)
// Don't fail startup, just log the error
} else {
logger.Info("event=pid_file_created path=%s pid=%d", s.cfg.PidFile, os.Getpid())
}
}
s.wg.Add(1)
go s.acceptLoop()
// Wait for shutdown signal
s.waitForSignal()
return nil
}
// createPidFile creates the PID file with the current process ID
func (s *Server) createPidFile() error {
pid := strconv.Itoa(os.Getpid())
//nolint: gosec
return os.WriteFile(s.cfg.PidFile, []byte(pid), 0644)
}
// removePidFile removes the PID file on shutdown
func (s *Server) removePidFile() {
if s.cfg.PidFile == "" {
return
}
if err := os.Remove(s.cfg.PidFile); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
logger.Error("event=pid_file_remove_error path=%s error=%v", s.cfg.PidFile, err)
}
} else {
logger.Info("event=pid_file_removed path=%s", s.cfg.PidFile)
}
}
// AcceptLoop runs in a goroutine and accepts incoming connections.
// Uses a loop with error handling to continuously accept connections until shutdown.
func (s *Server) acceptLoop() {
defer s.wg.Done()
for {
conn, err := s.ln.Accept()
if err != nil {
// Check if we're shutting down (context cancelled) vs real error
select {
case <-s.ctx.Done():
return
default:
}
// Handle temporary errors gracefully - they're often transient
//nolint: staticcheck
if ne, ok := err.(net.Error); ok && ne.Temporary() {
logger.Warn("event=accept_temporary error=%v retry_ms=5", err)
time.Sleep(5 * time.Millisecond)
continue
}
logger.Error("event=accept_error error=%v", err)
return
}
// Enforce max clients limit - protects server from resource exhaustion
if s.active.Load() >= int64(s.cfg.MaxClients) {
logger.Warn("event=max_clients_reached client=%s", conn.RemoteAddr())
_ = conn.Close()
continue
}
go s.handleConn(conn)
}
}
// HandleConn manages the lifecycle of a single client connection.
// Parses commands, processes them, and sends responses.
// Uses per-connection goroutine to allow concurrent client handling.
// nolint: cyclop
func (s *Server) handleConn(conn net.Conn) {
s.wg.Add(1)
defer s.wg.Done()
s.active.Add(1)
defer s.active.Add(-1)
// Create and register client
client := pubsub.NewClient(conn)
s.hub.RegisterClient(client)
defer func() {
s.hub.UnregisterClient(client)
s.hub.UnsubscribeAll(client)
_ = client.Close()
}()
logger.Info("client=%s event=connected client_id=%d", client.RemoteAddr(), client.ID())
// Set timeouts if configured
if s.cfg.ReadTimeoutDuration() > 0 {
_ = conn.SetReadDeadline(time.Now().Add(s.cfg.ReadTimeoutDuration()))
}
if s.cfg.WriteTimeoutDuration() > 0 {
_ = conn.SetWriteDeadline(time.Now().Add(s.cfg.WriteTimeoutDuration()))
}
reader := NewConnReader(conn, s.cfg.MaxMsgBytes)
writer := NewConnWriter(conn)
// Main command loop - process commands until error or disconnect
for {
// Check for shutdown before each command
select {
case <-s.ctx.Done():
return
default:
}
// Reset read deadline before each command if using timeouts
if s.cfg.ReadTimeoutDuration() > 0 {
_ = conn.SetReadDeadline(time.Now().Add(s.cfg.ReadTimeoutDuration()))
}
cmd, err := reader.ReadCommand()
if err != nil {
if err == io.EOF {
logger.Info("client=%s event=disconnected client_id=%d", client.RemoteAddr(), client.ID())
return // Client disconnected normally
}
logger.Error("client=%s error=protocol message=%v", client.RemoteAddr(), err)
writer.WriteError("Protocol error: " + err.Error())
_ = writer.Flush()
return
}
// Log incoming command at DEBUG level
cmdName := ""
if len(cmd.Args) > 0 {
cmdName = strings.ToLower(string(cmd.Args[0]))
}
logger.Debug("client=%s command=%s args=%d", client.RemoteAddr(), cmdName, len(cmd.Args)-1)
// Process command - handler returns error only on fatal issues
if err := s.processCommand(client, cmd, writer); err != nil {
if errors.Is(err, ErrQuit) {
_ = writer.Flush()
logger.Info("client=%s event=quit", client.RemoteAddr())
return // Graceful client disconnect via QUIT
}
logger.Error("client=%s error=processing message=%v", client.RemoteAddr(), err)
return
}
// Flush response - important for pubsub as we need immediate feedback
if s.cfg.WriteTimeoutDuration() > 0 {
_ = conn.SetWriteDeadline(time.Now().Add(s.cfg.WriteTimeoutDuration()))
}
if err := writer.Flush(); err != nil {
logger.Error("client=%s error=flush message=%v", client.RemoteAddr(), err)
return
}
}
}
// processCommand routes commands to their handlers.
// Each command type has its own handler for clarity.
// nolint: cyclop
func (s *Server) processCommand(client *pubsub.Client, cmd *Command, writer *ConnWriter) error {
if cmd == nil || len(cmd.Args) == 0 {
writer.WriteError("ERR empty command")
return nil
}
// Convert command name to lowercase for case-insensitive matching (Redis standard)
name := strings.ToLower(string(cmd.Args[0]))
switch name {
case "subscribe":
return s.handleSubscribe(client, cmd.Args[1:], writer)
case "unsubscribe":
return s.handleUnsubscribe(client, cmd.Args[1:], writer)
case "psubscribe":
return s.handlePsubscribe(client, cmd.Args[1:], writer)
case "punsubscribe":
return s.handlePunsubscribe(client, cmd.Args[1:], writer)
case "publish":
return s.handlePublish(cmd.Args[1:], writer)
case "pubsub":
return s.handlePubsub(cmd.Args[1:], writer)
case "ping":
return s.handlePing(cmd.Args[1:], writer)
case "quit":
return s.handleQuit(client, writer)
case "client":
return s.handleClient(client, cmd.Args[1:], writer)
default:
// Check if client is in pubsub mode - only pubsub commands allowed
if client != nil && (client.SubsCount() > 0 || client.PatternSubsCount() > 0) {
logger.Error("client=%s error=invalid_in_pubsub_mode command=%s", client.RemoteAddr(), name)
writer.WriteError("ERR Can't execute commands in pubsub mode")
return nil
}
clientAddr := ""
if client != nil {
clientAddr = client.RemoteAddr()
}
logger.Error("client=%s error=unknown_command command=%s", clientAddr, name)
writer.WriteError(fmt.Sprintf("ERR unknown command '%s'", name))
return nil
}
}
// waitForSignal listens for signals and handles them appropriately.
// On Unix: handles SIGINT, SIGTERM (shutdown), SIGHUP (reserved), SIGUSR1 (log reopen)
// On Windows: handles SIGINT, SIGTERM (shutdown), SIGHUP (log reopen)
func (s *Server) waitForSignal() {
sigCh := signalSetup()
for {
sig := <-sigCh
if s.handleSignal(sig) {
// Shutdown signal received - perform graceful shutdown
s.cancel()
// Close listener - causes Accept() to return error, stopping accept loop
if err := s.ln.Close(); err != nil {
logger.Error("event=listener_close_error error=%v", err)
}
// Wait for all active connections to finish
s.wg.Wait()
// Remove PID file
s.removePidFile()
logger.Info("event=shutdown_complete active_connections=%d", s.active.Load())
return
}
// Not a shutdown signal - continue listening
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
//go:build !windows
package server
import (
"os"
"os/signal"
"syscall"
"github.com/eleksir/pekoe/internal/logger"
)
// signalSetup configures signal handlers for Unix systems.
// Returns a channel that receives incoming signals.
func signalSetup() chan os.Signal {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
return sigCh
}
// handleSignal processes a received signal and returns true if the server should shutdown.
func (s *Server) handleSignal(sig os.Signal) bool {
switch sig {
case syscall.SIGUSR1:
// Reopen log files for log rotation
if err := logger.Reopen(); err != nil {
logger.Error("event=log_reopen_error error=%v", err)
} else {
logger.Info("event=log_reopened")
}
return false // Continue listening
case syscall.SIGINT, syscall.SIGTERM:
// Graceful shutdown
logger.Info("event=shutdown signal=%v", sig)
return true // Shutdown
case syscall.SIGHUP:
// Reserved for future config reload functionality
logger.Info("event=sighup_ignored message=reload not implemented")
return false // Continue listening
default:
return false
}
}

View File

@@ -0,0 +1,43 @@
//go:build windows
package server
import (
"os"
"os/signal"
"syscall"
"github.com/eleksir/pekoe/internal/logger"
)
// signalSetup configures signal handlers for Windows.
// Windows doesn't support SIGUSR1, so we only handle shutdown signals.
// For log rotation on Windows, use a different mechanism.
func signalSetup() chan os.Signal {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
return sigCh
}
// handleSignal processes a received signal and returns true if the server should shutdown.
func (s *Server) handleSignal(sig os.Signal) bool {
switch sig {
case syscall.SIGINT, syscall.SIGTERM:
// Graceful shutdown
logger.Info("event=shutdown signal=%v", sig)
return true // Shutdown
case syscall.SIGHUP:
// On Windows, SIGHUP can be used for log rotation since SIGUSR1 is not available
// Reopen log files
if err := logger.Reopen(); err != nil {
logger.Error("event=log_reopen_error error=%v", err)
} else {
logger.Info("event=log_reopened")
}
return false // Continue listening
default:
return false
}
}