Migrated the entire boot sequence to use the runtime API server

This commit is contained in:
Thorsten Sommer 2024-08-25 11:23:41 +02:00
parent fc3ab64787
commit 0c9df7d05c
Signed by: tsommer
GPG Key ID: 371BBA77A02C0108
4 changed files with 232 additions and 68 deletions

View File

@ -11,7 +11,15 @@ using System.Reflection;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
#endif #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(); var builder = WebApplication.CreateBuilder();
builder.Services.AddMudServices(config => builder.Services.AddMudServices(config =>
{ {
@ -27,7 +35,7 @@ builder.Services.AddMudServices(config =>
builder.Services.AddMudMarkdownServices(); builder.Services.AddMudMarkdownServices();
builder.Services.AddSingleton(MessageBus.INSTANCE); builder.Services.AddSingleton(MessageBus.INSTANCE);
builder.Services.AddSingleton<Rust>(); builder.Services.AddSingleton(rust);
builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>(); builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>();
builder.Services.AddSingleton<SettingsManager>(); builder.Services.AddSingleton<SettingsManager>();
builder.Services.AddSingleton<ThreadSafeRandom>(); builder.Services.AddSingleton<ThreadSafeRandom>();
@ -46,10 +54,10 @@ builder.Services.AddRazorComponents()
builder.Services.AddSingleton(new HttpClient 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 #if DEBUG
builder.WebHost.UseWebRoot("wwwroot"); builder.WebHost.UseWebRoot("wwwroot");
@ -79,5 +87,5 @@ app.MapRazorComponents<App>()
var serverTask = app.RunAsync(); var serverTask = app.RunAsync();
Console.WriteLine("RUST/TAURI SERVER STARTED"); await rust.AppIsReady();
await serverTask; await serverTask;

View File

@ -3,8 +3,65 @@ namespace AIStudio.Tools;
/// <summary> /// <summary>
/// Calling Rust functions. /// Calling Rust functions.
/// </summary> /// </summary>
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<int> 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}'");
}
}
/// <summary> /// <summary>
/// Tries to copy the given text to the clipboard. /// Tries to copy the given text to the clipboard.
/// </summary> /// </summary>
@ -51,4 +108,13 @@ public sealed class Rust
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
await jsRuntime.InvokeVoidAsync("window.__TAURI__.invoke", cts.Token, "install_update"); await jsRuntime.InvokeVoidAsync("window.__TAURI__.invoke", cts.Token, "install_update");
} }
#region IDisposable
public void Dispose()
{
this.http.Dispose();
}
#endregion
} }

View File

