@@ -18,27 +18,33 @@ import (
1818)
1919
2020type systemHandlerUpdateServiceStub struct {
21- performErr error
22- updateInfo * service.UpdateInfo
23- checkErr error
24- checkForces []bool
25- performCall int
26- rollbackCall int
27- rollbackToCall int
28- rollbackToVersions []string
29- rollbackToErr error
30- rollbackVersions []service.RollbackVersion
31- rollbackVersionsErr error
32- rollbackVersionsCall int
21+ performErr error
22+ updateInfo * service.UpdateInfo
23+ checkErr error
24+ checkForces []bool
25+ performCall int
26+ performCtxErr error
27+ performHasDeadline bool
28+ rollbackCall int
29+ rollbackToCall int
30+ rollbackToCtxErr error
31+ rollbackToHasDeadline bool
32+ rollbackToVersions []string
33+ rollbackToErr error
34+ rollbackVersions []service.RollbackVersion
35+ rollbackVersionsErr error
36+ rollbackVersionsCall int
3337}
3438
3539func (s * systemHandlerUpdateServiceStub ) CheckUpdate (_ context.Context , force bool ) (* service.UpdateInfo , error ) {
3640 s .checkForces = append (s .checkForces , force )
3741 return s .updateInfo , s .checkErr
3842}
3943
40- func (s * systemHandlerUpdateServiceStub ) PerformUpdate (context.Context ) error {
44+ func (s * systemHandlerUpdateServiceStub ) PerformUpdate (ctx context.Context ) error {
4145 s .performCall ++
46+ s .performCtxErr = ctx .Err ()
47+ _ , s .performHasDeadline = ctx .Deadline ()
4248 return s .performErr
4349}
4450
@@ -52,8 +58,10 @@ func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) (
5258 return s .rollbackVersions , s .rollbackVersionsErr
5359}
5460
55- func (s * systemHandlerUpdateServiceStub ) RollbackToVersion (_ context.Context , version string ) error {
61+ func (s * systemHandlerUpdateServiceStub ) RollbackToVersion (ctx context.Context , version string ) error {
5662 s .rollbackToCall ++
63+ s .rollbackToCtxErr = ctx .Err ()
64+ _ , s .rollbackToHasDeadline = ctx .Deadline ()
5765 s .rollbackToVersions = append (s .rollbackToVersions , version )
5866 return s .rollbackToErr
5967}
@@ -165,6 +173,55 @@ func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T
165173 require .Equal (t , "internal error" , body .Message )
166174}
167175
176+ // TestSystemHandlerPerformUpdateSurvivesClientDisconnect reproduces #4504:
177+ // the browser or a reverse proxy (axios 30s default, nginx proxy_read_timeout
178+ // 60s) aborts the long-running update request and cancels the request
179+ // context. The download must keep running on a detached, bounded context
180+ // instead of dying with "download failed: context canceled".
181+ func TestSystemHandlerPerformUpdateSurvivesClientDisconnect (t * testing.T ) {
182+ updateSvc := & systemHandlerUpdateServiceStub {}
183+ repo := newMemoryIdempotencyRepoStub ()
184+ router := newSystemHandlerTestRouter (t , updateSvc , repo )
185+
186+ rec := httptest .NewRecorder ()
187+ req := httptest .NewRequest (http .MethodPost , "/api/v1/admin/system/update" , nil )
188+ canceledCtx , cancel := context .WithCancel (context .Background ())
189+ cancel ()
190+ req = req .WithContext (canceledCtx )
191+ req .Header .Set ("Idempotency-Key" , "disconnected-update" )
192+ router .ServeHTTP (rec , req )
193+
194+ require .Equal (t , 1 , updateSvc .performCall )
195+ require .NoError (t , updateSvc .performCtxErr ,
196+ "update must not observe the canceled request context" )
197+ require .True (t , updateSvc .performHasDeadline ,
198+ "detached update context must still be bounded by a deadline" )
199+ requireSystemLockStatus (t , repo , service .IdempotencyStatusSucceeded )
200+ }
201+
202+ func TestSystemHandlerRollbackToVersionSurvivesClientDisconnect (t * testing.T ) {
203+ updateSvc := & systemHandlerUpdateServiceStub {}
204+ repo := newMemoryIdempotencyRepoStub ()
205+ router := newSystemHandlerTestRouter (t , updateSvc , repo )
206+
207+ rec := httptest .NewRecorder ()
208+ req := httptest .NewRequest (http .MethodPost , "/api/v1/admin/system/rollback" ,
209+ strings .NewReader (`{"version":"0.1.146"}` ))
210+ req .Header .Set ("Content-Type" , "application/json" )
211+ canceledCtx , cancel := context .WithCancel (context .Background ())
212+ cancel ()
213+ req = req .WithContext (canceledCtx )
214+ req .Header .Set ("Idempotency-Key" , "disconnected-rollback" )
215+ router .ServeHTTP (rec , req )
216+
217+ require .Equal (t , 1 , updateSvc .rollbackToCall )
218+ require .NoError (t , updateSvc .rollbackToCtxErr ,
219+ "versioned rollback must not observe the canceled request context" )
220+ require .True (t , updateSvc .rollbackToHasDeadline ,
221+ "detached rollback context must still be bounded by a deadline" )
222+ requireSystemLockStatus (t , repo , service .IdempotencyStatusSucceeded )
223+ }
224+
168225func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup (t * testing.T ) {
169226 updateSvc := & systemHandlerUpdateServiceStub {}
170227 repo := newMemoryIdempotencyRepoStub ()
0 commit comments