接入本机重置通道生命周期并验证失败关闭

This commit is contained in:
xuans 2026-09-21 00:54:58 +08:00
parent 651d7ba917
commit c7cfe98e1c
3 changed files with 285 additions and 23 deletions

View File

@ -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
}

View File

@ -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("本机通道没有关闭")
}
})
}
}

View File

@ -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 {