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
@@ -22,6 +22,27 @@ type SystemHandler struct {
lockSvc *service.SystemOperationLockService
}
// systemUpdateTimeout bounds a full in-place update or rollback: the release
// manifest fetch plus a large binary download over slow links. It must stay
// above the GitHub download client timeout (10 minutes) so the download owns
// its own deadline.
const systemUpdateTimeout = 15 * time.Minute
// systemUpdateContext detaches a long-running update/rollback from the HTTP
// request lifetime. Browsers and reverse proxies commonly abort idle requests
// after 30-60s (axios default, nginx proxy_read_timeout), which canceled
// c.Request.Context() mid-download and killed the update with
// "download failed: context canceled" (#4504). The swap keeps running after a
// client disconnect; a later retry then hits the system operation lock or
// reports "Already up to date".
func systemUpdateContext(ctx context.Context) (context.Context, context.CancelFunc) {
base := context.Background()
if ctx != nil {
base = context.WithoutCancel(ctx)
}
return context.WithTimeout(base, systemUpdateTimeout)
}
type systemUpdateService interface {
CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error)
PerformUpdate(ctx context.Context) error
@@ -75,9 +96,12 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) {
release(releaseReason, succeeded)
}()
if err := h.updateSvc.PerformUpdate(ctx); err != nil {
updateCtx, cancel := systemUpdateContext(ctx)
defer cancel()
if err := h.updateSvc.PerformUpdate(updateCtx); err != nil {
if errors.Is(err, service.ErrNoUpdateAvailable) {
info, checkErr := h.updateSvc.CheckUpdate(ctx, false)
info, checkErr := h.updateSvc.CheckUpdate(updateCtx, false)
if checkErr != nil {
releaseReason = "SYSTEM_UPDATE_FAILED"
return nil, checkErr
@@ -152,7 +176,10 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
}()
if targetVersion != "" {
err = h.updateSvc.RollbackToVersion(ctx, targetVersion)
// 指定版本回退同样要下载完整二进制,与更新一样和请求生命周期解耦。
rollbackCtx, cancel := systemUpdateContext(ctx)
defer cancel()
err = h.updateSvc.RollbackToVersion(rollbackCtx, targetVersion)
} else {
err = h.updateSvc.Rollback()
}