Skip to content

Commit

Permalink
add server draft skeleton and simple unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
Jakobeha committed Feb 4, 2024
1 parent ca8ad4b commit 4643e2b
Show file tree
Hide file tree
Showing 12 changed files with 688 additions and 0 deletions.
52 changes: 52 additions & 0 deletions src/main/java/org/prlprg/RVersion.java
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 src/main/java/org/prlprg/server/ClientHandleException.java
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;
}
}
120 changes: 120 additions & 0 deletions src/main/java/org/prlprg/server/ClientHandler.java
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 src/main/java/org/prlprg/server/ClientParseViolationException.java
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);
}
}
35 changes: 35 additions & 0 deletions src/main/java/org/prlprg/server/ClientState.java
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 src/main/java/org/prlprg/server/ClientStateViolationException.java
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);
}
}
57 changes: 57 additions & 0 deletions src/main/java/org/prlprg/server/ReqRepHandlers.java
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
}
Loading

0 comments on commit 4643e2b

Please sign in to comment.