diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index 903f474d6..ad09e8114 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -210,6 +211,51 @@ public QuickPreview previewQuickSetup(String preset, Map options changes(parse(current), parse(proposal.content()))); } + /** Returns the small, non-secret state represented by a guided setup form. */ + public QuickState readQuickSetup(String preset, Map options) throws IOException { + String fileName = quickSetupFile(preset); + String current = readRaw(resolve(fileName), false); + YamlConfiguration yaml = parse(current); + Map values = new LinkedHashMap<>(); + if ("standalone".equals(preset) || "proxy-backend".equals(preset)) { + values.put("useBungeecord", String.valueOf(yaml.getBoolean("UseBungeecord", false))); + values.put("server", yaml.getString("Server", "")); + values.put("method", canonicalBungeeMethod(yaml.getString("BungeeMethod", "PLUGINMESSAGING"))); + } else if ("vote-site".equals(preset)) { + String name = option(options, "name", "[A-Za-z0-9_-]{1,64}"); + String root = "VoteSites." + name; + values.put("name", name); + values.put("exists", String.valueOf(yaml.isConfigurationSection(root))); + values.put("enabled", String.valueOf(yaml.getBoolean(root + ".Enabled", true))); + values.put("displayName", yaml.getString(root + ".Name", name)); + values.put("priority", String.valueOf(yaml.getInt(root + ".Priority", 5))); + values.put("hidden", String.valueOf(yaml.getBoolean(root + ".Hidden", false))); + values.put("serviceSite", yaml.getString(root + ".ServiceSite", "")); + values.put("voteUrl", yaml.getString(root + ".VoteURL", "")); + values.put("voteDelay", yaml.getString(root + ".VoteDelay", "24h")); + values.put("material", yaml.getString(root + ".DisplayItem.Material", "DIAMOND")); + } else if ("common-settings".equals(preset)) { + values.put("processRewards", String.valueOf(yaml.getBoolean("ProcessRewards", true))); + values.put("autoCreateVoteSites", String.valueOf(yaml.getBoolean("AutoCreateVoteSites", true))); + values.put("extraAllSitesCheck", String.valueOf(yaml.getBoolean("ExtraAllSitesCheck", false))); + values.put("countFakeVotes", String.valueOf(yaml.getBoolean("CountFakeVotes", true))); + values.put("disableNoServiceSiteMessage", + String.valueOf(yaml.getBoolean("DisableNoServiceSiteMessage", false))); + values.put("disableUpdateChecking", String.valueOf(yaml.getBoolean("DisableUpdateChecking", false))); + } else if ("vote-party".equals(preset)) { + values.put("enabled", String.valueOf(yaml.getBoolean("VoteParty.Enabled", false))); + values.put("votesRequired", String.valueOf(yaml.getInt("VoteParty.VotesRequired", 20))); + values.put("broadcast", yaml.getString("VoteParty.Broadcast", "")); + values.put("giveAllPlayers", String.valueOf(yaml.getBoolean("VoteParty.GiveAllPlayers", false))); + values.put("onlineOnly", String.valueOf(yaml.getBoolean("VoteParty.GiveOnlinePlayersOnly", true))); + values.put("rewardCommandCount", + String.valueOf(yaml.getStringList("VoteParty.Rewards.Commands").size())); + } else { + throw new IllegalArgumentException("quick setup preset cannot be read"); + } + return new QuickState(Map.copyOf(values), quickSetupRevision(preset, current)); + } + String proposedQuickSetupRevision(String preset, QuickPreview preview) throws IOException { return quickSetupRevision(preset, preview.proposal().content()); } @@ -283,10 +329,10 @@ private QuickProposal quickProposal(String preset, Map options, if ("vote-site".equals(preset)) { String name = option(options, "name", "[A-Za-z0-9_-]{1,64}"); String root = "VoteSites." + name; - yaml.set(root + ".Enabled", true); + yaml.set(root + ".Enabled", booleanOption(options, "enabled", true)); yaml.set(root + ".Name", options.getOrDefault("displayName", name)); yaml.set(root + ".Priority", boundedInteger(options.getOrDefault("priority", "5"), 0, 100)); - yaml.set(root + ".Hidden", false); + yaml.set(root + ".Hidden", booleanOption(options, "hidden", false)); yaml.set(root + ".ServiceSite", option(options, "serviceSite", ".{1,200}")); yaml.set(root + ".VoteURL", option(options, "voteUrl", ".{1,500}")); yaml.set(root + ".VoteDelay", options.getOrDefault("voteDelay", "24h")); @@ -309,8 +355,10 @@ private QuickProposal quickProposal(String preset, Map options, if (command.isBlank() && message.isBlank()) { throw new IllegalArgumentException("easy reward requires a command or player message"); } - if (!command.isBlank()) yaml.set(root + ".Commands", List.of(command)); - if (!message.isBlank()) yaml.set(root + ".Messages.Player", message); + if (!command.isBlank()) appendUniqueString(yaml, root + ".Commands", command); + if (!message.isBlank() && !yaml.contains(root + ".Messages.Player")) { + yaml.set(root + ".Messages.Player", message); + } return new QuickProposal(fileName, yaml.saveToString()); } if ("common-settings".equals(preset)) { @@ -329,13 +377,30 @@ private QuickProposal quickProposal(String preset, Map options, yaml.set("VoteParty.GiveOnlinePlayersOnly", booleanOption(options, "onlineOnly")); String command = optional(options, "command", 500); String broadcast = optional(options, "broadcast", 500); - if (!command.isBlank()) yaml.set("VoteParty.Rewards.Commands", List.of(command)); + if (!command.isBlank()) appendUniqueString(yaml, "VoteParty.Rewards.Commands", command); if (!broadcast.isBlank()) yaml.set("VoteParty.Broadcast", broadcast); return new QuickProposal(fileName, yaml.saveToString()); } throw new IllegalArgumentException("quick setup preset is unsupported"); } + private static void appendUniqueString(YamlConfiguration yaml, String path, String value) { + Object current = yaml.get(path); + List values = new ArrayList<>(); + if (current instanceof List listed) { + for (Object item : listed) { + if (!(item instanceof String text)) { + throw new IllegalArgumentException(path + " contains a non-text entry; use the full YAML editor"); + } + values.add(text); + } + } else if (current != null) { + throw new IllegalArgumentException(path + " is not a list; use the full YAML editor"); + } + if (!values.contains(value)) values.add(value); + yaml.set(path, values); + } + private boolean caseInsensitiveYmlFiles() throws IOException { Path config = resolve("Config.yml"); if (!Files.exists(config)) return false; @@ -820,6 +885,11 @@ private static boolean booleanOption(Map options, String name) { return Boolean.parseBoolean(value); } + private static boolean booleanOption(Map options, String name, boolean defaultValue) { + if (options == null || !options.containsKey(name)) return defaultValue; + return booleanOption(options, name); + } + private static int boundedInteger(String value, int minimum, int maximum) { try { int parsed = Integer.parseInt(value); @@ -835,6 +905,7 @@ public record Preview(String fileName, String resolvedContent, String revision, public record ApplyResult(Document document, List changes, boolean rolledBack) { } public record QuickProposal(String fileName, String content) { } public record QuickPreview(QuickProposal proposal, String revision, List changes) { } + public record QuickState(Map options, String revision) { } @FunctionalInterface public interface ReloadAction { void run() throws Exception; } @FunctionalInterface public interface ApplyAction { void run(String fileName) throws Exception; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index cf52ddfc3..63c95c2ce 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -42,6 +42,7 @@ public final class BackendControlConnector implements AutoCloseable { private static final int PROTOCOL_VERSION = 1; private static final int MAX_RESPONSE_BYTES = 4 * 1024 * 1024; + private static final long OPERATION_POLL_MILLIS = 1000; private static final long SHUTDOWN_TIMEOUT_SECONDS = 65; private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Set CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1", @@ -69,6 +70,7 @@ public final class BackendControlConnector implements AutoCloseable { private volatile boolean voteSitesSyncAccepted; private volatile int failures; private volatile ScheduledFuture scheduled; + private volatile ScheduledFuture operationPolling; private volatile Future activeReload; private volatile CompletableFuture activeOperation; @@ -158,7 +160,45 @@ public boolean isClosed() { return closed; } - public void start() { schedule(0); } + public void start() { + if (closed) return; + schedule(0); + operationPolling = executor.scheduleWithFixedDelay(this::pollOperations, + OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + } + + /** Claims configuration work independently of the lower-frequency presence heartbeat. */ + private void pollOperations() { + if (closed || !registered || failures != 0 || !operationsAccepted + || !running.compareAndSet(false, true)) return; + CompletableFuture operation = new CompletableFuture<>(); + synchronized (operationLifecycle) { + if (closed) { + running.set(false); + return; + } + activeOperation = operation; + } + try { + claimAndExecute(); + } catch (Exception failure) { + registered = false; + failures = Math.min(30, failures + 1); + if (failures == 1 || failures % 10 == 0) { + plugin.getLogger().warning("[Control] Bukkit operation polling unavailable; VotingPlugin remains active"); + } + ScheduledFuture heartbeat = scheduled; + if (heartbeat != null) heartbeat.cancel(false); + if (!closed) schedule(Math.min(TimeUnit.MINUTES.toMillis(5), + 1000L << Math.min(failures - 1, 8))); + } finally { + operation.complete(null); + synchronized (operationLifecycle) { + if (activeOperation == operation) activeOperation = null; + } + running.set(false); + } + } private void schedule(long delayMillis) { if (!closed) scheduled = executor.schedule(this::cycle, delayMillis, TimeUnit.MILLISECONDS); @@ -450,7 +490,7 @@ private TaskResult execute(UUID operationId, JsonObject task) { } catch (BackendConfigurationService.StaleRevisionException e) { return TaskResult.failure("STALE_REVISION", "Configuration changed after preview"); } catch (BackendConfigurationService.ApplyFailureException e) { - return TaskResult.failure("RELOAD_FAILED", "Reload failed after persistence", e.rolledBack()); + return TaskResult.failure("RELOAD_FAILED", failureMessage("Reload failed", e), e.rolledBack()); } catch (IllegalArgumentException e) { return TaskResult.failure("VALIDATION_ERROR", e.getMessage()); } catch (Exception e) { @@ -487,12 +527,15 @@ private TaskResult executeFile(UUID operationId, String type, JsonObject configu private TaskResult executeQuick(UUID operationId, String type, JsonObject configuration, JsonObject task) throws IOException { - if ("READ".equals(type)) return TaskResult.failure("UNSUPPORTED_TASK", "Quick setups cannot be read"); String preset = string(configuration, "preset"); if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, voteSitesSyncAccepted)) { return TaskResult.failure("UNSUPPORTED_TASK", "VoteSites sync was not negotiated"); } Map options = options(configuration.getAsJsonObject("options")); + if ("READ".equals(type)) { + BackendConfigurationService.QuickState state = configurations.readQuickSetup(preset, options); + return TaskResult.quick(preset, state.options(), state.revision(), List.of(), false); + } if ("PREVIEW".equals(type)) { BackendConfigurationService.QuickPreview preview = configurations.previewQuickSetup(preset, options); return TaskResult.quick(preset, options, preview.revision(), preview.changes(), false); @@ -510,6 +553,19 @@ private TaskResult executeQuick(UUID operationId, String type, JsonObject config return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported"); } + static String failureMessage(String prefix, Throwable failure) { + Throwable detail = failure; + while (detail.getCause() != null && (detail instanceof BackendConfigurationService.ApplyFailureException + || detail instanceof java.util.concurrent.ExecutionException + || detail instanceof java.util.concurrent.CompletionException + || detail.getMessage() == null || detail.getMessage().isBlank())) detail = detail.getCause(); + String message = detail.getMessage(); + if (message == null || message.isBlank()) message = detail.getClass().getSimpleName(); + message = message.replaceAll("[\\p{Cntrl}&&[^\\t]]", " ").trim(); + if (message.length() > 240) message = message.substring(0, 237) + "..."; + return prefix + ": " + message; + } + static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, boolean voteSitesSyncAccepted) { return quickSetupsAccepted && (!"sync-vote-sites".equals(preset) || voteSitesSyncAccepted); @@ -594,6 +650,8 @@ public void close() { } ScheduledFuture current = scheduled; if (current != null) current.cancel(false); + ScheduledFuture polling = operationPolling; + if (polling != null) polling.cancel(false); if (reload != null && Bukkit.isPrimaryThread()) reload.cancel(false); awaitShutdown(executor, operation); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index 9bb34ab89..82b834c82 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -59,6 +59,7 @@ public final class ControlConnector implements AutoCloseable { private static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; private static final String PROXY_METHOD_PRESET = "proxy-method"; private static final String INTERNAL_OPERATION_TYPE = "_controlOperationType"; + private static final long OPERATION_POLL_MILLIS = 1000; private static final long MAX_BACKOFF_MILLIS = TimeUnit.MINUTES.toMillis(5); private static final long OPERATION_SHUTDOWN_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(65); @@ -87,6 +88,7 @@ public final class ControlConnector implements AutoCloseable { private volatile int failures; private volatile long snapshotSequence; private volatile ScheduledFuture scheduled; + private volatile ScheduledFuture operationPolling; private volatile CompletableFuture activeRequest; private volatile CompletableFuture activeOperation; private volatile Status status = Status.STARTING; @@ -202,6 +204,52 @@ public void start() { } status = Status.STARTING; schedule(0); + operationPolling = scheduler.scheduleWithFixedDelay(this::pollOperations, + OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + } + + /** Polls only the operation queue; heartbeat and presence retain their configured cadence. */ + void pollOperations() { + CompletableFuture operationDone; + synchronized (operationLifecycle) { + if (closed || !registered || status != Status.CONNECTED || !configurationAccepted + || !inFlight.compareAndSet(false, true)) return; + operationDone = new CompletableFuture<>(); + activeOperation = operationDone; + } + CompletableFuture operation; + try { + if (hasCompletedTask()) { + operation = submitCompletedResult(); + } else { + CompletableFuture claim = transport.send(claimRequest()); + activeRequest = claim; + operation = claim.thenCompose(this::handleClaimResponse); + } + } catch (RuntimeException failure) { + operation = new CompletableFuture<>(); + operation.completeExceptionally(failure); + } + operation.whenComplete((ignored, failure) -> { + Throwable cause = failure == null ? null : unwrap(failure); + try { + activeRequest = null; + if (cause == null) { + operationDone.complete(null); + } else { + registered = false; + operationDone.completeExceptionally(cause); + } + } finally { + if (activeOperation == operationDone) activeOperation = null; + finishCycle(); + } + if (cause != null && !closed) { + ScheduledFuture heartbeat = scheduled; + if (heartbeat != null) heartbeat.cancel(false); + onFailure(cause); + } + }); } public Status status() { @@ -247,7 +295,13 @@ private void schedule(long delayMillis) { void cycle() { synchronized (operationLifecycle) { - if (closed || !inFlight.compareAndSet(false, true)) return; + if (closed) return; + if (!inFlight.compareAndSet(false, true)) { + // A fast operation claim can overlap the one-shot heartbeat. Re-arm + // it so presence does not stop after this collision. + schedule(OPERATION_POLL_MILLIS); + return; + } } Request first = registered ? heartbeatRequest() : registrationRequest(); CompletableFuture primary; @@ -851,6 +905,8 @@ public void close() { if (scheduledRequest != null) { scheduledRequest.cancel(false); } + ScheduledFuture polling = operationPolling; + if (polling != null) polling.cancel(false); CompletableFuture request = activeRequest; if (request != null) { request.cancel(true); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index e34d992f4..86b6ec769 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -448,6 +448,50 @@ class BackendConfigurationServiceTest { assertTrue(party.proposal().content().contains("VotesRequired: 25")); } + @Test void guidedSettingsReadTheInstalledValuesInsteadOfAssumingDefaults() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "UseBungeecord: true\nServer: lobby\nBungeeMethod: REDIS\n"); + Files.writeString(directory.resolve("Config.yml"), "ProcessRewards: false\nAutoCreateVoteSites: true\n" + + "ExtraAllSitesCheck: true\nCountFakeVotes: false\nDisableNoServiceSiteMessage: true\n" + + "DisableUpdateChecking: true\n"); + Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n PMC:\n Enabled: false\n" + + " Name: Planet Minecraft\n Priority: 9\n Hidden: true\n" + + " ServiceSite: PlanetMinecraft.com\n VoteURL: https://example.com/vote\n" + + " VoteDelay: 12h\n DisplayItem:\n Material: EMERALD\n"); + Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: true\n" + + " VotesRequired: 35\n GiveAllPlayers: true\n GiveOnlinePlayersOnly: false\n" + + " Broadcast: Party!\n Rewards:\n Commands: [say one, say two]\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertEquals("lobby", service.readQuickSetup("proxy-backend", Map.of()).options().get("server")); + assertEquals("REDIS", service.readQuickSetup("proxy-backend", Map.of()).options().get("method")); + assertEquals("false", service.readQuickSetup("common-settings", Map.of()).options().get("processRewards")); + assertEquals("EMERALD", service.readQuickSetup("vote-site", Map.of("name", "PMC")) + .options().get("material")); + assertEquals("2", service.readQuickSetup("vote-party", Map.of()).options().get("rewardCommandCount")); + } + + @Test void guidedRewardsAppendWithoutReplacingExistingRewardConfiguration() throws Exception { + Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n PMC:\n Rewards:\n" + + " Commands: [existing]\n Messages:\n Player: Existing message\n"); + Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: true\n" + + " Rewards:\n Commands: [existing party]\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.QuickPreview reward = service.previewQuickSetup("easy-reward", Map.of( + "scope", "site", "name", "PMC", "command", "new reward", "message", "New message")); + assertTrue(reward.proposal().content().contains("existing")); + assertTrue(reward.proposal().content().contains("new reward")); + assertTrue(reward.proposal().content().contains("Existing message")); + assertFalse(reward.proposal().content().contains("New message")); + + BackendConfigurationService.QuickPreview party = service.previewQuickSetup("vote-party", Map.of( + "votesRequired", "20", "command", "new party", "broadcast", "Party!", + "giveAllPlayers", "false", "onlineOnly", "true")); + assertTrue(party.proposal().content().contains("existing party")); + assertTrue(party.proposal().content().contains("new party")); + } + @Test void proxyBackendQuickSetupRejectsUnknownTransportMethods() throws Exception { Files.writeString(directory.resolve("BungeeSettings.yml"), "UseBungeecord: false\nServer: PleaseSet\nBungeeMethod: PLUGINMESSAGING\n"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index f05c65544..24c9ee892 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -83,6 +83,13 @@ class BackendControlConnectorProtocolTest { assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("common-settings", true, false)); } + @Test void reloadFailureMessageIncludesTheUsefulNestedCause() { + String message = BackendControlConnector.failureMessage("Reload failed", + new java.util.concurrent.CompletionException(new IllegalStateException("invalid VoteSites.yml"))); + + assertTrue(message.equals("Reload failed: invalid VoteSites.yml")); + } + @Test void shutdownWaitsForTheClaimedBackendOperation() throws Exception { var executor = Executors.newSingleThreadScheduledExecutor(); CompletableFuture operation = new CompletableFuture<>(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java index 500b7b020..a6940fe03 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -141,6 +141,38 @@ class ControlConnectorTest { assertTrue(transport.requests.get(5).path().endsWith("/operations")); } + @Test void fastOperationPollDoesNotSendAnotherHeartbeatOrPresenceSnapshot() { + connector.close(); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, + new ProxyRoutingConfigurationService(new NoOpPlatform())); + transport.acceptConfiguration = true; + + connector.cycle(); + int afterHeartbeat = transport.requests.size(); + connector.pollOperations(); + + assertEquals(afterHeartbeat + 1, transport.requests.size()); + assertTrue(transport.requests.get(afterHeartbeat).path().endsWith("/operations")); + } + + @Test void heartbeatCollidingWithOperationPollIsRearmed() throws Exception { + connector.close(); + connector = new ControlConnector(settings(), scheduler, transport, + () -> List.of(), logs::add, UUID.randomUUID(), () -> 0L, + new ProxyRoutingConfigurationService(new NoOpPlatform())); + transport.acceptConfiguration = true; + connector.cycle(); + + transport.operationClaim = new CompletableFuture<>(); + transport.heartbeatSent = new CountDownLatch(1); + connector.pollOperations(); + connector.cycle(); + transport.operationClaim.complete(new Response(204, "")); + + assertTrue(transport.heartbeatSent.await(2, TimeUnit.SECONDS)); + } + @Test void slowTransportDoesNotBlockCallerAndShutdownCancelsInFlightRequest() { CompletableFuture stalled = new CompletableFuture<>(); transport.stalled = stalled; @@ -416,6 +448,7 @@ private static final class FakeTransport implements Transport { private CompletableFuture resultSubmission; private CountDownLatch firstSendEntered; private CountDownLatch releaseFirstSend; + private CountDownLatch heartbeatSent; @Override public CompletableFuture send(Request request) { @@ -425,6 +458,7 @@ public CompletableFuture send(Request request) { throw failure; } requests.add(request); + if (heartbeatSent != null && request.path().endsWith("/heartbeat")) heartbeatSent.countDown(); if (firstSendEntered != null) { firstSendEntered.countDown(); try {