diff --git a/README.md b/README.md index 126ff5a..b2c26ed 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,53 @@ computer.cpp app serve ./reminders.lua --listen 127.0.0.1:8787 POST /mcp ``` +### Multiple Lua App Servers + +The desktop tray can run every configured Lua app at the same time. Each app +gets its own `computer.cpp app serve` process and port, so it can be restarted +or stopped without affecting the others. + +Configure apps in **Settings > Server**. A blank app port is assigned +automatically from `base_port`; a fixed port is reserved for that app and +cannot be reused by another configured app. Automatic assignment checks at +most 100 consecutive ports beginning at `base_port` (and never past 65535). + +```toml +[server] +host = "127.0.0.1" +base_port = 8787 + +[server.apps.notes] +display_name = "Notes" +path = "/absolute/path/to/notes.lua" +port = 8787 + +[server.apps.reminders] +display_name = "Reminders" +path = "/absolute/path/to/reminders.lua" +# No port: choose the first available non-reserved port from 8787. +``` + +The tray menu shows the number of running servers, one-click **Start All +Servers** and **Stop All Servers** actions, and a Start/Stop row for each app. +Servers are not started automatically when ComputerCpp launches. Any healthy +configured servers left by an interrupted tray process are adopted, and +quitting ComputerCpp stops all managed servers. + +Server processes keep independent Lua memory and operation storage, while +top-level app commands share one desktop-control queue. If Notes and Reminders +receive commands at the same time, one command holds exclusive mouse and +keyboard control for its entire workflow and the other waits. The lease is +renewed while a long command runs and released on success or failure. Health, +schema, and operation-status requests do not acquire desktop control. + +With the example above, the MCP endpoints could be: + +```text +http://127.0.0.1:8787/mcp # Notes +http://127.0.0.1:8788/mcp # Reminders +``` + The MCP server turns a Lua app definition into app-level tools such as: ```text diff --git a/include/computer_cpp/AppConfig.h b/include/computer_cpp/AppConfig.h index 357480a..70756c9 100644 --- a/include/computer_cpp/AppConfig.h +++ b/include/computer_cpp/AppConfig.h @@ -3,8 +3,10 @@ #include #include +#include #include #include +#include #include #include @@ -44,6 +46,17 @@ struct ServerConfig { std::map apps; }; +struct ServerPortPlan { + std::map ports; + std::map errors; +}; + +ServerPortPlan PlanServerPorts( + const ServerConfig& server, + const std::set& appNames, + const std::set& occupiedPorts, + const std::function& portAvailable); + struct RecordingConfig { bool enabled = false; int retentionDays = 14; diff --git a/include/computer_cpp/TrayServerState.h b/include/computer_cpp/TrayServerState.h index 91fb12f..2b96c62 100644 --- a/include/computer_cpp/TrayServerState.h +++ b/include/computer_cpp/TrayServerState.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace ComputerCpp { @@ -13,11 +14,16 @@ struct TrayAppServerState { std::string url; std::string appPath; std::string appId; + std::string configName; std::string displayName; std::string startedAt; }; +// Legacy single-server state path. Kept for migration from older releases. std::filesystem::path TrayAppServerStatePath(); +std::filesystem::path TrayAppServerStateDirectory(); +std::filesystem::path TrayAppServerStatePath(const std::string& configName); +std::vector ListTrayAppServerStatePaths(std::string* error = nullptr); bool SaveTrayAppServerState(const TrayAppServerState& state, const std::filesystem::path& path, std::string* error = nullptr); std::optional LoadTrayAppServerState(const std::filesystem::path& path, std::string* error = nullptr); bool RemoveTrayAppServerState(const std::filesystem::path& path, std::string* error = nullptr); diff --git a/src/app/TrayIcon.cpp b/src/app/TrayIcon.cpp index 29f0fa9..7f73ba2 100644 --- a/src/app/TrayIcon.cpp +++ b/src/app/TrayIcon.cpp @@ -25,8 +25,9 @@ #include #include #include +#include +#include #include -#include #include #include #include @@ -49,7 +50,9 @@ #if defined(__unix__) || defined(__APPLE__) #include +#include #include +#include #include #include #include @@ -69,6 +72,7 @@ enum { ID_START_SERVER, ID_STOP_SERVER, ID_SERVER_PROCESS, + ID_SERVER_TIMER, ID_STATE, ID_TEST_SCREENSHOT, ID_TEST_MOUSE, @@ -278,7 +282,8 @@ bool HttpServerRequestOk( const std::string& bearerToken, const std::string& method, const std::string& path, - int expectedStatus + int expectedStatus, + int timeoutMs = 1000 ) { #if defined(__unix__) || defined(__APPLE__) if (bearerToken.empty() || state.port <= 0 || state.port > 65535) { @@ -289,8 +294,10 @@ bool HttpServerRequestOk( return false; } + timeoutMs = std::max(1, timeoutMs); timeval timeout {}; - timeout.tv_sec = 1; + timeout.tv_sec = timeoutMs / 1000; + timeout.tv_usec = (timeoutMs % 1000) * 1000; ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); @@ -302,7 +309,34 @@ bool HttpServerRequestOk( ::close(fd); return false; } + const int originalFlags = ::fcntl(fd, F_GETFL, 0); + if (originalFlags < 0 || + ::fcntl(fd, F_SETFL, originalFlags | O_NONBLOCK) != 0) { + ::close(fd); + return false; + } if (::connect(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { + if (errno != EINPROGRESS) { + ::close(fd); + return false; + } + pollfd descriptor{}; + descriptor.fd = fd; + descriptor.events = POLLOUT; + int ready = 0; + do { + ready = ::poll(&descriptor, 1, timeoutMs); + } while (ready < 0 && errno == EINTR); + int socketError = 0; + socklen_t socketErrorSize = sizeof(socketError); + if (ready <= 0 || + ::getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketError, &socketErrorSize) != 0 || + socketError != 0) { + ::close(fd); + return false; + } + } + if (::fcntl(fd, F_SETFL, originalFlags) != 0) { ::close(fd); return false; } @@ -340,11 +374,14 @@ bool HttpServerRequestOk( return false; } wxSocketClient socket; - socket.SetTimeout(1); + timeoutMs = std::max(1, timeoutMs); + socket.SetTimeout(std::max(1, (timeoutMs + 999) / 1000)); wxIPV4address addr; addr.Hostname(HealthConnectHost(state.host)); addr.Service(state.port); - if (!socket.Connect(addr, true)) { + socket.Connect(addr, false); + if (!socket.WaitOnConnect(timeoutMs / 1000, timeoutMs % 1000) || + !socket.IsConnected()) { return false; } std::string host = HealthConnectHost(state.host); @@ -353,12 +390,20 @@ bool HttpServerRequestOk( "\r\nAuthorization: Bearer " + bearerToken + "\r\nContent-Length: 0" + "\r\nConnection: close\r\n\r\n"; + if (!socket.WaitForWrite(timeoutMs / 1000, timeoutMs % 1000)) { + socket.Close(); + return false; + } socket.Write(request.data(), request.size()); if (socket.Error()) { socket.Close(); return false; } char buffer[512]{}; + if (!socket.WaitForRead(timeoutMs / 1000, timeoutMs % 1000)) { + socket.Close(); + return false; + } socket.Read(buffer, sizeof(buffer) - 1); size_t read = socket.LastCount(); socket.Close(); @@ -372,8 +417,12 @@ bool HttpServerRequestOk( #endif } -bool HttpHealthOk(const TrayAppServerState& state, const std::string& bearerToken) { - return HttpServerRequestOk(state, bearerToken, "GET", "/health", 200); +bool HttpHealthOk( + const TrayAppServerState& state, + const std::string& bearerToken, + int timeoutMs = 1000 +) { + return HttpServerRequestOk(state, bearerToken, "GET", "/health", 200, timeoutMs); } bool RequestServerShutdown(const TrayAppServerState& state, const std::string& bearerToken) { @@ -530,6 +579,7 @@ std::optional AdoptableServerState( state.url = "http://" + host + ":" + std::to_string(port); state.appPath = absoluteAppPath.empty() ? app.path : absoluteAppPath; state.appId = app.name; + state.configName = app.name; state.displayName = app.displayName.empty() ? app.name : app.displayName; if (HttpHealthOk(state, server.authToken)) { return state; @@ -552,52 +602,34 @@ std::optional FindAdoptableConfiguredServerState( return std::nullopt; } -std::optional FindAdoptableConfiguredServerState( - const ServerConfig& server, - const std::vector>& processes -) { - for (const auto& [_, app] : server.apps) { - auto state = FindAdoptableConfiguredServerState(server, app, processes); - if (state) { - return state; +bool ProcessHasExited(long pid, bool reapChild) { +#if defined(__unix__) || defined(__APPLE__) + if (reapChild) { + int status = 0; + pid_t result = ::waitpid(static_cast(pid), &status, WNOHANG); + if (result == static_cast(pid)) { + return true; } - } - return std::nullopt; -} - -std::optional AdoptableConfiguredServerState(const ServerConfig& server, long pid, const std::string& command) { - for (const auto& [_, app] : server.apps) { - auto state = AdoptableServerState(server, app, pid, command); - if (state) { - return state; + if (result < 0 && errno == ECHILD) { + return !IsProcessAlive(pid); } + return false; } - return std::nullopt; + return !IsProcessAlive(pid); +#else + (void)reapChild; + return !IsProcessAlive(pid); +#endif } bool WaitForProcessExit(long pid, bool reapChild) { for (int i = 0; i < 20; ++i) { -#if defined(__unix__) || defined(__APPLE__) - if (reapChild) { - int status = 0; - pid_t result = ::waitpid(static_cast(pid), &status, WNOHANG); - if (result == static_cast(pid)) { - return true; - } - if (result < 0 && errno == ECHILD) { - return !IsProcessAlive(pid); - } - } else if (!IsProcessAlive(pid)) { - return true; - } -#else - if (!IsProcessAlive(pid)) { + if (ProcessHasExited(pid, reapChild)) { return true; } -#endif std::this_thread::sleep_for(std::chrono::milliseconds(100)); } - return !IsProcessAlive(pid); + return ProcessHasExited(pid, reapChild); } void SignalServerProcess(long pid, wxSignal signal, bool includeChildren) { @@ -613,36 +645,6 @@ void SignalServerProcess(long pid, wxSignal signal, bool includeChildren) { #endif } -bool WaitForServerHealth(const TrayAppServerState& state, const std::string& bearerToken) { - for (int i = 0; i < 50; ++i) { - if (HttpHealthOk(state, bearerToken)) { - return true; - } - if (!IsProcessAlive(state.pid)) { - return false; - } - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - return false; -} - -std::optional ChooseServerPort(const ServerConfig& server, const ServerAppConfig& app) { - std::string host = NormalizeBindHost(server.host); - if (app.port.has_value()) { - if (IsTcpPortAvailable(host, *app.port)) { - return *app.port; - } - return std::nullopt; - } - int start = server.basePort > 0 && server.basePort <= 65535 ? server.basePort : 8787; - for (int port = start; port <= 65535 && port < start + 100; ++port) { - if (IsTcpPortAvailable(host, port)) { - return port; - } - } - return std::nullopt; -} - std::string ServerDisplayUrl(const std::string& host, int port) { return "http://" + NormalizeBindHost(host) + ":" + std::to_string(port); } @@ -1804,11 +1806,21 @@ class LlmSettingsDialog : public wxDialog { if (!FlushServerAppFields()) { return false; } + std::map fixedPorts; for (const auto& [name, app] : config_.server.apps) { if (ComputerCpp::Trim(name).empty()) { SetStatus("Server app stable name is required."); return false; } + if (app.port.has_value()) { + auto [existing, inserted] = fixedPorts.emplace(*app.port, name); + if (!inserted) { + SetStatus( + "Server apps '" + existing->second + "' and '" + name + + "' both use port " + std::to_string(*app.port) + "."); + return false; + } + } std::string error; if (!IsReadableLuaFile(app.path, &error)) { SetStatus("Server app '" + name + "': " + error); @@ -2661,6 +2673,7 @@ wxBEGIN_EVENT_TABLE(TrayIcon, wxTaskBarIcon) EVT_MENU(ID_TEST_MOUSE, TrayIcon::OnTestMouse) EVT_TASKBAR_RIGHT_UP(TrayIcon::OnTaskbarRightUp) EVT_END_PROCESS(ID_SERVER_PROCESS, TrayIcon::OnServerProcessEnded) + EVT_TIMER(ID_SERVER_TIMER, TrayIcon::OnServerTimer) EVT_MENU(ID_QUIT, TrayIcon::OnQuit) wxEND_EVENT_TABLE() @@ -2674,6 +2687,7 @@ TrayIcon::TrayIcon() { AppendPermissionTrace("tray_set_icon result=" + BoolString(iconSet) + " bundle_path=" + ComputerCppBundlePath()); #endif + serverTimer_ = std::make_unique(this, ID_SERVER_TIMER); updateFlow_ = std::make_unique([this] { #ifdef __APPLE__ DestroyNativeTrayIcon(nativeTrayIcon_); @@ -2684,7 +2698,8 @@ TrayIcon::TrayIcon() { wxExit(); }); StartOwnedDaemon(); - TryAdoptExistingServer(true); + RefreshConfiguredServers(true); + AdoptExistingServers(true); wxTheApp->CallAfter([this] { Platform::PermissionStatus status = Platform::CheckPermissions(false); AppendPermissionTrace("tray_started status=" + PermissionStatusSummary(status) + @@ -2709,7 +2724,11 @@ TrayIcon::~TrayIcon() { settingsDialog_ = nullptr; } updateFlow_.reset(); - StopServerProcess(); + if (serverTimer_) { + serverTimer_->Stop(); + } + StopAllServersBlocking(); + serverTimer_.reset(); StopDaemon("default"); if (daemonThread_.joinable()) { daemonThread_.join(); @@ -2733,16 +2752,80 @@ void TrayIcon::StartOwnedDaemon() { } wxMenu* TrayIcon::CreatePopupMenu() { + RefreshConfiguredServers(); + wxMenu* menu = new wxMenu; - wxString serverStatus = serverPid_ > 0 && !serverUrl_.empty() - ? "🟢 Server running at " + serverUrl_ - : "🔴 Server not running"; + size_t running = 0; + size_t configured = 0; + bool canStart = false; + bool canStop = false; + for (const auto& [_, server] : servers_) { + if (server.configured && server.status == ServerStatus::Running) { + ++running; + } + if (server.configured && + (server.status == ServerStatus::Stopped || + (server.status == ServerStatus::Failed && server.pid <= 0))) { + canStart = true; + } + if (server.status == ServerStatus::Running || + server.status == ServerStatus::Starting || + (server.status == ServerStatus::Failed && server.pid > 0)) { + canStop = true; + } + if (server.configured) { + ++configured; + } + } + + wxString serverStatus = configured == 0 + ? "Servers: none configured" + : "Servers: " + std::to_string(running) + " of " + std::to_string(configured) + " running"; wxMenuItem* serverStatusItem = menu->Append(wxID_ANY, serverStatus); serverStatusItem->Enable(false); - wxMenuItem* startServer = menu->Append(ID_START_SERVER, "Start Server..."); - startServer->Enable(serverPid_ == 0); - wxMenuItem* stopServer = menu->Append(ID_STOP_SERVER, "Stop Server"); - stopServer->Enable(serverPid_ > 0); + wxMenuItem* startServer = menu->Append(ID_START_SERVER, "Start All Servers"); + startServer->Enable(canStart && serverBatchAction_ == ServerBatchAction::None); + wxMenuItem* stopServer = menu->Append(ID_STOP_SERVER, "Stop All Servers"); + stopServer->Enable(canStop && serverBatchAction_ == ServerBatchAction::None); + if (!servers_.empty()) { + menu->AppendSeparator(); + } + for (const auto& [configName, server] : servers_) { + wxString label; + switch (server.status) { + case ServerStatus::Running: + label = "🟢 " + server.displayName + + (server.configured ? "" : " (unconfigured)") + + " — Stop (:" + std::to_string(server.port) + ")"; + break; + case ServerStatus::Starting: + label = "🟡 " + server.displayName + " — Starting…"; + break; + case ServerStatus::Stopping: + label = "🟡 " + server.displayName + " — Stopping…"; + break; + case ServerStatus::Failed: + label = server.pid > 0 + ? "⚠️ " + server.displayName + " — Retry Stop (:" + + std::to_string(server.port) + ")" + : "⚠️ " + server.displayName + " — Retry Start"; + break; + case ServerStatus::Stopped: + label = "⚪ " + server.displayName + " — Start"; + break; + } + wxMenuItem* item = menu->Append(wxID_ANY, label); + const bool actionable = + server.status == ServerStatus::Running || + server.status == ServerStatus::Stopped || + server.status == ServerStatus::Failed; + item->Enable(actionable && serverBatchAction_ == ServerBatchAction::None); + if (actionable) { + menu->Bind(wxEVT_MENU, [this, configName](wxCommandEvent&) { + ToggleServer(configName); + }, item->GetId()); + } + } menu->AppendSeparator(); menu->Append(ID_PERMISSIONS, "Permissions"); @@ -2789,6 +2872,7 @@ void TrayIcon::OnSettings(wxCommandEvent&) { settingsDialog_ = new LlmSettingsDialog(); settingsDialog_->Bind(wxEVT_DESTROY, [this](wxWindowDestroyEvent&) { settingsDialog_ = nullptr; + RefreshConfiguredServers(true); }); PresentSettingsDialog(settingsDialog_); } @@ -2850,15 +2934,6 @@ void TrayIcon::OnCheckForUpdates(wxCommandEvent&) { } void TrayIcon::OnStartServer(wxCommandEvent&) { - if (serverPid_ > 0) { - wxMessageBox("Server is already running at " + serverUrl_, "ComputerCpp Server", wxOK | wxICON_INFORMATION); - return; - } - if (TryAdoptExistingServer(true)) { - wxMessageBox("Server is already running at " + serverUrl_, "ComputerCpp Server", wxOK | wxICON_INFORMATION); - return; - } - std::string error; AppConfig config = LoadAppConfig(&error); if (!error.empty()) { @@ -2876,65 +2951,395 @@ void TrayIcon::OnStartServer(wxCommandEvent&) { wxMessageBox("Configure at least one Lua app in Settings > Server first.", "ComputerCpp Server", wxOK | wxICON_INFORMATION); return; } + serverAuthToken_ = config.server.authToken; + RefreshConfiguredServers(true); + AdoptExistingServers(false); - std::vector appKeys; - appKeys.reserve(config.server.apps.size()); + const std::set occupiedPorts = OccupiedServerPorts(); + + serverBatchAction_ = ServerBatchAction::Start; + serverBatchPending_.clear(); + serverBatchFailures_.clear(); for (const auto& [name, _] : config.server.apps) { - appKeys.push_back(name); + auto existing = servers_.find(name); + if (existing == servers_.end() || + existing->second.status == ServerStatus::Stopped || + (existing->second.status == ServerStatus::Failed && + existing->second.pid <= 0)) { + serverBatchPending_.insert(name); + servers_[name].batchMember = true; + } + } + + const std::string bindHost = NormalizeBindHost(config.server.host); + const ServerPortPlan portPlan = PlanServerPorts( + config.server, + serverBatchPending_, + occupiedPorts, + [&bindHost](int port) { + return IsTcpPortAvailable(bindHost, port); + }); + for (const auto& [name, app] : config.server.apps) { + if (!serverBatchPending_.contains(name)) { + continue; + } + std::string validationError; + if (!IsReadableLuaFile(app.path, &validationError)) { + servers_[name].status = ServerStatus::Failed; + CompleteServerAction(name, false, validationError); + continue; + } + + auto planError = portPlan.errors.find(name); + if (planError != portPlan.errors.end()) { + CompleteServerAction(name, false, planError->second); + continue; + } + StartOneServer(config.server, app, portPlan.ports.at(name), true); } - std::sort(appKeys.begin(), appKeys.end()); - wxArrayString choices; - for (const auto& key : appKeys) { - const ServerAppConfig& app = config.server.apps[key]; - choices.Add(app.displayName.empty() ? key : app.displayName); + FinishBatchIfReady(); +} + +void TrayIcon::OnStopServer(wxCommandEvent&) { + serverBatchAction_ = ServerBatchAction::Stop; + serverBatchPending_.clear(); + serverBatchFailures_.clear(); + for (auto& [name, server] : servers_) { + if (server.status == ServerStatus::Running || + server.status == ServerStatus::Starting || + (server.status == ServerStatus::Failed && server.pid > 0)) { + serverBatchPending_.insert(name); + server.batchMember = true; + } } + const std::vector names(serverBatchPending_.begin(), serverBatchPending_.end()); + for (const auto& name : names) { + StopOneServer(name, true); + } + FinishBatchIfReady(); +} - wxSingleChoiceDialog picker( - nullptr, - "Choose the app server to start.", - "Start Server", - choices); - if (picker.ShowModal() != wxID_OK) { +void TrayIcon::OnServerProcessEnded(wxProcessEvent& event) { + for (auto& [name, server] : servers_) { + if (server.pid != event.GetPid()) { + continue; + } + const ServerStatus previous = server.status; + RemoveTrayAppServerStateForPid(server.statePath, server.pid, nullptr); + ReleaseServerProcess(server); + server.pid = 0; + server.port = 0; + server.url.clear(); + if (previous == ServerStatus::Stopping) { + const std::string failure = server.failure; + server.status = failure.empty() ? ServerStatus::Stopped : ServerStatus::Failed; + CompleteServerAction(name, failure.empty(), failure); + } else if (previous == ServerStatus::Starting) { + server.status = ServerStatus::Failed; + CompleteServerAction(name, false, "server exited before becoming healthy"); + } else if (previous == ServerStatus::Running) { + server.status = ServerStatus::Failed; + server.failure = "server process exited unexpectedly"; + AppendAppLog("server", "unexpected_exit app=" + server.displayName + " pid=" + std::to_string(event.GetPid())); + } + break; + } +} + +void TrayIcon::OnServerTimer(wxTimerEvent&) { + PollServers(); +} + +void TrayIcon::RefreshConfiguredServers(bool force) { + const auto now = std::chrono::steady_clock::now(); + if (!force && + configuredServersRefreshedAt_.time_since_epoch().count() != 0 && + now - configuredServersRefreshedAt_ < std::chrono::seconds(2)) { return; } - int selection = picker.GetSelection(); - if (selection < 0 || static_cast(selection) >= appKeys.size()) { + std::string error; + const AppConfig config = LoadAppConfig(&error); + if (!error.empty()) { return; } - const ServerAppConfig& app = config.server.apps[appKeys[static_cast(selection)]]; + configuredServersRefreshedAt_ = now; + serverAuthToken_ = config.server.authToken; + for (auto& [_, server] : servers_) { + server.configured = false; + } + std::set configured; + for (const auto& [name, app] : config.server.apps) { + configured.insert(name); + ManagedServer& server = servers_[name]; + server.configured = true; + server.configName = name; + server.displayName = app.displayName.empty() ? name : app.displayName; + if (server.status == ServerStatus::Stopped || + (server.status == ServerStatus::Failed && server.pid <= 0)) { + server.appPath = AbsolutePathString(app.path); + if (server.appPath.empty()) { + server.appPath = app.path; + } + server.statePath = TrayAppServerStatePath(name); + } + } + for (auto it = servers_.begin(); it != servers_.end();) { + if (!configured.contains(it->first) && + (it->second.status == ServerStatus::Stopped || + (it->second.status == ServerStatus::Failed && it->second.pid <= 0))) { + ReleaseServerProcess(it->second); + it = servers_.erase(it); + } else { + ++it; + } + } +} - std::string validationError; - if (!IsReadableLuaFile(app.path, &validationError)) { - wxMessageBox(validationError, "ComputerCpp Server", wxOK | wxICON_ERROR); +std::set TrayIcon::OccupiedServerPorts(const std::string& excludedConfigName) const { + std::set ports; + for (const auto& [name, server] : servers_) { + if (name != excludedConfigName && server.pid > 0 && server.port > 0) { + ports.insert(server.port); + } + } + return ports; +} + +void TrayIcon::AdoptExistingServers(bool removeInvalidState) { + std::string configError; + const AppConfig config = LoadAppConfig(&configError); + if (!configError.empty()) { return; } - if (TryAdoptConfiguredServer(config.server, app)) { - wxMessageBox("Server is already running at " + serverUrl_, "ComputerCpp Server", wxOK | wxICON_INFORMATION); + serverAuthToken_ = config.server.authToken; + + std::vector paths = ListTrayAppServerStatePaths(nullptr); + const std::filesystem::path legacyPath = TrayAppServerStatePath(); + std::error_code existsError; + if (std::filesystem::exists(legacyPath, existsError) && !existsError) { + paths.push_back(legacyPath); + } + + for (const auto& statePath : paths) { + auto state = LoadTrayAppServerState(statePath, nullptr); + if (!state) { + if (removeInvalidState) { + RemoveTrayAppServerState(statePath, nullptr); + } + continue; + } + + std::string configName = state->configName; + if (configName.empty() || !config.server.apps.contains(configName)) { + configName.clear(); + const std::string stateAbsolute = AbsolutePathString(state->appPath); + for (const auto& [name, app] : config.server.apps) { + if (AbsolutePathString(app.path) == stateAbsolute) { + if (!configName.empty()) { + configName.clear(); + break; + } + configName = name; + } + } + } + if (!configName.empty()) { + auto managed = servers_.find(configName); + if (managed != servers_.end() && + (managed->second.status == ServerStatus::Running || + managed->second.status == ServerStatus::Starting || + managed->second.status == ServerStatus::Stopping || + managed->second.pid > 0)) { + continue; + } + const bool pidAlreadyManaged = std::any_of( + servers_.begin(), + servers_.end(), + [&](const auto& item) { + return item.first != configName && + item.second.pid > 0 && + item.second.pid == state->pid; + }); + if (pidAlreadyManaged) { + continue; + } + } + const bool valid = !configName.empty() && + IsProcessAlive(state->pid) && + LooksLikeTrayAppServerProcess(*state) && + HttpHealthOk(*state, config.server.authToken); + if (!valid) { + if (removeInvalidState) { + RemoveTrayAppServerStateForPid(statePath, state->pid, nullptr); + } + continue; + } + ManagedServer& server = servers_[configName]; + const ServerAppConfig& app = config.server.apps.at(configName); + server.configName = configName; + server.configured = true; + server.displayName = app.displayName.empty() ? configName : app.displayName; + server.appPath = AbsolutePathString(app.path); + server.host = state->host; + server.port = state->port; + server.pid = state->pid; + server.url = state->url; + server.process = nullptr; + server.status = ServerStatus::Running; + server.statePath = TrayAppServerStatePath(configName); + state->configName = configName; + state->displayName = server.displayName; + SaveTrayAppServerState(*state, server.statePath, nullptr); + if (statePath != server.statePath) { + RemoveTrayAppServerStateForPid(statePath, state->pid, nullptr); + } + AppendAppLog("server", "adopted app=" + server.displayName + " url=" + server.url + " pid=" + std::to_string(server.pid)); + } + + const bool needsProcessRecovery = std::any_of( + config.server.apps.begin(), + config.server.apps.end(), + [&](const auto& item) { + auto managed = servers_.find(item.first); + return managed == servers_.end() || + (managed->second.status != ServerStatus::Running && + managed->second.status != ServerStatus::Starting && + managed->second.status != ServerStatus::Stopping && + managed->second.pid <= 0); + }); + if (!needsProcessRecovery) { return; } - std::filesystem::path cliPath = ComputerCppCliHelperPath(); - std::error_code ec; - if (!std::filesystem::exists(cliPath, ec) || ec) { - wxMessageBox("Could not find bundled CLI helper:\n" + cliPath.string(), "ComputerCpp Server", wxOK | wxICON_ERROR); + const auto processes = AppServeProcesses(); + for (const auto& [name, app] : config.server.apps) { + ManagedServer& server = servers_[name]; + if (server.status == ServerStatus::Running || + server.status == ServerStatus::Starting || + server.status == ServerStatus::Stopping || + server.pid > 0) { + continue; + } + auto recovered = FindAdoptableConfiguredServerState(config.server, app, processes); + if (!recovered) { + continue; + } + const bool pidAlreadyManaged = std::any_of( + servers_.begin(), + servers_.end(), + [&](const auto& item) { + return item.first != name && + item.second.pid > 0 && + item.second.pid == recovered->pid; + }); + if (pidAlreadyManaged) { + continue; + } + recovered->configName = name; + server.configName = name; + server.configured = true; + server.displayName = app.displayName.empty() ? name : app.displayName; + server.appPath = recovered->appPath; + server.host = recovered->host; + server.port = recovered->port; + server.pid = recovered->pid; + server.url = recovered->url; + server.process = nullptr; + server.status = ServerStatus::Running; + server.statePath = TrayAppServerStatePath(name); + SaveTrayAppServerState(*recovered, server.statePath, nullptr); + AppendAppLog("server", "recovered app=" + server.displayName + " url=" + server.url + " pid=" + std::to_string(server.pid)); + } +} + +void TrayIcon::ToggleServer(const std::string& configName) { + auto managed = servers_.find(configName); + if (managed == servers_.end()) { + return; + } + if (managed->second.status == ServerStatus::Running || + (managed->second.status == ServerStatus::Failed && managed->second.pid > 0)) { + StopOneServer(configName, false); + return; + } + if (managed->second.status != ServerStatus::Stopped && + managed->second.status != ServerStatus::Failed) { return; } - std::optional port = ChooseServerPort(config.server, app); - if (!port.has_value()) { - wxString message; - if (app.port.has_value()) { - message << "Configured port " << *app.port << " is not available."; - } else { - message << "Could not find an available port starting at " << config.server.basePort << "."; + std::string error; + AppConfig config = LoadAppConfig(&error); + if (!error.empty() || !config.server.apps.contains(configName)) { + wxMessageBox(error.empty() ? "Server app is no longer configured." : error, "ComputerCpp Server", wxOK | wxICON_ERROR); + return; + } + if (EnsureServerAuthToken(config)) { + std::string saveError; + if (!SaveAppConfig(config, &saveError)) { + wxMessageBox("Could not save generated server token:\n" + saveError, "ComputerCpp Server", wxOK | wxICON_ERROR); + return; } - wxMessageBox(message, "ComputerCpp Server", wxOK | wxICON_ERROR); + } + serverAuthToken_ = config.server.authToken; + const ServerAppConfig& app = config.server.apps.at(configName); + std::string validationError; + if (!IsReadableLuaFile(app.path, &validationError)) { + managed->second.status = ServerStatus::Failed; + CompleteServerAction(configName, false, validationError); + return; + } + + const std::set occupiedPorts = OccupiedServerPorts(configName); + const std::string bindHost = NormalizeBindHost(config.server.host); + const ServerPortPlan portPlan = PlanServerPorts( + config.server, + {configName}, + occupiedPorts, + [&bindHost](int port) { + return IsTcpPortAvailable(bindHost, port); + }); + if (auto planError = portPlan.errors.find(configName); planError != portPlan.errors.end()) { + CompleteServerAction(configName, false, planError->second); return; } + StartOneServer(config.server, app, portPlan.ports.at(configName), false); +} - std::string host = NormalizeBindHost(config.server.host); - std::string listen = host + ":" + std::to_string(*port); - std::string displayName = app.displayName.empty() ? app.name : app.displayName; +void TrayIcon::StartOneServer( + const ServerConfig& serverConfig, + const ServerAppConfig& app, + int port, + bool batchMember +) { + const std::string host = NormalizeBindHost(serverConfig.host); + ManagedServer& server = servers_[app.name]; + server.configName = app.name; + server.configured = true; + server.displayName = app.displayName.empty() ? app.name : app.displayName; + server.appPath = AbsolutePathString(app.path); + if (server.appPath.empty()) { + server.appPath = app.path; + } + server.statePath = TrayAppServerStatePath(app.name); + server.host = host; + server.port = port; + server.url = ServerDisplayUrl(serverConfig.host, port); + server.status = ServerStatus::Starting; + server.failure.clear(); + server.batchMember = batchMember; + server.shutdownStage = 0; + server.deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + server.nextHealthProbe = std::chrono::steady_clock::now(); + + const std::filesystem::path cliPath = ComputerCppCliHelperPath(); + std::error_code ec; + if (!std::filesystem::exists(cliPath, ec) || ec) { + server.status = ServerStatus::Failed; + CompleteServerAction(app.name, false, "could not find bundled CLI helper: " + cliPath.string()); + return; + } + + const std::string listen = host + ":" + std::to_string(port); std::vector argStorage; auto addArg = [&argStorage](const std::string& value) { argStorage.push_back(wxString::FromUTF8(value).ToStdWstring()); @@ -2951,10 +3356,12 @@ void TrayIcon::OnStartServer(wxCommandEvent&) { addLiteralArg(L"--auth-token-env"); addLiteralArg(L"COMPUTER_CPP_TRAY_SERVER_TOKEN"); addLiteralArg(L"--tray-state-file"); - addArg(TrayAppServerStatePath().string()); + addArg(server.statePath.string()); + addLiteralArg(L"--tray-config-name"); + addArg(app.name); addLiteralArg(L"--tray-display-name"); - addArg(displayName); - for (const auto& origin : config.server.allowedOrigins) { + addArg(server.displayName); + for (const auto& origin : serverConfig.allowedOrigins) { addLiteralArg(L"--allowed-origin"); addArg(origin); } @@ -2969,12 +3376,12 @@ void TrayIcon::OnStartServer(wxCommandEvent&) { const bool hadPreviousToken = wxGetEnv("COMPUTER_CPP_TRAY_SERVER_TOKEN", &previousToken); wxString previousLogFile; const bool hadPreviousLogFile = wxGetEnv("COMPUTER_CPP_LOG_FILE", &previousLogFile); - wxSetEnv("COMPUTER_CPP_TRAY_SERVER_TOKEN", wxString::FromUTF8(config.server.authToken)); + wxSetEnv("COMPUTER_CPP_TRAY_SERVER_TOKEN", wxString::FromUTF8(serverConfig.authToken)); wxSetEnv("COMPUTER_CPP_LOG_FILE", wxString::FromUTF8(ComputerCpp::AppLogPath().string())); - AppendAppLog("server", "start_requested app=" + displayName + " listen=" + listen); + AppendAppLog("server", "start_requested app=" + server.displayName + " listen=" + listen); - serverProcess_ = new wxProcess(this, ID_SERVER_PROCESS); - long pid = wxExecute(argv.data(), wxEXEC_ASYNC, serverProcess_); + server.process = new wxProcess(this, ID_SERVER_PROCESS); + server.pid = wxExecute(argv.data(), wxEXEC_ASYNC, server.process); if (hadPreviousToken) { wxSetEnv("COMPUTER_CPP_TRAY_SERVER_TOKEN", previousToken); } else { @@ -2986,223 +3393,302 @@ void TrayIcon::OnStartServer(wxCommandEvent&) { wxUnsetEnv("COMPUTER_CPP_LOG_FILE"); } - if (pid == 0) { - delete serverProcess_; - serverProcess_ = nullptr; - AppendAppLog("server", "start_failed app=" + displayName + " listen=" + listen); - wxMessageBox("Failed to start app server.", "ComputerCpp Server", wxOK | wxICON_ERROR); + if (server.pid == 0) { + ReleaseServerProcess(server); + server.status = ServerStatus::Failed; + AppendAppLog("server", "start_failed app=" + server.displayName + " listen=" + listen); + CompleteServerAction(app.name, false, "failed to launch the server process"); return; } - serverPid_ = pid; - serverUrl_ = ServerDisplayUrl(host, *port); - serverAppDisplayName_ = displayName; - TrayAppServerState startedState; - startedState.pid = pid; - startedState.host = host; - startedState.port = *port; - startedState.url = serverUrl_; - startedState.appPath = AbsolutePathString(app.path); - if (startedState.appPath.empty()) { - startedState.appPath = app.path; - } - startedState.appId = app.name; - startedState.displayName = displayName; - if (!WaitForServerHealth(startedState, config.server.authToken)) { - StopServerProcess(false); - AppendAppLog("server", "health_failed app=" + displayName + " listen=" + listen + " pid=" + std::to_string(pid)); - wxMessageBox( - "The server process started but did not become healthy. Check that the Lua runtime is bundled and the Lua app can load.", - "ComputerCpp Server", - wxOK | wxICON_ERROR); - return; + TrayAppServerState state; + state.pid = server.pid; + state.host = host; + state.port = port; + state.url = server.url; + state.appPath = server.appPath; + state.appId = app.name; + state.configName = app.name; + state.displayName = server.displayName; + SaveTrayAppServerState(state, server.statePath, nullptr); + if (serverTimer_ && !serverTimer_->IsRunning()) { + serverTimer_->Start(250); } - AppendAppLog("server", "started app=" + displayName + " url=" + serverUrl_ + " pid=" + std::to_string(pid)); - wxMessageBox( - "Started " + serverAppDisplayName_ + " at " + serverUrl_, - "ComputerCpp Server", - wxOK | wxICON_INFORMATION); } -void TrayIcon::OnStopServer(wxCommandEvent&) { - if (serverPid_ == 0) { - wxMessageBox("Server is not running.", "ComputerCpp Server", wxOK | wxICON_INFORMATION); +void TrayIcon::StopOneServer(const std::string& configName, bool batchMember) { + auto it = servers_.find(configName); + if (it == servers_.end()) { return; } - StopServerProcess(true); -} - -void TrayIcon::OnServerProcessEnded(wxProcessEvent& event) { - if (serverPid_ == 0 || event.GetPid() != serverPid_) { + ManagedServer& server = it->second; + if (server.status != ServerStatus::Running && + server.status != ServerStatus::Starting && + !(server.status == ServerStatus::Failed && server.pid > 0)) { return; } - std::string stateError; - RemoveTrayAppServerStateForPid(TrayAppServerStatePath(), serverPid_, &stateError); - ClearServerProcessState(true); -} + server.batchMember = batchMember; + server.status = ServerStatus::Stopping; + server.failure.clear(); + AppendAppLog("server", "stop_requested app=" + server.displayName + " pid=" + std::to_string(server.pid)); -bool TrayIcon::TryAdoptExistingServer(bool removeInvalidState) { - if (serverPid_ > 0) { - return true; + TrayAppServerState state; + state.pid = server.pid; + state.host = server.host; + state.port = server.port; + state.url = server.url; + state.appPath = server.appPath; + state.configName = configName; + const bool shutdownRequested = server.pid > 0 && + RequestServerShutdown(state, serverAuthToken_); + server.shutdownStage = shutdownRequested ? 0 : 1; + if (!shutdownRequested && server.pid > 0) { + SignalServerProcess(server.pid, wxSIGTERM, server.process != nullptr); + } + server.deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + if (serverTimer_ && !serverTimer_->IsRunning()) { + serverTimer_->Start(250); } +} - const std::filesystem::path statePath = TrayAppServerStatePath(); - std::string stateError; - auto state = LoadTrayAppServerState(statePath, &stateError); - std::string configError; - AppConfig config = LoadAppConfig(&configError); - if (!state) { - if (removeInvalidState) { - RemoveTrayAppServerState(statePath, nullptr); - } - if (configError.empty()) { - auto recovered = FindAdoptableConfiguredServerState(config.server, AppServeProcesses()); - if (recovered) { - std::string saveError; - SaveTrayAppServerState(*recovered, statePath, &saveError); - serverPid_ = recovered->pid; - serverUrl_ = recovered->url; - serverAppDisplayName_ = recovered->displayName; - serverProcess_ = nullptr; +void TrayIcon::PollServers() { + const auto now = std::chrono::steady_clock::now(); + std::string healthProbeName; + auto selectHealthProbe = [&](auto begin, auto end) { + for (auto it = begin; it != end; ++it) { + if (it->second.status == ServerStatus::Starting && + now >= it->second.nextHealthProbe) { + healthProbeName = it->first; return true; } } return false; + }; + auto afterLastProbe = lastHealthProbeConfigName_.empty() + ? servers_.begin() + : servers_.upper_bound(lastHealthProbeConfigName_); + if (!selectHealthProbe(afterLastProbe, servers_.end())) { + selectHealthProbe(servers_.begin(), afterLastProbe); + } + if (!healthProbeName.empty()) { + lastHealthProbeConfigName_ = healthProbeName; + } + + std::vector> completions; + for (auto& [name, server] : servers_) { + if (server.status == ServerStatus::Starting) { + if (ProcessHasExited(server.pid, server.process != nullptr)) { + server.status = ServerStatus::Failed; + RemoveTrayAppServerStateForPid(server.statePath, server.pid, nullptr); + ReleaseServerProcess(server); + server.pid = 0; + server.port = 0; + server.url.clear(); + completions.emplace_back(name, false, "server exited before becoming healthy"); + continue; + } + if (name == healthProbeName) { + server.nextHealthProbe = now + std::chrono::milliseconds(500); + TrayAppServerState state; + state.pid = server.pid; + state.host = server.host; + state.port = server.port; + state.url = server.url; + state.appPath = server.appPath; + state.configName = name; + if (HttpHealthOk(state, serverAuthToken_, 100)) { + server.status = ServerStatus::Running; + server.failure.clear(); + AppendAppLog("server", "started app=" + server.displayName + " url=" + server.url + " pid=" + std::to_string(server.pid)); + completions.emplace_back(name, true, ""); + continue; + } + } + if (now >= server.deadline) { + SignalServerProcess(server.pid, wxSIGTERM, server.process != nullptr); + server.status = ServerStatus::Stopping; + server.shutdownStage = 1; + server.failure = "server did not become healthy within five seconds"; + server.deadline = now + std::chrono::seconds(2); + } + } else if (server.status == ServerStatus::Stopping) { + if (ProcessHasExited(server.pid, server.process != nullptr)) { + const std::string failure = server.failure; + RemoveTrayAppServerStateForPid(server.statePath, server.pid, nullptr); + ReleaseServerProcess(server); + server.pid = 0; + server.port = 0; + server.url.clear(); + server.status = failure.empty() ? ServerStatus::Stopped : ServerStatus::Failed; + completions.emplace_back(name, failure.empty(), failure); + continue; + } + if (now < server.deadline) { + continue; + } + if (server.shutdownStage == 0) { + SignalServerProcess(server.pid, wxSIGTERM, server.process != nullptr); + server.shutdownStage = 1; + server.deadline = now + std::chrono::seconds(2); + } else if (server.shutdownStage == 1) { + SignalServerProcess(server.pid, wxSIGKILL, server.process != nullptr); + server.shutdownStage = 2; + server.deadline = now + std::chrono::seconds(2); + } else { + const std::string failure = server.failure.empty() + ? "server process did not stop" + : server.failure + "; process did not stop"; + server.status = ServerStatus::Failed; + completions.emplace_back(name, false, failure); + } + } } - - bool valid = IsProcessAlive(state->pid) && - LooksLikeTrayAppServerProcess(*state); - if (valid) { - valid = configError.empty() && HttpHealthOk(*state, config.server.authToken); + for (const auto& [name, success, error] : completions) { + CompleteServerAction(name, success, error); } + bool transitioning = false; + for (const auto& [_, server] : servers_) { + if (server.status == ServerStatus::Starting || server.status == ServerStatus::Stopping) { + transitioning = true; + break; + } + } + if (!transitioning && serverTimer_) { + serverTimer_->Stop(); + } +} - if (!valid) { - if (removeInvalidState) { - RemoveTrayAppServerStateForPid(statePath, state->pid, nullptr); +void TrayIcon::CompleteServerAction( + const std::string& configName, + bool success, + const std::string& error +) { + auto it = servers_.find(configName); + if (it != servers_.end()) { + if (!success) { + it->second.failure = error; + if (it->second.status != ServerStatus::Running) { + it->second.status = ServerStatus::Failed; + } + AppendAppLog("server", "action_failed app=" + it->second.displayName + " error=" + error); } - if (configError.empty()) { - auto recovered = FindAdoptableConfiguredServerState(config.server, AppServeProcesses()); - if (recovered) { - std::string saveError; - SaveTrayAppServerState(*recovered, statePath, &saveError); - serverPid_ = recovered->pid; - serverUrl_ = recovered->url; - serverAppDisplayName_ = recovered->displayName; - serverProcess_ = nullptr; - return true; + if (it->second.batchMember) { + it->second.batchMember = false; + serverBatchPending_.erase(configName); + if (!success) { + serverBatchFailures_.push_back(it->second.displayName + ": " + error); } + FinishBatchIfReady(); + return; } - return false; } - - serverPid_ = state->pid; - serverUrl_ = state->url; - serverAppDisplayName_ = state->displayName; - serverProcess_ = nullptr; - return true; + if (!success) { + const std::string displayName = + it != servers_.end() ? it->second.displayName : configName; + QueueServerNotification(displayName + ": " + error); + } } -bool TrayIcon::TryAdoptConfiguredServer(const ServerConfig& server, const ServerAppConfig& app) { - if (serverPid_ > 0) { - return true; +void TrayIcon::FinishBatchIfReady() { + if (serverBatchAction_ == ServerBatchAction::None || !serverBatchPending_.empty()) { + return; + } + const ServerBatchAction completedAction = serverBatchAction_; + serverBatchAction_ = ServerBatchAction::None; + if (!serverBatchFailures_.empty()) { + std::ostringstream message; + message << (completedAction == ServerBatchAction::Start + ? "Some servers could not be started:" + : "Some servers could not be stopped:"); + for (const auto& failure : serverBatchFailures_) { + message << "\n\n• " << failure; + } + QueueServerNotification(message.str()); } + serverBatchFailures_.clear(); +} - auto state = FindAdoptableConfiguredServerState(server, app, AppServeProcesses()); - if (state) { - std::string stateError; - SaveTrayAppServerState(*state, TrayAppServerStatePath(), &stateError); - serverPid_ = state->pid; - serverUrl_ = state->url; - serverAppDisplayName_ = state->displayName; - serverProcess_ = nullptr; - AppendAppLog("server", "adopted app=" + serverAppDisplayName_ + " url=" + serverUrl_ + " pid=" + std::to_string(serverPid_)); - return true; +void TrayIcon::QueueServerNotification(std::string message) { + if (message.empty()) { + return; } - return false; + pendingServerNotifications_.push_back(std::move(message)); + if (serverNotificationScheduled_ || serverNotificationShowing_) { + return; + } + serverNotificationScheduled_ = true; + CallAfter([this] { + ShowPendingServerNotifications(); + }); } -bool TrayIcon::VerifyAdoptedServerBeforeStop(long pid, bool notifyOnFailure) { - std::string configError; - AppConfig config = LoadAppConfig(&configError); - std::string command = ProcessCommandLine(pid); -#if defined(_WIN32) - if (command.empty() && IsProcessAlive(pid)) { - return true; +void TrayIcon::ShowPendingServerNotifications() { + serverNotificationScheduled_ = false; + if (serverNotificationShowing_ || pendingServerNotifications_.empty()) { + return; } -#endif - auto state = configError.empty() && !command.empty() - ? AdoptableConfiguredServerState(config.server, pid, command) - : std::nullopt; - if (state) { - std::string stateError; - SaveTrayAppServerState(*state, TrayAppServerStatePath(), &stateError); - return true; + std::vector notifications; + notifications.swap(pendingServerNotifications_); + std::ostringstream message; + if (notifications.size() == 1) { + message << notifications.front(); + } else { + message << "Server errors:"; + for (const auto& notification : notifications) { + message << "\n\n• " << notification; + } + } + serverNotificationShowing_ = true; + wxMessageBox(message.str(), "ComputerCpp Server", wxOK | wxICON_ERROR); + serverNotificationShowing_ = false; + if (!pendingServerNotifications_.empty()) { + serverNotificationScheduled_ = true; + CallAfter([this] { + ShowPendingServerNotifications(); + }); } +} - RemoveTrayAppServerStateForPid(TrayAppServerStatePath(), pid, nullptr); - ClearServerProcessState(true); - if (notifyOnFailure) { - wxMessageBox( - "Server process is no longer running.", - "ComputerCpp Server", - wxOK | wxICON_INFORMATION); +void TrayIcon::ReleaseServerProcess(ManagedServer& server) { + if (!server.process) { + return; } - return false; + server.process->Detach(); + delete server.process; + server.process = nullptr; } -bool TrayIcon::StopServerProcess(bool notifyOnFailure) { - long pid = serverPid_; - bool currentSessionChild = serverProcess_ != nullptr; - if (pid > 0) { - if (!currentSessionChild && !VerifyAdoptedServerBeforeStop(pid, notifyOnFailure)) { - return false; - } - AppendAppLog("server", "stop_requested pid=" + std::to_string(pid) + " url=" + serverUrl_); - bool shutdownRequested = false; - std::string configError; - AppConfig config = LoadAppConfig(&configError); - std::string loadStateError; - auto state = LoadTrayAppServerState(TrayAppServerStatePath(), &loadStateError); - if (configError.empty() && state && state->pid == pid) { - shutdownRequested = RequestServerShutdown(*state, config.server.authToken); +void TrayIcon::StopAllServersBlocking() { + std::string configError; + const AppConfig config = LoadAppConfig(&configError); + const std::string token = configError.empty() ? config.server.authToken : serverAuthToken_; + for (auto& [_, server] : servers_) { + if (server.pid <= 0 || !IsProcessAlive(server.pid)) { + ReleaseServerProcess(server); + continue; } + TrayAppServerState state; + state.pid = server.pid; + state.host = server.host; + state.port = server.port; + state.url = server.url; + state.appPath = server.appPath; + bool shutdownRequested = RequestServerShutdown(state, token); if (!shutdownRequested) { - SignalServerProcess(pid, wxSIGTERM, currentSessionChild); - } - bool stopped = WaitForProcessExit(pid, currentSessionChild); - if (!stopped) { - SignalServerProcess(pid, wxSIGKILL, currentSessionChild); - stopped = WaitForProcessExit(pid, currentSessionChild); + SignalServerProcess(server.pid, wxSIGTERM, server.process != nullptr); } + bool stopped = WaitForProcessExit(server.pid, server.process != nullptr); if (!stopped) { - if (notifyOnFailure) { - wxMessageBox( - "Could not stop the server process. It is still running at " + serverUrl_, - "ComputerCpp Server", - wxOK | wxICON_ERROR); - } - AppendAppLog("server", "stop_failed pid=" + std::to_string(pid) + " url=" + serverUrl_); - return false; - } - std::string stateError; - RemoveTrayAppServerStateForPid(TrayAppServerStatePath(), pid, &stateError); - AppendAppLog("server", "stopped pid=" + std::to_string(pid)); - } - ClearServerProcessState(true); - return true; -} - -void TrayIcon::ClearServerProcessState(bool deleteProcess) { - if (serverProcess_) { - if (deleteProcess) { - serverProcess_->Detach(); - delete serverProcess_; + SignalServerProcess(server.pid, wxSIGKILL, server.process != nullptr); + WaitForProcessExit(server.pid, server.process != nullptr); } - serverProcess_ = nullptr; + RemoveTrayAppServerStateForPid(server.statePath, server.pid, nullptr); + ReleaseServerProcess(server); + server.pid = 0; + server.port = 0; + server.url.clear(); + server.status = ServerStatus::Stopped; } - serverPid_ = 0; - serverUrl_.clear(); - serverAppDisplayName_.clear(); } void TrayIcon::SetUpPermissionsIfNeeded(bool notifyWhenGranted) { diff --git a/src/app/TrayIcon.h b/src/app/TrayIcon.h index 9a181da..36f13d7 100644 --- a/src/app/TrayIcon.h +++ b/src/app/TrayIcon.h @@ -4,14 +4,20 @@ #include #include +#include +#include #include +#include #include #include +#include #include class wxDialog; class wxProcess; class wxProcessEvent; +class wxTimer; +class wxTimerEvent; namespace ComputerCpp { struct ServerAppConfig; @@ -29,6 +35,39 @@ class TrayIcon : public wxTaskBarIcon { void SetUpPermissionsIfNeeded(bool notifyWhenGranted = true); private: + enum class ServerStatus { + Stopped, + Starting, + Running, + Stopping, + Failed, + }; + + struct ManagedServer { + std::string configName; + std::string displayName; + std::string appPath; + std::string host; + std::string url; + std::filesystem::path statePath; + int port = 0; + long pid = 0; + wxProcess* process = nullptr; + ServerStatus status = ServerStatus::Stopped; + std::chrono::steady_clock::time_point deadline; + int shutdownStage = 0; + bool configured = false; + bool batchMember = false; + std::string failure; + std::chrono::steady_clock::time_point nextHealthProbe; + }; + + enum class ServerBatchAction { + None, + Start, + Stop, + }; + void OnPermissions(wxCommandEvent& event); void OnSettings(wxCommandEvent& event); void OnRecordingToggle(wxCommandEvent& event); @@ -37,17 +76,30 @@ class TrayIcon : public wxTaskBarIcon { void OnStartServer(wxCommandEvent& event); void OnStopServer(wxCommandEvent& event); void OnServerProcessEnded(wxProcessEvent& event); + void OnServerTimer(wxTimerEvent& event); void OnState(wxCommandEvent& event); void OnTestScreenshot(wxCommandEvent& event); void OnTestMouse(wxCommandEvent& event); void OnTaskbarRightUp(wxTaskBarIconEvent& event); void OnQuit(wxCommandEvent& event); void StartOwnedDaemon(); - bool TryAdoptExistingServer(bool removeInvalidState); - bool TryAdoptConfiguredServer(const ComputerCpp::ServerConfig& server, const ComputerCpp::ServerAppConfig& app); - bool VerifyAdoptedServerBeforeStop(long pid, bool notifyOnFailure); - bool StopServerProcess(bool notifyOnFailure = false); - void ClearServerProcessState(bool deleteProcess); + void RefreshConfiguredServers(bool force = false); + void AdoptExistingServers(bool removeInvalidState); + std::set OccupiedServerPorts(const std::string& excludedConfigName = {}) const; + void ToggleServer(const std::string& configName); + void StartOneServer( + const ComputerCpp::ServerConfig& server, + const ComputerCpp::ServerAppConfig& app, + int port, + bool batchMember); + void StopOneServer(const std::string& configName, bool batchMember); + void PollServers(); + void CompleteServerAction(const std::string& configName, bool success, const std::string& error = {}); + void FinishBatchIfReady(); + void QueueServerNotification(std::string message); + void ShowPendingServerNotifications(); + void ReleaseServerProcess(ManagedServer& server); + void StopAllServersBlocking(); bool daemonStarted_ = false; #ifdef __APPLE__ @@ -56,11 +108,18 @@ class TrayIcon : public wxTaskBarIcon { wxDialog* permissionDialog_ = nullptr; wxDialog* settingsDialog_ = nullptr; std::unique_ptr updateFlow_; - wxProcess* serverProcess_ = nullptr; - long serverPid_ = 0; - std::string serverUrl_; - std::string serverAppDisplayName_; + std::unique_ptr serverTimer_; + std::map servers_; + std::string serverAuthToken_; + ServerBatchAction serverBatchAction_ = ServerBatchAction::None; + std::set serverBatchPending_; + std::vector serverBatchFailures_; + std::vector pendingServerNotifications_; + bool serverNotificationScheduled_ = false; + bool serverNotificationShowing_ = false; + std::string lastHealthProbeConfigName_; std::thread daemonThread_; + std::chrono::steady_clock::time_point configuredServersRefreshedAt_; size_t cachedActiveRecordingCount_ = 0; std::chrono::steady_clock::time_point activeRecordingCountRefreshedAt_; diff --git a/src/cli/CliApp.cpp b/src/cli/CliApp.cpp index a961ff9..11529ed 100644 --- a/src/cli/CliApp.cpp +++ b/src/cli/CliApp.cpp @@ -1,5 +1,6 @@ #include "CliApp.h" #include "CliRecordingMetadata.h" +#include "PosixArgv.h" #include "computer_cpp/AppConfig.h" #include "computer_cpp/AppPaths.h" @@ -765,6 +766,10 @@ std::optional RunAppCommand( json* recordingOut, std::string& error ) { + constexpr int64_t kAppCommandLeaseTtlMs = 60 * 1000; + constexpr int64_t kAppCommandQueueWaitMs = 60 * 60 * 1000; + constexpr int64_t kAppCommandMaxRuntimeMs = 24 * 60 * 60 * 1000LL; + std::string configError; const AppConfig appConfig = LoadAppConfigForCommand(&configError); const bool recordingEnabled = recordingEnabledOverride.value_or( @@ -806,6 +811,15 @@ std::optional RunAppCommand( } LuaRunOptions lua = BaseLuaOptions(options, executablePath, appPath, "run"); + if (lua.controlSessionToken.empty() && + (!operationDir.has_value() || !executablePath.empty())) { + lua.acquireControlSession = true; + lua.leaseOwner = "lua-app:" + appId + ":" + surface; + lua.leasePurpose = "run " + commandName; + lua.leaseTtlMs = kAppCommandLeaseTtlMs; + lua.leaseWaitMs = kAppCommandQueueWaitMs; + lua.leaseMaxRuntimeMs = kAppCommandMaxRuntimeMs; + } lua.vars["__ac_app_command"] = commandName; lua.vars["__ac_app_input_json"] = input.dump(); if (operationDir.has_value()) { @@ -1072,6 +1086,51 @@ bool StartOperationProcess( std::string& error ) { #if defined(__unix__) || defined(__APPLE__) + if (!executablePath.empty()) { + std::vector command = {executablePath}; + command.push_back("--session"); + command.push_back(options.session); + if (!options.controlScope.empty()) { + command.push_back("--control-scope"); + command.push_back(options.controlScope); + } + if (!options.controlSessionToken.empty()) { + command.push_back("--control-session"); + command.push_back(options.controlSessionToken); + } + command.push_back("app"); + command.push_back("operation"); + command.push_back("__run-stored"); + command.push_back(appPath.string()); + command.push_back(appId); + command.push_back(operationId); + + pid_t pid = ::fork(); + if (pid < 0) { + error = "failed to fork operation runner"; + return false; + } + if (pid == 0) { + (void)::setsid(); + int devNull = ::open("/dev/null", O_RDWR); + if (devNull >= 0) { + ::dup2(devNull, STDIN_FILENO); + ::dup2(devNull, STDOUT_FILENO); + ::dup2(devNull, STDERR_FILENO); + if (devNull > STDERR_FILENO) { + ::close(devNull); + } + } + PosixArgv argv(command); + ::execv(argv.front(), argv.data()); + _exit(127); + } + return true; + } + + // Unit-test embeddings may not have a standalone CLI path. Production + // callers always exec a fresh process so SQLite and runtime locks are not + // inherited across fork. pid_t pid = ::fork(); if (pid < 0) { error = "failed to fork operation runner"; @@ -1469,6 +1528,7 @@ struct AppServeOptions { std::string authToken; std::set allowedOrigins; std::optional trayStateFile; + std::string trayConfigName; std::string trayDisplayName; }; @@ -2450,6 +2510,12 @@ std::optional ParseServeOptions(const std::vector& return std::nullopt; } serve.trayStateFile = args[++i]; + } else if (args[i] == "--tray-config-name") { + if (i + 1 >= args.size() || IsBlank(args[i + 1])) { + error = "app serve --tray-config-name requires a value"; + return std::nullopt; + } + serve.trayConfigName = args[++i]; } else if (args[i] == "--tray-display-name") { if (i + 1 >= args.size() || IsBlank(args[i + 1])) { error = "app serve --tray-display-name requires a value"; @@ -2560,6 +2626,7 @@ int RunHttpServer( state.url = "http://" + bindHost + ":" + std::to_string(serveOptions.port); state.appPath = fs::absolute(serveOptions.appPath).string(); state.appId = appId; + state.configName = serveOptions.trayConfigName; state.displayName = serveOptions.trayDisplayName; state.startedAt = NowIsoUtc(); std::string stateError; diff --git a/src/cli/LuaRunner.cpp b/src/cli/LuaRunner.cpp index 539b4d0..b08a461 100644 --- a/src/cli/LuaRunner.cpp +++ b/src/cli/LuaRunner.cpp @@ -1,17 +1,23 @@ #include "computer_cpp/LuaRunner.h" +#include "computer_cpp/ControlSession.h" #include "computer_cpp/WindowsUtil.h" #include "LuaPrelude.h" #include "PosixArgv.h" +#include +#include #include +#include #include +#include #include #include #include #include #include #include +#include #include #if defined(__unix__) || defined(__APPLE__) @@ -30,6 +36,103 @@ namespace fs = std::filesystem; namespace ComputerCpp { namespace { + +class ManagedControlSession { +public: + ManagedControlSession() = default; + ManagedControlSession(const ManagedControlSession&) = delete; + ManagedControlSession& operator=(const ManagedControlSession&) = delete; + + ~ManagedControlSession() { + StopAndRelease(); + } + + ControlSessionResult Acquire(const LuaRunOptions& options) { + ControlSessionAcquireOptions acquire; + acquire.scope = options.controlScope; + acquire.daemonSession = options.session; + acquire.owner = options.leaseOwner; + acquire.purpose = options.leasePurpose; + acquire.ttlMs = options.leaseTtlMs; + acquire.waitMs = options.leaseWaitMs; + acquire.maxRuntimeMs = options.leaseMaxRuntimeMs; + + ControlSessionResult result; + try { + result = AcquireControlSession(acquire); + } catch (const std::exception& ex) { + result.code = "control_session_error"; + result.error = ex.what(); + return result; + } + if (!result.ok) { + return result; + } + + token_ = result.record.token; + ttlMs_ = result.record.expiresAtMs - result.record.renewedAtMs; + if (ttlMs_ <= 0) { + ttlMs_ = ClampControlSessionTtlMs(options.leaseTtlMs); + } + renewIntervalMs_ = + std::clamp(ttlMs_ / 3, static_cast(250), static_cast(30000)); + nextRenewal_ = std::chrono::steady_clock::now() + + std::chrono::milliseconds(renewIntervalMs_); + return result; + } + + const std::string& token() const { + return token_; + } + + bool RenewIfDue() { + if (token_.empty() || + !renewalError_.empty() || + std::chrono::steady_clock::now() < nextRenewal_) { + return renewalError_.empty(); + } + ControlSessionResult renewed; + try { + renewed = RenewControlSession(token_, ttlMs_); + } catch (const std::exception& ex) { + renewalError_ = ex.what(); + return false; + } + if (!renewed.ok) { + renewalError_ = renewed.error.empty() + ? "control session renewal failed" + : renewed.error; + return false; + } + nextRenewal_ = std::chrono::steady_clock::now() + + std::chrono::milliseconds(renewIntervalMs_); + return true; + } + + const std::string& RenewalError() const { + return renewalError_; + } + + void StopAndRelease() { + if (!token_.empty()) { + try { + ReleaseControlSession(token_); + } catch (const std::exception&) { + // The lease will expire by TTL even if storage is unavailable + // during best-effort cleanup. + } + token_.clear(); + } + } + +private: + std::string token_; + int64_t ttlMs_ = 0; + int64_t renewIntervalMs_ = 0; + std::chrono::steady_clock::time_point nextRenewal_; + std::string renewalError_; +}; + bool IsExecutable(const fs::path& path) { #if defined(__unix__) || defined(__APPLE__) return ::access(path.c_str(), X_OK) == 0; @@ -206,7 +309,11 @@ fs::path TempPreludePath() { return fs::temp_directory_path() / ("computer.cpp-lua-" + std::to_string(pid) + "-" + std::to_string(stamp) + ".lua"); } -int RunChildProcess(const std::vector& args, bool agentStdio) { +int RunChildProcess( + const std::vector& args, + bool agentStdio, + ManagedControlSession* managedControlSession +) { #if defined(__unix__) || defined(__APPLE__) Cli::PosixArgv argv(args); @@ -225,9 +332,25 @@ int RunChildProcess(const std::vector& args, bool agentStdio) { } int status = 0; - if (::waitpid(pid, &status, 0) < 0) { - std::cerr << "Error: failed waiting for Lua runner\n"; - return 1; + while (true) { + pid_t waited = ::waitpid(pid, &status, WNOHANG); + if (waited == pid) { + break; + } + if (waited < 0 && errno == EINTR) { + continue; + } + if (waited < 0) { + std::cerr << "Error: failed waiting for Lua runner\n"; + return 1; + } + if (managedControlSession && !managedControlSession->RenewIfDue()) { + ::kill(pid, SIGKILL); + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + return 6; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); } if (WIFEXITED(status)) { return WEXITSTATUS(status); @@ -252,7 +375,22 @@ int RunChildProcess(const std::vector& args, bool agentStdio) { std::cerr << "Error: failed to start Lua interpreter: " << args[0] << "\n"; return 127; } - WaitForSingleObject(processInfo.hProcess, INFINITE); + while (true) { + DWORD wait = WaitForSingleObject(processInfo.hProcess, 100); + if (wait == WAIT_OBJECT_0) { + break; + } + if (wait != WAIT_TIMEOUT) { + TerminateProcess(processInfo.hProcess, 1); + WaitForSingleObject(processInfo.hProcess, INFINITE); + break; + } + if (managedControlSession && !managedControlSession->RenewIfDue()) { + TerminateProcess(processInfo.hProcess, 6); + WaitForSingleObject(processInfo.hProcess, INFINITE); + break; + } + } int exitCode = Windows::ProcessExitCode(processInfo.hProcess); CloseHandle(processInfo.hThread); CloseHandle(processInfo.hProcess); @@ -260,6 +398,7 @@ int RunChildProcess(const std::vector& args, bool agentStdio) { #else (void)args; (void)agentStdio; + (void)managedControlSession; std::cerr << "Error: Lua runner is not implemented on this platform yet\n"; return 1; #endif @@ -275,7 +414,12 @@ std::string ReadFileBestEffort(const fs::path& path) { return buffer.str(); } -LuaRunResult RunChildProcessCapture(const std::vector& args, bool agentStdio, bool streamStderr) { +LuaRunResult RunChildProcessCapture( + const std::vector& args, + bool agentStdio, + bool streamStderr, + ManagedControlSession* managedControlSession +) { LuaRunResult result; #if defined(__unix__) || defined(__APPLE__) fs::path stdoutPath = TempPreludePath(); @@ -319,15 +463,36 @@ LuaRunResult RunChildProcessCapture(const std::vector& args, bool a } int status = 0; - if (::waitpid(pid, &status, 0) < 0) { - result.exitCode = 1; - result.stderrText = "Error: failed waiting for Lua runner\n"; - } else if (WIFEXITED(status)) { + bool waitedSuccessfully = false; + while (true) { + pid_t waited = ::waitpid(pid, &status, WNOHANG); + if (waited == pid) { + waitedSuccessfully = true; + break; + } + if (waited < 0 && errno == EINTR) { + continue; + } + if (waited < 0) { + result.exitCode = 1; + result.stderrText = "Error: failed waiting for Lua runner\n"; + break; + } + if (managedControlSession && !managedControlSession->RenewIfDue()) { + ::kill(pid, SIGKILL); + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + result.exitCode = 6; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + if (waitedSuccessfully && WIFEXITED(status)) { result.exitCode = WEXITSTATUS(status); - } else if (WIFSIGNALED(status)) { + } else if (waitedSuccessfully && WIFSIGNALED(status)) { result.exitCode = 128 + WTERMSIG(status); result.stderrText = "Error: Lua runner terminated by signal " + std::to_string(WTERMSIG(status)) + "\n"; - } else { + } else if (waitedSuccessfully) { result.exitCode = 1; } @@ -404,7 +569,22 @@ LuaRunResult RunChildProcessCapture(const std::vector& args, bool a result.exitCode = 127; result.stderrText = "Error: failed to start Lua interpreter: " + args[0] + "\n"; } else { - WaitForSingleObject(processInfo.hProcess, INFINITE); + while (true) { + DWORD wait = WaitForSingleObject(processInfo.hProcess, 100); + if (wait == WAIT_OBJECT_0) { + break; + } + if (wait != WAIT_TIMEOUT) { + TerminateProcess(processInfo.hProcess, 1); + WaitForSingleObject(processInfo.hProcess, INFINITE); + break; + } + if (managedControlSession && !managedControlSession->RenewIfDue()) { + TerminateProcess(processInfo.hProcess, 6); + WaitForSingleObject(processInfo.hProcess, INFINITE); + break; + } + } result.exitCode = Windows::ProcessExitCode(processInfo.hProcess); CloseHandle(processInfo.hThread); CloseHandle(processInfo.hProcess); @@ -427,6 +607,7 @@ LuaRunResult RunChildProcessCapture(const std::vector& args, bool a (void)args; (void)agentStdio; (void)streamStderr; + (void)managedControlSession; result.exitCode = 1; result.stderrText = "Error: Lua runner is not implemented on this platform yet\n"; return result; @@ -453,6 +634,23 @@ LuaRunResult RunLuaScriptInternal(const LuaRunOptions& options, bool capture, bo return result; } + LuaRunOptions effectiveOptions = options; + ManagedControlSession managedControlSession; + if (!effectiveOptions.dryRun && + effectiveOptions.controlSessionToken.empty() && + effectiveOptions.acquireControlSession) { + ControlSessionResult acquired = managedControlSession.Acquire(effectiveOptions); + if (!acquired.ok) { + result.exitCode = acquired.code == "control_session_busy" ? 6 : 1; + result.stderrText = "Error: " + + (acquired.error.empty() ? "could not acquire desktop control" : acquired.error) + + "\n"; + return result; + } + effectiveOptions.controlSessionToken = managedControlSession.token(); + effectiveOptions.controlScope = acquired.record.scope; + } + fs::path prelude = TempPreludePath(); { std::ofstream file(prelude); @@ -461,7 +659,7 @@ LuaRunResult RunLuaScriptInternal(const LuaRunOptions& options, bool capture, bo result.stderrText = "Error: could not write Lua prelude: " + prelude.string() + "\n"; return result; } - file << LuaPreludeSource(options); + file << LuaPreludeSource(effectiveOptions); } std::vector args = { @@ -472,12 +670,26 @@ LuaRunResult RunLuaScriptInternal(const LuaRunOptions& options, bool capture, bo args.insert(args.end(), options.scriptArgs.begin(), options.scriptArgs.end()); if (capture) { - result = RunChildProcessCapture(args, options.agentStdio, streamStderr); + result = RunChildProcessCapture( + args, + effectiveOptions.agentStdio, + streamStderr, + managedControlSession.token().empty() ? nullptr : &managedControlSession); } else { - result.exitCode = RunChildProcess(args, options.agentStdio); + result.exitCode = RunChildProcess( + args, + effectiveOptions.agentStdio, + managedControlSession.token().empty() ? nullptr : &managedControlSession); } std::error_code ec; fs::remove(prelude, ec); + managedControlSession.StopAndRelease(); + const std::string renewalError = managedControlSession.RenewalError(); + if (!renewalError.empty()) { + result.exitCode = 6; + result.stdoutText.clear(); + result.stderrText += "Error: lost exclusive desktop control: " + renewalError + "\n"; + } return result; } diff --git a/src/core/AppConfig.cpp b/src/core/AppConfig.cpp index ea1b81d..f0505e1 100644 --- a/src/core/AppConfig.cpp +++ b/src/core/AppConfig.cpp @@ -325,6 +325,69 @@ bool EnsureServerAuthToken(AppConfig& config) { return true; } +ServerPortPlan PlanServerPorts( + const ServerConfig& server, + const std::set& appNames, + const std::set& occupiedPorts, + const std::function& portAvailable +) { + ServerPortPlan plan; + std::map fixedPortCounts; + std::set reservedFixedPorts; + for (const auto& [_, app] : server.apps) { + if (app.port.has_value()) { + ++fixedPortCounts[*app.port]; + reservedFixedPorts.insert(*app.port); + } + } + + std::set allocatedPorts; + const int start = server.basePort > 0 && server.basePort <= 65535 + ? server.basePort + : 8787; + const int end = std::min(65535, start + 99); + for (const auto& name : appNames) { + auto appIt = server.apps.find(name); + if (appIt == server.apps.end()) { + plan.errors[name] = "app is not configured"; + continue; + } + const ServerAppConfig& app = appIt->second; + if (app.port.has_value()) { + const int port = *app.port; + if (fixedPortCounts[port] > 1) { + plan.errors[name] = "configured port " + std::to_string(port) + " is also used by another app"; + } else if (occupiedPorts.contains(port) || !portAvailable(port)) { + plan.errors[name] = "configured port " + std::to_string(port) + " is not available"; + } else { + plan.ports[name] = port; + allocatedPorts.insert(port); + } + continue; + } + + std::optional selected; + for (int port = start; port <= end; ++port) { + if (reservedFixedPorts.contains(port) || + occupiedPorts.contains(port) || + allocatedPorts.contains(port) || + !portAvailable(port)) { + continue; + } + selected = port; + break; + } + if (!selected.has_value()) { + plan.errors[name] = "no available port was found in " + + std::to_string(start) + "-" + std::to_string(end); + continue; + } + plan.ports[name] = *selected; + allocatedPorts.insert(*selected); + } + return plan; +} + std::string NormalizeLlmProviderType(const std::string& value, std::string* error) { std::string provider = Lowercase(Trim(value)); if (provider.empty() || provider == "auto") { diff --git a/src/core/ControlSessionStore.cpp b/src/core/ControlSessionStore.cpp index c781934..0a8665c 100644 --- a/src/core/ControlSessionStore.cpp +++ b/src/core/ControlSessionStore.cpp @@ -4,10 +4,12 @@ #include "computer_cpp/Timeline.h" #include +#include #include #include #include #include +#include namespace fs = std::filesystem; @@ -21,7 +23,22 @@ fs::path ControlSessionDbPath() { } // namespace Db::Db() : connection_(ControlSessionDbPath(), "failed to open control session db", 5000) { - init(); + constexpr int kInitAttempts = 100; + for (int attempt = 0; ; ++attempt) { + try { + init(); + break; + } catch (const std::runtime_error& ex) { + const std::string message = ex.what(); + const bool locked = + message.find("database is locked") != std::string::npos || + message.find("database table is locked") != std::string::npos; + if (!locked || attempt + 1 >= kInitAttempts) { + throw; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } } Db::~Db() = default; diff --git a/src/core/TrayServerState.cpp b/src/core/TrayServerState.cpp index 513326e..6d3ae91 100644 --- a/src/core/TrayServerState.cpp +++ b/src/core/TrayServerState.cpp @@ -4,9 +4,14 @@ #include +#include #include +#include #include #include +#include +#include +#include #if defined(__unix__) || defined(__APPLE__) #include @@ -23,8 +28,45 @@ std::filesystem::path TrayAppServerStatePath() { return AppDataDir() / "tray-app-server.json"; } +std::filesystem::path TrayAppServerStateDirectory() { + return AppDataDir() / "tray-app-servers"; +} + namespace { +uint64_t Fnv1a64(const std::string& value) { + uint64_t hash = 1469598103934665603ULL; + for (unsigned char ch : value) { + hash ^= static_cast(ch); + hash *= 1099511628211ULL; + } + return hash; +} + +std::string SafeStateName(const std::string& configName) { + std::string prefix; + prefix.reserve(std::min(configName.size(), 48)); + for (unsigned char ch : configName) { + if (prefix.size() >= 48) { + break; + } + if ((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '-' || ch == '_') { + prefix.push_back(static_cast(ch)); + } else if (ch == '.' || ch == ' ') { + prefix.push_back('-'); + } + } + if (prefix.empty()) { + prefix = "app"; + } + std::ostringstream suffix; + suffix << std::hex << std::nouppercase << Fnv1a64(configName); + return prefix + "-" + suffix.str() + ".json"; +} + json TrayStateToJson(const TrayAppServerState& state) { return { {"pid", state.pid}, @@ -33,6 +75,7 @@ json TrayStateToJson(const TrayAppServerState& state) { {"url", state.url}, {"appPath", state.appPath}, {"appId", state.appId}, + {"configName", state.configName}, {"displayName", state.displayName}, {"startedAt", state.startedAt}, }; @@ -49,6 +92,7 @@ std::optional TrayStateFromJson(const json& value) { state.url = value.value("url", ""); state.appPath = value.value("appPath", ""); state.appId = value.value("appId", ""); + state.configName = value.value("configName", ""); state.displayName = value.value("displayName", ""); state.startedAt = value.value("startedAt", ""); if (state.pid <= 0 || state.host.empty() || state.port <= 0 || state.url.empty() || state.appPath.empty()) { @@ -59,6 +103,37 @@ std::optional TrayStateFromJson(const json& value) { } // namespace +std::filesystem::path TrayAppServerStatePath(const std::string& configName) { + return TrayAppServerStateDirectory() / SafeStateName(configName); +} + +std::vector ListTrayAppServerStatePaths(std::string* error) { + std::vector paths; + std::error_code ec; + const fs::path directory = TrayAppServerStateDirectory(); + if (!fs::exists(directory, ec)) { + if (ec && error) { + *error = "could not inspect tray server state directory: " + ec.message(); + } + return paths; + } + fs::directory_iterator end; + for (fs::directory_iterator it(directory, ec); !ec && it != end; it.increment(ec)) { + if (it->is_regular_file(ec) && !ec && it->path().extension() == ".json") { + paths.push_back(it->path()); + } + } + if (ec) { + if (error) { + *error = "could not list tray server state directory: " + ec.message(); + } + std::sort(paths.begin(), paths.end()); + return paths; + } + std::sort(paths.begin(), paths.end()); + return paths; +} + bool SaveTrayAppServerState(const TrayAppServerState& state, const fs::path& path, std::string* error) { std::error_code ec; fs::create_directories(path.parent_path(), ec); diff --git a/tests/CliTests.cpp b/tests/CliTests.cpp index 60cb39e..d4d2269 100644 --- a/tests/CliTests.cpp +++ b/tests/CliTests.cpp @@ -11,12 +11,14 @@ #include "computer_cpp/AppConfig.h" #include "computer_cpp/AppPaths.h" #include "computer_cpp/CommandRecording.h" +#include "computer_cpp/ControlSession.h" #include "computer_cpp/LuaRunner.h" #include #include #include +#include #include #include #include @@ -26,6 +28,7 @@ #include #include #include +#include #include #include @@ -96,6 +99,22 @@ CapturedConfigCommand RunConfigCommand(std::initializer_list args, return {exitCode, stdoutCapture.str(), stderrCapture.str()}; } +CapturedConfigCommand RunSemanticAppCommand(std::initializer_list args) { + ComputerCpp::Cli::CliOptions options; + options.jsonOutput = true; + std::ostringstream stdoutCapture; + std::ostringstream stderrCapture; + auto* oldOut = std::cout.rdbuf(stdoutCapture.rdbuf()); + auto* oldErr = std::cerr.rdbuf(stderrCapture.rdbuf()); + int exitCode = ComputerCpp::Cli::HandleSemanticAppCommand( + options, + std::vector(args), + "computer.cpp"); + std::cout.rdbuf(oldOut); + std::cerr.rdbuf(oldErr); + return {exitCode, stdoutCapture.str(), stderrCapture.str()}; +} + bool SkipLuaTestIfUnavailable(const char* testName) { if (!ComputerCpp::FindLuaInterpreter().empty()) { return false; @@ -2029,6 +2048,17 @@ void TestLuaRunCommandParsing() { assert(error.find("--var requires a value") != std::string::npos); } +void TestTrayServeConfigNameValidation() { + const auto missingValue = RunSemanticAppCommand({ + "app", + "serve", + (RepoRoot() / "tests" / "lua" / "app-basic.lua").string(), + "--tray-config-name", + }); + assert(missingValue.exitCode == 2); + assert(missingValue.stdoutText.find("--tray-config-name requires a value") != std::string::npos); +} + void TestConfigCliCanonicalFile() { auto init = RunConfigCommand({"config", "init", "--force"}); assert(init.exitCode == 0); @@ -2498,6 +2528,49 @@ void TestLuaAppErrorsAreUserFacing() { assert(raw.find("stack traceback") != std::string::npos); } +void TestLuaManagedControlSessionSerializesConcurrentApps() { + if (SkipLuaTestIfUnavailable("TestLuaManagedControlSessionSerializesConcurrentApps")) { + return; + } + + constexpr const char* scope = "desktop:test-lua-app-queue"; + std::vector results(2); + std::vector runners; + const auto started = std::chrono::steady_clock::now(); + + for (int i = 0; i < 2; ++i) { + runners.emplace_back([i, &results]() { + ComputerCpp::LuaRunOptions options; + options.scriptPath = RepoRoot() / "tests/lua/app-basic.lua"; + options.controlScope = "desktop:test-lua-app-queue"; + options.acquireControlSession = true; + options.leaseOwner = "unit-lua-app-" + std::to_string(i); + options.leasePurpose = "verify whole-command queue"; + options.leaseTtlMs = 1000; + options.leaseWaitMs = 5000; + options.leaseMaxRuntimeMs = 10000; + options.vars["__ac_app_mode"] = "run"; + options.vars["__ac_app_command"] = "slow"; + options.vars["__ac_app_input_json"] = R"({"delay":1})"; + results[static_cast(i)] = ComputerCpp::RunLuaScriptCapture(options); + }); + } + for (auto& runner : runners) { + runner.join(); + } + + const auto elapsedMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count(); + for (const auto& result : results) { + AssertLuaRunSucceeded(result); + const auto payload = nlohmann::json::parse(result.stdoutText); + assert(payload["ok"] == true); + assert(payload["data"]["result"]["done"] == true); + } + assert(elapsedMs >= 1800); + assert(!ComputerCpp::HasActiveControlSession(scope)); +} + void TestLuaRuntimeWritesConfiguredLogFile() { if (SkipLuaTestIfUnavailable("TestLuaRuntimeWritesConfiguredLogFile")) { return; @@ -2674,12 +2747,14 @@ void RunCliTests() { TestCliDurationParsing(); TestSessionChildCommandParsing(); TestLuaRunCommandParsing(); + TestTrayServeConfigNameValidation(); TestConfigCliCanonicalFile(); TestRecordingSurfaceMetadata(); TestCliCommandRecordingMetadata(); TestMicroAgentLuaDryRun(); TestMicroAgentStrictToolCallsLuaDryRun(); TestLuaAppErrorsAreUserFacing(); + TestLuaManagedControlSessionSerializesConcurrentApps(); TestLuaRuntimeWritesConfiguredLogFile(); TestLuaRuntimeLogFileHonorsQuietFlag(); TestLuaPortableTempCapture(); diff --git a/tests/CoreTests.cpp b/tests/CoreTests.cpp index 7de24eb..3de36ad 100644 --- a/tests/CoreTests.cpp +++ b/tests/CoreTests.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -137,6 +138,104 @@ void TestAppConfigServerRoundTrip() { assert(ComputerCpp::SaveAppConfig(loaded, &error)); } +void TestServerPortPlanning() { + ComputerCpp::ServerConfig server; + server.basePort = 8787; + + ComputerCpp::ServerAppConfig fixedA; + fixedA.name = "fixed-a"; + fixedA.port = 8787; + server.apps[fixedA.name] = fixedA; + + ComputerCpp::ServerAppConfig automatic; + automatic.name = "automatic"; + server.apps[automatic.name] = automatic; + + ComputerCpp::ServerAppConfig fixedB; + fixedB.name = "fixed-b"; + fixedB.port = 8789; + server.apps[fixedB.name] = fixedB; + + const std::set allNames = {"fixed-a", "automatic", "fixed-b"}; + auto available = [](int) { return true; }; + auto plan = ComputerCpp::PlanServerPorts(server, allNames, {}, available); + assert(plan.errors.empty()); + assert(plan.ports["fixed-a"] == 8787); + assert(plan.ports["automatic"] == 8788); + assert(plan.ports["fixed-b"] == 8789); + + auto occupiedPlan = ComputerCpp::PlanServerPorts(server, allNames, {8788}, available); + assert(occupiedPlan.errors.empty()); + assert(occupiedPlan.ports["automatic"] == 8790); + + auto unavailableFixed = ComputerCpp::PlanServerPorts( + server, + allNames, + {}, + [](int port) { return port != 8787; }); + assert(unavailableFixed.errors.contains("fixed-a")); + assert(unavailableFixed.ports["automatic"] == 8788); + assert(unavailableFixed.ports["fixed-b"] == 8789); + + server.apps["fixed-b"].port = 8787; + auto duplicate = ComputerCpp::PlanServerPorts(server, allNames, {}, available); + assert(duplicate.errors.contains("fixed-a")); + assert(duplicate.errors.contains("fixed-b")); + assert(duplicate.ports["automatic"] == 8788); + + ComputerCpp::ServerConfig exhausted; + exhausted.basePort = 65535; + ComputerCpp::ServerAppConfig exhaustedApp; + exhaustedApp.name = "exhausted"; + exhausted.apps[exhaustedApp.name] = exhaustedApp; + auto noPorts = ComputerCpp::PlanServerPorts( + exhausted, + {"exhausted"}, + {65535}, + available); + assert(noPorts.errors.contains("exhausted")); + assert(noPorts.errors["exhausted"].find("65535-65535") != std::string::npos); + + ComputerCpp::ServerConfig fallback; + fallback.basePort = 0; + ComputerCpp::ServerAppConfig fallbackApp; + fallbackApp.name = "fallback"; + fallback.apps[fallbackApp.name] = fallbackApp; + auto fallbackPlan = ComputerCpp::PlanServerPorts( + fallback, + {"fallback"}, + {}, + available); + assert(fallbackPlan.errors.empty()); + assert(fallbackPlan.ports["fallback"] == 8787); + fallback.basePort = 70000; + auto highFallbackPlan = ComputerCpp::PlanServerPorts( + fallback, + {"fallback"}, + {}, + available); + assert(highFallbackPlan.errors.empty()); + assert(highFallbackPlan.ports["fallback"] == 8787); + + ComputerCpp::ServerConfig bounded; + bounded.basePort = 10000; + ComputerCpp::ServerAppConfig boundedApp; + boundedApp.name = "bounded"; + bounded.apps[boundedApp.name] = boundedApp; + int highestChecked = 0; + auto boundedPlan = ComputerCpp::PlanServerPorts( + bounded, + {"bounded"}, + {}, + [&highestChecked](int port) { + highestChecked = std::max(highestChecked, port); + return port >= 10100; + }); + assert(boundedPlan.errors.contains("bounded")); + assert(boundedPlan.errors["bounded"].find("10000-10099") != std::string::npos); + assert(highestChecked == 10099); +} + class FakeScreenRecordingSession final : public ComputerCpp::Platform::ScreenRecordingSession { public: explicit FakeScreenRecordingSession(bool stopSucceeds) @@ -479,6 +578,7 @@ void TestTrayServerState() { state.url = "http://127.0.0.1:8787"; state.appPath = "/tmp/app.lua"; state.appId = "app-id"; + state.configName = "configured-app"; state.displayName = "Test App"; state.startedAt = "2026-06-22T00:00:00Z"; @@ -492,6 +592,7 @@ void TestTrayServerState() { assert(loaded->url == state.url); assert(loaded->appPath == state.appPath); assert(loaded->appId == state.appId); + assert(loaded->configName == state.configName); assert(loaded->displayName == state.displayName); assert(loaded->startedAt == state.startedAt); @@ -508,6 +609,38 @@ void TestTrayServerState() { assert(!invalid.has_value()); assert(ComputerCpp::RemoveTrayAppServerState(path, &error)); assert(!ComputerCpp::IsProcessAlive(-1)); + + ComputerCpp::TrayAppServerState first = state; + first.pid = 111; + first.configName = "first/app"; + ComputerCpp::TrayAppServerState second = state; + second.pid = 222; + second.configName = "../second app"; + const fs::path firstPath = ComputerCpp::TrayAppServerStatePath(first.configName); + const fs::path secondPath = ComputerCpp::TrayAppServerStatePath(second.configName); + assert(firstPath.parent_path() == ComputerCpp::TrayAppServerStateDirectory()); + assert(secondPath.parent_path() == ComputerCpp::TrayAppServerStateDirectory()); + assert(firstPath != secondPath); + assert(firstPath == ComputerCpp::TrayAppServerStatePath(first.configName)); + assert(firstPath.filename().string().find('/') == std::string::npos); + assert(secondPath.filename().string().find("..") == std::string::npos); + const std::string sharedPrefix(48, 'a'); + const fs::path collidingPrefixA = + ComputerCpp::TrayAppServerStatePath(sharedPrefix + "-first"); + const fs::path collidingPrefixB = + ComputerCpp::TrayAppServerStatePath(sharedPrefix + "-second"); + assert(collidingPrefixA != collidingPrefixB); + assert(collidingPrefixA.filename().string().substr(0, sharedPrefix.size()) == + sharedPrefix); + assert(collidingPrefixB.filename().string().substr(0, sharedPrefix.size()) == + sharedPrefix); + assert(ComputerCpp::SaveTrayAppServerState(first, firstPath, &error)); + assert(ComputerCpp::SaveTrayAppServerState(second, secondPath, &error)); + const auto paths = ComputerCpp::ListTrayAppServerStatePaths(&error); + assert(paths.size() == 2); + assert(paths[0] != paths[1]); + assert(ComputerCpp::RemoveTrayAppServerState(firstPath, &error)); + assert(ComputerCpp::RemoveTrayAppServerState(secondPath, &error)); } void TestRefStore() { @@ -952,6 +1085,7 @@ int main() { RunTest("StringUtils", TestStringUtils); RunTest("AppConfigServerRoundTrip", TestAppConfigServerRoundTrip); + RunTest("ServerPortPlanning", TestServerPortPlanning); RunTest("CommandRecordingLifecycle", TestCommandRecordingLifecycle); RunTest("NativeCommandRecordingSmoke", TestNativeCommandRecordingSmoke); RunTest("TrayServerState", TestTrayServerState);