Implementation 1 of a minimalistic IRC server
This commit is contained in:
60
src/transport/connectedClients.go
Normal file
60
src/transport/connectedClients.go
Normal 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
17
src/transport/messages.go
Normal 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
|
||||
}
|
30
src/transport/networkingUtilities.go
Normal file
30
src/transport/networkingUtilities.go
Normal 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
117
src/transport/parsing.go
Normal 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'
|
||||
}
|
66
src/transport/serialization.go
Normal file
66
src/transport/serialization.go
Normal 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
152
src/transport/server.go
Normal 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)
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user