fix(update): detach in-place update from the HTTP request lifetime

The in-place update ran entirely inside c.Request.Context(). Browsers and
reverse proxies commonly abort long-idle requests (axios global timeout
30s, nginx proxy_read_timeout 60s by default), which canceled the request
context mid-download and killed every slow update with
'download failed: context canceled' while the version stayed unchanged.
Users behind slow GitHub links saw the update button fail at a wall-clock
ceiling (~60s) on every attempt (#4504).

- Run PerformUpdate and RollbackToVersion on a context detached from the
  request (context.WithoutCancel) and bounded by a 15-minute deadline so
  the 10-minute GitHub download client owns its own timeout. A client
  disconnect no longer aborts the binary swap; retries then hit the
  system operation lock or report 'Already up to date'.
- Raise the frontend timeout for the update/rollback calls from the
  global 30s axios default to 15 minutes so the browser can actually
  wait for the result.

Fixes #4504
This commit is contained in:
superman2003
2026-07-19 22:40:44 +08:00
parent d4b9797ff7
commit 35b5edb24c
3 changed files with 114 additions and 19 deletions
@@ -18,18 +18,22 @@ import (
)
type systemHandlerUpdateServiceStub struct {
performErr error
updateInfo *service.UpdateInfo
checkErr error
checkForces []bool
performCall int
rollbackCall int
rollbackToCall int
rollbackToVersions []string
rollbackToErr error
rollbackVersions []service.RollbackVersion
rollbackVersionsErr error
rollbackVersionsCall int
performErr error
updateInfo *service.UpdateInfo
checkErr error
checkForces []bool
performCall int
performCtxErr error
performHasDeadline bool
rollbackCall int
rollbackToCall int
rollbackToCtxErr error
rollbackToHasDeadline bool
rollbackToVersions []string
rollbackToErr error
rollbackVersions []service.RollbackVersion
rollbackVersionsErr error
rollbackVersionsCall int
}
func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) {
@@ -37,8 +41,10 @@ func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bo
return s.updateInfo, s.checkErr
}
func (s *systemHandlerUpdateServiceStub) PerformUpdate(context.Context) error {
func (s *systemHandlerUpdateServiceStub) PerformUpdate(ctx context.Context) error {
s.performCall++
s.performCtxErr = ctx.Err()
_, s.performHasDeadline = ctx.Deadline()
return s.performErr
}
@@ -52,8 +58,10 @@ func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) (
return s.rollbackVersions, s.rollbackVersionsErr
}
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(_ context.Context, version string) error {
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(ctx context.Context, version string) error {
s.rollbackToCall++
s.rollbackToCtxErr = ctx.Err()
_, s.rollbackToHasDeadline = ctx.Deadline()
s.rollbackToVersions = append(s.rollbackToVersions, version)
return s.rollbackToErr
}
@@ -165,6 +173,55 @@ func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T
require.Equal(t, "internal error", body.Message)
}
// TestSystemHandlerPerformUpdateSurvivesClientDisconnect reproduces #4504:
// the browser or a reverse proxy (axios 30s default, nginx proxy_read_timeout
// 60s) aborts the long-running update request and cancels the request
// context. The download must keep running on a detached, bounded context
// instead of dying with "download failed: context canceled".
func TestSystemHandlerPerformUpdateSurvivesClientDisconnect(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
req = req.WithContext(canceledCtx)
req.Header.Set("Idempotency-Key", "disconnected-update")
router.ServeHTTP(rec, req)
require.Equal(t, 1, updateSvc.performCall)
require.NoError(t, updateSvc.performCtxErr,
"update must not observe the canceled request context")
require.True(t, updateSvc.performHasDeadline,
"detached update context must still be bounded by a deadline")
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
}
func TestSystemHandlerRollbackToVersionSurvivesClientDisconnect(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
strings.NewReader(`{"version":"0.1.146"}`))
req.Header.Set("Content-Type", "application/json")
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
req = req.WithContext(canceledCtx)
req.Header.Set("Idempotency-Key", "disconnected-rollback")
router.ServeHTTP(rec, req)
require.Equal(t, 1, updateSvc.rollbackToCall)
require.NoError(t, updateSvc.rollbackToCtxErr,
"versioned rollback must not observe the canceled request context")
require.True(t, updateSvc.rollbackToHasDeadline,
"detached rollback context must still be bounded by a deadline")
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
}
func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()