#5526·frp

frps 0.71.0 kills healthy client every `transport.heartbeatTimeout` seconds while heartbeats are actually received and ponged (verified by tcpdump)

Author: carlaauCreated Sep 9, 2026Updated Sep 9, 2026

1. 英文提交版

Bug Description

Title

frps 0.71.0 kills healthy client every transport.heartbeatTimeout seconds while heartbeats are actually received and ponged (verified by tcpdump)

Environment

  • frps: v0.71.0, windows/amd64 (cloud VM), config file frps.toml
  • frpc: v0.71.0-alpine (docker, network_mode: host), linux/amd64
  • 3 tcp proxies on one client; run id stays identical across reconnects
  • Traffic path: browser → nginx → frps (127.0.0.1:5667, tcp proxy remotePort) → frp tunnel → frpc → local Vite dev server (long-lived WebSocket for HMR)
  • transport.tcpMux default (on): work connections and control messages share the same TCP connection (yamux)

Symptom

frps kills the client with heartbeat timeout every N seconds, where N is exactly transport.heartbeatTimeout (verified with 90 and 300), even though the client's heartbeat messages are arriving and being answered normally.

Every kick:

[W] [server/control.go:647] heartbeat timeout
[I] all proxies closing (proxy closing ×3)
[I] [server/control.go:721] client exit success
[I] [server/service.go:803] client login info: ... (0.3~0.7s later, same run id)

Then the cycle repeats. Consequence: every TCP connection multiplexed on that control connection is destroyed, so long-lived proxied connections (e.g. Vite HMR websocket through a tcp proxy) break every N seconds.

Evidence that heartbeats ARE received

tcpdump -ni any host <frps_ip> and port 7000 on the frpc machine, captured across a kick moment (N=90):

00:36:43.948 Out 192.168.19.247.x > frps.7000: Flags [P.], length 34      <- ping (msgPing)
00:36:43.964 In  frps.7000 > 192.168.19.247.x: Flags [P.], length 34      <- pong (msgPong)
... (ping/pong every 30s, no loss, no RST/FIN around the kick)

frps answers every ping with a pong. Since sending a pong implies the ping was parsed and the heartbeat timer should have been reset, the timeout judgment cannot belong to this healthy control session.

Additional observation

  • With log.level = "debug", frps logs ~20-50 short-lived user connections per second (get a user connectionjoin connectionsjoin connections closed within milliseconds), all multiplexed on the same control TCP via tcp_mux. The storm starts ~1s after client login and persists. Possibly related to triggering the condition.
  • Timing is exact: kick happens 90.3s / 90.7s / 91.3s after each login (measured over multiple cycles, debug log attached). Changing transport.heartbeatTimeout 90 → 300 moves the kick to ~300s accordingly, so the timer that fires is definitely the heartbeat-timeout timer.
  • frpc was also tested at v0.70.1 (same behavior), so it is not a client-version mismatch.
  • frpc config contains no transport overrides (default 30s heartbeat interval).

Suspected cause

A stale control session object for the same run id survives a reconnect (half-open TCP → client re-login). Its heartbeat timer never gets refreshed (its read loop is gone), and when it fires, the cleanup path closes the whole client by run id — including the healthy new session. v0.70.1 fixed "control-session replacement leaks when frpc reconnects through a half-open TCP multiplexed connection"; this looks like a remaining edge case of the same area in v0.71.0.

Workaround (effective)

Setting on frps:

transport.heartbeatTimeout = -1

(disables the server-side heartbeat check) stops all kicks completely — the tunnel then stays up indefinitely, which further confirms the client side is healthy.

Request

  • Please check whether the heartbeat timer of a replaced/stale control session can fire and close the client registered by the newer session.
  • Consider whether server-side cleanup should be scoped to the dead session instead of the whole run id, or verify timer reset on pong.

frpc Version

v0.71.0-alpine

frps Version

v0.71.0

System Architecture

windows/amd64 (frps) / linux/amd64 (frpc)

Configurations

frps (trimmed; token omitted):

bindPort = 7000
transport.maxPoolCount = 20
transport.heartbeatTimeout = 90    # kick period follows this value exactly (90 -> 90s, 300 -> 300s, -1 -> no kicks)

frpc (trimmed; token omitted; proxies: 3 × type tcp):

serverAddr = "x.x.x.x"
serverPort = 7000
auth.method = "token"
# no transport.* overrides: default 30s heartbeat interval
[[proxies]]
name = "testapp"
type = "tcp"
localIP = "192.168.19.248"
localPort = 5667
remotePort = 5667

Logs

frps (kicks at exactly one heartbeatTimeout after each login, same run id):

