You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@dubbo.apache.org by al...@apache.org on 2026/05/21 06:06:28 UTC

(dubbo-go) 07/14: feat: support mounting existing HTTP handlers on the Triple listener (#3301)

This is an automated email from the ASF dual-hosted git repository.

AlexStocks pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go.git

commit 67ec99f307cfd65625aaa9deb9285dc2051461e9
Author: Zecheng Zhu <16...@qq.com>
AuthorDate: Sat May 9 13:54:31 2026 +0800

    feat: support mounting existing HTTP handlers on the Triple listener (#3301)
    
    * set http service fallback handler into mux
    
    * feat: add HTTP handler mounting for triple servers
    
    * set mounted http handler int server and refactor server start
    
    * mount first and http only server
    
    * fix: validate transport settings when reusing listeners
    
    * fix: determine whether the HTTP handler has already been mounted
    
    * refactor: clarify attach/host/fallback naming
    
    * fix: keep health status and attached HTTP startup consistent
---
 protocol/base/http_host.go                         |  33 ++
 protocol/triple/server.go                          | 341 +++++++++++++++------
 protocol/triple/server_test.go                     |  82 +++++
 protocol/triple/triple.go                          |  82 +++--
 .../triple/triple_protocol/method_route_mux.go     |  19 +-
 .../triple_protocol/method_route_mux_test.go       |  52 ++++
 protocol/triple/triple_protocol/server.go          |   7 +
 protocol/triple/triple_protocol/server_test.go     |  16 +
 protocol/triple/triple_test.go                     |  97 ++++++
 server/http_attach.go                              | 115 +++++++
 server/http_attach_test.go                         | 162 ++++++++++
 server/server.go                                   |   6 +
 server/triple_case_route_integration_test.go       | 164 ++++++++++
 13 files changed, 1069 insertions(+), 107 deletions(-)

diff --git a/protocol/base/http_host.go b/protocol/base/http_host.go
new file mode 100644
index 000000000..da35b7795
--- /dev/null
+++ b/protocol/base/http_host.go
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package base
+
+import (
+	"net/http"
+)
+
+import (
+	"dubbo.apache.org/dubbo-go/v3/common"
+)
+
+// HTTPHandlerHost is an optional protocol capability for protocols backed by
+// net/http that can host an existing HTTP handler on the same listener as
+// framework-managed routes.
+type HTTPHandlerHost interface {
+	HostHTTPHandler(url *common.URL, handler http.Handler) error
+}
diff --git a/protocol/triple/server.go b/protocol/triple/server.go
index 90e72bd6d..b3faa1326 100644
--- a/protocol/triple/server.go
+++ b/protocol/triple/server.go
@@ -54,10 +54,21 @@ import (
 // Server is TRIPLE adaptation layer representation. It makes use of tri.Server to
 // provide functionality.
 type Server struct {
-	triServer *tri.Server
-	cfg       *global.TripleConfig
-	mu        sync.RWMutex
-	services  map[string]grpc.ServiceInfo
+	triServer           *tri.Server
+	cfg                 *global.TripleConfig
+	mu                  sync.RWMutex
+	services            map[string]grpc.ServiceInfo
+	transportStarted    bool
+	attachedHTTPHandler http.Handler
+	transportSettings   *transportSettings
+}
+
+type transportSettings struct {
+	location     string
+	callProtocol string
+	tripleConfig *global.TripleConfig
+	tlsConfig    *tls.Config
+	rawTLSConfig *global.TLSConfig
 }
 
 // NewServer creates a new TRIPLE server.
@@ -68,64 +79,173 @@ func NewServer(cfg *global.TripleConfig) *Server {
 	}
 }
 
-// Start TRIPLE server
-func (s *Server) Start(invoker base.Invoker, info *common.ServiceInfo) {
+// Start prepares the Triple transport for one exported service. It resolves the
+// listener configuration, ensures the underlying tri.Server exists, registers
+// the service handlers, and finally starts the shared transport.
+func (s *Server) Start(invoker base.Invoker, info *common.ServiceInfo) error {
 	url := invoker.GetURL()
-	addr := url.Location
 
-	var tripleConf *global.TripleConfig
+	settings, err := resolveServerTransport(url)
+	if err != nil {
+		return fmt.Errorf("TRIPLE server init failed: %w", err)
+	}
 
-	tripleConfRaw, ok := url.GetAttribute(constant.TripleConfigKey)
-	if ok {
-		tripleConf = tripleConfRaw.(*global.TripleConfig)
+	if err := s.ensureTriServer(settings); err != nil {
+		return err
+	}
+	if err := s.refreshService(invoker, info); err != nil {
+		return err
 	}
+	s.startTransport(settings.callProtocol, settings.tlsConfig)
+	return nil
+}
 
-	var callProtocol string
-	if tripleConf != nil && tripleConf.Http3 != nil && tripleConf.Http3.Enable {
-		callProtocol = constant.CallHTTP2AndHTTP3
-	} else {
-		// HTTP default type is HTTP/2.
-		callProtocol = constant.CallHTTP2
+// resolveServerTransport extracts transport-level Triple configuration from the
+// export URL and converts it into the concrete listener settings used by the
+// adaptation layer.
+func resolveServerTransport(url *common.URL) (*transportSettings, error) {
+	if url == nil {
+		return nil, fmt.Errorf("triple server url must not be nil")
 	}
 
-	// initialize tri.Server
-	s.triServer = tri.NewServer(addr, tripleConf)
+	tripleConf, err := resolveTripleConfig(url)
+	if err != nil {
+		return nil, err
+	}
 
-	serialization := url.GetParam(constant.SerializationKey, constant.ProtobufSerialization)
-	switch serialization {
-	case constant.ProtobufSerialization:
-	case constant.JSONSerialization:
-	case constant.Hessian2Serialization:
-	case constant.MsgpackSerialization:
-	default:
-		panic(fmt.Sprintf("Unsupported serialization: %s", serialization))
+	callProtocol := constant.CallHTTP2
+	if tripleConf != nil && tripleConf.Http3 != nil && tripleConf.Http3.Enable {
+		callProtocol = constant.CallHTTP2AndHTTP3
 	}
+
 	// todo: support opentracing interceptor
 
 	// TODO: move tls config to handleService
 
-	var globalTlsConf *global.TLSConfig
-	var tlsConf *tls.Config
-	var err error
-
-	// handle tls
-	tlsConfRaw, ok := url.GetAttribute(constant.TLSConfigKey)
-	if ok {
-		globalTlsConf, ok = tlsConfRaw.(*global.TLSConfig)
-		if !ok {
-			logger.Errorf("TRIPLE Server initialized the TLSConfig configuration failed")
-			return
-		}
+	rawTLSConfig, err := resolveRawTLSConfig(url)
+	if err != nil {
+		return nil, err
 	}
-	if dubbotls.IsServerTLSValid(globalTlsConf) {
-		tlsConf, err = dubbotls.GetServerTlSConfig(globalTlsConf)
+
+	var tlsConf *tls.Config
+	if dubbotls.IsServerTLSValid(rawTLSConfig) {
+		tlsConf, err = dubbotls.GetServerTlSConfig(rawTLSConfig)
 		if err != nil {
-			logger.Errorf("TRIPLE Server initialized the TLSConfig configuration failed. err: %v", err)
-			return
+			return nil, fmt.Errorf("TRIPLE server initialized the TLSConfig configuration failed: %w", err)
 		}
 		logger.Infof("TRIPLE Server initialized the TLSConfig configuration")
 	}
 
+	return &transportSettings{
+		location:     url.Location,
+		callProtocol: callProtocol,
+		tripleConfig: tripleConf,
+		tlsConfig:    tlsConf,
+		rawTLSConfig: rawTLSConfig,
+	}, nil
+}
+
+// ensureTriServer lazily creates the shared tri.Server for one listener
+// address. Service export and mount-first startup both converge here so they
+// share the same transport lifecycle and reflection registration.
+func (s *Server) ensureTriServer(settings *transportSettings) error {
+	if err := s.validateTransportSettings(settings); err != nil {
+		return err
+	}
+
+	if s.triServer != nil {
+		return nil
+	}
+
+	s.mu.Lock()
+	s.transportSettings = settings
+	s.cfg = settings.tripleConfig
+	s.mu.Unlock()
+
+	s.triServer = tri.NewServer(settings.location, settings.tripleConfig)
+	s.wireAttachedHTTPFallback()
+	internal.ReflectionRegister(s)
+	return nil
+}
+
+// wireAttachedHTTPFallback keeps the tri.Server route mux as the main listener
+// handler and connects an attached external HTTP handler through the
+// transport-level fallback slot so Triple routes keep higher priority.
+func (s *Server) wireAttachedHTTPFallback() {
+	s.mu.RLock()
+	handler := s.attachedHTTPHandler
+	s.mu.RUnlock()
+	if handler != nil && s.triServer != nil {
+		s.triServer.SetFallbackHTTPHandler(handler)
+	}
+}
+
+// AttachHTTPHandler attaches a single root HTTP handler to the Triple server.
+// The attached handler follows the same single-root semantics as server.Server:
+// once a handler has been attached for one listener, later attaches must fail
+// fast instead of silently replacing the existing handler.
+func (s *Server) AttachHTTPHandler(handler http.Handler) error {
+	if handler == nil {
+		return fmt.Errorf("attached HTTP handler must not be nil")
+	}
+
+	s.mu.Lock()
+	if s.attachedHTTPHandler != nil {
+		s.mu.Unlock()
+		return fmt.Errorf("an HTTP handler has already been attached")
+	}
+	s.attachedHTTPHandler = handler
+	triServer := s.triServer
+	s.mu.Unlock()
+
+	if triServer != nil {
+		triServer.SetFallbackHTTPHandler(handler)
+	}
+	return nil
+}
+
+func (s *Server) ValidateTransportURL(url *common.URL) error {
+	settings, err := resolveServerTransport(url)
+	if err != nil {
+		return err
+	}
+	return s.validateTransportSettings(settings)
+}
+
+// StartHTTPTransport starts the shared Triple listener without requiring any
+// exported service handlers first. This is used by mount-first and HTTP-only
+// flows to bring up the listener before service export happens later.
+func (s *Server) StartHTTPTransport(url *common.URL) error {
+	settings, err := resolveServerTransport(url)
+	if err != nil {
+		return err
+	}
+
+	// Reuse the same transport bootstrap path as normal service export so mount-
+	// first and export-first flows converge on one listener lifecycle.
+	if err := s.ensureTriServer(settings); err != nil {
+		return err
+	}
+	s.startTransport(settings.callProtocol, settings.tlsConfig)
+	return nil
+}
+
+func (s *Server) startTransport(callProtocol string, tlsConf *tls.Config) {
+	if s.transportStarted {
+		return
+	}
+	s.transportStarted = true
+
+	go func() {
+		if runErr := s.triServer.Run(callProtocol, tlsConf); runErr != nil {
+			logger.Errorf("server serve failed with err: %v", runErr)
+		}
+	}()
+}
+
+func (s *Server) registerServiceHandlers(invoker base.Invoker, info *common.ServiceInfo, hanOpts []tri.HandlerOption) {
+	url := invoker.GetURL()
+
 	// IDLMode means that this will only be set when
 	// the new triple is started in non-IDL mode.
 	// TODO: remove IDLMode when config package is removed
@@ -136,13 +256,7 @@ func (s *Server) Start(invoker base.Invoker, info *common.ServiceInfo) {
 		service, _ = url.GetAttribute(constant.RpcServiceKey)
 	}
 
-	hanOpts := getHanOpts(url, tripleConf)
-
-	//Set expected codec name from serviceinfo
-	hanOpts = append(hanOpts, tri.WithExpectedCodecName(serialization))
-
 	intfName := url.Interface()
-
 	//OpenAPI group
 	var openapiGroup string
 	if g, ok := url.GetAttribute(constant.OpenAPIMetaKeyOpenAPIGroup); ok {
@@ -163,56 +277,111 @@ func (s *Server) Start(invoker base.Invoker, info *common.ServiceInfo) {
 	} else {
 		s.compatHandleService(url, intfName, url.Group(), url.Version(), hanOpts...)
 	}
-	internal.ReflectionRegister(s)
-
-	go func() {
-		if runErr := s.triServer.Run(callProtocol, tlsConf); runErr != nil {
-			logger.Errorf("server serve failed with err: %v", runErr)
-		}
-	}()
 }
 
-// todo(DMwangnima): extract a common function
-// RefreshService refreshes Triple Service
+// RefreshService refreshes Triple service.
+// The exported wrapper preserves the historical panic-on-invalid-export behavior
+// for direct callers, while internal paths use refreshService to surface errors
+// so export and mount flows can share one explicit failure path.
 func (s *Server) RefreshService(invoker base.Invoker, info *common.ServiceInfo) {
-	URL := invoker.GetURL()
-	serialization := URL.GetParam(constant.SerializationKey, constant.ProtobufSerialization)
+	if err := s.refreshService(invoker, info); err != nil {
+		panic(err)
+	}
+}
+
+func (s *Server) refreshService(invoker base.Invoker, info *common.ServiceInfo) error {
+	url := invoker.GetURL()
+
+	hanOpts, err := resolveHandlerOptions(url)
+	if err != nil {
+		return err
+	}
+
+	s.registerServiceHandlers(invoker, info, hanOpts)
+	return nil
+}
+
+// validateTransportSettings only guards listener-fixed settings. Handler-level
+// Triple options may still vary per exported service, but protocol and TLS
+// configuration must remain stable once one listener has been created.
+func (s *Server) validateTransportSettings(next *transportSettings) error {
+	if next == nil {
+		return nil
+	}
+
+	s.mu.RLock()
+	current := s.transportSettings
+	s.mu.RUnlock()
+	if current == nil {
+		return nil
+	}
+
+	if current.callProtocol != next.callProtocol {
+		return fmt.Errorf("triple transport at %s already uses protocol %s, cannot switch to %s", next.location, current.callProtocol, next.callProtocol)
+	}
+	if !reflect.DeepEqual(current.rawTLSConfig, next.rawTLSConfig) {
+		return fmt.Errorf("triple transport at %s already uses different TLS settings", next.location)
+	}
+	return nil
+}
+
+func resolveTripleConfig(url *common.URL) (*global.TripleConfig, error) {
+	if url == nil {
+		return nil, fmt.Errorf("triple server url must not be nil")
+	}
+
+	tripleConfRaw, ok := url.GetAttribute(constant.TripleConfigKey)
+	if !ok || tripleConfRaw == nil {
+		return nil, nil
+	}
+
+	tripleConf, ok := tripleConfRaw.(*global.TripleConfig)
+	if !ok {
+		return nil, fmt.Errorf("invalid triple config type %T", tripleConfRaw)
+	}
+	return tripleConf.Clone(), nil
+}
+
+func resolveRawTLSConfig(url *common.URL) (*global.TLSConfig, error) {
+	if url == nil {
+		return nil, fmt.Errorf("triple server url must not be nil")
+	}
+
+	tlsConfRaw, ok := url.GetAttribute(constant.TLSConfigKey)
+	if !ok || tlsConfRaw == nil {
+		return nil, nil
+	}
+
+	tlsConf, ok := tlsConfRaw.(*global.TLSConfig)
+	if !ok {
+		return nil, fmt.Errorf("invalid tls config type %T", tlsConfRaw)
+	}
+	return tlsConf.Clone(), nil
+}
+
+func resolveHandlerOptions(url *common.URL) ([]tri.HandlerOption, error) {
+	if url == nil {
+		return nil, fmt.Errorf("triple server url must not be nil")
+	}
+
+	serialization := url.GetParam(constant.SerializationKey, constant.ProtobufSerialization)
 	switch serialization {
 	case constant.ProtobufSerialization:
 	case constant.JSONSerialization:
 	case constant.Hessian2Serialization:
 	case constant.MsgpackSerialization:
 	default:
-		panic(fmt.Sprintf("Unsupported serialization: %s", serialization))
-	}
-	hanOpts := getHanOpts(URL, s.cfg)
-	//Set expected codec name from serviceinfo
-	hanOpts = append(hanOpts, tri.WithExpectedCodecName(serialization))
-	intfName := URL.Interface()
-
-	IDLMode := URL.GetParam(constant.IDLMode, "")
-	var service common.RPCService
-	if IDLMode == constant.NONIDL {
-		service, _ = URL.GetAttribute(constant.RpcServiceKey)
+		return nil, fmt.Errorf("unsupported serialization: %s", serialization)
 	}
 
-	var openapiGroup string
-	if g, ok := URL.GetAttribute(constant.OpenAPIMetaKeyOpenAPIGroup); ok {
-		if gs, ok := g.(string); ok && gs != "" {
-			openapiGroup = gs
-		}
+	tripleConf, err := resolveTripleConfig(url)
+	if err != nil {
+		return nil, err
 	}
 
-	if info != nil {
-		s.handleServiceWithInfo(intfName, invoker, info, hanOpts...)
-		s.saveServiceInfo(intfName, info, openapiGroup, URL.Group(), URL.Version())
-	} else if IDLMode == constant.NONIDL {
-		reflectInfo := createServiceInfoWithReflection(service)
-		s.handleServiceWithInfo(intfName, invoker, reflectInfo, hanOpts...)
-		s.saveServiceInfo(intfName, reflectInfo, openapiGroup, URL.Group(), URL.Version())
-	} else {
-		s.compatHandleService(URL, intfName, URL.Group(), URL.Version(), hanOpts...)
-	}
+	hanOpts := getHanOpts(url, tripleConf)
+	hanOpts = append(hanOpts, tri.WithExpectedCodecName(serialization))
+	return hanOpts, nil
 }
 
 func getHanOpts(url *common.URL, tripleConf *global.TripleConfig) (hanOpts []tri.HandlerOption) {
diff --git a/protocol/triple/server_test.go b/protocol/triple/server_test.go
index 508b21118..186a5d28d 100644
--- a/protocol/triple/server_test.go
+++ b/protocol/triple/server_test.go
@@ -577,10 +577,14 @@ func TestHandleServiceWithInfoSaveServiceInfoOnlyOriginalMethods(t *testing.T) {
 }
 
 type tripleServerTestInvoker struct {
+	url      *common.URL
 	invokeFn func(context.Context, base.Invocation) result.Result
 }
 
 func (m *tripleServerTestInvoker) GetURL() *common.URL {
+	if m.url != nil {
+		return m.url
+	}
 	return common.NewURLWithOptions()
 }
 
@@ -658,6 +662,84 @@ func (c *tripleServerTestConn) ResponseTrailer() http.Header {
 	return c.respTrailer
 }
 
+func TestServerValidateTransportSettingsAllowsCompatibleSettings(t *testing.T) {
+	server := NewServer(nil)
+	server.transportSettings = &transportSettings{
+		location:     "127.0.0.1:20000",
+		callProtocol: constant.CallHTTP2,
+		rawTLSConfig: global.DefaultTLSConfig(),
+	}
+
+	err := server.validateTransportSettings(&transportSettings{
+		location:     "127.0.0.1:20000",
+		callProtocol: constant.CallHTTP2,
+		rawTLSConfig: global.DefaultTLSConfig(),
+	})
+	require.NoError(t, err)
+}
+
+func TestServerValidateTransportSettingsRejectsConflictingProtocol(t *testing.T) {
+	server := NewServer(nil)
+	server.transportSettings = &transportSettings{
+		location:     "127.0.0.1:20000",
+		callProtocol: constant.CallHTTP2,
+		rawTLSConfig: global.DefaultTLSConfig(),
+	}
+
+	err := server.validateTransportSettings(&transportSettings{
+		location:     "127.0.0.1:20000",
+		callProtocol: constant.CallHTTP2AndHTTP3,
+		rawTLSConfig: global.DefaultTLSConfig(),
+	})
+	require.ErrorContains(t, err, "already uses protocol")
+}
+
+func TestServerValidateTransportSettingsRejectsConflictingTLS(t *testing.T) {
+	server := NewServer(nil)
+	server.transportSettings = &transportSettings{
+		location:     "127.0.0.1:20000",
+		callProtocol: constant.CallHTTP2,
+		rawTLSConfig: global.DefaultTLSConfig(),
+	}
+
+	err := server.validateTransportSettings(&transportSettings{
+		location:     "127.0.0.1:20000",
+		callProtocol: constant.CallHTTP2,
+		rawTLSConfig: &global.TLSConfig{TLSCertFile: "server.crt", TLSKeyFile: "server.key"},
+	})
+	require.ErrorContains(t, err, "different TLS settings")
+}
+
+func TestServerAttachHTTPHandlerRejectsDuplicate(t *testing.T) {
+	server := NewServer(nil)
+
+	require.NoError(t, server.AttachHTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))
+
+	err := server.AttachHTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+	require.ErrorContains(t, err, "already been attached")
+}
+
+func TestResolveHandlerOptionsRejectsUnsupportedSerialization(t *testing.T) {
+	url := common.NewURLWithOptions(
+		common.WithParamsValue(constant.SerializationKey, "yaml"),
+	)
+
+	_, err := resolveHandlerOptions(url)
+	require.ErrorContains(t, err, "unsupported serialization: yaml")
+}
+
+func TestServerRefreshServiceRejectsInvalidHandlerTripleConfig(t *testing.T) {
+	server := NewServer(nil)
+	invoker := &tripleServerTestInvoker{
+		url: common.NewURLWithOptions(
+			common.WithAttribute(constant.TripleConfigKey, "invalid"),
+		),
+	}
+
+	err := server.refreshService(invoker, nil)
+	require.ErrorContains(t, err, "invalid triple config type string")
+}
+
 func TestServerRegisterUnaryMethodHandler(t *testing.T) {
 	server := newServerForMethodHandlerTest()
 	invoker := &tripleServerTestInvoker{
diff --git a/protocol/triple/triple.go b/protocol/triple/triple.go
index c90ef65d9..caf3f8ad2 100644
--- a/protocol/triple/triple.go
+++ b/protocol/triple/triple.go
@@ -19,6 +19,8 @@ package triple
 
 import (
 	"context"
+	"fmt"
+	"net/http"
 	"strings"
 	"sync"
 )
@@ -31,7 +33,6 @@ import (
 	"dubbo.apache.org/dubbo-go/v3/common"
 	"dubbo.apache.org/dubbo-go/v3/common/constant"
 	"dubbo.apache.org/dubbo-go/v3/common/extension"
-	"dubbo.apache.org/dubbo-go/v3/global"
 	"dubbo.apache.org/dubbo-go/v3/internal"
 	"dubbo.apache.org/dubbo-go/v3/protocol/base"
 )
@@ -96,40 +97,43 @@ func (tp *TripleProtocol) Export(invoker base.Invoker) base.Exporter {
 	exporter := NewTripleExporter(serviceKey, invoker, tp.ExporterMap())
 	tp.SetExporterMap(serviceKey, exporter)
 	logger.Infof("[TRIPLE Protocol] Export service: %s", url.String())
-	tp.openServer(invoker, info)
+	if err := tp.openServer(invoker, info); err != nil {
+		logger.Errorf("[TRIPLE Protocol] Export service failed: %s, err: %v", url.String(), err)
+		exporter.UnExport()
+		panic(err)
+	}
 	internal.HealthSetServingStatusServing(serviceKey)
 	return exporter
 }
 
-func (tp *TripleProtocol) openServer(invoker base.Invoker, info *common.ServiceInfo) {
+func (tp *TripleProtocol) openServer(invoker base.Invoker, info *common.ServiceInfo) error {
 	url := invoker.GetURL()
 	tp.serverLock.Lock()
 	defer tp.serverLock.Unlock()
 
-	if _, ok := tp.serverMap[url.Location]; ok {
-		tp.serverMap[url.Location].RefreshService(invoker, info)
-		return
+	if srv, ok := tp.serverMap[url.Location]; ok {
+		if err := srv.ValidateTransportURL(url); err != nil {
+			return err
+		}
+		if err := srv.refreshService(invoker, info); err != nil {
+			return err
+		}
+		return nil
 	}
 
 	if _, ok := tp.ExporterMap().Load(url.ServiceKey()); !ok {
 		panic("[TRIPLE Protocol]" + url.Key() + "is not existing")
 	}
 
-	tripleConfRaw, ok := url.GetAttribute(constant.TripleConfigKey)
-	if !ok {
-		// NOTE: sometimes happened on old triple
-		logger.Warnf("Triple config is not found for url: %s", url.Key())
-	}
-
-	tripleConf, ok := tripleConfRaw.(*global.TripleConfig)
-	if !ok || tripleConf == nil {
-		// NOTE: sometimes happened on old triple
-		logger.Warnf("Triple config obtained from url: %s is not of type *global.TripleConfig or is nil", url.Key())
+	// Do not freeze listener configuration at construction time. The shared
+	// transport must be resolved from the current export URL so mount-first and
+	// export-first flows converge on the same bootstrap path.
+	srv := NewServer(nil)
+	if err := srv.Start(invoker, info); err != nil {
+		return err
 	}
-
-	srv := NewServer(tripleConf)
-	srv.Start(invoker, info)
 	tp.serverMap[url.Location] = srv
+	return nil
 }
 
 // Refer a remote triple service
@@ -187,6 +191,46 @@ func (tp *TripleProtocol) drainServers() []*Server {
 	return servers
 }
 
+// HostHTTPHandler ensures the Triple listener at the given location can host an
+// attached root HTTP handler. If the listener already exists, the handler is
+// attached to the existing server; otherwise the Triple transport is started
+// eagerly so later service export can reuse the same listener.
+func (tp *TripleProtocol) HostHTTPHandler(url *common.URL, handler http.Handler) error {
+	if url == nil {
+		return fmt.Errorf("triple server url must not be nil")
+	}
+	if handler == nil {
+		return fmt.Errorf("attached HTTP handler must not be nil")
+	}
+
+	tp.serverLock.Lock()
+	defer tp.serverLock.Unlock()
+
+	if srv, ok := tp.serverMap[url.Location]; ok {
+		if err := srv.ValidateTransportURL(url); err != nil {
+			return err
+		}
+		// If service export already created the listener, hosting only needs to
+		// attach the root handler to the existing server instance.
+		return srv.AttachHTTPHandler(handler)
+	}
+
+	// Keep construction side-effect free and let StartHTTPTransport resolve
+	// the actual listener settings from the host URL.
+	srv := NewServer(nil)
+	if err := srv.AttachHTTPHandler(handler); err != nil {
+		return err
+	}
+	// Boot transport eagerly so HTTP-only and attach-before-export scenarios
+	// reuse the same Triple listener once services are exported later.
+	if err := srv.StartHTTPTransport(url); err != nil {
+		return err
+	}
+
+	tp.serverMap[url.Location] = srv
+	return nil
+}
+
 // isGenericCall checks if the generic parameter indicates a generic call
 func isGenericCall(generic string) bool {
 	if generic == "" {
diff --git a/protocol/triple/triple_protocol/method_route_mux.go b/protocol/triple/triple_protocol/method_route_mux.go
index b4be12669..655677d2a 100644
--- a/protocol/triple/triple_protocol/method_route_mux.go
+++ b/protocol/triple/triple_protocol/method_route_mux.go
@@ -33,8 +33,9 @@ import (
 type methodRouteMux struct {
 	exact *http.ServeMux
 
-	mu    sync.RWMutex
-	lower map[string]methodRouteEntry
+	mu       sync.RWMutex
+	lower    map[string]methodRouteEntry
+	fallback http.Handler
 }
 
 type methodRouteEntry struct {
@@ -92,9 +93,23 @@ func (m *methodRouteMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	m.mu.RLock()
+	fallback := m.fallback
+	m.mu.RUnlock()
+	if fallback != nil {
+		fallback.ServeHTTP(w, r)
+		return
+	}
+
 	http.NotFound(w, r)
 }
 
+func (m *methodRouteMux) SetFallbackHandler(handler http.Handler) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.fallback = handler
+}
+
 func normalizeMethodRouteKey(path string) string {
 	lastSlash := -1
 	for i := len(path) - 1; i >= 0; i-- {
diff --git a/protocol/triple/triple_protocol/method_route_mux_test.go b/protocol/triple/triple_protocol/method_route_mux_test.go
index 331d7a8c9..c713f1b19 100644
--- a/protocol/triple/triple_protocol/method_route_mux_test.go
+++ b/protocol/triple/triple_protocol/method_route_mux_test.go
@@ -30,6 +30,7 @@ import (
 const (
 	methodRouteMuxGetUserPath      = "/Service/GetUser"
 	methodRouteMuxGetUserLowerPath = "/Service/getUser"
+	methodRouteMuxHealthPath       = "/healthz"
 	methodRouteMuxNotFoundBody     = "404 page not found\n"
 )
 
@@ -206,3 +207,54 @@ func TestMethodRouteMuxFallbackCollisionFirstRegistrationWins(t *testing.T) {
 	entry.handler.ServeHTTP(resp, req)
 	assert.Equal(t, "first", resp.Body.String())
 }
+
+func TestMethodRouteMuxUsesFallbackHandlerOnMiss(t *testing.T) {
+	mux := newMethodRouteMux()
+	mux.SetFallbackHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte("http-fallback"))
+	}))
+
+	req := httptest.NewRequest(http.MethodGet, methodRouteMuxHealthPath, nil)
+	resp := httptest.NewRecorder()
+
+	mux.ServeHTTP(resp, req)
+
+	assert.Equal(t, http.StatusOK, resp.Code)
+	assert.Equal(t, "http-fallback", resp.Body.String())
+}
+
+func TestMethodRouteMuxTripleMatchWinsOverFallback(t *testing.T) {
+	mux := newMethodRouteMux()
+	mux.Handle(methodRouteMuxGetUserPath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte("triple"))
+	}))
+	mux.SetFallbackHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte("http-fallback"))
+	}))
+
+	req := httptest.NewRequest(http.MethodPost, methodRouteMuxGetUserPath, nil)
+	resp := httptest.NewRecorder()
+
+	mux.ServeHTTP(resp, req)
+
+	assert.Equal(t, http.StatusOK, resp.Code)
+	assert.Equal(t, "triple", resp.Body.String())
+}
+
+func TestMethodRouteMuxCaseFallbackWinsOverHTTPFallback(t *testing.T) {
+	mux := newMethodRouteMux()
+	mux.Handle(methodRouteMuxGetUserPath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte("triple-case-fallback"))
+	}))
+	mux.SetFallbackHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte("http-fallback"))
+	}))
+
+	req := httptest.NewRequest(http.MethodPost, methodRouteMuxGetUserLowerPath, nil)
+	resp := httptest.NewRecorder()
+
+	mux.ServeHTTP(resp, req)
+
+	assert.Equal(t, http.StatusOK, resp.Code)
+	assert.Equal(t, "triple-case-fallback", resp.Body.String())
+}
diff --git a/protocol/triple/triple_protocol/server.go b/protocol/triple/triple_protocol/server.go
index 9859ed38b..95121c06a 100644
--- a/protocol/triple/triple_protocol/server.go
+++ b/protocol/triple/triple_protocol/server.go
@@ -175,6 +175,13 @@ func (s *Server) RegisterCompatStreamHandler(
 	return nil
 }
 
+func (s *Server) SetFallbackHTTPHandler(h http.Handler) {
+	if s.mux == nil {
+		return
+	}
+	s.mux.SetFallbackHandler(h)
+}
+
 func (s *Server) Run(callProtocol string, tlsConf *tls.Config) error {
 	// Support for starting HTTP/2 and HTTP/3 servers simultaneously.
 	switch callProtocol {
diff --git a/protocol/triple/triple_protocol/server_test.go b/protocol/triple/triple_protocol/server_test.go
index 33e024236..d2693c98d 100644
--- a/protocol/triple/triple_protocol/server_test.go
+++ b/protocol/triple/triple_protocol/server_test.go
@@ -19,6 +19,7 @@ package triple_protocol
 
 import (
 	"net/http"
+	"net/http/httptest"
 	"net/url"
 	"testing"
 )
@@ -99,3 +100,18 @@ func TestServer_RegisterMuxHandle(t *testing.T) {
 		assert.Equal(t, test.path, pattern)
 	}
 }
+
+func TestServerSetFallbackHTTPHandler(t *testing.T) {
+	srv := NewServer("127.0.0.1:0", nil)
+	srv.SetFallbackHTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte("ok"))
+	}))
+
+	req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+	resp := httptest.NewRecorder()
+
+	srv.mux.ServeHTTP(resp, req)
+
+	assert.Equal(t, http.StatusOK, resp.Code)
+	assert.Equal(t, "ok", resp.Body.String())
+}
diff --git a/protocol/triple/triple_test.go b/protocol/triple/triple_test.go
index 6af85ff40..a499d6712 100644
--- a/protocol/triple/triple_test.go
+++ b/protocol/triple/triple_test.go
@@ -19,6 +19,7 @@ package triple
 
 import (
 	"context"
+	"net/http"
 	"sync"
 	"sync/atomic"
 	"testing"
@@ -27,10 +28,15 @@ import (
 
 import (
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 import (
+	"dubbo.apache.org/dubbo-go/v3/common"
+	"dubbo.apache.org/dubbo-go/v3/common/constant"
 	"dubbo.apache.org/dubbo-go/v3/common/extension"
+	"dubbo.apache.org/dubbo-go/v3/global"
+	"dubbo.apache.org/dubbo-go/v3/internal"
 	"dubbo.apache.org/dubbo-go/v3/protocol/base"
 	tri "dubbo.apache.org/dubbo-go/v3/protocol/triple/triple_protocol"
 )
@@ -128,6 +134,97 @@ func TestTripleGracefulShutdownCallbackRegistration(t *testing.T) {
 	})
 }
 
+func TestTripleProtocolOpenServerRejectsConflictingTransportSettings(t *testing.T) {
+	tp := NewTripleProtocol()
+	location := "127.0.0.1:20000"
+	tp.serverMap[location] = &Server{
+		transportSettings: &transportSettings{
+			location:     location,
+			callProtocol: constant.CallHTTP2,
+			rawTLSConfig: global.DefaultTLSConfig(),
+		},
+	}
+
+	invoker := &tripleServerTestInvoker{
+		url: common.NewURLWithOptions(
+			common.WithProtocol(TRIPLE),
+			common.WithIp("127.0.0.1"),
+			common.WithPort("20000"),
+			common.WithAttribute(constant.TripleConfigKey, &global.TripleConfig{
+				Http3: &global.Http3Config{Enable: true},
+			}),
+			common.WithAttribute(constant.TLSConfigKey, global.DefaultTLSConfig()),
+		),
+	}
+
+	err := tp.openServer(invoker, nil)
+	require.ErrorContains(t, err, "already uses protocol")
+}
+
+func TestTripleProtocolExportPublishesServingStatusByServiceKey(t *testing.T) {
+	tp := NewTripleProtocol()
+	url := common.NewURLWithOptions(
+		common.WithProtocol(TRIPLE),
+		common.WithIp("127.0.0.1"),
+		common.WithPort("20000"),
+		common.WithPath("com.example.GreetService"),
+		common.WithParamsValue(constant.InterfaceKey, "com.example.GreetService"),
+		common.WithParamsValue(constant.GroupKey, "test-group"),
+		common.WithParamsValue(constant.VersionKey, "1.0.0"),
+	)
+	tp.serverMap[url.Location] = &Server{
+		triServer: tri.NewServer(url.Location, nil),
+		transportSettings: &transportSettings{
+			location:     url.Location,
+			callProtocol: constant.CallHTTP2,
+		},
+	}
+
+	originalSetServing := internal.HealthSetServingStatusServing
+	t.Cleanup(func() {
+		internal.HealthSetServingStatusServing = originalSetServing
+	})
+
+	published := make(chan string, 1)
+	internal.HealthSetServingStatusServing = func(service string) {
+		published <- service
+	}
+
+	exporter := tp.Export(&tripleServerTestInvoker{url: url})
+	require.NotNil(t, exporter)
+
+	select {
+	case got := <-published:
+		assert.Equal(t, url.ServiceKey(), got)
+		assert.NotEqual(t, url.Service(), got)
+	case <-time.After(time.Second):
+		t.Fatal("export did not publish serving status")
+	}
+}
+
+func TestTripleProtocolHostHTTPHandlerRejectsDuplicateOnExistingListener(t *testing.T) {
+	tp := NewTripleProtocol()
+	location := "127.0.0.1:20000"
+	tp.serverMap[location] = &Server{
+		attachedHTTPHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
+		transportSettings: &transportSettings{
+			location:     location,
+			callProtocol: constant.CallHTTP2,
+			rawTLSConfig: global.DefaultTLSConfig(),
+		},
+	}
+
+	url := common.NewURLWithOptions(
+		common.WithProtocol(TRIPLE),
+		common.WithIp("127.0.0.1"),
+		common.WithPort("20000"),
+		common.WithAttribute(constant.TLSConfigKey, global.DefaultTLSConfig()),
+	)
+
+	err := tp.HostHTTPHandler(url, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+	require.ErrorContains(t, err, "already been attached")
+}
+
 func TestTripleProtocol_Destroy_EmptyServerMap(t *testing.T) {
 	tp := NewTripleProtocol()
 
diff --git a/server/http_attach.go b/server/http_attach.go
new file mode 100644
index 000000000..0603ac628
--- /dev/null
+++ b/server/http_attach.go
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package server
+
+import (
+	"fmt"
+	"net/http"
+)
+
+import (
+	"dubbo.apache.org/dubbo-go/v3/common"
+	"dubbo.apache.org/dubbo-go/v3/common/constant"
+	"dubbo.apache.org/dubbo-go/v3/common/extension"
+	"dubbo.apache.org/dubbo-go/v3/global"
+	"dubbo.apache.org/dubbo-go/v3/protocol/base"
+)
+
+// AttachHTTPHandler records an existing HTTP root handler on the server before
+// Serve starts. The attached handler is later hosted on the selected protocol
+// listener and acts as the transport-level fallback after Triple route lookup,
+// so callers should aggregate any HTTP sub-services behind their own mux/router
+// before attaching.
+func (s *Server) AttachHTTPHandler(handler http.Handler) error {
+	if handler == nil {
+		return fmt.Errorf("attached HTTP handler must not be nil")
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.serve {
+		return fmt.Errorf("attached HTTP handler must be configured before Serve")
+	}
+	// Server-level HTTP integration is a single root handler. Callers that need
+	// multiple HTTP services should compose them behind their own mux/router.
+	if s.attachedHTTPHandler != nil {
+		return fmt.Errorf("an HTTP handler has already been attached")
+	}
+
+	s.attachedHTTPHandler = handler
+	return nil
+}
+
+// hostAttachedHTTPHandler asks an HTTP-capable protocol to host the attached
+// root handler on the same listener as framework-managed routes. Today that
+// means Triple running on an explicit, user-selected port.
+func (s *Server) hostAttachedHTTPHandler() error {
+	s.mu.RLock()
+	handler := s.attachedHTTPHandler
+	s.mu.RUnlock()
+	if handler == nil {
+		return nil
+	}
+
+	protocolConfigs := loadProtocol(s.cfg.Provider.ProtocolIDs, s.cfg.Protocols)
+	if len(protocolConfigs) == 0 {
+		// WithServerProtocol populates Server.Protocols, but HTTP mounting can run
+		// before any service export wires Provider.ProtocolIDs. Fall back to the
+		// declared server protocols so mount-only startup still works.
+		protocolConfigs = make([]*global.ProtocolConfig, 0, len(s.cfg.Protocols))
+		for _, protocolConf := range s.cfg.Protocols {
+			protocolConfigs = append(protocolConfigs, protocolConf)
+		}
+	}
+	mounted := false
+
+	for _, protocolConf := range protocolConfigs {
+		if protocolConf == nil || protocolConf.Name != constant.TriProtocol {
+			continue
+		}
+		if protocolConf.Port == "" {
+			// Hosting needs a stable listener up front; unlike normal service
+			// export we should not silently start on an implicit/random port here.
+			return fmt.Errorf("hosting an attached HTTP handler requires an explicit triple port")
+		}
+
+		proto := extension.GetProtocol(protocolConf.Name)
+		host, ok := proto.(base.HTTPHandlerHost)
+		if !ok {
+			return fmt.Errorf("protocol %s does not support hosting attached HTTP handlers", protocolConf.Name)
+		}
+
+		u := common.NewURLWithOptions(
+			common.WithProtocol(protocolConf.Name),
+			common.WithIp(protocolConf.Ip),
+			common.WithPort(protocolConf.Port),
+			common.WithAttribute(constant.TripleConfigKey, protocolConf.TripleConfig),
+			common.WithAttribute(constant.TLSConfigKey, s.cfg.TLS),
+		)
+		if err := host.HostHTTPHandler(u, handler); err != nil {
+			return err
+		}
+		mounted = true
+	}
+
+	if !mounted {
+		return fmt.Errorf("attached HTTP handler requires at least one triple protocol")
+	}
+	return nil
+}
diff --git a/server/http_attach_test.go b/server/http_attach_test.go
new file mode 100644
index 000000000..f51995c2a
--- /dev/null
+++ b/server/http_attach_test.go
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package server
+
+import (
+	"net/http"
+	"strconv"
+	"testing"
+	"time"
+)
+
+import (
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+import (
+	"dubbo.apache.org/dubbo-go/v3/common/constant"
+	"dubbo.apache.org/dubbo-go/v3/common/extension"
+	"dubbo.apache.org/dubbo-go/v3/protocol"
+	_ "dubbo.apache.org/dubbo-go/v3/protocol/triple"
+)
+
+type httpMountTestHandler struct{}
+
+func (h *httpMountTestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {}
+
+func TestServerAttachHTTPHandler(t *testing.T) {
+	srv, err := NewServer()
+	require.NoError(t, err)
+
+	handler := &httpMountTestHandler{}
+	err = srv.AttachHTTPHandler(handler)
+	require.NoError(t, err)
+
+	assert.Same(t, handler, srv.attachedHTTPHandler)
+}
+
+func TestServerAttachHTTPHandlerRejectsNil(t *testing.T) {
+	srv, err := NewServer()
+	require.NoError(t, err)
+
+	err = srv.AttachHTTPHandler(nil)
+	require.ErrorContains(t, err, "must not be nil")
+}
+
+func TestServerAttachHTTPHandlerRejectsDuplicate(t *testing.T) {
+	srv, err := NewServer()
+	require.NoError(t, err)
+
+	first := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
+	second := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
+
+	require.NoError(t, srv.AttachHTTPHandler(first))
+
+	err = srv.AttachHTTPHandler(second)
+	require.ErrorContains(t, err, "already been attached")
+}
+
+func TestServerAttachHTTPHandlerRejectsAfterServe(t *testing.T) {
+	srv, err := NewServer()
+	require.NoError(t, err)
+
+	srv.serve = true
+	err = srv.AttachHTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+	require.ErrorContains(t, err, "before Serve")
+}
+
+func TestHostAttachedHTTPHandlerRequiresTripleProtocol(t *testing.T) {
+	srv, err := NewServer(
+		WithServerProtocol(
+			protocol.WithDubbo(),
+		),
+	)
+	require.NoError(t, err)
+
+	require.NoError(t, srv.AttachHTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))
+
+	err = srv.hostAttachedHTTPHandler()
+	require.ErrorContains(t, err, "requires at least one triple protocol")
+}
+
+func TestHostAttachedHTTPHandlerRequiresExplicitPort(t *testing.T) {
+	srv, err := NewServer(
+		WithServerProtocol(
+			protocol.WithTriple(),
+		),
+	)
+	require.NoError(t, err)
+
+	srv.cfg.Protocols[constant.TriProtocol].Port = ""
+	require.NoError(t, srv.AttachHTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))
+
+	err = srv.hostAttachedHTTPHandler()
+	require.ErrorContains(t, err, "requires an explicit triple port")
+}
+
+func TestServeDoesNotHostAttachedHTTPHandlerBeforeLaterStartupFailures(t *testing.T) {
+	port := testFreePort(t)
+	srv, err := NewServer(
+		WithServerProtocol(
+			protocol.WithTriple(),
+			protocol.WithIp("127.0.0.1"),
+			protocol.WithPort(port),
+		),
+	)
+	require.NoError(t, err)
+
+	require.NoError(t, srv.AttachHTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/healthz":
+			_, _ = w.Write([]byte("healthy"))
+		default:
+			http.NotFound(w, r)
+		}
+	})))
+
+	internalProLock.Lock()
+	originalInternalServices := internalProServices
+	internalProServices = []*InternalService{
+		{Name: "broken-internal-service"},
+	}
+	internalProLock.Unlock()
+	t.Cleanup(func() {
+		internalProLock.Lock()
+		internalProServices = originalInternalServices
+		internalProLock.Unlock()
+	})
+
+	t.Cleanup(func() {
+		extension.GetProtocol(constant.TriProtocol).Destroy()
+	})
+
+	err = srv.Serve()
+	require.ErrorContains(t, err, "internal service init func is empty")
+
+	client := &http.Client{Timeout: 100 * time.Millisecond}
+	baseURL := "http://127.0.0.1:" + strconv.Itoa(port)
+	deadline := time.Now().Add(time.Second)
+	for time.Now().Before(deadline) {
+		status, body, reqErr := simpleHTTPRequest(client, http.MethodGet, baseURL+"/healthz")
+		if reqErr == nil {
+			t.Fatalf("attached HTTP handler started after Serve failure: status=%d body=%q", status, body)
+		}
+		time.Sleep(50 * time.Millisecond)
+	}
+}
diff --git a/server/server.go b/server/server.go
index 29c605acb..c3316223a 100644
--- a/server/server.go
+++ b/server/server.go
@@ -21,6 +21,7 @@ package server
 import (
 	"context"
 	stderrors "errors"
+	"net/http"
 	"reflect"
 	"sort"
 	"strconv"
@@ -59,6 +60,8 @@ type Server struct {
 	interfaceNameServices map[string]*ServiceOptions
 	// indicate whether the server is already started
 	serve bool
+
+	attachedHTTPHandler http.Handler
 }
 
 // ServiceInfo Deprecated: common.ServiceInfo type alias, just for compatible with old generate pb.go file
@@ -390,6 +393,9 @@ func (s *Server) ServeContext(ctx context.Context) error {
 		return s.rollbackServeStartWithCause(err, serviceInstanceRegistered)
 	}
 	serviceInstanceRegistered = true
+	if err := s.hostAttachedHTTPHandler(); err != nil {
+		return s.rollbackServeStartWithCause(err, serviceInstanceRegistered)
+	}
 
 	// k8s probe ready
 	probe.SetStartupComplete(true)
diff --git a/server/triple_case_route_integration_test.go b/server/triple_case_route_integration_test.go
index af0332949..5cc891c35 100644
--- a/server/triple_case_route_integration_test.go
+++ b/server/triple_case_route_integration_test.go
@@ -236,6 +236,27 @@ func tripleRouteRequest(client *http.Client, url string) (int, string, error) {
 	return resp.StatusCode, string(body), nil
 }
 
+func simpleHTTPRequest(client *http.Client, method string, url string) (int, string, error) {
+	req, err := http.NewRequest(method, url, nil)
+	if err != nil {
+		return 0, "", err
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return 0, "", err
+	}
+	defer func() {
+		_ = resp.Body.Close()
+	}()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return 0, "", err
+	}
+	return resp.StatusCode, string(body), nil
+}
+
 func waitTripleRouteReady(t *testing.T, client *http.Client, url string) {
 	t.Helper()
 
@@ -254,3 +275,146 @@ func waitTripleRouteReady(t *testing.T, client *http.Client, url string) {
 
 	t.Fatalf("triple route not ready: status=%d body=%q err=%v", lastStatus, lastBody, lastErr)
 }
+
+func waitSimpleHTTPReady(t *testing.T, client *http.Client, method string, url string, wantBody string) {
+	t.Helper()
+
+	deadline := time.Now().Add(5 * time.Second)
+	var lastStatus int
+	var lastBody string
+	var lastErr error
+
+	for time.Now().Before(deadline) {
+		lastStatus, lastBody, lastErr = simpleHTTPRequest(client, method, url)
+		if lastErr == nil && lastStatus == http.StatusOK && lastBody == wantBody {
+			return
+		}
+		time.Sleep(50 * time.Millisecond)
+	}
+
+	t.Fatalf("http handler not ready: status=%d body=%q err=%v", lastStatus, lastBody, lastErr)
+}
+
+// TestAttachedHTTPHandlerStartsTransportWithoutTripleServices covers the
+// attach-first flow where the shared listener is started for plain HTTP traffic
+// before any Triple service has been exported.
+func TestAttachedHTTPHandlerStartsTransportWithoutTripleServices(t *testing.T) {
+	port := testFreePort(t)
+
+	srv, err := NewServer(
+		WithServerProtocol(
+			protocol.WithTriple(),
+			protocol.WithIp("127.0.0.1"),
+			protocol.WithPort(port),
+		),
+	)
+	require.NoError(t, err)
+
+	// Attach-only startup should bring up the shared listener even before any
+	// Triple service has been exported.
+	err = srv.AttachHTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/healthz":
+			_, _ = w.Write([]byte("healthy"))
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+	require.NoError(t, err)
+
+	err = srv.hostAttachedHTTPHandler()
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		extension.GetProtocol(constant.TriProtocol).Destroy()
+	})
+
+	client := &http.Client{Timeout: 2 * time.Second}
+	baseURL := "http://127.0.0.1:" + strconv.Itoa(port)
+
+	waitSimpleHTTPReady(t, client, http.MethodGet, baseURL+"/healthz", "healthy")
+
+	status, body, reqErr := simpleHTTPRequest(client, http.MethodGet, baseURL+"/unknown")
+	require.NoError(t, reqErr)
+	assert.Equal(t, http.StatusNotFound, status)
+	assert.Equal(t, tripleCaseRouteNotFoundBody, body)
+}
+
+// TestAttachedHTTPHandlerCoexistsWithTripleRoutes verifies that the attached HTTP
+// handler remains a transport-level fallback and does not preempt registered
+// Triple procedures on the same listener.
+func TestAttachedHTTPHandlerCoexistsWithTripleRoutes(t *testing.T) {
+	port := testFreePort(t)
+
+	srv, err := NewServer(
+		WithServerProtocol(
+			protocol.WithTriple(),
+			protocol.WithIp("127.0.0.1"),
+			protocol.WithPort(port),
+		),
+	)
+	require.NoError(t, err)
+
+	// The attached handler is the transport-level fallback; Triple routes should
+	// still win whenever the request matches a registered procedure.
+	err = srv.AttachHTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/healthz":
+			_, _ = w.Write([]byte("healthy"))
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+	require.NoError(t, err)
+
+	service := &TripleCaseRouteService{}
+	info := &common.ServiceInfo{
+		InterfaceName: "com.example.GreetService",
+		ServiceType:   service,
+		Methods: []common.MethodInfo{
+			{
+				Name: "SayHello",
+				Type: constant.CallUnary,
+				ReqInitFunc: func() any {
+					return &emptypb.Empty{}
+				},
+				MethodFunc: func(ctx context.Context, args []any, handler any) (any, error) {
+					req := args[0].(*emptypb.Empty)
+					res, callErr := handler.(*TripleCaseRouteService).SayHello(ctx, req)
+					if callErr != nil {
+						return nil, callErr
+					}
+					return tri.NewResponse(res), nil
+				},
+			},
+		},
+	}
+
+	err = srv.Register(service, info, WithInterface(info.InterfaceName), WithNotRegister())
+	require.NoError(t, err)
+
+	err = srv.hostAttachedHTTPHandler()
+	require.NoError(t, err)
+
+	err = srv.exportServices(context.Background())
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		extension.GetProtocol(constant.TriProtocol).Destroy()
+	})
+
+	client := &http.Client{Timeout: 2 * time.Second}
+	baseURL := "http://127.0.0.1:" + strconv.Itoa(port)
+
+	waitTripleRouteReady(t, client, baseURL+"/com.example.GreetService/SayHello")
+
+	status, body, reqErr := simpleHTTPRequest(client, http.MethodGet, baseURL+"/healthz")
+	require.NoError(t, reqErr)
+	assert.Equal(t, http.StatusOK, status)
+	assert.Equal(t, "healthy", body)
+
+	status, body, reqErr = tripleRouteRequest(client, baseURL+"/com.example.GreetService/sayHello")
+	require.NoError(t, reqErr)
+	assert.Equal(t, http.StatusOK, status)
+	assert.Equal(t, tripleCaseRouteHelloBody, body)
+}