2025-07-08 10:46:31 +00:00
#if ENABLE_MONO & & ( DEVELOPMENT_BUILD | | UNITY_EDITOR )
using System ;
using System.Threading ;
using System.Threading.Tasks ;
using UnityEngine ;
namespace SingularityGroup.HotReload {
internal class ServerHandshake {
public static readonly ServerHandshake I = new ServerHandshake ( ) ;
/// <summary>
/// Not verified as compatible yet - need to do handshake
/// </summary>
private PatchServerInfo pendingServer ;
/// <summary>
/// Handshake is complete. Player can connect to this server.
/// </summary>
private PatchServerInfo verifiedServer ;
private Task handshakeCheck ;
private CancellationTokenSource cts = new CancellationTokenSource ( ) ;
/// Track first handshake request after calling SetServerInfo.
/// Sometimes and it can take 10-30 seconds and succeed.
private TaskCompletionSource < Result > firstHandshake = new TaskCompletionSource < Result > ( ) ;
/// <remarks>Server info should be well known or a strong guess, not just a random ip address.</remarks>
public Task < Result > SetServerInfo ( PatchServerInfo serverInfo ) {
if ( verifiedServer ! = null & & serverInfo = = verifiedServer ) {
return Task . FromResult ( Result . Verified ) ;
}
pendingServer = serverInfo ;
if ( serverInfo ! = null ) {
Prompts . SetConnectionState ( ConnectionSummary . Handshaking ) ;
}
// disconnect
verifiedServer = null ;
// cancel any ongoing RequestHandshake task
firstHandshake . TrySetCanceled ( cts . Token ) ;
firstHandshake = new TaskCompletionSource < Result > ( ) ;
cts . Cancel ( ) ;
cts = new CancellationTokenSource ( ) ;
if ( serverInfo = = null ) return Task . FromResult ( Result . None ) ;
return firstHandshake . Task ;
}
/// Ensures a handshake request is running.
public void CheckHandshake ( ) {
var serverToCheck = pendingServer ;
if ( verifiedServer = = null & & serverToCheck ! = null ) {
if ( handshakeCheck = = null | | handshakeCheck . IsCompleted ) {
handshakeCheck = Task . Run ( async ( ) = > {
try {
Log . Debug ( "Run RequestHandshake" ) ;
var results = await RequestHandshake ( serverToCheck ) ;
await ThreadUtility . SwitchToMainThread ( ) ;
var decisionIsFinal = await VerifyResults ( results , serverToCheck ) ;
firstHandshake . TrySetResult ( results ) ; // VerifyResults() can also set it, this is the default fallback
if ( decisionIsFinal ) {
pendingServer = null ;
}
} catch ( Exception ex ) {
Log . Exception ( ex ) ;
} finally {
// set as failed if wasnt set as true by above code
firstHandshake . TrySetResult ( Result . None ) ;
}
} , cts . Token ) ;
}
}
}
/// <summary>
/// Verify results of the handshake.
/// </summary>
/// <param name="results"></param>
/// <param name="server"></param>
/// <returns>True if the conclusion is final, otherwise false</returns>
/// <remarks>
/// Must be called on main thread because it uses Unity UI methods.
/// </remarks>
async Task < bool > VerifyResults ( Result results , PatchServerInfo server ) {
if ( results . HasFlag ( Result . QuietWarning ) ) {
// can handle here if needed later
}
if ( results . HasFlag ( Result . Verified ) ) {
if ( ! firstHandshake . Task . IsCompleted ) {
Prompts . SetConnectionState ( ConnectionSummary . Connecting ) ;
}
OnVerified ( server ) ;
return true ;
}
// handle objections in order of obviousness, most obvious goes first
if ( results . HasFlag ( Result . DifferentProject ) ) {
await Prompts . ShowQuestionDialog ( new QuestionDialog . Config {
summary = "Hot Reload was started from a different project" ,
suggestion = "Please run Hot Reload from the matching Unity project" ,
continueButtonText = "OK" ,
cancelButtonText = null ,
} ) ;
// they need to provide a new server info
Prompts . SetConnectionState ( ConnectionSummary . Cancelled ) ;
return true ;
}
if ( results . HasFlag ( Result . DifferentCommit ) ) {
Prompts . SetConnectionState ( ConnectionSummary . DifferencesFound ) ;
bool yes = await Prompts . ShowQuestionDialog ( new QuestionDialog . Config {
summary = "Editor and current build are on different commits" ,
suggestion = "This can cause errors when the build was made on an old commit." ,
continueButtonText = "Connect" ,
} ) ;
if ( yes ) {
results | = Result . Verified ;
Prompts . SetConnectionState ( ConnectionSummary . Connecting ) ;
firstHandshake . TrySetResult ( results ) ;
OnVerified ( server ) ;
} else {
Prompts . SetConnectionState ( ConnectionSummary . Cancelled ) ;
}
// cancel -> tell them to provide a new server
return true ;
}
if ( results . HasFlag ( Result . TempError ) ) {
// retry might work, its not over yet
return false ;
}
// at time of writing, code should never reach here. Adding new HandshakeResult flags should be handled above.
Log . Debug ( "UNEXPECTED: VerifyResults continued into untested code: {0}" , results ) ;
return true ;
}
void OnVerified ( PatchServerInfo serverToCheck ) {
verifiedServer = serverToCheck ;
}
public bool TryGetVerifiedServer ( out PatchServerInfo serverInfo ) {
// take verifiedServer
var server = Interlocked . Exchange ( ref verifiedServer , null ) ;
serverInfo = server ;
return serverInfo ! = null ;
}
/// <summary>
/// Result of a handshake with the remote Hot Reload instance.
/// </summary>
[Flags]
public enum Result {
None = 0 ,
DifferentCommit = 1 < < 0 ,
DifferentProject = 1 < < 1 ,
/// <summary>
/// A temporary error occurred, retrying might work.
/// </summary>
TempError = 1 < < 2 ,
/// <summary>
/// Hot Reload is compiling, so we should wait a bit before trying again.
/// </summary>
WaitForCompiling = 1 < < 3 ,
[Obsolete("Not needed so far", true)]
Placeholder2 = 1 < < 4 ,
// use when a warning is logged, but we're allowing Hot Reload to connect bcus it probably works.
QuietWarning = 1 < < 5 ,
Verified = 1 < < 6 ,
}
static async Task < Result > RequestHandshake ( PatchServerInfo info ) {
var buildInfo = PlayerEntrypoint . PlayerBuildInfo ;
var results = Result . None ;
var verified = true ;
Log . Debug ( $"Comparing commits {buildInfo.commitHash} and {info.commitHash}" ) ;
if ( buildInfo . IsDifferentCommit ( info . commitHash ) ) {
results | = Result . DifferentCommit ;
verified = false ;
}
// Check for health before sending handshake request
// If health check fails UI updates faster
var healthy = await ServerHealthCheck . CheckHealthAsync ( info ) ;
if ( ! healthy ) {
Log . Debug ( "Won't send handshake request because server is not healhy" ) ;
return results ;
}
Log . Info ( "Request handshake to Hot Reload server with hostname: {0}" , info . hostName ) ;
//Log.Debug("Handshake with projectOmissionRegex: \"{0}\"", buildInfo.projectOmissionRegex);
var response = await RequestHelper . RequestHandshake ( info , buildInfo . DefineSymbolsAsHashSet ,
buildInfo . projectOmissionRegex ) ;
if ( response . error ! = null ) {
verified = false ;
Log . Debug ( $"RequestHandshake errored: {response.error}" ) ;
if ( response . error = = Result . WaitForCompiling . ToString ( ) ) {
// WaitForCompiling is a temp error
results | = Result . WaitForCompiling ;
results | = Result . TempError ;
} else {
results | = Result . TempError ;
}
}
if ( response . data = = null ) {
// need response data to continue
verified = false ;
return results ;
}
// handshake response is what we post to /files which is BuildInfo
var remoteBuildTarget = response . data [ nameof ( BuildInfo . activeBuildTarget ) ] as string ;
var remoteCommitHash = response . data [ nameof ( BuildInfo . commitHash ) ] as string ;
var remoteProjectIdentifier = response . data [ nameof ( BuildInfo . projectIdentifier ) ] as string ;
if ( buildInfo . IsDifferentCommit ( remoteCommitHash ) ) {
Log . Debug ( $"RequestHandshake server is on different commit {response.error}" ) ;
results | = Result . DifferentCommit ;
verified = false ;
}
if ( remoteProjectIdentifier ! = buildInfo . projectIdentifier ) {
Log . Debug ( "RequestHandshake remote is using a different project identifier" ) ;
results | = Result . DifferentProject ;
verified = false ;
}
if ( remoteBuildTarget = = null ) {
// Should never happen. Server responsed with an error when no BuildInfo at all.
Log . Warning ( "Server did not declare its current Unity activeBuildTarget in the handshake response. Will assume it is {0}." , buildInfo . activeBuildTarget ) ;
results | = Result . QuietWarning ;
} else if ( remoteBuildTarget ! = buildInfo . activeBuildTarget ) {
Log . Warning ( "Your Unity project is running on {0}. You may need to switch it to {1} for Hot Reload to work." , remoteBuildTarget , buildInfo . activeBuildTarget ) ;
results | = Result . QuietWarning ;
}
if ( verified ) {
results | = Result . Verified ;
}
return results ;
}
}
}
#endif