Skip to content

Commit 870da52

Browse files
Merge pull request #572 from Hyperloop-UPV/develop
Prepara v11.1.2
2 parents 92f21f5 + f50973a commit 870da52

9 files changed

Lines changed: 165 additions & 8 deletions

File tree

backend/cmd/config.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
# Vehicle Configuration
1212
[vehicle]
13-
boards = ["HVBMS","LCU", "PCU", "VCU", "BLCU"]
13+
boards = ["HVBMS","LCU", "PCU", "VCU"]
1414

1515
# ADJ (Architecture Description JSON) Configuration
1616
[adj]
@@ -20,7 +20,6 @@ validate = true # Execute ADJ validator before starting backend
2020
# Transport Configuration
2121
[transport]
2222
propagate_fault = true
23-
2423
# TCP Configuration
2524
# These settings control how the backend reconnects to boards when connections are lost
2625
[tcp]

backend/cmd/dev-config.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
# Vehicle Configuration
1414
[vehicle]
1515
boards = ["HVSCU", "HVSCU-Cabinet", "PCU", "LCU", "BCU", "BMSL"]
16-
1716
# ADJ (Architecture Description JSON) Configuration
1817
[adj]
1918
branch = "software" # Leave blank when using ADJ as a submodule (like this: "")

backend/cmd/setup_transport.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,15 @@ func configureUDPServerTransport(
147147
if !ok {
148148
board = "unknown"
149149
}
150-
transp.ReportError(fmt.Errorf("UDP keep-alive timeout: no packets received from board %s (%s) for %dms, fault sent", board, ip, config.UDP.KeepAliveTimeoutMs))
150+
err := fmt.Errorf("UDP keep-alive timeout: no packets received from board %s (%s) for %dms, fault sent", board, ip, config.UDP.KeepAliveTimeoutMs)
151+
transp.ReportError(err)
152+
153+
// Close the board's TCP connection ourselves: the fault we just
154+
// broadcast leaves unacked data on a dead peer, which suppresses the
155+
// TCP keep-alive and would delay disconnect detection by minutes.
156+
if ok {
157+
transp.DisconnectTarget(board, err)
158+
}
151159
}
152160

