Skip to content

Commit 152f09d

Browse files
authored
feat: Add customizable sync error messages for session desync scenarios (#23267)
When client and server message IDs get out of sync (e.g., after abrupt pod termination), a specific MessageIdSyncException is now thrown instead of UnsupportedOperationException. Developers can customize the error notification via SystemMessages: - setSyncErrorCaption/Message/URL for custom content - setSyncErrorNotificationEnabled(false) for silent page refresh Fixes vaadin/kubernetes-kit#267
1 parent b45fac1 commit 152f09d

File tree

16 files changed

+1028
-20
lines changed

16 files changed

+1028
-20
lines changed

flow-server/src/main/java/com/vaadin/flow/server/CustomizedSystemMessages.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
* <li>Cookies disabled: the cookie support is disabled in the browser.
4242
* <li>Internal error: unhandled critical server error (e.g out of memory,
4343
* database crash)
44+
* <li>Synchronization error: client and server are out of sync due to abrupt
45+
* server termination (e.g., OOMKilled, SIGKILL) before UI state was saved
4446
* </ul>
4547
*
4648
* @since 1.0
@@ -207,4 +209,61 @@ public void setCookiesDisabledMessage(String cookiesDisabledMessage) {
207209
this.cookiesDisabledMessage = cookiesDisabledMessage;
208210
}
209211

212+
/**
213+
* Sets the URL the user will be redirected to after dismissing a
214+
* "synchronization error" message.
215+
*
216+
* Default value is {@literal null}.
217+
*
218+
* @param syncErrorURL
219+
* the URL to redirect to, or null to refresh the page
220+
* @since 25.1
221+
*/
222+
public void setSyncErrorURL(String syncErrorURL) {
223+
this.syncErrorURL = syncErrorURL;
224+
}
225+
226+
/**
227+
* Sets whether a "synchronization error" notification should be shown to
228+
* the end user. If the notification is disabled the user will be
229+
* immediately redirected to the URL returned by {@link #getSyncErrorURL()}.
230+
* <p>
231+
* Synchronization errors occur when the client and server become out of
232+
* sync, typically due to an abrupt server restart (e.g., OOMKilled,
233+
* SIGKILL) before the UI state could be serialized.
234+
*
235+
* By default, the "synchronization error" notification is enabled.
236+
*
237+
* @param syncErrorNotificationEnabled
238+
* {@code true} to show the notification to the end user,
239+
* {@code false} to redirect directly
240+
* @since 25.1
241+
*/
242+
public void setSyncErrorNotificationEnabled(
243+
boolean syncErrorNotificationEnabled) {
244+
this.syncErrorNotificationEnabled = syncErrorNotificationEnabled;
245+
}
246+
247+
/**
248+
* Sets the caption to show in a "synchronization error" notification.
249+
*
250+
* @param syncErrorCaption
251+
* The caption to show or {@code null} to show no caption.
252+
* @since 25.1
253+
*/
254+
public void setSyncErrorCaption(String syncErrorCaption) {
255+
this.syncErrorCaption = syncErrorCaption;
256+
}
257+
258+
/**
259+
* Sets the message to show in a "synchronization error" notification.
260+
*
261+
* @param syncErrorMessage
262+
* The message to show or {@code null} to show no message.
263+
* @since 25.1
264+
*/
265+
public void setSyncErrorMessage(String syncErrorMessage) {
266+
this.syncErrorMessage = syncErrorMessage;
267+
}
268+
210269
}

flow-server/src/main/java/com/vaadin/flow/server/SystemMessages.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@
4242
* <li><b>cookiesDisabledMessage</b> = "This application requires cookies to
4343
* function. Please enable cookies in your browser and click here or press ESC
4444
* to try again.</li>
45+
* <li><b>syncErrorURL</b> = null</li>
46+
* <li><b>syncErrorNotificationEnabled</b> = true</li>
47+
* <li><b>syncErrorCaption</b> = "Synchronization Error"</li>
48+
* <li><b>syncErrorMessage</b> = "Your session needs to be refreshed. Click here
49+
* or press ESC to reload and restore your last saved state."</li>
4550
* </ul>
4651
*
4752
* @since 1.0
@@ -62,6 +67,11 @@ public class SystemMessages implements Serializable {
6267
protected String cookiesDisabledCaption = "Cookies disabled";
6368
protected String cookiesDisabledMessage = "This application requires cookies to function. Please enable cookies in your browser and click here or press ESC to try again.";
6469

70+
protected String syncErrorURL = null;
71+
protected boolean syncErrorNotificationEnabled = true;
72+
protected String syncErrorCaption = "Synchronization Error";
73+
protected String syncErrorMessage = "Your session needs to be refreshed. Click here or press ESC to reload and restore your last saved state.";
74+
6575
/**
6676
* Private constructor
6777
*/
@@ -221,4 +231,62 @@ public String getCookiesDisabledMessage() {
221231
: null);
222232
}
223233

