Studio hotload deadlock fix

- Live boundary capture runs without the generation history lock
- Promotion prepares the boundary and activates under the lock in separate steps
- Tab completion and find lookups stay responsive while a Studio save captures terrain
- Admission test covers history reads during the boundary checkpoint
This commit is contained in:
Brian Neumann-Fopiano
2026-09-29 11:58:25 -04:00
parent 058d527c73
commit 593c3f732a
2 changed files with 79 additions and 16 deletions
@@ -457,9 +457,7 @@ public final class GenerationHistory {
"signatureCapture"
);
try (GenerationAdmission.CutoverLease ignored = admission.beginStartupCutover()) {
synchronized (this) {
return promotePendingLocked(requiredCapture, true, true);
}
return promoteCaptured(requiredCapture, true);
}
}
@@ -478,9 +476,7 @@ public final class GenerationHistory {
GenerationActivation promote(BoundarySignatureCapture signatureCapture) throws IOException {
requireOpen();
synchronized (GenerationHistory.this) {
return promotePendingLocked(Objects.requireNonNull(signatureCapture, "signature capture"), false, true);
}
return promoteCaptured(Objects.requireNonNull(signatureCapture, "signature capture"), false);
}
private void requireOpen() {
@@ -497,34 +493,67 @@ public final class GenerationHistory {
}
}
private GenerationActivation promoteCaptured(
BoundarySignatureCapture signatureCapture, boolean startupRecovery
) throws IOException {
Optional<PreparedPromotion> prepared;
synchronized (this) {
prepared = preparePromotionLocked(startupRecovery, true);
}
if (prepared.isEmpty()) {
return activeActivation();
}
TerrainBoundarySignatureStore.Snapshot terrainSnapshot = publishTerrainSignatures(prepared.get(), signatureCapture);
synchronized (this) {
return completePromotionLocked(prepared.get(), terrainSnapshot);
}
}
private GenerationActivation promotePendingLocked(
BoundarySignatureCapture signatureCapture, boolean startupRecovery, boolean validateSemantics
) throws IOException {
Optional<PreparedPromotion> prepared = preparePromotionLocked(startupRecovery, validateSemantics);
if (prepared.isEmpty()) {
return store.activeActivation();
}
return completePromotionLocked(prepared.get(), publishTerrainSignatures(prepared.get(), signatureCapture));
}
private Optional<PreparedPromotion> preparePromotionLocked(
boolean startupRecovery, boolean validateSemantics
) throws IOException {
sync();
WorldChunkInventory inventory = recoverUnstoredClaims(startupRecovery);
validateReferencedState(Optional.empty(), validateSemantics);
Optional<GenerationActivation> pending = store.pendingActivation();
if (pending.isEmpty()) {
return store.activeActivation();
return Optional.empty();
}
long outgoingActivationId = store.activeActivation().activationId();
ownership.assignUnassigned(inventory, outgoingActivationId);
ownership.persist();
requireExplicitOwnership(inventory);
GenerationBoundary boundary = boundaries.publishOwnership(
pending.get().activationId(),
ownership
);
TerrainBoundarySignatureStore.Snapshot terrainSnapshot;
long pendingActivationId = pending.get().activationId();
return Optional.of(new PreparedPromotion(pendingActivationId, boundaries.publishOwnership(pendingActivationId, ownership)));
}
private TerrainBoundarySignatureStore.Snapshot publishTerrainSignatures(
PreparedPromotion prepared, BoundarySignatureCapture signatureCapture
) throws IOException {
try (TerrainBoundarySignatureStore.SignatureSampler sampler = Objects.requireNonNull(
signatureCapture.capture(boundary), "boundary signature sampler"
signatureCapture.capture(prepared.boundary()), "boundary signature sampler"
)) {
terrainSnapshot = terrainSignatures.publish(pending.get().activationId(), boundary, sampler);
return terrainSignatures.publish(prepared.activationId(), prepared.boundary(), sampler);
}
}
private GenerationActivation completePromotionLocked(
PreparedPromotion prepared, TerrainBoundarySignatureStore.Snapshot terrainSnapshot
) throws IOException {
GenerationActivation completed = store.completePendingTransition(
pending.get().activationId(),
boundary.identity(),
prepared.activationId(),
prepared.boundary().identity(),
terrainSnapshot.identity()
);
GenerationActivation activated = store.activatePending(completed.activationId());
@@ -1396,6 +1425,9 @@ public final class GenerationHistory {
public interface BoundarySignatureCapture {
TerrainBoundarySignatureStore.SignatureSampler capture(GenerationBoundary boundary) throws IOException;
}
private record PreparedPromotion(long activationId, GenerationBoundary boundary) {
}
public record FreshCreation(Path dimensionRoot, Path packSource, String packFingerprint, long worldSeed,
GenerationEpoch.DimensionContract dimensionContract, GenerationRegistryContract registryContract) {
public FreshCreation {
@@ -143,6 +143,37 @@ public class GenerationHistoryAdmissionTest {
}
}
@Test
public void liveBoundaryCaptureLeavesHistoryReadableFromOtherThreads() throws Exception {
GenerationHistory history = history();
Path replacement = pack("replacement", "beta");
GenerationActivation pending = history.stageUpdate(replacement, fingerprint(replacement),
history.activeEpoch().dimensionContract(), GenerationRegistryContract.empty(), 32);
ExecutorService tickThread = Executors.newSingleThreadExecutor();
try (GenerationHistory.LiveCutover cutover = history.beginLiveCutover()) {
GenerationActivation promoted = cutover.promote(boundary -> new TerrainBoundarySignatureStore.SignatureSampler() {
@Override
public TerrainBoundarySignature sample(int blockX, int blockZ) {
return null;
}
@Override
public void close() throws IOException {
try {
assertEquals(pending.activationId(), tickThread.submit(history::pendingActivation)
.get(1, TimeUnit.SECONDS).orElseThrow().activationId());
} catch (InterruptedException | ExecutionException | TimeoutException failure) {
throw new IOException("History was unreadable while the boundary checkpoint waited", failure);
}
}
});
assertEquals(pending.activationId(), promoted.activationId());
} finally {
drain(tickThread);
}
assertEquals(pending.activationId(), history.activeActivation().activationId());
}
@Test
public void failedMetadataReadReleasesItsStageAdmission() throws Exception {
GenerationHistory history = history();