09:55:35.667 [I] frps started successfully
09:55:37.921 [I] [783087f8] client login info: ip [x.x.x.x:55366] version [0.71.0] ...
09:57:08.281 [W] [783087f8] heartbeat timeout                    <- +90.4s
09:57:08.281 [I] [783087f8] client exit success
09:57:08.656 [I] [783087f8] client login info: ip [x.x.x.x:39166] ...
09:58:38.969 [W] [783087f8] heartbeat timeout                    <- +90.3s
09:58:39.676 [I] [783087f8] client login info: ip [x.x.x.x:50150] ...
10:00:10.038 [W] [783087f8] heartbeat timeout                    <- +91.1s

frps debug (connection storm, starts ~1s after login, persists):

09:55:38.095 [I] [783087f8] [testapp] get a user connection [127.0.0.1:64852]
09:55:38.096 [D] [783087f8] get work connection from pool
09:55:38.096 [D] [783087f8] [testapp] join connections, workConn(...) userConn(...)
09:55:38.182 [D] [783087f8] [testapp] join connections closed     <- milliseconds later
... (~20-50/sec, 6058 occurrences in a 4.7-minute debug log)

tcpdump on frpc machine across a kick moment (no RST/FIN, heartbeat/pong healthy):

00:36:43.948 Out 192.168.19.247.38316 > frps.7000: Flags [P.], length 34
00:36:43.964 In  frps.7000 > 192.168.19.247.38316: Flags [P.], length 34

Steps to reproduce

  1. Run frps v0.71.0 on Windows with a tcp proxy registered by an frpc behind NAT, and keep a long-lived connection through the tunnel (e.g. a browser WebSocket).
  2. Let a control-session replacement happen at least once (observed after frps restart, or a network blip while a client session is alive — frpc re-logins with the same run id).
  3. From then on, frps logs heartbeat timeout and closes the client exactly transport.heartbeatTimeout seconds after every re-login, forever. tcpdump on the frpc side shows pings leaving every 30s and pongs returning, including right up to the kick moment.
  4. Setting transport.heartbeatTimeout = -1 on frps (restart) makes all kicks stop.

Note: the initial half-open event is not deterministic, but once it happens the kick loop is 100% reproducible and periodic.

Affected area

  • Docs
  • Installation
  • Performance and Scalability
  • Security
  • User Experience
  • Test and Release
  • Developer Infrastructure
  • Client Plugin
  • Server Plugin
  • Extensions
  • Others (server core: control session / heartbeat management)

2. 中文对照版

Bug 描述

标题

frps 0.71.0 每 transport.heartbeatTimeout 秒强制踢掉健康 client——tcpdump 证明心跳实际正常收发且被正常 pong 应答

环境

  • frps:v0.71.0,windows/amd64(云服务器),配置文件 frps.toml
  • frpc:v0.71.0-alpine(docker,network_mode: host),linux/amd64
  • 同一 client 下 3 个 tcp 代理;重连前后 run id 不变
  • 流量路径:浏览器 → nginx → frps(127.0.0.1:5667,tcp 代理 remotePort)→ frp 隧道 → frpc → 本地 Vite dev server(HMR 长连接 WebSocket)
  • transport.tcpMux 默认开启:工作连接与控制消息复用同一条 TCP(yamux)

症状

frps 以 heartbeat timeout 踢掉 client,间隔 N 秒恰好等于 transport.heartbeatTimeout (实测 90 和 300 两种取值均精确联动),而此时 client 的心跳消息实际正常到达并被正常应答。

每次踢:

[W] [server/control.go:647] heartbeat timeout
[I] 三个代理同时 proxy closing
[I] [server/control.go:721] client exit success
[I] [server/service.go:803] client login info: ...(0.3~0.7 秒后,run id 相同)

循环往复。后果:复用在该控制连接上的所有 TCP 连接被连带销毁,长连接业务(如经 tcp 代理的 Vite HMR WebSocket)每 N 秒断一次。

心跳确实被收到的证据

在 frpc 机器上跨踢点抓包(N=90):tcpdump -ni any host <frps_ip> and port 7000

00:36:43.948 Out 192.168.19.247.x > frps.7000: Flags [P.], length 34      <- ping (msgPing)
00:36:43.964 In  frps.7000 > 192.168.19.247.x: Flags [P.], length 34      <- pong (msgPong)
...(每 30s 一对 ping/pong,无丢失,踢点前后无 RST/FIN)

frps 对每个 ping 都回了 pong。回 pong 意味着 ping 已被解析、心跳计时器理应被刷新——因此 这次超时判定不可能属于这条健康的控制会话。