234+
/**
235+
* Gets the URL the user will be redirected to after dismissing a
236+
* "synchronization error" message.
237+
*
238+
* Default value is {@literal null}.
239+
*
240+
* @return the URL to redirect to, or null to refresh the page
241+
* @since 25.1
242+
*/
243+
public String getSyncErrorURL() {
244+
return syncErrorURL;
245+
}
246+
247+
/**
248+
* Checks if "synchronization error" notifications should be shown to the
249+
* end user. If the notification is disabled the user will be immediately
250+
* redirected to the URL returned by {@link #getSyncErrorURL()}.
251+
* <p>
252+
* Synchronization errors occur when the client and server become out of
253+
* sync, typically due to an abrupt server restart (e.g., OOMKilled,
254+
* SIGKILL) before the UI state could be serialized.
255+
*
256+
* By default, the "synchronization error" notification is enabled.
257+
*
258+
* @return {@code true} to show the notification to the end user,
259+
* {@code false} to redirect directly
260+
* @since 25.1
261+
*/
262+
public boolean isSyncErrorNotificationEnabled() {
263+
return syncErrorNotificationEnabled;
264+
}
265+
266+
/**
267+
* Gets the caption to show in a "synchronization error" notification.
268+
*
269+
* Returns {@literal null} if the "synchronization error" notification is
270+
* disabled.
271+
*
272+
* @return The caption to show or {@code null} to show no caption.
273+
* @since 25.1
274+
*/
275+
public String getSyncErrorCaption() {
276+
return (syncErrorNotificationEnabled ? syncErrorCaption : null);
277+
}
278+
279+
/**
280+
* Gets the message to show in a "synchronization error" notification.
281+
*
282+
* Returns {@literal null} if the "synchronization error" notification is
283+
* disabled.
284+
*
285+
* @return The message to show or {@code null} to show no message.
286+
* @since 25.1
287+
*/
288+
public String getSyncErrorMessage() {
289+
return (syncErrorNotificationEnabled ? syncErrorMessage : null);
290+
}
291+
224292
}