@ -1,8 +1,10 @@
// Prevents an additional console window on Windows in release, DO NOT REMOVE!! // Prevents an additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
extern crate rocket;
extern crate core; extern crate core;
use std::collections::HashSet;
use std::net::TcpListener; use std::net::TcpListener;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
@ -10,20 +12,50 @@ use once_cell::sync::Lazy;
use arboard::Clipboard; use arboard::Clipboard;
use keyring::Entry; use keyring::Entry;
use serde::Serialize; use serde::Serialize;
use tauri::{Manager, Url, Window, WindowUrl}; use tauri::{Manager, Url, Window};
use tauri::api::process::{Command, CommandChild, CommandEvent}; use tauri::api::process::{Command, CommandChild, CommandEvent};
use tauri::utils::config::AppUrl;
use tokio::time; use tokio::time;
use flexi_logger::{AdaptiveFormat, Logger}; use flexi_logger::{AdaptiveFormat, Logger};
use keyring::error::Error::NoEntry; use keyring::error::Error::NoEntry;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rocket::figment::Figment;
use rocket::{get, post, routes};
use rocket::config::Shutdown;
use tauri::updater::UpdateResponse; use tauri::updater::UpdateResponse;
static SERVER: Lazy<Arc<Mutex<Option<CommandChild>>>> = 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<Arc<Mutex<Option<CommandChild>>>> = 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<u16> = 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<Arc<Mutex<Option<rocket::Rocket<rocket::Ignite>>>>> = 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<u16> = Lazy::new(|| {
if is_dev() {
5000
} else {
get_available_port().unwrap()
}
});
// The Tauri main window.
static MAIN_WINDOW: Lazy<Mutex<Option<Window>>> = Lazy::new(|| Mutex::new(None)); static MAIN_WINDOW: Lazy<Mutex<Option<Window>>> = Lazy::new(|| Mutex::new(None));
// The update response coming from the Tauri updater.
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None)); static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
fn main() { #[tokio::main]
async fn main() {
let metadata = include_str!("../../metadata.txt"); let metadata = include_str!("../../metadata.txt");
let mut metadata_lines = metadata.lines(); let mut metadata_lines = metadata.lines();
@ -64,45 +96,76 @@ fn main() {
info!("Running in production mode."); info!("Running in production mode.");
} }
let port = match is_dev() { let api_port = *API_SERVER_PORT;
true => 5000, info!("Try to start the API server on 'http://localhost:{api_port}'...");
false => get_available_port().unwrap(), let figment = Figment::from(rocket::Config::release_default())
};
let url = match Url::parse(format!("http://localhost:{port}").as_str()) // We use the next available port which was determined before:
{ .merge(("port", api_port))
Ok(url) => url,
Err(msg) => {
error!("Error while parsing URL: {msg}");
return;
}
};
let app_url = AppUrl::Url(WindowUrl::External(url.clone())); // The runtime API server should be accessible only from the local machine:
let app_url_log = app_url.clone(); .merge(("address", "127.0.0.1"))
info!("Try to start the .NET server on {app_url_log}...");
// 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: // 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: // Channel to communicate with the server process:
let (sender, mut receiver) = tauri::async_runtime::channel(100); let (sender, mut receiver) = tauri::async_runtime::channel(100);
if is_prod() { if is_prod() {
info!("Try to start the .NET server...");
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let api_port = *API_SERVER_PORT;
let (mut rx, child) = Command::new_sidecar("mindworkAIStudioServer") let (mut rx, child) = Command::new_sidecar("mindworkAIStudioServer")
.expect("Failed to create sidecar") .expect("Failed to create sidecar")
.args([format!("{port}").as_str()]) .args([format!("{api_port}").as_str()])
.spawn() .spawn()
.expect("Failed to spawn .NET server process."); .expect("Failed to spawn .NET server process.");
let server_pid = child.pid(); 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: // Save the server process to stop it later:
*server_spawn_clone.lock().unwrap() = Some(child); *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 { while let Some(CommandEvent::Stdout(line)) = rx.recv().await {
let line_lower = line.to_lowercase(); let line_lower = line.to_lowercase();
let line_cleared = line_lower.trim(); let line_cleared = line_lower.trim();
@ -127,8 +190,8 @@ fn main() {
warn!("Running in development mode, no .NET server will be started."); warn!("Running in development mode, no .NET server will be started.");
} }
let main_window_spawn_clone = &MAIN_WINDOW; // TODO: Migrate logging to runtime API server:
let server_receive_clone = SERVER.clone(); let server_receive_clone = DOTNET_SERVER.clone();
// Create a thread to handle server events: // Create a thread to handle server events:
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
@ -136,35 +199,7 @@ fn main() {
loop { loop {
match receiver.recv().await { match receiver.recv().await {
Some(ServerEvent::Started) => { Some(ServerEvent::Started) => {
info!("The .NET server was booted successfully."); info!("The .NET server was started.");
// 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}."),
}
}, },
Some(ServerEvent::NotFound(line)) => { Some(ServerEvent::NotFound(line)) => {
@ -246,7 +281,7 @@ fn main() {
tauri::UpdaterEvent::Downloaded => { tauri::UpdaterEvent::Downloaded => {
info!("Updater: update has been downloaded!"); info!("Updater: update has been downloaded!");
warn!("Try to stop the .NET server now..."); warn!("Try to stop the .NET server now...");
stop_server(); stop_servers();
} }
tauri::UpdaterEvent::Updated => { tauri::UpdaterEvent::Updated => {
@ -278,8 +313,57 @@ fn main() {
info!("Tauri app was stopped."); info!("Tauri app was stopped.");
if is_prod() { if is_prod() {
info!("Try to stop the .NET server as well..."); info!("Try to stop the .NET & runtime API servers as well...");
stop_server(); 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<u16> {
.ok() .ok()
} }
fn stop_server() { fn stop_servers() {
if let Some(server_process) = SERVER.lock().unwrap().take() { if let Some(server_process) = DOTNET_SERVER.lock().unwrap().take() {
let server_kill_result = server_process.kill(); let server_kill_result = server_process.kill();
match server_kill_result { match server_kill_result {
Ok(_) => info!("The .NET server process was stopped."), Ok(_) => info!("The .NET server process was stopped."),
Err(e) => error!("Failed to stop the .NET server process: {e}."), Err(e) => error!("Failed to stop the .NET server process: {e}."),
} }
} else { } 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.");
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"build": { "build": {
"devPath": "http://localhost:5000", "devPath": "ui/",
"distDir": "ui/", "distDir": "ui/",
"withGlobalTauri": true "withGlobalTauri": true
}, },