From c7cfe98e1c8461f7c5bf4276dba41995a007c721 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 21 Sep 2026 00:54:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=9C=AC=E6=9C=BA=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E9=80=9A=E9=81=93=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E5=B9=B6=E9=AA=8C=E8=AF=81=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/cli/application_services.go | 89 +++++++++ .../internal/cli/application_services_test.go | 187 ++++++++++++++++++ backend/internal/cli/serve.go | 32 +-- 3 files changed, 285 insertions(+), 23 deletions(-) create mode 100644 backend/internal/cli/application_services.go create mode 100644 backend/internal/cli/application_services_test.go diff --git a/backend/internal/cli/application_services.go b/backend/internal/cli/application_services.go new file mode 100644 index 0000000..6d98663 --- /dev/null +++ b/backend/internal/cli/application_services.go @@ -0,0 +1,89 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "net/http" + + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type applicationHTTP interface { + ListenAndServe() error + Listening() <-chan struct{} +} + +type applicationControl interface { + Serve(context.Context, localreset.Factory) error + Close() error +} + +// runApplicationServices 将本机通道绑定到正式主服务生命周期,并在数据库关闭前等待重置处理退出。 +func runApplicationServices( + ctx context.Context, + web applicationHTTP, + startRenewal func(context.Context), + factory localreset.Factory, + openControl func() (applicationControl, error), +) (_ bool, resultErr error) { + if ctx.Err() != nil { + return false, nil + } + control, err := openControl() + if err != nil { + return false, fmt.Errorf("启动本机密码重置通道失败:%w", err) + } + controlCtx, cancel := context.WithCancel(ctx) + controlDone := make(chan error, 1) + go func() { controlDone <- control.Serve(controlCtx, factory) }() + controlFinished := false + defer func() { + cancel() + resultErr = errors.Join(resultErr, control.Close()) + if !controlFinished { + resultErr = errors.Join(resultErr, <-controlDone) + } + }() + httpDone := make(chan error, 1) + go func() { httpDone <- web.ListenAndServe() }() + select { + case <-ctx.Done(): + return false, nil + case err := <-controlDone: + controlFinished = true + return false, applicationControlExit(ctx, err) + case err := <-httpDone: + return false, applicationHTTPExit(err) + case <-web.Listening(): + } + if ctx.Err() != nil { + return false, nil + } + if startRenewal != nil { + startRenewal(ctx) + } + select { + case <-ctx.Done(): + return false, nil + case err := <-controlDone: + controlFinished = true + return false, applicationControlExit(ctx, err) + case err := <-httpDone: + return false, applicationHTTPExit(err) + } +} + +func applicationControlExit(ctx context.Context, err error) error { + if ctx.Err() != nil { + return err + } + return fmt.Errorf("本机密码重置通道意外退出:%w", errors.Join(localreset.ErrUnavailable, err)) +} + +func applicationHTTPExit(err error) error { + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} diff --git a/backend/internal/cli/application_services_test.go b/backend/internal/cli/application_services_test.go new file mode 100644 index 0000000..ce96a62 --- /dev/null +++ b/backend/internal/cli/application_services_test.go @@ -0,0 +1,187 @@ +package cli + +import ( + "context" + "errors" + "net/http" + "sync/atomic" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type applicationTestHTTP struct { + ready chan struct{} + exit chan error + started atomic.Int32 +} + +func (h *applicationTestHTTP) Listening() <-chan struct{} { return h.ready } +func (h *applicationTestHTTP) ListenAndServe() error { + h.started.Add(1) + return <-h.exit +} + +type applicationTestControl struct { + serve func(context.Context, localreset.Factory) error + closed atomic.Int32 +} + +func (c *applicationTestControl) Serve(ctx context.Context, factory localreset.Factory) error { + return c.serve(ctx, factory) +} +func (c *applicationTestControl) Close() error { + c.closed.Add(1) + return nil +} + +func applicationTestFactory(context.Context, localreset.Peer) (localreset.Session, error) { + return nil, localreset.ErrUnavailable +} + +func TestApplicationControlFailurePreventsHTTPStartup(t *testing.T) { + web := &applicationTestHTTP{ready: make(chan struct{}), exit: make(chan error, 1)} + expected := errors.New("test-control-bind-failure") + _, err := runApplicationServices(t.Context(), web, nil, applicationTestFactory, func() (applicationControl, error) { + return nil, expected + }) + if !errors.Is(err, expected) || web.started.Load() != 0 { + t.Fatal("本机通道创建失败后仍启动了网络服务") + } +} + +func TestApplicationCancellationBeforeStartupDoesNotOpenControl(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + opened := false + _, err := runApplicationServices(ctx, nil, nil, nil, func() (applicationControl, error) { + opened = true + return nil, nil + }) + if err != nil || opened { + t.Fatal("取消后仍然启动本机通道") + } +} + +func TestApplicationBootstrapCannotOpenControl(t *testing.T) { + cfg := config.Default(t.TempDir()) + cfg.SetupReady = false + if _, err := runApplication(t.Context(), cfg, ""); !errors.Is(err, localreset.ErrUnavailable) { + t.Fatal("初始化阶段没有拒绝正式本机通道") + } +} + +func TestApplicationWaitsForControlWorkBeforeReturning(t *testing.T) { + for _, scenario := range []string{"cancel-starting", "cancel-running", "http-startup-error", "http-runtime-error", "http-closed"} { + t.Run(scenario, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + web := &applicationTestHTTP{ready: make(chan struct{}), exit: make(chan error, 1)} + t.Cleanup(func() { web.exit <- http.ErrServerClosed }) + entered := make(chan struct{}) + cancelled := make(chan struct{}) + release := make(chan struct{}) + finished := make(chan struct{}) + control := &applicationTestControl{serve: func(ctx context.Context, factory localreset.Factory) error { + if factory == nil { + t.Error("主服务未提供重置工厂") + } + close(entered) + <-ctx.Done() + close(cancelled) + <-release + close(finished) + return nil + }} + renewal := make(chan struct{}) + done := make(chan error, 1) + go func() { + _, err := runApplicationServices(ctx, web, func(context.Context) { close(renewal) }, applicationTestFactory, func() (applicationControl, error) { return control, nil }) + done <- err + }() + <-entered + if scenario == "cancel-running" || scenario == "http-runtime-error" || scenario == "http-closed" { + close(web.ready) + <-renewal + } + expected := errors.New("test-http-failure") + switch scenario { + case "cancel-starting", "cancel-running": + cancel() + case "http-closed": + web.exit <- http.ErrServerClosed + default: + web.exit <- expected + } + select { + case <-cancelled: + case <-time.After(3 * time.Second): + t.Fatal("没有取消本机重置处理") + } + select { + case <-done: + t.Fatal("业务处理尚未结束就返回,可能提前关闭数据库") + default: + } + close(release) + select { + case err := <-done: + if scenario == "http-startup-error" || scenario == "http-runtime-error" { + if !errors.Is(err, expected) { + t.Fatal("丢失 HTTP 启动或运行错误") + } + } else if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("本机通道退出后主服务仍未返回") + } + <-finished + if control.closed.Load() != 1 { + t.Fatal("没有关闭本机通道") + } + }) + } +} + +func TestApplicationControlUnexpectedExitFailsClosed(t *testing.T) { + for _, ready := range []bool{false, true} { + name := "starting" + if ready { + name = "running" + } + t.Run(name, func(t *testing.T) { + web := &applicationTestHTTP{ready: make(chan struct{}), exit: make(chan error, 1)} + t.Cleanup(func() { web.exit <- http.ErrServerClosed }) + stop := make(chan struct{}) + control := &applicationTestControl{serve: func(context.Context, localreset.Factory) error { + <-stop + return nil + }} + renewal := make(chan struct{}) + done := make(chan error, 1) + go func() { + _, err := runApplicationServices(t.Context(), web, func(context.Context) { close(renewal) }, applicationTestFactory, func() (applicationControl, error) { return control, nil }) + done <- err + }() + if ready { + close(web.ready) + <-renewal + } + close(stop) + select { + case err := <-done: + if !errors.Is(err, localreset.ErrUnavailable) { + t.Fatal("本机安全通道异常退出被当作正常状态") + } + case <-time.After(3 * time.Second): + t.Fatal("本机通道退出后 HTTP 生命周期未结束") + } + if control.closed.Load() != 1 { + t.Fatal("本机通道没有关闭") + } + }) + } +} diff --git a/backend/internal/cli/serve.go b/backend/internal/cli/serve.go index 3ad26a6..7415cf9 100644 --- a/backend/internal/cli/serve.go +++ b/backend/internal/cli/serve.go @@ -4,13 +4,13 @@ import ( "context" "errors" "fmt" - "net/http" "os" "path/filepath" "runtime" "git.pcux.cn/xuans/Scert/backend/internal/app" "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" "github.com/spf13/cobra" ) @@ -77,35 +77,21 @@ func bootstrapFirewallMode(command *cobra.Command, value string) string { } func runApplication(ctx context.Context, cfg config.Config, path string) (bool, error) { + if !cfg.SetupReady { + return false, localreset.ErrUnavailable + } service, err := app.NewWithConfigPath(ctx, cfg, path) if err != nil { return false, err } defer service.Close() - errorsChannel := make(chan error, 1) - go func() { errorsChannel <- service.HTTP.ListenAndServe() }() - select { - case <-ctx.Done(): - return false, nil - case err := <-errorsChannel: - if errors.Is(err, http.ErrServerClosed) { - return false, nil - } - return false, err - case <-service.HTTP.Listening(): - } + var startRenewal func(context.Context) if service.Renewal != nil { - service.Renewal.Start(ctx) - } - select { - case <-ctx.Done(): - return false, nil - case err := <-errorsChannel: - if errors.Is(err, http.ErrServerClosed) { - return false, nil - } - return false, err + startRenewal = service.Renewal.Start } + return runApplicationServices(ctx, service.HTTP, startRenewal, service.HTTP.LocalResetFactory(), func() (applicationControl, error) { + return localreset.Listen("") + }) } func bootstrapURLPath(cfg config.Config) string {