@@ -4101,3 +4101,70 @@ func TestHandleNodeFromAuthPath_OldUserNil_NoPanic(t *testing.T) {
41014101 assert .NotEqual (t , types .NodeID (99002 ), node .ID (), "new node, not orphan" )
41024102 assert .Equal (t , userB .ID , node .UserID ().Get (), "new node belongs to userB" )
41034103}
4104+
4105+ // TestFollowupWaitPrefersCompletedAuthOverExpiredContext reproduces
4106+ // https://github.com/juanfont/headscale/issues/3385.
4107+ //
4108+ // Root cause: [Headscale.waitForFollowup] selects on ctx.Done() and the auth
4109+ // verdict channel with equal priority. When the registration has ALREADY
4110+ // completed (verdict buffered) but the request context has ALSO expired, Go's
4111+ // select picks a ready case at random, so roughly half the time it returns
4112+ // "registration timed out" and discards a successful registration.
4113+ //
4114+ // The v0.28.0 hscontrol test suite hit this because the followup context
4115+ // timeout was only 100ms while the setup goroutine (create user + node in
4116+ // SQLite) frequently took longer on slower/constrained builders (ppc64le,
4117+ // Alpine CI). Both channels ended up ready at once and the flake surfaced as
4118+ // TestAuthenticationFlows/followup_registration_success failing with
4119+ // "http error[401]: registration timed out".
4120+ //
4121+ // The fix must give the completed-auth case priority over context
4122+ // cancellation. This test forces both cases ready on every iteration; it must
4123+ // never report a timeout.
4124+ func TestFollowupWaitPrefersCompletedAuthOverExpiredContext (t * testing.T ) {
4125+ app := createTestApp (t )
4126+
4127+ machineKey := key .NewMachine ().Public ()
4128+ nodeKey := key .NewNode ().Public ()
4129+
4130+ const iterations = 300
4131+
4132+ timeouts , authorized := 0 , 0
4133+
4134+ for i := range iterations {
4135+ regID , err := types .NewAuthID ()
4136+ require .NoError (t , err )
4137+
4138+ authReq := types .NewRegisterAuthRequest (& types.RegistrationData {
4139+ Hostname : "followup-race-node" ,
4140+ })
4141+ app .state .SetAuthCacheEntry (regID , authReq )
4142+
4143+ // Registration completes BEFORE we wait: verdict is buffered.
4144+ user := app .state .CreateUserForTest (fmt .Sprintf ("followup-race-user-%d" , i ))
4145+ node := app .state .CreateNodeForTest (user , "followup-race-node" )
4146+ authReq .FinishAuth (types.AuthVerdict {Node : node .View ()})
4147+
4148+ // Context is expired BEFORE we wait: both select cases are ready.
4149+ ctx , cancel := context .WithCancel (context .Background ())
4150+ cancel ()
4151+
4152+ req := tailcfg.RegisterRequest {
4153+ Followup : fmt .Sprintf ("http://localhost:8080/register/%s" , regID ),
4154+ NodeKey : nodeKey ,
4155+ }
4156+
4157+ resp , err := app .waitForFollowup (ctx , req , machineKey )
4158+ switch {
4159+ case err != nil :
4160+ timeouts ++
4161+ case resp != nil && resp .MachineAuthorized :
4162+ authorized ++
4163+ }
4164+ }
4165+
4166+ assert .Zero (t , timeouts ,
4167+ "waitForFollowup must never report a timeout when auth has already completed; got %d/%d timeouts" ,
4168+ timeouts , iterations )
4169+ assert .Equal (t , iterations , authorized , "every completed registration must be returned as authorized" )
4170+ }
0 commit comments