From 0c9df7d05c10b995f8472c84b55c7c3a44cc3c17 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 25 Aug 2024 11:23:41 +0200 Subject: [PATCH] Migrated the entire boot sequence to use the runtime API server --- app/MindWork AI Studio/Program.cs | 18 ++- app/MindWork AI Studio/Tools/Rust.cs | 68 ++++++++- runtime/src/main.rs | 212 +++++++++++++++++++-------- runtime/tauri.conf.json | 2 +- 4 files changed, 232 insertions(+), 68 deletions(-) diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 8e8441bc..8a7638fd 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -11,7 +11,15 @@ using System.Reflection; using Microsoft.Extensions.FileProviders; #endif -var port = args.Length > 0 ? args[0] : "5000"; +var rustApiPort = args.Length > 0 ? args[0] : "5000"; +using var rust = new Rust(rustApiPort); +var appPort = await rust.GetAppPort(); +if(appPort == 0) +{ + Console.WriteLine("Failed to get the app port from Rust."); + return; +} + var builder = WebApplication.CreateBuilder(); builder.Services.AddMudServices(config => { @@ -27,7 +35,7 @@ builder.Services.AddMudServices(config => builder.Services.AddMudMarkdownServices(); builder.Services.AddSingleton(MessageBus.INSTANCE); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(rust); builder.Services.AddMudMarkdownClipboardService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -46,10 +54,10 @@ builder.Services.AddRazorComponents() builder.Services.AddSingleton(new HttpClient { - BaseAddress = new Uri($"http://localhost:{port}") + BaseAddress = new Uri($"http://localhost:{appPort}") }); -builder.WebHost.UseUrls($"http://localhost:{port}"); +builder.WebHost.UseUrls($"http://localhost:{appPort}"); #if DEBUG builder.WebHost.UseWebRoot("wwwroot"); @@ -79,5 +87,5 @@ app.MapRazorComponents() var serverTask = app.RunAsync(); -Console.WriteLine("RUST/TAURI SERVER STARTED"); +await rust.AppIsReady(); await serverTask; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust.cs b/app/MindWork AI Studio/Tools/Rust.cs index af85765d..5bcdb5d4 100644 --- a/app/MindWork AI Studio/Tools/Rust.cs +++ b/app/MindWork AI Studio/Tools/Rust.cs @@ -3,8 +3,65 @@ namespace AIStudio.Tools; /// /// Calling Rust functions. /// -public sealed class Rust +public sealed class Rust(string apiPort) : IDisposable { + private readonly HttpClient http = new() + { + BaseAddress = new Uri($"http://127.0.0.1:{apiPort}"), + }; + + public async Task GetAppPort() + { + Console.WriteLine("Trying to get app port from Rust runtime..."); + + // + // Note I: In the production environment, the Rust runtime is already running + // and listening on the given port. In the development environment, the IDE + // starts the Rust runtime in parallel with the .NET runtime. Since the + // Rust runtime needs some time to start, we have to wait for it to be ready. + // + const int MAX_TRIES = 160; + var tris = 0; + var wait4Try = TimeSpan.FromMilliseconds(250); + var url = new Uri($"http://127.0.0.1:{apiPort}/system/dotnet/port"); + while (tris++ < MAX_TRIES) + { + // + // Note II: We use a new HttpClient instance for each try to avoid + // .NET is caching the result. When we use the same HttpClient + // instance, we would always get the same result (403 forbidden), + // without even trying to connect to the Rust server. + // + using var initialHttp = new HttpClient(); + var response = await initialHttp.GetAsync(url); + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($" Try {tris}/{MAX_TRIES}"); + await Task.Delay(wait4Try); + continue; + } + + var appPortContent = await response.Content.ReadAsStringAsync(); + var appPort = int.Parse(appPortContent); + Console.WriteLine($" Received app port from Rust runtime: '{appPort}'"); + return appPort; + } + + Console.WriteLine("Failed to receive the app port from Rust runtime."); + return 0; + } + + public async Task AppIsReady() + { + const string URL = "/system/dotnet/ready"; + Console.WriteLine($"Notifying Rust runtime that the app is ready."); + var response = await this.http.PostAsync(URL, new StringContent(string.Empty)); + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"Failed to notify Rust runtime that the app is ready: '{response.StatusCode}'"); + } + } + /// /// Tries to copy the given text to the clipboard. /// @@ -51,4 +108,13 @@ public sealed class Rust var cts = new CancellationTokenSource(); await jsRuntime.InvokeVoidAsync("window.__TAURI__.invoke", cts.Token, "install_update"); } + + #region IDisposable + + public void Dispose() + { + this.http.Dispose(); + } + + #endregion } \ No newline at end of file diff --git a/runtime/src/main.rs b/runtime/src/main.rs index d2f5c9b3..d8a500b0 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -1,8 +1,10 @@ // Prevents an additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +extern crate rocket; extern crate core; +use std::collections::HashSet; use std::net::TcpListener; use std::sync::{Arc, Mutex}; use once_cell::sync::Lazy; @@ -10,20 +12,50 @@ use once_cell::sync::Lazy; use arboard::Clipboard; use keyring::Entry; use serde::Serialize; -use tauri::{Manager, Url, Window, WindowUrl}; +use tauri::{Manager, Url, Window}; use tauri::api::process::{Command, CommandChild, CommandEvent}; -use tauri::utils::config::AppUrl; use tokio::time; use flexi_logger::{AdaptiveFormat, Logger}; use keyring::error::Error::NoEntry; use log::{debug, error, info, warn}; +use rocket::figment::Figment; +use rocket::{get, post, routes}; +use rocket::config::Shutdown; use tauri::updater::UpdateResponse; -static SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); +// The .NET server is started in a separate process and communicates with this +// runtime process via IPC. However, we do net start the .NET server in +// the development environment. +static DOTNET_SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); + +// The .NET server port is relevant for the production environment only, sine we +// do not start the server in the development environment. +static DOTNET_SERVER_PORT: Lazy = Lazy::new(|| get_available_port().unwrap()); + +// Our runtime API server. We need it as a static variable because we need to +// shut it down when the app is closed. +static API_SERVER: Lazy>>>> = Lazy::new(|| Arc::new(Mutex::new(None))); + +// The port used for the runtime API server. In the development environment, we use a fixed +// port, in the production environment we use the next available port. This differentiation +// is necessary because we cannot communicate the port to the .NET server in the development +// environment. +static API_SERVER_PORT: Lazy = Lazy::new(|| { + if is_dev() { + 5000 + } else { + get_available_port().unwrap() + } +}); + +// The Tauri main window. static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); + +// The update response coming from the Tauri updater. static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); -fn main() { +#[tokio::main] +async fn main() { let metadata = include_str!("../../metadata.txt"); let mut metadata_lines = metadata.lines(); @@ -64,45 +96,76 @@ fn main() { info!("Running in production mode."); } - let port = match is_dev() { - true => 5000, - false => get_available_port().unwrap(), - }; + let api_port = *API_SERVER_PORT; + info!("Try to start the API server on 'http://localhost:{api_port}'..."); + let figment = Figment::from(rocket::Config::release_default()) - let url = match Url::parse(format!("http://localhost:{port}").as_str()) - { - Ok(url) => url, - Err(msg) => { - error!("Error while parsing URL: {msg}"); - return; - } - }; + // We use the next available port which was determined before: + .merge(("port", api_port)) - let app_url = AppUrl::Url(WindowUrl::External(url.clone())); - let app_url_log = app_url.clone(); - info!("Try to start the .NET server on {app_url_log}..."); + // The runtime API server should be accessible only from the local machine: + .merge(("address", "127.0.0.1")) + + // We do not want to use the Ctrl+C signal to stop the server: + .merge(("ctrlc", false)) + + // Set a name for the server: + .merge(("ident", "AI Studio Runtime API")) + + // Set the maximum number of workers and blocking threads: + .merge(("workers", 3)) + .merge(("max_blocking", 12)) + + // Set the shutdown configuration: + .merge(("shutdown", Shutdown { + + // Again, we do not want to use the Ctrl+C signal to stop the server: + ctrlc: false, + + // We do not want to use the termination signal to stop the server: + signals: HashSet::new(), + + // Everything else is set to default: + ..Shutdown::default() + })); + + // Start the runtime API server in a separate thread. This is necessary + // because the server is blocking, and we need to run the Tauri app in + // parallel: + let api_server_spawn_clone = API_SERVER.clone(); + tauri::async_runtime::spawn(async move { + let api_server = rocket::custom(figment) + .mount("/", routes![dotnet_port, dotnet_ready]) + .ignite().await.unwrap() + .launch().await.unwrap(); + + // We need to save the server to shut it down later: + *api_server_spawn_clone.lock().unwrap() = Some(api_server); + }); // Arc for the server process to stop it later: - let server_spawn_clone = SERVER.clone(); + let server_spawn_clone = DOTNET_SERVER.clone(); // Channel to communicate with the server process: let (sender, mut receiver) = tauri::async_runtime::channel(100); if is_prod() { + info!("Try to start the .NET server..."); tauri::async_runtime::spawn(async move { + let api_port = *API_SERVER_PORT; let (mut rx, child) = Command::new_sidecar("mindworkAIStudioServer") .expect("Failed to create sidecar") - .args([format!("{port}").as_str()]) + .args([format!("{api_port}").as_str()]) .spawn() .expect("Failed to spawn .NET server process."); let server_pid = child.pid(); - debug!(".NET server process started with PID={server_pid}."); + info!(".NET server process started with PID={server_pid}."); // Save the server process to stop it later: *server_spawn_clone.lock().unwrap() = Some(child); - - info!("Waiting for .NET server to boot..."); + + // TODO: Migrate to runtime API server: while let Some(CommandEvent::Stdout(line)) = rx.recv().await { let line_lower = line.to_lowercase(); let line_cleared = line_lower.trim(); @@ -127,8 +190,8 @@ fn main() { warn!("Running in development mode, no .NET server will be started."); } - let main_window_spawn_clone = &MAIN_WINDOW; - let server_receive_clone = SERVER.clone(); + // TODO: Migrate logging to runtime API server: + let server_receive_clone = DOTNET_SERVER.clone(); // Create a thread to handle server events: tauri::async_runtime::spawn(async move { @@ -136,35 +199,7 @@ fn main() { loop { match receiver.recv().await { Some(ServerEvent::Started) => { - info!("The .NET server was booted successfully."); - - // Try to get the main window. If it is not available yet, wait for it: - let mut main_window_ready = false; - let mut main_window_status_reported = false; - while !main_window_ready - { - main_window_ready = { - let main_window = main_window_spawn_clone.lock().unwrap(); - main_window.is_some() - }; - - if !main_window_ready { - if !main_window_status_reported { - info!("Waiting for main window to be ready, because .NET was faster than Tauri."); - main_window_status_reported = true; - } - - time::sleep(time::Duration::from_millis(100)).await; - } - } - - let main_window = main_window_spawn_clone.lock().unwrap(); - let js_location_change = format!("window.location = '{url}';"); - let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str()); - match location_change_result { - Ok(_) => info!("Location was changed to {url}."), - Err(e) => error!("Failed to change location to {url}: {e}."), - } + info!("The .NET server was started."); }, Some(ServerEvent::NotFound(line)) => { @@ -246,7 +281,7 @@ fn main() { tauri::UpdaterEvent::Downloaded => { info!("Updater: update has been downloaded!"); warn!("Try to stop the .NET server now..."); - stop_server(); + stop_servers(); } tauri::UpdaterEvent::Updated => { @@ -278,8 +313,57 @@ fn main() { info!("Tauri app was stopped."); if is_prod() { - info!("Try to stop the .NET server as well..."); - stop_server(); + info!("Try to stop the .NET & runtime API servers as well..."); + stop_servers(); + } +} + +#[get("/system/dotnet/port")] +fn dotnet_port() -> String { + let dotnet_server_port = *DOTNET_SERVER_PORT; + format!("{dotnet_server_port}") +} + +#[post("/system/dotnet/ready")] +async fn dotnet_ready() { + let main_window_spawn_clone = &MAIN_WINDOW; + let dotnet_server_port = *DOTNET_SERVER_PORT; + let url = match Url::parse(format!("http://localhost:{dotnet_server_port}").as_str()) + { + Ok(url) => url, + Err(msg) => { + error!("Error while parsing URL: {msg}"); + return; + } + }; + info!("The .NET server was booted successfully."); + + // Try to get the main window. If it is not available yet, wait for it: + let mut main_window_ready = false; + let mut main_window_status_reported = false; + while !main_window_ready + { + main_window_ready = { + let main_window = main_window_spawn_clone.lock().unwrap(); + main_window.is_some() + }; + + if !main_window_ready { + if !main_window_status_reported { + info!("Waiting for main window to be ready, because .NET was faster than Tauri."); + main_window_status_reported = true; + } + + time::sleep(time::Duration::from_millis(100)).await; + } + } + + let main_window = main_window_spawn_clone.lock().unwrap(); + let js_location_change = format!("window.location = '{url}';"); + let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str()); + match location_change_result { + Ok(_) => info!("Location was changed to {url}."), + Err(e) => error!("Failed to change location to {url}: {e}."), } } @@ -306,15 +390,21 @@ fn get_available_port() -> Option { .ok() } -fn stop_server() { - if let Some(server_process) = SERVER.lock().unwrap().take() { +fn stop_servers() { + if let Some(server_process) = DOTNET_SERVER.lock().unwrap().take() { let server_kill_result = server_process.kill(); match server_kill_result { Ok(_) => info!("The .NET server process was stopped."), Err(e) => error!("Failed to stop the .NET server process: {e}."), } } else { - warn!("The .NET server process was not started or already stopped."); + warn!("The .NET server process was not started or is already stopped."); + } + + if let Some(api_server) = API_SERVER.lock().unwrap().take() { + _ = api_server.shutdown(); + } else { + warn!("The API server was not started or is already stopped."); } } diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index aab42db5..9f5bed7a 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -1,6 +1,6 @@ { "build": { - "devPath": "http://localhost:5000", + "devPath": "ui/", "distDir": "ui/", "withGlobalTauri": true },