Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion cmd/agent-compose/cli_daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type daemonServer struct {
listener net.Listener
server *http.Server
cleanup func() error
h2c *http2.Server
}

type localUnixSocketRequestKey struct{}
Expand Down Expand Up @@ -271,7 +272,15 @@ func isLoopbackListenAddress(address string) bool {
}

func (s *daemonServers) add(name, value string, listener net.Listener, handler http.Handler, cleanup func() error) {
server := &http.Server{Handler: h2c.NewHandler(handler, &http2.Server{})} //nolint:staticcheck // h2c is required for unencrypted HTTP/2 compatibility with Connect bidi streams.
// Keep HTTP/2 connection timeouts disabled: this server carries long-lived
// Connect bidi streams, whose idle periods are part of the protocol.
h2cConfig := &http2.Server{}
server := &http.Server{
Handler: h2c.NewHandler(handler, h2cConfig), //nolint:staticcheck // h2c is required for unencrypted HTTP/2 compatibility with Connect bidi streams.
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 64 << 10,
}
Comment thread
monkeyscan[bot] marked this conversation as resolved.
if listener.Addr().Network() == "unix" {
server.ConnContext = func(ctx context.Context, conn net.Conn) context.Context {
if isTrustedUnixSocketConn(conn) {
Expand All @@ -286,6 +295,7 @@ func (s *daemonServers) add(name, value string, listener net.Listener, handler h
listener: listener,
server: server,
cleanup: cleanup,
h2c: h2cConfig,
})
}

Expand Down
23 changes: 23 additions & 0 deletions cmd/agent-compose/cli_daemon_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@ import (
"github.com/samber/do/v2"
)

func TestDaemonServerConfiguresConnectionLimits(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen tcp: %v", err)
}
servers := &daemonServers{}
servers.add("HTTP_LISTEN", listener.Addr().String(), listener, http.NewServeMux(), nil)
server := servers.items[0].server
if server.ReadHeaderTimeout != 5*time.Second || server.IdleTimeout != 2*time.Minute || server.MaxHeaderBytes != 64<<10 {
t.Fatalf("server limits = header %s idle %s max-header %d", server.ReadHeaderTimeout, server.IdleTimeout, server.MaxHeaderBytes)
}
if server.WriteTimeout != 0 {
t.Fatalf("server WriteTimeout = %s, want zero for long-running streams", server.WriteTimeout)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

新增测试未覆盖实际改动(http2.Server 超时清零),已确认的长连接回归仍无测试保护

本次改动的实际行为变化是把 h2c.NewHandler 的 http2.Server 配置由带显式超时(IdleTimeout 2m、ReadIdleTimeout 30s、PingTimeout 15s、WriteByteTimeout 30s)改为零值 &http2.Server{},以避免 HTTP/2 层超时主动中断长时间运行的 Connect attach bidi 会话——这正是历史已确认问题(66a7dab3)所指向的回归,本次为修复。但新增测试只断言 http.Server.WriteTimeout == 0,该字段本就不曾设置、保持 0,并不属于本次改动;真正被清零的 http2.Server 字段被封装在 h2c.NewHandler 内部,现有测试(包括 TestDaemonTCPServerAttachAgentRunBidiUsesH2C)都无法感知。若后续有人再次以"加固"名义给 http2.Server 加上 ReadIdleTimeout/WriteByteTimeout/IdleTimeout,测试仍会全部通过,该文件历史上已经发生过一次反复(先加超时、本次移除),因此回归风险是具体且已被证实的。另外需注意:x/net 对零值 http2.Server 会把 WriteByteTimeout 回退为默认 15s,即写阻塞断连阈值从 30s 收紧到 15s,与该改动"保护长流"的目标存在细微张力,值得显式决策并在测试/注释中固化。

Problem code:

Changed code at cmd/agent-compose/cli_daemon_server_test.go:36-38

Recommendation:
为本次行为补充能真正兜底的回归保护:(1) 把传入 h2c.NewHandler 的 *http2.Server 保存在 daemonServer 结构上,并在 TestDaemonServerConfiguresConnectionLimits 中断言其 ReadIdleTimeout/IdleTimeout/WriteByteTimeout 为零(或与显式意图一致);或(2) 增加集成测试——经 h2c 打开一个 Connect bidi 流并保持静默超过原 ReadIdleTimeout+PingTimeout 窗口(约 45s),断言流仍存活且连接未被服务端关闭。同时建议显式设置或注释说明 WriteByteTimeout 零值回退 15s 的语义,避免后续误以为"未设置即无写超时"。

config := servers.items[0].h2c
if config == nil || config.IdleTimeout != 0 || config.ReadIdleTimeout != 0 || config.PingTimeout != 0 || config.WriteByteTimeout != 0 {
t.Fatalf("h2c connection limits = %#v, want all zero for long-running streams", config)
}
if err := servers.shutdown(context.Background()); err != nil {
t.Fatalf("shutdown server: %v", err)
}
}

func TestDaemonTCPServerAttachAgentRunBidiUsesH2C(t *testing.T) {
seen := make(chan string, 1)
mux := http.NewServeMux()
Expand Down
5 changes: 3 additions & 2 deletions test/e2e/graceful_sandbox_stop_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"connectrpc.com/connect"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/durationpb"

"github.com/chaitin/agent-compose/pkg/agentcompose/api"
Expand Down Expand Up @@ -68,9 +69,9 @@ func TestGracefulSandboxStopOutcomesUsePublicConnectContract(t *testing.T) {
t.Cleanup(server.Close)
client := agentcomposev2connect.NewSandboxServiceClient(server.Client(), server.URL)

request := *test.request
request := proto.Clone(test.request).(*agentcomposev2.StopSandboxRequest)
request.SandboxId = sandboxID
response, err := client.StopSandbox(context.Background(), connect.NewRequest(&request))
response, err := client.StopSandbox(context.Background(), connect.NewRequest(request))
if test.wantCode != 0 {
if connect.CodeOf(err) != test.wantCode {
t.Fatalf("StopSandbox() error = %v, code = %s, want %s", err, connect.CodeOf(err), test.wantCode)
Expand Down
Loading