{ "assetKey": "a11-f001-close-leak", "kind": "terminal", "command": "set -e\nD=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT\nH=/shared/codebase/clean-architecture-backend-template/src/adapter/outbound/httpclient\nJAR=$H/build/libs/httpclient-0.0.1+21234e38cdb9.jar\njava -version 2>&1 | head -1\njavac -encoding UTF-8 -cp \"$JAR\" -d \"$D\" \\\n \"$H/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/ClientProfiles.java\"\ncat > \"$D/DrainThreadProbe.java\" <<'JAVA'\nimport dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;\nimport dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;\nimport dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;\nimport dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeState;\nimport dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;\nimport dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic final class DrainThreadProbe {\n\n private static final String DRAIN = \"httpclient-runtime-drain\";\n\n private static ClientRuntime runtime(long generation, Runnable closer) {\n return new ClientRuntime(\n ClientProfiles.builder(\"payment\").build(), new RuntimeGeneration(generation), closer);\n }\n\n private static long drainThreads() {\n return Thread.getAllStackTraces().keySet().stream()\n .filter(t -> t.getName().startsWith(DRAIN))\n .count();\n }\n\n private static String close(ClientRuntimeRegistry registry) {\n try {\n registry.close();\n return \"정상 반환\";\n } catch (RuntimeException failure) {\n return failure.getClass().getSimpleName() + \": \" + failure.getMessage();\n }\n }\n\n /** The shape the resource-bound suite rotates in: no lease is held. */\n private static void suiteShape() {\n System.out.println(\"[자원 경계 묶음이 도는 모양] 임차를 쥐지 않고 회전한다\");\n AtomicInteger closed = new AtomicInteger();\n ClientRuntime first = runtime(1, closed::incrementAndGet);\n long peak = 0;\n try (ClientRuntimeRegistry registry = new ClientRuntimeRegistry(Map.of(first.name(), first))) {\n for (int generation = 2; generation <= 50; generation++) {\n registry.swap(first.name(), runtime(generation, closed::incrementAndGet), Duration.ofSeconds(1));\n peak = Math.max(peak, drainThreads());\n }\n }\n System.out.println(\" 회전 49회 동안 관측된 drain 스레드 최대치 : \" + peak);\n System.out.println(\" 닫힌 세대 : \" + closed.get() + \" / close() 후 drain 스레드 : \" + drainThreads());\n System.out.println();\n }\n\n /** The path the record describes: one forceClose throws. */\n private static void refusingPool() {\n System.out.println(\"[강제 닫기 하나가 던지는 경로]\");\n AtomicInteger released = new AtomicInteger();\n ClientRuntime first = runtime(1, () -> {});\n ClientRuntime second =\n runtime(\n 2,\n () -> {\n released.incrementAndGet();\n throw new IllegalStateException(\"pool refused to close\");\n });\n ClientRuntimeRegistry registry = new ClientRuntimeRegistry(Map.of(first.name(), first));\n ClientRuntimeLease lease = registry.acquire(first.name());\n registry.swap(first.name(), second, Duration.ofSeconds(30));\n System.out.println(\" swap 직후 drain 스레드 : \" + drainThreads());\n System.out.println(\" 1회차 close() : \" + close(registry) + \" / drain 스레드 \" + drainThreads());\n System.out.println(\" 거부한 런타임 state : \" + second.state()\n + \" / 자원 닫기 호출 \" + released.get() + \"회\");\n second.forceClose();\n System.out.println(\" 다시 forceClose 후 자원 닫기 호출 : \" + released.get() + \"회\");\n System.out.println(\" 2회차 close() : \" + close(registry) + \" / drain 스레드 \" + drainThreads());\n lease.close();\n System.out.println();\n }\n\n /** No forceClose fails, yet the thread survives: the shutdown block throws on its own. */\n private static void slowScheduler() throws Exception {\n System.out.println(\"[강제 닫기가 하나도 던지지 않는데 스레드가 남는 경로]\");\n ClientRuntime first =\n runtime(\n 1,\n () -> {\n long until = System.nanoTime() + Duration.ofSeconds(8).toNanos();\n while (System.nanoTime() < until) {\n try {\n Thread.sleep(50);\n } catch (InterruptedException ignored) {\n // The pool's own close ignores the interrupt, which shutdownNow can only send.\n }\n }\n });\n ClientRuntime second = runtime(2, () -> {});\n ClientRuntimeRegistry registry = new ClientRuntimeRegistry(Map.of(first.name(), first));\n ClientRuntimeLease lease = registry.acquire(first.name());\n registry.swap(first.name(), second, Duration.ofMillis(50));\n Thread.sleep(400);\n System.out.println(\" 마감 작업이 도는 중 drain 스레드 : \" + drainThreads());\n long started = System.nanoTime();\n String outcome = close(registry);\n long elapsed = (System.nanoTime() - started) / 1_000_000L;\n System.out.println(\" 강제 닫기가 던진 것 : 없음\");\n System.out.println(\" 1회차 close() : \" + outcome + \" (\" + elapsed + \"ms)\");\n System.out.println(\" 1회차 후 drain 스레드 : \" + drainThreads());\n System.out.println(\" 2회차 close() : \" + close(registry) + \" / drain 스레드 \" + drainThreads());\n lease.close();\n }\n\n public static void main(String[] args) throws Exception {\n System.out.println(\"test 가 검증하는 성질 : close() 뒤에 \" + DRAIN + \" 스레드가 없어야 한다\");\n System.out.println();\n suiteShape();\n refusingPool();\n slowScheduler();\n }\n}\nJAVA\njavac -encoding UTF-8 -cp \"$JAR:$D\" -d \"$D\" \"$D/DrainThreadProbe.java\"\njava -Dstdout.encoding=UTF-8 -cp \"$JAR:$D\" DrainThreadProbe\n", "cwd": "/shared/codebase/clean-architecture-backend-template/src/adapter/outbound/httpclient", "exitCode": 0, "executedAt": "2026-09-02T13:21:50+00:00", "sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916", "raw": "evidence/raw/a11-f001-close-leak.txt", "svg": "evidence/rendered/a11-f001-close-leak.svg", "rawSha256": "c953ce70bba5f0a05e085b80ac3321768ed80d9742ed6303e40f82448b825d39", "lines": 20, "redaction": "none — 밀폐 탐침 실행 출력, 자격증명 없음" }