Merge pull request #339 from gnh1201/codex/abstract-connection-management-for-serial-port

Abstract connection management and add serial port support
This commit is contained in:
Namhyeon Go 2025-11-02 17:36:39 +09:00 committed by GitHub
commit a638a7a6e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1139 additions and 69 deletions

View File

@ -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
{
/// <summary>
/// Provides a reusable pattern for keeping long-lived connections alive and
/// recreating them transparently when the underlying connection becomes invalid.
/// </summary>
/// <typeparam name="TParameters">A descriptor used to create a unique key for each connection.</typeparam>
/// <typeparam name="TConnection">The concrete connection type.</typeparam>
public abstract class ConnectionManagerBase<TParameters, TConnection>
where TConnection : class
{
private readonly ConcurrentDictionary<string, (TConnection Connection, TParameters Parameters)> _pool
= new ConcurrentDictionary<string, (TConnection, TParameters)>();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _openLocks
= new ConcurrentDictionary<string, SemaphoreSlim>();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _opLocks
= new ConcurrentDictionary<string, SemaphoreSlim>();
/// <summary>
/// Creates a unique cache key for the given connection parameters.
/// </summary>
protected abstract string CreateKey(TParameters parameters);
/// <summary>
/// Establishes a new connection using the provided parameters.
/// </summary>
protected abstract Task<TConnection> OpenConnectionAsync(TParameters parameters, CancellationToken token);
/// <summary>
/// Validates whether the existing connection is still usable.
/// </summary>
protected abstract bool IsConnectionValid(TConnection connection);
/// <summary>
/// Releases the resources associated with a connection instance.
/// </summary>
protected virtual void CloseConnection(TConnection connection)
{
if (connection is IDisposable disposable)
{
disposable.Dispose();
}
}
/// <summary>
/// Retrieves a cached connection or creates a new one if needed.
/// </summary>
protected async Task<TConnection> 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();
}
}
/// <summary>
/// Removes the connection associated with the provided parameters.
/// </summary>
public void Remove(TParameters parameters)
{
string key = CreateKey(parameters);
if (_pool.TryRemove(key, out var entry))
{
CloseSafely(entry.Connection);
}
}
/// <summary>
/// Removes the connection associated with the provided cache key.
/// </summary>
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;
}
/// <summary>
/// Provides a snapshot of the currently tracked connections.
/// </summary>
protected IReadOnlyList<ConnectionSnapshot> 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;
}
/// <summary>
/// Executes an action against the managed connection, retrying once if the first attempt fails.
/// </summary>
protected async Task<TResult> ExecuteWithRetryAsync<TResult>(
TParameters parameters,
Func<TConnection, CancellationToken, Task<TResult>> 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.
}
}
/// <summary>
/// Represents an immutable snapshot of a managed connection.
/// </summary>
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; }
}
}
}

View File

@ -0,0 +1,132 @@
namespace WelsonJS.Launcher
{
partial class ConnectionMonitorForm
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
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;
}
}

View File

@ -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<string>();
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<IManagedConnectionProvider> providers = ConnectionMonitorRegistry.GetProviders();
lvConnections.BeginUpdate();
lvConnections.Items.Clear();
foreach (var provider in providers)
{
IReadOnlyCollection<ManagedConnectionStatus> 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; }
}
}
}

View File

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@ -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
{
/// <summary>
/// Keeps track of connection providers that should appear in the monitor UI.
/// </summary>
public static class ConnectionMonitorRegistry
{
private static readonly object _syncRoot = new object();
private static readonly List<IManagedConnectionProvider> _providers = new List<IManagedConnectionProvider>();
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<IManagedConnectionProvider> GetProviders()
{
lock (_syncRoot)
{
return _providers.ToArray();
}
}
}
}

View File

@ -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
{
/// <summary>
/// Exposes connection status information for use by the connection monitor UI.
/// </summary>
public interface IManagedConnectionProvider
{
/// <summary>
/// Gets a human-friendly name for the connection type managed by this provider.
/// </summary>
string ConnectionType { get; }
/// <summary>
/// Retrieves the current connections handled by the provider.
/// </summary>
IReadOnlyCollection<ManagedConnectionStatus> GetStatuses();
/// <summary>
/// Attempts to close the connection associated with the supplied cache key.
/// </summary>
bool TryClose(string key);
}
}

