ServerHandshake.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. #if ENABLE_MONO && (DEVELOPMENT_BUILD || UNITY_EDITOR)
  2. using System;
  3. using System.Threading;
  4. using System.Threading.Tasks;
  5. using UnityEngine;
  6. namespace SingularityGroup.HotReload {
  7. internal class ServerHandshake {
  8. public static readonly ServerHandshake I = new ServerHandshake();
  9. /// <summary>
  10. /// Not verified as compatible yet - need to do handshake
  11. /// </summary>
  12. private PatchServerInfo pendingServer;
  13. /// <summary>
  14. /// Handshake is complete. Player can connect to this server.
  15. /// </summary>
  16. private PatchServerInfo verifiedServer;
  17. private Task handshakeCheck;
  18. private CancellationTokenSource cts = new CancellationTokenSource();
  19. /// Track first handshake request after calling SetServerInfo.
  20. /// Sometimes and it can take 10-30 seconds and succeed.
  21. private TaskCompletionSource<Result> firstHandshake = new TaskCompletionSource<Result>();
  22. /// <remarks>Server info should be well known or a strong guess, not just a random ip address.</remarks>
  23. public Task<Result> SetServerInfo(PatchServerInfo serverInfo) {
  24. if (verifiedServer != null && serverInfo == verifiedServer) {
  25. return Task.FromResult(Result.Verified);
  26. }
  27. pendingServer = serverInfo;
  28. if (serverInfo != null) {
  29. Prompts.SetConnectionState(ConnectionSummary.Handshaking);
  30. }
  31. // disconnect
  32. verifiedServer = null;
  33. // cancel any ongoing RequestHandshake task
  34. firstHandshake.TrySetCanceled(cts.Token);
  35. firstHandshake = new TaskCompletionSource<Result>();
  36. cts.Cancel();
  37. cts = new CancellationTokenSource();
  38. if (serverInfo == null) return Task.FromResult(Result.None);
  39. return firstHandshake.Task;
  40. }
  41. /// Ensures a handshake request is running.
  42. public void CheckHandshake() {
  43. var serverToCheck = pendingServer;
  44. if (verifiedServer == null && serverToCheck != null) {
  45. if (handshakeCheck == null || handshakeCheck.IsCompleted) {
  46. handshakeCheck = Task.Run(async () => {
  47. try {
  48. Log.Debug("Run RequestHandshake");
  49. var results = await RequestHandshake(serverToCheck);
  50. await ThreadUtility.SwitchToMainThread();
  51. var decisionIsFinal = await VerifyResults(results, serverToCheck);
  52. firstHandshake.TrySetResult(results); // VerifyResults() can also set it, this is the default fallback
  53. if (decisionIsFinal) {
  54. pendingServer = null;
  55. }
  56. } catch (Exception ex) {
  57. Log.Exception(ex);
  58. } finally {
  59. // set as failed if wasnt set as true by above code
  60. firstHandshake.TrySetResult(Result.None);
  61. }
  62. }, cts.Token);
  63. }
  64. }
  65. }
  66. /// <summary>
  67. /// Verify results of the handshake.
  68. /// </summary>
  69. /// <param name="results"></param>
  70. /// <param name="server"></param>
  71. /// <returns>True if the conclusion is final, otherwise false</returns>
  72. /// <remarks>
  73. /// Must be called on main thread because it uses Unity UI methods.
  74. /// </remarks>
  75. async Task<bool> VerifyResults(Result results, PatchServerInfo server) {
  76. if (results.HasFlag(Result.QuietWarning)) {
  77. // can handle here if needed later
  78. }
  79. if (results.HasFlag(Result.Verified)) {
  80. if (!firstHandshake.Task.IsCompleted) {
  81. Prompts.SetConnectionState(ConnectionSummary.Connecting);
  82. }
  83. OnVerified(server);
  84. return true;
  85. }
  86. // handle objections in order of obviousness, most obvious goes first
  87. if (results.HasFlag(Result.DifferentProject)) {
  88. await Prompts.ShowQuestionDialog(new QuestionDialog.Config {
  89. summary = "Hot Reload was started from a different project",
  90. suggestion = "Please run Hot Reload from the matching Unity project",
  91. continueButtonText = "OK",
  92. cancelButtonText = null,
  93. });
  94. // they need to provide a new server info
  95. Prompts.SetConnectionState(ConnectionSummary.Cancelled);
  96. return true;
  97. }
  98. if (results.HasFlag(Result.DifferentCommit)) {
  99. Prompts.SetConnectionState(ConnectionSummary.DifferencesFound);
  100. bool yes = await Prompts.ShowQuestionDialog(new QuestionDialog.Config {
  101. summary = "Editor and current build are on different commits",
  102. suggestion = "This can cause errors when the build was made on an old commit.",
  103. continueButtonText = "Connect",
  104. });
  105. if (yes) {
  106. results |= Result.Verified;
  107. Prompts.SetConnectionState(ConnectionSummary.Connecting);
  108. firstHandshake.TrySetResult(results);
  109. OnVerified(server);
  110. } else {
  111. Prompts.SetConnectionState(ConnectionSummary.Cancelled);
  112. }
  113. // cancel -> tell them to provide a new server
  114. return true;
  115. }
  116. if (results.HasFlag(Result.TempError)) {
  117. // retry might work, its not over yet
  118. return false;
  119. }
  120. // at time of writing, code should never reach here. Adding new HandshakeResult flags should be handled above.
  121. Log.Debug("UNEXPECTED: VerifyResults continued into untested code: {0}", results);
  122. return true;
  123. }
  124. void OnVerified(PatchServerInfo serverToCheck) {
  125. verifiedServer = serverToCheck;
  126. }
  127. public bool TryGetVerifiedServer(out PatchServerInfo serverInfo) {
  128. // take verifiedServer
  129. var server = Interlocked.Exchange(ref verifiedServer, null);
  130. serverInfo = server;
  131. return serverInfo != null;
  132. }
  133. /// <summary>
  134. /// Result of a handshake with the remote Hot Reload instance.
  135. /// </summary>
  136. [Flags]
  137. public enum Result {
  138. None = 0,
  139. DifferentCommit = 1 << 0,
  140. DifferentProject = 1 << 1,
  141. /// <summary>
  142. /// A temporary error occurred, retrying might work.
  143. /// </summary>
  144. TempError = 1 << 2,
  145. /// <summary>
  146. /// Hot Reload is compiling, so we should wait a bit before trying again.
  147. /// </summary>
  148. WaitForCompiling = 1 << 3,
  149. [Obsolete("Not needed so far", true)]
  150. Placeholder2 = 1 << 4,
  151. // use when a warning is logged, but we're allowing Hot Reload to connect bcus it probably works.
  152. QuietWarning = 1 << 5,
  153. Verified = 1 << 6,
  154. }
  155. static async Task<Result> RequestHandshake(PatchServerInfo info) {
  156. var buildInfo = PlayerEntrypoint.PlayerBuildInfo;
  157. var results = Result.None;
  158. var verified = true;
  159. Log.Debug($"Comparing commits {buildInfo.commitHash} and {info.commitHash}");
  160. if (buildInfo.IsDifferentCommit(info.commitHash)) {
  161. results |= Result.DifferentCommit;
  162. verified = false;
  163. }
  164. // Check for health before sending handshake request
  165. // If health check fails UI updates faster
  166. var healthy = await ServerHealthCheck.CheckHealthAsync(info);
  167. if (!healthy) {
  168. Log.Debug("Won't send handshake request because server is not healhy");
  169. return results;
  170. }
  171. Log.Info("Request handshake to Hot Reload server with hostname: {0}", info.hostName);
  172. //Log.Debug("Handshake with projectOmissionRegex: \"{0}\"", buildInfo.projectOmissionRegex);
  173. var response = await RequestHelper.RequestHandshake(info, buildInfo.DefineSymbolsAsHashSet,
  174. buildInfo.projectOmissionRegex);
  175. if (response.error != null) {
  176. verified = false;
  177. Log.Debug($"RequestHandshake errored: {response.error}");
  178. if (response.error == Result.WaitForCompiling.ToString()) {
  179. // WaitForCompiling is a temp error
  180. results |= Result.WaitForCompiling;
  181. results |= Result.TempError;
  182. } else {
  183. results |= Result.TempError;
  184. }
  185. }
  186. if (response.data == null) {
  187. // need response data to continue
  188. verified = false;
  189. return results;
  190. }
  191. // handshake response is what we post to /files which is BuildInfo
  192. var remoteBuildTarget = response.data[nameof(BuildInfo.activeBuildTarget)] as string;
  193. var remoteCommitHash = response.data[nameof(BuildInfo.commitHash)] as string;
  194. var remoteProjectIdentifier = response.data[nameof(BuildInfo.projectIdentifier)] as string;
  195. if (buildInfo.IsDifferentCommit(remoteCommitHash)) {
  196. Log.Debug($"RequestHandshake server is on different commit {response.error}");
  197. results |= Result.DifferentCommit;
  198. verified = false;
  199. }
  200. if (remoteProjectIdentifier != buildInfo.projectIdentifier) {
  201. Log.Debug("RequestHandshake remote is using a different project identifier");
  202. results |= Result.DifferentProject;
  203. verified = false;
  204. }
  205. if (remoteBuildTarget == null) {
  206. // Should never happen. Server responsed with an error when no BuildInfo at all.
  207. Log.Warning("Server did not declare its current Unity activeBuildTarget in the handshake response. Will assume it is {0}.", buildInfo.activeBuildTarget);
  208. results |= Result.QuietWarning;
  209. } else if (remoteBuildTarget != buildInfo.activeBuildTarget) {
  210. 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);
  211. results |= Result.QuietWarning;
  212. }
  213. if (verified) {
  214. results |= Result.Verified;
  215. }
  216. return results;
  217. }
  218. }
  219. }
  220. #endif