From 39f0840087932d91796dbc678657bcd3acca3b60 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:48:51 +0700 Subject: [PATCH] Fix IPC framing, add global pipe name, and auto-reconnect tests --- .../Mocks/MockFingerprintScanner.cs | 2 + .../ScannerRecoveryTests.cs | 41 +++++++++++++++ .../Services/TerminalIpc.cs | 50 ++++++++++++++++--- docs/HARDWARE-TEST-SLK20R.md | 1 + 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs b/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs index b3218f2..e89b438 100644 --- a/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs +++ b/apps/KronaStaffTerminal.Tests/Mocks/MockFingerprintScanner.cs @@ -15,6 +15,7 @@ public sealed class MockFingerprintScanner : IFingerprintScanner public IReadOnlyList DiscoveredDevices => _devices; public bool IsConnected => State == ScannerState.Connected || State == ScannerState.Busy; public int SkippedTemplateCount { get; set; } = 0; + public int ConnectCallCount { get; private set; } = 0; public event Action? FingerRecognized; public event Action? RegistrationProgress; @@ -47,6 +48,7 @@ public sealed class MockFingerprintScanner : IFingerprintScanner public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates) { + ConnectCallCount++; if (_devices.Count == 0) { State = ScannerState.DriverError; diff --git a/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs b/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs index 7373a43..433ed23 100644 --- a/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs +++ b/apps/KronaStaffTerminal.Tests/ScannerRecoveryTests.cs @@ -73,4 +73,45 @@ public class ScannerRecoveryTests Assert.Null(error); Assert.False(rehashNeeded); } + + [Fact] + public async Task Scanner_AutoReconnectLogic_CallsConnectSuccessfully() + { + using var scanner = new MockFingerprintScanner(); + scanner.Connect(0, Array.Empty<(int, string)>()); + Assert.Equal(1, scanner.ConnectCallCount); + + var reconnectTcs = new TaskCompletionSource(); + scanner.ConnectionLost += async () => + { + // Simulate typical application retry loop + int retries = 0; + while (retries < 5) + { + await Task.Delay(10); + try + { + scanner.Connect(0, Array.Empty<(int, string)>()); + if (scanner.IsConnected) + { + reconnectTcs.TrySetResult(); + return; + } + } + catch { } + retries++; + } + reconnectTcs.TrySetException(new Exception("Failed to reconnect")); + }; + + scanner.SimulateDisconnect(); + // Emulate the hardware coming back after a short delay + await Task.Delay(20); + scanner.AddVirtualDevice(0, "SLK20R", "SERIAL"); // ensure device is discoverable if it was cleared + + await reconnectTcs.Task; + + Assert.True(scanner.ConnectCallCount > 1); + Assert.Equal(ScannerState.Connected, scanner.State); + } } diff --git a/apps/KronaStaffTerminal/Services/TerminalIpc.cs b/apps/KronaStaffTerminal/Services/TerminalIpc.cs index f287eed..11729b2 100644 --- a/apps/KronaStaffTerminal/Services/TerminalIpc.cs +++ b/apps/KronaStaffTerminal/Services/TerminalIpc.cs @@ -37,6 +37,7 @@ public sealed class IpcServiceStatus public static class TerminalIpcConstants { public const string PipeName = "KronaStaffTerminalPipe"; + public const string PipeServerName = "."; } public sealed class TerminalIpcClient @@ -74,20 +75,36 @@ public sealed class TerminalIpcClient { try { - await using var pipe = new NamedPipeClientStream(".", TerminalIpcConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + await using var pipe = new NamedPipeClientStream(TerminalIpcConstants.PipeServerName, TerminalIpcConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(TimeSpan.FromSeconds(2)); await pipe.ConnectAsync(timeoutCts.Token); var request = new IpcRequest { Command = command, Payload = payload }; - var json = JsonSerializer.Serialize(request) + "\n"; + var json = JsonSerializer.Serialize(request); var bytes = Encoding.UTF8.GetBytes(json); + var lengthPrefix = BitConverter.GetBytes(bytes.Length); + await pipe.WriteAsync(lengthPrefix, cancellationToken); await pipe.WriteAsync(bytes, cancellationToken); await pipe.FlushAsync(cancellationToken); - using var reader = new StreamReader(pipe, Encoding.UTF8); - var responseJson = await reader.ReadLineAsync(cancellationToken); + var respLenBytes = new byte[4]; + var read = await pipe.ReadAsync(respLenBytes, cancellationToken); + if (read < 4) return null; + var respLen = BitConverter.ToInt32(respLenBytes); + if (respLen is <= 0 or > 10 * 1024 * 1024) return null; + + var respBytes = new byte[respLen]; + var totalRead = 0; + while (totalRead < respLen) + { + var r = await pipe.ReadAsync(respBytes.AsMemory(totalRead), cancellationToken); + if (r == 0) return null; + totalRead += r; + } + + var responseJson = Encoding.UTF8.GetString(respBytes); if (string.IsNullOrWhiteSpace(responseJson)) return null; return JsonSerializer.Deserialize(responseJson); @@ -152,8 +169,22 @@ public sealed class TerminalIpcServer : IDisposable { try { - using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); - var line = await reader.ReadLineAsync(token); + var reqLenBytes = new byte[4]; + var read = await stream.ReadAsync(reqLenBytes, token); + if (read < 4) return; + var reqLen = BitConverter.ToInt32(reqLenBytes); + if (reqLen is <= 0 or > 10 * 1024 * 1024) return; + + var reqBytes = new byte[reqLen]; + var totalRead = 0; + while (totalRead < reqLen) + { + var r = await stream.ReadAsync(reqBytes.AsMemory(totalRead), token); + if (r == 0) return; + totalRead += r; + } + + var line = Encoding.UTF8.GetString(reqBytes); if (string.IsNullOrWhiteSpace(line)) return; var request = JsonSerializer.Deserialize(line); @@ -161,8 +192,11 @@ public sealed class TerminalIpcServer : IDisposable ? await _handler(request) : new IpcResponse { Success = false, ErrorMessage = "Invalid request" }; - var responseJson = JsonSerializer.Serialize(response) + "\n"; + var responseJson = JsonSerializer.Serialize(response); var responseBytes = Encoding.UTF8.GetBytes(responseJson); + var lengthPrefix = BitConverter.GetBytes(responseBytes.Length); + + await stream.WriteAsync(lengthPrefix, token); await stream.WriteAsync(responseBytes, token); await stream.FlushAsync(token); } @@ -185,7 +219,7 @@ public sealed class TerminalIpcServer : IDisposable pipeSecurity.AddAccessRule(new PipeAccessRule(usersSid, PipeAccessRights.ReadWrite, AccessControlType.Allow)); return NamedPipeServerStreamAcl.Create( - TerminalIpcConstants.PipeName, + @"global\" + TerminalIpcConstants.PipeName, PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, diff --git a/docs/HARDWARE-TEST-SLK20R.md b/docs/HARDWARE-TEST-SLK20R.md index 4d9565c..f1aa180 100644 --- a/docs/HARDWARE-TEST-SLK20R.md +++ b/docs/HARDWARE-TEST-SLK20R.md @@ -123,3 +123,4 @@ If an enumerated slot returns a serial number that has already been registered b | USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED | | Cable Re-insertion to Active Ready State | < 3000 ms | 2150 ms | PASSED | | Duplicate Scan Guard (30s lockout) | 100% rejection | 100% rejection (0 duplicate punches) | PASSED | +| Windows Service startup persistence after reboot | Reliable | NOT TESTED YET IN HARDWARE (Requires real service installation test after machine reboot to verify background sync without login) | PENDING |