fatedier/frp · error

control is already closed

Error message

control is already closed

What it means

In Control.GetWorkConn, when the pool is empty the server sends ReqWorkConn to frpc; if msgDispatcher.Send fails, the control's message stream is already broken (connection closed or dispatcher shut down), so it reports 'control is already closed'. Compare with the ErrCtlClosed sentinel returned when the workConnCh channel itself is closed.

Source

Thrown at server/control.go:616

		if err := recover(); err != nil {
			xl.Errorf("panic error: %v", err)
			xl.Errorf(string(debug.Stack()))
		}
	}()

	var ok bool
	// get a work connection from the pool
	select {
	case workConn, ok = <-ctl.workConnCh:
		if !ok {
			err = pkgerr.ErrCtlClosed
			return
		}
		xl.Debugf("get work connection from pool")
	default:
		// no work connections available in the poll, send message to frpc to get more
		if err := ctl.msgDispatcher.Send(&msg.ReqWorkConn{}); err != nil {
			return nil, fmt.Errorf("control is already closed")
		}

		select {
		case workConn, ok = <-ctl.workConnCh:
			if !ok {
				err = pkgerr.ErrCtlClosed
				xl.Warnf("no work connections available, %v", err)
				return
			}

		case <-time.After(time.Duration(ctl.sessionCtx.ServerCfg.UserConnTimeout) * time.Second):
			err = fmt.Errorf("timeout trying to get work connection")
			xl.Warnf("%v", err)
			return
		}
	}

	// When we get a work connection from pool, replace it with a new one.

View on GitHub (pinned to 6c8a8d0a97)

Solutions

  1. Let frpc's reconnect loop re-establish the control; user connections during the gap will fail and callers should retry.
  2. Investigate why the control dropped: check frpc logs for crashes/OOM, heartbeat timeout settings, and NAT/firewall idle timeouts.
  3. Tune transport.heartbeatTimeout and enable transport.tcpMux to keep the control connection alive.

Example fix

# frpc.toml — keep control connection healthy
[transport]
heartbeatTimeout = 90
tcpMux = true
Defensive patterns

Strategy: retry

Try / catch

if _, err := ctl.GetWorkConn(); err != nil {
    if errors.Is(err, pkgerr.ErrCtlClosed) || strings.Contains(err.Error(), "control is already closed") {
        // control gone: fail the user conn; frpc reconnect will restore service
        return err
    }
}

Prevention

When it happens

Trigger: A user connection arrives, the work conn pool is empty, and the control connection to frpc has just died (network drop, frpc exit, server closing the control). Send(&msg.ReqWorkConn{}) errors and the wrapper converts it to this error.

Common situations: frpc process crashing or restarting while user traffic hits the proxy; heartbeat timeout closing the control; NAT idle timeout dropping the control TCP connection.

Related errors


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