Implementation 1 of a minimalistic IRC server

This commit is contained in:
2025-09-26 20:36:54 -07:00
commit d340bc49da
18 changed files with 1082 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
package transport
import (
"context"
"sync"
)
type ClientId uint64
type ConnectedClients struct {
mutex sync.Mutex
nextId ClientId
table map[ClientId]*ConnectedClient
}
type ConnectedClient struct {
cancel context.CancelCauseFunc
outgoingMessages chan<- OutgoingMessage
}
func newConnectedClients() ConnectedClients {
return ConnectedClients{
mutex: sync.Mutex{},
nextId: 1,
table: make(map[ClientId]*ConnectedClient),
}
}
func (cc *ConnectedClients) Enroll(callback func(ClientId) ConnectedClient) ClientId {
cc.mutex.Lock()
defer cc.mutex.Unlock()
clientId := cc.nextId
cc.nextId += 1
newClient := callback(clientId)
cc.table[clientId] = &newClient
return clientId
}
func (cc *ConnectedClients) Unenroll(clientId ClientId) {
cc.mutex.Lock()
defer cc.mutex.Unlock()
delete(cc.table, clientId)
}
func (cc *ConnectedClients) BorrowIfPresent(clientId ClientId, callback func(*ConnectedClient)) {
cc.mutex.Lock()
defer cc.mutex.Unlock()
client, ok := cc.table[clientId]
if !ok {
return
}
callback(client)
}

17
src/transport/messages.go Normal file
View File

@@ -0,0 +1,17 @@
package transport
type IncomingMessage struct {
Sender ClientId
Content Content
}
type OutgoingMessage struct {
Recipient ClientId
Content Content
}
type Content struct {
Source *string
Command string
Arguments []string
}

View File

@@ -0,0 +1,30 @@
package transport
import (
"bufio"
"io"
)
type lineByLineItem struct {
Line string
Error error
}
func readLineByLine(reader io.Reader) <-chan lineByLineItem {
bufReader := bufio.NewReader(reader)
channel := make(chan lineByLineItem)
go (func() {
defer close(channel)
for {
line, err := bufReader.ReadString('\n')
channel <- lineByLineItem{Line: line, Error: err}
if err != nil {
return
}
}
})()
return channel
}

117
src/transport/parsing.go Normal file
View File

@@ -0,0 +1,117 @@
package transport
import (
"fmt"
"strings"
)
var ErrInvalidIncomingMessage = fmt.Errorf("invalid content in ingoing message")
func Deserialize(line string) (*Content, error) {
line, found := strings.CutSuffix(line, "\r\n")
if !found {
return nil, fmt.Errorf("%w: all IRC messages should be terminated by \\r\\n (%s)", ErrInvalidIncomingMessage, line)
}
if line == "" {
// blank line
return nil, nil
}
var p = &parser{basis: line, index: 0}
var source *string
if p.Pop(":") {
src := p.PopWhile(isNotWhitespace)
source = &src
p.PopWhile(isWhitespace)
if len(src) == 0 {
return nil, p.NewError("zero-length source")
}
}
command := p.PopWhile(isNotWhitespace)
p.PopWhile(isWhitespace)
if len(command) == 0 {
return nil, p.NewError("zero-length command")
}
var args []string
for !p.Depleted() {
var arg string
if p.Pop(":") {
arg = p.PopWhile(isNotNewline)
} else {
arg = p.PopWhile(isNotWhitespace)
if len(arg) == 0 {
return nil, p.NewError("zero-length arg in non-final position")
}
}
p.PopWhile(isWhitespace)
args = append(args, arg)
}
return &Content{
Source: source,
Command: strings.ToUpper(command),
Arguments: args,
}, nil
}
type parser struct {
basis string
index int
}
func (p *parser) Depleted() bool {
return p.index >= len(p.basis)
}
func (p *parser) Pop(s string) bool {
n := len(s)
if p.index+n > len(p.basis) {
return false
}
if p.basis[p.index:p.index+n] == s {
p.index += n
return true
}
return false
}
func (p *parser) PopWhile(pred func(byte) bool) string {
start := p.index
end := start
for {
if end < len(p.basis) && pred(p.basis[end]) {
end = end + 1
} else {
break
}
}
p.index = end
return p.basis[start:end]
}
func (p *parser) NewError(msg string) error {
return fmt.Errorf("%w: %s (%s, %d)", ErrInvalidIncomingMessage, msg, p.basis, p.index)
}
func isNotWhitespace(b byte) bool {
return !isWhitespace(b)
}
func isWhitespace(b byte) bool {
return b == '\n' || b == '\r' || b == ' '
}
func isNotNewline(b byte) bool {
return !isNewline(b)
}
func isNewline(b byte) bool {
return b == '\n' || b == '\r'
}

View File