View File

@ -39,6 +39,7 @@
this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.userdefinedVariablesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.userdefinedVariablesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.instancesToolStripMenuItem = 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.runAsAdministratorToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.globalSettingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.globalSettingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.startTheEditorToolStripMenuItem = 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.settingsToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.userdefinedVariablesToolStripMenuItem, this.userdefinedVariablesToolStripMenuItem,
this.instancesToolStripMenuItem, this.instancesToolStripMenuItem,
this.connectionMonitorToolStripMenuItem,
this.runAsAdministratorToolStripMenuItem, this.runAsAdministratorToolStripMenuItem,
this.globalSettingsToolStripMenuItem, this.globalSettingsToolStripMenuItem,
this.startTheEditorToolStripMenuItem, this.startTheEditorToolStripMenuItem,
@ -153,6 +155,13 @@
this.instancesToolStripMenuItem.Size = new System.Drawing.Size(196, 22); this.instancesToolStripMenuItem.Size = new System.Drawing.Size(196, 22);
this.instancesToolStripMenuItem.Text = "Instances"; this.instancesToolStripMenuItem.Text = "Instances";
this.instancesToolStripMenuItem.Click += new System.EventHandler(this.instancesToolStripMenuItem_Click); 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 // runAsAdministratorToolStripMenuItem
// //
@ -282,6 +291,7 @@
private System.Windows.Forms.ToolStripMenuItem settingsToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem settingsToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem userdefinedVariablesToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem userdefinedVariablesToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem instancesToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem instancesToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem connectionMonitorToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem runAsAdministratorToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem runAsAdministratorToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem globalSettingsToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem globalSettingsToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem startTheEditorToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem startTheEditorToolStripMenuItem;

View File

@ -23,6 +23,7 @@ namespace WelsonJS.Launcher
private string _workingDirectory; private string _workingDirectory;
private string _instanceId; private string _instanceId;
private string _scriptName; private string _scriptName;
private ConnectionMonitorForm _connectionMonitorForm;
public MainForm(ICompatibleLogger logger = null) public MainForm(ICompatibleLogger logger = null)
{ {
@ -276,6 +277,17 @@ namespace WelsonJS.Launcher
(new InstancesForm()).Show(); (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) private void runAsAdministratorToolStripMenuItem_Click(object sender, EventArgs e)
{ {
if (!IsInAdministrator()) if (!IsInAdministrator())

View File

@ -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
{
/// <summary>
/// Represents the state of a managed connection for UI presentation.
/// </summary>
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; }
}
}

View File

@ -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<SerialPortManager.ConnectionParameters, SerialPort>, 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<SerialPort> 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<TResult> ExecuteAsync<TResult>(
ConnectionParameters parameters,
Func<SerialPort, CancellationToken, Task<TResult>> operation,
int maxAttempts = 2,
CancellationToken cancellationToken = default)
{
if (operation == null) throw new ArgumentNullException(nameof(operation));
return ExecuteWithRetryAsync(parameters, operation, maxAttempts, cancellationToken);
}
public async Task<string> 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<ManagedConnectionStatus> GetStatuses()
{
var snapshots = SnapshotConnections();
var result = new List<ManagedConnectionStatus>(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<string> 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());
}
}
}
}

View File

