Files
document-haness/docs/clean-architecture-backend-template/final/evidence/meta/a11-f001-close-leak.json
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

15 lines
6.6 KiB
JSON

{
"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 — 밀폐 탐침 실행 출력, 자격증명 없음"
}