Skip to content

Commit 31bde9c

Browse files
committed
test(process): add C02 bounded lifecycle RED contracts
Use self-bounded offline child processes to expose limiter replacement, queue deadline bypass, unbounded stdout/stderr and interrupted-child leaks. Also require explicit shared runtime and pre-cancel semantics. No production lifecycle implementation changes in this RED checkpoint.
1 parent 48b1438 commit 31bde9c

2 files changed

Lines changed: 273 additions & 0 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package io.github.easy4j.opencli.contract;
2+
3+
import java.nio.charset.StandardCharsets;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
6+
import java.nio.file.Paths;
7+
import java.util.Arrays;
8+
9+
/** Offline, self-bounded child fixture. Release files allow test cleanup even against broken SDKs. */
10+
public final class LifecycleProbe {
11+
private LifecycleProbe() { }
12+
13+
public static void main(String[] args) throws Exception {
14+
String mode = args[0];
15+
if ("stdout".equals(mode) || "stderr".equals(mode)) {
16+
byte[] block = new byte[8192];
17+
Arrays.fill(block, (byte) 'x');
18+
int remaining = Integer.parseInt(args[1]);
19+
while (remaining > 0) {
20+
int size = Math.min(block.length, remaining);
21+
if ("stdout".equals(mode)) { System.out.write(block, 0, size); }
22+
else { System.err.write(block, 0, size); }
23+
remaining -= size;
24+
}
25+
return;
26+
}
27+
Path marker = Paths.get(args[1]);
28+
Files.write(marker, "started".getBytes(StandardCharsets.UTF_8));
29+
if ("write".equals(mode)) { return; }
30+
Path release = Paths.get(args[2]);
31+
long start = System.nanoTime();
32+
int tick = 0;
33+
while (!Files.exists(release) && System.nanoTime() - start < 10_000_000_000L) {
34+
if ("heartbeat".equals(mode)) {
35+
Files.write(marker, Integer.toString(++tick).getBytes(StandardCharsets.UTF_8));
36+
}
37+
Thread.sleep(20L);
38+
}
39+
}
40+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package io.github.easy4j.opencli.contract;
2+
3+
import io.github.easy4j.opencli.OpenCliProperties;
4+
import io.github.easy4j.opencli.core.OpenCliExecutor;
5+
import io.github.easy4j.opencli.core.OpenCliResult;
6+
import io.github.easy4j.opencli.exception.OpenCliException;
7+
import io.github.easy4j.opencli.exception.OpenCliTimeoutException;
8+
import java.io.File;
9+
import java.nio.charset.StandardCharsets;
10+
import java.nio.file.Files;
11+
import java.nio.file.Path;
12+
import java.util.ArrayList;
13+
import java.util.Arrays;
14+
import java.util.Collections;
15+
import java.util.List;
16+
import java.util.concurrent.ExecutorService;
17+
import java.util.concurrent.Executors;
18+
import java.util.concurrent.Future;
19+
import java.util.concurrent.ScheduledExecutorService;
20+
import java.util.concurrent.TimeUnit;
21+
import java.util.concurrent.atomic.AtomicBoolean;
22+
import java.util.concurrent.atomic.AtomicReference;
23+
import org.junit.jupiter.api.Test;
24+
import org.junit.jupiter.api.Timeout;
25+
import org.junit.jupiter.api.io.TempDir;
26+
import static org.junit.jupiter.api.Assertions.*;
27+
28+
/** C02 tests observe actual fixture processes, not private semaphore counters. */
29+
@Timeout(20)
30+
class OpenCliProcessContractTest {
31+
@TempDir Path dir;
32+
33+
private static OpenCliProperties properties(int maxConcurrent) {
34+
OpenCliProperties p = new OpenCliProperties();
35+
String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
36+
p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath());
37+
p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp",
38+
System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")),
39+
LifecycleProbe.class.getName())));
40+
p.setCommandTimeoutMillis(10000L);
41+
p.setMaxConcurrentExecutions(maxConcurrent);
42+
return p;
43+
}
44+
45+
private static boolean awaitFile(Path path, long millis) throws Exception {
46+
long start = System.nanoTime();
47+
while (System.nanoTime() - start < TimeUnit.MILLISECONDS.toNanos(millis)) {
48+
if (Files.exists(path)) { return true; }
49+
Thread.sleep(10L);
50+
}
51+
return Files.exists(path);
52+
}
53+
54+
private static void release(Path path) {
55+
try { Files.write(path, new byte[]{1}); }
56+
catch (Exception ex) { throw new AssertionError("fixture cleanup failed", ex); }
57+
}
58+
59+
private static Object getter(Object object, String name) {
60+
assertNotNull(object, "partial evidence is required");
61+
return assertDoesNotThrow(() -> object.getClass().getMethod(name).invoke(object),
62+
"required execution evidence is missing: " + name);
63+
}
64+
65+
private static Object details(OpenCliResult result) { return getter(result, "getExecutionDetails"); }
66+
67+
@Test
68+
void anotherClientCannotReplaceAnActiveClientsLimiter() throws Exception {
69+
OpenCliExecutor a = new OpenCliExecutor(properties(1));
70+
Path first = dir.resolve("first");
71+
Path second = dir.resolve("second");
72+
Path gate = dir.resolve("release");
73+
ExecutorService workers = Executors.newFixedThreadPool(2);
74+
try {
75+
Future<OpenCliResult> one = workers.submit(() -> a.invoke("hold", first.toString(), gate.toString()));
76+
assertTrue(awaitFile(first, 3000), "first child did not start");
77+
new OpenCliExecutor(properties(4));
78+
Future<OpenCliResult> two = workers.submit(() -> a.invoke("write", second.toString()));
79+
assertFalse(awaitFile(second, 600), "constructing B bypassed A's active limiter");
80+
release(gate);
81+
assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess());
82+
assertTrue(two.get(5, TimeUnit.SECONDS).isSuccess());
83+
} finally {
84+
release(gate);
85+
workers.shutdownNow();
86+
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
87+
}
88+
}
89+
90+
@Test
91+
void queuedDeadlineExpiresWithoutSpawning() throws Exception {
92+
OpenCliProperties p = properties(1);
93+
OpenCliExecutor executor = new OpenCliExecutor(p);
94+
Path first = dir.resolve("first");
95+
Path second = dir.resolve("must-not-start");
96+
Path gate = dir.resolve("release");
97+
ExecutorService worker = Executors.newSingleThreadExecutor();
98+
ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor();
99+
try {
100+
Future<OpenCliResult> one = worker.submit(() -> executor.invoke("hold", first.toString(), gate.toString()));
101+
assertTrue(awaitFile(first, 3000));
102+
p.setCommandTimeoutMillis(50L);
103+
cleanup.schedule(() -> release(gate), 1500L, TimeUnit.MILLISECONDS);
104+
long started = System.nanoTime();
105+
OpenCliTimeoutException failure = assertThrows(OpenCliTimeoutException.class,
106+
() -> executor.invoke("write", second.toString()));
107+
long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
108+
assertTrue(elapsed < 750L, "queue wait ignored total deadline: " + elapsed);
109+
assertFalse(Files.exists(second), "queue-expired child was started");
110+
Object evidence = details(failure.getPartialResult());
111+
assertEquals("QUEUE_TIMEOUT", String.valueOf(getter(evidence, "getTerminationReason")));
112+
assertEquals(false, getter(evidence, "isProcessStarted"));
113+
assertNull(failure.getPartialResult().getExitCode());
114+
release(gate);
115+
assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess());
116+
} finally {
117+
release(gate);
118+
worker.shutdownNow();
119+
cleanup.shutdownNow();
120+
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
121+
assertTrue(cleanup.awaitTermination(5, TimeUnit.SECONDS));
122+
}
123+
}
124+
125+
@Test
126+
void stdoutDefaultBudgetFailsInsteadOfReturningUnboundedSuccess() {
127+
OpenCliException failure = assertThrows(OpenCliException.class,
128+
() -> new OpenCliExecutor(properties(1)).invoke("stdout", Integer.toString(9 * 1024 * 1024)));
129+
OpenCliResult partial = failure.getPartialResult();
130+
Object evidence = details(partial);
131+
assertEquals("OUTPUT_LIMIT", String.valueOf(getter(evidence, "getTerminationReason")));
132+
assertEquals(8L * 1024 * 1024, ((Number) getter(evidence, "getStdoutCapturedBytes")).longValue());
133+
assertTrue(((Number) getter(evidence, "getStdoutObservedBytes")).longValue() > 8L * 1024 * 1024);
134+
assertEquals(true, getter(evidence, "isStdoutTruncated"));
135+
assertFalse(partial.isSuccess());
136+
}
137+
138+
@Test
139+
void stderrHasItsOwnSmallerBudget() {
140+
OpenCliException failure = assertThrows(OpenCliException.class,
141+
() -> new OpenCliExecutor(properties(1)).invoke("stderr", Integer.toString(3 * 1024 * 1024)));
142+
Object evidence = details(failure.getPartialResult());
143+
assertEquals("OUTPUT_LIMIT", String.valueOf(getter(evidence, "getTerminationReason")));
144+
assertEquals(2L * 1024 * 1024, ((Number) getter(evidence, "getStderrCapturedBytes")).longValue());
145+
assertEquals(true, getter(evidence, "isStderrTruncated"));
146+
}
147+
148+
@Test
149+
void interruptionStopsTheOwnedHeartbeatAndRestoresFlag() throws Exception {
150+
OpenCliExecutor executor = new OpenCliExecutor(properties(1));
151+
Path heartbeat = dir.resolve("heartbeat");
152+
Path gate = dir.resolve("release");
153+
AtomicBoolean restored = new AtomicBoolean();
154+
AtomicReference<Throwable> error = new AtomicReference<>();
155+
Thread caller = new Thread(() -> {
156+
try { executor.invoke("heartbeat", heartbeat.toString(), gate.toString()); }
157+
catch (Throwable failure) { error.set(failure); restored.set(Thread.currentThread().isInterrupted()); }
158+
}, "contract-interrupted-caller");
159+
try {
160+
caller.start();
161+
assertTrue(awaitFile(heartbeat, 3000));
162+
Thread.sleep(80L);
163+
caller.interrupt();
164+
caller.join(1500L);
165+
assertFalse(caller.isAlive(), "interrupted call did not finish cleanup");
166+
assertTrue(error.get() instanceof OpenCliException);
167+
assertTrue(restored.get(), "caller interrupt flag was lost");
168+
String observed = new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8);
169+
Thread.sleep(250L);
170+
assertEquals(observed, new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8),
171+
"owned child kept running after interrupted call returned");
172+
OpenCliException failure = (OpenCliException) error.get();
173+
assertEquals("CANCELLED", String.valueOf(getter(details(failure.getPartialResult()), "getTerminationReason")));
174+
assertEquals("ROOT_EXIT_CONFIRMED", String.valueOf(getter(details(failure.getPartialResult()), "getCleanupState")));
175+
Path next = dir.resolve("next");
176+
assertTrue(executor.invoke("write", next.toString()).isSuccess(), "permit leaked after cancellation");
177+
} finally {
178+
release(gate);
179+
caller.interrupt();
180+
caller.join(5000L);
181+
}
182+
}
183+
184+
@Test
185+
void negativeConcurrencyIsNotSilentlyTreatedAsDefault() {
186+
assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(properties(-1)));
187+
}
188+
189+
@Test
190+
void explicitSharedRuntimeLimitsBothClients() throws Exception {
191+
Class<?> runtimeType = assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core.OpenCliProcessRuntime"));
192+
Object runtime = runtimeType.getConstructor(int.class).newInstance(1);
193+
OpenCliExecutor a = OpenCliExecutor.class.getConstructor(OpenCliProperties.class, runtimeType)
194+
.newInstance(properties(4), runtime);
195+
OpenCliExecutor b = OpenCliExecutor.class.getConstructor(OpenCliProperties.class, runtimeType)
196+
.newInstance(properties(4), runtime);
197+
Path first = dir.resolve("shared-first");
198+
Path second = dir.resolve("shared-second");
199+
Path gate = dir.resolve("release");
200+
ExecutorService workers = Executors.newFixedThreadPool(2);
201+
try {
202+
Future<OpenCliResult> one = workers.submit(() -> a.invoke("hold", first.toString(), gate.toString()));
203+
assertTrue(awaitFile(first, 3000));
204+
Future<OpenCliResult> two = workers.submit(() -> b.invoke("write", second.toString()));
205+
assertFalse(awaitFile(second, 500), "shared runtime did not enforce its one permit");
206+
release(gate);
207+
assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess());
208+
assertTrue(two.get(5, TimeUnit.SECONDS).isSuccess());
209+
} finally {
210+
release(gate);
211+
workers.shutdownNow();
212+
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
213+
}
214+
}
215+
216+
@Test
217+
void preCancelledRequestNeverStartsAProcess() throws Exception {
218+
Class<?> tokenType = assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core.OpenCliCancellationToken"));
219+
Object token = tokenType.getConstructor().newInstance();
220+
tokenType.getMethod("cancel").invoke(token);
221+
Path marker = dir.resolve("pre-cancelled");
222+
OpenCliExecutor executor = new OpenCliExecutor(properties(1));
223+
java.lang.reflect.InvocationTargetException failure = assertThrows(java.lang.reflect.InvocationTargetException.class,
224+
() -> OpenCliExecutor.class.getMethod("invoke", List.class, tokenType)
225+
.invoke(executor, Arrays.asList("write", marker.toString()), token));
226+
assertTrue(failure.getCause() instanceof OpenCliException);
227+
OpenCliResult partial = ((OpenCliException) failure.getCause()).getPartialResult();
228+
assertEquals("CANCELLED", String.valueOf(getter(details(partial), "getTerminationReason")));
229+
assertEquals(false, getter(details(partial), "isProcessStarted"));
230+
assertNull(partial.getExitCode());
231+
assertFalse(Files.exists(marker));
232+
}
233+
}

0 commit comments

Comments
 (0)