From dca2cec67fdfe289bf455a16fb4f294f8dd4d710 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Wed, 6 Mar 2019 12:25:02 -0800 Subject: [PATCH 1/9] chat/message: Add timestamp prefix support --- chat/message/theme.go | 39 ++++++++++++++++++++++++--------------- chat/message/user.go | 13 +++++++++---- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/chat/message/theme.go b/chat/message/theme.go index d2585b7..9a10cb2 100644 --- a/chat/message/theme.go +++ b/chat/message/theme.go @@ -1,6 +1,9 @@ package message -import "fmt" +import ( + "fmt" + "time" +) const ( // Reset resets the color @@ -122,43 +125,49 @@ type Theme struct { names *Palette } -func (t Theme) ID() string { - return t.id +func (theme Theme) ID() string { + return theme.id } // Colorize name string given some index -func (t Theme) ColorName(u *User) string { - if t.names == nil { +func (theme Theme) ColorName(u *User) string { + if theme.names == nil { return u.Name() } - return t.names.Get(u.colorIdx).Format(u.Name()) + return theme.names.Get(u.colorIdx).Format(u.Name()) } // Colorize the PM string -func (t Theme) ColorPM(s string) string { - if t.pm == nil { +func (theme Theme) ColorPM(s string) string { + if theme.pm == nil { return s } - return t.pm.Format(s) + return theme.pm.Format(s) } // Colorize the Sys message -func (t Theme) ColorSys(s string) string { - if t.sys == nil { +func (theme Theme) ColorSys(s string) string { + if theme.sys == nil { return s } - return t.sys.Format(s) + return theme.sys.Format(s) } // Highlight a matched string, usually name -func (t Theme) Highlight(s string) string { - if t.highlight == nil { +func (theme Theme) Highlight(s string) string { + if theme.highlight == nil { return s } - return t.highlight.Format(s) + return theme.highlight.Format(s) +} + +// Timestamp formats and colorizes the timestamp. +func (theme Theme) Timestamp(t time.Time) string { + // TODO: Change this per-theme? Or config? + return theme.sys.Format(t.Format("2006-01-02 15:04 UTC")) } // List of initialzied themes diff --git a/chat/message/user.go b/chat/message/user.go index d094bde..807d1f4 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -158,17 +158,22 @@ func (u *User) SetHighlight(s string) error { func (u *User) render(m Message) string { cfg := u.Config() + var out string switch m := m.(type) { case PublicMsg: - return m.RenderFor(cfg) + Newline + out += m.RenderFor(cfg) case *PrivateMsg: + out += m.Render(cfg.Theme) if cfg.Bell { - return m.Render(cfg.Theme) + Bel + Newline + out += Bel } - return m.Render(cfg.Theme) + Newline default: - return m.Render(cfg.Theme) + Newline + out += m.Render(cfg.Theme) } + if cfg.Timestamp { + return cfg.Theme.Timestamp(m.Timestamp()) + " " + out + Newline + } + return out + Newline } // writeMsg renders the message and attempts to write it, will Close the user From 4188a3bdacd1ce3d1f1986d64de856d7aeeda3a8 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Wed, 6 Mar 2019 12:25:38 -0800 Subject: [PATCH 2/9] host: Add timestamps into prompt when enabled --- host.go | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/host.go b/host.go index 068b969..e8943e9 100644 --- a/host.go +++ b/host.go @@ -25,6 +25,10 @@ func GetPrompt(user *message.User) string { if cfg.Theme != nil { name = cfg.Theme.ColorName(user) } + if cfg.Timestamp && cfg.Theme != nil { + ts := cfg.Theme.Timestamp(time.Now()) + return fmt.Sprintf("%s [%s] ", ts, name) + } return fmt.Sprintf("[%s] ", name) } @@ -128,6 +132,12 @@ func (h *Host) Connect(term *sshd.Terminal) { term.AutoCompleteCallback = h.AutoCompleteFunction(user) user.SetHighlight(user.Name()) + // Update prompt once per minute + stopUpdater := make(chan struct{}, 1) + defer func() { stopUpdater <- struct{}{} }() + // FIXME: Would be nice to unify this into a single goroutine rather than one per user. + go h.promptUpdater(term, user, stopUpdater) + // Should the user be op'd on join? if h.isOp(term.Conn) { member.IsOp = true @@ -166,7 +176,7 @@ func (h *Host) Connect(term *sshd.Terminal) { h.HandleMsg(m) cmd := m.Command() - if cmd == "/nick" || cmd == "/theme" { + if cmd == "/nick" || cmd == "/theme" || cmd == "/timestamp" { // Hijack /nick command to update terminal synchronously. Wouldn't // work if we use h.room.Send(m) above. // @@ -185,6 +195,31 @@ func (h *Host) Connect(term *sshd.Terminal) { logger.Debugf("[%s] Leaving: %s", term.Conn.RemoteAddr(), user.Name()) } +func (h *Host) promptUpdater(term *sshd.Terminal, user *message.User, stopper <-chan struct{}) { + now := time.Now() + interval := time.Second * 60 + nextMinute := time.Duration(60-now.Second()) * time.Second + setupWait := time.After(nextMinute) + timer := time.NewTimer(interval) + defer timer.Stop() + + for { + select { + case <-setupWait: + // Wait until we're at :00 seconds so that minute-resolution + // timestamps happen on the minute change. This only happens once. + timer.Reset(interval) + term.SetPrompt(GetPrompt(user)) + term.Write([]byte{}) // Empty write to re-render the prompt + case <-timer.C: + term.SetPrompt(GetPrompt(user)) + term.Write([]byte{}) // Empty write to re-render the prompt + case <-stopper: + return + } + } +} + // Serve our chat room onto the listener func (h *Host) Serve() { h.listener.HandlerFunc = h.Connect From 6d3fce47ccb72a391ca760cf830990f50ce197fb Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 15 Mar 2019 15:57:50 -0400 Subject: [PATCH 3/9] Revert "host: Add timestamps into prompt when enabled" This reverts commit 6ce14bfc33e5a5aeb6003cdec9a873588eabe8f0. We're going to do another approach. --- host.go | 37 +------------------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/host.go b/host.go index e8943e9..068b969 100644 --- a/host.go +++ b/host.go @@ -25,10 +25,6 @@ func GetPrompt(user *message.User) string { if cfg.Theme != nil { name = cfg.Theme.ColorName(user) } - if cfg.Timestamp && cfg.Theme != nil { - ts := cfg.Theme.Timestamp(time.Now()) - return fmt.Sprintf("%s [%s] ", ts, name) - } return fmt.Sprintf("[%s] ", name) } @@ -132,12 +128,6 @@ func (h *Host) Connect(term *sshd.Terminal) { term.AutoCompleteCallback = h.AutoCompleteFunction(user) user.SetHighlight(user.Name()) - // Update prompt once per minute - stopUpdater := make(chan struct{}, 1) - defer func() { stopUpdater <- struct{}{} }() - // FIXME: Would be nice to unify this into a single goroutine rather than one per user. - go h.promptUpdater(term, user, stopUpdater) - // Should the user be op'd on join? if h.isOp(term.Conn) { member.IsOp = true @@ -176,7 +166,7 @@ func (h *Host) Connect(term *sshd.Terminal) { h.HandleMsg(m) cmd := m.Command() - if cmd == "/nick" || cmd == "/theme" || cmd == "/timestamp" { + if cmd == "/nick" || cmd == "/theme" { // Hijack /nick command to update terminal synchronously. Wouldn't // work if we use h.room.Send(m) above. // @@ -195,31 +185,6 @@ func (h *Host) Connect(term *sshd.Terminal) { logger.Debugf("[%s] Leaving: %s", term.Conn.RemoteAddr(), user.Name()) } -func (h *Host) promptUpdater(term *sshd.Terminal, user *message.User, stopper <-chan struct{}) { - now := time.Now() - interval := time.Second * 60 - nextMinute := time.Duration(60-now.Second()) * time.Second - setupWait := time.After(nextMinute) - timer := time.NewTimer(interval) - defer timer.Stop() - - for { - select { - case <-setupWait: - // Wait until we're at :00 seconds so that minute-resolution - // timestamps happen on the minute change. This only happens once. - timer.Reset(interval) - term.SetPrompt(GetPrompt(user)) - term.Write([]byte{}) // Empty write to re-render the prompt - case <-timer.C: - term.SetPrompt(GetPrompt(user)) - term.Write([]byte{}) // Empty write to re-render the prompt - case <-stopper: - return - } - } -} - // Serve our chat room onto the listener func (h *Host) Serve() { h.listener.HandlerFunc = h.Connect From eb10bad08ef64fb17642eecf4f0e2ce6362c34c2 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 15 Mar 2019 16:01:47 -0400 Subject: [PATCH 4/9] sshchat, chat: Override terminal echo and do our own echo --- chat/room.go | 11 +---------- host.go | 9 +++++++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/chat/room.go b/chat/room.go index 71e3171..4fb56dd 100644 --- a/chat/room.go +++ b/chat/room.go @@ -90,12 +90,7 @@ func (r *Room) HandleMsg(m message.Message) { user := m.To() user.Send(m) default: - fromMsg, skip := m.(message.MessageFrom) - var skipUser *message.User - if skip { - skipUser = fromMsg.From() - } - + fromMsg, _ := m.(message.MessageFrom) r.history.Add(m) r.Members.Each(func(_ string, item set.Item) (err error) { user := item.Value().(*Member).User @@ -105,10 +100,6 @@ func (r *Room) HandleMsg(m message.Message) { return } - if skip && skipUser == user { - // Skip self - return - } if _, ok := m.(*message.AnnounceMsg); ok { if user.Config().Quiet { // Skip announcements diff --git a/host.go b/host.go index 068b969..9db1479 100644 --- a/host.go +++ b/host.go @@ -175,6 +175,15 @@ func (h *Host) Connect(term *sshd.Terminal) { term.SetPrompt(GetPrompt(user)) user.SetHighlight(user.Name()) } + + // Gross hack to override line echo in golang.org/x/crypto/ssh/terminal + term.Write([]byte{ + 27, // keyEscape + '[', 'A', // Up + 27, // keyEscape + '[', '2', 'K', // Clear line + }) + // May the gods have mercy on our souls. } err = h.Leave(user) From 33d0c08ffe834439bf5146660089d6ceeac8fdee Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 15 Mar 2019 16:20:13 -0400 Subject: [PATCH 5/9] chat/message: Add PublicMsg.RenderSelf(...) to support old-style formatting --- chat/message/message.go | 6 ++++++ chat/message/user.go | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/chat/message/message.go b/chat/message/message.go index 5b4f76a..143096a 100644 --- a/chat/message/message.go +++ b/chat/message/message.go @@ -112,6 +112,7 @@ func (m PublicMsg) Render(t *Theme) string { return fmt.Sprintf("%s: %s", t.ColorName(m.from), m.body) } +// RenderFor renders the message for other users to see. func (m PublicMsg) RenderFor(cfg UserConfig) string { if cfg.Highlight == nil || cfg.Theme == nil { return m.Render(cfg.Theme) @@ -128,6 +129,11 @@ func (m PublicMsg) RenderFor(cfg UserConfig) string { return fmt.Sprintf("%s: %s", cfg.Theme.ColorName(m.from), body) } +// RenderSelf renders the message for when it's echoing your own message. +func (m PublicMsg) RenderSelf(cfg UserConfig) string { + return fmt.Sprintf("[%s] %s", cfg.Theme.ColorName(m.from), m.body) +} + func (m PublicMsg) String() string { return fmt.Sprintf("%s: %s", m.from.Name(), m.body) } diff --git a/chat/message/user.go b/chat/message/user.go index 807d1f4..f8cebf4 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -161,7 +161,11 @@ func (u *User) render(m Message) string { var out string switch m := m.(type) { case PublicMsg: - out += m.RenderFor(cfg) + if u == m.From() { + out += m.RenderSelf(cfg) + } else { + out += m.RenderFor(cfg) + } case *PrivateMsg: out += m.Render(cfg.Theme) if cfg.Bell { From 783c607fad11b93a321bd8734b6116fcb107cd70 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 15 Mar 2019 16:26:39 -0400 Subject: [PATCH 6/9] chat/message: Add second resolution --- chat/message/theme.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chat/message/theme.go b/chat/message/theme.go index 9a10cb2..b3bfde2 100644 --- a/chat/message/theme.go +++ b/chat/message/theme.go @@ -167,7 +167,7 @@ func (theme Theme) Highlight(s string) string { // Timestamp formats and colorizes the timestamp. func (theme Theme) Timestamp(t time.Time) string { // TODO: Change this per-theme? Or config? - return theme.sys.Format(t.Format("2006-01-02 15:04 UTC")) + return theme.sys.Format(t.Format("2006-01-02 15:04:05 UTC")) } // List of initialzied themes From 54ce8bb08d12d76c6d465c1697557b2ae943df61 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 15 Mar 2019 16:30:06 -0400 Subject: [PATCH 7/9] chat: Remove injectTimestamp after 30min stuff --- chat/command.go | 2 +- chat/message/user.go | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/chat/command.go b/chat/command.go index d459fc5..cba9bbc 100644 --- a/chat/command.go +++ b/chat/command.go @@ -284,7 +284,7 @@ func InitCommands(c *Commands) { c.Add(Command{ Prefix: "/timestamp", - Help: "Timestamps after 30min of inactivity.", + Help: "Prefix messages with a timestamp.", Handler: func(room *Room, msg message.CommandMsg) error { u := msg.From() cfg := u.Config() diff --git a/chat/message/user.go b/chat/message/user.go index f8cebf4..e825ae1 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -195,20 +195,8 @@ func (u *User) writeMsg(m Message) error { // HandleMsg will render the message to the screen, blocking. func (u *User) HandleMsg(m Message) error { u.mu.Lock() - cfg := u.config - lastMsg := u.lastMsg u.lastMsg = m.Timestamp() - injectTimestamp := !lastMsg.IsZero() && cfg.Timestamp && u.lastMsg.Sub(lastMsg) > timestampTimeout u.mu.Unlock() - - if injectTimestamp { - // Inject a timestamp at most once every timestampTimeout between message intervals - ts := NewSystemMsg(fmt.Sprintf("Timestamp: %s", m.Timestamp().UTC().Format(timestampLayout)), u) - if err := u.writeMsg(ts); err != nil { - return err - } - } - return u.writeMsg(m) } From 8282fad7dc1047a32e546435d4cafefcb907e0eb Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 16 Mar 2019 12:20:43 -0400 Subject: [PATCH 8/9] chat: Add timezone support to /timestamp --- chat/command.go | 27 +++++++++++++++++++++++---- chat/message/theme.go | 4 +++- chat/message/user.go | 10 ++++++++-- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/chat/command.go b/chat/command.go index cba9bbc..f4bc903 100644 --- a/chat/command.go +++ b/chat/command.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/shazow/ssh-chat/chat/message" "github.com/shazow/ssh-chat/internal/sanitize" @@ -283,16 +284,34 @@ func InitCommands(c *Commands) { }) c.Add(Command{ - Prefix: "/timestamp", - Help: "Prefix messages with a timestamp.", + Prefix: "/timestamp", + PrefixHelp: "[TZINFO]", + Help: "Prefix messages with a timestamp. (Example: America/Toronto)", Handler: func(room *Room, msg message.CommandMsg) error { u := msg.From() cfg := u.Config() - cfg.Timestamp = !cfg.Timestamp + + args := msg.Args() + if len(args) >= 1 { + // FIXME: This is an annoying format to demand from users, but + // hopefully we can make it a non-primary flow if we add GeoIP + // someday. + timeLoc, err := time.LoadLocation(args[0]) + if err != nil { + err = fmt.Errorf("%s: Use a location name such as \"America/Toronto\" or refer to the IANA Time Zone database for the full list of names: https://wikipedia.org/wiki/List_of_tz_database_time_zones", err) + return err + } + cfg.Timezone = timeLoc + cfg.Timestamp = true + } else { + cfg.Timestamp = !cfg.Timestamp + } u.SetConfig(cfg) var body string - if cfg.Timestamp { + if cfg.Timestamp && cfg.Timezone != nil { + body = fmt.Sprintf("Timestamp is toggled ON with timezone %q", cfg.Timezone) + } else if cfg.Timestamp { body = "Timestamp is toggled ON" } else { body = "Timestamp is toggled OFF" diff --git a/chat/message/theme.go b/chat/message/theme.go index b3bfde2..bc79f41 100644 --- a/chat/message/theme.go +++ b/chat/message/theme.go @@ -5,6 +5,8 @@ import ( "time" ) +const timestampLayout = "2006-01-02 15:04:05 MST" + const ( // Reset resets the color Reset = "\033[0m" @@ -167,7 +169,7 @@ func (theme Theme) Highlight(s string) string { // Timestamp formats and colorizes the timestamp. func (theme Theme) Timestamp(t time.Time) string { // TODO: Change this per-theme? Or config? - return theme.sys.Format(t.Format("2006-01-02 15:04:05 UTC")) + return theme.sys.Format(t.Format(timestampLayout)) } // List of initialzied themes diff --git a/chat/message/user.go b/chat/message/user.go index e825ae1..7635161 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -16,7 +16,6 @@ const messageBuffer = 5 const messageTimeout = 5 * time.Second const reHighlight = `\b(%s)\b` const timestampTimeout = 30 * time.Minute -const timestampLayout = "2006-01-02 15:04:05 UTC" var ErrUserClosed = errors.New("user closed") @@ -175,7 +174,13 @@ func (u *User) render(m Message) string { out += m.Render(cfg.Theme) } if cfg.Timestamp { - return cfg.Theme.Timestamp(m.Timestamp()) + " " + out + Newline + ts := m.Timestamp() + if cfg.Timezone != nil { + ts = ts.In(cfg.Timezone) + } else { + ts = ts.UTC() + } + return cfg.Theme.Timestamp(ts) + " " + out + Newline } return out + Newline } @@ -220,6 +225,7 @@ type UserConfig struct { Bell bool Quiet bool Timestamp bool + Timezone *time.Location Theme *Theme } From 9c3db55c8318d8cc2b204fdc8a24a7f4b2ee499f Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 17 Mar 2019 13:50:01 -0400 Subject: [PATCH 9/9] sshchat: Echo command messages with the new timestamp code. --- chat/message/user.go | 2 ++ host.go | 23 ++++++++++-------- host_test.go | 55 +++++++++++++++++++++++++++++++++++++------- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/chat/message/user.go b/chat/message/user.go index 7635161..6a9e17c 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -170,6 +170,8 @@ func (u *User) render(m Message) string { if cfg.Bell { out += Bel } + case *CommandMsg: + out += m.RenderSelf(cfg) default: out += m.Render(cfg.Theme) } diff --git a/host.go b/host.go index 9db1479..176187b 100644 --- a/host.go +++ b/host.go @@ -162,6 +162,20 @@ func (h *Host) Connect(term *sshd.Terminal) { m := message.ParseInput(line, user) + // Gross hack to override line echo in golang.org/x/crypto/ssh/terminal + // It needs to live before we render the resulting message. + term.Write([]byte{ + 27, '[', 'A', // Up + 27, '[', '2', 'K', // Clear line + }) + // May the gods have mercy on our souls. + + if m, ok := m.(*message.CommandMsg); ok { + // Other messages render themselves by the room, commands we'll + // have to re-echo ourselves manually. + user.HandleMsg(m) + } + // FIXME: Any reason to use h.room.Send(m) instead? h.HandleMsg(m) @@ -175,15 +189,6 @@ func (h *Host) Connect(term *sshd.Terminal) { term.SetPrompt(GetPrompt(user)) user.SetHighlight(user.Name()) } - - // Gross hack to override line echo in golang.org/x/crypto/ssh/terminal - term.Write([]byte{ - 27, // keyEscape - '[', 'A', // Up - 27, // keyEscape - '[', '2', 'K', // Clear line - }) - // May the gods have mercy on our souls. } err = h.Leave(user) diff --git a/host_test.go b/host_test.go index b4ee362..cfb88f4 100644 --- a/host_test.go +++ b/host_test.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "errors" "io" + mathRand "math/rand" "strings" "testing" @@ -15,22 +16,53 @@ import ( ) func stripPrompt(s string) string { - pos := strings.LastIndex(s, "\033[K") - if pos < 0 { - return s + // FIXME: Is there a better way to do this? + if endPos := strings.Index(s, "\x1b[K "); endPos > 0 { + return s[endPos+3:] + } + if endPos := strings.Index(s, "\x1b[2K "); endPos > 0 { + return s[endPos+4:] + } + if endPos := strings.Index(s, "] "); endPos > 0 { + return s[endPos+2:] + } + return s +} + +func TestStripPrompt(t *testing.T) { + tests := []struct { + Input string + Want string + }{ + { + Input: "\x1b[A\x1b[2K[quux] hello", + Want: "hello", + }, + { + Input: "[foo] \x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[K * Guest1 joined. (Connected: 2)\r", + Want: " * Guest1 joined. (Connected: 2)\r", + }, + } + + for i, tc := range tests { + if got, want := stripPrompt(tc.Input), tc.Want; got != want { + t.Errorf("case #%d:\n got: %q\nwant: %q", i, got, want) + } } - return s[pos+3:] } func TestHostGetPrompt(t *testing.T) { var expected, actual string + // Make the random colors consistent across tests + mathRand.Seed(1) + u := message.NewUser(&Identity{id: "foo"}) actual = GetPrompt(u) expected = "[foo] " if actual != expected { - t.Errorf("Got: %q; Expected: %q", actual, expected) + t.Errorf("Invalid host prompt:\n Got: %q;\nWant: %q", actual, expected) } u.SetConfig(message.UserConfig{ @@ -39,7 +71,7 @@ func TestHostGetPrompt(t *testing.T) { actual = GetPrompt(u) expected = "[\033[38;05;88mfoo\033[0m] " if actual != expected { - t.Errorf("Got: %q; Expected: %q", actual, expected) + t.Errorf("Invalid host prompt:\n Got: %q;\nWant: %q", actual, expected) } } @@ -205,18 +237,23 @@ func TestHostKick(t *testing.T) { // Change nicks, make sure op sticks w.Write([]byte("/nick quux\r\n")) scanner.Scan() // Prompt + scanner.Scan() // Prompt echo scanner.Scan() // Nick change response + // Signal for the second client to connect + connected <- struct{}{} + // Block until second client is here connected <- struct{}{} scanner.Scan() // Connected message w.Write([]byte("/kick bar\r\n")) scanner.Scan() // Prompt + scanner.Scan() // Prompt echo - scanner.Scan() + scanner.Scan() // Kick result if actual, expected := stripPrompt(scanner.Text()), " * bar was kicked by quux.\r"; actual != expected { - t.Errorf("Got %q; expected %q", actual, expected) + t.Errorf("Failed to detect kick:\n Got: %q;\nWant: %q", actual, expected) } kicked <- struct{}{} @@ -231,6 +268,8 @@ func TestHostKick(t *testing.T) { }() go func() { + <-connected + // Second client err := sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) error { scanner := bufio.NewScanner(r)