补充观察

  • log.level = "debug" 下,frps 每秒记录 20~50 条毫秒级短连接(get a user connectionjoin connections → 数毫秒后 join connections closed),全部经 tcp_mux 复用在同一条控制 TCP 上;风暴在 client login 后约 1 秒开始并持续。可能与触发条件相关。
  • 时间精确:多次测量,踢点均在 login 后 90.3s / 90.7s / 91.3s;将 transport.heartbeatTimeout 90 → 300 后踢点同步移到 ~300s——触发的必然就是心跳超时计时器。
  • frpc 在 v0.70.1 上同样复现,排除客户端版本不匹配。
  • frpc 配置无任何 transport 覆盖(默认 30s 心跳间隔)。

怀疑原因

同 run id 的旧控制会话对象在重连后残留(半开 TCP → client 重新 login)。其心跳计时器永远 得不到刷新(读循环已不存在),到点触发时清理路径按 run id 关闭整个 client——连健康的 新会话一起杀掉。v0.70.1 修复过 "control-session replacement leaks when frpc reconnects through a half-open TCP multiplexed connection",本问题疑似同区域在 v0.71.0 的残留边界场景。

有效规避

frps 侧配置:

transport.heartbeatTimeout = -1

(禁用服务端心跳检查)后所有踢点完全消失——隧道无限期保持稳定,进一步证明 client 侧是健康的。

诉求

  • 排查:被替换/残留的 control 会话的心跳计时器触发时,是否会按 run id 关闭新会话注册的 client;
  • 考虑:服务端清理是否应只作用于死掉的会话而非整个 run id;或确认 pong 时是否正确重置了计时器。

frpc 版本

v0.71.0-alpine

frps 版本

v0.71.0

系统架构

windows/amd64(frps)/ linux/amd64(frpc)

配置

frps(节选,token 略):

bindPort = 7000
transport.maxPoolCount = 20
transport.heartbeatTimeout = 90    # 踢点周期精确跟随该值(90→90s,300→300s,-1→不再踢)

frpc(节选,token 略;代理:3 × type tcp):

serverAddr = "x.x.x.x"
serverPort = 7000
auth.method = "token"
# 无 transport.* 覆盖:默认 30s 心跳间隔
[[proxies]]
name = "testapp"
type = "tcp"
localIP = "192.168.19.248"
localPort = 5667
remotePort = 5667

日志

frps(每次 login 后恰好一个 heartbeatTimeout,run id 相同):

09:55:35.667 [I] frps started successfully
09:55:37.921 [I] [783087f8] client login info: ip [x.x.x.x:55366] version [0.71.0] ...
09:57:08.281 [W] [783087f8] heartbeat timeout                    <- +90.4s
09:57:08.281 [I] [783087f8] client exit success
09:57:08.656 [I] [783087f8] client login info: ip [x.x.x.x:39166] ...
09:58:38.969 [W] [783087f8] heartbeat timeout                    <- +90.3s
09:58:39.676 [I] [783087f8] client login info: ip [x.x.x.x:50150] ...
10:00:10.038 [W] [783087f8] heartbeat timeout                    <- +91.1s

frps debug(连接风暴:login 后约 1s 开始,持续不断):

09:55:38.095 [I] [783087f8] [testapp] get a user connection [127.0.0.1:64852]
09:55:38.096 [D] [783087f8] get work connection from pool
09:55:38.096 [D] [783087f8] [testapp] join connections, workConn(...) userConn(...)
09:55:38.182 [D] [783087f8] [testapp] join connections closed     <- 毫秒级关闭
...(每秒 20~50 条,4.7 分钟 debug 日志中共 6058 条)

frpc 机器上跨踢点 tcpdump(无 RST/FIN,心跳/pong 健康):

00:36:43.948 Out 192.168.19.247.38316 > frps.7000: Flags [P.], length 34
00:36:43.964 In  frps.7000 > 192.168.19.247.38316: Flags [P.], length 34

复现步骤

  1. 在 Windows 上运行 frps v0.71.0,由一个 NAT 后的 frpc 注册 tcp 代理,并保持一条经隧道的 长连接(如浏览器 WebSocket);
  2. 让控制会话替换至少发生一次(观察到 frps 重启时、或 client 会话存活期间网络抖动时会出现 ——frpc 以相同 run id 重新 login);
  3. 此后 frps 在每次 re-login 后恰好 transport.heartbeatTimeout 秒记录 heartbeat timeout 并关闭 client,无限循环。frpc 侧 tcpdump 显示 ping 每 30s 发出、pong 正常返回,直到踢点 时刻依然如此;
  4. frps 设置 transport.heartbeatTimeout = -1(重启)后所有踢点消失。

说明:最初的半开事件不是确定性触发的,但一旦发生,踢循环 100% 可复现且呈严格周期性。

影响区域

  • Others(服务端核心:control 会话 / 心跳管理)