Add test-network.sh and fix IPC goroutine lifecycle bugs
test-network.sh: shell script that starts anchor + alice + bob + charlie, joins them to a named network, exchanges five messages, then has charlie leave — verifying the full join/chat/leave cycle with coloured per-peer output. ipc: two fixes exposed by the test script: - Network join context was per-IPC-client, so the join was immediately cancelled when the nc connection closed. Lifted join/leave state to the Run() level (shared across all clients, protected by a mutex) so the network stays connected independent of which client issued the command. - Event-pusher goroutine could panic with "send on closed channel" when the command loop closed writeCh while the pusher was mid-select. Added defer recover() to both the pusher goroutine and the send helper, and removed the default arm so the select blocks cleanly on done. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -20,10 +21,10 @@ import (
|
||||
)
|
||||
|
||||
// JoinFunc is called when the UI issues a join_network command.
|
||||
// It should connect to the anchor and block until done or ctx is cancelled.
|
||||
type JoinFunc func(ctx context.Context, networkName string)
|
||||
|
||||
// Run starts the IPC listener. Blocks until the listener fails.
|
||||
// Network join/leave state is daemon-scoped (shared across all IPC clients).
|
||||
func Run(m *mesh.Mesh, port int, join JoinFunc) error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
@@ -32,24 +33,53 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
|
||||
}
|
||||
log.Printf("ipc: listening on %s", addr)
|
||||
|
||||
// networkCancel is shared across all clients — any client can join/leave,
|
||||
// and the join persists even after the commanding client disconnects.
|
||||
var (
|
||||
networkMu sync.Mutex
|
||||
networkCancel context.CancelFunc
|
||||
)
|
||||
|
||||
doJoin := func(name string) {
|
||||
networkMu.Lock()
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
networkCancel = cancel
|
||||
networkMu.Unlock()
|
||||
go join(ctx, name)
|
||||
}
|
||||
|
||||
doLeave := func() {
|
||||
networkMu.Lock()
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
networkCancel = nil
|
||||
}
|
||||
networkMu.Unlock()
|
||||
}
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ipc accept: %w", err)
|
||||
}
|
||||
log.Printf("ipc: UI client connected from %s", conn.RemoteAddr())
|
||||
go handleClient(conn, m, join)
|
||||
go handleClient(conn, m, doJoin, doLeave)
|
||||
}
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn, m *mesh.Mesh, join JoinFunc) {
|
||||
func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func()) {
|
||||
defer conn.Close()
|
||||
|
||||
events := m.Subscribe()
|
||||
defer m.Unsubscribe(events)
|
||||
|
||||
writeCh := make(chan []byte, 128)
|
||||
done := make(chan struct{})
|
||||
|
||||
// Writer goroutine — sole owner of the write side of the connection.
|
||||
go func() {
|
||||
w := bufio.NewWriter(conn)
|
||||
for line := range writeCh {
|
||||
@@ -61,31 +91,51 @@ func handleClient(conn net.Conn, m *mesh.Mesh, join JoinFunc) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Event pusher — forwards mesh events to the UI client.
|
||||
// recover() guards against the rare race where writeCh is closed while a
|
||||
// send is in flight (closed channel panics even inside select).
|
||||
go func() {
|
||||
for evt := range events {
|
||||
line, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer func() { recover() }() //nolint:errcheck
|
||||
for {
|
||||
select {
|
||||
case writeCh <- line:
|
||||
default:
|
||||
case evt, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
line, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case writeCh <- line:
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
send(writeCh, proto.IpcMessage{
|
||||
send := func(msg proto.IpcMessage) {
|
||||
defer func() { recover() }() //nolint:errcheck
|
||||
line, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case writeCh <- line:
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
LocalPeer: ptr(m.Identity.PeerInfo()),
|
||||
ConnectedPeers: m.ConnectedPeers(),
|
||||
Rooms: []string{"general"},
|
||||
})
|
||||
|
||||
// Track an active network join so we can cancel it on leave_network.
|
||||
var (
|
||||
networkCancel context.CancelFunc
|
||||
)
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
var cmd proto.IpcMessage
|
||||
@@ -115,24 +165,16 @@ func handleClient(conn net.Conn, m *mesh.Mesh, join JoinFunc) {
|
||||
|
||||
case proto.CmdJoinNetwork:
|
||||
if cmd.NetworkName == "" {
|
||||
send(writeCh, errMsg("join_network: network_name is required"))
|
||||
send(errMsg("join_network: network_name is required"))
|
||||
continue
|
||||
}
|
||||
if networkCancel != nil {
|
||||
networkCancel() // leave any previous network
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
networkCancel = cancel
|
||||
go join(ctx, cmd.NetworkName)
|
||||
doJoin(cmd.NetworkName)
|
||||
|
||||
case proto.CmdLeaveNetwork:
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
networkCancel = nil
|
||||
}
|
||||
doLeave()
|
||||
|
||||
case proto.CmdGetState:
|
||||
send(writeCh, proto.IpcMessage{
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
LocalPeer: ptr(m.Identity.PeerInfo()),
|
||||
ConnectedPeers: m.ConnectedPeers(),
|
||||
@@ -140,31 +182,18 @@ func handleClient(conn net.Conn, m *mesh.Mesh, join JoinFunc) {
|
||||
})
|
||||
|
||||
case proto.CmdSendFile:
|
||||
send(writeCh, errMsg("file transfer not yet implemented"))
|
||||
send(errMsg("file transfer not yet implemented"))
|
||||
|
||||
default:
|
||||
send(writeCh, errMsg(fmt.Sprintf("unknown command: %s", cmd.Type)))
|
||||
send(errMsg(fmt.Sprintf("unknown command: %s", cmd.Type)))
|
||||
}
|
||||
}
|
||||
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
}
|
||||
close(done)
|
||||
close(writeCh)
|
||||
log.Printf("ipc: UI client disconnected")
|
||||
}
|
||||
|
||||
func send(ch chan<- []byte, msg proto.IpcMessage) {
|
||||
line, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func errMsg(s string) proto.IpcMessage {
|
||||
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user