Repository: ergochat/ergo (go)

Issue:
Don't respond to TAGMSG with away replies
Typing TAGMSG's cause ergo to send countless away replies as you type the message in PM to someone.

```
[06:52:32]	<eskimo> ohai
[06:52:32]	DALEK is away (Gone til 5pm)
[06:52:32]	DALEK is away (Gone til 5pm)
[06:52:39]	DALEK is away (Gone til 5pm)
[06:52:41]	<eskimo> hows it goin
[06:52:41]	DALEK is away (Gone til 5pm)
[06:52:43]	DALEK is away (Gone til 5pm)
[06:52:46]	DALEK is away (Gone til 5pm)
```

Pull request:
fix #1983
TAGMSG should not get automatic RPL_AWAY replies

Patch:
diff --git a/default.yaml b/default.yaml
index 8da88e09..d4e5cbd0 100644
--- a/default.yaml
+++ b/default.yaml
@@ -849,6 +849,14 @@ fakelag:
     # sending any commands:
     cooldown: 2s
 
+    # exempt a certain number of command invocations per session from fakelag;
+    # this is to speed up "resynchronization" of client state during reattach
+    command-budgets:
+        "CHATHISTORY": 16
+        "MARKREAD":    16
+        "MONITOR":     1
+        "WHO":         4
+
 # the roleplay commands are semi-standardized extensions to IRC that allow
 # sending and receiving messages from pseudo-nicknames. this can be used either
 # for actual roleplaying, or for bridging IRC with other protocols.
@@ -942,7 +950,9 @@ history:
 
     # options to store history messages in a persistent database (currently only MySQL).
     # in order to enable any of this functionality, you must configure a MySQL server
-    # in the `datastore.mysql` section.
+    # in the `datastore.mysql` section. enabling persistence overrides the history
+    # size limits above (`channel-length`, `client-length`, etc.); persistent
+    # history has no limits other than those imposed by expire-time.
     persistent:
         enabled: false
 