@ -4,7 +4,7 @@
// https://github.com/gnh1201/welsonjs // https://github.com/gnh1201/welsonjs
// //
using System; using System;
using System.Collections.Concurrent; using System.Collections.Generic;
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
@ -13,86 +13,82 @@ using System.Threading.Tasks;
namespace WelsonJS.Launcher namespace WelsonJS.Launcher
{ {
public class WebSocketManager public sealed class WebSocketManager : ConnectionManagerBase<WebSocketManager.Endpoint, ClientWebSocket>, IManagedConnectionProvider
{ {
private class Entry private const string ConnectionTypeName = "WebSocket";
public struct Endpoint
{ {
public ClientWebSocket Socket; public Endpoint(string host, int port, string path)
public string Host; {
public int Port; Host = host ?? throw new ArgumentNullException(nameof(host));
public string Path; Port = port;
Path = path ?? string.Empty;
}
public string Host { get; }
public int Port { get; }
public string Path { get; }
} }
private readonly ConcurrentDictionary<string, Entry> _pool = new ConcurrentDictionary<string, Entry>(); public WebSocketManager()
// Create a unique cache key using MD5 hash
private string MakeKey(string host, int port, string path)
{ {
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()) using (var md5 = MD5.Create())
{ {
byte[] hash = md5.ComputeHash(Encoding.UTF8.GetBytes(raw)); 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 protected override async Task<ClientWebSocket> OpenConnectionAsync(Endpoint parameters, CancellationToken token)
public async Task<ClientWebSocket> GetOrCreateAsync(string host, int port, string path)
{ {
string key = MakeKey(host, port, path); var socket = new ClientWebSocket();
var uri = new Uri($"ws://{parameters.Host}:{parameters.Port}/{parameters.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}");
try try
{ {
await newSock.ConnectAsync(uri, CancellationToken.None); await socket.ConnectAsync(uri, token).ConfigureAwait(false);
return socket;
_pool[key] = new Entry
{
Socket = newSock,
Host = host,
Port = port,
Path = path
};
return newSock;
} }
catch (Exception ex) catch (Exception ex)
{ {
newSock.Dispose(); socket.Dispose();
Remove(host, port, path);
throw new WebSocketException("WebSocket connection failed", ex); 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) public void Remove(string host, int port, string path)
{ {
string key = MakeKey(host, port, path); Remove(new Endpoint(host, port, path));
if (_pool.TryRemove(key, out var entry))
{
try
{
entry.Socket?.Abort();
entry.Socket?.Dispose();
}
catch { /* Ignore dispose exceptions */ }
}
} }
// Send and receive with automatic retry on first failure // Send and receive with automatic retry on first failure
@ -103,35 +99,68 @@ namespace WelsonJS.Launcher
? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec)) ? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec))
: new CancellationTokenSource(); : 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<ManagedConnectionStatus> GetStatuses()
{
var snapshots = SnapshotConnections();
var result = new List<ManagedConnectionStatus>(snapshots.Count);
foreach (var snapshot in snapshots)
{
string state;
try try
{ {
return await TrySendAndReceiveAsync(host, port, path, buf, cts.Token); state = snapshot.Connection?.State.ToString() ?? "Unknown";
} }
catch catch
{ {
Remove(host, port, path); state = "Unknown";
if (attempt == 1) throw;
} }
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. // Actual send and receive implementation that never truncates the accumulated data.
// - Uses a fixed-size read buffer ONLY for I/O // - Uses a fixed-size read buffer ONLY for I/O
// - Accumulates dynamically into a List<byte[]> until EndOfMessage // - Accumulates dynamically into a List<byte[]> until EndOfMessage
private async Task<string> TrySendAndReceiveAsync(string host, int port, string path, byte[] buf, CancellationToken token) private async Task<string> TrySendAndReceiveAsync(ClientWebSocket socket, byte[] buf, CancellationToken token)
{ {
try try
{ {
var sock = await GetOrCreateAsync(host, port, path); if (socket.State != WebSocketState.Open)
if (sock.State != WebSocketState.Open)
throw new WebSocketException("WebSocket is not in an open state"); throw new WebSocketException("WebSocket is not in an open state");
// Send request as a single text frame // Send request as a single text frame
await sock.SendAsync(new ArraySegment<byte>(buf), WebSocketMessageType.Text, true, token); await socket.SendAsync(new ArraySegment<byte>(buf), WebSocketMessageType.Text, true, token).ConfigureAwait(false);
// Fixed-size read buffer for I/O (does NOT cap total message size) // Fixed-size read buffer for I/O (does NOT cap total message size)
byte[] readBuffer = new byte[8192]; byte[] readBuffer = new byte[8192];
@ -142,12 +171,12 @@ namespace WelsonJS.Launcher
while (true) while (true)
{ {
var res = await sock.ReceiveAsync(new ArraySegment<byte>(readBuffer), token); var res = await socket.ReceiveAsync(new ArraySegment<byte>(readBuffer), token).ConfigureAwait(false);
if (res.MessageType == WebSocketMessageType.Close) if (res.MessageType == WebSocketMessageType.Close)
{ {
try { await sock.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing as requested by server", token); } catch { } try { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing as requested by server", token).ConfigureAwait(false); } catch { }
throw new WebSocketException($"WebSocket closed by server: {sock.CloseStatus} {sock.CloseStatusDescription}"); throw new WebSocketException($"WebSocket closed by server: {socket.CloseStatus} {socket.CloseStatusDescription}");
} }
if (res.Count > 0) if (res.Count > 0)

View File

@ -80,6 +80,7 @@
<Reference Include="System.Deployment" /> <Reference Include="System.Deployment" />
<Reference Include="System.Drawing" /> <Reference Include="System.Drawing" />
<Reference Include="System.IO.Compression.FileSystem" /> <Reference Include="System.IO.Compression.FileSystem" />
<Reference Include="System.IO.Ports" />
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
<Reference Include="System.Web" /> <Reference Include="System.Web" />
<Reference Include="System.Windows.Forms" /> <Reference Include="System.Windows.Forms" />
@ -87,11 +88,21 @@
<Reference Include="System.Xml.Linq" /> <Reference Include="System.Xml.Linq" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="ConnectionManagerBase.cs" />
<Compile Include="ConnectionMonitorForm.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="ConnectionMonitorForm.Designer.cs">
<DependentUpon>ConnectionMonitorForm.cs</DependentUpon>
</Compile>
<Compile Include="ConnectionMonitorRegistry.cs" />
<Compile Include="ICompatibleLogger.cs" /> <Compile Include="ICompatibleLogger.cs" />
<Compile Include="IManagedConnectionProvider.cs" />
<Compile Include="IResourceTool.cs" /> <Compile Include="IResourceTool.cs" />
<Compile Include="JsCore.cs" /> <Compile Include="JsCore.cs" />
<Compile Include="JsNative.cs" /> <Compile Include="JsNative.cs" />
<Compile Include="JsSerializer.cs" /> <Compile Include="JsSerializer.cs" />
<Compile Include="ManagedConnectionStatus.cs" />
<Compile Include="NativeBootstrap.cs" /> <Compile Include="NativeBootstrap.cs" />
<Compile Include="ResourceTools\ImageColorPicker.cs" /> <Compile Include="ResourceTools\ImageColorPicker.cs" />
<Compile Include="ResourceTools\IpQuery.cs" /> <Compile Include="ResourceTools\IpQuery.cs" />
@ -128,11 +139,15 @@
<DependentUpon>GlobalSettingsForm.cs</DependentUpon> <DependentUpon>GlobalSettingsForm.cs</DependentUpon>
</Compile> </Compile>
<Compile Include="ResourceServer.cs" /> <Compile Include="ResourceServer.cs" />
<Compile Include="SerialPortManager.cs" />
<Compile Include="TraceLogger.cs" /> <Compile Include="TraceLogger.cs" />
<Compile Include="WebSocketManager.cs" /> <Compile Include="WebSocketManager.cs" />
<EmbeddedResource Include="EnvForm.resx"> <EmbeddedResource Include="EnvForm.resx">
<DependentUpon>EnvForm.cs</DependentUpon> <DependentUpon>EnvForm.cs</DependentUpon>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="ConnectionMonitorForm.resx">
<DependentUpon>ConnectionMonitorForm.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Include="InstancesForm.resx"> <EmbeddedResource Include="InstancesForm.resx">
<DependentUpon>InstancesForm.cs</DependentUpon> <DependentUpon>InstancesForm.cs</DependentUpon>
</EmbeddedResource> </EmbeddedResource>