Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions backend/cmd/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

# Vehicle Configuration
[vehicle]
boards = ["HVBMS","LCU", "PCU", "VCU", "BLCU"]
boards = ["HVBMS","LCU", "PCU", "VCU"]

# ADJ (Architecture Description JSON) Configuration
[adj]
Expand All @@ -20,7 +20,6 @@ validate = true # Execute ADJ validator before starting backend
# Transport Configuration
[transport]
propagate_fault = true

# TCP Configuration
# These settings control how the backend reconnects to boards when connections are lost
[tcp]
Expand Down
1 change: 0 additions & 1 deletion backend/cmd/dev-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
# Vehicle Configuration
[vehicle]
boards = ["HVSCU", "HVSCU-Cabinet", "PCU", "LCU", "BCU", "BMSL"]

# ADJ (Architecture Description JSON) Configuration
[adj]
branch = "software" # Leave blank when using ADJ as a submodule (like this: "")
Expand Down
10 changes: 9 additions & 1 deletion backend/cmd/setup_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,15 @@ func configureUDPServerTransport(
if !ok {
board = "unknown"
}
transp.ReportError(fmt.Errorf("UDP keep-alive timeout: no packets received from board %s (%s) for %dms, fault sent", board, ip, config.UDP.KeepAliveTimeoutMs))
err := fmt.Errorf("UDP keep-alive timeout: no packets received from board %s (%s) for %dms, fault sent", board, ip, config.UDP.KeepAliveTimeoutMs)
transp.ReportError(err)

// Close the board's TCP connection ourselves: the fault we just
// broadcast leaves unacked data on a dead peer, which suppresses the
// TCP keep-alive and would delay disconnect detection by minutes.
if ok {
transp.DisconnectTarget(board, err)
}
}

