diff options
author | Přemysl Janouch <p@janouch.name> | 2018-07-29 07:50:27 +0200 |
---|---|---|
committer | Přemysl Janouch <p@janouch.name> | 2018-07-29 08:14:07 +0200 |
commit | 208a8fcc7e48c64315977a92011ddd63c4a68eda (patch) | |
tree | f4c1e7ae3b96c43eb330e13388528342e655d9fc | |
parent | 2d287752d4b17ebac1db25bc6a013215e8b32162 (diff) | |
download | xK-208a8fcc7e48c64315977a92011ddd63c4a68eda.tar.gz xK-208a8fcc7e48c64315977a92011ddd63c4a68eda.tar.xz xK-208a8fcc7e48c64315977a92011ddd63c4a68eda.zip |
hid: first round of mixed fixes and cleanups
-rw-r--r-- | xS/main.go | 460 |
1 files changed, 253 insertions, 207 deletions
@@ -16,55 +16,101 @@ // hid is a straight-forward port of kike IRCd from C. package main -/* +import ( + "bufio" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "flag" + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "os/user" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) -// ANSI terminal formatting, would be better if we had isatty() available -func tf(text string, ansi string) string { - return "\x1b[0;" + ansi + "m" + text + "\x1b[0m" -} +var debugMode = false -func logErrorf(format string, args ...interface{}) { - fmt.Fprintf(os.Stderr, tf("error: "+format+"\n", "1;31"), args...) -} +const ( + projectName = "hid" + // TODO: Consider using the same version number for all subprojects. + projectVersion = "0" +) -func logFatalf(format string, args ...interface{}) { - fmt.Fprintf(os.Stderr, tf("fatal: "+format+"\n", "1;31"), args...) - os.Exit(1) -} +// --- Utilities --------------------------------------------------------------- -func logFatal(object interface{}) { - logFatalf("%s", object) +// Split a string by a set of UTF-8 delimiters, optionally ignoring empty items. +func splitString(s, delims string, ignoreEmpty bool) (result []string) { + for { + end := strings.IndexAny(s, delims) + if end < 0 { + break + } + if !ignoreEmpty || end != 0 { + result = append(result, s[:end]) + } + s = s[end+1:] + } + if !ignoreEmpty || s != "" { + result = append(result, s) + } + return } -func getHome() (home string) { - if u, _ := user.Current(); u != nil { - home = u.HomeDir - } else { - home = os.Getenv("HOME") +func findTildeHome(username string) string { + if username != "" { + if u, _ := user.Lookup(username); u != nil { + return u.HomeDir + } + } else if u, _ := user.Current(); u != nil { + return u.HomeDir + } else if v, ok := os.LookupEnv("HOME"); ok { + return v } - return + return "~" + username } -// Only handling the simple case as that's what one mostly wants. -// TODO(p): Handle the generic case as well. +// Tries to expand the tilde in paths, leaving it as-is on error. func expandTilde(path string) string { - if strings.HasPrefix(path, "~/") { - return getHome() + path[1:] + if path[0] != '~' { + return path + } + + var n int + for n = 0; n < len(path); n++ { + if path[n] == '/' { + break + } } - return path + return findTildeHome(path[1:n]) + path[n:] } -func getXdgHomeDir(name, def string) string { +func getXDGHomeDir(name, def string) string { env := os.Getenv(name) if env != "" && env[0] == '/' { return env } - return filepath.Join(getHome(), def) + + home := "" + if v, ok := os.LookupEnv("HOME"); ok { + home = v + } else if u, _ := user.Current(); u != nil { + home = u.HomeDir + } + return filepath.Join(home, def) } -// Retrieve all XDG base directories for configuration files -func getXdgConfigDirs() (result []string) { - home := getXdgHomeDir("XDG_CONFIG_HOME", ".config") +// Retrieve all XDG base directories for configuration files. +func getXDGConfigDirs() (result []string) { + home := getXDGHomeDir("XDG_CONFIG_HOME", ".config") if home != "" { result = append(result, home) } @@ -80,56 +126,6 @@ func getXdgConfigDirs() (result []string) { return } -// Read a configuration file with the given basename w/o extension -func readConfigFile(name string, output interface{}) error { - var suffix = filepath.Join(projectName, name+".json") - for _, path := range getXdgConfigDirs() { - full := filepath.Join(path, suffix) - file, err := os.Open(full) - if err != nil { - if !os.IsNotExist(err) { - return err - } - continue - } - defer file.Close() - - decoder := json.NewDecoder(file) - err = decoder.Decode(output) - if err != nil { - return fmt.Errorf("%s: %s", full, err) - } - return nil - } - return errors.New("configuration file not found") -} - -*/ - -import ( - "bufio" - "crypto/sha256" - "crypto/tls" - "encoding/hex" - "flag" - "fmt" - "io" - "log" - "net" - "os" - "os/signal" - "path/filepath" - "regexp" - "strconv" - "strings" - "syscall" - "time" -) - -var debugMode = false - -// --- Utilities --------------------------------------------------------------- - // // Trivial SSL/TLS autodetection. The first block of data returned by Recvfrom // must be at least three octets long for this to work reliably, but that should @@ -173,6 +169,35 @@ var config = []struct { {"bind", []rune(":6667"), "Address of the IRC server"}, } +/* + +// Read a configuration file with the given basename w/o extension. +func readConfigFile(name string, output interface{}) error { + var suffix = filepath.Join(projectName, name+".json") + for _, path := range getXDGConfigDirs() { + full := filepath.Join(path, suffix) + file, err := os.Open(full) + if err != nil { + if !os.IsNotExist(err) { + return err + } + continue + } + defer file.Close() + + // TODO: We don't want to use JSON. + decoder := json.NewDecoder(file) + err = decoder.Decode(output) + if err != nil { + return fmt.Errorf("%s: %s", full, err) + } + return nil + } + return errors.New("configuration file not found") +} + +*/ + // --- Rate limiter ------------------------------------------------------------ type floodDetector struct { @@ -231,21 +256,40 @@ func ircToLower(c byte) byte { return c } -// TODO: To support ALL CAPS initialization of maps, perhaps we should use -// ircToUpper instead. -// FIXME: This doesn't follow the meaning of strxfrm and perhaps should be -// renamed to ircNormalize. -func ircStrxfrm(ident string) string { +func ircToUpper(c byte) byte { + switch c { + case '{': + return '[' + case '}': + return ']' + case '|': + return '\\' + case '^': + return '~' + } + if c >= 'a' && c <= 'z' { + return c - ('a' - 'A') + } + return c +} + +// Convert identifier to a canonical form for case-insensitive comparisons. +// ircToUpper is used so that statically initialized maps can be in uppercase. +func ircToCanon(ident string) string { var canon []byte for _, c := range []byte(ident) { - canon = append(canon, ircToLower(c)) + canon = append(canon, ircToUpper(c)) } return string(canon) } +func ircEqual(s1, s2 string) bool { + return ircToCanon(s1) == ircToCanon(s2) +} + func ircFnmatch(pattern string, s string) bool { - pattern, s = ircStrxfrm(pattern), ircStrxfrm(s) - // FIXME: This should not support [] ranges and handle / specially. + pattern, s = ircToCanon(pattern), ircToCanon(s) + // FIXME: This should not support [] ranges and handle '/' specially. // We could translate the pattern to a regular expression. matched, _ := filepath.Match(pattern, s) return matched @@ -331,8 +375,8 @@ type client struct { transport net.Conn // underlying connection tls *tls.Conn // TLS, if detected conn connCloseWrite // high-level connection - inQ []byte // unprocessed input - outQ []byte // unprocessed output + recvQ []byte // unprocessed input + sendQ []byte // unprocessed output reading bool // whether a reading goroutine is running writing bool // whether a writing goroutine is running closing bool // whether we're closing the connection @@ -459,10 +503,8 @@ type writeEvent struct { var ( started int64 // when has the server been started - users map[string]*client // maps nicknames to clients - channels map[string]*channel // maps channel names to data - handlers map[string]bool // TODO message handlers - capHandlers map[string]bool // TODO CAP message handlers + users map[string]*client // maps nicknames to clients + channels map[string]*channel // maps channel names to data whowas map[string]*whowasInfo // WHOWAS registry @@ -481,38 +523,38 @@ var ( writes = make(chan writeEvent) timeouts = make(chan *client) - tlsConf *tls.Config - clients = make(map[*client]bool) - listener net.Listener - // TODO: quitting, quitTimer as they are named in kike? - inShutdown bool - shutdownTimer <-chan time.Time + tlsConf *tls.Config + clients = make(map[*client]bool) + listener net.Listener + quitting bool + quitTimer <-chan time.Time ) // Forcefully tear down all connections. -func forceShutdown(reason string) { - if !inShutdown { - log.Fatalln("forceShutdown called without initiateShutdown") +func forceQuit(reason string) { + if !quitting { + log.Fatalln("forceQuit called without initiateQuit") } log.Printf("forced shutdown (%s)\n", reason) for c := range clients { - c.destroy("TODO") + // initiateQuit has already unregistered the client. + c.kill("Shutting down") } } // Initiate a clean shutdown of the whole daemon. -func initiateShutdown() { +func initiateQuit() { log.Println("shutting down") if err := listener.Close(); err != nil { log.Println(err) } for c := range clients { - c.closeLink("TODO") + c.closeLink("Shutting down") } - shutdownTimer = time.After(5 * time.Second) - inShutdown = true + quitTimer = time.After(5 * time.Second) + quitting = true } // TODO: ircChannelCreate @@ -538,32 +580,31 @@ func ircSendToRoommates(c *client, message string) { // --- Clients (continued) ----------------------------------------------------- -// TODO: Perhaps we should append to *[]byte for performance. -func clientModeToString(m uint, mode *string) { +func clientModeToString(m uint, mode *[]byte) { if 0 != m&ircUserModeInvisible { - *mode += "i" + *mode = append(*mode, 'i') } if 0 != m&ircUserModeRxWallops { - *mode += "w" + *mode = append(*mode, 'w') } if 0 != m&ircUserModeRestricted { - *mode += "r" + *mode = append(*mode, 'r') } if 0 != m&ircUserModeOperator { - *mode += "o" + *mode = append(*mode, 'o') } if 0 != m&ircUserModeRxServerNotices { - *mode += "s" + *mode = append(*mode, 's') } } func (c *client) getMode() string { - mode := "" + var mode []byte if c.awayMessage != "" { - mode += "a" + mode = append(mode, 'a') } clientModeToString(c.mode, &mode) - return mode + return string(mode) } func (c *client) send(line string) { @@ -571,14 +612,13 @@ func (c *client) send(line string) { return } - // TODO: Rename inQ and outQ to recvQ and sendQ as they are usually named. - oldOutQ := len(c.outQ) + oldSendQLen := len(c.sendQ) // So far there's only one message tag we use, so we can do it simple; // note that a 1024-character limit applies to messages with tags on. if 0 != c.capsEnabled&ircCapServerTime { - c.outQ = time.Now().UTC(). - AppendFormat(c.outQ, "@time=2006-01-02T15:04:05.000Z ") + c.sendQ = time.Now().UTC(). + AppendFormat(c.sendQ, "@time=2006-01-02T15:04:05.000Z ") } bytes := []byte(line) @@ -587,13 +627,13 @@ func (c *client) send(line string) { } // TODO: Kill the connection above some "SendQ" threshold (careful!) - c.outQ = append(c.outQ, bytes...) - c.outQ = append(c.outQ, "\r\n"...) - c.flushOutQ() + c.sendQ = append(c.sendQ, bytes...) + c.sendQ = append(c.sendQ, "\r\n"...) + c.flushSendQ() // Technically we haven't sent it yet but that's a minor detail c.nSentMessages++ - c.sentBytes += len(c.outQ) - oldOutQ + c.sentBytes += len(c.sendQ) - oldSendQLen } func (c *client) sendf(format string, a ...interface{}) { @@ -604,7 +644,14 @@ func (c *client) addToWhowas() { // Only keeping one entry for each nickname. // TODO: Make sure this list doesn't get too long, for example by // putting them in a linked list ordered by time. - whowas[ircStrxfrm(c.nickname)] = newWhowasInfo(c) + whowas[ircToCanon(c.nickname)] = newWhowasInfo(c) +} + +func (c *client) nicknameOrStar() string { + if c.nickname == "" { + return "*" + } + return c.nickname } func (c *client) unregister(reason string) { @@ -622,14 +669,13 @@ func (c *client) unregister(reason string) { } c.addToWhowas() - delete(users, ircStrxfrm(c.nickname)) + delete(users, ircToCanon(c.nickname)) c.nickname = "" c.registered = false } -// TODO: Rename to kill. // Close the connection and forget about the client. -func (c *client) destroy(reason string) { +func (c *client) kill(reason string) { if reason == "" { reason = "Client exited" } @@ -661,25 +707,20 @@ func (c *client) closeLink(reason string) { // We also want to avoid accidentally writing to the socket before // address resolution has finished. if c.conn == nil { - c.destroy(reason) + c.kill(reason) return } if c.closing { return } - nickname := c.nickname - if nickname == "" { - nickname = "*" - } - // We push an "ERROR" message to the write buffer and let the writer send // it, with some arbitrary timeout. The "closing" state makes sure // that a/ we ignore any successive messages, and b/ that the connection // is killed after the write buffer is transferred and emptied. // (Since we send this message, we don't need to call CloseWrite here.) c.sendf("ERROR :Closing link: %s[%s] (%s)", - nickname, c.hostname /* TODO host IP? */, reason) + c.nicknameOrStar(), c.hostname /* TODO host IP? */, reason) c.closing = true c.unregister(reason) @@ -720,12 +761,7 @@ func (c *client) getTLSCertFingerprint() string { // XXX: ap doesn't really need to be a slice. func (c *client) makeReply(id int, ap []interface{}) string { - nickname := c.nickname - if nickname == "" { - nickname = "*" - } - - s := fmt.Sprintf(":%s %03d %s ", serverName, id, nickname) + s := fmt.Sprintf(":%s %03d %s ", serverName, id, c.nicknameOrStar()) a := fmt.Sprintf(defaultReplies[id], ap...) return s + a } @@ -809,7 +845,7 @@ func isThisMe(target string) bool { if ircFnmatch(target, serverName) { return true } - _, ok := users[ircStrxfrm(target)] + _, ok := users[ircToCanon(target)] return ok } @@ -821,21 +857,20 @@ func (c *client) sendISUPPORT() { } func (c *client) tryFinishRegistration() { - // TODO: Check if the realname is really required. - if c.nickname == "" || c.username == "" || c.realname == "" { + if c.registered || c.capNegotiating { return } - if c.registered || c.capNegotiating { + if c.nickname == "" || c.username == "" { return } c.registered = true c.sendReply(RPL_WELCOME, c.nickname, c.username, c.hostname) - c.sendReply(RPL_YOURHOST, serverName, "TODO version") + c.sendReply(RPL_YOURHOST, serverName, projectVersion) // The purpose of this message eludes me. c.sendReply(RPL_CREATED, time.Unix(started, 0).Format("Mon, 02 Jan 2006")) - c.sendReply(RPL_MYINFO, serverName, "TODO version", + c.sendReply(RPL_MYINFO, serverName, projectVersion, ircSupportedUserModes, ircSupportedChanModes) c.sendISUPPORT() @@ -852,7 +887,7 @@ func (c *client) tryFinishRegistration() { serverName, c.nickname, c.tlsCertFingerprint) } - delete(whowas, ircStrxfrm(c.nickname)) + delete(whowas, ircToCanon(c.nickname)) } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -957,8 +992,6 @@ func (c *client) handleCAPEND(a *ircCapArgs) { // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// TODO: Beware of case sensitivity, probably need to index it by ircStrxfrm, -// which should arguably be named ircToLower and ircToUpper or something. var ircCapHandlers = map[string]func(*client, *ircCapArgs){ "LS": (*client).handleCAPLS, "LIST": (*client).handleCAPLIST, @@ -976,14 +1009,8 @@ func ircHandleCAP(msg *message, c *client) { return } - // TODO: This really does seem to warrant a method. - nickname := c.nickname - if nickname == "" { - nickname = "*" - } - args := &ircCapArgs{ - target: nickname, + target: c.nicknameOrStar(), subcommand: msg.params[0], fullParams: "", params: []string{}, @@ -991,12 +1018,10 @@ func ircHandleCAP(msg *message, c *client) { if len(msg.params) > 1 { args.fullParams = msg.params[1] - // TODO: ignore_empty, likely create SplitSkipEmpty - args.params = strings.Split(args.fullParams, " ") + args.params = splitString(args.fullParams, " ", true) } - // FIXME: We should ASCII ToUpper the subcommand. - if fn, ok := ircCapHandlers[ircStrxfrm(args.subcommand)]; !ok { + if fn, ok := ircCapHandlers[ircToCanon(args.subcommand)]; !ok { c.sendReply(ERR_INVALIDCAPCMD, args.subcommand, "Invalid CAP subcommand") } else { @@ -1026,8 +1051,8 @@ func ircHandleNICK(msg *message, c *client) { return } - nicknameNormalized := ircStrxfrm(nickname) - if client, ok := users[nicknameNormalized]; ok && client != c { + nicknameCanon := ircToCanon(nickname) + if client, ok := users[nicknameCanon]; ok && client != c { c.sendReply(ERR_NICKNAMEINUSE, nickname) return } @@ -1043,11 +1068,11 @@ func ircHandleNICK(msg *message, c *client) { // Release the old nickname and allocate a new one. if c.nickname != "" { - delete(users, ircStrxfrm(c.nickname)) + delete(users, ircToCanon(c.nickname)) } c.nickname = nickname - users[nicknameNormalized] = c + users[nicknameCanon] = c c.tryFinishRegistration() } @@ -1064,7 +1089,7 @@ func ircHandleUSER(msg *message, c *client) { username, mode, realname := msg.params[0], msg.params[1], msg.params[3] - // Unfortunately the protocol doesn't give us any means of rejecting it + // Unfortunately, the protocol doesn't give us any means of rejecting it. if !ircIsValidUsername(username) { username = "*" } @@ -1091,7 +1116,30 @@ func ircHandleUSERHOST(msg *message, c *client) { return } - // TODO + var reply []byte + for i := 0; i < 5 && i < len(msg.params); i++ { + nick := msg.params[i] + target := users[ircToCanon(nick)] + if target == nil { + continue + } + if i != 0 { + reply = append(reply, ' ') + } + + reply = append(reply, nick...) + if 0 != target.mode&ircUserModeOperator { + reply = append(reply, '*') + } + + if target.awayMessage != "" { + reply = append(reply, "=-"...) + } else { + reply = append(reply, "=+"...) + } + reply = append(reply, (target.username + "@" + target.hostname)...) + } + c.sendReply(RPL_USERHOST, string(reply)) } func ircHandleLUSERS(msg *message, c *client) { @@ -1162,8 +1210,8 @@ func ircHandleVERSION(msg *message, c *client) { postVersion = 1 } - c.sendReply(RPL_VERSION, "TODO version", postVersion, serverName, - "TODO program name"+" "+"TODO version") + c.sendReply(RPL_VERSION, projectVersion, postVersion, serverName, + projectName+" "+projectVersion) c.sendISUPPORT() } @@ -1199,13 +1247,13 @@ func ircHandleMODE(msg *message, c *client) { // TODO target := msg.params[0] - client := users[ircStrxfrm(target)] - ch := users[ircStrxfrm(target)] + client := users[ircToCanon(target)] + ch := users[ircToCanon(target)] if client != nil { - // TODO: Think about strcmp. - //if ircStrcmp(target, c.nickname) != 0 { - //} + // TODO + if ircEqual(target, c.nickname) { + } } else if ch != nil { // TODO } @@ -1223,11 +1271,11 @@ func ircHandleUserMessage(msg *message, c *client, } target, text := msg.params[0], msg.params[1] - if client, ok := users[ircStrxfrm(target)]; ok { + if client, ok := users[ircToCanon(target)]; ok { // TODO _ = client _ = text - } else if ch, ok := channels[ircStrxfrm(target)]; ok { + } else if ch, ok := channels[ircToCanon(target)]; ok { // TODO _ = ch } else { @@ -1254,7 +1302,7 @@ func (c *client) onPrepared(host string, isTLS bool) { c.hostname = host c.address = net.JoinHostPort(host, c.port) - // TODO: If we've tried to send any data before now, we need to flushOutQ. + // TODO: If we've tried to send any data before now, we need to flushSendQ. go read(c) c.reading = true } @@ -1266,16 +1314,16 @@ func (c *client) onRead(data []byte, readErr error) { return } - c.inQ = append(c.inQ, data...) + c.recvQ = append(c.recvQ, data...) for { // XXX: This accepts even simple LF newlines, even though they're not // really allowed by the protocol. - advance, token, _ := bufio.ScanLines(c.inQ, false /* atEOF */) + advance, token, _ := bufio.ScanLines(c.recvQ, false /* atEOF */) if advance == 0 { break } - c.inQ = c.inQ[advance:] + c.recvQ = c.recvQ[advance:] line := string(token) log.Printf("-> %s\n", line) @@ -1298,18 +1346,18 @@ func (c *client) onRead(data []byte, readErr error) { if readErr != io.EOF { log.Println(readErr) - c.destroy("TODO") + c.kill(readErr.Error()) } else if c.closing { // Disregarding whether a clean shutdown has happened or not. log.Println("client finished shutdown") - c.destroy("TODO") + c.kill("TODO") } else { log.Println("client EOF") c.closeLink("") } - } else if len(c.inQ) > 8192 { - log.Println("client inQ overrun") - c.closeLink("inQ overrun") + } else if len(c.recvQ) > 8192 { + log.Println("client recvQ overrun") + c.closeLink("recvQ overrun") // tls.Conn doesn't have the CloseRead method (and it needs to be able // to read from the TCP connection even for writes, so there isn't much @@ -1319,29 +1367,29 @@ func (c *client) onRead(data []byte, readErr error) { } } -// Spawn a goroutine to flush the outQ if possible and necessary. -func (c *client) flushOutQ() { +// Spawn a goroutine to flush the sendQ if possible and necessary. +func (c *client) flushSendQ() { if !c.writing && c.conn != nil { - go write(c, c.outQ) + go write(c, c.sendQ) c.writing = true } } // Handle the results from trying to write to the client connection. func (c *client) onWrite(written int, writeErr error) { - c.outQ = c.outQ[written:] + c.sendQ = c.sendQ[written:] c.writing = false if writeErr != nil { log.Println(writeErr) - c.destroy("TODO") - } else if len(c.outQ) > 0 { - c.flushOutQ() + c.kill(writeErr.Error()) + } else if len(c.sendQ) > 0 { + c.flushSendQ() } else if c.closing { if c.reading { c.conn.CloseWrite() } else { - c.destroy("TODO") + c.kill("TODO") } } } @@ -1419,7 +1467,7 @@ func read(client *client) { } } -// Flush outQ, which is passed by parameter so that there are no data races. +// Flush sendQ, which is passed by parameter so that there are no data races. func write(client *client, data []byte) { // We just write as much as we can, the main goroutine does the looping. n, err := client.conn.Write(data) @@ -1431,14 +1479,14 @@ func write(client *client, data []byte) { func processOneEvent() { select { case <-sigs: - if inShutdown { - forceShutdown("requested by user") + if quitting { + forceQuit("requested by user") } else { - initiateShutdown() + initiateQuit() } - case <-shutdownTimer: - forceShutdown("timeout") + case <-quitTimer: + forceQuit("timeout") case conn := <-conns: log.Println("accepted client connection") @@ -1480,7 +1528,7 @@ func processOneEvent() { case c := <-timeouts: if _, ok := clients[c]; ok { log.Println("client timeouted") - c.destroy("TODO") + c.kill("TODO") } } } @@ -1490,14 +1538,12 @@ func main() { version := flag.Bool("version", false, "show version and exit") flag.Parse() - // TODO: Consider using the same version number for all subprojects. if *version { - fmt.Printf("%s %s\n", "hid", "0") + fmt.Printf("%s %s\n", projectName, projectVersion) return } - // TODO: Configuration--create an INI parser, probably; - // lift XDG_CONFIG_HOME from gitlab-notifier. + // TODO: Configuration--create an INI parser, probably. if len(flag.Args()) != 3 { log.Fatalf("usage: %s KEY CERT ADDRESS\n", os.Args[0]) } @@ -1520,7 +1566,7 @@ func main() { go accept(listener) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - for !inShutdown || len(clients) > 0 { + for !quitting || len(clients) > 0 { processOneEvent() } } |