From 6a4e02ec6fbee9e599b833d21ac2e54b9633bdb3 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 21 May 2026 15:42:13 +0800 Subject: [PATCH 1/7] fix(dialtesting): fix traceroute timeout --- dialtesting/icmp.go | 4 +- dialtesting/tcp.go | 12 ++++-- dialtesting/tcp_test.go | 54 +++++++++++++++++++++++++ dialtesting/traceroute.go | 16 ++++++++ dialtesting/traceroute_other.go | 24 +++++++---- dialtesting/traceroute_test.go | 71 +++++++++++++++++++++++++++++++++ 6 files changed, 168 insertions(+), 13 deletions(-) diff --git a/dialtesting/icmp.go b/dialtesting/icmp.go index 38765ebd..934b3028 100644 --- a/dialtesting/icmp.go +++ b/dialtesting/icmp.go @@ -338,7 +338,7 @@ func (t *ICMPTask) run() error { if t.EnableTraceroute { hostIP := net.ParseIP(t.Host) if hostIP == nil { - if ips, err := net.LookupIP(t.Host); err != nil { + if ips, err := lookupIP(t.Host); err != nil { t.reqError = err.Error() return nil } else { @@ -347,7 +347,7 @@ func (t *ICMPTask) run() error { t.reqError = err.Error() return nil } else { - hostIP = ips[0] + hostIP = preferredIP(ips) } } } diff --git a/dialtesting/tcp.go b/dialtesting/tcp.go index 5241dc50..d785332d 100644 --- a/dialtesting/tcp.go +++ b/dialtesting/tcp.go @@ -269,10 +269,11 @@ func (t *TCPTask) run() error { defer cancel() hostIP := net.ParseIP(t.Host) + hostIsName := hostIP == nil - if hostIP == nil { // host name + if hostIsName { // host name start := time.Now() - if ips, err := net.LookupIP(t.Host); err != nil { + if ips, err := lookupIP(t.Host); err != nil { t.reqError = err.Error() return nil } else { @@ -317,7 +318,12 @@ func (t *TCPTask) run() error { } if t.EnableTraceroute { - routes, err := TracerouteIP(hostIP.String(), t.TracerouteConfig) + tracerouteHost := hostIP.String() + if hostIsName { + tracerouteHost = t.Host + } + + routes, err := TracerouteIP(tracerouteHost, t.TracerouteConfig) if err != nil { t.reqError = err.Error() } else { diff --git a/dialtesting/tcp_test.go b/dialtesting/tcp_test.go index bd5f35e2..dad55796 100644 --- a/dialtesting/tcp_test.go +++ b/dialtesting/tcp_test.go @@ -139,6 +139,60 @@ func TestTcp(t *testing.T) { } } +func TestTCPRunUsesFirstDNSResultForPrimaryDial(t *testing.T) { + server, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("IPv6 loopback is not available: %s", err) + } + defer server.Close() + + go func() { + conn, err := server.Accept() + if err != nil { + return + } + _ = conn.Close() + }() + + _, port, err := net.SplitHostPort(server.Addr().String()) + assert.NoError(t, err) + + oldLookupIP := lookupIP + lookupIP = func(host string) ([]net.IP, error) { + assert.Equal(t, "dual-stack.example", host) + return []net.IP{ + net.ParseIP("::1"), + net.ParseIP("127.0.0.1"), + }, nil + } + defer func() { + lookupIP = oldLookupIP + }() + + task := &TCPTask{ + Host: "dual-stack.example", + Port: port, + SuccessWhen: []*TCPSuccess{ + { + ResponseTime: []*TCPResponseTime{{ + Target: "10s", + }}, + }, + }, + Task: &Task{ + ExternalID: "xxxx", + Frequency: "10s", + Name: "dual-stack", + }, + } + task.SetChild(task) + + assert.NoError(t, task.Check()) + assert.NoError(t, task.Run()) + assert.Empty(t, task.reqError) + assert.Equal(t, "::1", task.destIP) +} + func tcpServer() (server net.Listener, err error) { server, err = net.Listen("tcp", "") if err != nil { diff --git a/dialtesting/traceroute.go b/dialtesting/traceroute.go index a99e73d3..c8c203a6 100644 --- a/dialtesting/traceroute.go +++ b/dialtesting/traceroute.go @@ -18,6 +18,8 @@ const ( MaxRetry = 3 ) +var lookupIP = net.LookupIP + // TracerouteOption represent traceroute option. type TracerouteOption struct { Hops int @@ -61,6 +63,20 @@ type Packet struct { startTime time.Time } +func preferredIP(ips []net.IP) net.IP { + for _, ip := range ips { + if ip.To4() != nil { + return ip + } + } + + if len(ips) == 0 { + return nil + } + + return ips[0] +} + func mean(v []float64) float64 { var res float64 = 0 var n int = len(v) diff --git a/dialtesting/traceroute_other.go b/dialtesting/traceroute_other.go index df7252af..64bb2d80 100644 --- a/dialtesting/traceroute_other.go +++ b/dialtesting/traceroute_other.go @@ -53,7 +53,7 @@ func (t *Traceroute) init() { } if t.Retry <= 0 { - t.Retry = 3 + t.Retry = 2 } else if t.Retry > MaxRetry { t.Retry = MaxRetry } @@ -80,20 +80,28 @@ func (t *Traceroute) getRandomID() uint32 { return uint32(rand.Intn(60000)) // nolint:gosec } +func (t *Traceroute) resolveHostIP() (net.IP, error) { + ips, err := lookupIP(t.Host) + if err != nil { + return nil, err + } + + if len(ips) == 0 { + return nil, fmt.Errorf("invalid host: %s", t.Host) + } + + return preferredIP(ips), nil +} + func (t *Traceroute) Run() error { var runError error - ips, err := net.LookupIP(t.Host) + ip, err := t.resolveHostIP() if err != nil { return err } t.init() - if len(ips) == 0 { - return fmt.Errorf("invalid host: %s", t.Host) - } - ip := ips[0] - var wg sync.WaitGroup wg.Add(2) go func() { @@ -371,7 +379,7 @@ func (t *Traceroute) sendICMP(ip net.IP, ttl int) error { } func TracerouteIP(ip string, opt *TracerouteOption) (routes []*Route, err error) { - defaultTimeout := 30 * time.Millisecond + defaultTimeout := 500 * time.Millisecond if opt == nil { opt = &TracerouteOption{ Hops: 30, diff --git a/dialtesting/traceroute_test.go b/dialtesting/traceroute_test.go index ebf84b77..55362106 100644 --- a/dialtesting/traceroute_test.go +++ b/dialtesting/traceroute_test.go @@ -7,7 +7,10 @@ package dialtesting import ( "fmt" + "net" "testing" + + "github.com/stretchr/testify/assert" ) func TestTraceroute(t *testing.T) { @@ -26,3 +29,71 @@ func TestTraceroute(t *testing.T) { fmt.Printf(" total: %d, failed: %d, loss: %f, avg: %f, max: %f, min: %f, std: %f\n", route.Total, route.Failed, route.Loss, route.AvgCost, route.MaxCost, route.MinCost, route.StdCost) } } + +func TestPreferredIP(t *testing.T) { + tests := []struct { + name string + ips []net.IP + want string + }{ + { + name: "prefers IPv4 from dual-stack DNS result", + ips: []net.IP{ + net.ParseIP("2001:db8::1"), + net.ParseIP("192.0.2.10"), + }, + want: "192.0.2.10", + }, + { + name: "returns IPv4 when IPv4 is first", + ips: []net.IP{ + net.ParseIP("192.0.2.20"), + net.ParseIP("2001:db8::2"), + }, + want: "192.0.2.20", + }, + { + name: "falls back to IPv6 when no IPv4 exists", + ips: []net.IP{ + net.ParseIP("2001:db8::3"), + }, + want: "2001:db8::3", + }, + { + name: "returns nil for empty result", + ips: nil, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := preferredIP(tt.ips) + if tt.want == "" { + assert.Nil(t, got) + return + } + + assert.Equal(t, tt.want, got.String()) + }) + } +} + +func TestTracerouteResolveHostIPPrefersIPv4ForDualStackDNS(t *testing.T) { + oldLookupIP := lookupIP + lookupIP = func(host string) ([]net.IP, error) { + assert.Equal(t, "dual-stack.example", host) + return []net.IP{ + net.ParseIP("2001:db8::1"), + net.ParseIP("192.0.2.10"), + }, nil + } + defer func() { + lookupIP = oldLookupIP + }() + + traceroute := &Traceroute{Host: "dual-stack.example"} + ip, err := traceroute.resolveHostIP() + assert.NoError(t, err) + assert.Equal(t, "192.0.2.10", ip.String()) +} From b6989b69e8e52e7876cdba7bb74f6fa782008860 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 21 May 2026 15:55:21 +0800 Subject: [PATCH 2/7] save --- dialtesting/traceroute_other.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dialtesting/traceroute_other.go b/dialtesting/traceroute_other.go index 64bb2d80..dfcee670 100644 --- a/dialtesting/traceroute_other.go +++ b/dialtesting/traceroute_other.go @@ -53,7 +53,7 @@ func (t *Traceroute) init() { } if t.Retry <= 0 { - t.Retry = 2 + t.Retry = 3 } else if t.Retry > MaxRetry { t.Retry = MaxRetry } @@ -383,7 +383,7 @@ func TracerouteIP(ip string, opt *TracerouteOption) (routes []*Route, err error) if opt == nil { opt = &TracerouteOption{ Hops: 30, - Retry: 2, + Retry: 3, timeout: defaultTimeout, } } else { From 62197b764fa1401f97c2693a04636c942757b3bc Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 25 May 2026 10:23:59 +0800 Subject: [PATCH 3/7] save --- dialtesting/http.go | 123 +++++++++++++++++++++++++++-- dialtesting/http_test.go | 165 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 6 deletions(-) diff --git a/dialtesting/http.go b/dialtesting/http.go index 6d16c6fb..ec0bbe8e 100644 --- a/dialtesting/http.go +++ b/dialtesting/http.go @@ -27,6 +27,7 @@ import ( "text/template" "time" + "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" "golang.org/x/net/http2" ) @@ -37,8 +38,9 @@ var ( ) const ( - MaxBodySize = 10 * 1024 - DefaultHTTPTimeout = 60 * time.Second + MaxBodySize = 10 * 1024 + DefaultHTTPTimeout = 60 * time.Second + HTTP3HandshakeTimeout = 5 * time.Second ProtocolAuto = "auto" ProtocolHTTP11 = "http/1.1" @@ -381,6 +383,14 @@ func (t *HTTPTask) run() error { t.reqStart = time.Now() t.resp, err = t.cli.Do(t.req) + if t.protocol == ProtocolHTTP3 && t.resp != nil && t1.IsZero() { + // For HTTP/3, response_ttfb is response header arrival measured by http.Client.Do return. + t1 = time.Now() + t.ttfbTime = float64(time.Since(t.reqStart)) / float64(time.Microsecond) + } + if t.protocol == ProtocolHTTP3 && t.resp != nil && t.resp.TLS != nil && t.sslCertNotAfter == 0 { + t.extractSSLCertificateValidity(*t.resp.TLS) + } if t.resp != nil { defer t.resp.Body.Close() //nolint:errcheck } @@ -633,10 +643,8 @@ func (t *HTTPTask) init() error { switch protocol { case ProtocolHTTP3: t.cli = &http.Client{ - Timeout: httpTimeout, - Transport: &http3.RoundTripper{ - TLSClientConfig: tlsConfig, - }, + Timeout: httpTimeout, + Transport: t.newHTTP3RoundTripper(tlsConfig, httpTimeout), } case ProtocolHTTP2Only: if isPlainHTTP(t.URL) { @@ -774,6 +782,109 @@ func isPlainHTTP(rawURL string) bool { return err == nil && strings.EqualFold(u.Scheme, "http") } +func (t *HTTPTask) newHTTP3RoundTripper(tlsConfig *tls.Config, httpTimeout time.Duration) http.RoundTripper { + return &http3.RoundTripper{ + TLSClientConfig: tlsConfig, + Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + resolveStart := time.Now() + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + t.dnsParseTime = float64(time.Since(resolveStart)) / float64(time.Microsecond) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, fmt.Errorf("no IP addresses found for %q", host) + } + ips = preferIPv4(ips) + + dialTLSConfig := tlsCfg + if dialTLSConfig == nil { + dialTLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } else { + dialTLSConfig = dialTLSConfig.Clone() + } + if dialTLSConfig.ServerName == "" { + dialTLSConfig.ServerName = host + } + dialQUICConfig := limitHTTP3HandshakeTimeout(cfg, httpTimeout) + + var lastErr error + for _, ip := range ips { + resolvedAddr := net.JoinHostPort(ip.IP.String(), port) + connectStart := time.Now() + conn, err := quic.DialAddrEarly(ctx, resolvedAddr, dialTLSConfig, dialQUICConfig) + // For HTTP/3, response_connection is QUIC connection setup time, not TCP connect time. + t.connectionTime = float64(time.Since(connectStart)) / float64(time.Microsecond) + if err != nil { + lastErr = err + continue + } + + handshakeStart := time.Now() + select { + case <-conn.HandshakeComplete(): + // For HTTP/3, response_ssl is QUIC/TLS handshake time, not standalone TCP TLS handshake time. + t.sslTime = float64(time.Since(handshakeStart)) / float64(time.Microsecond) + t.destIP = ip.IP.String() + return conn, nil + case <-ctx.Done(): + _ = conn.CloseWithError(quic.ApplicationErrorCode(0), ctx.Err().Error()) + return nil, ctx.Err() + case <-conn.Context().Done(): + lastErr = conn.Context().Err() + continue + } + } + + return nil, lastErr + }, + } +} + +func limitHTTP3HandshakeTimeout(cfg *quic.Config, httpTimeout time.Duration) *quic.Config { + var dialQUICConfig *quic.Config + if cfg == nil { + dialQUICConfig = &quic.Config{} + } else { + dialQUICConfig = cfg.Clone() + } + + handshakeTimeout := HTTP3HandshakeTimeout + if httpTimeout > 0 && httpTimeout < HTTP3HandshakeTimeout { + handshakeTimeout = httpTimeout + } else if dialQUICConfig.HandshakeIdleTimeout == 0 { + return dialQUICConfig + } + if dialQUICConfig.HandshakeIdleTimeout > handshakeTimeout { + dialQUICConfig.HandshakeIdleTimeout = handshakeTimeout + } + return dialQUICConfig +} + +func preferIPv4(ips []net.IPAddr) []net.IPAddr { + if len(ips) <= 1 { + return ips + } + + sorted := make([]net.IPAddr, 0, len(ips)) + for _, ip := range ips { + if ip.IP.To4() != nil { + sorted = append(sorted, ip) + } + } + for _, ip := range ips { + if ip.IP.To4() == nil { + sorted = append(sorted, ip) + } + } + return sorted +} + func (t *HTTPTask) getHostName() ([]string, error) { if hostName, err := getHostName(t.URL); err != nil { return nil, err diff --git a/dialtesting/http_test.go b/dialtesting/http_test.go index bf402742..fd71c8e5 100644 --- a/dialtesting/http_test.go +++ b/dialtesting/http_test.go @@ -1199,6 +1199,171 @@ func TestHTTPProtocolHTTP3(t *testing.T) { t.Logf("tags: %+v", tags) t.Logf("fields: %+v", fields) assert.Equal(t, "OK", tags["status"]) + assert.NotEmpty(t, tags["dest_ip"]) + assert.Greater(t, fields["response_time"].(int64), int64(0)) + assert.Greater(t, fields["response_dns"].(float64), float64(0)) + assert.GreaterOrEqual(t, fields["response_connection"].(float64), float64(0)) + assert.GreaterOrEqual(t, fields["response_ssl"].(float64), float64(0)) + assert.Greater(t, fields["response_ttfb"].(float64), float64(0)) + assert.Less(t, fields["response_download"].(float64), float64((10*time.Second)/time.Microsecond)) + assert.NotNil(t, fields["ssl_cert_not_after"]) + assert.NotNil(t, fields["ssl_cert_expires_in_days"]) +} + +func TestHTTPProtocolHTTP3RedirectDownloadTime(t *testing.T) { + certPEM, keyPEM, err := generateSelfSignedCert() + assert.NoError(t, err) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + assert.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://console.truewatch.com") + w.WriteHeader(http.StatusPermanentRedirect) + }) + + tlsConf := &tls.Config{ + Certificates: []tls.Certificate{cert}, + InsecureSkipVerify: true, + NextProtos: []string{"h3"}, + ServerName: "localhost", + } + + server := &http3.Server{ + Handler: mux, + TLSConfig: tlsConf, + } + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + assert.NoError(t, err) + + port := listener.LocalAddr().(*net.UDPAddr).Port + serverURL := fmt.Sprintf("https://localhost:%d", port) + + go func() { + _ = server.Serve(listener) + }() + + defer server.Close() + + time.Sleep(500 * time.Millisecond) + + task := &HTTPTask{ + Task: &Task{ + ExternalID: cliutils.XID("dtst_"), + Name: "_test_http3_redirect_download_time", + Region: "hangzhou", + Frequency: "1s", + }, + Method: "GET", + URL: serverURL, + AdvanceOptions: &HTTPAdvanceOption{ + Protocol: "http/3", + Certificate: &HTTPOptCertificate{ + IgnoreServerCertificateError: true, + }, + RequestOptions: &HTTPOptRequest{ + FollowRedirect: false, + }, + RequestTimeout: "10s", + }, + SuccessWhen: []*HTTPSuccess{ + { + StatusCode: []*SuccessOption{ + {Is: "308"}, + }, + }, + }, + } + + task.SetChild(task) + err = task.Init() + assert.NoError(t, err) + + err = task.Run() + assert.NoError(t, err) + + tags, fields := task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.NotEmpty(t, tags["dest_ip"]) + assert.Equal(t, http.StatusPermanentRedirect, fields["status_code"]) + assert.Greater(t, fields["response_dns"].(float64), float64(0)) + assert.GreaterOrEqual(t, fields["response_connection"].(float64), float64(0)) + assert.GreaterOrEqual(t, fields["response_ssl"].(float64), float64(0)) + assert.Less(t, fields["response_download"].(float64), float64((10*time.Second)/time.Microsecond)) +} + +func TestHTTPProtocolHTTP3HandshakeFailureReturnsQuickly(t *testing.T) { + certPEM, keyPEM, err := generateSelfSignedCert() + assert.NoError(t, err) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + assert.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("HTTP/3 response")) + }) + + tlsConf := &tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"h3"}, + ServerName: "localhost", + } + + server := &http3.Server{ + Handler: mux, + TLSConfig: tlsConf, + } + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + assert.NoError(t, err) + + port := listener.LocalAddr().(*net.UDPAddr).Port + serverURL := fmt.Sprintf("https://localhost:%d", port) + + go func() { + _ = server.Serve(listener) + }() + + defer server.Close() + + time.Sleep(500 * time.Millisecond) + + task := &HTTPTask{ + Task: &Task{ + ExternalID: cliutils.XID("dtst_"), + Name: "_test_http3_handshake_failure", + Region: "hangzhou", + Frequency: "1s", + }, + Method: "GET", + URL: serverURL, + AdvanceOptions: &HTTPAdvanceOption{ + Protocol: "http/3", + RequestTimeout: "10s", + }, + SuccessWhen: []*HTTPSuccess{ + { + StatusCode: []*SuccessOption{ + {Is: "200"}, + }, + }, + }, + } + + task.SetChild(task) + err = task.Init() + assert.NoError(t, err) + + start := time.Now() + err = task.Run() + assert.NoError(t, err) + assert.Less(t, time.Since(start), 6*time.Second) + + _, fields := task.GetResults() + assert.NotEmpty(t, fields["fail_reason"]) } func TestHTTPProtocolAuto(t *testing.T) { From f944a0b2e8d8cab4251cd2c45d3d5b6da28ca281 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 25 May 2026 11:05:59 +0800 Subject: [PATCH 4/7] save --- dialtesting/icmp.go | 2 +- dialtesting/tcp.go | 7 +--- dialtesting/tcp_test.go | 71 +++++++++++++++++++++++++++++++++++++++ dialtesting/traceroute.go | 5 ++- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/dialtesting/icmp.go b/dialtesting/icmp.go index 934b3028..a1e9e163 100644 --- a/dialtesting/icmp.go +++ b/dialtesting/icmp.go @@ -351,7 +351,7 @@ func (t *ICMPTask) run() error { } } } - routes, err := TracerouteIP(hostIP.String(), t.TracerouteConfig) + routes, err := runTracerouteIP(hostIP.String(), t.TracerouteConfig) if err != nil { t.reqError = err.Error() } else { diff --git a/dialtesting/tcp.go b/dialtesting/tcp.go index d785332d..ce3d7f9f 100644 --- a/dialtesting/tcp.go +++ b/dialtesting/tcp.go @@ -318,12 +318,7 @@ func (t *TCPTask) run() error { } if t.EnableTraceroute { - tracerouteHost := hostIP.String() - if hostIsName { - tracerouteHost = t.Host - } - - routes, err := TracerouteIP(tracerouteHost, t.TracerouteConfig) + routes, err := runTracerouteIP(hostIP.String(), t.TracerouteConfig) if err != nil { t.reqError = err.Error() } else { diff --git a/dialtesting/tcp_test.go b/dialtesting/tcp_test.go index dad55796..bba867aa 100644 --- a/dialtesting/tcp_test.go +++ b/dialtesting/tcp_test.go @@ -193,6 +193,77 @@ func TestTCPRunUsesFirstDNSResultForPrimaryDial(t *testing.T) { assert.Equal(t, "::1", task.destIP) } +func TestTCPRunTracerouteUsesSelectedDialIP(t *testing.T) { + server, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("IPv6 loopback is not available: %s", err) + } + defer server.Close() + + go func() { + conn, err := server.Accept() + if err != nil { + return + } + _ = conn.Close() + }() + + _, port, err := net.SplitHostPort(server.Addr().String()) + assert.NoError(t, err) + + oldLookupIP := lookupIP + lookupIP = func(host string) ([]net.IP, error) { + assert.Equal(t, "dual-stack.example", host) + return []net.IP{ + net.ParseIP("::1"), + net.ParseIP("127.0.0.1"), + }, nil + } + defer func() { + lookupIP = oldLookupIP + }() + + var tracerouteTarget string + oldRunTracerouteIP := runTracerouteIP + runTracerouteIP = func(ip string, opt *TracerouteOption) ([]*Route, error) { + tracerouteTarget = ip + return []*Route{ + { + Total: 1, + Items: []*RouteItem{{IP: ip}}, + }, + }, nil + } + defer func() { + runTracerouteIP = oldRunTracerouteIP + }() + + task := &TCPTask{ + Host: "dual-stack.example", + Port: port, + EnableTraceroute: true, + SuccessWhen: []*TCPSuccess{ + { + ResponseTime: []*TCPResponseTime{{ + Target: "10s", + }}, + }, + }, + Task: &Task{ + ExternalID: "xxxx", + Frequency: "10s", + Name: "dual-stack-traceroute", + }, + } + task.SetChild(task) + + assert.NoError(t, task.Check()) + assert.NoError(t, task.Run()) + assert.Empty(t, task.reqError) + assert.Equal(t, "::1", task.destIP) + assert.Equal(t, task.destIP, tracerouteTarget) +} + func tcpServer() (server net.Listener, err error) { server, err = net.Listen("tcp", "") if err != nil { diff --git a/dialtesting/traceroute.go b/dialtesting/traceroute.go index c8c203a6..ceddf32f 100644 --- a/dialtesting/traceroute.go +++ b/dialtesting/traceroute.go @@ -18,7 +18,10 @@ const ( MaxRetry = 3 ) -var lookupIP = net.LookupIP +var ( + lookupIP = net.LookupIP + runTracerouteIP = TracerouteIP +) // TracerouteOption represent traceroute option. type TracerouteOption struct { From 9b6e7d7305aa6b9eaf75b4b99833c7f82563325f Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 25 May 2026 17:14:13 +0800 Subject: [PATCH 5/7] save --- dialtesting/http.go | 32 +++++++++++++++ dialtesting/http_test.go | 86 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/dialtesting/http.go b/dialtesting/http.go index ec0bbe8e..b0419b78 100644 --- a/dialtesting/http.go +++ b/dialtesting/http.go @@ -81,6 +81,8 @@ type HTTPTask struct { sslCertNotBefore int64 sslCertNotAfter int64 protocol string + httpTimeout time.Duration + tlsConfig *tls.Config } func (t *HTTPTask) clear() { @@ -97,6 +99,7 @@ func (t *HTTPTask) clear() { t.reqBodyBytesBuffer = nil t.sslCertNotBefore = 0 t.sslCertNotAfter = 0 + t.destIP = "" if t.reqBody != nil { t.reqBody.bodyType = t.reqBody.BodyType @@ -104,6 +107,7 @@ func (t *HTTPTask) clear() { } func (t *HTTPTask) stop() { + t.closeHTTP3Transport() if t.cli != nil { t.cli.CloseIdleConnections() } @@ -381,6 +385,10 @@ func (t *HTTPTask) run() error { t.req.Header.Add("User-Agent", agentInfo) } + if t.protocol == ProtocolHTTP3 { + t.resetHTTP3Client() + } + t.reqStart = time.Now() t.resp, err = t.cli.Do(t.req) if t.protocol == ProtocolHTTP3 && t.resp != nil && t1.IsZero() { @@ -639,6 +647,8 @@ func (t *HTTPTask) init() error { protocol := strings.ToLower(opt.getProtocol()) t.protocol = protocol + t.httpTimeout = httpTimeout + t.tlsConfig = tlsConfig.Clone() switch protocol { case ProtocolHTTP3: @@ -782,6 +792,28 @@ func isPlainHTTP(rawURL string) bool { return err == nil && strings.EqualFold(u.Scheme, "http") } +func (t *HTTPTask) resetHTTP3Client() { + t.closeHTTP3Transport() + t.cli = &http.Client{ + Timeout: t.httpTimeout, + Transport: t.newHTTP3RoundTripper(t.tlsConfig, t.httpTimeout), + } + if t.AdvanceOptions != nil && t.AdvanceOptions.RequestOptions != nil && !t.AdvanceOptions.RequestOptions.FollowRedirect { + t.cli.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } +} + +func (t *HTTPTask) closeHTTP3Transport() { + if t.protocol != ProtocolHTTP3 || t.cli == nil || t.cli.Transport == nil { + return + } + if closer, ok := t.cli.Transport.(interface{ Close() error }); ok { + _ = closer.Close() + } +} + func (t *HTTPTask) newHTTP3RoundTripper(tlsConfig *tls.Config, httpTimeout time.Duration) http.RoundTripper { return &http3.RoundTripper{ TLSClientConfig: tlsConfig, diff --git a/dialtesting/http_test.go b/dialtesting/http_test.go index fd71c8e5..905208e1 100644 --- a/dialtesting/http_test.go +++ b/dialtesting/http_test.go @@ -1294,6 +1294,92 @@ func TestHTTPProtocolHTTP3RedirectDownloadTime(t *testing.T) { assert.Less(t, fields["response_download"].(float64), float64((10*time.Second)/time.Microsecond)) } +func TestHTTPProtocolHTTP3RepeatedRunKeepsTimingFields(t *testing.T) { + certPEM, keyPEM, err := generateSelfSignedCert() + assert.NoError(t, err) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + assert.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("HTTP/3 response")) + }) + + tlsConf := &tls.Config{ + Certificates: []tls.Certificate{cert}, + InsecureSkipVerify: true, + NextProtos: []string{"h3"}, + ServerName: "localhost", + } + + server := &http3.Server{ + Handler: mux, + TLSConfig: tlsConf, + } + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + assert.NoError(t, err) + + port := listener.LocalAddr().(*net.UDPAddr).Port + serverURL := fmt.Sprintf("https://localhost:%d", port) + + go func() { + _ = server.Serve(listener) + }() + + defer server.Close() + + time.Sleep(500 * time.Millisecond) + + task := &HTTPTask{ + Task: &Task{ + ExternalID: cliutils.XID("dtst_"), + Name: "_test_http3_repeated_run_timing", + Region: "hangzhou", + Frequency: "1s", + }, + Method: "GET", + URL: serverURL, + AdvanceOptions: &HTTPAdvanceOption{ + Protocol: "http/3", + Certificate: &HTTPOptCertificate{ + IgnoreServerCertificateError: true, + }, + RequestTimeout: "10s", + }, + SuccessWhen: []*HTTPSuccess{ + { + StatusCode: []*SuccessOption{ + {Is: "200"}, + }, + }, + }, + } + + task.SetChild(task) + err = task.Init() + assert.NoError(t, err) + + err = task.Run() + assert.NoError(t, err) + tags, fields := task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.NotEmpty(t, tags["dest_ip"]) + assert.Greater(t, fields["response_dns"].(float64), float64(0)) + assert.Greater(t, fields["response_connection"].(float64), float64(0)) + assert.GreaterOrEqual(t, fields["response_ssl"].(float64), float64(0)) + + err = task.Run() + assert.NoError(t, err) + tags, fields = task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.NotEmpty(t, tags["dest_ip"]) + assert.Greater(t, fields["response_dns"].(float64), float64(0)) + assert.Greater(t, fields["response_connection"].(float64), float64(0)) + assert.GreaterOrEqual(t, fields["response_ssl"].(float64), float64(0)) +} + func TestHTTPProtocolHTTP3HandshakeFailureReturnsQuickly(t *testing.T) { certPEM, keyPEM, err := generateSelfSignedCert() assert.NoError(t, err) From d801cbf7336217abad01db2dcea89d8337b5acf9 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 26 May 2026 14:53:40 +0800 Subject: [PATCH 6/7] save --- dialtesting/tcp.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dialtesting/tcp.go b/dialtesting/tcp.go index ce3d7f9f..17154575 100644 --- a/dialtesting/tcp.go +++ b/dialtesting/tcp.go @@ -269,9 +269,8 @@ func (t *TCPTask) run() error { defer cancel() hostIP := net.ParseIP(t.Host) - hostIsName := hostIP == nil - if hostIsName { // host name + if hostIsName := hostIP == nil; hostIsName { // host name start := time.Now() if ips, err := lookupIP(t.Host); err != nil { t.reqError = err.Error() From 0562fb7cbc0d9c6d62c0282d159ad307e057bc77 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 26 May 2026 15:40:53 +0800 Subject: [PATCH 7/7] rerun ci