Skip to content

Commit 28101bd

Browse files
committed
splitscreen: clients can now reliably create and/or connect to a unix socket and reach a packet play state
1 parent 8fd3236 commit 28101bd

File tree

9 files changed

+85
-43
lines changed

9 files changed

+85
-43
lines changed

build.gradle.kts

+2-2
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ dependencies {
4444

4545
propMap("deps.mixinExtras") {
4646
when {
47-
modstitch.isLoom -> modstitchImplementation(annotationProcessor("io.github.llamalad7:mixinextras-fabric:$it")!!).jij()
48-
modstitch.isModDevGradleRegular -> implementation("io.github.llamalad7:mixinextras-neoforge:$it").jij()
47+
modstitch.isLoom -> modstitchApi(annotationProcessor("io.github.llamalad7:mixinextras-fabric:$it")!!).jij()
48+
modstitch.isModDevGradleRegular -> api("io.github.llamalad7:mixinextras-neoforge:$it").jij()
4949
else -> error("Unknown loader")
5050
}
5151
}

splitscreen/src/main/java/dev/isxander/controlify/splitscreen/SplitscreenBootstrapper.java

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package dev.isxander.controlify.splitscreen;
22

3+
import com.mojang.logging.LogUtils;
34
import dev.isxander.controlify.splitscreen.client.protocol.PawnConnectionListener;
45
import dev.isxander.controlify.splitscreen.server.SplitscreenController;
56
import dev.isxander.controlify.splitscreen.util.SocketUtil;
67
import dev.isxander.controlify.utils.Platform;
78
import net.minecraft.client.Minecraft;
89
import org.jetbrains.annotations.Nullable;
10+
import org.slf4j.Logger;
911

1012
import java.util.Optional;
1113

1214
public class SplitscreenBootstrapper {
15+
private static final Logger LOGGER = LogUtils.getLogger();
16+
1317
private static @Nullable SplitscreenController controller;
1418
private static @Nullable PawnConnectionListener pawnConnectionListener;
1519

@@ -27,9 +31,11 @@ public static void bootstrap(Minecraft minecraft) {
2731
.orElseThrow(() -> new RuntimeException("No connection method available"));
2832

2933
// Attempt to be a pawn (client) first...
30-
if (SocketUtil.isSocketOpen(connectionMethod)) {
34+
if (SocketUtil.isSocketListening(connectionMethod)) {
35+
LOGGER.info("Socket already open, attempting to become a pawn");
3136
bootstrapAsPawn(minecraft, connectionMethod);
3237
} else {
38+
LOGGER.info("Socket not open, attempting to become a controller");
3339
bootstrapAsController(minecraft, connectionMethod);
3440
}
3541
}

splitscreen/src/main/java/dev/isxander/controlify/splitscreen/client/protocol/PawnConnectionListener.java

+25-18
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.google.common.base.Suppliers;
44
import com.google.common.util.concurrent.ThreadFactoryBuilder;
5+
import com.mojang.logging.LogUtils;
56
import dev.isxander.controlify.splitscreen.SocketConnectionMethod;
67
import dev.isxander.controlify.splitscreen.protocol.ConnectionUtils;
78
import dev.isxander.controlify.splitscreen.client.protocol.handshake.ControllerboundHandshakePacket;
@@ -25,11 +26,14 @@
2526
import net.minecraft.network.Connection;
2627
import net.minecraft.network.protocol.PacketFlow;
2728
import org.apache.commons.lang3.Validate;
29+
import org.slf4j.Logger;
2830

2931
import java.net.InetAddress;
3032
import java.util.function.Supplier;
3133

3234
public class PawnConnectionListener {
35+
private static final Logger LOGGER = LogUtils.getLogger();
36+
3337
private static final Supplier<EpollEventLoopGroup> NETWORK_EPOLL_WORKER_GROUP = Suppliers.memoize(
3438
() -> new EpollEventLoopGroup(2, new ThreadFactoryBuilder().setNameFormat("Controlify Netty Epoll Client IO #%d").setDaemon(true).build())
3539
);
@@ -42,21 +46,25 @@ public class PawnConnectionListener {
4246
public PawnConnectionListener(Minecraft minecraft, SocketConnectionMethod connectionMethod) {
4347
this.controllerConnection = switch (connectionMethod) {
4448
case SocketConnectionMethod.TCP(int port) -> connectToTcp(port, minecraft);
45-
case SocketConnectionMethod.Unix(String socketPath) -> connectToSocket(socketPath, minecraft);
49+
case SocketConnectionMethod.Unix(String socketPath) -> connectToUnixSocket(socketPath, minecraft);
4650
};
4751
}
4852

4953
public Connection getControllerConnection() {
5054
return controllerConnection;
5155
}
5256

53-
private Connection connectToSocket(String socketPath, Minecraft minecraft) {
57+
private Connection connectToUnixSocket(String socketPath, Minecraft minecraft) {
58+
LOGGER.info("Connecting to controller unix socket at {}", socketPath);
59+
5460
return connect(minecraft, new Bootstrap()
5561
.channel(Epoll.isAvailable() ? EpollDomainSocketChannel.class : NioServerDomainSocketChannel.class)
5662
.remoteAddress(new DomainSocketAddress(socketPath)));
5763
}
5864

5965
private Connection connectToTcp(int port, Minecraft minecraft) {
66+
LOGGER.info("Connecting to controller tcp port {}", port);
67+
6068
return connect(minecraft, new Bootstrap()
6169
.channel(Epoll.isAvailable() ? EpollSocketChannel.class : NioSocketChannel.class)
6270
.remoteAddress(InetAddress.getLoopbackAddress(), port));
@@ -69,10 +77,20 @@ private Connection connect(Minecraft minecraft, Bootstrap bootstrap) {
6977

7078
bootstrap
7179
.group(Epoll.isAvailable() ? NETWORK_EPOLL_WORKER_GROUP.get() : NETWORK_WORKER_GROUP.get())
72-
.handler(new ChannelInitializer<DomainSocketChannel>() {
80+
.handler(new ChannelInitializer<>() {
7381
@Override
74-
protected void initChannel(DomainSocketChannel ch) {
75-
PawnConnectionListener.this.initChannel(ch, connection);
82+
protected void initChannel(Channel ch) {
83+
try {
84+
ch.config().setOption(ChannelOption.TCP_NODELAY, true);
85+
} catch (ChannelException ignored) {
86+
}
87+
88+
ChannelPipeline pipeline = ch.pipeline()
89+
.addLast("timeout", new ReadTimeoutHandler(5));
90+
ConnectionUtils.configureSerialization(pipeline, PacketFlow.CLIENTBOUND, false, HandshakeProtocols.CONTROLLERBOUND);
91+
connection.configurePacketHandler(pipeline);
92+
93+
LOGGER.info("Established connection with controller");
7694
}
7795
}).connect().syncUninterruptibly();
7896

@@ -82,20 +100,9 @@ protected void initChannel(DomainSocketChannel ch) {
82100
c.setupOutboundProtocol(PlayProtocols.CONTROLLERBOUND);
83101
});
84102
// will run after above since it is flushed above
85-
connection.send(new ControllerboundHelloPacket(Minecraft.getInstance().getWindow().getWindow()));
103+
// TODO: too early to access window handle, it hasn't been created yet, have to negotiate window later
104+
connection.send(new ControllerboundHelloPacket(0L));
86105

87106
return connection;
88107
}
89-
90-
private void initChannel(Channel ch, Connection connection) {
91-
try {
92-
ch.config().setOption(ChannelOption.TCP_NODELAY, true);
93-
} catch (ChannelException ignored) {
94-
}
95-
96-
ChannelPipeline pipeline = ch.pipeline()
97-
.addLast("timeout", new ReadTimeoutHandler(5));
98-
ConnectionUtils.configureSerialization(pipeline, PacketFlow.CLIENTBOUND, false, HandshakeProtocols.CONTROLLERBOUND);
99-
connection.configurePacketHandler(pipeline);
100-
}
101108
}

splitscreen/src/main/java/dev/isxander/controlify/splitscreen/client/protocol/handshake/ControllerboundHandshakePacket.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
public record ControllerboundHandshakePacket(int protocolVersion) implements Packet<ControllerHandshakePacketListener> {
1212
public static final StreamCodec<ByteBuf, ControllerboundHandshakePacket> CODEC =
1313
ByteBufCodecs.INT.map(ControllerboundHandshakePacket::new, ControllerboundHandshakePacket::protocolVersion);
14-
public static final PacketType<ControllerboundHandshakePacket> TYPE = new PacketType<>(PacketFlow.CLIENTBOUND, CUtil.rl("handshake"));
14+
public static final PacketType<ControllerboundHandshakePacket> TYPE = new PacketType<>(PacketFlow.SERVERBOUND, CUtil.rl("handshake"));
1515

1616
@Override
1717
public void handle(ControllerHandshakePacketListener handler) {

splitscreen/src/main/java/dev/isxander/controlify/splitscreen/mixins/core/WindowMixin.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ private void preventModeChange(CallbackInfo ci) {
4242
private void preventIfSplitscreen(String message, CallbackInfo ci) {
4343
if (SplitscreenBootstrapper.isSplitscreen()) {
4444
if (!hasDoneInitialSetup) {
45-
throw new IllegalStateException("Window did not get enough time to do initial setup before Splitscreen was setup.");
45+
return;
4646
}
4747

4848
LOGGER.info(message);

splitscreen/src/main/java/dev/isxander/controlify/splitscreen/server/SplitscreenController.java

+6
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
package dev.isxander.controlify.splitscreen.server;
22

3+
import com.mojang.logging.LogUtils;
34
import dev.isxander.controlify.splitscreen.SocketConnectionMethod;
45
import dev.isxander.controlify.splitscreen.SplitscreenPawn;
56
import dev.isxander.controlify.splitscreen.client.ClientSplitscreenPawn;
67
import dev.isxander.controlify.splitscreen.server.protocol.ControllerConnectionListener;
78
import net.minecraft.client.Minecraft;
89
import org.lwjgl.glfw.GLFW;
10+
import org.slf4j.Logger;
911

1012
import java.util.ArrayList;
1113
import java.util.List;
1214
import java.util.function.Consumer;
1315

1416
public class SplitscreenController {
17+
private static final Logger LOGGER = LogUtils.getLogger();
18+
1519
private final List<SplitscreenPawn> pawns = new ArrayList<>();
1620
private final ControllerConnectionListener connectionListener;
1721

@@ -25,6 +29,8 @@ public void forEachPawn(Consumer<SplitscreenPawn> consumer) {
2529
}
2630

2731
public void addPawn(SplitscreenPawn pawn) {
32+
LOGGER.info("Adding pawn #{}", this.pawns.size());
33+
2834
this.pawns.add(pawn);
2935
}
3036

splitscreen/src/main/java/dev/isxander/controlify/splitscreen/server/protocol/ControllerConnectionListener.java

+4-2
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ private void startListener(SplitscreenController controller, ServerBootstrap boo
7979
synchronized (this.channels) {
8080
boostrap
8181
.group(Epoll.isAvailable() ? SERVER_EPOLL_EVENT_GROUP.get() : SERVER_EVENT_GROUP.get())
82-
.childHandler(new ChannelInitializer<ServerSocketChannel>() {
82+
.childHandler(new ChannelInitializer<>() {
8383
@Override
84-
protected void initChannel(ServerSocketChannel ch) {
84+
protected void initChannel(Channel ch) {
8585
try {
8686
ch.config().setOption(ChannelOption.TCP_NODELAY, true);
8787
} catch (ChannelException ignored) {}
@@ -136,6 +136,8 @@ public void tick() {
136136
connection.setReadOnly();
137137
}
138138
} else {
139+
LOGGER.info("Disconnected {}", connection.getLoggableAddress(false));
140+
139141
it.remove();
140142
connection.handleDisconnection();
141143
}

splitscreen/src/main/java/dev/isxander/controlify/splitscreen/server/protocol/play/ControllerPlayPacketListener.java

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package dev.isxander.controlify.splitscreen.server.protocol.play;
22

3+
import com.mojang.logging.LogUtils;
34
import dev.isxander.controlify.splitscreen.SplitscreenPawn;
45
import dev.isxander.controlify.splitscreen.client.protocol.play.ControllerboundHelloPacket;
56
import dev.isxander.controlify.splitscreen.client.protocol.play.ControllerboundKeepAlivePacket;
@@ -10,8 +11,11 @@
1011
import net.minecraft.network.ConnectionProtocol;
1112
import net.minecraft.network.DisconnectionDetails;
1213
import net.minecraft.network.protocol.game.ServerPacketListener;
14+
import org.slf4j.Logger;
1315

1416
public class ControllerPlayPacketListener implements ControllerboundCommonPacketListener, ServerPacketListener {
17+
private static final Logger LOGGER = LogUtils.getLogger();
18+
1519
private final SplitscreenController controller;
1620
private SplitscreenPawn pawnInstance;
1721
private final Connection connection;

splitscreen/src/main/java/dev/isxander/controlify/splitscreen/util/SocketUtil.java

+35-18
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
package dev.isxander.controlify.splitscreen.util;
22

3+
import com.mojang.logging.LogUtils;
34
import dev.isxander.controlify.splitscreen.SocketConnectionMethod;
5+
import org.slf4j.Logger;
46

57
import java.io.IOException;
6-
import java.net.InetSocketAddress;
7-
import java.net.StandardProtocolFamily;
8-
import java.net.UnixDomainSocketAddress;
8+
import java.net.*;
99
import java.nio.channels.AlreadyBoundException;
1010
import java.nio.channels.ServerSocketChannel;
11+
import java.nio.channels.SocketChannel;
1112
import java.nio.file.Files;
13+
import java.nio.file.NoSuchFileException;
1214
import java.nio.file.Path;
1315

1416
public final class SocketUtil {
17+
private static final Logger LOGGER = LogUtils.getLogger();
18+
1519
private static final boolean IS_AF_UNIX_SUPPORTED;
1620
static {
1721
boolean isAfUnixSupported;
@@ -29,7 +33,7 @@ public final class SocketUtil {
2933
IS_AF_UNIX_SUPPORTED = isAfUnixSupported;
3034
}
3135

32-
public static boolean isSocketOpen(SocketConnectionMethod method) {
36+
public static boolean isSocketListening(SocketConnectionMethod method) {
3337
switch (method) {
3438
case SocketConnectionMethod.TCP(int port) -> {
3539
try (ServerSocketChannel server = ServerSocketChannel.open()) {
@@ -38,8 +42,7 @@ public static boolean isSocketOpen(SocketConnectionMethod method) {
3842
} catch (AlreadyBoundException e) {
3943
return true; // Socket is open
4044
} catch (IOException io) {
41-
// Handle other IO exceptions (e.g., permission issues)
42-
return true; // Socket is open
45+
throw new RuntimeException(io);
4346
}
4447
}
4548
case SocketConnectionMethod.Unix(String path) -> {
@@ -50,18 +53,32 @@ public static boolean isSocketOpen(SocketConnectionMethod method) {
5053
var socketPath = Path.of(path);
5154
var address = UnixDomainSocketAddress.of(path);
5255

53-
// If a stale socket file is lying around (common after crashes), remove it first;
54-
// otherwise bind() will throw “Address already in use”.
55-
try { Files.deleteIfExists(socketPath); } catch (IOException ignored) {}
56-
57-
try (ServerSocketChannel server = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) {
58-
server.bind(address);
59-
return false; // Socket is not open
60-
} catch (AlreadyBoundException e) {
61-
return true; // Socket is open
62-
} catch (IOException io) {
63-
// Handle other IO exceptions (e.g., permission issues)
64-
return true; // Socket is open
56+
// Utilise try-with-resources to ensure the socket is closed automatically
57+
try (SocketChannel socket = SocketChannel.open(StandardProtocolFamily.UNIX)) {
58+
LOGGER.info("Attempting to connect to {}", socketPath);
59+
socket.connect(address);
60+
// If connection succeeds, something is listening.
61+
LOGGER.info("Connection successful to {}. Socket is active.", socketPath);
62+
// The try-with-resources will close the socket upon exiting this block.
63+
return true;
64+
} catch (ConnectException e) {
65+
// Connection refused means the file might exist,
66+
// but nothing is actively listening/accepting connections.
67+
LOGGER.info("Connection refused for {}: {}", socketPath, e.getMessage());
68+
try {
69+
Files.deleteIfExists(socketPath);
70+
} catch (IOException ex) {
71+
throw new RuntimeException(ex);
72+
}
73+
return false;
74+
} catch (NoSuchFileException e) {
75+
// The socket file doesn't even exist.
76+
LOGGER.info("Socket file not found: {}: {}", socketPath, e.getMessage());
77+
return false;
78+
} catch (IOException e) {
79+
// Handle other potential I/O errors (e.g., permission denied)
80+
LOGGER.info("IOException while checking socket {}", socketPath, e);
81+
return false;
6582
}
6683
}
6784
}

0 commit comments

Comments
 (0)