From 6e759008dda649c4e9c7ea64a75173bc916b845a Mon Sep 17 00:00:00 2001 From: "Namhyeon, Go" Date: Tue, 28 Oct 2025 13:30:48 +0900 Subject: [PATCH 1/7] Abstract connection management and add serial port support --- .../ConnectionManagerBase.cs | 147 ++++++++++++++ .../WelsonJS.Launcher/SerialPortManager.cs | 183 ++++++++++++++++++ .../WelsonJS.Launcher/WebSocketManager.cs | 135 ++++++------- .../WelsonJS.Launcher.csproj | 3 + 4 files changed, 392 insertions(+), 76 deletions(-) create mode 100644 WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs create mode 100644 WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs new file mode 100644 index 0000000..68fb8c2 --- /dev/null +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs @@ -0,0 +1,147 @@ +// 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.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(); + + /// + /// 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) && IsConnectionValid(existing)) + { + return existing; + } + + RemoveInternal(key, existing); + + var connection = await OpenConnectionAsync(parameters, token).ConfigureAwait(false); + _pool[key] = connection; + return connection; + } + + /// + /// Removes the connection associated with the provided parameters. + /// + public void Remove(TParameters parameters) + { + string key = CreateKey(parameters); + if (_pool.TryRemove(key, out var connection)) + { + CloseSafely(connection); + } + } + + /// + /// 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; + + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + token.ThrowIfCancellationRequested(); + var connection = await GetOrCreateAsync(parameters, token).ConfigureAwait(false); + + try + { + return await operation(connection, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lastError = ex; + Remove(parameters); + if (attempt == maxAttempts - 1) + { + throw; + } + } + } + + throw lastError ?? new InvalidOperationException("Unreachable state in ExecuteWithRetryAsync"); + } + + 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. + } + } + } +} diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs new file mode 100644 index 0000000..82d2fa3 --- /dev/null +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs @@ -0,0 +1,183 @@ +// 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.IO; +using System.IO.Ports; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace WelsonJS.Launcher +{ + public sealed class SerialPortManager : ConnectionManagerBase + { + 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) + { + 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; + } + + 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; } + } + + protected override string CreateKey(ConnectionParameters parameters) + { + return string.Join(",", new object[] + { + parameters.PortName.ToUpperInvariant(), + parameters.BaudRate, + parameters.Parity, + parameters.DataBits, + parameters.StopBits, + parameters.Handshake + }); + } + + 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, token), + 2, + cancellationToken).ConfigureAwait(false); + } + + private static async Task SendAndReceiveInternalAsync( + SerialPort port, + int bufferSize, + byte[] payload, + Encoding encoding, + CancellationToken token) + { + 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]; + + while (true) + { + try + { + int read = await Task.Run(() => port.Read(buffer, 0, buffer.Length), token).ConfigureAwait(false); + if (read > 0) + { + stream.Write(buffer, 0, read); + if (read < buffer.Length) + { + break; + } + } + else + { + break; + } + } + 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..f34ebcb 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs @@ -4,7 +4,6 @@ // https://github.com/gnh1201/welsonjs // using System; -using System.Collections.Concurrent; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; @@ -13,86 +12,73 @@ using System.Threading.Tasks; namespace WelsonJS.Launcher { - public class WebSocketManager + public sealed class WebSocketManager : ConnectionManagerBase { - private class Entry + 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) + protected override string CreateKey(Endpoint parameters) { - string raw = host + ":" + port + "/" + path; + 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 +89,32 @@ namespace WelsonJS.Launcher ? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec)) : new CancellationTokenSource(); - for (int attempt = 0; attempt < 2; attempt++) + try { - try - { - return await TrySendAndReceiveAsync(host, port, path, buf, cts.Token); - } - catch - { - Remove(host, port, path); - if (attempt == 1) throw; - } + return await ExecuteWithRetryAsync( + new Endpoint(host, port, path), + (socket, token) => TrySendAndReceiveAsync(socket, buf, token), + 2, + cts.Token).ConfigureAwait(false); + } + finally + { + cts.Dispose(); } - - throw new InvalidOperationException("Unreachable"); } // 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 +125,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..e1fc1f4 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj @@ -80,6 +80,7 @@ + @@ -87,6 +88,7 @@ + @@ -128,6 +130,7 @@ GlobalSettingsForm.cs + From b8362e570cc4d88187d9d2f865220d76b84b96ff Mon Sep 17 00:00:00 2001 From: "Namhyeon, Go" Date: Tue, 28 Oct 2025 13:42:35 +0900 Subject: [PATCH 2/7] Add connection monitor for managed transports --- .../ConnectionManagerBase.cs | 98 ++++++++++++- .../ConnectionMonitorForm.Designer.cs | 132 +++++++++++++++++ .../ConnectionMonitorForm.cs | 137 ++++++++++++++++++ .../ConnectionMonitorForm.resx | 120 +++++++++++++++ .../ConnectionMonitorRegistry.cs | 56 +++++++ .../IManagedConnectionProvider.cs | 30 ++++ .../WelsonJS.Launcher/MainForm.Designer.cs | 10 ++ .../WelsonJS.Launcher/MainForm.cs | 12 ++ .../ManagedConnectionStatus.cs | 32 ++++ .../WelsonJS.Launcher/SerialPortManager.cs | 48 +++++- .../WelsonJS.Launcher/WebSocketManager.cs | 48 +++++- .../WelsonJS.Launcher.csproj | 12 ++ 12 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.Designer.cs create mode 100644 WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.cs create mode 100644 WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorForm.resx create mode 100644 WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionMonitorRegistry.cs create mode 100644 WelsonJS.Toolkit/WelsonJS.Launcher/IManagedConnectionProvider.cs create mode 100644 WelsonJS.Toolkit/WelsonJS.Launcher/ManagedConnectionStatus.cs diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs index 68fb8c2..9bf436f 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs @@ -5,6 +5,7 @@ // using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -19,7 +20,8 @@ namespace WelsonJS.Launcher public abstract class ConnectionManagerBase where TConnection : class { - private readonly ConcurrentDictionary _pool = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _pool + = new ConcurrentDictionary(); /// /// Creates a unique cache key for the given connection parameters. @@ -54,15 +56,15 @@ namespace WelsonJS.Launcher { string key = CreateKey(parameters); - if (_pool.TryGetValue(key, out var existing) && IsConnectionValid(existing)) + if (_pool.TryGetValue(key, out var existing) && IsConnectionHealthy(existing.Connection)) { - return existing; + return existing.Connection; } - RemoveInternal(key, existing); + RemoveInternal(key, existing.Connection); var connection = await OpenConnectionAsync(parameters, token).ConfigureAwait(false); - _pool[key] = connection; + _pool[key] = (connection, parameters); return connection; } @@ -72,12 +74,55 @@ namespace WelsonJS.Launcher public void Remove(TParameters parameters) { string key = CreateKey(parameters); - if (_pool.TryRemove(key, out var connection)) + if (_pool.TryRemove(key, out var entry)) { - CloseSafely(connection); + 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. /// @@ -119,6 +164,23 @@ namespace WelsonJS.Launcher 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)) @@ -143,5 +205,27 @@ namespace WelsonJS.Launcher // 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 index 82d2fa3..144b5a8 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs @@ -4,6 +4,7 @@ // https://github.com/gnh1201/welsonjs // using System; +using System.Collections.Generic; using System.IO; using System.IO.Ports; using System.Text; @@ -12,8 +13,10 @@ using System.Threading.Tasks; namespace WelsonJS.Launcher { - public sealed class SerialPortManager : ConnectionManagerBase + public sealed class SerialPortManager : ConnectionManagerBase, IManagedConnectionProvider { + private const string ConnectionTypeName = "Serial Port"; + public struct ConnectionParameters { public ConnectionParameters( @@ -51,6 +54,13 @@ namespace WelsonJS.Launcher public int ReadBufferSize { get; } } + public SerialPortManager() + { + ConnectionMonitorRegistry.RegisterProvider(this); + } + + public string ConnectionType => ConnectionTypeName; + protected override string CreateKey(ConnectionParameters parameters) { return string.Join(",", new object[] @@ -133,6 +143,42 @@ namespace WelsonJS.Launcher 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, diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs index f34ebcb..433e13b 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/WebSocketManager.cs @@ -4,6 +4,7 @@ // https://github.com/gnh1201/welsonjs // using System; +using System.Collections.Generic; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; @@ -12,8 +13,10 @@ using System.Threading.Tasks; namespace WelsonJS.Launcher { - public sealed class WebSocketManager : ConnectionManagerBase + public sealed class WebSocketManager : ConnectionManagerBase, IManagedConnectionProvider { + private const string ConnectionTypeName = "WebSocket"; + public struct Endpoint { public Endpoint(string host, int port, string path) @@ -28,6 +31,13 @@ namespace WelsonJS.Launcher public string Path { get; } } + public WebSocketManager() + { + ConnectionMonitorRegistry.RegisterProvider(this); + } + + public string ConnectionType => ConnectionTypeName; + protected override string CreateKey(Endpoint parameters) { string raw = parameters.Host + ":" + parameters.Port + "/" + parameters.Path; @@ -103,6 +113,42 @@ namespace WelsonJS.Launcher } } + public IReadOnlyCollection GetStatuses() + { + var snapshots = SnapshotConnections(); + var result = new List(snapshots.Count); + + foreach (var snapshot in snapshots) + { + string state; + try + { + state = snapshot.Connection?.State.ToString() ?? "Unknown"; + } + catch + { + 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)); + } + + 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 diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj b/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj index e1fc1f4..b171397 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/WelsonJS.Launcher.csproj @@ -89,11 +89,20 @@ + + Form + + + ConnectionMonitorForm.cs + + + + @@ -136,6 +145,9 @@ EnvForm.cs + + ConnectionMonitorForm.cs + InstancesForm.cs From c7890aaadb13fb63274dbc630172e92bfbee2cd6 Mon Sep 17 00:00:00 2001 From: "Namhyeon, Go" Date: Tue, 28 Oct 2025 14:12:25 +0900 Subject: [PATCH 3/7] Serialize connection creation per key --- .../ConnectionManagerBase.cs | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs index 9bf436f..d2129f5 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs @@ -22,6 +22,8 @@ namespace WelsonJS.Launcher { private readonly ConcurrentDictionary _pool = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _openLocks + = new ConcurrentDictionary(); /// /// Creates a unique cache key for the given connection parameters. @@ -61,11 +63,28 @@ namespace WelsonJS.Launcher return existing.Connection; } - RemoveInternal(key, 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; + } - var connection = await OpenConnectionAsync(parameters, token).ConfigureAwait(false); - _pool[key] = (connection, parameters); - return 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(); + } } /// From 7795946f9fa3b8d02df4290e23de67e26bd59989 Mon Sep 17 00:00:00 2001 From: "Namhyeon, Go" Date: Tue, 28 Oct 2025 14:12:31 +0900 Subject: [PATCH 4/7] Serialize per-key operations --- .../WelsonJS.Launcher/ConnectionManagerBase.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs index d2129f5..5723bd2 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/ConnectionManagerBase.cs @@ -24,6 +24,8 @@ namespace WelsonJS.Launcher = new ConcurrentDictionary(); private readonly ConcurrentDictionary _openLocks = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _opLocks + = new ConcurrentDictionary(); /// /// Creates a unique cache key for the given connection parameters. @@ -155,14 +157,16 @@ namespace WelsonJS.Launcher 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++) { - token.ThrowIfCancellationRequested(); - var connection = await GetOrCreateAsync(parameters, token).ConfigureAwait(false); - + 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) @@ -178,6 +182,10 @@ namespace WelsonJS.Launcher throw; } } + finally + { + opLock.Release(); + } } throw lastError ?? new InvalidOperationException("Unreachable state in ExecuteWithRetryAsync"); From 3ce8d126288b2b6c143eb78bb0cb07ff52d48894 Mon Sep 17 00:00:00 2001 From: "Namhyeon, Go" Date: Tue, 28 Oct 2025 14:12:37 +0900 Subject: [PATCH 5/7] Include timeouts in serial connection key --- WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs index 144b5a8..fa2db4f 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs @@ -70,7 +70,9 @@ namespace WelsonJS.Launcher parameters.Parity, parameters.DataBits, parameters.StopBits, - parameters.Handshake + parameters.Handshake, + parameters.ReadTimeout, + parameters.WriteTimeout }); } From 9f4219e971462a393c34b3ad2c16200bd9c7b259 Mon Sep 17 00:00:00 2001 From: "Namhyeon, Go" Date: Tue, 28 Oct 2025 14:12:44 +0900 Subject: [PATCH 6/7] Make serial buffer resets optional and improve receive loop --- .../WelsonJS.Launcher/SerialPortManager.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs index fa2db4f..21d94e8 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs @@ -28,7 +28,8 @@ namespace WelsonJS.Launcher Handshake handshake = Handshake.None, int readTimeout = 500, int writeTimeout = 500, - int readBufferSize = 1024) + int readBufferSize = 1024, + bool resetBuffersBeforeRequest = false) { if (string.IsNullOrWhiteSpace(portName)) throw new ArgumentNullException(nameof(portName)); @@ -41,6 +42,7 @@ namespace WelsonJS.Launcher ReadTimeout = readTimeout; WriteTimeout = writeTimeout; ReadBufferSize = readBufferSize > 0 ? readBufferSize : 1024; + ResetBuffersBeforeRequest = resetBuffersBeforeRequest; } public string PortName { get; } @@ -52,6 +54,7 @@ namespace WelsonJS.Launcher public int ReadTimeout { get; } public int WriteTimeout { get; } public int ReadBufferSize { get; } + public bool ResetBuffersBeforeRequest { get; } } public SerialPortManager() @@ -140,7 +143,13 @@ namespace WelsonJS.Launcher return await ExecuteWithRetryAsync( parameters, - (port, token) => SendAndReceiveInternalAsync(port, parameters.ReadBufferSize, payload, encoding, token), + (port, token) => SendAndReceiveInternalAsync( + port, + parameters.ReadBufferSize, + payload, + encoding, + parameters.ResetBuffersBeforeRequest, + token), 2, cancellationToken).ConfigureAwait(false); } @@ -186,10 +195,14 @@ namespace WelsonJS.Launcher int bufferSize, byte[] payload, Encoding encoding, + bool resetBuffers, CancellationToken token) { - port.DiscardInBuffer(); - port.DiscardOutBuffer(); + if (resetBuffers) + { + port.DiscardInBuffer(); + port.DiscardOutBuffer(); + } if (payload.Length > 0) { @@ -208,14 +221,17 @@ namespace WelsonJS.Launcher if (read > 0) { stream.Write(buffer, 0, read); - if (read < buffer.Length) + if (port.BytesToRead == 0) { break; } } else { - break; + if (port.BytesToRead == 0) + { + break; + } } } catch (TimeoutException) From 4489c3150919db2bc937f6e730c5e7a3c6427262 Mon Sep 17 00:00:00 2001 From: "Namhyeon, Go" Date: Tue, 28 Oct 2025 16:36:58 +0900 Subject: [PATCH 7/7] Guard serial reads with timeout-based termination and cap --- .../WelsonJS.Launcher/SerialPortManager.cs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs index 21d94e8..feadbcd 100644 --- a/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs +++ b/WelsonJS.Toolkit/WelsonJS.Launcher/SerialPortManager.cs @@ -212,7 +212,7 @@ namespace WelsonJS.Launcher using (var stream = new MemoryStream()) { var buffer = new byte[bufferSize]; - + const int MaxResponseBytes = 1 * 1024 * 1024; // 1 MiB safety cap while (true) { try @@ -220,18 +220,10 @@ namespace WelsonJS.Launcher 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); - if (port.BytesToRead == 0) - { - break; - } - } - else - { - if (port.BytesToRead == 0) - { - break; - } + continue; // keep reading until idle timeout } } catch (TimeoutException)