Repository: cloudwego/hertz
Pull request #996 (2023-11-15 08:46:47)

chore: release v0.7.2


Patch:
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..8c2820f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,7 @@
+# Handle line endings automatically for files detected as text
+# and leave all files detected as binary untouched.
+* text=auto eol=lf
+
+# Force bash scripts to always use LF line endings so that if a repo is accessed
+# in Unix via a file share from Windows, the scripts will work.
+*.sh text eol=lf
diff --git a/.github/workflows/vulncheck.yml b/.github/workflows/vulncheck.yml
new file mode 100644
index 0000000..9b88498
--- /dev/null
+++ b/.github/workflows/vulncheck.yml
@@ -0,0 +1,35 @@
+name: Run govulncheck
+
+on:
+  push:
+    branches:
+      - develop
+    paths:
+      - "**"
+      - "!**.md"
+  pull_request:
+    paths:
+      - "**"
+      - "!**.md"
+
+jobs:
+  vulncheck-check:
+    runs-on: ubuntu-latest
+    env:
+      GO111MODULE: on
+    steps:
+      - name: Fetch Repository
+        uses: actions/checkout@v4
+
+      - name: Install Go
+        uses: actions/setup-go@v4
+        with:
+          go-version: "stable"
+          check-latest: true
+          cache: false
+
+      - name: Install Govulncheck
+        run: go install golang.org/x/vuln/cmd/govulncheck@latest
+
+      - name: Run Govulncheck
+        run: govulncheck ./...
diff --git a/cmd/hz/config/cmd.go b/cmd/hz/config/cmd.go
index 983946c..fc83c57 100644
--- a/cmd/hz/config/cmd.go
+++ b/cmd/hz/config/cmd.go
@@ -173,6 +173,7 @@ func BuildPluginCmd(args *Argument) (*exec.Cmd, error) {
 }
 
 func (arg *Argument) GetThriftgoOptions() (string, error) {
+	defaultOpt := "reserve_comments,gen_json_tag=false,"
 	prefix, err := arg.ModelPackagePrefix()
 	if err != nil {
 		return "", err
@@ -181,6 +182,6 @@ func (arg *Argument) GetThriftgoOptions() (string, error) {
 	if arg.JSONEnumStr {
 		arg.ThriftOptions = append(arg.ThriftOptions, "json_enum_as_text")
 	}
-	gas := "go:" + strings.Join(arg.ThriftOptions, ",") + ",reserve_comments,gen_json_tag=false"
+	gas := "go:" + defaultOpt + strings.Join(arg.ThriftOptions, ",")
 	return gas, nil
 }
diff --git a/cmd/hz/meta/const.go b/cmd/hz/meta/const.go
index 8a36ba2..3f4d916 100644
--- a/cmd/hz/meta/const.go
+++ b/cmd/hz/meta/const.go
@@ -19,7 +19,7 @@ package meta
 import "runtime"
 
 // Version hz version
-const Version = "v0.7.0"
+const Version = "v0.7.1"
 
 const DefaultServiceName = "hertz_service"
 
diff --git a/cmd/hz/thrift/ast.go b/cmd/hz/thrift/ast.go
index 8372235..5005a20 100644
--- a/cmd/hz/thrift/ast.go
+++ b/cmd/hz/thrift/ast.go
@@ -127,7 +127,7 @@ func astToService(ast *parser.Thrift, resolver *Resolver, args *config.Argument)
 			}
 
 			hmethod, path := httpAnnos[0].method, httpAnnos[0].path
-			if len(path) != 1 || path[0] == "" {
+			if len(path) == 0 || path[0] == "" {
 				return nil, fmt.Errorf("invalid api.%s  for %s.%s: %s", hmethod, s.Name, m.Name, path)
 			}
 
@@ -198,18 +198,16 @@ func astToService(ast *parser.Thrift, resolver *Resolver, args *config.Argument)
 			models.MergeMap(method.Models)
 			methods = append(methods, method)
 			for idx, anno := range httpAnnos {
-				if idx == 0 {
-					continue
-				}
-				tmp := *method
-				hmethod, path := anno.method, anno.path
-				if len(path) != 1 || path[0] == "" {
-					return nil, fmt.Errorf("invalid api.%s  for %s.%s: %s", hmethod, s.Name, m.Name, path)
+				for i := 0; i < len(anno.path); i++ {
+					if idx == 0 && i == 0 { // idx==0 && i==0 has been added above
+						continue
+					}
+					newMethod, err := newHTTPMethod(s, m, method, i, anno)
+					if err != nil {
+						return nil, err
+					}
+					methods = append(methods, newMethod)
 				}
-				tmp.HTTPMethod = hmethod
-				tmp.Path = path[0]
-				tmp.GenHandler = false
-				methods = append(methods, &tmp)
 			}
 			if args.CmdType == meta.CmdClient {
 				clientMethod := &generator.ClientMethod{}
@@ -234,6 +232,18 @@ func astToService(ast *parser.Thrift, resolver *Resolver, args *config.Argument)
 	return out, nil
 }
 
+func newHTTPMethod(s *parser.Service, m *parser.Function, method *generator.HttpMethod, i int, anno httpAnnotation) (*generator.HttpMethod, error) {
+	newMethod := *method
+	hmethod, path := anno.method, anno.path
+	if path[i] == "" {
+		return nil, fmt.Errorf("invalid api.%s for %s.%s: %s", hmethod, s.Name, m.Name, path[i])
+	}
+	newMethod.HTTPMethod = hmethod
+	newMethod.Path = path[i]
+	newMethod.GenHandler = false
+	return &newMethod, nil
+}
+
 func parseAnnotationToClient(clientMethod *generator.ClientMethod, p *parser.Type, symbol ResolvedSymbol) error {
 	if p == nil {
 		return fmt.Errorf("get type failed for parse annotatoon to client")
diff --git a/pkg/app/context.go b/pkg/app/context.go
index 46aeee7..4e2d4be 100644
--- a/pkg/app/context.go
+++ b/pkg/app/context.go
@@ -809,10 +809,20 @@ func (ctx *RequestContext) Reset() {
 	ctx.conn = nil
 }
 
+// Redirect returns an HTTP redirect to the specific location.
+// Note that this will not stop the current handler.
+// In other words, even if Redirect() is called, the remaining handlers will still be executed and cause unexpected result.
+// So it should call Abort to ensure the remaining handlers of this request will not be called.
+//
+//	ctx.Abort()
+//	return
 func (ctx *RequestContext) Redirect(statusCode int, uri []byte) {
 	ctx.redirect(uri, statusCode)
 }
 
+// Header is an intelligent shortcut for ctx.Response.Header.Set(key, value).
+// It writes a header in the response.
+// If value == "", this method removes the header `ctx.Response.Header.Del(key)`.
 func (ctx *RequestContext) Header(key, value string) {
 	if value == "" {
 		ctx.Response.Header.Del(key)
diff --git a/pkg/app/server/option.go b/pkg/app/server/option.go
index fcf3804..e797034 100644
--- a/pkg/app/server/option.go
+++ b/pkg/app/server/option.go
@@ -382,3 +382,15 @@ func WithDisableHeaderNamesNormalizing(disable bool) config.Option {
 		o.DisableHeaderNamesNormalizing = disable
 	}}
 }
+
+func WithDisableDefaultDate(disable bool) config.Option {
+	return config.Option{F: func(o *config.Options) {
+		o.NoDefaultDate = disable
+	}}
+}
+
+func WithDisableDefaultContentType(disable bool) config.Option {
+	return config.Option{F: func(o *config.Options) {
+		o.NoDefaultContentType = disable
+	}}
+}
diff --git a/pkg/common/config/option.go b/pkg/common/config/option.go
index 048fb36..417955f 100644
--- a/pkg/common/config/option.go
+++ b/pkg/common/config/option.go
@@ -58,6 +58,8 @@ type Options struct {
 	RemoveExtraSlash             bool
 	UnescapePathValues           bool
 	DisablePreParseMultipartForm bool
+	NoDefaultDate                bool
+	NoDefaultContentType         bool
 	StreamRequestBody            bool
 	NoDefaultServerHeader        bool
 	DisablePrintRoute            bool
@@ -191,6 +193,12 @@ func NewOptions(opts []Option) *Options {
 		// like they are normal requests
 		DisablePreParseMultipartForm: false,
 
+		// When set to true, causes the default Content-Type header to be excluded from the response.
+		NoDefaultContentType: false,
+
+		// When set to true, causes the default date header to be excluded from the response.
+		NoDefaultDate: false,
+
 		// Routes info printing is not disabled by default
 		// Disabled when set to True
 		DisablePrintRoute: false,
diff --git a/pkg/protocol/args.go b/pkg/protocol/args.go
index f29aefe..fa2a670 100644
--- a/pkg/protocol/args.go
+++ b/pkg/protocol/args.go
@@ -379,6 +379,17 @@ func (a *Args) PeekExists(key string) (string, bool) {
 	return peekArgStrExists(a.args, key)
 }
 
+// PeekAll returns all the arg values for the given key.
+func (a *Args) PeekAll(key string) [][]byte {
+	var values [][]byte
+	a.VisitAll(func(k, v []byte) {
+		if bytesconv.B2s(k) == key {
+			values = append(values, v)
+		}
+	})
+	return values
+}
+
 func visitArgs(args []argsKV, f func(k, v []byte)) {
 	for i, n := 0, len(args); i < n; i++ {
 		kv := &args[i]
diff --git a/pkg/protocol/header.go b/pkg/protocol/header.go
index 14df744..7edf19b 100644
--- a/pkg/protocol/header.go
+++ b/pkg/protocol/header.go
@@ -950,6 +950,11 @@ func (h *ResponseHeader) SetNoDefaultContentType(b bool) {
 	h.noDefaultContentType = b
 }
 
+// SetNoDefaultDate set noDefaultDate value of ResponseHeader.
+func (h *ResponseHeader) SetNoDefaultDate(b bool) {
+	h.noDefaultDate = b
+}
+
 // SetServerBytes sets Server header value.
 func (h *ResponseHeader) SetServerBytes(server []byte) {
 	h.server = append(h.server[:0], server...)
diff --git a/pkg/protocol/http1/server.go b/pkg/protocol/http1/server.go
index 66cb01e..c17aa0f 100644
--- a/pkg/protocol/http1/server.go
+++ b/pkg/protocol/http1/server.go
@@ -56,6 +56,8 @@ var (
 type Option struct {
 	StreamRequestBody             bool
 	GetOnly                       bool
+	NoDefaultDate                 bool
+	NoDefaultContentType          bool
 	DisablePreParseMultipartForm  bool
 	DisableKeepalive              bool
 	NoDefaultServerHeader         bool
@@ -181,6 +183,9 @@ func (s Server) Serve(c context.Context, conn network.Conn) (err error) {
 			})
 		}
 
+		ctx.Response.Header.SetNoDefaultDate(s.NoDefaultDate)
+		ctx.Response.Header.SetNoDefaultContentType(s.NoDefaultContentType)
+
 		if s.DisableHeaderNamesNormalizing {
 			ctx.Request.Header.DisableNormalizing()
 			ctx.Response.Header.DisableNormalizing()
@@ -369,11 +374,6 @@ func (s Server) Serve(c context.Context, conn network.Conn) (err error) {
 			return
 		}
 
-		// general case
-		if s.EnableTrace {
-			traceCtl.DoFinish(cc, ctx, err)
-		}
-
 		if connectionClose {
 			return errShortConnection
 		}
@@ -382,6 +382,10 @@ func (s Server) Serve(c context.Context, conn network.Conn) (err error) {
 		if s.IdleTimeout == 0 {
 			return
 		}
+		// general case
+		if s.EnableTrace {
+			traceCtl.DoFinish(cc, ctx, err)
+		}
 
 		ctx.ResetWithoutConn()
 	}
diff --git a/pkg/protocol/uri.go b/pkg/protocol/uri.go
index 6bba7a9..4fd8788 100644
--- a/pkg/protocol/uri.go
+++ b/pkg/protocol/uri.go
@@ -49,6 +49,7 @@ import (
 	"github.com/cloudwego/hertz/internal/bytesconv"
 	"github.com/cloudwego/hertz/internal/bytestr"
 	"github.com/cloudwego/hertz/internal/nocopy"
+	"github.com/cloudwego/hertz/pkg/common/hlog"
 )
 
 // AcquireURI returns an empty URI instance from the pool.
@@ -373,6 +374,34 @@ func (u *URI) Parse(host, uri []byte) {
 	u.parse(host, uri, false)
 }
 
+// Maybe rawURL is of the form scheme:path.
+// (Scheme must be [a-zA-Z][a-zA-Z0-9+-.]*)
+// If so, return scheme, path; else return nil, rawURL.
+func getScheme(rawURL []byte) (scheme, path []byte) {
+	for i := 0; i < len(rawURL); i++ {
+		c := rawURL[i]
+		switch {
+		case 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z':
+		// do nothing
+		case '0' <= c && c <= '9' || c == '+' || c == '-' || c == '.':
+			if i == 0 {
+				return nil, rawURL
+			}
+		case c == ':':
+			if i == 0 {
+				hlog.Errorf("error happened when try to parse the rawURL(%s): missing protocol scheme", rawURL)
+				return nil, nil
+			}
+			return rawURL[:i], rawURL[i+1:]
+		default:
+			// we have encountered an invalid character,
+			// so there is no valid scheme
+			return nil, rawURL
+		}
+	}
+	return nil, rawURL
+}
+
 func (u *URI) parse(host, uri []byte, isTLS bool) {
 	u.Reset()
 
@@ -455,20 +484,14 @@ func stringContainsCTLByte(s []byte) bool {
 }
 
 func splitHostURI(host, uri []byte) ([]byte, []byte, []byte) {
-	n := bytes.Index(uri, bytestr.StrSlashSlash)
-	if n < 0 {
-		return bytestr.StrHTTP, host, uri
-	}
-	scheme := uri[:n]
-	if bytes.IndexByte(scheme, '/') >= 0 {
+	scheme, path := getScheme(uri)
+
+	if scheme == nil {
 		return bytestr.StrHTTP, host, uri
 	}
-	if len(scheme) > 0 && scheme[len(scheme)-1] == ':' {
-		scheme = scheme[:len(scheme)-1]
-	}
-	n += len(bytestr.StrSlashSlash)
-	uri = uri[n:]
-	n = bytes.IndexByte(uri, '/')
+
+	uri = path[len(bytestr.StrSlashSlash):]
+	n := bytes.IndexByte(uri, '/')
 	if n < 0 {
 		// A hack for bogus urls like foobar.com?a=b without
 		// slash after host.
@@ -587,6 +610,9 @@ func (u *URI) updateBytes(newURI, buf []byte) []byte {
 		if len(u.scheme) > 0 {
 			schemeOriginal = append([]byte(nil), u.scheme...)
 		}
+		if n == 0 {
+			newURI = bytes.Join([][]byte{u.scheme, bytestr.StrColon, newURI}, nil)
+		}
 		u.Parse(nil, newURI)
 		if len(schemeOriginal) > 0 && len(u.scheme) == 0 {
 			u.scheme = append(u.scheme[:0], schemeOriginal...)
diff --git a/pkg/route/engine.go b/pkg/route/engine.go
index 168c79b..8b65e6e 100644
--- a/pkg/route/engine.go
+++ b/pkg/route/engine.go
@@ -89,6 +89,8 @@ var (
 	default404Body = []byte("404 page not found")
 	default405Body = []byte("405 method not allowed")
 	default400Body = []byte("400 bad request")
+
+	requiredHostBody = []byte("missing required Host header")
 )
 
 type hijackConn struct {
@@ -721,6 +723,13 @@ func (engine *Engine) ServeHTTP(c context.Context, ctx *app.RequestContext) {
 	}
 
 	rPath := string(ctx.Request.URI().Path())
+
+	// align with https://datatracker.ietf.org/doc/html/rfc2616#section-5.2
+	if len(ctx.Request.Host()) == 0 && ctx.Request.Header.IsHTTP11() && bytesconv.B2s(ctx.Request.Method()) != consts.MethodConnect {
+		serveError(c, ctx, consts.StatusBadRequest, requiredHostBody)
+		return
+	}
+
 	httpMethod := bytesconv.B2s(ctx.Request.Header.Method())
 	unescape := false
 	if engine.options.UseRawPath {
@@ -1056,6 +1065,8 @@ func newHttp1OptionFromEngine(engine *Engine) *http1.Option {
 		EnableTrace:                   engine.IsTraceEnable(),
 		HijackConnHandle:              engine.HijackConnHandle,
 		DisableHeaderNamesNormalizing: engine.options.DisableHeaderNamesNormalizing,
+		NoDefaultDate:                 engine.options.NoDefaultDate,
+		NoDefaultContentType:          engine.options.NoDefaultContentType,
 	}
 	// Idle timeout of standard network must not be zero. Set it to -1 seconds if it is zero.
 	// Due to the different triggering ways of the network library, see the actual use of this value for the detailed reasons.
diff --git a/pkg/route/tree.go b/pkg/route/tree.go
index 64f3aff..8b63c14 100644
--- a/pkg/route/tree.go
+++ b/pkg/route/tree.go
@@ -303,9 +303,7 @@ func (r *router) insert(path string, h app.HandlersChain, t kind, ppath string,
 			if h != nil {
 				currentNode.handlers = h
 				currentNode.ppath = ppath
-				if len(currentNode.pnames) == 0 {
-					currentNode.pnames = pnames
-				}
+				currentNode.pnames = pnames
 			}
 		}
 		return
diff --git a/version.go b/version.go
index 4e128b1..db06047 100644
--- a/version.go
+++ b/version.go
@@ -19,5 +19,5 @@ package hertz
 // Name and Version info of this framework, used for statistics and debug
 const (
 	Name    = "Hertz"
-	Version = "v0.7.1"
+	Version = "v0.7.2"
 )


Test patch:
diff --git a/pkg/app/server/hertz_test.go b/pkg/app/server/hertz_test.go
index 5baa3ec..4a9cee3 100644
--- a/pkg/app/server/hertz_test.go
+++ b/pkg/app/server/hertz_test.go
@@ -259,7 +259,7 @@ func TestNotAbsolutePath(t *testing.T) {
 	go engine.Run()
 	time.Sleep(200 * time.Microsecond)
 
-	s := "POST ?a=b HTTP/1.1\r\nContent-Length: 5\r\nContent-Type: foo/bar\r\n\r\nabcdef4343"
+	s := "POST ?a=b HTTP/1.1\r\nHost: a.b.c\r\nContent-Length: 5\r\nContent-Type: foo/bar\r\n\r\nabcdef4343"
 	zr := mock.NewZeroCopyReader(s)
 
 	ctx := app.NewContext(0)
@@ -270,7 +270,7 @@ func TestNotAbsolutePath(t *testing.T) {
 	assert.DeepEqual(t, consts.StatusOK, ctx.Response.StatusCode())
 	assert.DeepEqual(t, ctx.Request.Body(), ctx.Response.Body())
 
-	s = "POST a?a=b HTTP/1.1\r\nContent-Length: 5\r\nContent-Type: foo/bar\r\n\r\nabcdef4343"
+	s = "POST a?a=b HTTP/1.1\r\nHost: a.b.c\r\nContent-Length: 5\r\nContent-Type: foo/bar\r\n\r\nabcdef4343"
 	zr = mock.NewZeroCopyReader(s)
 
 	ctx = app.NewContext(0)
@@ -291,7 +291,7 @@ func TestNotAbsolutePathWithRawPath(t *testing.T) {
 	go engine.Run()
 	time.Sleep(200 * time.Microsecond)
 
-	s := "POST ?a=b HTTP/1.1\r\nContent-Length: 5\r\nContent-Type: foo/bar\r\n\r\nabcdef4343"
+	s := "POST ?a=b HTTP/1.1\r\nHost: a.b.c\r\nContent-Length: 5\r\nContent-Type: foo/bar\r\n\r\nabcdef4343"
 	zr := mock.NewZeroCopyReader(s)
 
 	ctx := app.NewContext(0)
@@ -302,7 +302,7 @@ func TestNotAbsolutePathWithRawPath(t *testing.T) {
 	assert.DeepEqual(t, consts.StatusBadRequest, ctx.Response.StatusCode())
 	assert.DeepEqual(t, default400Body, ctx.Response.Body())
 
-	s = "POST a?a=b HTTP/1.1\r\nContent-Length: 5\r\nContent-Type: foo/bar\r\n\r\nabcdef4343"
+	s = "POST a?a=b HTTP/1.1\r\nHost: a.b.c\r\nContent-Length: 5\r\nContent-Type: foo/bar\r\n\r\nabcdef4343"
 	zr = mock.NewZeroCopyReader(s)
 
 	ctx = app.NewContext(0)
@@ -1066,3 +1066,29 @@ func TestValidateConfigAndBindConfig(t *testing.T) {
 	assert.Nil(t, err)
 	time.Sleep(100 * time.Millisecond)
 }
+
+func TestWithDisableDefaultDate(t *testing.T) {
+	h := New(
+		WithHostPorts("localhost:8321"),
+		WithDisableDefaultDate(true),
+	)
+	h.GET("/", func(_ context.Context, c *app.RequestContext) {})
+	go h.Spin()
+	time.Sleep(100 * time.Millisecond)
+	hc := http.Client{Timeout: time.Second}
+	r, _ := hc.Get("http://127.0.0.1:8321") //nolint:errcheck
+	assert.DeepEqual(t, "", r.Header.Get("Date"))
+}
+
+func TestWithDisableDefaultContentType(t *testing.T) {
+	h := New(
+		WithHostPorts("localhost:8324"),
+		WithDisableDefaultContentType(true),
+	)
+	h.GET("/", func(_ context.Context, c *app.RequestContext) {})
+	go h.Spin()
+	time.Sleep(100 * time.Millisecond)
+	hc := http.Client{Timeout: time.Second}
+	r, _ := hc.Get("http://127.0.0.1:8324") //nolint:errcheck
+	assert.DeepEqual(t, "", r.Header.Get("Content-Type"))
+}
diff --git a/pkg/protocol/args_test.go b/pkg/protocol/args_test.go
index 6a613dc..0e89f30 100644
--- a/pkg/protocol/args_test.go
+++ b/pkg/protocol/args_test.go
@@ -129,3 +129,26 @@ func TestArgsVisitAll(t *testing.T) {
 	})
 	assert.DeepEqual(t, []string{"cloudwego", "hertz", "hello", "world"}, s)
 }
+
+func TestArgsPeekMulti(t *testing.T) {
+	var a Args
+	a.Add("cloudwego", "hertz")
+	a.Add("cloudwego", "kitex")
+	a.Add("cloudwego", "")
+	a.Add("hello", "world")
+
+	vv := a.PeekAll("cloudwego")
+	expectedVV := [][]byte{
+		[]byte("hertz"),
+		[]byte("kitex"),
+		[]byte(nil),
+	}
+	assert.DeepEqual(t, expectedVV, vv)
+
+	vv = a.PeekAll("aaaa")
+	assert.DeepEqual(t, 0, len(vv))
+
+	vv = a.PeekAll("hello")
+	expectedVV = [][]byte{[]byte("world")}
+	assert.DeepEqual(t, expectedVV, vv)
+}
diff --git a/pkg/protocol/header_test.go b/pkg/protocol/header_test.go
index 8d768fd..35cc001 100644
--- a/pkg/protocol/header_test.go
+++ b/pkg/protocol/header_test.go
@@ -720,3 +720,15 @@ func TestResponseHeaderCopyTo(t *testing.T) {
 	assert.DeepEqual(t, hCopy.noDefaultContentType, true)
 	assert.DeepEqual(t, hCopy.GetHeaderLength(), 100)
 }
+
+func TestResponseHeaderDateEmpty(t *testing.T) {
+	t.Parallel()
+
+	var h ResponseHeader
+	h.noDefaultDate = true
+	headers := string(h.Header())
+
+	if strings.Contains(headers, "\r\nDate: ") {
+		t.Fatalf("ResponseDateNoDefaultNotEmpty fail, response: \n%+v\noutcome: \n%q\n", h, headers) //nolint:govet
+	}
+}
diff --git a/pkg/protocol/http1/server_test.go b/pkg/protocol/http1/server_test.go
index e34bb3f..5f8f782 100644
--- a/pkg/protocol/http1/server_test.go
+++ b/pkg/protocol/http1/server_test.go
@@ -359,6 +359,52 @@ func TestExpect100ContinueHandler(t *testing.T) {
 	assert.DeepEqual(t, "", string(response.Body()))
 }
 
+type mockController struct {
+	FinishTimes int
+}
+
+func (m *mockController) Append(col tracer.Tracer) {}
+
+func (m *mockController) DoStart(ctx context.Context, c *app.RequestContext) context.Context {
+	return ctx
+}
+
+func (m *mockController) DoFinish(ctx context.Context, c *app.RequestContext, err error) {
+	m.FinishTimes++
+}
+
+func (m *mockController) HasTracer() bool { return true }
+
+func (m *mockController) reset() { m.FinishTimes = 0 }
+
+func TestTraceDoFinishTimes(t *testing.T) {
+	server := &Server{}
+	server.eventStackPool = pool
+	server.EnableTrace = true
+	reqCtx := &app.RequestContext{}
+	controller := &mockController{}
+	server.Core = &mockCore{
+		ctxPool: &sync.Pool{New: func() interface{} {
+			ti := traceinfo.NewTraceInfo()
+			ti.Stats().SetLevel(2)
+			reqCtx.SetTraceInfo(&mockTraceInfo{ti})
+			return reqCtx
+		}},
+		controller: controller,
+	}
+	// for disableKeepAlive case
+	server.DisableKeepalive = true
+	err := server.Serve(context.TODO(), mock.NewConn("GET /aaa HTTP/1.1\nHost: foobar.com\n\n"))
+	assert.True(t, errors.Is(err, errs.ErrShortConnection))
+	assert.DeepEqual(t, 1, controller.FinishTimes)
+	// for IdleTimeout==0 case
+	server.IdleTimeout = 0
+	controller.reset()
+	err = server.Serve(context.TODO(), mock.NewConn("GET /aaa HTTP/1.1\nHost: foobar.com\n\n"))
+	assert.True(t, errors.Is(err, errs.ErrShortConnection))
+	assert.DeepEqual(t, 1, controller.FinishTimes)
+}
+
 type mockCore struct {
 	ctxPool     *sync.Pool
 	controller  tracer.Controller
diff --git a/pkg/protocol/uri_test.go b/pkg/protocol/uri_test.go
index e245faf..3578dda 100644
--- a/pkg/protocol/uri_test.go
+++ b/pkg/protocol/uri_test.go
@@ -42,6 +42,7 @@
 package protocol
 
 import (
+	"bytes"
 	"path/filepath"
 	"reflect"
 	"runtime"
@@ -468,3 +469,35 @@ func TestParseURI(t *testing.T) {
 	uri := string(ParseURI(expectURI).FullURI())
 	assert.DeepEqual(t, expectURI, uri)
 }
+
+func TestSplitHostURI(t *testing.T) {
+	cases := []struct {
+		host, uri                      []byte
+		wantScheme, wantHost, wantPath []byte
+	}{
+		{
+			[]byte("example.com"), []byte("/foobar"),
+			[]byte("http"), []byte("example.com"), []byte("/foobar"),
+		},
+		{
+			[]byte("example2.com"), []byte("http://example2.com"),
+			[]byte("http"), []byte("example2.com"), []byte("/"),
+		},
+		{
+			[]byte("example2.com"), []byte("http://example3.com"),
+			[]byte("http"), []byte("example3.com"), []byte("/"),
+		},
+		{
+			[]byte("example3.com"), []byte("https://foobar.com?a=b"),
+			[]byte("https"), []byte("foobar.com"), []byte("?a=b"),
+		},
+	}
+
+	for _, c := range cases {
+		gotScheme, gotHost, gotPath := splitHostURI(c.host, c.uri)
+		if !bytes.Equal(gotScheme, c.wantScheme) || !bytes.Equal(gotHost, c.wantHost) || !bytes.Equal(gotPath, c.wantPath) {
+			t.Errorf("splitHostURI(%q, %q) == (%q, %q, %q), want (%q, %q, %q)",
+				c.host, c.uri, gotScheme, gotHost, gotPath, c.wantScheme, c.wantHost, c.wantPath)
+		}
+	}
+}
diff --git a/pkg/route/tree_test.go b/pkg/route/tree_test.go
index 946bfd6..296edfa 100644
--- a/pkg/route/tree_test.go
+++ b/pkg/route/tree_test.go
@@ -706,3 +706,32 @@ func TestTreeFindCaseInsensitivePath(t *testing.T) {
 		}
 	}
 }
+
+func TestTreeParamNotOptimize(t *testing.T) {
+	tree := &router{method: "GET", root: &node{}, hasTsrHandler: make(map[string]bool)}
+	routes := [...]string{
+		"/:parama/start",
+		"/:paramb",
+	}
+	for _, route := range routes {
+		tree.addRoute(route, fakeHandler(route))
+	}
+	checkRequests(t, tree, testRequests{
+		{"/1", false, "/:paramb", param.Params{param.Param{Key: "paramb", Value: "1"}}},
+		{"/1/start", false, "/:parama/start", param.Params{param.Param{Key: "parama", Value: "1"}}},
+	})
+
+	// other sequence
+	tree = &router{method: "GET", root: &node{}, hasTsrHandler: make(map[string]bool)}
+	routes = [...]string{
+		"/:paramb",
+		"/:parama/start",
+	}
+	for _, route := range routes {
+		tree.addRoute(route, fakeHandler(route))
+	}
+	checkRequests(t, tree, testRequests{
+		{"/1/start", false, "/:parama/start", param.Params{param.Param{Key: "parama", Value: "1"}}},
+		{"/1", false, "/:paramb", param.Params{param.Param{Key: "paramb", Value: "1"}}},
+	})
+}