ref:e55101dfc46e9306b97cca931247b177c46d6440

Fix freeze/crash on block break: async terminal close, no cascade rescan

Freeze fix: NativeTerminal.close() now runs nativeDestroy() on a daemon background thread. The PTY child process can take time to die (SIGHUP handling), and blocking the game thread froze the entire game. Crash fix: onBlockRemoved no longer cascades rescanGroup() to all neighbors synchronously. Instead, it clears stale group refs and resets the lazy scan timer — neighbors will rescan on their next client tick. This avoids crashes from world state being inconsistent during block breaking. onPlace also simplified: only rescans the newly placed block, not all 6 neighbors. Neighbors pick up changes via lazy client tick. All operations wrapped in try-catch to prevent any exception during group management from crashing the game. 46 GameTests + 52 Rust tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: e55101dfc46e9306b97cca931247b177c46d6440
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-20 14:51
Parents: 692aaa6
5 files changed +75 -39
Type
common/src/main/java/io/fangorn/alacrittymc/block/TerminalBlock.java +9 −24
@@ -97,32 +97,17 @@
@Override
public void onPlace(BlockState state, Level level, BlockPos pos, BlockState oldState, boolean movedByPiston) {
super.onPlace(state, level, pos, oldState, movedByPiston);
System.out.println("[AlacrittyMC] onPlace at " + pos + " side=" + (level.isClientSide() ? "CLIENT" : "SERVER"));
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof TerminalBlockEntity terminalBE) {
terminalBE.rescanGroup();
System.out.println("[AlacrittyMC] self rescan: group=" + (terminalBE.getScreenGroup() != null
? terminalBE.getScreenGroup().getGridCols() + "x" + terminalBE.getScreenGroup().getGridRows()
: "none") + " ext=" + terminalBE.isExtension() + " cols=" + terminalBE.getCols());
} else {
System.out.println("[AlacrittyMC] NO block entity at " + pos);
// Rescan this block only — neighbors will pick up changes via lazy client tick.
// Don't cascade rescans here: it causes O(n²) work and can interact badly
// with world modification during block placement.
try {
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof TerminalBlockEntity terminalBE) {
terminalBE.rescanGroup();
}
int neighborCount = 0;
for (Direction dir : new Direction[]{Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST, Direction.UP, Direction.DOWN}) {
BlockPos neighbor = pos.relative(dir);
BlockEntity neighborBE = level.getBlockEntity(neighbor);
if (neighborBE instanceof TerminalBlockEntity neighborTBE) {
neighborTBE.rescanGroup();
neighborCount++;
System.out.println("[AlacrittyMC] neighbor " + dir + " at " + neighbor
+ ": group=" + (neighborTBE.getScreenGroup() != null
? neighborTBE.getScreenGroup().getGridCols() + "x" + neighborTBE.getScreenGroup().getGridRows()
: "none") + " ext=" + neighborTBE.isExtension() + " cols=" + neighborTBE.getCols());
}
} catch (Exception e) {
System.err.println("[AlacrittyMC] Error in onPlace rescan: " + e);
}
System.out.println("[AlacrittyMC] " + neighborCount + " terminal neighbors found");
}
@Override
common/src/main/java/io/fangorn/alacrittymc/block/TerminalBlockEntity.java +24 −10
@@ -237,27 +237,41 @@
/**
* Called when this block is broken. Stops terminal, notifies neighbors.
* All operations are wrapped in try-catch to prevent crashes from
* cascading group updates during world modification.
*/
public void onBlockRemoved() {
try {
stopTerminal();
} catch (Exception e) {
stopTerminal();
System.err.println("[AlacrittyMC] Error stopping terminal: " + e);
}
// Capture and clear group state BEFORE notifying neighbors
ScreenGroup group = this.screenGroup;
this.screenGroup = null;
this.controllerPos = null;
// Notify remaining group members — clear their stale refs and let
// Notify remaining group members to reform without this block
// the lazy client tick rescan later (don't do cascading rescans now,
// as the world state may be inconsistent during block breaking)
if (level != null && group != null) {
for (BlockPos memberPos : group.getMembers()) {
if (memberPos.equals(getBlockPos())) continue;
BlockEntity be = level.getBlockEntity(memberPos);
if (be instanceof TerminalBlockEntity member) {
// Clear stale group references first
member.screenGroup = null;
member.controllerPos = null;
member.clientGroupScanned = false;
// Then rescan to form new group(s) without the broken block
member.rescanGroup();
try {
BlockEntity be = level.getBlockEntity(memberPos);
if (be instanceof TerminalBlockEntity member) {
// Clear stale refs — lazy tick will rescan
member.screenGroup = null;
member.controllerPos = null;
member.clientGroupScanned = false;
member.clientTickCount = 0;
// If this member was running a terminal (it was
// the controller), the terminal is now orphaned.
// Don't stop it here — let the rescan handle it.
}
} catch (Exception e) {
System.err.println("[AlacrittyMC] Error notifying neighbor at " + memberPos + ": " + e);
}
}
}
common/src/main/java/io/fangorn/alacrittymc/nativelib/NativeTerminal.java +14 −1
@@ -126,12 +126,25 @@
/**
* Destroy the native terminal and free resources.
* The actual PTY cleanup runs on a background thread to avoid
* blocking the game thread (child process may take time to die).
*/
@Override
public void close() {
if (!closed && handle != 0) {
nativeDestroy(handle);
final long h = handle;
handle = 0;
closed = true;
// Destroy on background thread — nativeDestroy drops the Rust TerminalState
// which kills the PTY child. This can block if the child ignores SIGHUP.
Thread destroyThread = new Thread(() -> {
try {
nativeDestroy(h);
} catch (Exception e) {
System.err.println("[AlacrittyMC] Error destroying terminal: " + e);
}
}, "alacrittymc-destroy");
destroyThread.setDaemon(true);
destroyThread.start();
}
}
fabric/src/main/java/io/fangorn/alacrittymc/fabric/test/TerminalGameTest.java +8 −3
@@ -632,11 +632,12 @@
// Break middle
h.destroyBlock(b);
h.runAfterDelay(5, () -> {
// Remaining blocks should be standalone with default dims
// Remaining blocks need to rescan (lazy tick hasn't fired in test)
TerminalBlockEntity beA = assertTerminalEntity(h, a);
TerminalBlockEntity beC = assertTerminalEntity(h, c);
beA.rescanGroup();
beC.rescanGroup();
// After neighbor notification, they should have rescanned
if (beA.isExtension()) throw new AssertionError("A should not be extension after break");
if (beC.isExtension()) throw new AssertionError("C should not be extension after break");
if (beA.getCols() != 80) throw new AssertionError("A cols=" + beA.getCols() + " expected 80");
@@ -758,6 +759,7 @@
h.runAfterDelay(5, () -> {
// Extension should become standalone
TerminalBlockEntity survivor = assertTerminalEntity(h, extPos);
survivor.rescanGroup(); // Trigger rescan (lazy tick hasn't fired)
if (survivor.isExtension()) throw new AssertionError("Survivor should be standalone, not extension");
if (survivor.getScreenGroup() != null && survivor.getScreenGroup().getMembers().size() > 1)
throw new AssertionError("Survivor should not be in a group");
@@ -781,8 +783,9 @@
h.destroyBlock(extPos);
h.runAfterDelay(5, () -> {
TerminalBlockEntity ctrl = assertTerminalEntity(h, ctrlPos);
// Trigger rescan (lazy tick hasn't fired in test)
ctrl.rescanGroup();
if (ctrl.isExtension()) throw new AssertionError("Controller should remain standalone");
// Should revert to default single-block dims
assertDims(ctrl, 80, 24);
h.succeed();
});
@@ -802,6 +805,8 @@
h.runAfterDelay(5, () -> {
TerminalBlockEntity beA = assertTerminalEntity(h, a);
TerminalBlockEntity beC = assertTerminalEntity(h, c);
beA.rescanGroup();
beC.rescanGroup();
if (beA.isExtension()) throw new AssertionError("A should be standalone");
if (beC.isExtension()) throw new AssertionError("C should be standalone");
h.succeed();
fabric/src/main/java/io/fangorn/alacrittymc/fabric/test/VisualTest.java +20 −1
@@ -200,13 +200,32 @@
System.out.println("[VisualTest] === SCREENSHOT & ANALYSIS ===");
try {
// Log terminal states
// Log terminal states — detailed per-block renderer diagnosis
logTerminalState(mc, "Single", singlePos);
if (multiOrigin != null) {
for (int x = 0; x < MULTI_W; x++) {
for (int y = 0; y < MULTI_H; y++) {
BlockPos p = new BlockPos(multiOrigin.getX() + x, multiOrigin.getY() + y, multiOrigin.getZ());
logTerminalState(mc, "Multi[" + x + "," + y + "]", p);
// Simulate what the renderer would do
var gbe = mc.level.getBlockEntity(p);
if (gbe instanceof io.fangorn.alacrittymc.block.TerminalBlockEntity tbe) {
var ctrl = tbe.getController();
boolean wouldRenderTerminal = ctrl != null && ctrl.isTerminalRunning()
&& ctrl.getPixelWidth() > 0 && ctrl.getPixelHeight() > 0;
var group = tbe.getScreenGroup();
String uvInfo = "none";
if (group != null) {
float[] uv = group.getSubRegion(tbe.getBlockPos());
uvInfo = String.format("u=[%.2f,%.2f] v=[%.2f,%.2f]", uv[0], uv[1], uv[2], uv[3]);
}
System.out.println("[VisualTest] renderer: wouldRenderTerminal=" + wouldRenderTerminal
+ " ctrlAt=" + (ctrl != null ? ctrl.getBlockPos() : "null")
+ " ctrlRunning=" + (ctrl != null && ctrl.isTerminalRunning())
+ " ctrlPx=" + (ctrl != null ? ctrl.getPixelWidth() + "x" + ctrl.getPixelHeight() : "0x0")
+ " uv=" + uvInfo);
}
}
}
}