flow-server/src/main/java/com/vaadin/flow/server/communication/PushHandler.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import com.vaadin.flow.server.VaadinServletService;
5252
import com.vaadin.flow.server.VaadinSession;
5353
import com.vaadin.flow.server.communication.ServerRpcHandler.InvalidUIDLSecurityKeyException;
54+
import com.vaadin.flow.server.communication.ServerRpcHandler.MessageIdSyncException;
5455
import com.vaadin.flow.server.dau.DAUUtils;
5556
import com.vaadin.flow.server.dau.DauEnforcementException;
5657
import com.vaadin.flow.server.startup.ApplicationConfiguration;
@@ -78,6 +79,8 @@ public class PushHandler {
7879
*/
7980
private final Map<String, Long> disconnectedUuidBuffer = new ConcurrentHashMap<>();
8081

82+
private VaadinServletService service;
83+
8184
/**
8285
* Callback interface used internally to process an event with the
8386
* corresponding UI properly locked.
@@ -176,6 +179,18 @@ interface PushEventCallback {
176179
resource.getRequest().getRemoteHost());
177180
// Refresh on client side
178181
sendRefreshAndDisconnect(resource);
182+
} catch (MessageIdSyncException e) {
183+
getLogger().warn(
184+
"Message ID sync error. Expected: {}, received: {}",
185+
e.getExpectedId(), e.getReceivedId());
186+
SystemMessages msgs = service.getSystemMessages(
187+
HandlerHelper.findLocale(null, vaadinRequest),
188+
vaadinRequest);
189+
sendNotificationAndDisconnect(resource,
190+
VaadinService.createCriticalNotificationJSON(
191+
msgs.getSyncErrorCaption(),
192+
msgs.getSyncErrorMessage(), null,
193+
msgs.getSyncErrorURL()));
179194
} catch (DauEnforcementException e) {
180195
getLogger().warn(
181196
"Daily Active User limit reached. Blocking new user request");
@@ -185,8 +200,6 @@ interface PushEventCallback {
185200

186201
};
187202

188-
private VaadinServletService service;
189-
190203
/**
191204
* Creates an instance connected to the given service.
192205
*

flow-server/src/main/java/com/vaadin/flow/server/communication/ServerRpcHandler.java

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,57 @@ public ClientResentPayloadException() {
252252
}
253253
}
254254

255+
/**
256+
* Exception thrown when the client sends a message with an unexpected
257+
* message ID, indicating that the client and server are out of sync.
258+
* <p>
259+
* This typically occurs when a server pod terminates abruptly (e.g.,
260+
* OOMKilled, SIGKILL) before UI state can be serialized to distributed
261+
* session storage. When clients reconnect to a healthy pod, their message
262+
* ID exceeds what the server expects.
263+
*
264+
* @since 25.1
265+
*/
266+
public static class MessageIdSyncException extends Exception {
267+
268+
private final int expectedId;
269+
private final int receivedId;
270+
271+
/**
272+
* Creates a new MessageIdSyncException with the expected and received
273+
* message IDs.
274+
*
275+
* @param expectedId
276+
* the message ID the server expected
277+
* @param receivedId
278+
* the message ID received from the client
279+
*/
280+
public MessageIdSyncException(int expectedId, int receivedId) {
281+
super("Unexpected message id from the client. Expected: "
282+
+ expectedId + ", got: " + receivedId);
283+
this.expectedId = expectedId;
284+
this.receivedId = receivedId;
285+
}
286+
287+
/**
288+
* Gets the message ID the server expected.
289+
*
290+
* @return the expected message ID
291+
*/
292+
public int getExpectedId() {
293+
return expectedId;
294+
}
295+
296+
/**
297+
* Gets the message ID received from the client.
298+
*
299+
* @return the received message ID
300+
*/
301+
public int getReceivedId() {
302+
return receivedId;
303+
}
304+
}
305+
255306
/**
256307
* Reads JSON containing zero or more serialized RPC calls (including legacy
257308
* variable changes) and executes the calls.
@@ -269,7 +320,8 @@ public ClientResentPayloadException() {
269320
* the session.
270321
*/
271322
public void handleRpc(UI ui, Reader reader, VaadinRequest request)
272-
throws IOException, InvalidUIDLSecurityKeyException {
323+
throws IOException, InvalidUIDLSecurityKeyException,
324+
MessageIdSyncException {
273325
handleRpc(ui, SynchronizedRequestHandler.getRequestBody(reader),
274326
request);
275327
}
@@ -289,7 +341,7 @@ public void handleRpc(UI ui, Reader reader, VaadinRequest request)
289341
* the session.
290342
*/
291343
public void handleRpc(UI ui, String message, VaadinRequest request)
292-
throws InvalidUIDLSecurityKeyException {
344+
throws InvalidUIDLSecurityKeyException, MessageIdSyncException {
293345
ui.getSession().setLastRequestTimestamp(System.currentTimeMillis());
294346

295347
if (message == null || message.isEmpty()) {
@@ -362,11 +414,7 @@ public void handleRpc(UI ui, String message, VaadinRequest request)
362414
getLogger().debug("Unexpected message id from the client."
363415
+ " Expected client id: " + expectedId + ", got "
364416
+ requestId + ". Message start: " + messageDetails);
365-
throw new UnsupportedOperationException(
366-
"Unexpected message id from the client."
367-
+ " Expected client id: " + expectedId
368-
+ ", got " + requestId
369-
+ ". more details logged on DEBUG level.");
417+
throw new MessageIdSyncException(expectedId, requestId);
370418
}
371419
} else {
372420
// Message id ok, process RPCs

flow-server/src/main/java/com/vaadin/flow/server/communication/UidlRequestHandler.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,14 @@
3838
import com.vaadin.flow.server.HttpStatusCode;
3939
import com.vaadin.flow.server.SessionExpiredHandler;
4040
import com.vaadin.flow.server.SynchronizedRequestHandler;
41+
import com.vaadin.flow.server.SystemMessages;
4142
import com.vaadin.flow.server.VaadinRequest;
4243
import com.vaadin.flow.server.VaadinResponse;
4344
import com.vaadin.flow.server.VaadinService;
4445
import com.vaadin.flow.server.VaadinSession;
4546
import com.vaadin.flow.server.communication.ServerRpcHandler.ClientResentPayloadException;
4647
import com.vaadin.flow.server.communication.ServerRpcHandler.InvalidUIDLSecurityKeyException;
48+
import com.vaadin.flow.server.communication.ServerRpcHandler.MessageIdSyncException;
4749
import com.vaadin.flow.server.communication.ServerRpcHandler.ResynchronizationRequiredException;
4850
import com.vaadin.flow.server.dau.DAUUtils;
4951
import com.vaadin.flow.server.dau.DauEnforcementException;
@@ -145,6 +147,14 @@ public Optional<ResponseWriter> synchronizedHandleRequest(
145147
request.getRemoteHost());
146148
// Refresh on client side
147149
return Optional.of(() -> writeRefresh(response));
150+
} catch (MessageIdSyncException e) {
151+
getLogger().warn(
152+
"Message ID sync error. Expected: {}, received: {}",
153+
e.getExpectedId(), e.getReceivedId());
154+
SystemMessages systemMessages = session.getService()
155+
.getSystemMessages(HandlerHelper.findLocale(null, request),
156+
request);
157+
return Optional.of(() -> writeSyncError(systemMessages, response));
148158
} catch (DauEnforcementException e) {
149159
getLogger().warn(
150160
"Daily Active User limit reached. Blocking new user request");
@@ -169,6 +179,15 @@ private void writeRefresh(VaadinResponse response) throws IOException {
169179
commitJsonResponse(response, json);
170180
}
171181

182+
private void writeSyncError(SystemMessages systemMessages,
183+
VaadinResponse response) throws IOException {
184+
String json = VaadinService.createCriticalNotificationJSON(
185+
systemMessages.getSyncErrorCaption(),
186+
systemMessages.getSyncErrorMessage(), null,
187+
systemMessages.getSyncErrorURL());
188+
commitJsonResponse(response, json);
189+
}
190+
172191
void writeUidl(UI ui, Writer writer, boolean resync) throws IOException {
173192
ObjectNode uidl = createUidl(ui, resync);
174193

0 commit comments

Comments
 (0)