-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add server draft skeleton and simple unit tests
- Loading branch information
Showing
12 changed files
with
688 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
package org.prlprg; | ||
|
||
import javax.annotation.Nullable; | ||
|
||
/** | ||
* Major.Minor.Patch version number with an optional suffix for things like "Beta" and "RC". | ||
* | ||
* <p>This class needs to support whatever format GNU-R versions can have. But it's not a GNU-R | ||
* specific | ||
*/ | ||
public record RVersion(int major, int minor, int patch, @Nullable String suffix) { | ||
/** The latest version we handle. */ | ||
public static final RVersion LATEST_AWARE = new RVersion(4, 3, 2); | ||
|
||
public static RVersion parse(String textual) { | ||
var parts = textual.split("\\."); | ||
|
||
int major; | ||
int minor; | ||
int patch; | ||
try { | ||
major = Integer.parseInt(parts[0]); | ||
minor = Integer.parseInt(parts[1]); | ||
patch = Integer.parseInt(parts[2]); | ||
} catch (ArrayIndexOutOfBoundsException e) { | ||
throw new IllegalArgumentException("Invalid version number: " + textual, e); | ||
} | ||
|
||
String suffix; | ||
if (parts.length > 3) { | ||
if (parts[3].startsWith("-")) { | ||
suffix = parts[3].substring(1); | ||
} else { | ||
throw new IllegalArgumentException( | ||
"Invalid version number: " + textual + " (suffix must start with '-')"); | ||
} | ||
} else { | ||
suffix = null; | ||
} | ||
|
||
return new RVersion(major, minor, patch, suffix); | ||
} | ||
|
||
RVersion(int major, int minor, int patch) { | ||
this(major, minor, patch, null); | ||
} | ||
|
||
@Override | ||
public String toString() { | ||
return major + "." + minor + "." + patch + (suffix == null ? "" : "-" + suffix); | ||
} | ||
} |
14 changes: 14 additions & 0 deletions
14
src/main/java/org/prlprg/server/ClientHandleException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
package org.prlprg.server; | ||
|
||
/** | ||
* An exception a background thread got when processing a client request. This exception is checked | ||
* while the underlying exception may not be. | ||
*/ | ||
public final class ClientHandleException extends Exception { | ||
public final String address; | ||
|
||
public ClientHandleException(String address, Throwable cause) { | ||
super("Handler/thread for client " + address + " crashed", cause); | ||
this.address = address; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
package org.prlprg.server; | ||
|
||
import javax.annotation.Nullable; | ||
import org.zeromq.ZMQ; | ||
import org.zeromq.ZMQ.Error; | ||
import org.zeromq.ZMQException; | ||
|
||
/** | ||
* Manages communication between the server and a particular client. Contains the client's socket, | ||
* thread, and {@link ClientState}. | ||
* | ||
* <p>This class doesn't implement {@link AutoCloseable}; if you want to close this, close the | ||
* socket. The reason is that the socket may close on its own, so we have to handle that case | ||
* anyways, and don't want two separate ways to close. | ||
*/ | ||
final class ClientHandler { | ||
private final String address; | ||
private final ZMQ.Socket socket; | ||
private final Thread thread; | ||
private @Nullable Throwable exception = null; | ||
// When the socket closes, we aren't made aware until we handle its next message and get null or | ||
// an exception. | ||
private boolean isDefinitelyClosed = false; | ||
private final ClientState state = new ClientState(); | ||
|
||
/** | ||
* <b>This will immediately start handling client requests on a background thread immediately.</b> | ||
* | ||
* <p>As specified in the class description, to close this (and stop the thread), close the | ||
* socket. | ||
* | ||
* @param address The address of the socket. It must resolve to the same address as {@code | ||
* socket.getLastEndpoint()}, however the actual text may be different (e.g. localhost vs | ||
* 127.0.0.1) which is why we need to pass it explicitly (because otherwise {@link | ||
* Server#unbind} won't work). | ||
*/ | ||
ClientHandler(String address, ZMQ.Socket socket) { | ||
this.address = address; | ||
this.socket = socket; | ||
this.thread = | ||
new Thread( | ||
() -> { | ||
try { | ||
while (true) { | ||
var message = socket.recvStr(); | ||
if (message == null) { | ||
// Socket closed | ||
isDefinitelyClosed = true; | ||
break; | ||
} | ||
var response = ReqRepHandlers.handle(state, message); | ||
if (response != null) { | ||
socket.send(response); | ||
} | ||
} | ||
} catch (ZMQException e) { | ||
close(); | ||
var error = Error.findByCode(e.getErrorCode()); | ||
switch (error) { | ||
// Currently assume these are normal | ||
case Error.ECONNABORTED: | ||
case Error.ETERM: | ||
System.out.println("ClientState-" + address() + " closed with " + error); | ||
break; | ||
default: | ||
exception = e; | ||
throw e; | ||
} | ||
} catch (Throwable e) { | ||
close(); | ||
exception = e; | ||
throw e; | ||
} | ||
}); | ||
thread.setName("ClientState-" + address()); | ||
thread.start(); | ||
} | ||
|
||
/** The address the client is connected to. */ | ||
String address() { | ||
return address; | ||
} | ||
|
||
/** | ||
* Close the underlying socket, which makes the thread stop on next communication (if it's doing | ||
* something it won't stop immediately). | ||
* | ||
* <p>If the socket is already closed, the behavior of this method is undefined. | ||
*/ | ||
void close() { | ||
if (isDefinitelyClosed) { | ||
throw new IllegalStateException("Socket already closed"); | ||
} | ||
isDefinitelyClosed = true; | ||
socket.close(); | ||
} | ||
|
||
/** | ||
* Throws a {@link ClientHandleException} if the client disconnected because it got an exception. | ||
*/ | ||
void checkForException() throws ClientHandleException { | ||
if (exception != null) { | ||
throw new ClientHandleException(address(), exception); | ||
} | ||
} | ||
|
||
/** | ||
* Waits for the client to disconnect and the socket to close on its own. | ||
* | ||
* @throws ClientHandleException if a client disconnected because it got an exception. | ||
*/ | ||
void waitForDisconnect() throws ClientHandleException { | ||
try { | ||
thread.join(); | ||
} catch (InterruptedException e) { | ||
throw new RuntimeException(e); | ||
} | ||
checkForException(); | ||
} | ||
} |
18 changes: 18 additions & 0 deletions
18
src/main/java/org/prlprg/server/ClientParseViolationException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package org.prlprg.server; | ||
|
||
/** The client sent a malformed message. */ | ||
public final class ClientParseViolationException extends IllegalStateException { | ||
@SuppressWarnings("unused") | ||
ClientParseViolationException(String message) { | ||
super(message); | ||
} | ||
|
||
ClientParseViolationException(Throwable cause) { | ||
super(cause); | ||
} | ||
|
||
@SuppressWarnings("unused") | ||
ClientParseViolationException(String message, Throwable cause) { | ||
super(message, cause); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
package org.prlprg.server; | ||
|
||
import javax.annotation.Nullable; | ||
import org.prlprg.RVersion; | ||
|
||
/** | ||
* State for a client stored on the server visible to {@link ReqRepHandlers}. | ||
* | ||
* <p>Doesn't include the socket and client thread, that's the role of {@link ClientHandler}. | ||
*/ | ||
final class ClientState { | ||
private record Init(RVersion version) {} | ||
|
||
private @Nullable Init init; | ||
|
||
ClientState() {} | ||
|
||
void init(RVersion version) { | ||
if (init != null) { | ||
throw new ClientStateViolationException("Client already initialized"); | ||
} | ||
init = new Init(version); | ||
} | ||
|
||
RVersion version() { | ||
return init().version; | ||
} | ||
|
||
private Init init() { | ||
if (init == null) { | ||
throw new ClientStateViolationException("Client not initialized"); | ||
} | ||
return init; | ||
} | ||
} |
21 changes: 21 additions & 0 deletions
21
src/main/java/org/prlprg/server/ClientStateViolationException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
package org.prlprg.server; | ||
|
||
/** | ||
* The client sent a message or data the server wasn't expecting in its state. e.g. client | ||
* initializes itself twice. | ||
*/ | ||
public final class ClientStateViolationException extends IllegalStateException { | ||
ClientStateViolationException(String message) { | ||
super(message); | ||
} | ||
|
||
@SuppressWarnings("unused") | ||
ClientStateViolationException(Throwable cause) { | ||
super(cause); | ||
} | ||
|
||
@SuppressWarnings("unused") | ||
ClientStateViolationException(String message, Throwable cause) { | ||
super(message, cause); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
package org.prlprg.server; | ||
|
||
import javax.annotation.Nullable; | ||
import org.prlprg.RVersion; | ||
import org.prlprg.util.NotImplementedError; | ||
|
||
/** | ||
* Functions for each initial request the client can make. They take the initial request body as | ||
* params (as well as client state), and return the final response (or void if there are none). If a | ||
* request is more complicated than a simple function (e.g. server can respond with "more | ||
* information needed" and then wait until the client sends more), the function will also take a | ||
* {@link Mediator} to send intermediate responses and handle intermediate requests (possibly out- | ||
* of-order, we're not sure what kinds of communicatio we'll need yet). | ||
* | ||
* <p>The specific handlers are actually all private, because this class's interface has the method | ||
* which handles a generic initial request, parsing and dispatching to the specific handler. | ||
*/ | ||
final class ReqRepHandlers { | ||
private static final String SIMPLE_ACK = ""; | ||
|
||
/** | ||
* Handle an initial request (not intermediate request in a request chain) from the client. | ||
* | ||
* <p>Apparetly ZMQ needs every request to have some response. If this returns null, it already | ||
* send a response. If this only needs to do a simple ACK, it will return the empty string. | ||
*/ | ||
// TODO: Add GenericMediator which can create all other mediators which we'll do depending on the | ||
// request type. GenericMediator will have a reference to the ClientHandler's socket and thread | ||
// so it can send and receive subsequect requests, | ||
static @Nullable String handle(ClientState state, String request) { | ||
// TODO: Parse request type and the rest of the data, create specific mediator if necessary, | ||
// dispatch to the specific handler, and encode the response. | ||
if (request.equals("Hello, server!")) { | ||
throw new ClientStateViolationException("bad message"); | ||
} | ||
if (request.startsWith("Proper init ")) { | ||
RVersion rVersion; | ||
try { | ||
rVersion = RVersion.parse(request.substring("Proper init ".length())); | ||
} catch (IllegalArgumentException e) { | ||
throw new ClientParseViolationException(e); | ||
} | ||
init(state, rVersion); | ||
return SIMPLE_ACK; | ||
} | ||
if (request.startsWith("Something which returns null so IntelliJ doesn't complain")) { | ||
return null; | ||
} | ||
throw new NotImplementedError(); | ||
} | ||
|
||
// region specific handlers | ||
private static void init(ClientState state, RVersion rVersion) { | ||
state.init(rVersion); | ||
} | ||
// endregion | ||
} |
Oops, something went wrong.