@@ -0,0 +1,66 @@
package transport
import (
"fmt"
"strings"
)
var ErrInvalidContent = fmt.Errorf("invalid content in message")
func (c Content) Serialize() (*string, error) {
var builder strings.Builder
if c.Source != nil {
src := *c.Source
builder.WriteString(":")
err := writeDisallowingWhitespace(&builder, src, "space in source")
if err != nil {
return nil, err
}
builder.WriteByte(' ')
}
err := writeDisallowingWhitespace(&builder, c.Command, "space in command")
if err != nil {
return nil, err
}
for ix, arg := range c.Arguments {
builder.WriteByte(' ')
isLast := ix == len(c.Arguments)-1
if isLast {
builder.WriteString(":")
writeDisallowingNewlines(&builder, arg, "newline in final arg")
} else {
writeDisallowingWhitespace(&builder, arg, "space in non-final arg")
}
}
builder.WriteString("\r\n")
result := builder.String()
return &result, nil
}
func writeDisallowingWhitespace(sb *strings.Builder, s string, msg string) error {
if containsWhitespace(s) {
return fmt.Errorf("%w: %s (%s)", ErrInvalidContent, s, msg)
}
sb.WriteString(s)
return nil
}
func writeDisallowingNewlines(sb *strings.Builder, s string, msg string) error {
if containsNewlines(s) {
return fmt.Errorf("%w: %s (%s)", ErrInvalidContent, s, msg)
}
sb.WriteString(s)
return nil
}
func containsWhitespace(s string) bool {
return strings.Contains(s, " ") || strings.Contains(s, "\n") || strings.Contains(s, "\r")
}
func containsNewlines(s string) bool {
return strings.Contains(s, "\n") || strings.Contains(s, "\r")
}

152
src/transport/server.go Normal file
View File

@@ -0,0 +1,152 @@
package transport
import (
"bufio"
"context"
"fmt"
"log"
"net"
)
type Server struct {
ctx context.Context
cancel context.CancelCauseFunc
connectedClients ConnectedClients
incomingMessages chan IncomingMessage
}
var ErrAlreadyClosed = fmt.Errorf("server already closed")
func NewServer(address string) (*Server, error) {
ctx, cancel := context.WithCancelCause(context.Background())
listener, err := net.Listen("tcp", address)
if err != nil {
return nil, err
}
server := &Server{
ctx: ctx,
cancel: cancel,
connectedClients: newConnectedClients(),
incomingMessages: make(chan IncomingMessage),
}
go (func() {
for {
connection, err := listener.Accept()
if err != nil {
cancel(err)
return
}
go server.handleConnection(connection)
}
})()
return server, nil
}
func (server *Server) Close() {
close(server.incomingMessages)
server.cancel(nil)
}
func (server *Server) handleConnection(conn net.Conn) {
defer conn.Close()
clientCtx, cancel := context.WithCancelCause(server.ctx)
outgoingMessages := make(chan OutgoingMessage)
clientId := server.connectedClients.Enroll(func(id ClientId) ConnectedClient {
return ConnectedClient{
cancel: cancel,
outgoingMessages: outgoingMessages,
}
})
defer server.connectedClients.Unenroll(clientId)
go (func() {
<-clientCtx.Done()
cause := context.Cause(clientCtx)
log.Printf("client %d done: %s", clientId, cause)
})()
ingoingLines := readLineByLine(conn)
outgoingLines := bufio.NewWriter(conn)
for {
select {
case item := <-ingoingLines:
line := item.Line
err := item.Error
if err != nil {
cancel(err)
continue
}
msg, err := Deserialize(line)
if err != nil {
cancel(err)
continue
}
if msg == nil {
continue
}
log.Printf("recv: %v", msg)
server.incomingMessages <- IncomingMessage{
Sender: clientId,
Content: *msg,
}
case outgoing := <-outgoingMessages:
log.Printf("sent: %v", outgoing.Content)
content, err := outgoing.Content.Serialize()
if err != nil {
cancel(err)
continue
}
log.Printf("content: %s", *content)
_, err = outgoingLines.WriteString(*content)
if err != nil {
cancel(err)
continue
}
// TODO: Don't flush on every iteration
err = outgoingLines.Flush()
if err != nil {
cancel(err)
continue
}
case <-clientCtx.Done():
return
}
}
}
func (server *Server) ReceiveMessage() (IncomingMessage, error) {
message, ok := <-server.incomingMessages
if !ok {
return IncomingMessage{}, ErrAlreadyClosed
}
return message, nil
}
func (server *Server) SendMessage(client ClientId, content Content) {
outgoing := OutgoingMessage{
Recipient: client,
Content: content,
}
server.connectedClients.BorrowIfPresent(client, func(connectedClient *ConnectedClient) {
connectedClient.outgoingMessages <- outgoing
})
}
func (server *Server) TerminateClient(client ClientId, err error) {
server.connectedClients.BorrowIfPresent(client, func(connectedClient *ConnectedClient) {
connectedClient.cancel(err)
})
}