Skip to content

Commit d759e90

Browse files
committed
1.3.0 major stability improvements, moved javascript execution fully to websocket server and fixed deadlock
1 parent 8f76a9b commit d759e90

File tree

14 files changed

+814
-399
lines changed

14 files changed

+814
-399
lines changed

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ AUTO-GENERATED FILE, CHANGES SHOULD BE DONE IN ./JPM.java or ./src/main/java/com
1111
<modelVersion>4.0.0</modelVersion>
1212
<groupId>com.osiris.desku</groupId>
1313
<artifactId>Desku</artifactId>
14-
<version>1.0.21</version>
14+
<version>1.3.0</version>
1515
<properties>
1616
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
1717
</properties>

src/main/java/com/osiris/desku/Value.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,12 @@ else if(Reflect.isPseudoPrimitiveType(val)){
8383
/**
8484
* NOTE THAT UNESCAPING MUST BE PERFORMED BY YOU BEFORE EXECUTING THIS, IF ESCAPING WAS DONE.
8585
*/
86-
public static <T> T stringToVal(String s, Component<?,T> comp){
86+
public static Object stringToVal(String s, Component<?, ?> comp){
8787
if(s == null || s.isEmpty()){
8888
return comp.internalDefaultValue;
8989
}
9090
if(Reflect.isPseudoPrimitiveType(comp.internalValueClass))
91-
return (T) Reflect.pseudoPrimitivesAndParsers.get(comp.internalValueClass)
91+
return Reflect.pseudoPrimitivesAndParsers.get(comp.internalValueClass)
9292
.apply(s);
9393
else{
9494
return JsonFile.parser.fromJson(s, comp.internalValueClass);
@@ -98,7 +98,7 @@ public static <T> T stringToVal(String s, Component<?,T> comp){
9898
/**
9999
* NOTE THAT UNESCAPING MUST BE PERFORMED BY YOU BEFORE EXECUTING THIS, IF ESCAPING WAS DONE.
100100
*/
101-
public static <T> T jsonElToVal(JsonElement el, Component<?, T> comp){
101+
public static Object jsonElToVal(JsonElement el, Component<?, ?> comp){
102102
if(el.isJsonPrimitive()){
103103
return stringToVal(el.getAsString(), comp);
104104
} else
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.osiris.desku;
2+
3+
public class WarnDoc {
4+
/**
5+
* Note that this might return an error message starting with "!" instead, if there was an issue with the underlying internal JavaScript code to
6+
* retrieve the value. If this is the case however create a bug report.
7+
*/
8+
public String might_return_javascript_exception_message;
9+
}

src/main/java/com/osiris/desku/ui/Component.java

Lines changed: 86 additions & 44 deletions
Large diffs are not rendered by default.
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
package com.osiris.desku.ui;
2+
3+
import com.osiris.desku.App;
4+
import com.osiris.desku.ui.utils.Event;
5+
import com.osiris.jlib.logger.AL;
6+
import org.java_websocket.WebSocket;
7+
import org.java_websocket.handshake.ClientHandshake;
8+
import org.java_websocket.server.WebSocketServer;
9+
import org.jetbrains.annotations.Nullable;
10+
11+
import java.net.InetSocketAddress;
12+
import java.time.Duration;
13+
import java.util.ArrayList;
14+
import java.util.List;
15+
import java.util.concurrent.CopyOnWriteArrayList;
16+
import java.util.concurrent.ExecutorService;
17+
import java.util.concurrent.Executors;
18+
import java.util.concurrent.atomic.AtomicInteger;
19+
import java.util.function.Consumer;
20+
21+
/**
22+
* A WebSocket server that sends JavaScript code to be executed in the browser
23+
* and returns its execution result.
24+
*/
25+
public class JSWebSocketServer extends WebSocketServer {
26+
public final String domain;
27+
public final int port;
28+
public final List<PendingJavaScriptResult> javaScriptCallbacks = new CopyOnWriteArrayList<>();
29+
public final AtomicInteger counter = new AtomicInteger();
30+
public transient long lastPingReceivedMs = System.currentTimeMillis();
31+
public final Thread timeoutCheckerThread = new Thread(() -> {
32+
// Kind of wasteful to have a thread pretty much always in sleep, virtual threads would be perfect here
33+
// TODO however not sure if we lose a lot of backwards support of android versions if we upgrade the java version
34+
int maxSeconds = 30;
35+
long maxMillis = Duration.ofSeconds(maxSeconds).toMillis();
36+
var toRemove = new ArrayList<PendingJavaScriptResult>(0);
37+
while(true){
38+
try{
39+
Thread.sleep(1000);
40+
long now = System.currentTimeMillis();
41+
synchronized (javaScriptCallbacks){
42+
toRemove.clear();
43+
for (PendingJavaScriptResult javaScriptCallback : javaScriptCallbacks) {
44+
if(javaScriptCallback.isPermanent) continue; // Skip permanent callbacks
45+
if(now - javaScriptCallback.msCreated > maxMillis){
46+
System.err.println("This pending javascript result never got a response from the browser/client within "+maxSeconds+" seconds,\n" +
47+
"thus it was removed and will never complete which might result in unexpected behavior.\n" +
48+
"This could mean the client closed the page during something running or something else went entirely wrong.\n" +
49+
"This cannot be caused by wrong/broken javascript code provided by you since that is within a try/catch block.");
50+
System.err.println("PendingJavaScriptResult-ID == "+javaScriptCallback.id);
51+
new Exception().printStackTrace();
52+
toRemove.add(javaScriptCallback);
53+
}
54+
}
55+
javaScriptCallbacks.removeAll(toRemove);
56+
}
57+
} catch (InterruptedException e){
58+
break;
59+
} catch (Exception e) {
60+
System.err.println("Something went wrong in logic of timeoutCheckerThread!");
61+
e.printStackTrace();
62+
}
63+
}
64+
});
65+
{
66+
timeoutCheckerThread.setName("JavaScriptExecutionTimeoutCheckerThread");
67+
}
68+
69+
public JSWebSocketServer(String domain, int port) {
70+
super(new InetSocketAddress(domain, port));
71+
start();
72+
this.domain = domain;
73+
this.port = port;
74+
timeoutCheckerThread.start();
75+
}
76+
77+
// public String jsStartWebSocketClient(String serverDomain, int serverPort) {
78+
// String url = "ws://" + serverDomain + ":" + serverPort;
79+
// String jsCode = "try{ var webSocketServer = new WebSocket('" + url + "');\n" +
80+
// "window.webSocketServer = webSocketServer;\n" + // Make globally accessible
81+
// "console.log(\"Connecting to WebSocket server...\")\n" +
82+
// "\n" +
83+
// " webSocketServer.addEventListener(\"open\", (event) => {\n" +
84+
// " console.log('WebSocket connection established.');\n" +
85+
// " });\n" +
86+
// "\n" +
87+
// " webSocketServer.addEventListener(\"message\", (event) => {\n" +
88+
// " // Receive a message from the server\n" +
89+
// " var receivedMessage = event.data;\n" +
90+
// (App.isInDepthDebugging ? " console.log('Received message from server:\\n', receivedMessage);\n" : "") +
91+
// " eval(event.data);\n" +
92+
// " });\n" +
93+
// "\n" +
94+
// " webSocketServer.addEventListener(\"close\", (event) => {\n" +
95+
// " console.log('WebSocket connection closed.');\n" +
96+
// " });\n" +
97+
// "} catch (e) {console.error(e)}\n";
98+
// return jsCode;
99+
// }
100+
101+
public String jsStartWebSocketClient(String serverDomain, int serverPort) {
102+
String url = "ws://" + serverDomain + ":" + serverPort;
103+
String jsCode =
104+
"try {\n" +
105+
" var webSocketServer;\n" +
106+
" var reconnectAttempts = 0;\n" +
107+
" var maxReconnectInterval = 3000; // 3 seconds cap\n" +
108+
"\n" +
109+
" function connectWebSocket() {\n" +
110+
" console.log(\"Connecting to WebSocket server...\");\n" +
111+
" webSocketServer = new WebSocket('" + url + "');\n" +
112+
" window.webSocketServer = webSocketServer;\n" + // Make globally accessible
113+
"\n" +
114+
" webSocketServer.addEventListener(\"open\", () => {\n" +
115+
" console.log('WebSocket connection established.');\n" +
116+
" reconnectAttempts = 0; // Reset on successful connection\n" +
117+
" });\n" +
118+
"\n" +
119+
" webSocketServer.addEventListener(\"message\", (event) => {\n" +
120+
" var receivedMessage = event.data;\n" +
121+
(App.isInDepthDebugging ? " console.log('Received message from server:\\n', receivedMessage);\n" : "") +
122+
" eval(receivedMessage);\n" +
123+
" });\n" +
124+
"\n" +
125+
" webSocketServer.addEventListener(\"close\", () => {\n" +
126+
" console.log('WebSocket connection closed. Attempting to reconnect...');\n" +
127+
" scheduleReconnect();\n" +
128+
" });\n" +
129+
"\n" +
130+
" webSocketServer.addEventListener(\"error\", (error) => {\n" +
131+
" console.error('WebSocket error:', error);\n" +
132+
" webSocketServer.close(); // Force close to trigger reconnect\n" +
133+
" });\n" +
134+
" }\n" +
135+
"\n" +
136+
" function scheduleReconnect() {\n" +
137+
" let reconnectDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectInterval);\n" +
138+
" console.log(`Reconnecting in ${reconnectDelay / 1000} seconds...`);\n" +
139+
" setTimeout(connectWebSocket, reconnectDelay);\n" +
140+
" reconnectAttempts++;\n" +
141+
" }\n" +
142+
"\n" +
143+
" connectWebSocket(); // Initial connection\n" +
144+
"} catch (e) { console.error(e); }\n";
145+
146+
return jsCode;
147+
}
148+
149+
/**
150+
* Executes JavaScript (JS) code now and returns directly.
151+
* The {@link Event} gets triggered once JS code execution finishes/fails.<br>
152+
* <br>
153+
* Do not worry if you add an action/listener to the event after it was triggered, because this is a {@link Event}
154+
* and your listener will still be run. <br>
155+
* <br>
156+
* Since we use JavaScript WebSockets, its ensured that the sent JS code is executed in an orderly, synchronous fashion.
157+
* Meaning that the JS code in the second call of this method, gets executed after the code in the first call finished/failed.<br>
158+
* <br>
159+
*
160+
*/
161+
public void executeJavaScript(UI ui, Event<JavaScriptResult> event, String code){
162+
Registration registration = addPermanentCallback(ui, code, result -> {
163+
event.execute(result);
164+
});
165+
166+
registration.pendingJavaScriptResult.isPermanent = false;
167+
code = registration.jsCode; // Registration adds some extra js code
168+
code = "// PendingJavaScriptResult-ID == "+registration.pendingJavaScriptResult.id+"\n"+ code;
169+
broadcast(code); // Execute code in client
170+
}
171+
172+
public static class Registration{
173+
public String jsCode;
174+
public PendingJavaScriptResult pendingJavaScriptResult;
175+
176+
public Registration(String jsCode, PendingJavaScriptResult pendingJavaScriptResult) {
177+
this.jsCode = jsCode;
178+
this.pendingJavaScriptResult = pendingJavaScriptResult;
179+
}
180+
}
181+
182+
/**
183+
* Returns new JS (JavaScript) code, that when executed in client browser
184+
* results in onJSFunctionExecuted being executed. <br><br>
185+
* <p>
186+
* It wraps around your jsCode and adds callback related stuff, as well as error handling. <br><br>
187+
* <p>
188+
* Its a permanent callback, because the returned JS code can be executed multiple times
189+
* which results in onJSFunctionExecuted or onJSFuntionFailed to get executed multiple times too. <br><br>
190+
* <p>
191+
* Also appends a check to the JS code that sets message="" if it was null/undefined.<br><br>
192+
*
193+
* @param jsCode modify the message variable in the provided JS (JavaScript) code to send information from JS to Java.
194+
* Your JS code could look like this: <br>
195+
* message = 'first second third etc...';
196+
* @param onFinished executed when the provided jsCode executes successfully. String contains the message variable that can be set in your jsCode.
197+
* Or executed when the provided jsCode threw an exception/failed. String contains details about the exception/error in that case.
198+
*/
199+
public Registration addPermanentCallback(UI ui, String jsCode, Consumer<JavaScriptResult> onFinished) {
200+
// 1. execute client JS
201+
// 2. execute callback in java with params from client JS
202+
// 3. return success to client JS and execute it
203+
int id = counter.getAndIncrement();
204+
PendingJavaScriptResult pendingJavaScriptResult;
205+
synchronized (javaScriptCallbacks) {
206+
pendingJavaScriptResult = new PendingJavaScriptResult(id, onFinished, ui);
207+
javaScriptCallbacks.add(pendingJavaScriptResult);
208+
}
209+
return new Registration("var message = '';\n" + // Separated by space
210+
"var error = null;\n" +
211+
"try{" + jsCode + "\n" +
212+
"if(message==null) message = '';\n" +
213+
"} catch (e) { error = e; console.error(e);}\n" +
214+
jsClientSendWebSocketMessage("(error == null ? ('" + id + " '+message) : ('!" + id + " '+error))"), pendingJavaScriptResult);
215+
}
216+
217+
public String jsClientSendWebSocketMessage(String message) {
218+
return "if(webSocketServer.readyState != 1) {\n" +
219+
//"console.log(`Frontend and Backend are connected!`)\n" + // 1 == OPEN
220+
" webSocketServer.addEventListener(\"open\", (event) => {\n" +
221+
" webSocketServer.send(" + message + ");\n" +
222+
" });\n" +
223+
"} else webSocketServer.send(" + message + ");\n";
224+
}
225+
226+
@Override
227+
public void onOpen(WebSocket conn, ClientHandshake handshake) {
228+
229+
}
230+
231+
@Override
232+
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
233+
timeoutCheckerThread.interrupt();
234+
AL.info("Closed websocket server probably due to inactivity "+this.domain+":"+this.port+" - "+this.toString());
235+
}
236+
237+
@Override
238+
public void onMessage(WebSocket conn, String message) {
239+
// message format: id <message>
240+
int iFirstSpace = message.indexOf(" ");
241+
boolean isError = message.startsWith("!");
242+
int id = isError ? Integer.parseInt(message.substring(1, iFirstSpace)) :
243+
Integer.parseInt(message.substring(0, iFirstSpace));
244+
int iPendingResultToRemove = -1;
245+
246+
// To avoid deadlocks do not execute ui.access inside a synchronized javaScriptCallbacks block
247+
@Nullable PendingJavaScriptResult pendingResult = null;
248+
synchronized (javaScriptCallbacks) {
249+
for (int i = 0; i < javaScriptCallbacks.size(); i++) {
250+
PendingJavaScriptResult pr = javaScriptCallbacks.get(i);
251+
if (pr.id == id) {
252+
pendingResult = pr;
253+
if (!pendingResult.isPermanent) iPendingResultToRemove = i;
254+
break;
255+
}
256+
}
257+
if (iPendingResultToRemove != -1)
258+
javaScriptCallbacks.remove(iPendingResultToRemove);
259+
}
260+
261+
// Execute the pending javascript result event in the correct UI context
262+
if(pendingResult != null){
263+
final var finalPendingResult = pendingResult;
264+
if (!isError) // message looks like this: "!3 Error details..." 3 is the id and can be any number
265+
pendingResult.ui.access(() -> finalPendingResult.onFinished.accept(new JavaScriptResult(message.substring(iFirstSpace + 1), true)));
266+
else // message looks like this: "3 first second etc..." 3 is the id and can be any number, the rest can be any data or even empty
267+
pendingResult.ui.access(() -> finalPendingResult.onFinished.accept(new JavaScriptResult(message.substring(iFirstSpace + 1), false)));
268+
}
269+
}
270+
271+
@Override
272+
public void onError(WebSocket conn, Exception ex) {
273+
274+
}
275+
276+
@Override
277+
public void onStart() {
278+
}
279+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.osiris.desku.ui;
2+
3+
public class JavaScriptResult{
4+
public String message;
5+
public boolean isSuccess;
6+
7+
public JavaScriptResult(String message, boolean isSuccess) {
8+
this.message = message;
9+
this.isSuccess = isSuccess;
10+
}
11+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.osiris.desku.ui;
2+
3+
import org.jetbrains.annotations.NotNull;
4+
5+
import java.util.function.Consumer;
6+
7+
public class PendingJavaScriptResult {
8+
public final int id;
9+
public final long msCreated = System.currentTimeMillis();
10+
public final Consumer<JavaScriptResult> onFinished;
11+
public boolean isPermanent = true;
12+
public @NotNull UI ui;
13+
14+
public PendingJavaScriptResult(int id, Consumer<JavaScriptResult> onFinished, @NotNull UI ui) {
15+
this.id = id;
16+
this.onFinished = onFinished;
17+
this.ui = ui;
18+
}
19+
}

0 commit comments

Comments
 (0)