chore: add the upstream helpers the composite alarm change builds on
Composite alarms from upstream (#6408) use SmithyValidationErrors for AWS's validation messages, and their tests use the CfnStackWaits, PartitionCleanup and ThreadStates test helpers and ReplacementCleanup.owedPhysicalIds. These are copied from upstream as they were at that commit; none brings in other behaviour.
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
package io.github.hectorvent.floci.core.common;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message AWS's Smithy validation layer answers when a request breaks its model's constraints:
|
||||||
|
* "1 validation error detected: a" or "N validation errors detected: a; b", each violation reading
|
||||||
|
* "Value at 'path' failed to satisfy constraint: ...". The wording is Smithy's, shared by every
|
||||||
|
* service built on it; only the error code differs by protocol.
|
||||||
|
*/
|
||||||
|
public final class SmithyValidationErrors {
|
||||||
|
|
||||||
|
private SmithyValidationErrors() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message for one or more violations. An empty list is the caller's bug, so it fails rather
|
||||||
|
* than answering "0 validation errors"; check {@code isEmpty()} before throwing.
|
||||||
|
*/
|
||||||
|
public static String message(List<String> violations) {
|
||||||
|
if (violations.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("no violations to report");
|
||||||
|
}
|
||||||
|
String count = violations.size() == 1
|
||||||
|
? "1 validation error detected: "
|
||||||
|
: violations.size() + " validation errors detected: ";
|
||||||
|
return count + String.join("; ", violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The violation for a required member the request leaves out. */
|
||||||
|
public static String notNull(String path) {
|
||||||
|
return "Value null at '" + path + "' failed to satisfy constraint: Member must not be null";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 400 error carrying {@link #message} under the protocol's validation error code. */
|
||||||
|
public static AwsException exception(String errorCode, List<String> violations) {
|
||||||
|
return new AwsException(errorCode, message(violations), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AwsException exception(String errorCode, String violation) {
|
||||||
|
return exception(errorCode, List.of(violation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The code most JSON services use, on both the awsJson and the restJson protocols. */
|
||||||
|
public static AwsException validationException(List<String> violations) {
|
||||||
|
return exception("ValidationException", violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AwsException validationException(String violation) {
|
||||||
|
return validationException(List.of(violation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The awsQuery protocol's code. */
|
||||||
|
public static AwsException validationError(List<String> violations) {
|
||||||
|
return exception("ValidationError", violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AwsException validationError(String violation) {
|
||||||
|
return validationError(List.of(violation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The code of REST JSON services that model their validation failure as BadRequestException. */
|
||||||
|
public static AwsException badRequest(List<String> violations) {
|
||||||
|
return exception("BadRequestException", violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AwsException badRequest(String violation) {
|
||||||
|
return badRequest(List.of(violation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The violation for a number outside {@code min} to {@code max}, or {@code null} for one inside. */
|
||||||
|
public static String outOfRange(String path, long value, long min, long max) {
|
||||||
|
if (value < min) {
|
||||||
|
return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: "
|
||||||
|
+ "Member must have value greater than or equal to " + min;
|
||||||
|
}
|
||||||
|
if (value > max) {
|
||||||
|
return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: "
|
||||||
|
+ "Member must have value less than or equal to " + max;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The violation for a value outside its enum, listing the allowed values in the model's order. */
|
||||||
|
public static String notInEnum(String value, String path, List<String> allowed) {
|
||||||
|
return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: "
|
||||||
|
+ "Member must satisfy enum value set: " + allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The violation for a list holding a value outside its members' enum. The list is checked as a
|
||||||
|
* whole, so the whole list is the offending value and the enum constraint is nested.
|
||||||
|
*/
|
||||||
|
public static String notAllInEnum(List<String> values, String path, List<String> allowed) {
|
||||||
|
return "Value '" + values + "' at '" + path + "' failed to satisfy constraint: "
|
||||||
|
+ "Member must satisfy constraint: [Member must satisfy enum value set: " + allowed + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -9,9 +9,11 @@ import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
|
|||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The replacement lifecycle for provisioners whose replacing update simply creates the new entity
|
* The replacement lifecycle for provisioners whose replacing update simply creates the new entity
|
||||||
@@ -121,6 +123,22 @@ public final class ReplacementCleanup {
|
|||||||
return cleanup != null && cleanup.path("displaced").size() > 0;
|
return cleanup != null && cleanup.path("displaced").size() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The physical IDs of replaced resources this resource still has to delete. */
|
||||||
|
static Set<String> owedPhysicalIds(StackResource r) {
|
||||||
|
Set<String> owed = new HashSet<>();
|
||||||
|
ObjectNode cleanup = read(r);
|
||||||
|
if (cleanup == null) {
|
||||||
|
return owed;
|
||||||
|
}
|
||||||
|
for (JsonNode entry : cleanup.path("displaced")) {
|
||||||
|
String physicalId = entry.path("physicalId").asText(null);
|
||||||
|
if (physicalId != null) {
|
||||||
|
owed.add(physicalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return owed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entity the cleanup is working on, or null when none is owed or {@code UpdateReplacePolicy:
|
* The entity the cleanup is working on, or null when none is owed or {@code UpdateReplacePolicy:
|
||||||
* Retain} keeps it. The engine announces one physical id per resource, so a resource owing more
|
* Retain} keeps it. The engine announces one physical id per resource, so a resource owing more
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package io.github.hectorvent.floci.testing;
|
||||||
|
|
||||||
|
import io.restassured.path.xml.XmlPath;
|
||||||
|
import io.restassured.path.xml.element.Node;
|
||||||
|
import io.restassured.response.Response;
|
||||||
|
import io.restassured.specification.RequestSpecification;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.MatcherAssert.assertThat;
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.hamcrest.Matchers.not;
|
||||||
|
import static org.junit.jupiter.api.Assertions.fail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeleteStack answers before the stack is gone. The deletion runs on an executor, so a test
|
||||||
|
* that looks at the stack's resources right after the answer races it.
|
||||||
|
*/
|
||||||
|
public final class CfnStackWaits {
|
||||||
|
|
||||||
|
private CfnStackWaits() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What DescribeStacks reported: the status and, when present, the StackStatusReason. */
|
||||||
|
public record StackState(String status, String reason) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Set<String> TERMINAL = Set.of(
|
||||||
|
"CREATE_COMPLETE", "CREATE_FAILED", "ROLLBACK_COMPLETE", "ROLLBACK_FAILED",
|
||||||
|
"UPDATE_COMPLETE", "UPDATE_FAILED", "UPDATE_ROLLBACK_COMPLETE", "UPDATE_ROLLBACK_FAILED",
|
||||||
|
"DELETE_COMPLETE", "DELETE_FAILED");
|
||||||
|
|
||||||
|
private static final Set<String> OPERATION_STARTS = Set.of("CREATE_IN_PROGRESS", "UPDATE_IN_PROGRESS");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until DescribeStacks reports a terminal status for a create or update and returns
|
||||||
|
* it, so the caller decides what counts as success. A DescribeStacks that is not 200 (the
|
||||||
|
* stack does not exist) fails at once with the error body rather than polling the clock out;
|
||||||
|
* ten seconds without a terminal status fails too.
|
||||||
|
*/
|
||||||
|
public static StackState awaitTerminal(String stackName) {
|
||||||
|
return awaitTerminal(stackName, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StackState awaitTerminal(String stackName, String authorization) {
|
||||||
|
long deadline = System.currentTimeMillis() + 10_000;
|
||||||
|
StackState last = new StackState(null, "");
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
RequestSpecification request = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("Action", "DescribeStacks")
|
||||||
|
.formParam("StackName", stackName);
|
||||||
|
if (authorization != null) {
|
||||||
|
request.header("Authorization", authorization);
|
||||||
|
}
|
||||||
|
Response response = request.when().post("/");
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
fail("DescribeStacks " + stackName + " answered " + response.statusCode() + ": " + response.asString());
|
||||||
|
}
|
||||||
|
XmlPath xml = response.xmlPath();
|
||||||
|
String status = xml.getString("**.find { it.name() == 'StackStatus' }");
|
||||||
|
String reason = xml.getString("**.find { it.name() == 'StackStatusReason' }");
|
||||||
|
last = new StackState(status == null || status.isEmpty() ? null : status, reason == null ? "" : reason);
|
||||||
|
if (last.status() != null && TERMINAL.contains(last.status())) {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(50);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new AssertionError("Interrupted while waiting for stack " + stackName, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail("Stack " + stackName + " reached no terminal status within ten seconds, last: " + last);
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until DescribeStacks reports {@code expectedStatus} and returns that state. Any other
|
||||||
|
* terminal status fails at once with its reason, as do a DescribeStacks that is not 200 and ten
|
||||||
|
* seconds without the expected status.
|
||||||
|
*/
|
||||||
|
public static StackState awaitStatus(String stackName, String expectedStatus) {
|
||||||
|
long deadline = System.currentTimeMillis() + 10_000;
|
||||||
|
StackState last = new StackState(null, "");
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
Response response = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("Action", "DescribeStacks")
|
||||||
|
.formParam("StackName", stackName)
|
||||||
|
.when().post("/");
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
fail("DescribeStacks " + stackName + " answered " + response.statusCode() + ": " + response.asString());
|
||||||
|
}
|
||||||
|
XmlPath xml = response.xmlPath();
|
||||||
|
String status = xml.getString("**.find { it.name() == 'StackStatus' }");
|
||||||
|
String reason = xml.getString("**.find { it.name() == 'StackStatusReason' }");
|
||||||
|
last = new StackState(status == null || status.isEmpty() ? null : status, reason == null ? "" : reason);
|
||||||
|
if (expectedStatus.equals(last.status())) {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
if (last.status() != null && TERMINAL.contains(last.status())) {
|
||||||
|
fail("Stack " + stackName + " reached " + last.status() + " instead of " + expectedStatus
|
||||||
|
+ ": " + last.reason());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(50);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new AssertionError("Interrupted while waiting for stack " + stackName, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail("Stack " + stackName + " did not reach " + expectedStatus + " within ten seconds, last: " + last);
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reason on the first resource event that failed a create or update in the stack's latest
|
||||||
|
* operation, the one its last stack-level CREATE_IN_PROGRESS or UPDATE_IN_PROGRESS began. The
|
||||||
|
* stack's own reason only names the failed resource, as on AWS, so the failure's message is read
|
||||||
|
* here. Fails when DescribeStackEvents is not 200 or no resource failed in that operation.
|
||||||
|
*/
|
||||||
|
public static String failedResourceReason(String stackName) {
|
||||||
|
return failedResourceReason(stackName, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String failedResourceReason(String stackName, String authorization) {
|
||||||
|
RequestSpecification request = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("Action", "DescribeStackEvents")
|
||||||
|
.formParam("StackName", stackName);
|
||||||
|
if (authorization != null) {
|
||||||
|
request.header("Authorization", authorization);
|
||||||
|
}
|
||||||
|
Response response = request.when().post("/");
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
fail("DescribeStackEvents " + stackName + " answered " + response.statusCode() + ": " + response.asString());
|
||||||
|
}
|
||||||
|
List<Node> newestFirst = response.xmlPath().getList("**.findAll { it.name() == 'member' }", Node.class);
|
||||||
|
String reason = null;
|
||||||
|
for (Node event : newestFirst) {
|
||||||
|
String status = text(event, "ResourceStatus");
|
||||||
|
if (text(event, "StackName").equals(text(event, "LogicalResourceId"))
|
||||||
|
&& text(event, "StackId").equals(text(event, "PhysicalResourceId"))) {
|
||||||
|
if (OPERATION_STARTS.contains(status)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if ("CREATE_FAILED".equals(status) || "UPDATE_FAILED".equals(status)) {
|
||||||
|
reason = text(event, "ResourceStatusReason");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reason == null) {
|
||||||
|
fail("Stack " + stackName + " has no failed resource event in its latest operation: " + response.asString());
|
||||||
|
}
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(Node event, String name) {
|
||||||
|
Node child = event.getNode(name);
|
||||||
|
return child == null ? "" : child.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until DescribeStacks answers 400, which is how a missing stack is reported. Fails on
|
||||||
|
* DELETE_FAILED or after ten seconds.
|
||||||
|
*/
|
||||||
|
public static void awaitStackDeleted(String stackName) {
|
||||||
|
awaitStackDeleted(stackName, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void awaitStackDeleted(String stackName, String authorization) {
|
||||||
|
long deadline = System.currentTimeMillis() + 10_000;
|
||||||
|
String body = "";
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
RequestSpecification request = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("Action", "DescribeStacks")
|
||||||
|
.formParam("StackName", stackName);
|
||||||
|
if (authorization != null) {
|
||||||
|
request.header("Authorization", authorization);
|
||||||
|
}
|
||||||
|
Response response = request.when().post("/");
|
||||||
|
body = response.asString();
|
||||||
|
assertThat(body, not(containsString("<StackStatus>DELETE_FAILED</StackStatus>")));
|
||||||
|
if (response.statusCode() == 400) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(50);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new AssertionError("Interrupted while waiting for stack " + stackName + " to be deleted", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail("Stack " + stackName + " was not deleted within ten seconds: " + body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package io.github.hectorvent.floci.testing;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.extension.AfterEachCallback;
|
||||||
|
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Deque;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes what a test created, in reverse order, after each test. The {@code @QuarkusTest}
|
||||||
|
* emulator is shared by every test class, so a resource left behind outlives the class that made
|
||||||
|
* it: one under {@code cn-north-1} shows up in every later cross-region listing (ResourceExplorer
|
||||||
|
* scans all resources), and an IAM user or an S3 bucket shows up in the next class to list either
|
||||||
|
* one unscoped. A test that leaves such a resource registers each create here.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @RegisterExtension
|
||||||
|
* final PartitionCleanup cleanup = new PartitionCleanup();
|
||||||
|
* ...
|
||||||
|
* createTable(region, name);
|
||||||
|
* cleanup.register(() -> deleteTable(region, name));
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* A failing cleanup never fails the test: it is reported on stderr so the leak is visible and
|
||||||
|
* the assertion that actually failed stays the headline.
|
||||||
|
*/
|
||||||
|
public final class PartitionCleanup implements AfterEachCallback {
|
||||||
|
|
||||||
|
private final Deque<Runnable> actions = new ArrayDeque<>();
|
||||||
|
|
||||||
|
public void register(Runnable action) {
|
||||||
|
actions.push(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterEach(ExtensionContext context) {
|
||||||
|
while (!actions.isEmpty()) {
|
||||||
|
Runnable action = actions.pop();
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (RuntimeException | AssertionError e) {
|
||||||
|
System.err.println("PartitionCleanup: cleanup step failed after "
|
||||||
|
+ context.getDisplayName() + ": " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package io.github.hectorvent.floci.testing;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.concurrent.locks.LockSupport;
|
||||||
|
import java.util.function.BooleanSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.junit.jupiter.api.Assertions.fail;
|
||||||
|
|
||||||
|
/** Waiting for a thread to reach a lock, for tests that force two requests to overlap. */
|
||||||
|
public final class ThreadStates {
|
||||||
|
|
||||||
|
/** Every state a thread is in once it has stopped running: parked in any way, or ended. */
|
||||||
|
public static final Set<Thread.State> PARKED_OR_DONE = Collections.unmodifiableSet(EnumSet.of(
|
||||||
|
Thread.State.BLOCKED, Thread.State.WAITING, Thread.State.TIMED_WAITING, Thread.State.TERMINATED));
|
||||||
|
|
||||||
|
private ThreadStates() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns once {@code thread} is blocked entering a monitor or parked on a lock, or {@code task} has
|
||||||
|
* finished, whichever comes first. Code that serializes correctly leaves the second request blocked
|
||||||
|
* behind the first; code that does not lets it run to the end, which the caller's assertions
|
||||||
|
* then catch. Polls every 5 ms and gives up after five seconds rather than hang a broken test.
|
||||||
|
*/
|
||||||
|
public static void awaitBlockedOrDone(AtomicReference<Thread> thread, Future<?> task)
|
||||||
|
throws InterruptedException {
|
||||||
|
awaitBlockedOrDone(thread::get, task::isDone, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns once {@code thread} is blocked entering a monitor, parked on a lock, or is no longer alive.
|
||||||
|
* Same wait as the {@link Future} overload, for a test that owns the {@link Thread} directly; gives up
|
||||||
|
* after ten seconds and fails, since a thread still running unblocked by then is not overlapping the
|
||||||
|
* way the test needs.
|
||||||
|
*/
|
||||||
|
public static void awaitBlockedOrDone(Thread thread) throws InterruptedException {
|
||||||
|
assertTrue(awaitBlockedOrDone(() -> thread, () -> !thread.isAlive(), 10),
|
||||||
|
"worker blocked or finished before timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean awaitBlockedOrDone(Supplier<Thread> thread, BooleanSupplier done, long timeoutSeconds)
|
||||||
|
throws InterruptedException {
|
||||||
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
|
||||||
|
while (System.nanoTime() < deadline && !done.getAsBoolean()) {
|
||||||
|
if (isBlocked(thread.get())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
return done.getAsBoolean() || isBlocked(thread.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlocked(Thread thread) {
|
||||||
|
if (thread == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Thread.State state = thread.getState();
|
||||||
|
return state == Thread.State.BLOCKED || state == Thread.State.WAITING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until {@code thread} is parked in any way (blocked on a monitor, waiting or timed
|
||||||
|
* waiting) or has ended, and returns the state it reached. Fails after ten seconds.
|
||||||
|
*/
|
||||||
|
public static Thread.State awaitParkedOrDone(Thread thread) {
|
||||||
|
return awaitState(thread, PARKED_OR_DONE, Duration.ofSeconds(10), "the thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until {@code thread} is in one of {@code states} and returns the one it reached, checking
|
||||||
|
* every millisecond. Fails naming {@code what} and the state it was left in once {@code timeout}
|
||||||
|
* passes, so a thread that never gets there fails the test instead of hanging it.
|
||||||
|
*/
|
||||||
|
public static Thread.State awaitState(Thread thread, Set<Thread.State> states, Duration timeout, String what) {
|
||||||
|
long deadline = System.nanoTime() + timeout.toNanos();
|
||||||
|
Thread.State state = thread.getState();
|
||||||
|
while (!states.contains(state)) {
|
||||||
|
if (System.nanoTime() - deadline > 0) {
|
||||||
|
fail(what + " never reached " + states + ", it was " + state);
|
||||||
|
}
|
||||||
|
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
|
||||||
|
state = thread.getState();
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user