diff --git a/docs/MANUAL.md b/docs/MANUAL.md
index eeff2c21..f13dd940 100644
--- a/docs/MANUAL.md
+++ b/docs/MANUAL.md
@@ -200,7 +200,7 @@ On a non-systemd system, ergo can be configured to log to a file and used [logro
 The other major hurdle for productionizing (but one well worth the effort) is obtaining valid TLS certificates for your domain, if you haven't already done so:
 
 1. The simplest way to get valid TLS certificates is from [Let's Encrypt](https://letsencrypt.org/) with [Certbot](https://certbot.eff.org/). The correct procedure will depend on whether you are already running a web server on port 80. If you are, follow the guides on the Certbot website; if you aren't, you can use `certbot certonly --standalone --preferred-challenges http -d example.com` (replace `example.com` with your domain).
-1. At this point, you should have certificates available at `/etc/letsencrypt/live/example.com` (replacing `example.com` with your domain). You should serve `fullchain.pem` as the certificate and `privkey.pem` as its private key. However, these files are owned by root and the private key is not readable by the ergo role user, so you won't be able to use them directly in their current locations. You can write a post-renewal hook for certbot to make copies of these certificates accessible to the ergo role user. For example, install the following script as `/etc/letsencrypt/renewal-hooks/post/install-ergo-certificates`, again replacing `example.com` with your domain name, and chmod it 0755:
+1. At this point, you should have certificates available at `/etc/letsencrypt/live/example.com` (replacing `example.com` with your domain). You should serve `fullchain.pem` as the certificate and `privkey.pem` as its private key. However, these files are owned by root and the private key is not readable by the ergo role user, so you won't be able to use them directly in their current locations. You can write a renewal deploy hook for certbot to make copies of these certificates accessible to the ergo role user. For example, install the following script as `/etc/letsencrypt/renewal-hooks/deploy/install-ergo-certificates` (which will update the certificate and key after a successful renewal), again replacing `example.com` with your domain name, and chmod it 0755:
 
 ````bash
 #!/bin/bash
diff --git a/gencapdefs.py b/gencapdefs.py
index e4f293cc..76e9d667 100644
--- a/gencapdefs.py
+++ b/gencapdefs.py
@@ -63,6 +63,12 @@ CAPDEFS = [
         url="https://ircv3.net/specs/extensions/extended-join-3.1.html",
         standard="IRCv3",
     ),
+    CapDef(
+        identifier="ExtendedMonitor",
+        name="extended-monitor",
+        url="https://ircv3.net/specs/extensions/extended-monitor.html",
+        standard="IRCv3",
+    ),
     CapDef(
         identifier="InviteNotify",
         name="invite-notify",
@@ -177,12 +183,6 @@ CAPDEFS = [
         url="https://github.com/ircv3/ircv3-specifications/pull/435",
         standard="draft IRCv3",
     ),
-    CapDef(
-        identifier="ExtendedMonitor",
-        name="draft/extended-monitor",
-        url="https://github.com/ircv3/ircv3-specifications/pull/466",
-        standard="draft IRCv3",
-    ),
     CapDef(
         identifier="ReadMarker",
         name="draft/read-marker",
diff --git a/irc/caps/defs.go b/irc/caps/defs.go
index cc2e174f..b1f7192c 100644
--- a/irc/caps/defs.go
+++ b/irc/caps/defs.go
@@ -53,10 +53,6 @@ const (
 	// https://github.com/ircv3/ircv3-specifications/pull/362
 	EventPlayback Capability = iota
 
-	// ExtendedMonitor is the draft IRCv3 capability named "draft/extended-monitor":
-	// https://github.com/ircv3/ircv3-specifications/pull/466
-	ExtendedMonitor Capability = iota
-
 	// Languages is the proposed IRCv3 capability named "draft/languages":
 	// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
 	Languages Capability = iota
@@ -85,6 +81,10 @@ const (
 	// https://ircv3.net/specs/extensions/extended-join-3.1.html
 	ExtendedJoin Capability = iota
 
+	// ExtendedMonitor is the IRCv3 capability named "extended-monitor":
+	// https://ircv3.net/specs/extensions/extended-monitor.html
+	ExtendedMonitor Capability = iota
+
 	// InviteNotify is the IRCv3 capability named "invite-notify":
 	// https://ircv3.net/specs/extensions/invite-notify-3.2.html
 	InviteNotify Capability = iota
@@ -143,7 +143,6 @@ var (
 		"draft/channel-rename",
 		"draft/chathistory",
 		"draft/event-playback",
-		"draft/extended-monitor",
 		"draft/languages",
 		"draft/multiline",
 		"draft/read-marker",
@@ -151,6 +150,7 @@ var (
 		"echo-message",
 		"ergo.chat/nope",
 		"extended-join",
+		"extended-monitor",
 		"invite-notify",
 		"labeled-response",
 		"message-tags",
diff --git a/irc/channel.go b/irc/channel.go
index 8aafe13a..2cb4e385 100644
--- a/irc/channel.go
+++ b/irc/channel.go
@@ -47,10 +47,10 @@ type Channel struct {
 	userLimit         int
 	accountToUMode    map[string]modes.Mode
 	history           history.Buffer
-	stateMutex        sync.RWMutex    // tier 1
-	writerSemaphore   utils.Semaphore // tier 1.5
-	joinPartMutex     sync.Mutex      // tier 3
-	ensureLoaded      utils.Once      // manages loading stored registration info from the database
+	stateMutex        sync.RWMutex // tier 1
+	writebackLock     sync.Mutex   // tier 1.5
+	joinPartMutex     sync.Mutex   // tier 3
+	ensureLoaded      utils.Once   // manages loading stored registration info from the database
 	dirtyBits         uint
 	settings          ChannelSettings
 }
@@ -61,12 +61,11 @@ func NewChannel(s *Server, name, casefoldedName string, registered bool) *Channe
 	config := s.Config()
 
 	channel := &Channel{
-		createdTime:     time.Now().UTC(), // may be overwritten by applyRegInfo
-		members:         make(MemberSet),
-		name:            name,
-		nameCasefolded:  casefoldedName,
-		server:          s,
-		writerSemaphore: utils.NewSemaphore(1),
+		createdTime:    time.Now().UTC(), // may be overwritten by applyRegInfo
+		members:        make(MemberSet),
+		name:           name,
+		nameCasefolded: casefoldedName,
+		server:         s,
 	}
 
 	channel.initializeLists()
@@ -209,11 +208,11 @@ func (channel *Channel) MarkDirty(dirtyBits uint) {
 // ChannelManager's lock (that way, no one can join and make the channel dirty again
 // between this method exiting and the actual deletion).
 func (channel *Channel) IsClean() bool {
-	if !channel.writerSemaphore.TryAcquire() {
+	if !channel.writebackLock.TryLock() {
 		// a database write (which may fail) is in progress, the channel cannot be cleaned up
 		return false
 	}
-	defer channel.writerSemaphore.Release()
+	defer channel.writebackLock.Unlock()
 
 	channel.stateMutex.RLock()
 	defer channel.stateMutex.RUnlock()
@@ -225,7 +224,7 @@ func (channel *Channel) IsClean() bool {
 }
 
 func (channel *Channel) wakeWriter() {
-	if channel.writerSemaphore.TryAcquire() {
+	if channel.writebackLock.TryLock() {
 		go channel.writeLoop()
 	}
 }
@@ -235,7 +234,7 @@ func (channel *Channel) writeLoop() {
 	for {
 		// TODO(#357) check the error value of this and implement timed backoff
 		channel.performWrite(0)
-		channel.writerSemaphore.Release()
+		channel.writebackLock.Unlock()
 
 		channel.stateMutex.RLock()
 		isDirty := channel.dirtyBits != 0
@@ -249,7 +248,7 @@ func (channel *Channel) writeLoop() {
 			return // nothing to do
 		} // else: isDirty, so we need to write again
 
-		if !channel.writerSemaphore.TryAcquire() {
+		if !channel.writebackLock.TryLock() {
 			return
 		}
 	}
@@ -272,8 +271,8 @@ func (channel *Channel) Store(dirtyBits uint) (err error) {
 		}
 	}()
 
-	channel.writerSemaphore.Acquire()
-	defer channel.writerSemaphore.Release()
+	channel.writebackLock.Lock()
+	defer channel.writebackLock.Unlock()
 	return channel.performWrite(dirtyBits)
 }
 
diff --git a/irc/chanserv.go b/irc/chanserv.go
index 361f394a..c4b75974 100644
--- a/irc/chanserv.go
+++ b/irc/chanserv.go
@@ -213,8 +213,17 @@ func csAmodeHandler(service *ircService, server *Server, client *Client, command
 	}
 
 	modeChanges, unknown := modes.ParseChannelModeChanges(params[1:]...)
+	invalid := len(unknown) != 0
+	// #2002: +f takes an argument but is not a channel-user mode,
+	// check for anything valid as a channel mode change that is not valid
+	// as an AMODE change
+	for _, modeChange := range modeChanges {
+		if !utils.SliceContains(modes.ChannelUserModes, modeChange.Mode) {
+			invalid = true
+		}
+	}
 	var change modes.ModeChange
-	if len(modeChanges) > 1 || len(unknown) > 0 {
+	if len(modeChanges) > 1 || invalid {
 		service.Notice(rb, client.t("Invalid mode change"))
 		return
 	} else if len(modeChanges) == 1 {
diff --git a/irc/client.go b/irc/client.go
index a9cf5b05..b98a8501 100644
--- a/irc/client.go
+++ b/irc/client.go
@@ -113,7 +113,7 @@ type Client struct {
 	vhost              string
 	history            history.Buffer
 	dirtyBits          uint
-	writerSemaphore    utils.Semaphore // tier 1.5
+	writebackLock      sync.Mutex // tier 1.5
 }
 
 type saslStatus struct {
@@ -335,16 +335,15 @@ func (server *Server) RunClient(conn IRCConn) {
 			Duration: config.Accounts.LoginThrottling.Duration,
 			Limit:    config.Accounts.LoginThrottling.MaxAttempts,
 		},
-		server:          server,
-		accountName:     "*",
-		nick:            "*", // * is used until actual nick is given
-		nickCasefolded:  "*",
-		nickMaskString:  "*", // * is used until actual nick is given
-		realIP:          realIP,
-		proxiedIP:       proxiedIP,
-		requireSASL:     requireSASL,
-		nextSessionID:   1,
-		writerSemaphore: utils.NewSemaphore(1),
+		server:         server,
+		accountName:    "*",
+		nick:           "*", // * is used until actual nick is given
+		nickCasefolded: "*",
+		nickMaskString: "*", // * is used until actual nick is given
+		realIP:         realIP,
+		proxiedIP:      proxiedIP,
+		requireSASL:    requireSASL,
+		nextSessionID:  1,
 	}
 	if requireSASL {
 		client.requireSASLMessage = banMsg
@@ -424,8 +423,6 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
 		realname: realname,
 
 		nextSessionID: 1,
-
-		writerSemaphore: utils.NewSemaphore(1),
 	}
 
 	if client.checkAlwaysOnExpirationNoMutex(config, true) {
@@ -668,12 +665,21 @@ func (client *Client) run(session *Session) {
 			}
 		}
 
+		msg, err := ircmsg.ParseLineStrict(line, true, MaxLineLen)
+		// XXX defer processing of command error parsing until after fakelag
+
 		if client.registered {
-			touches := session.deferredFakelagCount + 1
+			// apply deferred fakelag
+			for i := 0; i < session.deferredFakelagCount; i++ {
+				session.fakelag.Touch("")
+			}
 			session.deferredFakelagCount = 0
-			for i := 0; i < touches; i++ {
-				session.fakelag.Touch()
+			// touch for the current command
+			var command string
+			if err == nil {
+				command = msg.Command
 			}
+			session.fakelag.Touch(command)
 		} else {
 			// DoS hardening, #505
 			session.registrationMessages++
@@ -683,7 +689,6 @@ func (client *Client) run(session *Session) {
 			}
 		}
 
-		msg, err := ircmsg.ParseLineStrict(line, true, MaxLineLen)
 		if err == ircmsg.ErrorLineIsEmpty {
 			continue
 		} else if err == ircmsg.ErrorTagsTooLong {
@@ -1764,7 +1769,7 @@ func (client *Client) markDirty(dirtyBits uint) {
 }
 
 func (client *Client) wakeWriter() {
-	if client.writerSemaphore.TryAcquire() {
+	if client.writebackLock.TryLock() {
 		go client.writeLoop()
 	}
 }
@@ -1772,13 +1777,13 @@ func (client *Client) wakeWriter() {
 func (client *Client) writeLoop() {
 	for {
 		client.performWrite(0)
-		client.writerSemaphore.Release()
+		client.writebackLock.Unlock()
 
 		client.stateMutex.RLock()
 		isDirty := client.dirtyBits != 0
 		client.stateMutex.RUnlock()
 
-		if !isDirty || !client.writerSemaphore.TryAcquire() {
+		if !isDirty || !client.writebackLock.TryLock() {
 			return
 		}
 	}
@@ -1836,8 +1841,8 @@ func (client *Client) Store(dirtyBits uint) (err error) {
 		}
 	}()
 
-	client.writerSemaphore.Acquire()
-	defer client.writerSemaphore.Release()
+	client.writebackLock.Lock()
+	defer client.writebackLock.Unlock()
 	client.performWrite(dirtyBits)
 	return nil
 }
diff --git a/irc/config.go b/irc/config.go
index 75fd1f27..79aa7da8 100644
--- a/irc/config.go
+++ b/irc/config.go
@@ -524,6 +524,7 @@ type FakelagConfig struct {
 	BurstLimit        uint `yaml:"burst-limit"`
 	MessagesPerWindow uint `yaml:"messages-per-window"`
 	Cooldown          time.Duration
+	CommandBudgets    map[string]int `yaml:"command-budgets"`
 }
 
 type TorListenersConfig struct {
@@ -1428,6 +1429,17 @@ func LoadConfig(filename string) (config *Config, err error) {
 	}
 	config.Server.capValues[caps.Languages] = config.languageManager.CapValue()
 
+	if len(config.Fakelag.CommandBudgets) != 0 {
+		// normalize command names to uppercase:
+		commandBudgets := make(map[string]int, len(config.Fakelag.CommandBudgets))
+		for command, budget := range config.Fakelag.CommandBudgets {
+			commandBudgets[strings.ToUpper(command)] = budget
+		}
+		config.Fakelag.CommandBudgets = commandBudgets
+	} else {
+		config.Fakelag.CommandBudgets = nil
+	}
+
 	if config.Server.Relaymsg.Enabled {
 		for _, char := range protocolBreakingNameCharacters {
 			if strings.ContainsRune(config.Server.Relaymsg.Separators, char) {
@@ -1577,6 +1589,8 @@ func (config *Config) generateISupport() (err error) {
 	isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient))
 	isupport.Add("CHANMODES", chanmodesToken)
 	if config.History.Enabled && config.History.ChathistoryMax > 0 {
+		isupport.Add("CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax))
+		// Kiwi expects this legacy token name:
 		isupport.Add("draft/CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax))
 	}
 	isupport.Add("CHANNELLEN", strconv.Itoa(config.Limits.ChannelLen))
diff --git a/irc/fakelag.go b/irc/fakelag.go
index 8f3a4c9e..25ef0609 100644
--- a/irc/fakelag.go
+++ b/irc/fakelag.go
@@ -5,6 +5,8 @@ package irc
 
 import (
 	"time"
+
+	"github.com/ergochat/ergo/irc/utils"
 )
 
 // fakelag is a system for artificially delaying commands when a user issues
@@ -36,6 +38,10 @@ type Fakelag struct {
 
 func (fl *Fakelag) Initialize(config FakelagConfig) {
 	fl.config = config
+	// XXX don't share mutable member CommandBudgets:
+	if config.CommandBudgets != nil {
+		fl.config.CommandBudgets = utils.CopyMap(config.CommandBudgets)
+	}
 	fl.nowFunc = time.Now
 	fl.sleepFunc = time.Sleep
 	fl.state = FakelagBursting
@@ -58,11 +64,16 @@ func (fl *Fakelag) Unsuspend() {
 }
 
 // register a new command, sleep if necessary to delay it
-func (fl *Fakelag) Touch() {
+func (fl *Fakelag) Touch(command string) {
 	if !fl.config.Enabled {
 		return
 	}
 
+	if budget, ok := fl.config.CommandBudgets[command]; ok && budget > 0 {
+		fl.config.CommandBudgets[command] = budget - 1
+		return
+	}
+
 	now := fl.nowFunc()
 	// XXX if lastTouch.IsZero(), treat it as "very far in the past", which is fine
 	elapsed := now.Sub(fl.lastTouch)
diff --git a/irc/handlers.go b/irc/handlers.go
index 17bee6e0..7f3143d6 100644
--- a/irc/handlers.go
+++ b/irc/handlers.go
@@ -449,10 +449,10 @@ func awayHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
 	var isAway bool
 	var awayMessage string
 	if len(msg.Params) > 0 {
-		isAway = true
 		awayMessage = msg.Params[0]
 		awayMessage = ircutils.TruncateUTF8Safe(awayMessage, server.Config().Limits.AwayLen)
 	}
+	isAway = (awayMessage != "") // #1996
 
 	rb.session.SetAway(awayMessage)
 
@@ -1892,7 +1892,7 @@ func umodeHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
 
 // get the correct capitalization of a nick (if it's online), otherwise return ""
 func (server *Server) getCurrentNick(nick string) (result string) {
-	if service, isService := OragonoServices[strings.ToLower(nick)]; isService {
+	if service, isService := ErgoServices[strings.ToLower(nick)]; isService {
 		return service.Name
 	} else if iclient := server.clients.Get(nick); iclient != nil {
 		return iclient.Nick()
@@ -2277,7 +2277,7 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
 		}
 	} else {
 		lowercaseTarget := strings.ToLower(target)
-		service, isService := OragonoServices[lowercaseTarget]
+		service, isService := ErgoServices[lowercaseTarget]
 		_, isZNC := zncHandlers[lowercaseTarget]
 
 		if isService || isZNC {
@@ -2360,7 +2360,7 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
 
 		// the originating session may get an echo message:
 		rb.addEchoMessage(tags, nickMaskString, accountName, command, tnick, message)
-		if histType != history.Notice {
+		if histType == history.Privmsg {
 			//TODO(dan): possibly implement cooldown of away notifications to users
 			if away, awayMessage := user.Away(); away {
 				rb.Add(nil, server.name, RPL_AWAY, client.Nick(), tnick, awayMessage)
@@ -3427,6 +3427,59 @@ func (client *Client) rplWhoReply(channel *Channel, target *Client, rb *Response
 	rb.Add(nil, client.server.name, numeric, params...)
 }
 
+func serviceWhoReply(client *Client, service *ircService, rb *ResponseBuffer, isWhox bool, fields whoxFields, whoType string) {
+	params := []string{client.Nick()}
+
+	if fields.Has('t') {
+		params = append(params, whoType)
+	}
+	if fields.Has('c') {
+		params = append(params, "*")
+	}
+	if fields.Has('u') {
+		params = append(params, service.Name)
+	}
+	if fields.Has('i') {
+		params = append(params, "127.0.0.1")
+	}
+	if fields.Has('h') {
+		params = append(params, "localhost")
+	}
+	if fields.Has('s') {
+		params = append(params, client.server.name)
+	}
+	if fields.Has('n') {
+		params = append(params, service.Name)
+	}
+	if fields.Has('f') { // "flags" (away + oper state + channel status prefix + bot)
+		params = append(params, "H")
+	}
+	if fields.Has('d') { // server hops from us to target
+		params = append(params, "0")
+	}
+	if fields.Has('l') { // idle seconds
+		params = append(params, "0")
+	}
+	if fields.Has('a') { // account, services are considered not to have one
+		params = append(params, "0")
+	}
+	if fields.Has('o') { // channel oplevel, not implemented
+		params = append(params, "*")
+	}
+	if fields.Has('r') {
+		params = append(params, service.Realname(client))
+	}
+
+	numeric := RPL_WHOSPCRPL
+	if !isWhox {
+		numeric = RPL_WHOREPLY
+		// if this isn't WHOX, stick hops + realname at the end
+		params = append(params, "0 "+service.Realname(client))
+	}
+
+	rb.Add(nil, client.server.name, numeric, params...)
+}
+
 // WHO <mask> [<filter>%<fields>,<type>]
 func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
 	origMask := utils.SafeErrorParam(msg.Params[0])
@@ -3519,9 +3572,10 @@ func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response
 			}
 		}
 	} else if isBareNick {
-		mclient := server.clients.Get(mask)
-		if mclient != nil {
+		if mclient := server.clients.Get(mask); mclient != nil {
 			client.rplWhoReply(nil, mclient, rb, canSeeIPs, oper != nil, includeRFlag, isWhox, fields, whoType)
+		} else if service, ok := ErgoServices[strings.ToLower(mask)]; ok {
+			serviceWhoReply(client, service, rb, isWhox, fields, whoType)
 		}
 	} else {
 		// Construct set of channels the client is in.
@@ -3571,7 +3625,7 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
 
 	handleService := func(nick string) bool {
 		cfnick, _ := CasefoldName(nick)
-		service, ok := OragonoServices[cfnick]
+		service, ok := ErgoServices[cfnick]
 		hostname := "localhost"
 		config := server.Config()
 		if config.Server.OverrideServicesHostname != "" {
@@ -3581,7 +3635,7 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
 			return false
 		}
 		clientNick := client.Nick()
-		rb.Add(nil, client.server.name, RPL_WHOISUSER, clientNick, service.Name, service.Name, hostname, "*", fmt.Sprintf(client.t("Network service, for more info /msg %s HELP"), service.Name))
+		rb.Add(nil, client.server.name, RPL_WHOISUSER, clientNick, service.Name, service.Name, hostname, "*", service.Realname(client))
 		// #1080:
 		rb.Add(nil, client.server.name, RPL_WHOISOPERATOR, clientNick, service.Name, client.t("is a network service"))
 		// hehe
diff --git a/irc/services.go b/irc/services.go
index 10a50fa3..7bd9c5f1 100644
--- a/irc/services.go
+++ b/irc/services.go
@@ -26,6 +26,10 @@ type ircService struct {
 	HelpBanner     string
 }
 
+func (service *ircService) Realname(client *Client) string {
+	return fmt.Sprintf(client.t("Network service, for more info /msg %s HELP"), service.Name)
+}
+
 // defines a command associated with a service, e.g., NICKSERV IDENTIFY
 type serviceCommand struct {
 	aliasOf           string   // marks this command as an alias of another
@@ -92,7 +96,7 @@ var (
 )
 
 // all services, by lowercase name
-var OragonoServices = map[string]*ircService{
+var ErgoServices = map[string]*ircService{
 	"nickserv": nickservService,
 	"chanserv": chanservService,
 	"hostserv": hostservService,
@@ -105,7 +109,7 @@ func (service *ircService) Notice(rb *ResponseBuffer, text string) {
 
 // all service commands at the protocol level, by uppercase command name
 // e.g., NICKSERV, NS
-var oragonoServicesByCommandAlias map[string]*ircService
+var ergoServicesByCommandAlias map[string]*ircService
 
 // special-cased command shared by all services
 var servHelpCmd serviceCommand = serviceCommand{
@@ -117,7 +121,7 @@ HELP returns information on the given command.`,
 
 // generic handler for IRC commands like `/NICKSERV INFO`
 func serviceCmdHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
-	service, ok := oragonoServicesByCommandAlias[msg.Command]
+	service, ok := ergoServicesByCommandAlias[msg.Command]
 	if !ok {
 		server.logger.Warning("internal", "can't handle unrecognized service", msg.Command)
 		return false
@@ -323,7 +327,7 @@ func overrideServicePrefixes(hostname string) error {
 	if !utils.IsHostname(hostname) {
 		return fmt.Errorf("`%s` is an invalid services hostname", hostname)
 	}
-	for _, serv := range OragonoServices {
+	for _, serv := range ErgoServices {
 		serv.prefix = fmt.Sprintf("%s!%s@%s", serv.Name, serv.Name, hostname)
 	}
 	return nil
@@ -332,9 +336,9 @@ func overrideServicePrefixes(hostname string) error {
 func initializeServices() {
 	// this modifies the global Commands map,
 	// so it must be called from irc/commands.go's init()
-	oragonoServicesByCommandAlias = make(map[string]*ircService)
+	ergoServicesByCommandAlias = make(map[string]*ircService)
 
-	for serviceName, service := range OragonoServices {
+	for serviceName, service := range ErgoServices {
 		service.prefix = fmt.Sprintf("%s!%s@localhost", service.Name, service.Name)
 
 		// make `/MSG ServiceName HELP` work correctly
@@ -349,7 +353,7 @@ func initializeServices() {
 		ircCmdDef.handler = serviceCmdHandler
 		for _, ircCmd := range service.CommandAliases {
 			Commands[ircCmd] = ircCmdDef
-			oragonoServicesByCommandAlias[ircCmd] = service
+			ergoServicesByCommandAlias[ircCmd] = service
 			Help[strings.ToLower(ircCmd)] = HelpEntry{
 				textGenerator: makeServiceHelpTextGenerator(ircCmd, service.HelpBanner),
 			}
diff --git a/irc/socket.go b/irc/socket.go
index 3a55bf5f..c27ccd52 100644
--- a/irc/socket.go
+++ b/irc/socket.go
@@ -8,8 +8,6 @@ import (
 	"errors"
 	"io"
 	"sync"
-
-	"github.com/ergochat/ergo/irc/utils"
 )
 
 var (
@@ -27,7 +25,7 @@ type Socket struct {
 	maxSendQBytes int
 
 	// this is a trylock enforcing that only one goroutine can write to `conn` at a time
-	writerSemaphore utils.Semaphore
+	writeLock sync.Mutex
 
 	buffers       [][]byte
 	totalLength   int
@@ -40,9 +38,8 @@ type Socket struct {
 // NewSocket returns a new Socket.
 func NewSocket(conn IRCConn, maxSendQBytes int) *Socket {
 	result := Socket{
-		conn:            conn,
-		maxSendQBytes:   maxSendQBytes,
-		writerSemaphore: utils.NewSemaphore(1),
+		conn:          conn,
+		maxSendQBytes: maxSendQBytes,
 	}
 	return &result
 }
@@ -128,8 +125,8 @@ func (socket *Socket) BlockingWrite(data []byte) (err error) {
 	}()
 
 	// blocking acquire of the trylock
-	socket.writerSemaphore.Acquire()
-	defer socket.writerSemaphore.Release()
+	socket.writeLock.Lock()
+	defer socket.writeLock.Unlock()
 
 	// first, flush any buffered data, to preserve the ordering guarantees
 	closed := socket.performWrite()
@@ -146,7 +143,7 @@ func (socket *Socket) BlockingWrite(data []byte) (err error) {
 
 // wakeWriter starts the goroutine that actually performs the write, without blocking
 func (socket *Socket) wakeWriter() {
-	if socket.writerSemaphore.TryAcquire() {
+	if socket.writeLock.TryLock() {
 		// acquired the trylock; send() will release it
 		go socket.send()
 	}
@@ -182,12 +179,12 @@ func (socket *Socket) send() {
 		socket.performWrite()
 		// surrender the trylock, avoiding a race where a write comes in after we've
 		// checked readyToWrite() and it returned false, but while we still hold the trylock:
-		socket.writerSemaphore.Release()
+		socket.writeLock.Unlock()
 		// check if more data came in while we held the trylock:
 		if !socket.readyToWrite() {
 			return
 		}
-		if !socket.writerSemaphore.TryAcquire() {
+		if !socket.writeLock.TryLock() {
 			// failed to acquire; exit and wait for the holder to observe readyToWrite()
 			// after releasing it
 			return
diff --git a/irc/utils/types.go b/irc/utils/types.go
index 5ad2ce93..ca1e4f7a 100644
--- a/irc/utils/types.go
+++ b/irc/utils/types.go
@@ -34,3 +34,12 @@ func ReverseSlice[T any](results []T) {
 		results[i], results[j] = results[j], results[i]
 	}
 }
+
+func SliceContains[T comparable](slice []T, elem T) (result bool) {
+	for _, t := range slice {
+		if elem == t {
+			return true
+		}
+	}
+	return false
+}
diff --git a/traditional.yaml b/traditional.yaml
index 7ea27137..21e46945 100644
--- a/traditional.yaml
+++ b/traditional.yaml
@@ -821,6 +821,14 @@ fakelag:
     # sending any commands:
     cooldown: 2s
 
+    # exempt a certain number of command invocations per session from fakelag;
+    # this is to speed up "resynchronization" of client state during reattach
+    command-budgets:
+        "CHATHISTORY": 16
+        "MARKREAD":    16
+        "MONITOR":     1
+        "WHO":         4
+
 # the roleplay commands are semi-standardized extensions to IRC that allow
 # sending and receiving messages from pseudo-nicknames. this can be used either
 # for actual roleplaying, or for bridging IRC with other protocols.
@@ -914,7 +922,9 @@ history:
 
     # options to store history messages in a persistent database (currently only MySQL).
     # in order to enable any of this functionality, you must configure a MySQL server
-    # in the `datastore.mysql` section.
+    # in the `datastore.mysql` section. enabling persistence overrides the history
+    # size limits above (`channel-length`, `client-length`, etc.); persistent
+    # history has no limits other than those imposed by expire-time.
     persistent:
         enabled: false
 


Test patch:
diff --git a/irc/fakelag_test.go b/irc/fakelag_test.go
index bc1ff45b..e1079466 100644
--- a/irc/fakelag_test.go
+++ b/irc/fakelag_test.go
@@ -60,7 +60,7 @@ func TestFakelag(t *testing.T) {
 	window, _ := time.ParseDuration("1s")
 	fl, mt := newFakelagForTesting(window, 3, 2, window)
 
-	fl.Touch()
+	fl.Touch("")
 	slept, _ := mt.lastSleep()
 	if slept {
 		t.Fatalf("should not have slept")
@@ -69,7 +69,7 @@ func TestFakelag(t *testing.T) {
 	interval, _ := time.ParseDuration("100ms")
 	for i := 0; i < 2; i++ {
 		mt.pause(interval)
-		fl.Touch()
+		fl.Touch("")
 		slept, _ := mt.lastSleep()
 		if slept {
 			t.Fatalf("should not have slept")
@@ -77,7 +77,7 @@ func TestFakelag(t *testing.T) {
 	}
 
 	mt.pause(interval)
-	fl.Touch()
+	fl.Touch("")
 	if fl.state != FakelagThrottled {
 		t.Fatalf("should be throttled")
 	}
@@ -91,7 +91,7 @@ func TestFakelag(t *testing.T) {
 	}
 
 	// send another message without a pause; we should have to sleep for 500 msec
-	fl.Touch()
+	fl.Touch("")
 	if fl.state != FakelagThrottled {
 		t.Fatalf("should be throttled")
 	}
@@ -102,7 +102,7 @@ func TestFakelag(t *testing.T) {
 	}
 
 	mt.pause(interval * 6)
-	fl.Touch()
+	fl.Touch("")
 	if fl.state != FakelagThrottled {
 		t.Fatalf("should still be throttled")
 	}
@@ -112,7 +112,7 @@ func TestFakelag(t *testing.T) {
 	}
 
 	mt.pause(window * 2)
-	fl.Touch()
+	fl.Touch("")
 	if fl.state != FakelagBursting {
 		t.Fatalf("should be bursting again")
 	}
diff --git a/irctest b/irctest
index 058fab85..35d342a4 160000
--- a/irctest
+++ b/irctest
@@ -1,1 +1,1 @@
-Subproject commit 058fab85b0967ed0f7011d1b161a5ad0a702b441
+Subproject commit 35d342a478f8ddc7d6b9ba7b2e55f769c60478d1