Skip to content

Commit ee9992f

Browse files
fix(connection): send fault if frontend disconnect
1 parent 61d8f52 commit ee9992f

4 files changed

Lines changed: 56 additions & 0 deletions

File tree

backend/cmd/setup_vehicle.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ func configureBroker(subloggers abstraction.SubloggersMap, loggerHandler *logger
6464
pool := websocket.NewPool(connections, trace.Logger)
6565
pool.SetOnDisconnect(func(count int) {
6666
if count == 0 {
67+
// Losing the last control-station GUI (closed, crashed or hung:
68+
// heartbeats stop and the read deadline expires) must put the
69+
// vehicle in a safe state, as required by competition rules.
70+
trace.Warn().Msg("no clients connected, sending FAULT order")
71+
faultOrder := &order_topic.Order{Id: 0, Fields: map[string]order_topic.Field{}}
72+
if err := broker.UserPush(faultOrder); err != nil {
73+
trace.Error().Err(err).Msg("failed to send fault order on client disconnect")
74+
}
75+
6776
trace.Info().Msg("no clients connected, stopping logger")
6877
loggerHandler.Stop()
6978
if err := loggerTopic.NotifyStopped(); err != nil {

backend/pkg/websocket/client.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import (
99
ws "github.com/gorilla/websocket"
1010
)
1111

12+
// HeartbeatTopic is the liveness message every frontend sends once per
13+
// second. It is consumed by the websocket layer and never reaches the broker.
14+
const HeartbeatTopic = "connection/heartbeat"
15+
16+
// heartbeatTimeout bounds how long a dead or frozen frontend goes undetected:
17+
// if no message (the heartbeat guarantees one per second) arrives within this
18+
// window, Read fails and the client is treated as disconnected.
19+
const heartbeatTimeout = 3 * time.Second
20+
1221
type Client struct {
1322
readMx *sync.Mutex
1423
writeMx *sync.Mutex
@@ -26,6 +35,8 @@ func NewClient(conn *ws.Conn) *Client {
2635
onClose: func() {},
2736
}
2837

38+
conn.SetReadDeadline(time.Now().Add(heartbeatTimeout))
39+
2940
return client
3041
}
3142

@@ -46,6 +57,9 @@ func (client *Client) Read() (Message, error) {
4657

4758
var message Message
4859
err := client.conn.ReadJSON(&message)
60+
if err == nil {
61+
client.conn.SetReadDeadline(time.Now().Add(heartbeatTimeout))
62+
}
4963
return message, err
5064
}
5165

backend/pkg/websocket/pool.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ func (pool *Pool) handle(id ClientId, client *Client) {
8484
}
8585

8686
clientLogger.Trace().Msg("read")
87+
88+
// Heartbeats only refresh the read deadline; they carry no payload
89+
// for the broker.
90+
if string(message.Topic) == HeartbeatTopic {
91+
continue
92+
}
93+
8794
pool.onMessage(id, &message)
8895
}
8996
}

frontend/frontend-kit/core/src/websocket.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ class SocketService {
6464
*/
6565
private ws: WebSocketSubject<any> | null = null;
6666

67+
/**
68+
* Handle of the interval that emits the liveness heartbeat while connected.
69+
*/
70+
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
71+
6772
/**
6873
* Connects to the WebSocket server by creating a new connection object and pushing it to `socketSource$`.
6974
* @param port - the port to connect to. Defaults to 4000.
@@ -81,6 +86,7 @@ class SocketService {
8186
next: () => {
8287
this.status$.next("connected");
8388
logger.core.log("WebSocket connected");
89+
this.startHeartbeat();
8490
},
8591
},
8692
});
@@ -102,10 +108,30 @@ class SocketService {
102108
this.socketSource$.next(this.ws);
103109
}
104110

111+
/**
112+
* Starts the periodic heartbeat that lets the backend detect a dead or
113+
* frozen GUI. The backend expects one at least every 3 seconds and treats
114+
* a missing heartbeat as a disconnection.
115+
*/
116+
private startHeartbeat() {
117+
this.stopHeartbeat();
118+
this.heartbeatInterval = setInterval(() => {
119+
this.ws?.next({ topic: "connection/heartbeat", payload: {} });
120+
}, 1000);
121+
}
122+
123+
private stopHeartbeat() {
124+
if (this.heartbeatInterval !== null) {
125+
clearInterval(this.heartbeatInterval);
126+
this.heartbeatInterval = null;
127+
}
128+
}
129+
105130
/**
106131
* Cleans up the WebSocket connection by setting the connection object to null and updating the status to "disconnected".
107132
*/
108133
private cleanup() {
134+
this.stopHeartbeat();
109135
this.ws = null;
110136
this.status$.next("disconnected");
111137
}

0 commit comments

Comments
 (0)