fatedier/frp · warning

listener closed

Error message

listener closed

What it means

InternalListener is a net.Listener whose connections are injected via PutConn rather than a socket. Accept receives from acceptCh; once Close() closes the channel, Accept returns this 'listener closed' error permanently. It exists so frp can present in-process connections through the standard Listener interface.

Source

Thrown at pkg/util/net/listener.go:42

// InternalListener is a listener that can be used to accept connections from
// other goroutines.
type InternalListener struct {
	acceptCh chan net.Conn
	closed   bool
	mu       sync.Mutex
}

func NewInternalListener() *InternalListener {
	return &InternalListener{
		acceptCh: make(chan net.Conn, 128),
	}
}

func (l *InternalListener) Accept() (net.Conn, error) {
	conn, ok := <-l.acceptCh
	if !ok {
		return nil, fmt.Errorf("listener closed")
	}
	return conn, nil
}

func (l *InternalListener) PutConn(conn net.Conn) error {
	err := errors.PanicToError(func() {
		select {
		case l.acceptCh <- conn:
		default:
			conn.Close()
		}
	})
	if err != nil {
		return fmt.Errorf("put conn error: listener is closed")
	}
	return nil
}

View on GitHub (pinned to 6c8a8d0a97)

Solutions

  1. Stop the accept loop when this error is observed — it is terminal, not retriable.
  2. Close the listener before waiting on accept-loop goroutines so the blocked Accept wakes with this error.
  3. If you need sentinel comparison, check err.Error() == "listener closed" or wrap InternalListener to return net.ErrClosed.

Example fix

// before
func (s *Server) serveLoop(l net.Listener) {
    for {
        c, err := l.Accept()
        if err != nil {
            panic(err) // crashes at shutdown
        }
        go s.handle(c)
    }
}

// after
for {
    c, err := l.Accept()
    if err != nil {
        return // listener closed — clean exit
    }
    go s.handle(c)
}
Defensive patterns

Strategy: try-catch

Type guard

func isListenerClosedErr(err error) bool {
    return errors.Is(err, net.ErrClosed) || err.Error() == "listener closed"
}

Try / catch

conn, err := internalListener.Accept()
if err != nil {
    if isListenerClosedErr(err) {
        return // terminal: channel was closed by Close()
    }
    log.Errorf("accept error: %v", err)
    continue
}

Prevention

When it happens

Trigger: Calling Accept after Close on an InternalListener — e.g. the https-to-http fallback or other virtual hosts being torn down at frps shutdown while the accept loop is still blocked in <-acceptCh.

Common situations: Shutdown ordering bugs where the accept loop is joined after (or never notified of) Close; tests that close listeners then call Accept once more.

Related errors


AI-assisted analysis of fatedier/frp@6c8a8d0a97 (2026-08-15). Data as JSON: /api/errors/59814ed70bf78bba. Report an issue: GitHub.