diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs
new file mode 100644
index 0000000..5723bd2
--- /dev/null
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs
@@ -0,0 +1,258 @@
+// ConnectionManagerBase.cs
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
+// https://github.com/gnh1201/welsonjs
+//
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace WelsonJS.Launcher
+{
+ ///
+ /// Provides a reusable pattern for keeping long-lived connections alive and
+ /// recreating them transparently when the underlying connection becomes invalid.
+ ///
+ /// A descriptor used to create a unique key for each connection.
+ /// The concrete connection type.
+ public abstract class ConnectionManagerBase
+ where TConnection : class
+ {
+ private readonly ConcurrentDictionary _pool
+ = new ConcurrentDictionary();
+ private readonly ConcurrentDictionary _openLocks
+ = new ConcurrentDictionary();
+ private readonly ConcurrentDictionary _opLocks
+ = new ConcurrentDictionary();
+
+ ///
+ /// Creates a unique cache key for the given connection parameters.
+ ///
+ protected abstract string CreateKey(TParameters parameters);
+
+ ///
+ /// Establishes a new connection using the provided parameters.
+ ///
+ protected abstract Task OpenConnectionAsync(TParameters parameters, CancellationToken token);
+
+ ///
+ /// Validates whether the existing connection is still usable.
+ ///
+ protected abstract bool IsConnectionValid(TConnection connection);
+
+ ///
+ /// Releases the resources associated with a connection instance.
+ ///
+ protected virtual void CloseConnection(TConnection connection)
+ {
+ if (connection is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ }
+
+ ///
+ /// Retrieves a cached connection or creates a new one if needed.
+ ///
+ protected async Task GetOrCreateAsync(TParameters parameters, CancellationToken token)
+ {
+ string key = CreateKey(parameters);
+
+ if (_pool.TryGetValue(key, out var existing) && IsConnectionHealthy(existing.Connection))
+ {
+ return existing.Connection;
+ }
+
+ var gate = _openLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ if (_pool.TryGetValue(key, out existing) && IsConnectionHealthy(existing.Connection))
+ {
+ return existing.Connection;
+ }
+
+ if (existing.Connection != null && !IsConnectionHealthy(existing.Connection))
+ {
+ RemoveInternal(key, existing.Connection);
+ }
+
+ var connection = await OpenConnectionAsync(parameters, token).ConfigureAwait(false);
+ _pool[key] = (connection, parameters);
+ return connection;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ ///
+ /// Removes the connection associated with the provided parameters.
+ ///
+ public void Remove(TParameters parameters)
+ {
+ string key = CreateKey(parameters);
+ if (_pool.TryRemove(key, out var entry))
+ {
+ CloseSafely(entry.Connection);
+ }
+ }
+
+ ///
+ /// Removes the connection associated with the provided cache key.
+ ///
+ protected bool TryRemoveByKey(string key)
+ {
+ if (string.IsNullOrEmpty(key))
+ {
+ return false;
+ }
+
+ if (_pool.TryRemove(key, out var entry))
+ {
+ CloseSafely(entry.Connection);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Provides a snapshot of the currently tracked connections.
+ ///
+ protected IReadOnlyList SnapshotConnections()
+ {
+ var entries = _pool.ToArray();
+ var result = new ConnectionSnapshot[entries.Length];
+
+ for (int i = 0; i < entries.Length; i++)
+ {
+ var entry = entries[i];
+ var connection = entry.Value.Connection;
+ bool isValid = IsConnectionHealthy(connection);
+
+ result[i] = new ConnectionSnapshot(
+ entry.Key,
+ entry.Value.Parameters,
+ connection,
+ isValid);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Executes an action against the managed connection, retrying once if the first attempt fails.
+ ///
+ protected async Task ExecuteWithRetryAsync(
+ TParameters parameters,
+ Func> operation,
+ int maxAttempts,
+ CancellationToken token)
+ {
+ if (operation == null) throw new ArgumentNullException(nameof(operation));
+ if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts));
+
+ Exception lastError = null;
+ var key = CreateKey(parameters);
+ var opLock = _opLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
+
+ for (int attempt = 0; attempt < maxAttempts; attempt++)
+ {
+ await opLock.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ token.ThrowIfCancellationRequested();
+ var connection = await GetOrCreateAsync(parameters, token).ConfigureAwait(false);
+ return await operation(connection, token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ lastError = ex;
+ Remove(parameters);
+ if (attempt == maxAttempts - 1)
+ {
+ throw;
+ }
+ }
+ finally
+ {
+ opLock.Release();
+ }
+ }
+
+ throw lastError ?? new InvalidOperationException("Unreachable state in ExecuteWithRetryAsync");
+ }
+
+ private bool IsConnectionHealthy(TConnection connection)
+ {
+ if (connection == null)
+ {
+ return false;
+ }
+
+ try
+ {
+ return IsConnectionValid(connection);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private void RemoveInternal(string key, TConnection connection)
+ {
+ if (!string.IsNullOrEmpty(key))
+ {
+ _pool.TryRemove(key, out _);
+ }
+
+ if (connection != null)
+ {
+ CloseSafely(connection);
+ }
+ }
+
+ private void CloseSafely(TConnection connection)
+ {
+ try
+ {
+ CloseConnection(connection);
+ }
+ catch
+ {
+ // Ignore dispose exceptions.
+ }
+ }
+
+ ///
+ /// Represents an immutable snapshot of a managed connection.
+ ///
+ protected readonly struct ConnectionSnapshot
+ {
+ public ConnectionSnapshot(string key, TParameters parameters, TConnection connection, bool isValid)
+ {
+ Key = key;
+ Parameters = parameters;
+ Connection = connection;
+ IsValid = isValid;
+ }
+
+ public string Key { get; }
+
+ public TParameters Parameters { get; }
+
+ public TConnection Connection { get; }
+
+ public bool IsValid { get; }
+ }
+ }
+}
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.Designer.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.Designer.cs
new file mode 100644
index 0000000..a555de8
--- /dev/null
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.Designer.cs
@@ -0,0 +1,132 @@
+namespace WelsonJS.Launcher
+{
+ partial class ConnectionMonitorForm
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ private void InitializeComponent()
+ {
+ this.lvConnections = new System.Windows.Forms.ListView();
+ this.chType = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader()));
+ this.chKey = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader()));
+ this.chState = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader()));
+ this.chDetails = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader()));
+ this.chHealth = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader()));
+ this.btnRefresh = new System.Windows.Forms.Button();
+ this.btnCloseSelected = new System.Windows.Forms.Button();
+ this.SuspendLayout();
+ //
+ // lvConnections
+ //
+ this.lvConnections.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] {
+ this.chType,
+ this.chKey,
+ this.chState,
+ this.chDetails,
+ this.chHealth});
+ this.lvConnections.FullRowSelect = true;
+ this.lvConnections.HideSelection = false;
+ this.lvConnections.Location = new System.Drawing.Point(12, 12);
+ this.lvConnections.MultiSelect = true;
+ this.lvConnections.Name = "lvConnections";
+ this.lvConnections.Size = new System.Drawing.Size(640, 260);
+ this.lvConnections.TabIndex = 0;
+ this.lvConnections.UseCompatibleStateImageBehavior = false;
+ this.lvConnections.View = System.Windows.Forms.View.Details;
+ this.lvConnections.SelectedIndexChanged += new System.EventHandler(this.lvConnections_SelectedIndexChanged);
+ //
+ // chType
+ //
+ this.chType.Text = "Type";
+ this.chType.Width = 100;
+ //
+ // chKey
+ //
+ this.chKey.Text = "Key";
+ this.chKey.Width = 140;
+ //
+ // chState
+ //
+ this.chState.Text = "State";
+ this.chState.Width = 100;
+ //
+ // chDetails
+ //
+ this.chDetails.Text = "Details";
+ this.chDetails.Width = 220;
+ //
+ // chHealth
+ //
+ this.chHealth.Text = "Health";
+ this.chHealth.Width = 80;
+ //
+ // btnRefresh
+ //
+ this.btnRefresh.Location = new System.Drawing.Point(12, 280);
+ this.btnRefresh.Name = "btnRefresh";
+ this.btnRefresh.Size = new System.Drawing.Size(95, 30);
+ this.btnRefresh.TabIndex = 1;
+ this.btnRefresh.Text = "Refresh";
+ this.btnRefresh.UseVisualStyleBackColor = true;
+ this.btnRefresh.Click += new System.EventHandler(this.btnRefresh_Click);
+ //
+ // btnCloseSelected
+ //
+ this.btnCloseSelected.Enabled = false;
+ this.btnCloseSelected.Location = new System.Drawing.Point(557, 280);
+ this.btnCloseSelected.Name = "btnCloseSelected";
+ this.btnCloseSelected.Size = new System.Drawing.Size(95, 30);
+ this.btnCloseSelected.TabIndex = 2;
+ this.btnCloseSelected.Text = "Close";
+ this.btnCloseSelected.UseVisualStyleBackColor = true;
+ this.btnCloseSelected.Click += new System.EventHandler(this.btnCloseSelected_Click);
+ //
+ // ConnectionMonitorForm
+ //
+ this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F);
+ this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ this.ClientSize = new System.Drawing.Size(664, 322);
+ this.Controls.Add(this.btnCloseSelected);
+ this.Controls.Add(this.btnRefresh);
+ this.Controls.Add(this.lvConnections);
+ this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
+ this.Icon = global::WelsonJS.Launcher.Properties.Resources.favicon;
+ this.MaximizeBox = false;
+ this.MinimizeBox = false;
+ this.Name = "ConnectionMonitorForm";
+ this.Text = "Connection Monitor";
+ this.Load += new System.EventHandler(this.ConnectionMonitorForm_Load);
+ this.ResumeLayout(false);
+
+ }
+
+ #endregion
+
+ private System.Windows.Forms.ListView lvConnections;
+ private System.Windows.Forms.ColumnHeader chType;
+ private System.Windows.Forms.ColumnHeader chKey;
+ private System.Windows.Forms.ColumnHeader chState;
+ private System.Windows.Forms.ColumnHeader chDetails;
+ private System.Windows.Forms.ColumnHeader chHealth;
+ private System.Windows.Forms.Button btnRefresh;
+ private System.Windows.Forms.Button btnCloseSelected;
+ }
+}
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.cs
new file mode 100644
index 0000000..c7a9bbf
--- /dev/null
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.cs
@@ -0,0 +1,137 @@
+// ConnectionMonitorForm.cs
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
+// https://github.com/gnh1201/welsonjs
+//
+using System;
+using System.Collections.Generic;
+using System.Windows.Forms;
+
+namespace WelsonJS.Launcher
+{
+ public partial class ConnectionMonitorForm : Form
+ {
+ public ConnectionMonitorForm()
+ {
+ InitializeComponent();
+ }
+
+ private void ConnectionMonitorForm_Load(object sender, EventArgs e)
+ {
+ RefreshConnections();
+ }
+
+ private void btnRefresh_Click(object sender, EventArgs e)
+ {
+ RefreshConnections();
+ }
+
+ private void lvConnections_SelectedIndexChanged(object sender, EventArgs e)
+ {
+ btnCloseSelected.Enabled = lvConnections.SelectedItems.Count > 0;
+ }
+
+ private void btnCloseSelected_Click(object sender, EventArgs e)
+ {
+ if (lvConnections.SelectedItems.Count == 0)
+ {
+ return;
+ }
+
+ var errors = new List();
+ bool anyClosed = false;
+
+ foreach (ListViewItem item in lvConnections.SelectedItems)
+ {
+ if (item.Tag is ConnectionItemTag tag)
+ {
+ try
+ {
+ if (tag.Provider.TryClose(tag.Key))
+ {
+ anyClosed = true;
+ }
+ else
+ {
+ errors.Add($"Unable to close {tag.Provider.ConnectionType} connection {tag.Key}.");
+ }
+ }
+ catch (Exception ex)
+ {
+ errors.Add($"{tag.Provider.ConnectionType} {tag.Key}: {ex.Message}");
+ }
+ }
+ }
+
+ if (anyClosed)
+ {
+ RefreshConnections();
+ }
+
+ if (errors.Count > 0)
+ {
+ MessageBox.Show(string.Join(Environment.NewLine, errors), "Connection Monitor", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ }
+
+ private void RefreshConnections()
+ {
+ IReadOnlyList providers = ConnectionMonitorRegistry.GetProviders();
+
+ lvConnections.BeginUpdate();
+ lvConnections.Items.Clear();
+
+ foreach (var provider in providers)
+ {
+ IReadOnlyCollection statuses;
+ try
+ {
+ statuses = provider.GetStatuses();
+ }
+ catch (Exception ex)
+ {
+ var errorItem = new ListViewItem(provider.ConnectionType)
+ {
+ Tag = null
+ };
+ errorItem.SubItems.Add(string.Empty);
+ errorItem.SubItems.Add("Error");
+ errorItem.SubItems.Add(ex.Message);
+ errorItem.SubItems.Add("Unknown");
+ lvConnections.Items.Add(errorItem);
+ continue;
+ }
+
+ foreach (var status in statuses)
+ {
+ var item = new ListViewItem(status.ConnectionType)
+ {
+ Tag = new ConnectionItemTag(provider, status.Key)
+ };
+ item.SubItems.Add(status.Key);
+ item.SubItems.Add(status.State);
+ item.SubItems.Add(status.Description);
+ item.SubItems.Add(status.IsValid ? "Healthy" : "Stale");
+ lvConnections.Items.Add(item);
+ }
+ }
+
+ lvConnections.EndUpdate();
+
+ btnCloseSelected.Enabled = lvConnections.SelectedItems.Count > 0;
+ }
+
+ private sealed class ConnectionItemTag
+ {
+ public ConnectionItemTag(IManagedConnectionProvider provider, string key)
+ {
+ Provider = provider;
+ Key = key ?? string.Empty;
+ }
+
+ public IManagedConnectionProvider Provider { get; }
+
+ public string Key { get; }
+ }
+ }
+}
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.resx b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.resx
new file mode 100644
index 0000000..bdd5b01
--- /dev/null
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.resx
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorRegistry.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorRegistry.cs
new file mode 100644
index 0000000..fa639c4
--- /dev/null
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorRegistry.cs
@@ -0,0 +1,56 @@
+// ConnectionMonitorRegistry.cs
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
+// https://github.com/gnh1201/welsonjs
+//
+using System;
+using System.Collections.Generic;
+
+namespace WelsonJS.Launcher
+{
+ ///
+ /// Keeps track of connection providers that should appear in the monitor UI.
+ ///
+ public static class ConnectionMonitorRegistry
+ {
+ private static readonly object _syncRoot = new object();
+ private static readonly List _providers = new List();
+
+ public static void RegisterProvider(IManagedConnectionProvider provider)
+ {
+ if (provider == null)
+ {
+ throw new ArgumentNullException(nameof(provider));
+ }
+
+ lock (_syncRoot)
+ {
+ if (!_providers.Contains(provider))
+ {
+ _providers.Add(provider);
+ }
+ }
+ }
+
+ public static void UnregisterProvider(IManagedConnectionProvider provider)
+ {
+ if (provider == null)
+ {
+ return;
+ }
+
+ lock (_syncRoot)
+ {
+ _providers.Remove(provider);
+ }
+ }
+
+ public static IReadOnlyList GetProviders()
+ {
+ lock (_syncRoot)
+ {
+ return _providers.ToArray();
+ }
+ }
+ }
+}
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/IManagedConnectionProvider.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/IManagedConnectionProvider.cs
new file mode 100644
index 0000000..44e055a
--- /dev/null
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/IManagedConnectionProvider.cs
@@ -0,0 +1,30 @@
+// IManagedConnectionProvider.cs
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
+// https://github.com/gnh1201/welsonjs
+//
+using System.Collections.Generic;
+
+namespace WelsonJS.Launcher
+{
+ ///
+ /// Exposes connection status information for use by the connection monitor UI.
+ ///
+ public interface IManagedConnectionProvider
+ {
+ ///
+ /// Gets a human-friendly name for the connection type managed by this provider.
+ ///
+ string ConnectionType { get; }
+
+ ///
+ /// Retrieves the current connections handled by the provider.
+ ///
+ IReadOnlyCollection GetStatuses();
+
+ ///
+ /// Attempts to close the connection associated with the supplied cache key.
+ ///
+ bool TryClose(string key);
+ }
+}
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/MainForm.Designer.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/MainForm.Designer.cs
index 76ba271..e5faf00 100644
--- a/WelsonJS.Toolkit/WelsonJS.Launcher/MainForm.Designer.cs
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/MainForm.Designer.cs
@@ -39,6 +39,7 @@
this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.userdefinedVariablesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.instancesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ this.connectionMonitorToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.runAsAdministratorToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.globalSettingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.startTheEditorToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -132,6 +133,7 @@
this.settingsToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.userdefinedVariablesToolStripMenuItem,
this.instancesToolStripMenuItem,
+ this.connectionMonitorToolStripMenuItem,
this.runAsAdministratorToolStripMenuItem,
this.globalSettingsToolStripMenuItem,
this.startTheEditorToolStripMenuItem,
@@ -153,6 +155,13 @@
this.instancesToolStripMenuItem.Size = new System.Drawing.Size(196, 22);
this.instancesToolStripMenuItem.Text = "Instances";
this.instancesToolStripMenuItem.Click += new System.EventHandler(this.instancesToolStripMenuItem_Click);
+ //
+ // connectionMonitorToolStripMenuItem
+ //
+ this.connectionMonitorToolStripMenuItem.Name = "connectionMonitorToolStripMenuItem";
+ this.connectionMonitorToolStripMenuItem.Size = new System.Drawing.Size(196, 22);
+ this.connectionMonitorToolStripMenuItem.Text = "Connections...";
+ this.connectionMonitorToolStripMenuItem.Click += new System.EventHandler(this.connectionMonitorToolStripMenuItem_Click);
//
// runAsAdministratorToolStripMenuItem
//
@@ -282,6 +291,7 @@
private System.Windows.Forms.ToolStripMenuItem settingsToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem userdefinedVariablesToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem instancesToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem connectionMonitorToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem runAsAdministratorToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem globalSettingsToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem startTheEditorToolStripMenuItem;
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/MainForm.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/MainForm.cs
index f55da4b..446650e 100644
--- a/WelsonJS.Toolkit/WelsonJS.Launcher/MainForm.cs
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/MainForm.cs
@@ -23,6 +23,7 @@ namespace WelsonJS.Launcher
private string _workingDirectory;
private string _instanceId;
private string _scriptName;
+ private ConnectionMonitorForm _connectionMonitorForm;
public MainForm(ICompatibleLogger logger = null)
{
@@ -276,6 +277,17 @@ namespace WelsonJS.Launcher
(new InstancesForm()).Show();
}
+ private void connectionMonitorToolStripMenuItem_Click(object sender, EventArgs e)
+ {
+ if (_connectionMonitorForm == null || _connectionMonitorForm.IsDisposed)
+ {
+ _connectionMonitorForm = new ConnectionMonitorForm();
+ }
+
+ _connectionMonitorForm.Show();
+ _connectionMonitorForm.Focus();
+ }
+
private void runAsAdministratorToolStripMenuItem_Click(object sender, EventArgs e)
{
if (!IsInAdministrator())
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ManagedConnectionStatus.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ManagedConnectionStatus.cs
new file mode 100644
index 0000000..263d5c2
--- /dev/null
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ManagedConnectionStatus.cs
@@ -0,0 +1,32 @@
+// ManagedConnectionStatus.cs
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
+// https://github.com/gnh1201/welsonjs
+//
+namespace WelsonJS.Launcher
+{
+ ///
+ /// Represents the state of a managed connection for UI presentation.
+ ///
+ public sealed class ManagedConnectionStatus
+ {
+ public ManagedConnectionStatus(string connectionType, string key, string state, string description, bool isValid)
+ {
+ ConnectionType = connectionType ?? string.Empty;
+ Key = key ?? string.Empty;
+ State = state ?? string.Empty;
+ Description = description ?? string.Empty;
+ IsValid = isValid;
+ }
+
+ public string ConnectionType { get; }
+
+ public string Key { get; }
+
+ public string State { get; }
+
+ public string Description { get; }
+
+ public bool IsValid { get; }
+ }
+}
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs
new file mode 100644
index 0000000..feadbcd
--- /dev/null
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs
@@ -0,0 +1,239 @@
+// SerialPortManager.cs
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
+// https://github.com/gnh1201/welsonjs
+//
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Ports;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace WelsonJS.Launcher
+{
+ public sealed class SerialPortManager : ConnectionManagerBase, IManagedConnectionProvider
+ {
+ private const string ConnectionTypeName = "Serial Port";
+
+ public struct ConnectionParameters
+ {
+ public ConnectionParameters(
+ string portName,
+ int baudRate,
+ Parity parity = Parity.None,
+ int dataBits = 8,
+ StopBits stopBits = StopBits.One,
+ Handshake handshake = Handshake.None,
+ int readTimeout = 500,
+ int writeTimeout = 500,
+ int readBufferSize = 1024,
+ bool resetBuffersBeforeRequest = false)
+ {
+ if (string.IsNullOrWhiteSpace(portName)) throw new ArgumentNullException(nameof(portName));
+
+ PortName = portName;
+ BaudRate = baudRate;
+ Parity = parity;
+ DataBits = dataBits;
+ StopBits = stopBits;
+ Handshake = handshake;
+ ReadTimeout = readTimeout;
+ WriteTimeout = writeTimeout;
+ ReadBufferSize = readBufferSize > 0 ? readBufferSize : 1024;
+ ResetBuffersBeforeRequest = resetBuffersBeforeRequest;
+ }
+
+ public string PortName { get; }
+ public int BaudRate { get; }
+ public Parity Parity { get; }
+ public int DataBits { get; }
+ public StopBits StopBits { get; }
+ public Handshake Handshake { get; }
+ public int ReadTimeout { get; }
+ public int WriteTimeout { get; }
+ public int ReadBufferSize { get; }
+ public bool ResetBuffersBeforeRequest { get; }
+ }
+
+ public SerialPortManager()
+ {
+ ConnectionMonitorRegistry.RegisterProvider(this);
+ }
+
+ public string ConnectionType => ConnectionTypeName;
+
+ protected override string CreateKey(ConnectionParameters parameters)
+ {
+ return string.Join(",", new object[]
+ {
+ parameters.PortName.ToUpperInvariant(),
+ parameters.BaudRate,
+ parameters.Parity,
+ parameters.DataBits,
+ parameters.StopBits,
+ parameters.Handshake,
+ parameters.ReadTimeout,
+ parameters.WriteTimeout
+ });
+ }
+
+ protected override Task OpenConnectionAsync(ConnectionParameters parameters, CancellationToken token)
+ {
+ token.ThrowIfCancellationRequested();
+
+ var port = new SerialPort(parameters.PortName, parameters.BaudRate, parameters.Parity, parameters.DataBits, parameters.StopBits)
+ {
+ Handshake = parameters.Handshake,
+ ReadTimeout = parameters.ReadTimeout,
+ WriteTimeout = parameters.WriteTimeout
+ };
+
+ try
+ {
+ port.Open();
+ return Task.FromResult(port);
+ }
+ catch
+ {
+ port.Dispose();
+ throw;
+ }
+ }
+
+ protected override bool IsConnectionValid(SerialPort connection)
+ {
+ return connection != null && connection.IsOpen;
+ }
+
+ protected override void CloseConnection(SerialPort connection)
+ {
+ try
+ {
+ if (connection != null && connection.IsOpen)
+ {
+ connection.Close();
+ }
+ }
+ finally
+ {
+ connection?.Dispose();
+ }
+ }
+
+ public Task ExecuteAsync(
+ ConnectionParameters parameters,
+ Func> operation,
+ int maxAttempts = 2,
+ CancellationToken cancellationToken = default)
+ {
+ if (operation == null) throw new ArgumentNullException(nameof(operation));
+ return ExecuteWithRetryAsync(parameters, operation, maxAttempts, cancellationToken);
+ }
+
+ public async Task SendAndReceiveAsync(
+ ConnectionParameters parameters,
+ string message,
+ Encoding encoding,
+ CancellationToken cancellationToken = default)
+ {
+ if (encoding == null) throw new ArgumentNullException(nameof(encoding));
+ byte[] payload = encoding.GetBytes(message ?? string.Empty);
+
+ return await ExecuteWithRetryAsync(
+ parameters,
+ (port, token) => SendAndReceiveInternalAsync(
+ port,
+ parameters.ReadBufferSize,
+ payload,
+ encoding,
+ parameters.ResetBuffersBeforeRequest,
+ token),
+ 2,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public IReadOnlyCollection GetStatuses()
+ {
+ var snapshots = SnapshotConnections();
+ var result = new List(snapshots.Count);
+
+ foreach (var snapshot in snapshots)
+ {
+ string state;
+ try
+ {
+ state = snapshot.Connection?.IsOpen == true ? "Open" : "Closed";
+ }
+ catch
+ {
+ state = "Unknown";
+ }
+
+ var parameters = snapshot.Parameters;
+ string description = $"{parameters.PortName} @ {parameters.BaudRate} bps";
+
+ result.Add(new ManagedConnectionStatus(
+ ConnectionTypeName,
+ snapshot.Key,
+ state,
+ description,
+ snapshot.IsValid));
+ }
+
+ return result;
+ }
+
+ public bool TryClose(string key)
+ {
+ return TryRemoveByKey(key);
+ }
+
+ private static async Task SendAndReceiveInternalAsync(
+ SerialPort port,
+ int bufferSize,
+ byte[] payload,
+ Encoding encoding,
+ bool resetBuffers,
+ CancellationToken token)
+ {
+ if (resetBuffers)
+ {
+ port.DiscardInBuffer();
+ port.DiscardOutBuffer();
+ }
+
+ if (payload.Length > 0)
+ {
+ await Task.Run(() => port.Write(payload, 0, payload.Length), token).ConfigureAwait(false);
+ }
+
+ using (var stream = new MemoryStream())
+ {
+ var buffer = new byte[bufferSize];
+ const int MaxResponseBytes = 1 * 1024 * 1024; // 1 MiB safety cap
+ while (true)
+ {
+ try
+ {
+ int read = await Task.Run(() => port.Read(buffer, 0, buffer.Length), token).ConfigureAwait(false);
+ if (read > 0)
+ {
+ if (stream.Length + read > MaxResponseBytes)
+ throw new InvalidOperationException("Serial response exceeded maximum allowed size.");
+ stream.Write(buffer, 0, read);
+ continue; // keep reading until idle timeout
+ }
+ }
+ catch (TimeoutException)
+ {
+ break;
+ }
+ }
+
+ return encoding.GetString(stream.ToArray());
+ }
+ }
+ }
+}
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs
index 4d953c3..433e13b 100644
--- a/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs
@@ -4,7 +4,7 @@
// https://github.com/gnh1201/welsonjs
//
using System;
-using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
@@ -13,86 +13,82 @@ using System.Threading.Tasks;
namespace WelsonJS.Launcher
{
- public class WebSocketManager
+ public sealed class WebSocketManager : ConnectionManagerBase, IManagedConnectionProvider
{
- private class Entry
+ private const string ConnectionTypeName = "WebSocket";
+
+ public struct Endpoint
{
- public ClientWebSocket Socket;
- public string Host;
- public int Port;
- public string Path;
+ public Endpoint(string host, int port, string path)
+ {
+ Host = host ?? throw new ArgumentNullException(nameof(host));
+ Port = port;
+ Path = path ?? string.Empty;
+ }
+
+ public string Host { get; }
+ public int Port { get; }
+ public string Path { get; }
}
- private readonly ConcurrentDictionary _pool = new ConcurrentDictionary();
-
- // Create a unique cache key using MD5 hash
- private string MakeKey(string host, int port, string path)
+ public WebSocketManager()
{
- string raw = host + ":" + port + "/" + path;
+ ConnectionMonitorRegistry.RegisterProvider(this);
+ }
+
+ public string ConnectionType => ConnectionTypeName;
+
+ protected override string CreateKey(Endpoint parameters)
+ {
+ string raw = parameters.Host + ":" + parameters.Port + "/" + parameters.Path;
using (var md5 = MD5.Create())
{
byte[] hash = md5.ComputeHash(Encoding.UTF8.GetBytes(raw));
- return BitConverter.ToString(hash).Replace("-", "").ToLower();
+ return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
}
}
- // Get an open WebSocket or connect a new one
- public async Task GetOrCreateAsync(string host, int port, string path)
+ protected override async Task OpenConnectionAsync(Endpoint parameters, CancellationToken token)
{
- string key = MakeKey(host, port, path);
-
- if (_pool.TryGetValue(key, out var entry))
- {
- var sock = entry.Socket;
-
- if (sock == null || sock.State != WebSocketState.Open)
- {
- Remove(host, port, path);
- }
- else
- {
- return sock;
- }
- }
-
- var newSock = new ClientWebSocket();
- var uri = new Uri($"ws://{host}:{port}/{path}");
+ var socket = new ClientWebSocket();
+ var uri = new Uri($"ws://{parameters.Host}:{parameters.Port}/{parameters.Path}");
try
{
- await newSock.ConnectAsync(uri, CancellationToken.None);
-
- _pool[key] = new Entry
- {
- Socket = newSock,
- Host = host,
- Port = port,
- Path = path
- };
-
- return newSock;
+ await socket.ConnectAsync(uri, token).ConfigureAwait(false);
+ return socket;
}
catch (Exception ex)
{
- newSock.Dispose();
- Remove(host, port, path);
+ socket.Dispose();
throw new WebSocketException("WebSocket connection failed", ex);
}
}
- // Remove a socket from the pool and dispose it
+ protected override bool IsConnectionValid(ClientWebSocket connection)
+ {
+ return connection != null && connection.State == WebSocketState.Open;
+ }
+
+ protected override void CloseConnection(ClientWebSocket connection)
+ {
+ try
+ {
+ connection?.Abort();
+ }
+ catch
+ {
+ // Ignore abort exceptions.
+ }
+ finally
+ {
+ connection?.Dispose();
+ }
+ }
+
public void Remove(string host, int port, string path)
{
- string key = MakeKey(host, port, path);
- if (_pool.TryRemove(key, out var entry))
- {
- try
- {
- entry.Socket?.Abort();
- entry.Socket?.Dispose();
- }
- catch { /* Ignore dispose exceptions */ }
- }
+ Remove(new Endpoint(host, port, path));
}
// Send and receive with automatic retry on first failure
@@ -103,35 +99,68 @@ namespace WelsonJS.Launcher
? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec))
: new CancellationTokenSource();
- for (int attempt = 0; attempt < 2; attempt++)
+ try
{
+ return await ExecuteWithRetryAsync(
+ new Endpoint(host, port, path),
+ (socket, token) => TrySendAndReceiveAsync(socket, buf, token),
+ 2,
+ cts.Token).ConfigureAwait(false);
+ }
+ finally
+ {
+ cts.Dispose();
+ }
+ }
+
+ public IReadOnlyCollection GetStatuses()
+ {
+ var snapshots = SnapshotConnections();
+ var result = new List(snapshots.Count);
+
+ foreach (var snapshot in snapshots)
+ {
+ string state;
try
{
- return await TrySendAndReceiveAsync(host, port, path, buf, cts.Token);
+ state = snapshot.Connection?.State.ToString() ?? "Unknown";
}
catch
{
- Remove(host, port, path);
- if (attempt == 1) throw;
+ state = "Unknown";
}
+
+ var endpoint = snapshot.Parameters;
+ var description = $"ws://{endpoint.Host}:{endpoint.Port}/{endpoint.Path}";
+
+ result.Add(new ManagedConnectionStatus(
+ ConnectionTypeName,
+ snapshot.Key,
+ state,
+ description,
+ snapshot.IsValid));
}
- throw new InvalidOperationException("Unreachable");
+ return result;
+ }
+
+ public bool TryClose(string key)
+ {
+ return TryRemoveByKey(key);
}
// Actual send and receive implementation that never truncates the accumulated data.
// - Uses a fixed-size read buffer ONLY for I/O
// - Accumulates dynamically into a List until EndOfMessage
- private async Task TrySendAndReceiveAsync(string host, int port, string path, byte[] buf, CancellationToken token)
+ private async Task TrySendAndReceiveAsync(ClientWebSocket socket, byte[] buf, CancellationToken token)
{
try
{
- var sock = await GetOrCreateAsync(host, port, path);
- if (sock.State != WebSocketState.Open)
+ if (socket.State != WebSocketState.Open)
throw new WebSocketException("WebSocket is not in an open state");
// Send request as a single text frame
- await sock.SendAsync(new ArraySegment(buf), WebSocketMessageType.Text, true, token);
+ await socket.SendAsync(new ArraySegment(buf), WebSocketMessageType.Text, true, token).ConfigureAwait(false);
// Fixed-size read buffer for I/O (does NOT cap total message size)
byte[] readBuffer = new byte[8192];
@@ -142,12 +171,12 @@ namespace WelsonJS.Launcher
while (true)
{
- var res = await sock.ReceiveAsync(new ArraySegment(readBuffer), token);
+ var res = await socket.ReceiveAsync(new ArraySegment(readBuffer), token).ConfigureAwait(false);
if (res.MessageType == WebSocketMessageType.Close)
{
- try { await sock.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing as requested by server", token); } catch { }
- throw new WebSocketException($"WebSocket closed by server: {sock.CloseStatus} {sock.CloseStatusDescription}");
+ try { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing as requested by server", token).ConfigureAwait(false); } catch { }
+ throw new WebSocketException($"WebSocket closed by server: {socket.CloseStatus} {socket.CloseStatusDescription}");
}
if (res.Count > 0)
diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj b/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj
index 5bc8b61..b171397 100644
--- a/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj
+++ b/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj
@@ -80,6 +80,7 @@
+
@@ -87,11 +88,21 @@
+
+
+ Form
+
+
+ ConnectionMonitorForm.cs
+
+
+
+
@@ -128,11 +139,15 @@
GlobalSettingsForm.cs
+
EnvForm.cs
+
+ ConnectionMonitorForm.cs
+
InstancesForm.cs