udpServer := udp.NewServer(
Expand Down
14 changes: 14 additions & 0 deletions backend/pkg/transport/network/tcp/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,17 @@ func (conn *connWithErr) Write(b []byte) (n int, err error) {
func (conn *connWithErr) Close() error {
return conn.Conn.Close()
}

// CloseWithError closes conn, delivering reason to its error channel first so
// the connection handler blocked on that channel wakes up. Closing a
// connection directly would not do this, since Read/Write filter out
// net.ErrClosed.
func CloseWithError(conn net.Conn, reason error) error {
if cwe, ok := conn.(*connWithErr); ok {
select {
case cwe.errors <- reason:
default:
}
}
return conn.Close()
}
5 changes: 4 additions & 1 deletion backend/pkg/transport/network/udp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ func (s *Server) keepAliveLoop() {
Dur("timeout", s.keepAliveTimeout).
Msg("keep-alive timeout: no UDP packets received")
if s.OnDisconnect != nil {
s.OnDisconnect(ip)
// Run the callback on its own goroutine so a slow
// handler (e.g. blocking TCP writes) cannot stall
// timeout detection for the remaining IPs
go s.OnDisconnect(ip)
}
}
}
Expand Down
44 changes: 41 additions & 3 deletions backend/pkg/transport/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ func (transport *Transport) readLoopTCPConn(conn net.Conn, logger zerolog.Logger
for {
packet, err := transport.decoder.DecodeNext(conn)
if err != nil {
// net.ErrClosed means we closed the connection on purpose
// (e.g. UDP keep-alive timeout), and a fault was already sent
if errors.Is(err, net.ErrClosed) {
return
}
logger.Error().Stack().Err(err).Msg("decode")
transport.errChan <- err
transport.SendFault()
Expand Down Expand Up @@ -268,6 +273,10 @@ func (transport *Transport) SendMessage(message abstraction.TransportMessage) er
return err
}

// faultWriteTimeout bounds each TCP write of the fault broadcast; boards on
// the vehicle LAN ack in milliseconds, so exceeding this means a dead peer
const faultWriteTimeout = time.Second

// handlePacketEvent is used to send an order to one of the connected boards
func (transport *Transport) handlePacketEvent(message PacketMessage) error {
eventLogger := transport.logger.With().Str("type", fmt.Sprintf("%T", message.Packet)).Uint16("id", uint16(message.Id())).Logger()
Expand All @@ -287,17 +296,30 @@ func (transport *Transport) handlePacketEvent(message PacketMessage) error {
defer transport.connectionsMx.RUnlock()
for target, conn := range transport.connections {
targetName := string(target)

// Bound each write so a dead peer with a full send buffer cannot
// hold the connections lock (and the caller) for minutes
conn.SetWriteDeadline(time.Now().Add(faultWriteTimeout))
var writeErr error
totalWritten := 0
for totalWritten < len(data) {
n, err := conn.Write(data[totalWritten:])
eventLogger.Trace().Str("target", targetName).Int("amount", n).Msg("written chunk")
totalWritten += n
if err != nil {
eventLogger.Error().Str("target", targetName).Stack().Err(err).Msg("write")
transport.errChan <- err
return err
writeErr = err
break
}
}
conn.SetWriteDeadline(time.Time{})

// Keep broadcasting to the remaining boards even if one write
// fails: the fault must reach every live board
if writeErr != nil {
eventLogger.Error().Str("target", targetName).Stack().Err(writeErr).Msg("write")
transport.errChan <- writeErr
continue
}
eventLogger.Info().Str("target", targetName).Msg("sent")
}
return nil
Expand Down Expand Up @@ -475,6 +497,22 @@ func (transport *Transport) TargetFromIp(ip string) (abstraction.TransportTarget
return target, ok
}

// DisconnectTarget forcefully closes the TCP connection to target, if any.
// The connection handler wakes up with reason, cleans up and notifies the
// disconnection, and the client reconnection loop takes over.
func (transport *Transport) DisconnectTarget(target abstraction.TransportTarget, reason error) bool {
transport.connectionsMx.RLock()
conn, ok := transport.connections[target]
transport.connectionsMx.RUnlock()
if !ok {
return false
}

transport.logger.Warn().Str("target", string(target)).Err(reason).Msg("forcefully disconnecting target")
tcp.CloseWithError(conn, reason)
return true
}

func (transport *Transport) SendFault() {
err := transport.SendMessage(NewPacketMessage(data.NewPacket(0)))
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions electron-app/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
restartBackend: () => ipcRenderer.invoke("restart-backend"),
// Get the list of views available in this build
getAvailableViews: () => ipcRenderer.invoke("get-available-views"),
// Run the kernel setup script (used by mode selector renderer)
setupKernel: () => ipcRenderer.invoke("setup-kernel"),
// Set initial mode (used by mode selector renderer)
setInitialMode: (mode) => {
ipcRenderer.send("mode-selected", mode);
Expand Down
59 changes: 59 additions & 0 deletions electron-app/renderer/mode-selector/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,38 @@
box-shadow: inset 0 0 0 1px rgba(237, 238, 240, 0.04);
}

.kernel-setup {
margin-top: 16px;
display: grid;
gap: 8px;
justify-items: center;
}

.kernel-setup button {
min-height: 44px;
padding: 10px 24px;
}

.kernel-setup button:disabled {
opacity: 0.5;
cursor: wait;
transform: none;
}

.kernel-status {
font-size: 0.85rem;
min-height: 1.2em;
opacity: 0.85;
}

.kernel-status.ok {
color: #7ee787;
}

.kernel-status.error {
color: #ff8080;
}

@media (max-width: 420px) {
.card {
padding: 20px;
Expand Down Expand Up @@ -209,6 +241,10 @@
<h1>Control Station</h1>
</div>
<div class="button-grid" id="button-grid"></div>
<div class="kernel-setup">
<button id="setup-kernel" class="secondary">Set up kernel</button>
<div id="kernel-status" class="kernel-status"></div>
</div>
<div class="aux-logo-wrap">
<img src="src/sign.png" alt="Logo auxiliar" class="logo-aux" />
<div class="app-footer">
Expand Down Expand Up @@ -258,6 +294,29 @@ <h1>Control Station</h1>

renderButtons();

const kernelButton = document.getElementById('setup-kernel');
const kernelStatus = document.getElementById('kernel-status');
kernelButton.addEventListener('click', async () => {
if (!window.electronAPI?.setupKernel) {
kernelStatus.textContent = 'Not available';
kernelStatus.className = 'kernel-status error';
return;
}
kernelButton.disabled = true;
kernelStatus.textContent = 'Setting up kernel…';
kernelStatus.className = 'kernel-status';
try {
const result = await window.electronAPI.setupKernel();
kernelStatus.textContent = result.message;
kernelStatus.className = `kernel-status ${result.success ? 'ok' : 'error'}`;
} catch (e) {
kernelStatus.textContent = 'Kernel setup failed';
kernelStatus.className = 'kernel-status error';
} finally {
kernelButton.disabled = false;
}
});

const appVersionNode = document.getElementById('app-version');
if (appVersionNode && window.electronAPI?.getAppVersion) {
window.electronAPI.getAppVersion().then((version) => {
Expand Down
35 changes: 35 additions & 0 deletions electron-app/src/ipc/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
*/

import { app, dialog, ipcMain, shell } from "electron";
import { execFile } from "child_process";
import { readFile } from "fs/promises";
import fs from "fs";
import { isAbsolute, join } from "path";
import { promisify } from "util";
import {
importConfig,
readConfig,
Expand Down Expand Up @@ -62,6 +64,39 @@ function setupIpcHandlers() {

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

/**
* @event setup-kernel
* @async
* @description Configures kernel parameters needed by the backend
* (disables the TCP invalid-packet rate limit). Linux only; on other
* platforms it shows a warning dialog.
* @returns {Promise<{success: boolean, message: string}>}
*/
ipcMain.handle("setup-kernel", async () => {
if (process.platform !== "linux") {
const message = "Kernel setup is only available on Linux";
dialog.showMessageBox({
type: "warning",
title: "Not available",
message,
});
return { success: false, message };
}

try {
const { stdout } = await promisify(execFile)("pkexec", [
"sysctl",
"-w",
"net.ipv4.tcp_invalid_ratelimit=0",
]);
logger.electron.info(`Kernel setup completed: ${stdout.trim()}`);
return { success: true, message: "Kernel set up successfully" };
} catch (error) {
logger.electron.error("Kernel setup failed:", error);
return { success: false, message: error.stderr?.trim() || error.message };
}
});

ipcMain.handle("get-available-views", () => {
const ALL_VIEWS = [
{ mode: "testing", label: "Testing View" },
Expand Down
Loading