Update embedded app debugging workflow

This commit is contained in:
zoukai committed 2026-10-22 14:09:49 +08:00
1 parent d50be29246
commit 38807e5f97
13 files changed
+846 -122

No files matched your search

+95 -6
View File
@@ -29,6 +29,7 @@ namespace EmbeddedAppPcDebug
private readonly object _logLock = new object();
private readonly object _stateLock = new object();
private readonly Dictionary<string, DateTime> _webClients = new Dictionary<string, DateTime>(StringComparer.Ordinal);
private readonly Dictionary<string, HttpListenerResponse> _liveWebClients = new Dictionary<string, HttpListenerResponse>(StringComparer.Ordinal);
private HttpListener _listener;
private FileStream _rawStream;
@@ -122,6 +123,8 @@ namespace EmbeddedAppPcDebug
_rawPath));
_listener = new HttpListener();
// 必须收到页面连接断开的写异常,不能吞掉后把已关闭的页面永久计为在线。
_listener.IgnoreWriteExceptions = false;
_listener.Prefixes.Add(_listenPrefix);
_listener.Start();
@@ -269,6 +272,7 @@ namespace EmbeddedAppPcDebug
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128) return;
lock (_stateLock)
{
if (_stopping) return;
_webClients[clientId] = DateTime.UtcNow;
_hadWebClient = true;
_noClientSinceUtc = DateTime.MinValue;
@@ -276,14 +280,76 @@ namespace EmbeddedAppPcDebug
}
private void RemoveWebClient(string clientId)
{
RemoveWebClient(clientId, null, "网页主动离页");
}
private void RemoveWebClient(string clientId, HttpListenerResponse expectedResponse, string reason)
{
if (string.IsNullOrWhiteSpace(clientId)) return;
HttpListenerResponse liveResponse;
bool removed;
lock (_stateLock)
{
_webClients.Remove(clientId);
_liveWebClients.TryGetValue(clientId, out liveResponse);
// EventSource 重连可能先建立新连接,旧连接的 finally 不能删除新连接。
if (expectedResponse != null && !object.ReferenceEquals(liveResponse, expectedResponse)) return;
_liveWebClients.Remove(clientId);
removed = _webClients.Remove(clientId);
if (_hadWebClient && _webClients.Count == 0 && _noClientSinceUtc == DateTime.MinValue)
_noClientSinceUtc = DateTime.UtcNow;
}
if (liveResponse != null) { try { liveResponse.Abort(); } catch { } }
if (removed && !_stopping) WriteSystemLog(reason + ";客户端 " + clientId);
}
private void SendClientLiveness(HttpListenerContext context)
{
string clientId = context.Request.QueryString["client"];
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128)
throw new ArgumentException("页面客户端标识无效。");
HttpListenerResponse response = context.Response;
HttpListenerResponse previousResponse;
lock (_stateLock)
{
if (_stopping) throw new IOException("串口网页服务正在结束。");
_liveWebClients.TryGetValue(clientId, out previousResponse);
_liveWebClients[clientId] = response;
_webClients[clientId] = DateTime.UtcNow;
_hadWebClient = true;
_noClientSinceUtc = DateTime.MinValue;
}
if (previousResponse != null) { try { previousResponse.Abort(); } catch { } }
try
{
response.StatusCode = 200;
response.ContentType = "text/event-stream; charset=utf-8";
response.Headers["Cache-Control"] = "no-store";
response.SendChunked = true;
byte[] heartbeat = Encoding.UTF8.GetBytes("retry: 500\n: page connection alive\n\n");
// 原生 EventSource 持续连接不依赖页面 setInterval。即使页面定时器被节流,
// 仍由服务端每秒探测一次连接;页面关闭/崩溃时,写失败后释放该页面租约。
while (!_stopping)
{
lock (_stateLock)
{
HttpListenerResponse current;
if (!_liveWebClients.TryGetValue(clientId, out current) || !object.ReferenceEquals(current, response)) break;
}
response.OutputStream.Write(heartbeat, 0, heartbeat.Length);
response.OutputStream.Flush();
Thread.Sleep(1000);
}
}
catch (HttpListenerException) { /* 断开的网页连接是正常生命周期事件,不是串口故障。 */ }
catch (IOException) { }
catch (ObjectDisposedException) { }
finally
{
RemoveWebClient(clientId, response, "网页持续连接已断开(未收到主动离页通知)");
try { response.Abort(); } catch { }
}
}
private void WebClientMonitorLoop()
@@ -297,10 +363,14 @@ namespace EmbeddedAppPcDebug
List<string> expiredClients = new List<string>();
foreach (KeyValuePair<string, DateTime> item in _webClients)
{
if ((now - item.Value).TotalSeconds >= WebClientTimeoutSeconds)
if (!_liveWebClients.ContainsKey(item.Key) && (now - item.Value).TotalSeconds >= WebClientTimeoutSeconds)
expiredClients.Add(item.Key);
}
foreach (string clientId in expiredClients) _webClients.Remove(clientId);
foreach (string clientId in expiredClients)
{
_webClients.Remove(clientId);
WriteSystemLog("网页轮询心跳超时(不能据此断定页面已关闭);客户端 " + clientId);
}
if (_webClients.Count > 0)
{
@@ -310,13 +380,14 @@ namespace EmbeddedAppPcDebug
{
if (_noClientSinceUtc == DateTime.MinValue) _noClientSinceUtc = now;
shouldStop = (now - _noClientSinceUtc).TotalMilliseconds >= LastClientGraceMilliseconds;
// 判定和置位必须在同一把锁内,防止刷新页面刚接管就被旧判定停止。
if (shouldStop) _stopping = true;
}
}
if (shouldStop)
{
WriteSystemLog("最后一个串口网页已关闭,正在停止日志采集并释放串口。");
_stopping = true;
WriteSystemLog("所有串口网页连接已结束,宽限期内无页面接管;正在停止日志采集并释放串口。");
try { if (_listener != null) _listener.Stop(); } catch { }
break;
}
@@ -377,6 +448,13 @@ namespace EmbeddedAppPcDebug
return;
}
if (method == "GET" && path == "/api/client/live")
{
ValidateDisconnectRequest(context);
SendClientLiveness(context);
return;
}
if (method == "GET" && path == "/api/log")
{
TouchWebClient(context.Request.QueryString["client"]);
@@ -442,7 +520,7 @@ namespace EmbeddedAppPcDebug
private void ValidateDisconnectRequest(HttpListenerContext context)
{
// sendBeacon 无法自定义请求头,因此仅离页通知接口允许在查询参数中携带会话令牌。
// sendBeacon / EventSource 无法自定义请求头,仅页面生命周期接口允许查询令牌。
string token = context.Request.Headers["X-Serial-Token"];
if (string.IsNullOrWhiteSpace(token)) token = context.Request.QueryString["token"];
if (!string.Equals(token, _sessionToken, StringComparison.Ordinal))
@@ -503,11 +581,13 @@ namespace EmbeddedAppPcDebug
string lastError;
DateTime lastReceive;
int activeWebClients;
int liveWebClients;
lock (_stateLock)
{
lastError = _lastError;
lastReceive = _lastReceiveUtc;
activeWebClients = _webClients.Count;
liveWebClients = _liveWebClients.Count;
}
return "{" +
@@ -516,6 +596,7 @@ namespace EmbeddedAppPcDebug
"\"receivedBytes\":" + Interlocked.Read(ref _receivedBytes).ToString(CultureInfo.InvariantCulture) + "," +
"\"lastReceiveUtc\":\"" + (lastReceive == DateTime.MinValue ? string.Empty : lastReceive.ToString("o", CultureInfo.InvariantCulture)) + "\"," +
"\"activeWebClients\":" + activeWebClients.ToString(CultureInfo.InvariantCulture) + "," +
"\"liveWebClients\":" + liveWebClients.ToString(CultureInfo.InvariantCulture) + "," +
"\"lastError\":\"" + JsonEscape(lastError) + "\"" +
"}";
}
@@ -581,6 +662,14 @@ namespace EmbeddedAppPcDebug
public void Stop()
{
_stopping = true;
List<HttpListenerResponse> liveResponses;
lock (_stateLock)
{
liveResponses = new List<HttpListenerResponse>(_liveWebClients.Values);
_liveWebClients.Clear();
_webClients.Clear();
}
foreach (HttpListenerResponse response in liveResponses) { try { response.Abort(); } catch { } }
try { if (_listener != null) _listener.Stop(); } catch { }
try { if (_serialPort.IsOpen) _serialPort.Close(); } catch { }