153161
udpServer := udp.NewServer(

backend/pkg/transport/network/tcp/conn.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,17 @@ func (conn *connWithErr) Write(b []byte) (n int, err error) {
4343
func (conn *connWithErr) Close() error {
4444
return conn.Conn.Close()
4545
}
46+
47+
// CloseWithError closes conn, delivering reason to its error channel first so
48+
// the connection handler blocked on that channel wakes up. Closing a
49+
// connection directly would not do this, since Read/Write filter out
50+
// net.ErrClosed.
51+
func CloseWithError(conn net.Conn, reason error) error {
52+
if cwe, ok := conn.(*connWithErr); ok {
53+
select {
54+
case cwe.errors <- reason:
55+
default:
56+
}
57+
}
58+
return conn.Close()
59+
}

backend/pkg/transport/network/udp/server.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,10 @@ func (s *Server) keepAliveLoop() {
182182
Dur("timeout", s.keepAliveTimeout).
183183
Msg("keep-alive timeout: no UDP packets received")
184184
if s.OnDisconnect != nil {
185-
s.OnDisconnect(ip)
185+
// Run the callback on its own goroutine so a slow
186+
// handler (e.g. blocking TCP writes) cannot stall
187+
// timeout detection for the remaining IPs
188+
go s.OnDisconnect(ip)
186189
}
187190
}
188191
}

backend/pkg/transport/transport.go

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ func (transport *Transport) readLoopTCPConn(conn net.Conn, logger zerolog.Logger
224224
for {
225225
packet, err := transport.decoder.DecodeNext(conn)
226226
if err != nil {
227+
// net.ErrClosed means we closed the connection on purpose
228+
// (e.g. UDP keep-alive timeout), and a fault was already sent
229+
if errors.Is(err, net.ErrClosed) {
230+
return
231+
}
227232
logger.Error().Stack().Err(err).Msg("decode")
228233
transport.errChan <- err
229234
transport.SendFault()
@@ -268,6 +273,10 @@ func (transport *Transport) SendMessage(message abstraction.TransportMessage) er
268273
return err
269274
}
270275

276+
// faultWriteTimeout bounds each TCP write of the fault broadcast; boards on
277+
// the vehicle LAN ack in milliseconds, so exceeding this means a dead peer
278+
const faultWriteTimeout = time.Second
279+
271280
// handlePacketEvent is used to send an order to one of the connected boards
272281
func (transport *Transport) handlePacketEvent(message PacketMessage) error {
273282
eventLogger := transport.logger.With().Str("type", fmt.Sprintf("%T", message.Packet)).Uint16("id", uint16(message.Id())).Logger()
@@ -287,17 +296,30 @@ func (transport *Transport) handlePacketEvent(message PacketMessage) error {
287296
defer transport.connectionsMx.RUnlock()
288297
for target, conn := range transport.connections {
289298
targetName := string(target)
299+
300+
// Bound each write so a dead peer with a full send buffer cannot
301+
// hold the connections lock (and the caller) for minutes
302+
conn.SetWriteDeadline(time.Now().Add(faultWriteTimeout))
303+
var writeErr error
290304
totalWritten := 0
291305
for totalWritten < len(data) {
292306
n, err := conn.Write(data[totalWritten:])
293307
eventLogger.Trace().Str("target", targetName).Int("amount", n).Msg("written chunk")
294308
totalWritten += n
295309
if err != nil {
296-
eventLogger.Error().Str("target", targetName).Stack().Err(err).Msg("write")
297-
transport.errChan <- err
298-
return err
310+
writeErr = err
311+
break
299312
}
300313
}
314+
conn.SetWriteDeadline(time.Time{})
315+
316+
// Keep broadcasting to the remaining boards even if one write
317+
// fails: the fault must reach every live board
318+
if writeErr != nil {
319+
eventLogger.Error().Str("target", targetName).Stack().Err(writeErr).Msg("write")
320+
transport.errChan <- writeErr
321+
continue
322+
}
301323
eventLogger.Info().Str("target", targetName).Msg("sent")
302324
}
303325
return nil
@@ -475,6 +497,22 @@ func (transport *Transport) TargetFromIp(ip string) (abstraction.TransportTarget
475497
return target, ok
476498
}
477499

500+
// DisconnectTarget forcefully closes the TCP connection to target, if any.
501+
// The connection handler wakes up with reason, cleans up and notifies the
502+
// disconnection, and the client reconnection loop takes over.
503+
func (transport *Transport) DisconnectTarget(target abstraction.TransportTarget, reason error) bool {
504+
transport.connectionsMx.RLock()
505+
conn, ok := transport.connections[target]
506+
transport.connectionsMx.RUnlock()
507+
if !ok {
508+
return false
509+
}
510+
511+
transport.logger.Warn().Str("target", string(target)).Err(reason).Msg("forcefully disconnecting target")
512+
tcp.CloseWithError(conn, reason)
513+
return true
514+
}
515+
478516
func (transport *Transport) SendFault() {
479517
err := transport.SendMessage(NewPacketMessage(data.NewPacket(0)))
480518
if err != nil {

electron-app/preload.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
4848
restartBackend: () => ipcRenderer.invoke("restart-backend"),
4949
// Get the list of views available in this build
5050
getAvailableViews: () => ipcRenderer.invoke("get-available-views"),
51+
// Run the kernel setup script (used by mode selector renderer)
52+
setupKernel: () => ipcRenderer.invoke("setup-kernel"),
5153
// Set initial mode (used by mode selector renderer)
5254
setInitialMode: (mode) => {
5355
ipcRenderer.send("mode-selected", mode);

electron-app/renderer/mode-selector/index.html

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,38 @@
178178
box-shadow: inset 0 0 0 1px rgba(237, 238, 240, 0.04);
179179
}
180180

181+
.kernel-setup {
182+
margin-top: 16px;
183+
display: grid;
184+
gap: 8px;
185+
justify-items: center;
186+
}
187+
188+
.kernel-setup button {
189+
min-height: 44px;
190+
padding: 10px 24px;
191+
}
192+
193+
.kernel-setup button:disabled {
194+
opacity: 0.5;
195+
cursor: wait;
196+
transform: none;
197+
}
198+
199+
.kernel-status {
200+
font-size: 0.85rem;
201+
min-height: 1.2em;
202+
opacity: 0.85;
203+
}
204+
205+
.kernel-status.ok {
206+
color: #7ee787;
207+
}
208+
209+
.kernel-status.error {
210+
color: #ff8080;
211+
}
212+
181213
@media (max-width: 420px) {
182214
.card {
183215
padding: 20px;
@@ -209,6 +241,10 @@
209241
<h1>Control Station</h1>
210242
</div>
211243
<div class="button-grid" id="button-grid"></div>
244+
<div class="kernel-setup">
245+
<button id="setup-kernel" class="secondary">Set up kernel</button>
246+
<div id="kernel-status" class="kernel-status"></div>
247+
</div>
212248
<div class="aux-logo-wrap">
213249
<img src="src/sign.png" alt="Logo auxiliar" class="logo-aux" />
214250
<div class="app-footer">
@@ -258,6 +294,29 @@ <h1>Control Station</h1>
258294

259295
renderButtons();
260296

297+
const kernelButton = document.getElementById('setup-kernel');
298+
const kernelStatus = document.getElementById('kernel-status');
299+
kernelButton.addEventListener('click', async () => {
300+
if (!window.electronAPI?.setupKernel) {
301+
kernelStatus.textContent = 'Not available';
302+
kernelStatus.className = 'kernel-status error';
303+
return;
304+
}
305+
kernelButton.disabled = true;
306+
kernelStatus.textContent = 'Setting up kernel…';
307+
kernelStatus.className = 'kernel-status';
308+
try {
309+
const result = await window.electronAPI.setupKernel();
310+
kernelStatus.textContent = result.message;
311+
kernelStatus.className = `kernel-status ${result.success ? 'ok' : 'error'}`;
312+
} catch (e) {
313+
kernelStatus.textContent = 'Kernel setup failed';
314+
kernelStatus.className = 'kernel-status error';
315+
} finally {
316+
kernelButton.disabled = false;
317+
}
318+
});
319+
261320
const appVersionNode = document.getElementById('app-version');
262321
if (appVersionNode && window.electronAPI?.getAppVersion) {
263322
window.electronAPI.getAppVersion().then((version) => {

electron-app/src/ipc/handlers.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
*/
99

1010
import { app, dialog, ipcMain, shell } from "electron";
11+
import { execFile } from "child_process";
1112
import { readFile } from "fs/promises";
1213
import fs from "fs";
1314
import { isAbsolute, join } from "path";
15+
import { promisify } from "util";
1416
import {
1517
importConfig,
1618
readConfig,
@@ -62,6 +64,39 @@ function setupIpcHandlers() {
6264

6365
ipcMain.handle("get-app-version", () => app.getVersion());
6466

67+
/**
68+
* @event setup-kernel
69+
* @async
70+
* @description Configures kernel parameters needed by the backend
71+
* (disables the TCP invalid-packet rate limit). Linux only; on other
72+
* platforms it shows a warning dialog.
73+
* @returns {Promise<{success: boolean, message: string}>}
74+
*/
75+
ipcMain.handle("setup-kernel", async () => {
76+
if (process.platform !== "linux") {
77+
const message = "Kernel setup is only available on Linux";
78+
dialog.showMessageBox({
79+
type: "warning",
80+
title: "Not available",
81+
message,
82+
});
83+
return { success: false, message };
84+
}
85+
86+
try {
87+
const { stdout } = await promisify(execFile)("pkexec", [
88+
"sysctl",
89+
"-w",
90+
"net.ipv4.tcp_invalid_ratelimit=0",
91+
]);
92+
logger.electron.info(`Kernel setup completed: ${stdout.trim()}`);
93+
return { success: true, message: "Kernel set up successfully" };
94+
} catch (error) {
95+
logger.electron.error("Kernel setup failed:", error);
96+
return { success: false, message: error.stderr?.trim() || error.message };
97+
}
98+
});
99+
65100
ipcMain.handle("get-available-views", () => {
66101
const ALL_VIEWS = [
67102
{ mode: "testing", label: "Testing View" },

0 commit comments

Comments
 (0)