diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..faa6d81 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "claude-plugins-official": { + "source": { + "source": "github", + "repo": "anthropics/claude-plugins-official" + } + } + }, + "enabledPlugins": { + "superpowers@claude-plugins-official": true + } +} diff --git a/.github/workflows/dashboard.yml b/.github/workflows/dashboard.yml index 150e9e6..7dde353 100644 --- a/.github/workflows/dashboard.yml +++ b/.github/workflows/dashboard.yml @@ -18,6 +18,8 @@ on: - 'tests-projects/LibTestTelemetry/**' - 'LibCommons/**' - 'LibNetworks/**' + # 목적: FastPortDashboard.Core가 template-projects/Protos/*.proto로 Echo 메시지를 생성 + - 'template-projects/Protos/**' - 'FastPortSharp.Dashboard.sln' - '.github/workflows/dashboard.yml' pull_request: @@ -31,6 +33,8 @@ on: - 'tests-projects/LibTestTelemetry/**' - 'LibCommons/**' - 'LibNetworks/**' + # 목적: FastPortDashboard.Core가 template-projects/Protos/*.proto로 Echo 메시지를 생성 + - 'template-projects/Protos/**' - 'FastPortSharp.Dashboard.sln' - '.github/workflows/dashboard.yml' workflow_dispatch: diff --git a/AGENTS.md b/AGENTS.md index fc7ce07..bc9d05d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,70 @@ # Agent Instructions (Codex / Claude Code 공통) +LLM 에이전트(Codex, Claude Code 등)가 이 저장소에서 서버 코드를 개발할 때 따르는 규칙이다. 규칙은 이 파일 한 곳에서만 고친다. + ## Language - 기본 응답 언어는 한국어로 한다. - 사용자가 다른 언어를 명시적으로 요청한 경우에만 해당 언어로 답한다. - 코드, 명령어, 파일 경로, API 이름, 에러 메시지는 원문을 유지하고, 설명은 한국어로 작성한다. -## Coding Skill Rule +## 개발 워크플로 (superpowers) + +- Claude Code는 프로젝트 설정 `.claude/settings.json`으로 `superpowers@claude-plugins-official` 플러그인을 켠다. 작업을 시작하기 전에 상황에 맞는 superpowers 스킬을 먼저 호출하고 그 절차를 따른다. +- 상황별 스킬: + +| 상황 | 스킬 | +|---|---| +| 새 기능, 동작 변경, 요구가 모호한 요청의 설계 | `brainstorming` | +| 여러 단계에 걸친 구현 | `writing-plans` → `executing-plans` 또는 `subagent-driven-development` | +| 코드 작성, 버그 수정 | `test-driven-development` (MSTest로 실패하는 테스트를 먼저 만든다) | +| 버그, 테스트 실패, CI 실패의 원인 찾기 | `systematic-debugging` | +| 서로 독립인 작업 여러 개 | `dispatching-parallel-agents` | +| "완료", 커밋, PR 직전 | `verification-before-completion` (빌드·테스트 출력을 직접 확인) | +| 리뷰 요청과 리뷰 반영 | `requesting-code-review`, `receiving-code-review` | +| 작업 브랜치 마무리 | `finishing-a-development-branch` | + +- 오타, 문서 한 줄, 설정값 하나처럼 작은 변경은 `brainstorming`·`writing-plans`를 건너뛴다. `verification-before-completion`은 건너뛰지 않는다. +- 산출물 위치: 설계는 `docs/superpowers/specs/YYYY-MM-DD--design.md`, 계획은 `docs/superpowers/plans/YYYY-MM-DD-.md`에 두고 구현과 같은 PR로 커밋한다. 이 문서들은 결정 기록이고 코드 지도가 아니다. 구현이 끝나면 `docs/llm/` 도메인 문서를 고친다. +- 계획(`writing-plans`)에는 이 파일의 "문서 유지 규칙"과 "변경 시 함께 해야 하는 일"(golden hash 갱신 등)을 단계로 넣는다. 테스트 명령은 `docs/llm/platform.md`의 것을 쓴다. +- `finishing-a-development-branch`에서는 항상 "push 후 PR"을 고른다. `main`은 보호 브랜치라 로컬 머지나 직접 push를 하지 않는다. +- 우선순위: 이 파일(`AGENTS.md`)과 사용자 지시 > superpowers 스킬 > 기본 동작. 예를 들어 스킬 예시의 커밋 메시지(`feat: ...`)보다 아래 "Git / 커밋 메시지" 형식을 따른다. +- 플러그인을 쓸 수 없는 에이전트(Codex 등)는 같은 순서(설계 → 계획 → 테스트 먼저 구현 → 검증 → 리뷰 → PR)를 직접 따른다. 스킬 원문: . + +## 코드 탐색 규칙 + +- 코드를 탐색하기 전에 `docs/llm/README.md`를 먼저 읽는다. "도메인 고르기" 표에서 요청에 맞는 도메인 문서 1~2개를 골라, 그 문서의 "작업별 시작점"에 있는 파일만 연다. +- 용어(특히 `BaseSessionClient`/`BaseSessionServer`처럼 이름과 역할이 엇갈리는 것)는 `docs/llm/glossary.md`에서 확인한다. +- 심볼은 이름으로 찾는다. 예: `grep -n "void RequestDisconnect" -r LibNetworks`. 문서에는 줄 번호가 없다. +- `docs/llm/README.md`의 "큰 파일" 표에 있는 파일은 통째로 읽지 않는다. grep으로 위치를 찾고 그 부분만 읽는다. 이미 대화에서 확인한 내용은 다시 읽지 않는다. +- 문서와 코드가 다르면 코드를 믿는다. 작업이 끝나면 문서를 고친다. + +## 문서 유지 규칙 -- For code writing, editing, refactoring, debugging, test work, and code review, use the `karpathy-guidelines` skill before starting implementation. -- Apply the skill with emphasis on explicit assumptions, simplicity first, surgical changes, verifiable success criteria, and verification. -- If the skill is not available in the current session, read `/Users/boinred/.codex/skills/karpathy-guidelines/SKILL.md` and follow those instructions as the fallback. +- 커밋하기 전에 이번 변경이 `docs/llm/` 문서 내용과 맞는지 확인한다. +- 파일·타입·public/protected 멤버·패킷 ID·설정 키·명령어·CI job을 추가, 이동, 삭제, 변경했으면 해당 도메인 문서를 먼저 고친다. +- 문서 수정은 코드 변경과 **같은 커밋**에 넣는다. 코드를 커밋한 뒤 문서를 따로 커밋하지 않는다. +- 수정 기준은 `docs/llm/README.md`의 "문서 유지 규칙"을 따른다. -## Code Map Rule +## C# 코드 스타일 -- 코드 탐색 전에 `docs/CODEMAP.md`를 먼저 읽고, 작업에 필요한 파일만 열어 토큰을 절약한다. -- 프로젝트·디렉터리 추가/삭제, 공개 타입 이동, 빌드·테스트 명령 변경처럼 코드맵 내용이 달라지는 변경을 하면 같은 커밋에서 `docs/CODEMAP.md`도 갱신한다. +대상: .NET 10 / C# 14 (`enable`, `enable`). 포맷터·분석기 설정 파일(`.editorconfig`)은 아직 없으므로 아래 규칙은 리뷰로 지킨다. + +- 네임스페이스는 file-scoped(`namespace LibNetworks.Sessions;`)로 쓴다. +- 새 파일은 UTF-8, LF로 저장한다. 기존 파일의 인코딩은 요청 없이 바꾸지 않는다. 일부 파일(`LibCommons/LatencyStats.cs` 등)은 CP949라서, 다시 저장하면 diff가 커지고 scaffold golden hash가 바뀐다. +- 필드 이름은 **고치는 파일의 기존 규칙을 따른다.** 새 파일은 소속 프로젝트의 규칙을 따른다. + - 엔진·템플릿·샘플(`LibCommons`, `LibNetworks`, `template-projects`, `FastPortServer`, `FastPortClient`): 인스턴스 필드 `m_PascalCase`, static 필드 `s_PascalCase`, 상수 `C_PascalCase` 또는 `PascalCase`. + - 도구·대시보드(`tests-projects/*`, `FastPortDashboard.*`): `_camelCase`. + - 기존 이름을 한꺼번에 바꾸는 리팩터링은 요청이 있을 때만 한다. +- 타입·메서드·프로퍼티·이벤트는 PascalCase, 지역 변수·매개변수는 camelCase로 쓴다. 인터페이스는 `I` 접두어를 붙인다. +- nullable 경고를 `!`로 숨기지 않는다. 불변식으로 null이 아님이 보장될 때만 쓰고 바로 위에 이유 주석을 단다. `#pragma warning disable`도 같은 기준이다. +- 비동기 메서드는 `Task`/`ValueTask`를 반환하고 `async void`를 쓰지 않는다(이벤트 핸들러 제외). `ConfigureAwait(false)`는 고치는 파일의 기존 방식을 따른다(`TimerQueue.cs`는 쓰고 `BaseSession.cs`는 쓰지 않는다). 한 파일 안에서 섞지 않는다. +- `CancellationToken`을 받는 API는 끝까지 전달한다. 취소로 인한 `OperationCanceledException`은 오류 로그로 남기지 않는다. +- 핫 패스(수신·송신·accept 루프)에서는 할당을 늘리지 않는다. `ArrayPool.Shared`에서 빌린 버퍼는 반환 책임자를 한 곳으로 정하고, 반환 후 다시 쓰지 않는다. 핫 패스에 로그를 추가할 때는 `IsEnabled` 검사로 감싼다. +- 세션당 상태는 해당 세션의 worker Task/잠금 규칙 안에서만 바꾼다. 새 공유 상태를 만들면 어떤 스레드가 읽고 쓰는지 주석으로 적는다. +- 엔진(`LibNetworks`)은 텔레메트리·로깅 구현을 모른다. 관측이 필요하면 `protected virtual OnNetwork*` hook을 추가하고 구현은 앱(예: SmokeServer 세션)에서 override한다. +- 템플릿(`template-projects/FastPortGameServerTemplate`)은 `LibCommons`, `LibNetworks`만 참조한다. `Protocols`, `FastPortServer`, 테스트 프로젝트를 참조하지 않는다. +- 공개 API(엔진의 public/protected 멤버)를 바꾸면 템플릿·샘플·SmokeServer·Dashboard 사용처를 함께 고치고 빌드로 확인한다. ## Commenting Rule @@ -26,3 +75,30 @@ - 복잡한 로직은 단계별로 `//` 주석을 나눠서 읽는 사람이 흐름을 따라갈 수 있게 한다. - 주석은 실제 코드 동작과 일치해야 하며, 변경 시 코드와 함께 갱신한다. - 의미 없는 반복 설명이나 코드와 모순되는 주석은 작성하지 않는다. + +## 테스트 규칙 + +- 테스트 프레임워크는 MSTest다. 엔진·도구 테스트는 `tests-projects/FastPortTests`, 대시보드 Core 테스트는 `tests-projects/FastPortDashboardTests`에 둔다. +- 테스트 클래스 파일 이름은 대상 타입을 따른다(`<대상>Tests.cs`). 메서드 이름은 영어 `대상_상황_기대결과` 형식으로 쓴다(`BaseSession_InvalidPacketHeaderOnly_DisconnectsWithoutDelivery`). +- `FastPortTests`는 메서드 단위 병렬 실행이다(`MSTestSettings.cs`의 `Parallelize(Scope = ExecutionScope.MethodLevel)`). 고정 포트, 공유 static 상태, 실행 순서 의존을 만들지 않는다. 소켓 테스트는 loopback 포트 0과 기존 `SocketPair` 헬퍼 패턴을 쓴다. +- 비동기 대기는 고정 `Task.Delay` 대신 완료 신호(`TaskCompletionSource`)와 타임아웃으로 기다린다. 타이밍에 따라 흔들리는 단언을 만들지 않는다. +- 버그 수정은 먼저 실패하는 테스트로 재현하고, 수정 후 통과를 확인한다. +- 커밋 전 최소 확인: `dotnet build FastPortSharp.sln -c Release`와 변경 영역 테스트(`--filter`). 엔진을 바꿨으면 `dotnet test FastPortSharp.sln -c Release` 전체를 돌린다. 명령은 `docs/llm/platform.md`의 "빌드·테스트"에 있다. + +## 변경 시 함께 해야 하는 일 + +- `LibCommons/**`, `LibNetworks/**`, `template-projects/FastPortGameServerTemplate/**`, `template-projects/Protos/**`를 고치면(주석만 바꿔도) scaffold golden hash를 갱신한다: `tests/scaffold/run.sh --update-golden case-01-simple` 후 `tests/scaffold/run.sh` 전체 통과를 확인한다. +- `scripts/scaffold-game-server.sh`와 `.ps1`은 같은 결과(바이트 단위)를 내야 한다. 한쪽을 고치면 다른 쪽도 고친다. +- `NetworkDisconnectReason` 값을 추가하면 SmokeServer 세션의 reason 문자열 매핑도 추가한다. +- `.github/workflows/*.yml`의 job `name`을 바꾸면 `main` ruleset 필수 체크 이름도 바꿔야 PR이 머지된다. + +## Git / 커밋 메시지 + +- 작업 브랜치에서 개발하고 PR로 `main`에 머지한다. `main`은 브랜치 보호 대상이다(직접 push 금지, 필수 체크 `build (ubuntu-latest)`, `build (macos-latest)`, `build (windows-latest)`). +- 릴리스 브랜치 이름은 `builds/release`다(`builds.release` 아님). 승격은 `main` → `builds/release` PR로 한다. +- 커밋 author·committer는 `boinred `이다. +- 형식은 `(): <설명>`이다. 커밋 메시지와 PR 제목에 모두 쓴다. + - type: `feat`(기능), `fix`(버그 수정), `perf`(성능), `refactor`(동작 변화 없는 구조 변경), `test`(테스트만), `docs`(문서만), `ci`(워크플로), `chore`(그 밖의 설정·정리). + - scope는 선택이다. 변경이 한 영역에 속하면 넣는다: `buffers`, `packet`, `session`, `listener`, `connector`, `sample`, `template`, `scaffold`, `smoke-server`, `load-runner`, `load-validation`, `telemetry`, `dashboard`, `llm-docs`. + - 설명은 영어 명령형 소문자로 시작하고 첫 줄은 72자 미만, 끝에 마침표를 찍지 않는다. + - 예: `fix(session): guard receive path against malformed headers`, `docs(llm-docs): add session domain map`. diff --git a/CLAUDE.md b/CLAUDE.md index e64f2e0..0e19702 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,19 +1,11 @@ # CLAUDE.md -공통 에이전트 규칙(언어, 코딩, 주석 규칙)은 `AGENTS.md`에 있고, 저장소 구조는 `docs/CODEMAP.md`에 있다. -Claude Code는 아래 import로 두 파일을 자동으로 읽는다. 규칙은 `AGENTS.md` 한 곳에서만 수정한다. +공통 에이전트 규칙(언어, 코드 탐색, 문서 유지, C# 스타일, 주석, 테스트, 커밋)은 `AGENTS.md`에 있고, 코드 지도는 `docs/llm/`에 있다. +Claude Code는 아래 import로 `AGENTS.md`만 자동으로 읽는다. 코드 지도(`docs/llm/README.md`)는 매 세션 컨텍스트를 아끼려고 import하지 않고, `AGENTS.md`의 "코드 탐색 규칙"에 따라 코드 탐색 전에 직접 읽는다. 규칙은 `AGENTS.md` 한 곳에서만 수정한다. @AGENTS.md -@docs/CODEMAP.md ## Claude Code 전용 메모 -### Git / PR -- 커밋 author·committer: `boinred `. `Co-Authored-By: Claude` 트레일러는 넣지 않는다. -- `main`은 브랜치 보호(ruleset) 대상: 직접 push 금지, PR + 필수 체크 `build (ubuntu-latest)`, `build (macos-latest)`, `build (windows-latest)` 통과 후 merge commit으로 머지. -- 릴리스 브랜치 이름은 `builds/release` (점 `.`이 아니라 슬래시 `/`). 승격은 `main` → `builds/release` PR. - -### 변경 시 함께 해야 하는 일 -- `LibCommons/**`, `LibNetworks/**`, `template-projects/FastPortGameServerTemplate/**`, `template-projects/Protos/**` 수정 → scaffold golden hash 갱신 필수: - `tests/scaffold/run.sh --update-golden case-01-simple` 후 `tests/scaffold/run.sh` 전체 통과 확인. -- `.github/workflows/*.yml`의 job `name` 변경 → main ruleset 필수 체크 이름도 함께 바꿔야 PR이 머지됨. +- 커밋에 `Co-Authored-By: Claude` 트레일러를 넣지 않는다. +- 프로젝트 설정 `.claude/settings.json`이 `superpowers@claude-plugins-official` 플러그인을 켠다. 스킬 사용 방식은 `AGENTS.md`의 "개발 워크플로 (superpowers)"를 따르고, 스킬 절차와 `AGENTS.md`가 충돌하면 `AGENTS.md`를 따른다. diff --git a/README.ko.md b/README.ko.md index 5e5e761..15e9872 100644 --- a/README.ko.md +++ b/README.ko.md @@ -402,7 +402,7 @@ FastPortSharp/ ├── 📂 FastPortDashboard.Maui/ # MAUI desktop dashboard (macOS / Windows) │ # FastPortSharp.Dashboard.sln 로 빌드 │ -├── 📂 docs/ # 벤치마크 리포트, runbook, CODEMAP.md +├── 📂 docs/ # 벤치마크 리포트, runbook, llm/ (에이전트용 코드 지도) └── FastPortSharp.sln ``` diff --git a/README.md b/README.md index d23a88c..f542917 100644 --- a/README.md +++ b/README.md @@ -411,7 +411,7 @@ FastPortSharp/ ├── 📂 FastPortDashboard.Maui/ # MAUI desktop dashboard (macOS / Windows) │ # Built via FastPortSharp.Dashboard.sln │ -├── 📂 docs/ # Benchmark reports, runbooks, CODEMAP.md +├── 📂 docs/ # Benchmark reports, runbooks, llm/ (agent code map) └── FastPortSharp.sln ``` diff --git a/docs/CODEMAP.md b/docs/CODEMAP.md deleted file mode 100644 index 9320d91..0000000 --- a/docs/CODEMAP.md +++ /dev/null @@ -1,163 +0,0 @@ -# FastPortSharp Code Map - -> LLM/에이전트용 저장소 지도. 코드를 열기 전에 이 문서로 위치를 찾고, 필요한 파일만 읽는다. -> 구조가 바뀌면 같은 커밋에서 이 문서도 갱신한다 (`AGENTS.md` Code Map Rule). -> 최종 갱신: 2026-10-05 (`main` 1aa6e63 + job 이름 변경 기준) - -## 1. 한눈에 보기 - -- .NET 10 / C# 14. `SocketAsyncEventArgs` 기반 TCP 엔진(`LibCommons` + `LibNetworks`) + 게임 서버 템플릿 + 부하/검증 도구 + MAUI 대시보드. -- 솔루션 2개 - - `FastPortSharp.sln`: 엔진, 샘플 서버/클라이언트, 템플릿, 테스트 도구 (Linux/macOS/Windows 빌드 가능) - - `FastPortSharp.Dashboard.sln`: `FastPortDashboard.Core` / `.Maui` / `FastPortDashboardTests` / `LibTestTelemetry` (MAUI workload 필요, macOS·Windows만) -- 와이어 포맷: `[UInt16 LE 전체 길이(헤더 포함)][int32 LE packetId][protobuf payload]`. 패킷 최대 65,535B. - -## 2. 프로젝트 의존 그래프 - -``` -LibCommons ◀── LibNetworks ◀──┬── FastPortServer - ▲ ▲ ├── FastPortClient ──────────────▶ Protocols - │ │ ├── template-projects/FastPortGameServerTemplate (+ .SampleClient) - │ │ ├── FastPortDashboard.Core ──▶ LibTestTelemetry - │ │ └── tests-projects/FastPortTestSmokeServer ──▶ LibTestTelemetry, Protocols - │ │ - └── tests-projects/FastPortTestLoadRunner (LibNetworks 미사용, 자체 소켓) ──▶ LibTestTelemetry, Protocols -tests-projects/FastPortTestLoadValidation ──▶ LibTestTelemetry (LoadRunner를 프로세스로 실행) -tests-projects/FastPortTests (MSTest) ──▶ LibCommons, LibNetworks, LoadRunner, LoadValidation, SmokeServer, LibTestTelemetry -tests-projects/FastPortDashboardTests (MSTest) ──▶ FastPortDashboard.Core -FastPortDashboard.Maui ──▶ FastPortDashboard.Core -``` - -템플릿은 `LibCommons` + `LibNetworks`만 참조한다 (`Protocols`/`FastPortServer`/테스트 프로젝트 참조 금지 — scaffold가 엔진만 복사). - -## 3. 디렉터리 맵 - -| 경로 | 역할 | -|---|---| -| `LibCommons/` | 버퍼, `BasePacket`, ID 생성, 지연 통계, 타이머 큐 | -| `LibNetworks/` | Listener / Connector / Session 엔진 | -| `Protocols/Protos/` | 엔진 샘플용 proto (`commons.proto`: `ProtocolId`, `ResultCode`, `Header` / `tests.proto`: Ping·Echo·Error) | -| `FastPortServer/` | 엔진 샘플 서버 (Generic Host, Windows Service 지원) | -| `FastPortClient/` | 엔진 샘플 클라이언트 (`LatencyStats` 사용) | -| `template-projects/FastPortGameServerTemplate/` | 게임 서버 스타터 (Serilog, DI, dispatcher/handler) | -| `template-projects/FastPortGameServerTemplate.SampleClient/` | 템플릿 echo round-trip 검증 클라이언트 | -| `template-projects/Protos/` | 템플릿 공유 proto (`PacketIds.proto` enum, `Sample.proto` Echo 메시지). 각 소비 프로젝트가 ``로 자체 생성 | -| `FastPortDashboard.Core/` | 대시보드 로직 (JSONL 폴링, 차트 수학, Echo 클라이언트, ViewModel) | -| `FastPortDashboard.Maui/` | MAUI UI (macOS Catalyst / Windows) | -| `tests-projects/FastPortTests/` | 엔진·도구 단위/통합 테스트 (MSTest) | -| `tests-projects/FastPortDashboardTests/` | 대시보드 Core 테스트 (MSTest) | -| `tests-projects/FastPortTestSmokeServer/` | 계측 포함 echo 서버 (텔레메트리 JSONL export, idle 정리) | -| `tests-projects/FastPortTestLoadRunner/` | 10K 세션 부하 생성기 (CLI) | -| `tests-projects/FastPortTestLoadValidation/` | 단계별 부하 검증 하네스 (LoadRunner 실행 + 서버 메트릭 병합·판정) | -| `tests-projects/LibTestTelemetry/` | 서버 텔레메트리 수집/스냅샷/JSONL 계약 | -| `scripts/scaffold-game-server.{sh,ps1}` | 템플릿 → 새 게임 서버 솔루션 생성 | -| `scripts/cloud/`, `scripts/load-validation/` | 클라우드(Azure/OCI) 부하 검증 보조 스크립트 | -| `tests/scaffold/` | scaffold golden 테스트 (`run.sh` / `run.ps1`, case-01~08) | -| `docs/` | 벤치마크 리포트, 부하 검증 runbook/가이드, 이 코드맵 | -| `.github/workflows/` | `build.yml`, `dashboard.yml`, `scaffold.yml` | - -## 4. 엔진 핵심 - -### LibCommons - -| 파일 | 타입 | 요점 | -|---|---|---| -| `IBuffers.cs` | `IBuffers` | `Write`, `Peek(ref byte[])`, `Drain`, `TryGetBasePackets`, `CanReadSize` | -| `ArrayPoolCircularBuffers.cs` | `ArrayPoolCircularBuffers` | **실사용 기본 구현**. `ArrayPool` 대여, 부족 시 확장(상한 없음 → 상한은 `BaseSession`이 강제), `Lock` 보호 | -| `BaseCircularBuffers.cs` / `BaseQueueBuffers.cs` | 레거시 구현 | 테스트·비교용 | -| `BasePacket.cs` | `BasePacket` | `HeaderSize = 2`, payload 복사본 보유, `Data`(ReadOnlySpan) | -| `IDGenerator.cs` | `IDGenerator` | 세션 ID 발급 | -| `LatencyStats.cs` | `LatencyStats` 외 | RTT/서버 처리/네트워크 지연 통계 (FastPortClient) | -| `Timers/` | `ITimerQueue`, `TimerQueue`, `IMonotonicTimeSource` | 단조 시계 기반 타이머 큐 (SmokeServer `SessionIdleTracker`가 사용) | - -### LibNetworks - -| 파일 | 타입 | 요점 | -|---|---|---| -| `BaseSocket.cs` | `BaseSocket` | listener/connector 공통 소켓 보유 | -| `BaseListener.cs` | `BaseListener` (abstract) | `StartAccept(ip, port[, backlog, outstandingAccepts])`, `RequestShutdown()`. accept → `IClientSessionFactory.Create` → `OnAccepted` (세션 생성은 accept pump 밖으로 offload). 관측 hook: `OnAcceptSucceeded/SessionCreated/SessionTaskStarted/AcceptFailed/ListenerSocketError` | -| `BaseMessageListener.cs` | `BaseMessageListener` | `BaseListener` + maxConnections 1000 (서버들이 상속) | -| `BaseConnector.cs` / `BaseMessageConnector.cs` | `BaseConnector` | `StartConnect(ip, port, connectionCount)` → `IServerSessionFactory.Create` | -| `Sessions/BaseSession.cs` | `BaseSession` (abstract, ~1.3K줄) | 세션 엔진 본체. 아래 5절 참고 | -| `Sessions/BaseSessionClient.cs` | `BaseSessionClient` | **서버 측에서 accept된 클라이언트 세션**. `Id`, `OnAccepted()` | -| `Sessions/BaseSessionServer.cs` | `BaseSessionServer` | **클라이언트 측에서 서버에 연결된 세션**. `OnConnected()` | -| `Sessions/IServerSessionFactory.cs` | `IClientSessionFactory` ⚠️ | 파일명과 타입명이 서로 뒤바뀜 (아래 9절) | -| `Sessions/IClientSessionFactory.cs` | `IServerSessionFactory` ⚠️ | 〃 | -| `Sessions/SessionSendOptions.cs` | `SessionSendOptions` | 송신 큐 상한(기본 1MB), chunk 64KB, drain 예산 | -| `Sessions/NetworkDisconnectReason.cs` | enum | `RemoteClosed`…`LocalShutdown`, `InvalidPacketHeader`, `ReceiveBufferOverflow`, `PacketHandlerError` | -| `Sessions/SendCompletionTracker.cs` | internal | 송신 완료 바이트 추적. 현재 엔진은 미사용, `FastPortTests`만 참조 (`InternalsVisibleTo`) | -| `Extensions/BasePacket+Extensions.cs` | `ParseMessageFromPacket` | payload 선두 int32 packetId + protobuf 파싱 | -| `AddressConverter.cs` | `AddressConverter` | ip/port → `EndPoint` 변환 | -| `Extensions/Socket+Extensions.cs` | `SetKeepAlive` | TCP keep-alive 설정 확장 | -| `SocketEventsPool.cs` | internal | SAEA 풀. 현재 어디에서도 사용하지 않음 | - -## 5. 데이터 흐름 (BaseSession) - -``` -[수신] SAEA ReceiveAsync (세션당 8KB 고정 배열, 동기 완료는 루프 처리) - → ProcessReceiveCompleted: 미파싱 바이트 > MaxReceiveBufferedBytes(기본 1MB, virtual) 이면 disconnect(ReceiveBufferOverflow) - → m_ReceivedBuffers.Write → SemaphoreSlim signal - → Task DoWorkReceivedBuffers: TryGetBasePackets - 실패 시 header size < 2 → disconnect(InvalidPacketHeader), 아니면 partial로 대기 - → bounded Channel(1000) → Task DoWorkReceivedPackets → OnReceived(packet) - handler 예외 → disconnect(PacketHandlerError) - -[송신] TryRequestSendMessage(packetId, IMessage) / TryRequestSendBuffers(span) - → 큐 바이트 예약(SessionSendOptions.MaxQueuedBytes 초과 시 거부) → ArrayPool 대여 + 헤더 기록 - → unbounded Channel → Task DoWorkSendBuffers: 최대 16 segment batch, drain 예산 후 Yield, transient 오류 backoff - -[종료] RequestDisconnect(reason): 1회만 실행 → OnNetworkSessionDisconnected(reason) → CTS cancel → socket close → 채널 complete → OnDisconnected -``` - -- 세션당 백그라운드 Task 3개 (`DoWorkReceivedBuffers`, `DoWorkReceivedPackets`, `DoWorkSendBuffers`). `WaitSession()`으로 종료 대기. -- 관측 hook (`protected virtual OnNetwork*`): SocketError, PacketReceived, ReceiveCompleted, OperationDuration, BytesSent, SendRequested/Completed/Abandoned/Backpressure/Rejected/DrainYield/BufferSample, SessionDisconnected(reason). 엔진은 텔레메트리 구현을 모름 → SmokeServer 세션이 override해 `IServerTelemetry`로 연결. - -## 6. 앱·템플릿 - -| 프로젝트 | 진입점 / 구성 | 핵심 타입 | -|---|---|---| -| FastPortServer | `Program.cs` (Generic Host), `appsettings.json`: `Logging`, `LatencyStats` | `FastPortServer : BaseMessageListener`, `FastPortClientSession : BaseSessionClient`, `FastPortClientSessionManager`(빈 stub) | -| FastPortClient | `Program.cs`, `appsettings.json`: `Logging`, `LatencyStats` | `FastPortConnector : BaseConnector`, `FastPortServerSession : BaseSessionServer` | -| GameServerTemplate | `Program.cs` DI: `IGameServerTelemetry`→`NullGameServerTelemetry`, `IPacketHandler`→`EchoHandler`, `PacketDispatcher`, `IClientSessionFactory`→`GameSessionFactory`, `GameServer`, `GameServerHostedService`. `appsettings.json`: `Serilog`, `GameServer` | `GameServer : BaseMessageListener`, `GameSession : BaseSessionClient` (`Send`), `PacketDispatcher` (packetId → handler, 예외 catch), `GameSessionFactory` (`BufferCapacityBytes` 8KB) | -| Template.SampleClient | `appsettings.json`: `Serilog`, `SampleClient` | `SampleClientConnector : BaseMessageConnector`, `SampleClientSession : BaseSessionServer`, `SampleClientHostedService` (1001 전송 → 1002 대기) | -| SmokeServer | `appsettings.json`: `FastPortTestSmokeServer`, `SessionIdleCleanup` | `FastPortTestSmokeServer : BaseMessageListener`, `FastPortTestSmokeClientSession` (hook → 텔레메트리), `SessionIdleTracker`, `ServerTelemetryExportBackgroundService` (JSONL) | -| Dashboard.Core | — | `JsonlPollingAdapter`(SmokeServer JSONL 읽기), `LineChartMath`, `EchoClient*`(LibNetworks 연결), `DashboardViewModel`, `EchoClientViewModel` (CommunityToolkit.Mvvm) | - -**템플릿에 패킷 추가**: `template-projects/Protos/`에 메시지 추가 → `PacketIds.proto` enum에 ID 추가(사용자 정의 ≥ 2000, C#에서는 `PACKET_IDS_` 접두어 제거됨) → `IPacketHandler` 구현(`PacketId => (int)PacketIds.X`) → `Program.cs`에 `AddSingleton()`. - -## 7. 테스트·검증 도구 - -| 대상 | 위치 | 비고 | -|---|---|---| -| 엔진/도구 테스트 | `tests-projects/FastPortTests/*Tests.cs` | 파일명이 대상 타입을 따름 (예: `BaseSessionReceivePolicyTests`, `BaseSessionSendPolicyTests`, `ArrayPoolCircularBufferTest`, `TimerQueueTests`, `BaseListenerShutdownTests`). 소켓 테스트는 loopback `SocketPair` 헬퍼 사용 | -| 대시보드 테스트 | `tests-projects/FastPortDashboardTests/` | Adapters, Charts, EchoClient, ViewModels, E2E(Mock) | -| 부하 생성 | `FastPortTestLoadRunner` | 주요 옵션: `--host --port --sessions --payload --duration --ramp-up --rate --pacing-policy --output` | -| 부하 검증 | `FastPortTestLoadValidation` | `--profile --stage --server-metrics --runner-project --dry-run` 등. 서버 JSONL과 러너 결과 병합·임계값 판정 | -| scaffold | `tests/scaffold/run.sh` / `run.ps1` | case-01 sha256/tree golden. 실패 시 scaffold stdout/stderr 마지막 60줄 출력 | - -## 8. 빌드·테스트·CI - -```bash -dotnet build FastPortSharp.sln -c Release -dotnet test FastPortSharp.sln -c Release # FastPortTests -dotnet test tests-projects/FastPortDashboardTests -c Release # Dashboard Core 테스트 (MAUI 불필요) -tests/scaffold/run.sh [--script ps1] [--update-golden case-01-simple] [case...] -``` - -| 워크플로 | 트리거 | job 이름 | -|---|---|---| -| `build.yml` | `main`, `builds/release` push/PR | `build (ubuntu-latest / macos-latest / windows-latest)` — main 필수 체크 | -| `dashboard.yml` | 위 브랜치 + Dashboard·엔진 경로 변경 시 | `dashboard (macos-latest / windows-latest)` | -| `scaffold.yml` | `main` push / 모든 PR + scaffold·템플릿·엔진·Protos 경로 변경 시 | ` / `, `cross-OS byte-identical compare` (windows/ps1 ≈ 16분) | - -## 9. 주의사항 (자주 밟는 함정) - -- **scaffold golden**: scaffold가 복사하는 `LibCommons/`, `LibNetworks/`, `template-projects/FastPortGameServerTemplate/`, `template-projects/Protos/` 파일 내용이 golden sha256에 포함됨 → 수정 시 `tests/scaffold/run.sh --update-golden case-01-simple` 필수 (주석만 바꿔도 해당). -- **팩토리 파일명 뒤바뀜**: `IClientSessionFactory.cs`에 `IServerSessionFactory`가, `IServerSessionFactory.cs`에 `IClientSessionFactory`가 선언됨. 타입 검색은 파일명이 아니라 타입명으로. -- **Client/Server 명명**: `BaseSessionClient` = 서버가 accept한 세션, `BaseSessionServer` = 클라이언트가 연결한 세션. -- **미사용 코드**: `BaseSession` 생성자의 `sendbuffers`(IBuffers), `BaseListener.C_MaxConnections`(저장만 함), `SocketEventsPool`, `FastPortClientSessionManager`(빈 stub). -- **Dashboard.sln**에는 `LibCommons`/`LibNetworks`가 포함되지 않아 Release 빌드에서도 ProjectReference가 Debug 구성으로 빌드됨. -- **MAUI CI**: `--no-restore` Release 빌드 전 restore에도 `-p:Configuration=Release` 필요 (NETSDK1047/1112). workload는 `dotnet workload install maui --version 10.0.401`로 고정 — 최신 set은 runner Xcode보다 높은 MacCatalyst SDK를 요구할 수 있음. -- **macOS symlink**: `/var` → `/private/var`. 절대 경로 sln 빌드 시 ProjectReference 중복 restore 경합 → scaffold smoke build는 dest로 이동 후 상대 경로로 빌드. -- **릴리스 브랜치 이름**: `builds/release` (`builds.release` 아님). -- **코드 주석의 `Design Ref: §...`**: 과거 설계 문서 참조이며 해당 문서는 저장소에 없음. diff --git a/docs/llm/README.md b/docs/llm/README.md new file mode 100644 index 0000000..14b884a --- /dev/null +++ b/docs/llm/README.md @@ -0,0 +1,102 @@ +# LLM 코드 탐색 가이드 + +이 폴더는 LLM 에이전트가 요청에 필요한 코드만 찾도록 돕는 지도다. 코드 전체를 훑기 전에 이 파일과 도메인 문서 1~2개만 읽는다. + +- 용어 정의: [glossary.md](glossary.md) +- 작업 규칙(스타일, 테스트, 커밋): 루트 `AGENTS.md` +- 기준 시점: 2026-10-05 (`main` `c709a26` 기준) + +## 사용 순서 + +1. 아래 "도메인 고르기" 표에서 요청 키워드로 문서를 고른다. +2. 고른 문서의 "작업별 시작점"에서 고칠 파일과 심볼을 찾는다. +3. 심볼은 이름으로 찾는다. 예: `grep -n "void RequestDisconnect" -r LibNetworks`. 이 문서들은 줄 번호를 적지 않는다. +4. 문서와 코드가 다르면 코드를 믿는다. 작업이 끝나면 해당 문서를 고친다. + +## 도메인 고르기 + +| 요청에 나오는 말 | 문서 | +|---|---| +| 패킷 포맷, 헤더, 길이 필드, packetId, protobuf 파싱, 수신 버퍼 구현, 링버퍼, `ArrayPool`, 세션 ID, 지연(RTT) 통계, 타이머 큐 | [packet-buffers.md](packet-buffers.md) | +| 세션, 수신·송신 흐름, 송신 큐 상한, 백프레셔, 연결 종료, disconnect reason, 관측 hook(`OnNetwork*`), 패킷 핸들러 예외, 잘못된 헤더, 수신 버퍼 초과 | [session.md](session.md) | +| 리스너, accept, 서버 시작·종료, 최대 접속 수, backlog, 커넥터, 클라이언트 접속, 세션 팩토리, keep-alive | [listener-connector.md](listener-connector.md) | +| 엔진 샘플 서버·클라이언트(`FastPortServer`, `FastPortClient`), Windows 서비스, 엔진 샘플 proto(`Protocols`) | [sample-apps.md](sample-apps.md) | +| 게임 서버 템플릿, 패킷 핸들러, 디스패처, DI 등록, Serilog, 템플릿 패킷 추가, `PacketIds.proto`, scaffold 스크립트, 새 게임 서버 생성, golden hash | [game-server-template.md](game-server-template.md) | +| 부하 테스트, 스모크 서버, 텔레메트리, JSONL, 부하 생성기(LoadRunner), 단계별 검증(LoadValidation), 임계값, idle 세션 정리, 클라우드 부하 검증, 벤치마크 리포트 | [load-testing.md](load-testing.md) | +| 대시보드, MAUI, 차트, ViewModel, Echo 클라이언트 UI, JSONL 폴링 | [dashboard.md](dashboard.md) | +| 빌드·테스트 명령, CI 워크플로, 필수 체크, 브랜치·릴리스, 개발 환경, 줄바꿈 정책, 코드 스타일 도구 | [platform.md](platform.md) | + +## 실행 파일 → 진입 파일 + +| 실행 파일(프로젝트) | 진입 파일 | 문서 | +|---|---|---| +| `FastPortServer` | `FastPortServer/Program.cs` | sample-apps | +| `FastPortClient` | `FastPortClient/Program.cs` | sample-apps | +| `FastPortGameServerTemplate` | `template-projects/FastPortGameServerTemplate/Program.cs` | game-server-template | +| `FastPortGameServerTemplate.SampleClient` | `template-projects/FastPortGameServerTemplate.SampleClient/Program.cs` | game-server-template | +| `FastPortTestSmokeServer` | `tests-projects/FastPortTestSmokeServer/Program.cs` | load-testing | +| `FastPortTestLoadRunner` | `tests-projects/FastPortTestLoadRunner/Program.cs` | load-testing | +| `FastPortTestLoadValidation` | `tests-projects/FastPortTestLoadValidation/Program.cs` | load-testing | +| `FastPortDashboard.Maui` | `FastPortDashboard.Maui/MauiProgram.cs` | dashboard | +| scaffold | `scripts/scaffold-game-server.sh`, `scripts/scaffold-game-server.ps1` | game-server-template | + +## 전체 구조 + +- .NET 10 / C# 14. `SocketAsyncEventArgs` 기반 TCP 엔진(`LibCommons` + `LibNetworks`) 위에 샘플, 게임 서버 템플릿, 부하·검증 도구, MAUI 대시보드가 있다. +- 솔루션은 두 개다. + - `FastPortSharp.sln`: 엔진, 샘플, 템플릿, 테스트 도구. Linux·macOS·Windows에서 빌드한다. + - `FastPortSharp.Dashboard.sln`: `FastPortDashboard.Core`, `FastPortDashboard.Maui`, `FastPortDashboardTests`, `LibTestTelemetry`. MAUI workload가 필요하고 macOS·Windows만 빌드한다. +- 와이어 포맷: `[UInt16 LE 전체 길이(헤더 포함)][int32 LE packetId][protobuf payload]`. 패킷 최대 65,535B. + +``` +LibCommons ◀── LibNetworks ◀──┬── FastPortServer + ▲ ▲ ├── FastPortClient ──────────────▶ Protocols + │ │ ├── template-projects/FastPortGameServerTemplate (+ .SampleClient) + │ │ ├── FastPortDashboard.Core ──▶ LibTestTelemetry + │ │ └── tests-projects/FastPortTestSmokeServer ──▶ LibTestTelemetry, Protocols + │ │ + └── tests-projects/FastPortTestLoadRunner (LibNetworks 미사용, 자체 소켓) ──▶ LibTestTelemetry, Protocols +tests-projects/FastPortTestLoadValidation ──▶ LibTestTelemetry (LoadRunner를 프로세스로 실행) +tests-projects/FastPortTests (MSTest) ──▶ LibCommons, LibNetworks, LoadRunner, LoadValidation, SmokeServer, LibTestTelemetry +tests-projects/FastPortDashboardTests (MSTest) ──▶ FastPortDashboard.Core +FastPortDashboard.Maui ──▶ FastPortDashboard.Core +``` + +## 모든 도메인에 공통인 패턴 + +- **엔진 확장은 상속 + override다.** 서버는 `BaseMessageListener`를, 세션은 `BaseSessionClient`(서버 쪽) 또는 `BaseSessionServer`(클라이언트 쪽)를 상속하고 `OnReceived` 등을 override한다. 세션 생성은 팩토리 인터페이스(`IClientSessionFactory`, `IServerSessionFactory`)가 맡는다. +- **엔진은 관측 구현을 모른다.** `BaseSession`, `BaseListener`의 `protected virtual On*` hook을 앱이 override해서 로그·텔레메트리로 연결한다(예: SmokeServer 세션 → `IServerTelemetry`). +- **세션당 백그라운드 Task 3개**(`DoWorkReceivedBuffers`, `DoWorkReceivedPackets`, `DoWorkSendBuffers`)가 돈다. 종료는 `RequestDisconnect(reason)` 한 곳으로 모이고 한 번만 실행된다. +- **버퍼는 `ArrayPool.Shared`에서 빌린다.** 누가 반환하는지 한 곳으로 정해져 있다. 수정 시 반환 경로를 함께 확인한다. +- **설정은 `appsettings.json` + Generic Host**다. 앱마다 섹션 이름이 다르다(각 도메인 문서 참고). +- **proto는 두 벌이다.** `Protocols/Protos/`(packetId `ProtocolId.Tests`=1)는 `FastPortClient`, SmokeServer, LoadRunner가 쓴다. `template-projects/Protos/`(`PacketIds` 1001/1002…)는 템플릿, SampleClient, `FastPortDashboard.Core`가 각자 ``로 생성한다. 두 계열은 서로 통신하지 않으므로 섞지 않는다(대시보드 Echo 클라이언트는 템플릿 서버용이다). + +## 큰 파일 + +아래 파일은 통째로 읽지 않는다. 먼저 grep으로 위치를 찾고 그 부분만 읽는다. 표에 없는 파일도 패턴만 확인할 때는 grep을 쓴다. + +| 파일 | 줄 수 | 찾는 방법 | +|---|---|---| +| `LibNetworks/Sessions/BaseSession.cs` | ~1300 | `grep -n "OnReceived\|RequestDisconnect\|DoWork" LibNetworks/Sessions/BaseSession.cs`처럼 메서드 이름으로 | +| `tests-projects/FastPortTests/FastPortTestLoadRunnerTests.cs` | ~1000 | `grep -n "public .*void\|public async Task"` 후 테스트 이름으로 | +| `tests-projects/FastPortTests/BaseSessionSendPolicyTests.cs` | ~790 | 테스트 메서드 이름 | +| `tests-projects/FastPortTests/FastPortTestLoadValidationTests.cs` | ~760 | 테스트 메서드 이름 | +| `tests-projects/FastPortTestLoadRunner/Metrics.cs` | ~740 | 타입·메서드 이름 | +| `scripts/scaffold-game-server.ps1`, `.sh` | ~690, ~670 | 함수 목록: `grep -n '^function ' *.ps1`, `grep -n '() {' *.sh`. 같은 단계가 짝을 이룬다(`smoke_build` ↔ `Invoke-SmokeBuild`) | +| `tests-projects/FastPortTestLoadRunner/LoadRunnerOptions.cs` | ~610 | 옵션 이름(`--sessions` 등) | +| `tests-projects/LibTestTelemetry/ServerTelemetry.cs` | ~530 | 지표 이름 | + +## 읽지 않아도 되는 곳 + +- `bin/`, `obj/`, `TestResults/`: 빌드 산출물. +- `docs/*.md`(이 폴더 제외): 벤치마크 리포트와 runbook. 성능 수치나 클라우드 절차가 필요할 때만 [load-testing.md](load-testing.md)에서 골라 읽는다. +- 코드 주석의 `Design Ref: §...`: 저장소에 없는 과거 설계 문서 참조다. +- `docs/superpowers/specs/`, `docs/superpowers/plans/`: superpowers 워크플로가 남긴 설계·계획 기록이다. 현재 코드 구조는 이 폴더(`docs/llm/`)가 기준이다. 결정 배경이 필요할 때만 읽는다. + +## 문서 유지 규칙 + +- 파일, 타입, public/protected 멤버, 패킷 ID, 설정 키, 명령어, CI job을 추가하거나 옮기면 해당 도메인 문서의 표를 고친다. 코드와 같은 커밋에 넣는다. +- 줄 번호는 적지 않는다. 이름으로 찾을 수 있게 쓴다. 줄 수는 "큰 파일" 표에만 대략 적는다. +- 새 도메인(새 프로젝트·디렉터리)이 생기면 문서를 추가하고 위 "도메인 고르기" 표와 "실행 파일 → 진입 파일" 표에 한 줄씩 넣는다. +- 새 용어나 헷갈리는 이름이 생기면 [glossary.md](glossary.md)에 추가한다. +- 문서 문체: 한국어, "~다"로 끝나는 짧은 문장, 코드 식별자는 백틱으로 원문 유지. diff --git a/docs/llm/dashboard.md b/docs/llm/dashboard.md new file mode 100644 index 0000000..1422245 --- /dev/null +++ b/docs/llm/dashboard.md @@ -0,0 +1,89 @@ +# 대시보드 (MAUI·JSONL 폴링·차트·ViewModel·Echo 클라이언트 UI·Dashboard 솔루션·CI) + +서버 텔레메트리 JSONL을 실시간으로 그리는 데스크톱 앱이다. 로직은 MAUI 의존이 없는 `FastPortDashboard.Core`(net10.0)에 있고, `FastPortDashboard.Maui`는 화면만 맡는다. 앱은 탭 두 개다: JSONL Polling(서버·클라이언트 지표 차트)과 Echo Client(직접 접속해 RTT 측정). + +**이 문서를 읽는 경우**: 대시보드 KPI·차트 추가, `JsonlPollingAdapter` 동작(offset·truncate·파일 공유 모드), Mock 데이터, `DashboardViewModel`·`EchoClientViewModel` 바인딩, Echo 클라이언트 연결 상태 머신, `FastPortSharp.Dashboard.sln` 빌드, MAUI workload, `dashboard.yml` CI 실패. + +**다른 문서로 가는 경우**: JSONL을 만드는 쪽(SmokeServer 텔레메트리, LoadRunner, `ObservedMetricsSnapshot` 계약) → [load-testing.md](load-testing.md). Echo 클라이언트가 붙는 서버와 `PacketIds` proto → [game-server-template.md](game-server-template.md). `BaseMessageConnector`·`BaseSessionServer` 엔진 동작 → [listener-connector.md](listener-connector.md), [session.md](session.md). 메인 솔루션 빌드·필수 체크 → [platform.md](platform.md). + +## 핵심 규칙 + +- **Core에는 MAUI 타입을 넣지 않는다.** ViewModel·Adapter·차트 수학·Echo 클라이언트는 모두 `FastPortDashboard.Core`에 있고, 그래서 `FastPortDashboardTests`가 MAUI 없이 돈다. `IDrawable`·`ICanvas`·`Dispatcher` 같은 MAUI 의존은 `FastPortDashboard.Maui/Views/`에만 둔다. +- Core의 `RootNamespace`는 `FastPortDashboard.Maui`다. 그래서 Core 타입의 namespace도 `FastPortDashboard.Maui.Adapters`, `FastPortDashboard.Maui.ViewModels`, `FastPortDashboard.Maui.EchoClient`다. 프로젝트 이름으로 namespace를 추측하지 않는다. +- **데이터 계약은 `LibTestTelemetry`의 `ObservedMetricsSnapshot`이다.** `JsonlPollingAdapter`는 한 줄씩 `ObservedMetricsJson.SerializerOptions`(camelCase)로 역직렬화한다. 깨진 줄은 건너뛴다. 계약 필드를 바꾸면 SmokeServer·LoadRunner·LoadValidation과 함께 맞춘다([load-testing.md](load-testing.md)). +- `DashboardViewModel.ApplySnapshot`은 `serverObserved`가 있으면 KPI(`CurrentSessions`, `TotalAcceptedSessions`, `TotalSentBytes`, `PendingSendRequests`, `SendBufferBytes`, `LastUpdate`)와 `ThroughputSeries`(`SentBytesPerSecond`)를 갱신한다. `clientObserved`가 있으면 `ClientRttSeries`(P50/P95/P99)를 더한다. 두 시리즈 모두 최대 600점(`MaxChartPoints`)만 유지한다. +- SmokeServer JSONL에는 `serverObserved`만 있다. RTT 차트를 채우려면 LoadRunner JSONL, LoadValidation의 `*.combined.metrics.jsonl`, 또는 Mock을 연다. +- **`JsonlPollingAdapter`는 파일을 `FileShare.ReadWrite | FileShare.Delete`로 연다.** 생산자(`ServerTelemetryExportBackgroundService`)가 쓰기 핸들을 잡고 있어도 Windows에서 `IOException`이 반복되지 않게 하려는 것이다. 읽을 위치(offset)는 yield 전에 확정한다. 파일이 offset보다 짧아지면 처음부터 다시 읽는다. `IOException`은 다음 폴링에서 재시도한다. +- `IPollingAdapter.StreamAsync`가 소스 추상화다. 구현은 `JsonlPollingAdapter`와 `MockPollingAdapter`(seed 기반 random walk, 서버·클라이언트 스냅샷 모두 생성) 두 개다. 새 소스(HTTP 등)는 이 인터페이스로 추가한다. +- **Echo 클라이언트는 게임 서버 템플릿용이다.** Core가 `template-projects/Protos/*.proto`를 ``로 직접 생성해 `PacketIds.EchoRequest`(1001)/`EchoResponse`(1002)를 쓴다. 기본 포트도 템플릿의 `ListenPort`와 같은 7777이다. SmokeServer(6628, `ProtocolId.Tests` = 1)에 붙이면 서버가 packetId 1001을 protocol error로 세고 응답하지 않아 RTT가 쌓이지 않는다. 응답 packetId가 1002가 아니면 클라이언트는 `EC-PROTO-001` 오류를 낸다. +- `EchoClientConnector`의 상태 전이(`TryBeginConnect`, `NotifyConnected`, `NotifyError`, `NotifyDisconnected`)는 소켓 없이 테스트할 수 있게 분리돼 있다. 실제 연결은 `StartConnect`가 `BaseMessageConnector` + `EchoClientSessionFactory`로 한다. +- UI 스레드 처리: `EchoClientViewModel`은 생성자로 받은 `postToUi`(`EchoClientPage`가 `Dispatcher.Dispatch`로 감쌈)로 모든 컬렉션·상태 변경을 넘긴다. KPI는 `System.Threading.Timer`로 1초마다 `EchoClientStats.Snapshot`을 가져온다. `DashboardViewModel`은 dispatcher 없이 `[RelayCommand]` 비동기 흐름 안에서 갱신한다. +- 차트는 SkiaSharp/Microcharts를 쓰지 않는다(macOS 26 crash 때문에 제거). `Microsoft.Maui.Graphics`의 `GraphicsView` + `LineChartDrawable`/`MultiLineChartDrawable`이 그리고, 범위 계산은 Core의 `LineChartMath`가 한다. + +## 파일·타입 + +| 파일 | 타입·심볼 | 내용 | +|---|---|---| +| `FastPortDashboard.Core/FastPortDashboard.Core.csproj` | — | net10.0, `CommunityToolkit.Mvvm`, `Google.Protobuf`·`Grpc.Tools`, 템플릿 proto 생성, `LibTestTelemetry`·`LibCommons`·`LibNetworks` 참조 | +| `FastPortDashboard.Core/Adapters/IPollingAdapter.cs` | `IPollingAdapter` | `IAsyncEnumerable StreamAsync` | +| `FastPortDashboard.Core/Adapters/JsonlPollingAdapter.cs` | `JsonlPollingAdapter` | JSONL tail 폴링(기본 1초). offset·truncate 처리 | +| `FastPortDashboard.Core/Adapters/MockPollingAdapter.cs` | `MockPollingAdapter` | 가짜 서버·클라이언트 스냅샷 | +| `FastPortDashboard.Core/Charts/LineChartMath.cs` | `LineChartMath` | `ComputeRange`, `ComputeRangeMulti`, `ComputeStepX`. 빈 값·동일 값 보정 | +| `FastPortDashboard.Core/ViewModels/DashboardViewModel.cs` | `DashboardViewModel` | KPI `[ObservableProperty]`, `ThroughputSeries`, `ClientRttSeries`, `ConnectCommand`/`DisconnectCommand`, `PumpAsync`·`ApplySnapshot`(테스트 진입점) | +| `FastPortDashboard.Core/ViewModels/PollingState.cs`, `TimedDoublePoint.cs`, `TimedRttPoint.cs` | `PollingState`, `TimedDoublePoint`, `TimedRttPoint` | 상태 enum(Idle/Polling/Disconnected/Error)과 차트 점 | +| `FastPortDashboard.Core/ViewModels/EchoClientViewModel.cs` | `EchoClientViewModel` | `Host`·`Port`·`Message`·`SendIntervalMs` 입력, `RttSeries`(최대 600), `Snapshot` KPI | +| `FastPortDashboard.Core/EchoClient/EchoClientConnector.cs` | `EchoClientConnector` | 연결 상태 머신, `StartConnect`, `RequestDisconnect`, `StateChanged` | +| `FastPortDashboard.Core/EchoClient/EchoClientSession.cs`, `EchoClientSessionFactory.cs` | `EchoClientSession : BaseSessionServer`, `EchoClientSessionFactory : IServerSessionFactory` | 연결 후 `SendIntervalMs` 간격 Echo 송신, 응답으로 RTT 계산 | +| `FastPortDashboard.Core/EchoClient/EchoClientStats.cs`, `EchoClientModels.cs` | `EchoClientStats`, `EchoClientOptions`, `EchoClientState`, `RttSample`, `EchoStatsSnapshot` | 송수신 카운터·1초 창 rate·평균 RTT | +| `FastPortDashboard.Maui/AppShell.xaml` | `AppShell` | `TabBar`: `JsonlPollingPage`, `EchoClientPage` | +| `FastPortDashboard.Maui/Views/JsonlPollingPage.xaml(.cs)` | `JsonlPollingPage` | `DashboardViewModel` 바인딩, 파일 선택(`FilePicker`), RTT·throughput 차트 | +| `FastPortDashboard.Maui/Views/EchoClientPage.xaml(.cs)` | `EchoClientPage` | `EchoClientViewModel` 생성, dispatcher 연결, RTT 차트 | +| `FastPortDashboard.Maui/Views/LineChartDrawable.cs`, `MultiLineChartDrawable.cs`, `LineChartSeries.cs` | `LineChartDrawable`, `MultiLineChartDrawable`, `LineChartSeries` | `IDrawable` 라인 차트 | +| `FastPortDashboard.Maui/MainPage.xaml(.cs)` | `MainPage` | 라우팅에서 쓰지 않는 호환용 잔존 페이지 | +| `FastPortDashboard.Maui/FastPortDashboard.Maui.csproj` | — | TFM `net10.0-maccatalyst`(Windows 호스트면 `net10.0-windows10.0.19041.0` 추가), `Microsoft.Maui.Controls` 10.0.20, Catalyst AOT 끔 | +| `FastPortSharp.Dashboard.sln` | — | Maui, Core, `FastPortDashboardTests`, `LibTestTelemetry`만 포함 | +| `.github/workflows/dashboard.yml` | job `dashboard (${{ matrix.os }})` | macOS·Windows에서 restore → build → test | + +## 작업별 시작점 + +| 하려는 작업 | 고칠 곳 | 같이 확인할 것 | +|---|---|---| +| 대시보드 KPI 추가 | `DashboardViewModel`에 `[ObservableProperty]` + `ApplySnapshot` 대입 → `JsonlPollingPage.xaml` 바인딩 | 지표가 계약에 없으면 먼저 [load-testing.md](load-testing.md)의 지표 추가, `MockPollingAdapter`, `DashboardViewModelTests` | +| 대시보드 차트 추가 | `DashboardViewModel`에 `ObservableCollection` 시리즈 + 600점 trim → `JsonlPollingPage.xaml`에 `GraphicsView` → `.xaml.cs`에서 drawable 연결·`CollectionChanged` 구독 | 여러 선이면 `MultiLineChartDrawable`/`LineChartSeries`, 범위 수학은 `LineChartMath` + `LineChartMathTests` | +| JSONL 읽기 문제(누락·중복·IOException) | `JsonlPollingAdapter.ReadNewSnapshotsAsync` | 생산자의 `FileShare`·flush(`ServerTelemetryExportBackgroundService`), `JsonlPollingAdapterTests` | +| 새 데이터 소스 | `IPollingAdapter` 구현 → `DashboardViewModel.StartAsync`의 선택 분기 | `UseMock`/`FilePath` 입력 UI | +| Echo 클라이언트 프로토콜 변경 | `EchoClientSession`(송신·`OnReceived`), `template-projects/Protos/` | 템플릿·SampleClient 영향([game-server-template.md](game-server-template.md)), scaffold golden 갱신 | +| Echo 연결 상태·오류 표시 | `EchoClientConnector`, `EchoClientViewModel.OnConnectorStateChanged` | `EchoClientConnectorTests` | +| Echo KPI 계산 | `EchoClientStats.Snapshot` | `EchoClientStatsTests` | +| MAUI CI 실패 | `.github/workflows/dashboard.yml` | workload 버전·Xcode, restore의 `-p:Configuration=Release` | + +## 실행·테스트 + +```bash +# MAUI 없이 Core 테스트 (Linux 포함 어디서나) +dotnet test tests-projects/FastPortDashboardTests -c Release + +# 전체 대시보드 솔루션 (macOS/Windows + MAUI workload) +dotnet workload install maui --version 10.0.401 +dotnet restore FastPortSharp.Dashboard.sln -p:Configuration=Release +dotnet build FastPortSharp.Dashboard.sln -c Release --no-restore +dotnet test FastPortSharp.Dashboard.sln -c Release --no-build + +# macOS Catalyst 실행 +dotnet build FastPortDashboard.Maui/FastPortDashboard.Maui.csproj -c Release -f net10.0-maccatalyst -t:Run +``` + +- 실데이터 확인: SmokeServer를 `--Telemetry:Output=<경로>`로 띄운 뒤([load-testing.md](load-testing.md)) JSONL Polling 탭에서 Mock을 끄고 그 파일을 고른다. Echo 탭은 게임 서버 템플릿을 띄우고 7777에 붙는다. +- 테스트(`tests-projects/FastPortDashboardTests/`): `Adapters/JsonlPollingAdapterTests.cs`(offset 유지, truncate, 깨진 줄, 동시 쓰기), `Adapters/MockPollingAdapterTests.cs`, `Charts/LineChartMathTests.cs`, `EchoClient/EchoClientConnectorTests.cs`, `EchoClient/EchoClientStatsTests.cs`, `ViewModels/DashboardViewModelTests.cs`, `E2E/MockE2ETests.cs`(Mock → ViewModel 전체 흐름). `EchoClientViewModel` 전용 테스트는 없다. +- CI `dashboard.yml`: `main`·`builds/release`의 push/PR 중 `FastPortDashboard.Maui/**`, `FastPortDashboard.Core/**`, `tests-projects/FastPortDashboardTests/**`, `tests-projects/LibTestTelemetry/**`, `LibCommons/**`, `LibNetworks/**`, `template-projects/Protos/**`, `FastPortSharp.Dashboard.sln`, 워크플로 자신이 바뀔 때와 `workflow_dispatch`로 돈다. job 이름은 `dashboard (macos-latest)`, `dashboard (windows-latest)`이다. Linux는 MAUI TFM을 빌드할 수 없어 matrix에서 뺐다. + +## 주의 + +- 솔루션 파일 이름은 `FastPortSharp.Dashboard.sln`이다. `FastPortSharp.sln`에는 대시보드 프로젝트가 없어서 `build.yml`과 `dotnet test FastPortSharp.sln`은 대시보드 테스트를 돌리지 않는다. +- `FastPortSharp.Dashboard.sln`에 `LibCommons`/`LibNetworks`가 없다. 솔루션에 없는 ProjectReference는 솔루션 구성을 물려받지 않아 `-c Release`로 빌드해도 Debug 구성으로 빌드된다. +- MAUI는 Release에서만 maccatalyst RuntimeIdentifiers(x64/arm64)를 추가한다. `--no-restore` Release 빌드 전 restore에도 `-p:Configuration=Release`를 줘야 NETSDK1047이 나지 않는다. +- MAUI workload는 `--version 10.0.401`로 고정한다. 최신 workload set은 runner의 Xcode보다 높은 MacCatalyst SDK를 요구해 빌드가 깨질 수 있다. 올릴 때는 `Microsoft.Maui.Controls` 버전과 runner Xcode를 함께 확인한다. +- macOS Catalyst Release는 AOT에서 시작 시 SIGABRT가 나서 `RunAOTCompilation=false`, `MtouchInterpreter=all`로 둔다. +- `Platforms/` 아래 Android·iOS·Tizen 폴더가 남아 있지만 TFM에 없어 빌드되지 않는다. +- `FastPortDashboard.Maui/README.md`는 일부 오래됐다(ViewModel 위치를 Maui로 적음, RTT 차트 미포함이라고 적음, SmokeServer가 기본으로 JSONL을 쓴다고 적음). 코드가 기준이다. SmokeServer는 `Telemetry:Output`을 줘야 JSONL을 쓴다. +- 코드 주석의 `Design Ref: §...`는 저장소에 없는 과거 설계 문서 참조다. 용어는 [glossary.md](glossary.md), 전체 지도는 [README.md](README.md), 샘플 서버·클라이언트는 [sample-apps.md](sample-apps.md). diff --git a/docs/llm/game-server-template.md b/docs/llm/game-server-template.md new file mode 100644 index 0000000..c00de05 --- /dev/null +++ b/docs/llm/game-server-template.md @@ -0,0 +1,121 @@ +# 게임 서버 템플릿 (GameServerTemplate·패킷 핸들러·디스패처·DI·Serilog·샘플 클라이언트·scaffold·golden hash) + +`template-projects/`는 엔진(`LibCommons` + `LibNetworks`) 위에 올린 게임 서버 스타터다. `scripts/scaffold-game-server.{sh,ps1}`가 이 템플릿과 엔진을 복사해 새 게임 서버 솔루션을 만들고, `tests/scaffold/`가 그 출력을 golden 파일로 검증한다. + +**이 문서를 읽는 경우**: 템플릿 서버 DI 구성, `IPacketHandler`·`PacketDispatcher` 동작, 템플릿에 새 패킷 추가, `GameSession.Send`, Serilog·`GameServer` 설정, 샘플 클라이언트 echo 검증, scaffold로 새 게임 서버 생성, scaffold golden hash 갱신·CI 실패. + +**다른 문서로 가는 경우**: 세션 수신·송신·종료 엔진 동작 → [session.md](session.md). `BaseListener`·`BaseConnector`·팩토리 인터페이스 → [listener-connector.md](listener-connector.md). `BasePacket`·버퍼·와이어 포맷 → [packet-buffers.md](packet-buffers.md). `FastPortServer`/`FastPortClient` 샘플 → [sample-apps.md](sample-apps.md). 빌드·CI 전체 → [platform.md](platform.md). 용어 → [glossary.md](glossary.md). + +## 핵심 규칙 + +- **템플릿은 `LibCommons` + `LibNetworks`만 참조한다.** `Protocols`, `FastPortServer`, `FastPortClient`, `LibTestTelemetry`, 테스트 프로젝트를 참조하면 안 된다. scaffold가 엔진 두 개와 템플릿만 복사하므로 다른 참조는 생성된 솔루션에서 깨진다(csproj 주석에도 명시). +- **golden hash 대상 파일을 바꾸면 golden을 갱신한다.** `LibCommons/**`, `LibNetworks/**`, `template-projects/FastPortGameServerTemplate/**`, `template-projects/Protos/**`는 scaffold 출력에 그대로 들어가 `case-01-simple`의 sha256에 포함된다. 주석 한 줄, `README.md`, `QUICKSTART.ko.md` 수정도 해당한다. scaffold 스크립트가 생성하는 `.gitignore`·`.gitattributes`·`README.md` 내용을 바꿔도 같다. +- **sh와 ps1은 byte-identical 출력을 내야 한다.** CI의 `cross-OS byte-identical compare` job이 OS·flavor별 `case-01` sha256을 서로 비교한다. 예: csproj의 Protos 상대 경로는 두 스크립트 모두 `\` 구분자로 쓴다(sh는 `/`를 `\`로 치환). 한쪽 스크립트만 고치지 않는다. +- **핸들러 예외는 세션을 끊지 않는다.** `PacketDispatcher.Dispatch`가 모든 예외를 catch해 로그와 `IGameServerTelemetry.OnHandlerException`만 남긴다. 엔진의 `PacketHandlerError` disconnect까지 예외가 올라가지 않는다. +- 핸들러가 없는 packetId는 경고 로그 후 버린다. payload가 4바이트 미만이면 packetId는 `-1`이다. +- `PacketDispatcher`는 `handlers.ToDictionary(h => h.PacketId)`로 만든다. 같은 `PacketId` 핸들러를 두 개 등록하면 생성 시 예외로 호스트 시작이 실패한다. +- packet ID 대역: `1000-1999`는 템플릿 내장(Echo `1001`/`1002`), 사용자 정의는 `2000` 이상이다(`PacketIds.proto` 주석). +- `PacketIds` enum 값은 `PACKET_IDS_` 접두어로 쓴다. C# 생성기가 접두어를 떼서 `PacketIds.EchoRequest`처럼 노출한다. C#에서는 `(int)` 캐스트가 필요하다. +- `GameSession.Send`는 `RequestSendMessage`를 호출하고 성공 여부(bool)를 버린다. 송신 큐 상한 초과 거부는 호출자에게 보이지 않는다(→ [session.md](session.md)). + +## 파일·타입 + +| 파일 | 타입·심볼 | 내용 | +|---|---|---| +| `template-projects/FastPortGameServerTemplate/Program.cs` | Generic Host | Serilog(`ReadFrom.Configuration`) 연결, `GameServerOptions` 바인딩, DI 등록: `IGameServerTelemetry`→`NullGameServerTelemetry`, `IPacketHandler`→`EchoHandler`, `PacketDispatcher`, `IClientSessionFactory`→`GameSessionFactory`, `GameServer`, `AddHostedService` | +| `.../FastPortGameServerTemplate/appsettings.json` | `Serilog`, `GameServer` | Console sink, `ListenAddress` `0.0.0.0`, `ListenPort` `7777`, `MaxSessions` `1024` | +| `.../Configuration/GameServerOptions.cs` | `GameServerOptions` | `SectionName = "GameServer"`. `MaxSessions`는 시작 로그에만 쓰이고 연결 수를 제한하지 않는다 | +| `.../Application/GameServer.cs` | `GameServer : BaseMessageListener` | 빈 서브클래스. 생성자에서 `IClientSessionFactory`를 받는다 | +| `.../Application/GameServerHostedService.cs` | `GameServerHostedService : BackgroundService` | `ExecuteAsync` → `StartAccept(ListenAddress, ListenPort)`, `StopAsync` → `RequestShutdown()` | +| `.../Application/PacketDispatcher.cs` | `PacketDispatcher` | payload 선두 int32 LE로 packetId 읽기 → 핸들러 호출, 처리 시간 측정, 예외 catch | +| `.../Handlers/IPacketHandler.cs` | `IPacketHandler` | `int PacketId { get; }`, `void Handle(GameSession, BasePacket)` | +| `.../Handlers/EchoHandler.cs` | `EchoHandler` | `EchoRequest` 파싱 → `EchoResponse{Message, ServerUnixMs}`를 `PacketIds.EchoResponse`로 응답 | +| `.../Sessions/GameSession.cs` | `GameSession : BaseSessionClient` | `IDGenerator`로 `Id` 발급, `Send(int, IMessage)`, `OnReceived` → `PacketDispatcher.Dispatch`, accept/disconnect 텔레메트리 | +| `.../Sessions/GameSessionFactory.cs` | `GameSessionFactory : IClientSessionFactory` | `BufferCapacityBytes = 8 * 1024`. 세션마다 `ArrayPoolCircularBuffers` 두 개 생성 | +| `.../Telemetry/IGameServerTelemetry.cs`, `NullGameServerTelemetry.cs` | `IGameServerTelemetry`, `NullGameServerTelemetry` | 세션 accept/disconnect, packet received/handled, handler exception hook. 기본 구현은 no-op | +| `.../FastPortGameServerTemplate.csproj` | `` | `Protos/`의 모든 proto를 자체 어셈블리에 생성. 엔진 `ProjectReference` 두 개 | +| `.../README.md`, `QUICKSTART.ko.md` | 문서 | scaffold 출력에 복사된다(golden 대상) | +| `template-projects/FastPortGameServerTemplate.SampleClient/` | `SampleClientConnector : BaseMessageConnector`, `SampleClientSession : BaseSessionServer`, `SampleClientSessionFactory : IServerSessionFactory`, `SampleClientHostedService`, `EchoSignal`, `SampleClientOptions` | 연결 시 `EchoRequest`(1001) 전송 → `EchoResponse`(1002) 수신 시 RTT 기록 → `EchoSignal` 완료. 10초 타임아웃. `ExitAfterOneEcho`면 종료 | +| `.../SampleClient/appsettings.json` | `Serilog`, `SampleClient` | `Host` `127.0.0.1`, `Port` `7777`, `Message`, `ExitAfterOneEcho` `true` | +| `template-projects/Protos/PacketIds.proto` | `PacketIds` enum | `PACKET_IDS_UNSPECIFIED = 0`, `PACKET_IDS_ECHO_REQUEST = 1001`, `PACKET_IDS_ECHO_RESPONSE = 1002` | +| `template-projects/Protos/Sample.proto` | `EchoRequest`, `EchoResponse` | `package fastport.sample`, `csharp_namespace = "FastPortGameServerTemplate.Protocols"` | +| `scripts/scaffold-game-server.sh`, `.ps1` | 12단계 scaffold | 아래 "scaffold와 golden 테스트" | +| `tests/scaffold/run.sh`, `run.ps1`, `case-*/`, `_shared/` | golden 러너 | `_shared/blocked-tokens.txt`(이름 차단 목록, 스크립트도 읽음), `_shared/name-validation.txt` | +| `.github/workflows/scaffold.yml` | `cases`, `compare` job | OS×flavor 매트릭스 + 교차 비교 | + +## 작업별 시작점 + +| 하려는 작업 | 고칠 곳 | 같이 확인할 것 | +|---|---|---| +| 새 패킷·핸들러 추가 | `template-projects/Protos/*.proto`, `Handlers/`, `Program.cs` | 아래 절차, golden 갱신, `FastPortDashboard.Core` 빌드(같은 Protos 사용) | +| 핸들러 예외·미등록 packetId 정책 변경 | `Application/PacketDispatcher.cs` | `IGameServerTelemetry` hook 호출 순서 | +| 텔레메트리 구현 교체 | `IGameServerTelemetry` 구현 추가 → `Program.cs` 등록 교체 | `GameSession`, `PacketDispatcher`, `GameSessionFactory`가 주입받음 | +| 버퍼 크기 조정 | `Sessions/GameSessionFactory.cs`의 `BufferCapacityBytes` | 수신 상한 동작 → [session.md](session.md) | +| 포트·주소 변경 | `appsettings.json`의 `GameServer` | 샘플 클라이언트 `SampleClient.Port` | +| 세션 수명 hook 추가 | `Sessions/GameSession.cs` (`OnAccepted`, `OnDisconnected`) | 엔진 hook 목록 → [session.md](session.md) | +| scaffold 동작 변경 | `scripts/scaffold-game-server.sh`와 `.ps1` 둘 다 | `tests/scaffold/case-*`, golden, cross-OS compare | +| 이름 차단 토큰 추가 | `tests/scaffold/_shared/blocked-tokens.txt` | `_shared/name-validation.txt` 기대값 | +| 템플릿 폴더·파일 추가 | 템플릿 디렉터리 | `case-01-simple/expected/tree.txt`·`sha256.txt` 갱신, `files-present.txt` | + +## 새 패킷 추가 절차 + +1. 메시지를 정의한다. `template-projects/Protos/`에 새 `.proto`를 추가하거나 `Sample.proto`에 메시지를 더한다. 새 파일은 `package fastport.sample;`와 `option csharp_namespace = "FastPortGameServerTemplate.Protocols";`를 그대로 쓴다(scaffold가 이 토큰을 새 이름으로 치환한다). +2. csproj는 고치지 않는다. 템플릿·SampleClient·`FastPortDashboard.Core`의 ``가 `*.proto` 와일드카드라 새 파일도 빌드 때 자동 생성된다. +3. `template-projects/Protos/PacketIds.proto`의 `PacketIds` enum에 값을 추가한다. 이름은 `PACKET_IDS_MY_REQUEST = 2001;`처럼 `PACKET_IDS_` 접두어를 붙이고 값은 `2000` 이상으로 한다. C#에서는 `PacketIds.MyRequest`가 된다. +4. `template-projects/FastPortGameServerTemplate/Handlers/`에 `IPacketHandler` 구현을 만든다. `EchoHandler`를 본뜬다. + - `public int PacketId => (int)PacketIds.MyRequest;` + - `Handle`에서 `packet.ParseMessageFromPacket(out _, out var request)`(`LibNetworks.Extensions`)로 파싱하고, 실패하면 로그 후 return 한다. + - 응답은 `session.Send((int)PacketIds.MyResponse, response);`로 보낸다. +5. `Program.cs`에 `builder.Services.AddSingleton();`를 추가한다. `PacketDispatcher`가 `IEnumerable`로 모두 받는다. +6. `dotnet build FastPortSharp.sln -c Release`로 확인한다. 클라이언트 쪽 송수신이 필요하면 SampleClient의 `SampleClientSession`도 고친다. +7. `tests/scaffold/run.sh --update-golden case-01-simple` 후 `tests/scaffold/run.sh` 전체를 통과시킨다. proto 파일을 추가하면 `tree.txt`도 바뀐다. + +## scaffold와 golden 테스트 + +- 사용법: `scripts/scaffold-game-server.sh [--protos-path PATH] [--force] [--no-git] [--skip-smoke] [--dry-run]`. ps1은 `-ProtosPath`, `-Force`, `-NoGit`, `-SkipSmoke`, `-DryRun`, `-Help`이고 PowerShell 7 이상이 필요하다. +- 이름 규칙: `^[A-Z][A-Za-z0-9]{0,63}$`이고 `blocked-tokens.txt`에 없어야 한다. +- 종료 코드: `0` 성공, `2` 입력 검증 실패, `3` 대상 충돌(`--force` 필요), `4` smoke build 실패, `5` IO·git·dotnet 오류. +- 복사: 템플릿 → `/`, `template-projects/Protos` → `/Protos`(또는 `--protos-path`), `LibCommons`·`LibNetworks` → `/` 그대로. `bin`, `obj`, `*.user`는 제외한다. **SampleClient는 복사하지 않는다.** +- 토큰 치환: `FastPortGameServerTemplate` → ``을 템플릿 하위 텍스트 파일과 복사된 proto 파일에만 적용한다. 엔진 폴더는 건드리지 않는다. csproj의 `..\..\LibCommons`·`..\..\LibNetworks`는 `..\`로, `..\Protos`는 실제 Protos 상대 경로(`\` 구분자)로 바꾼다. +- 생성: `.gitignore`, `.gitattributes`, `README.md`, `.sln`(`--format sln`, 프로젝트 3개 + `Protos` solution folder). sln 이름은 프로젝트 이름이 아니라 대상 폴더 이름이다. +- smoke build는 dest로 `cd`(ps1은 `Push-Location`)한 뒤 상대 경로 sln으로 `dotnet build -c Release`를 한다. macOS `/var` → `/private/var` symlink 때문에 절대 경로로 빌드하면 같은 프로젝트를 중복 restore해 `obj`가 충돌한다. + +| case | 인자 요약 | 검증 | +|---|---|---| +| `case-01-simple` | `MyLobbyServer --no-git --skip-smoke` | exit 0, stdout `[1/12]`·`[12/12]`·`Done.`, 필수 파일, 원래 토큰 폴더 부재, **sha256·tree golden** | +| `case-02-blocked-name` | `Application` | exit 2, stderr `blocked tokens list` | +| `case-03-regex-meta` | `My$Game` | exit 2, stderr `does not match required pattern` | +| `case-04-existing-dest-no-force` | 비어 있지 않은 dest(`input/pre/`) | exit 3, 기존 파일 유지 | +| `case-05-existing-dest-with-force` | 같은 dest + `--force` | exit 0, 기존 파일 삭제, 새 파일 존재 | +| `case-06-dry-run` | `--dry-run` | exit 0, `[DRY-RUN]` 계획 출력, dest 미생성 | +| `case-07-no-git-no-smoke` | `Foo --no-git --skip-smoke` | `.git` 없음, 11·12단계 skip 메시지 | +| `case-08-external-protos` | `--protos-path {DEST}-protos --no-git` | Protos가 dest 밖에 생성, `/Protos` 없음, **smoke build 실행**(dotnet 필요) | + +- golden 비교: `compute_sha256`은 `.git/`, `bin/`, `obj/`, `*.sln`, `*.bak`를 제외한다. `tree.txt`는 `*.sln`을 포함한다. 둘 다 `--update-golden` 시 `case-01-simple`만 다시 쓴다. +- 실패 시 러너가 diff 앞 40줄과 scaffold stdout/stderr 마지막 60줄을 출력하고 tmpdir를 남긴다. +- CI `scaffold.yml`: `main` push와 모든 PR에서 scaffold 스크립트·`tests/scaffold/**`·템플릿·`template-projects/Protos/**`·엔진·`.gitattributes`·워크플로 경로가 바뀔 때 돈다(`workflow_dispatch`도 가능). job 이름은 ` / `(ubuntu·macos는 sh·ps1, windows는 ps1·sh 총 6개)와 `cross-OS byte-identical compare`다. windows/ps1이 가장 느리다(약 16분). main 필수 체크는 아니다. + +## 테스트·실행 + +```bash +dotnet build FastPortSharp.sln -c Release # 템플릿·SampleClient 포함 +dotnet run --project template-projects/FastPortGameServerTemplate -c Release # 0.0.0.0:7777 대기 +dotnet run --project template-projects/FastPortGameServerTemplate.SampleClient -c Release # echo 1회 후 종료 +tests/scaffold/run.sh # 전체 case, sh flavor +tests/scaffold/run.sh --script ps1 case-01-simple # ps1로 단일 case +tests/scaffold/run.sh --update-golden case-01-simple # golden 재생성 +pwsh -NoProfile -File tests/scaffold/run.ps1 -Script ps1 -UpdateGolden -Cases case-01-simple +scripts/scaffold-game-server.sh MyLobbyServer ../my-lobby --dry-run +``` + +- 샘플 클라이언트 성공 로그는 `Echo round-trip succeeded`, 실패는 `Echo round-trip timed out (10s).`다. +- 템플릿 전용 단위 테스트 프로젝트는 없다. 검증은 빌드, SampleClient 왕복, scaffold 러너로 한다. + +## 주의 + +- `IPacketHandler.cs` 주석의 "registering them in PacketDispatcher"는 실제와 다르다. 등록은 `Program.cs` DI에서 한다. +- `template-projects/Protos`는 템플릿·SampleClient 외에 `FastPortDashboard.Core`도 ``로 쓴다. 기존 메시지·ID를 바꾸면 대시보드 Echo 클라이언트도 깨질 수 있다(→ [dashboard.md](dashboard.md)). +- 엔진 `Protocols/Protos/`(`commons.proto`, `tests.proto`)와 템플릿 `template-projects/Protos/`는 별개다. 템플릿에서 `Protocols` 프로젝트를 참조하지 않는다. +- `run.sh --script ps1`의 인자 변환에는 `--protos-path` → `-ProtosPath`가 없다(`run.ps1`에는 있다). ps1 flavor로 case-08을 돌릴 때는 `run.ps1`을 쓴다. CI도 ps1 flavor는 `run.ps1`로 돈다. +- scaffold 스크립트 주석의 `Design Ref`·`Plan Ref`가 가리키는 `docs/01-plan`, `docs/02-design` 등은 저장소에 없다. +- `GameServer`는 `BaseMessageListener`를 상속해 `BaseListener`에 `1000`을 넘기지만 엔진은 `C_MaxConnections`에 저장만 한다. `GameServer:MaxSessions` 설정도 연결 수를 제한하지 않는다. 동시 접속 제한이 필요하면 직접 구현한다. diff --git a/docs/llm/glossary.md b/docs/llm/glossary.md new file mode 100644 index 0000000..8a0e54e --- /dev/null +++ b/docs/llm/glossary.md @@ -0,0 +1,55 @@ +# 용어집 + +코드와 문서에서 쓰는 이름을 정리했다. 이름과 역할이 엇갈리는 항목은 **주의**로 표시했다. + +## 엔진 + +| 용어 | 뜻 | 코드 | +|---|---|---| +| 엔진 | `LibCommons` + `LibNetworks`. 템플릿과 scaffold가 복사해 가는 범위 | `LibCommons/`, `LibNetworks/` | +| 패킷 | `[UInt16 LE 전체 길이][int32 LE packetId][protobuf payload]` 한 덩어리. 길이는 헤더 2바이트를 포함한다 | `BasePacket`, `BasePacket.HeaderSize` | +| 헤더 | 패킷 앞 2바이트 길이 필드. 값이 `HeaderSize`(2)보다 작으면 잘못된 헤더다 | `NetworkDisconnectReason.InvalidPacketHeader` | +| packetId | payload 앞 4바이트 int32. 메시지 종류를 나타낸다 | `ParseMessageFromPacket` | +| 세션 | TCP 연결 하나와 그 송수신 상태. 세션당 worker Task 3개가 돈다 | `BaseSession` | +| **`BaseSessionClient`** (주의) | **서버가 accept한 클라이언트 연결**을 표현하는 서버 쪽 세션이다. 이름의 Client는 "상대가 클라이언트"라는 뜻이다 | `LibNetworks/Sessions/BaseSessionClient.cs` | +| **`BaseSessionServer`** (주의) | **클라이언트가 서버에 연결한** 클라이언트 쪽 세션이다. 이름의 Server는 "상대가 서버"라는 뜻이다 | `LibNetworks/Sessions/BaseSessionServer.cs` | +| **세션 팩토리** (주의) | 리스너는 `IClientSessionFactory`, 커넥터는 `IServerSessionFactory`로 세션을 만든다. 두 인터페이스는 **파일 이름과 타입 이름이 서로 뒤바뀌어 있다**(`IClientSessionFactory.cs`에 `IServerSessionFactory`가 있다). 타입 이름으로 찾는다 | `LibNetworks/Sessions/` | +| 리스너 | 포트를 열고 accept 루프를 돌리는 서버 객체 | `BaseListener`, `BaseMessageListener` | +| 커넥터 | 서버에 연결을 여러 개 맺는 클라이언트 객체 | `BaseConnector`, `BaseMessageConnector` | +| accept pump | 리스너의 accept 반복. 세션 생성은 pump 밖으로 넘겨 accept 지연을 줄인다 | `BaseListener` | +| outstanding accepts | 동시에 걸어 두는 accept 요청 수 | `BaseListener.StartAccept` 인자 | +| 관측 hook | 엔진이 이벤트마다 부르는 `protected virtual` 메서드. 기본 구현은 비어 있고 앱이 override한다 | `OnNetwork*`, `OnAccept*` | +| disconnect reason | 세션이 끊긴 이유 enum. 종료 경로마다 하나를 넘긴다 | `NetworkDisconnectReason` | +| 송신 큐 상한 | 아직 보내지 못한 바이트 예약 한도. 넘으면 송신 요청을 거부한다 | `SessionSendOptions` | +| 백프레셔 | 송신 큐가 차서 보내기를 늦추거나 거부하는 상태 | `OnNetworkSendBackpressure` 등 | +| drain 예산 | 송신 worker가 한 번에 내보내고 양보(`Yield`)하기 전까지 처리하는 양 | `SessionSendOptions` | + +## 템플릿·scaffold + +| 용어 | 뜻 | 코드 | +|---|---|---| +| 템플릿 | 새 게임 서버의 출발점 프로젝트. 엔진만 참조한다 | `template-projects/FastPortGameServerTemplate/` | +| 디스패처 | packetId로 `IPacketHandler`를 골라 호출하는 객체 | `PacketDispatcher` | +| 핸들러 | packetId 하나를 처리하는 클래스 | `IPacketHandler`, `EchoHandler` | +| scaffold | 템플릿과 엔진을 복사해 새 게임 서버 솔루션을 만드는 스크립트 | `scripts/scaffold-game-server.sh`, `.ps1` | +| golden | scaffold 결과의 기대 sha256·파일 트리. 엔진·템플릿 파일 내용이 들어가므로 주석만 바꿔도 갱신해야 한다 | `tests/scaffold/case-01-simple/expected/` | +| 사용자 정의 패킷 ID | 템플릿에서 새 패킷에 쓰는 ID 범위(2000 이상) | `template-projects/Protos/PacketIds.proto` | + +## 부하·관측 도구 + +| 용어 | 뜻 | 코드 | +|---|---|---| +| 스모크 서버 | 계측이 들어간 echo 서버. 텔레메트리를 JSONL로 내보낸다 | `tests-projects/FastPortTestSmokeServer/` | +| 텔레메트리 | 서버 지표 수집·스냅샷과 JSONL 계약 | `tests-projects/LibTestTelemetry/` | +| JSONL | 한 줄에 스냅샷 JSON 하나를 쓰는 지표 파일. 대시보드와 LoadValidation이 읽는다 | `ServerTelemetryExportBackgroundService` | +| LoadRunner | 최대 10K 세션 부하 생성 CLI. 엔진(`LibNetworks`)을 쓰지 않고 자체 소켓을 쓴다 | `tests-projects/FastPortTestLoadRunner/` | +| LoadValidation | LoadRunner를 프로세스로 실행하고 서버 지표와 합쳐 단계별 합격·불합격을 판정하는 하네스 | `tests-projects/FastPortTestLoadValidation/` | +| idle 정리 | 일정 시간 수신이 없는 세션을 끊는 기능 | `SessionIdleTracker` | + +## 저장소·운영 + +| 용어 | 뜻 | +|---|---| +| 필수 체크 | `main` ruleset이 머지 전에 요구하는 CI job: `build (ubuntu-latest)`, `build (macos-latest)`, `build (windows-latest)` | +| 릴리스 브랜치 | `builds/release`. `main`에서 PR로 승격한다 | +| `Design Ref: §...` | 코드 주석에 남은 과거 설계 문서 참조. 문서는 저장소에 없다 | diff --git a/docs/llm/listener-connector.md b/docs/llm/listener-connector.md new file mode 100644 index 0000000..58f6e0c --- /dev/null +++ b/docs/llm/listener-connector.md @@ -0,0 +1,94 @@ +# 리스너·커넥터 (accept, 서버 시작·종료, 접속, 세션 팩토리, backlog, keep-alive, 주소 변환) + +`LibNetworks`의 소켓 진입점이다. 서버 쪽은 `BaseListener`가 accept한 소켓을 `IClientSessionFactory`로 세션으로 만들고, 클라이언트 쪽은 `BaseConnector`가 연결한 소켓을 `IServerSessionFactory`로 세션으로 만든다. 세션 이후의 송수신은 이 문서 범위가 아니다. + +**이 문서를 읽는 경우**: 리스너 상속, accept pump, `StartAccept`·`RequestShutdown`, listen backlog·outstanding accept 수, accept 실패·소켓 오류 관측 hook, 최대 동시 접속 수, 커넥터 `StartConnect`, 접속 실패 처리, 세션 팩토리 등록, keep-alive 설정, ip/port → `EndPoint` 변환. + +**다른 문서로 가는 경우**: 세션 송수신·종료 사유·`OnNetwork*` hook → [session.md](session.md). 버퍼·`BasePacket` → [packet-buffers.md](packet-buffers.md). 리스너를 쓰는 앱 예시 → [sample-apps.md](sample-apps.md), [game-server-template.md](game-server-template.md). accept 텔레메트리·backlog 튜닝 실측 → [load-testing.md](load-testing.md). 용어(Client/Server 세션 명명) → [glossary.md](glossary.md). + +## 핵심 규칙 + +- **명명이 반대다.** `BaseListener`(서버)는 `IClientSessionFactory.Create(Socket)` → `BaseSessionClient`를 만든다. `BaseConnector`(클라이언트)는 `IServerSessionFactory.Create(Socket)` → `BaseSessionServer`를 만든다. "Client 세션 = 서버가 accept한 상대"다. +- **팩토리 파일명이 뒤바뀌어 있다.** `Sessions/IClientSessionFactory.cs`에는 `IServerSessionFactory`가, `Sessions/IServerSessionFactory.cs`에는 `IClientSessionFactory`가 선언돼 있다. 파일명이 아니라 타입명으로 grep한다. +- `StartAccept` 오버로드는 3개다. `(ip, port)`는 backlog `C_DefaultListenBacklog`(4096), outstanding accept `C_DefaultOutstandingAccepts`(1)를 쓴다. `(ip, port, backlog)`, `(ip, port, backlog, outstandingAccepts)`로 바꿀 수 있다. +- 값 보정: backlog ≤ 0이면 4096으로 바꾼다(`NormalizeListenBacklog`, 로컬 함수). outstanding accept ≤ 0이면 1, 64(`C_MaxOutstandingAccepts`) 초과면 64로 자른다(`NormalizeOutstandingAccepts`, `internal static`, 테스트가 직접 호출하므로 시그니처 유지). +- **시작 순서**: `AddressConverter.TryToEndPoint` → `Bind` → `Listen(backlog)` → `m_bIsRunning = true` → outstanding 수만큼 `SocketAsyncEventArgs` 생성 → 각각 `Accept`. 초기 `Accept` 하나라도 실패하면 `RequestShutdown()` 후 `false`를 돌려준다. +- **accept pump**: args 하나는 `AcceptAsync` 하나만 담당한다. 동기 완료는 재귀 없이 `Accept`의 `while` 루프로 처리한다. 비동기 완료는 `OnSocketEventsAcceptCompleted` → `ProcessAccept` → 같은 args로 `Accept` 재등록이다. +- **세션 생성은 pump 밖에서 한다.** `ProcessAccept`는 `OnAcceptSucceeded` 호출 후 `ThreadPool.UnsafeQueueUserWorkItem(new AcceptedSessionWork(...), preferLocal: false)`로 넘긴다. `RunAcceptedSessionWork`가 `m_ClientSessionFactory.Create` → `OnAcceptSessionCreated` → `OnAcceptSessionTaskStarted` → `clientSession.OnAccepted()` 순서로 실행한다. pump 스레드에서 무거운 일을 하지 않는다. +- hook 이름은 `OnAcceptSucceeded`, `OnAcceptSessionCreated`, `OnAcceptSessionTaskStarted`, `OnAcceptFailed`, `OnListenerSocketError`다. 이름에 Task가 있어도 별도 Task는 없다. work item 안에서 `OnAccepted` 직전에 호출된다. +- `OnAcceptFailed`·`OnListenerSocketError`는 `phase` 문자열이 있는 오버로드와 없는 호환용 오버로드가 있다. phase 버전의 기본 구현이 호환용을 부른다. phase 값: `start-endpoint`, `start-bind-listen`, `accept-start`, `accept-completion`, `accept-completion-null-socket`, `accept-process`, `accept-session-work`, `shutdown-close`(`OnListenerSocketError`만). +- 모든 hook은 텔레메트리 타입을 모른다. 엔진에 텔레메트리 의존을 넣지 말고 하위 클래스에서 override한다(예: `FastPortTestSmokeServer`). +- **종료**: `RequestShutdown()`은 `Interlocked.CompareExchange(ref m_bIsRunning, false, true)`로 1회만 실행하고 리스닝 소켓을 `Close()`한다. 두 번 불러도 안전하다. 대기 중인 accept는 `OperationAborted`로 끝나며, running이 아니면 실패로 기록하지 않는다. args는 각 pump가 끝날 때 `Dispose`된다. +- `BaseSocket.RequestDisconnect()`는 `Connected`가 아니면 바로 반환하므로 리스너 종료에 쓰지 않는다. 리스너는 반드시 `RequestShutdown()`이다. +- `RequestShutdown()`은 이미 accept한 세션을 닫지 않는다(세션 관리자 없음, `TODO`). 세션 정리는 앱 책임이다. +- 종료한 리스너 인스턴스는 재시작할 수 없다(소켓을 새로 만들지 않음). 같은 포트로 다시 열려면 새 인스턴스를 만든다. +- `C_MaxConnections`는 생성자 인자를 저장만 하고 접속 수를 제한하지 않는다. `BaseMessageListener`는 이 값으로 1000을 넘긴다. +- **커넥터는 인스턴스당 소켓 1개다.** `StartConnect(ip, port, connectionCount)`의 `connectionCount`는 쓰이지 않는다. `BaseSocket.m_Socket`·`m_SocketEvent` 하나로 `ConnectAsync`를 한 번 건다. 여러 연결이 필요하면 커넥터를 여러 개 만든다(`FastPortClient`는 `AddTransient`). +- `StartConnect`의 `true`는 "연결 시도 시작"이다. 연결 실패는 `OnSocketEventsConnectedCompleted`에서 로그만 남기고, 재시도·hook·반환값이 없다. 성공하면 `IServerSessionFactory.Create(m_Socket)` 후 `Task.Run(() => session.OnConnected())`다. 커넥터와 세션이 같은 `Socket`을 공유한다. +- `AddressConverter.TryToEndPoint`는 `IPAddress.TryParse`만 한다. 호스트명(`localhost` 등)은 실패한다. IP 문자열만 넘긴다. + +## 파일·타입 + +| 파일 | 타입·심볼 | 내용 | +|---|---|---| +| `LibNetworks/BaseSocket.cs` | `BaseSocket`, `m_Socket`, `m_SocketEvent`, `RequestDisconnect` | TCP 소켓 1개와 SAEA 1개를 가진 공통 부모. `m_SocketEvent`는 커넥터만 쓴다 | +| `LibNetworks/BaseListener.cs` | `BaseListener` (abstract), `StartAccept`, `RequestShutdown`, `Accept`, `ProcessAccept`, `AcceptedSessionWork`, `RunAcceptedSessionWork`, `NormalizeOutstandingAccepts` | accept pump, 세션 생성 offload, 관측 hook, 상수 `C_DefaultListenBacklog`·`C_DefaultOutstandingAccepts`·`C_MaxOutstandingAccepts` | +| `LibNetworks/BaseMessageListener.cs` | `BaseMessageListener` | `BaseListener`에 maxConnections 1000을 넘기는 얇은 하위 클래스. 앱 서버들이 상속한다 | +| `LibNetworks/BaseConnector.cs` | `BaseConnector`, `StartConnect`, `OnSocketEventsConnectedCompleted` | 단일 연결, `IServerSessionFactory`로 세션 생성 | +| `LibNetworks/BaseMessageConnector.cs` | `BaseMessageConnector` | 생성자만 있는 하위 클래스. 템플릿 SampleClient·Dashboard가 쓴다 | +| `LibNetworks/Sessions/IServerSessionFactory.cs` | `IClientSessionFactory` | `BaseSessionClient Create(Socket clientSocket)` (파일명 주의) | +| `LibNetworks/Sessions/IClientSessionFactory.cs` | `IServerSessionFactory` | `BaseSessionServer Create(Socket connectedSocket)` (파일명 주의) | +| `LibNetworks/AddressConverter.cs` | `AddressConverter.TryToEndPoint` | ip 문자열 + port → `IPEndPoint` | +| `LibNetworks/Extensions/Socket+Extensions.cs` | `SocketExtensions.SetKeepAlive` | `SIO_KEEPALIVE_VALS` 설정. Windows에서만 적용, 다른 OS는 아무것도 안 한다. 기본 1000ms/1000ms. 엔진·앱 어디에서도 호출하지 않는다 | +| `LibNetworks/SocketEventsPool.cs` | `SocketEventsPool` (internal) | SAEA 스택 풀. 사용처 없음 | +| `LibNetworks/Properties/AssemblyInfo.cs` | `InternalsVisibleTo("FastPortTests")` | `NormalizeOutstandingAccepts` 테스트 접근 | + +세션 소켓의 실제 keep-alive는 `BaseSession` 생성자의 `SetSocketOption(..., SocketOptionName.KeepAlive, true)`다 → [session.md](session.md). + +## 상속·사용처 + +| 하위 클래스 | 부모 | 팩토리 | +|---|---|---| +| `FastPortServer.FastPortServer` | `BaseMessageListener` | `FastPortClientSessionFactory` | +| `FastPortTestSmokeServer.FastPortTestSmokeServer` | `BaseMessageListener` (hook 전부 override → `IServerTelemetry`) | `FastPortTestSmokeClientSessionFactory` | +| `GameServer` (템플릿) | `BaseMessageListener` | `GameSessionFactory` | +| `FastPortClient.FastPortConnector` | `BaseConnector` | `FastPortServerSessionFactory` | +| `SampleClientConnector` (템플릿) | `BaseMessageConnector` | `SampleClientSessionFactory` | +| `EchoClientConnector` (Dashboard) | `BaseMessageConnector`를 내부에서 생성 | `EchoClientSessionFactory` | + +## 작업별 시작점 + +| 하려는 작업 | 고칠 곳 | 같이 확인할 것 | +|---|---|---| +| 새 서버 앱에서 리스너 상속하기 | `BaseMessageListener` 상속 + `IClientSessionFactory` 구현 → DI 등록 | 호스팅 서비스 `StartAsync`/`StopAsync`에서 `StartAccept`/`RequestShutdown` (예: `GameServerHostedService`, `FastPortServerBackgroundService`) | +| listen backlog·outstanding accept 조정 | 호출부에서 4-인자 `StartAccept` 사용 | `FastPortTestSmokeServerBackgroundService`의 `ListenBacklog`·`OutstandingAccepts` 옵션, 상한 64 | +| 기본 backlog·상한 변경 | `BaseListener` 상수 | `ServerTelemetryTests`의 `BaseListener_NormalizeOutstandingAccepts_*` 기대값 | +| 최대 동시 접속 수 바꾸기 | 현재 강제 로직 없음. `BaseListener`의 `C_MaxConnections`를 실제로 검사하도록 `ProcessAccept` 또는 `RunAcceptedSessionWork`에 추가해야 한다 | 세션 수 추적 주체(세션 관리자 없음), 초과 소켓 닫기, `OnAcceptFailed` phase 추가 | +| accept 실패 관측 추가 | 하위 클래스에서 `OnAcceptFailed(string phase, ...)` override | 새 phase를 추가하면 `FastPortTestSmokeServer`·`LibTestTelemetry` 분류도 확인 | +| 세션 생성 지연 계측 | `OnAcceptSessionCreated`·`OnAcceptSessionTaskStarted` override | `acceptCompletedTimestamp`는 `Stopwatch.GetTimestamp()` 기준 | +| 종료 시 접속 세션도 정리 | 앱 쪽 세션 목록 → 각 세션 `RequestDisconnect` | `RequestShutdown`은 리스닝 소켓만 닫는다 | +| 커넥터 연결 실패 처리·재시도 | `BaseConnector.OnSocketEventsConnectedCompleted` | 현재 로그만 남김. Dashboard `EchoClientConnector`는 자체 상태 머신으로 감싼다 | +| 다중 연결 클라이언트 | 연결마다 커넥터 인스턴스 생성 | `connectionCount` 미사용. 대량 부하는 `FastPortTestLoadRunner`(자체 소켓) → [load-testing.md](load-testing.md) | +| 호스트명으로 접속 | `AddressConverter.TryToEndPoint`에 DNS 해석 추가 | 리스너·커넥터 둘 다 이 함수를 쓴다 | +| keep-alive 시간 설정 | `SetKeepAlive`를 세션 소켓에 호출 | Windows 전용 구현. Linux/macOS는 `SocketOptionName.TcpKeepAliveTime` 등 별도 필요 | + +## 테스트 + +- `tests-projects/FastPortTests/BaseListenerShutdownTests.cs`: `RequestShutdown` 후 같은 포트 재바인딩, 이중 호출 안전성. +- `tests-projects/FastPortTests/ServerTelemetryTests.cs`: `BaseListener_NormalizeOutstandingAccepts_UsesDefaultForInvalidValues`, `BaseListener_NormalizeOutstandingAccepts_ClampsLargeValues`. +- `tests-projects/FastPortTests/FastPortTestSmokeServerTests.cs`: 실제 accept·echo 왕복과 accept 텔레메트리. `FastPortTestSmokeServer_MultipleOutstandingAccepts_EchoesAndRecordsTelemetry`가 outstanding accept 2개를 검증한다. +- `BaseConnector` 단위 테스트는 없다. `FastPortDashboardTests/EchoClient/EchoClientConnectorTests.cs`는 소켓 없는 상태 머신만 본다. + +```bash +dotnet test tests-projects/FastPortTests -c Release --filter "FullyQualifiedName~BaseListener" +dotnet test tests-projects/FastPortTests -c Release --filter "FullyQualifiedName~FastPortTestSmokeServerTests" +``` + +## 주의 + +- `LibNetworks/**` 수정은 주석만 바꿔도 scaffold golden hash가 바뀐다. `tests/scaffold/run.sh --update-golden case-01-simple` 후 `tests/scaffold/run.sh` 전체 통과를 확인한다. +- `BaseListener` 생성자 시그니처, hook 시그니처를 바꾸면 `FastPortServer`, `FastPortTestSmokeServer`, 템플릿 `GameServer`가 함께 깨진다. 템플릿은 scaffold 대상이다. +- `StartAccept`의 `Bind`/`Listen` 예외는 `OnAcceptFailed`와 `OnListenerSocketError`를 둘 다 부른다. 카운터가 두 번 오르는 것이 의도다. +- `BaseConnector`의 로그 문자열 일부가 `BaseListener, ...`로 시작한다(복사 흔적). 로그 검색 시 혼동하지 않는다. +- 같은 커넥터에 `StartConnect`를 두 번 부르면 `Completed` 핸들러가 중복 등록되고 이미 연결된 소켓에 `ConnectAsync`를 건다. 재사용하지 않는다. +- `SocketEventsPool`, `C_MaxConnections`, `SetKeepAlive`는 미사용이다. 동작한다고 가정하지 않는다. diff --git a/docs/llm/load-testing.md b/docs/llm/load-testing.md new file mode 100644 index 0000000..8a5c502 --- /dev/null +++ b/docs/llm/load-testing.md @@ -0,0 +1,101 @@ +# 부하 테스트 (스모크 서버·텔레메트리·JSONL·부하 생성기·단계별 검증·임계값·클라우드 부하 검증·idle 세션 정리) + +엔진의 실제 TCP 경로를 계측하고 검증하는 도구 묶음이다. 스모크 서버가 서버 지표를 JSONL로 내보내고, 부하 생성기(LoadRunner)가 클라이언트 지표를 JSONL로 쓰며, 검증 하네스(LoadValidation)가 둘을 합쳐 단계별로 통과·실패를 판정한다. + +**이 문서를 읽는 경우**: `FastPortTestSmokeServer` 실행·설정, 서버 텔레메트리 지표 추가·변경, `ObservedMetricsSnapshot` JSONL 형식, idle 세션 정리(`SessionIdleTracker`), `FastPortTestLoadRunner` CLI 옵션·pacing, `FastPortTestLoadValidation` profile·stage·임계값, 서버·러너 지표 병합, Azure/OCI 클라우드 부하 검증 스크립트, 벤치마크 리포트·runbook 위치. + +**다른 문서로 가는 경우**: 엔진 관측 hook(`OnNetwork*`)과 `NetworkDisconnectReason` 정의 → [session.md](session.md). accept 경로 hook(`OnAcceptSucceeded` 등)과 `StartAccept` backlog 인자 → [listener-connector.md](listener-connector.md). `TimerQueue`·`IMonotonicTimeSource` 자체 → [packet-buffers.md](packet-buffers.md). JSONL을 화면에 그리는 쪽 → [dashboard.md](dashboard.md). 빌드·CI 전반 → [platform.md](platform.md). + +## 핵심 규칙 + +- **엔진은 텔레메트리를 모른다.** `LibNetworks`는 `protected virtual OnNetwork*`·`OnAccept*` hook만 제공한다. `FastPortTestSmokeServer`와 `FastPortTestSmokeClientSession`이 override해 `IServerTelemetry`로 넘긴다. 엔진에 `LibTestTelemetry` 참조를 넣지 않는다. +- **JSONL 한 줄 = `ObservedMetricsSnapshot` 하나다.** 직렬화는 항상 `ObservedMetricsJson.SerializerOptions`(camelCase)를 쓴다. 서버 줄은 `serverObserved`만, 러너 줄은 `clientObserved`만 채운다(`FromServer`/`FromClient`). 병합 결과(`*.combined.metrics.jsonl`)는 둘 다 채운다(`Combined`). +- **JSONL 계약은 네 곳이 공유한다.** 생산: SmokeServer(`ServerTelemetryExportBackgroundService`), LoadRunner(`JsonMetricsReporter`). 소비: LoadValidation(`JsonlObservedMetricsReader`), Dashboard(`JsonlPollingAdapter`). 필드 이름을 바꾸거나 지우면 네 곳과 테스트를 같이 고친다. 새 필드는 기본값이 있는 선택 인자로 끝에 붙여 옛 JSONL도 읽히게 한다. +- 서버 지표는 3단계로 흐른다: `ServerTelemetryCollector`(누적 카운터) → `ServerTelemetrySnapshot`(시점 값) → `ServerObservedMetricsSnapshot.FromTelemetry`(직전 스냅샷과의 차이로 초당 값 계산). 첫 스냅샷의 `*PerSecond`는 0이다. +- **LoadRunner는 `LibNetworks`를 쓰지 않는다.** `LoadSession`이 `TcpClient`/`NetworkStream`으로 직접 연결하고 와이어 헤더를 손으로 쓴다(`BinaryPrimitives`). 엔진 버그가 측정 도구에 섞이지 않게 하려는 분리다. csproj에 `LibCommons` 참조는 있지만 코드에서 쓰지 않는다. +- SmokeServer echo는 `Protocols`의 `ProtocolId.Tests` + `EchoRequest`/`EchoResponse`(`tests.proto`)를 쓴다. 템플릿 proto(`PacketIds` 1001/1002)와 다르다. +- 끊김 사유 문자열은 `FastPortTestSmokeClientSession.ToTelemetryReason`이 `NetworkDisconnectReason`을 kebab-case(`remote-closed`, `idle-timeout`, `receive-buffer-overflow` 등)로 바꾼다. enum에 값을 추가하면 여기도 추가한다. 빠지면 `unknown`으로 집계된다. +- **idle 정리는 애플리케이션 정책이다.** 엔진이 아니라 SmokeServer의 `SessionIdleTracker`가 `ITimerQueue.SchedulePeriodic`으로 `ScanExpired`를 돌린다. `LastReceivedTimestamp`가 `IdleTimeout`을 넘으면 `RequestDisconnect(NetworkDisconnectReason.IdleTimeout)`을 호출하고 `RecordIdleTimeoutDisconnect`로 센다. 세션은 `OnAccepted`에서 `Register`, `OnDisconnected`에서 `Unregister`한다. +- LoadRunner heartbeat(`--heartbeat-interval`, 기본 30s)는 서버 idle timeout(기본 120s)보다 짧아야 저속 시나리오에서 세션이 정리되지 않는다. +- 판정 임계값은 코드 상수다: `LoadValidationThresholds.Default`(최소 peak 세션 비율 0.95, 최대 소켓 오류율 0.01, 최대 disconnect 비율 0.05, 최종 disconnect 0, `receive|IOException|TimedOut` 0건, 최소 JSON 샘플 3). profile과 stage 목록은 `LoadValidationProfiles`에 하드코딩돼 있다(`smoke`, `staged`). +- 서버·러너 병합은 **타임스탬프 최근접 매칭**이다(`ObservedMetricsMerger`, 기본 허용치 1500ms). 서버와 러너가 다른 머신이면 시계 차이가 매칭 실패 원인이 된다. + +## 파일·타입 + +| 파일 | 타입·심볼 | 내용 | +|---|---|---| +| `tests-projects/FastPortTestSmokeServer/Program.cs` | top-level | Generic Host. 설정 섹션 파싱, `TimerQueue`·`SessionIdleTracker`·`ServerTelemetryCollector`·`ServerTelemetryExporter` 등록 | +| `tests-projects/FastPortTestSmokeServer/FastPortTestSmokeServerOptions.cs` | `FastPortTestSmokeServerConfiguration`, `FastPortTestSmokeServerOptions`, `FastPortTestSmokeServerTelemetryOptions` | 섹션 `FastPortTestSmokeServer`(없으면 구 이름 `FastPortSmokeServer`). 기본 포트 6628, `DefaultListenBacklog` 4096, `DefaultOutstandingAccepts` 1 | +| `tests-projects/FastPortTestSmokeServer/FastPortTestSmokeServer.cs` | `FastPortTestSmokeServer : BaseMessageListener` | accept hook → `RecordAccept`, `accept-session-create`·`accept-task-start` duration | +| `tests-projects/FastPortTestSmokeServer/FastPortTestSmokeServerBackgroundService.cs` | `FastPortTestSmokeServerBackgroundService` | `StartAccept` 호출, 종료 시 `RequestShutdown` | +| `tests-projects/FastPortTestSmokeServer/ServerTelemetryExportBackgroundService.cs` | `ServerTelemetryExportBackgroundService` | `Telemetry:Output`이 있을 때만 주기적으로 JSONL append. `FileShare.ReadWrite` + `WriteThrough` | +| `tests-projects/FastPortTestSmokeServer/Sessions/FastPortTestSmokeClientSession.cs` | `FastPortTestSmokeClientSession : BaseSessionClient, IIdleTrackedSession` | echo 응답, `OnNetwork*` override, accept 경로 지연(`accept-first-receive` 등), `ToTelemetryReason` | +| `tests-projects/FastPortTestSmokeServer/Sessions/FastPortTestSmokeClientSessionFactory.cs` | `FastPortTestSmokeClientSessionFactory : IClientSessionFactory` | 수신·송신 `ArrayPoolCircularBuffers(8 * 1024)` | +| `tests-projects/FastPortTestSmokeServer/Sessions/SessionIdleTracker.cs` | `SessionIdleTracker`, `IIdleTrackedSession`, `SessionIdleTrackerOptions` | idle 정리. 0 이하 설정은 `Normalized*`가 기본값으로 보정 | +| `tests-projects/LibTestTelemetry/ServerTelemetry.cs` | `IServerTelemetry`, `ServerTelemetryCollector`, `ServerTelemetrySnapshot` | 서버 카운터 누적, 소켓 오류 phase/type/code/class 분류, operation duration 요약 | +| `tests-projects/LibTestTelemetry/ObservedMetrics.cs` | `ObservedMetricsSnapshot`, `ServerObservedMetricsSnapshot`, `ClientObservedMetricsSnapshot`, `SessionRttSummarySnapshot`, `IServerTelemetryExporter`, `ServerTelemetryExporter`, `ObservedMetricsJson` | JSONL 계약과 직렬화 옵션 | +| `tests-projects/FastPortTestLoadRunner/LoadRunnerOptions.cs` | `LoadRunnerOptions`, `LoadScenario`, `LoadPacingOptions`, `LoadPacingPolicy`, `PayloadProfile`, `DurationParser` | CLI 파싱·기본값·`PrintUsage` | +| `tests-projects/FastPortTestLoadRunner/LoadRunner.cs`, `LoadSession.cs` | `LoadRunner`, `LoadSession`, `PayloadGenerator` | ramp-up 간격으로 세션 시작, 세션별 송수신·RTT·heartbeat | +| `tests-projects/FastPortTestLoadRunner/OutstandingRequestPacer.cs` | `OutstandingRequestPacer` | fixed/adaptive window pacing | +| `tests-projects/FastPortTestLoadRunner/Metrics.cs` | `MetricsCollector`, `MetricsSnapshot`, `ConsoleMetricsReporter`, `JsonMetricsReporter` | 클라이언트 지표 수집·출력 | +| `tests-projects/FastPortTestLoadRunner/ObservedMetricsExtensions.cs` | `ToClientObservedMetricsSnapshot` | `MetricsSnapshot` → JSONL 계약 매핑 | +| `tests-projects/FastPortTestLoadRunner/ConnectEventReporter.cs` | `JsonConnectEventReporter`, `ConnectSessionEvent` | 세션별 connect 결과 JSONL | +| `tests-projects/FastPortTestLoadValidation/LoadValidationOptions.cs` | `LoadValidationOptions` | CLI 파싱. 기본 출력 `artifacts/load-validation/{timestamp}-{profile}` | +| `tests-projects/FastPortTestLoadValidation/LoadValidationProfile.cs` | `LoadValidationProfiles` | `smoke`(smoke-fixed-10, smoke-random-25), `staged`(s1-fixed-1k ~ s5-random-10k) | +| `tests-projects/FastPortTestLoadValidation/LoadValidationStage.cs` | `LoadValidationStage`, `LoadValidationThresholds`, `LoadValidationStageSummary`, `LoadValidationRunSummary` | stage 정의·임계값·결과 모델 | +| `tests-projects/FastPortTestLoadValidation/LoadRunnerCommandBuilder.cs`, `ProcessRunner.cs` | `LoadRunnerCommandBuilder`, `LoadRunnerCommand`, `ProcessRunner` | `dotnet run --project -- ...` 명령 생성·실행, stdout/stderr 로그 저장 | +| `tests-projects/FastPortTestLoadValidation/JsonlObservedMetricsReader.cs`, `ObservedMetricsMerger.cs` | `JsonlObservedMetricsReader`, `ObservedMetricsMerger` | JSONL 읽기, 서버·클라이언트 샘플 병합 | +| `tests-projects/FastPortTestLoadValidation/LoadValidationEvaluator.cs`, `LoadValidationSummaryWriter.cs` | `LoadValidationEvaluator`, `LoadValidationSummaryWriter` | 판정, `manifest.json`·`summary.json`·`summary.md` 작성 | +| `scripts/cloud/` | `server-start.sh`, `runner-smoke.sh`, `runner-10k.sh`, `runner-connectivity.sh`, `ssh-readiness.sh`, `os-readiness.sh`, `write-manifest.sh`, `collect-artifacts.sh`, `azure-*.sh`, `oci-*.sh`, `free-tier-guard.sh` | 서버 VM·러너 분리 검증 보조. 환경 변수 `FASTPORT_*`로 설정 | +| `scripts/load-validation/decompose-summary.sh` | — | `summary.json`을 `jq`로 분해해 출력 | + +문서(`docs/`): `staged-load-validation-test-guide.md`(smoke·staged 실행과 결과 읽기), `loadrunner-os-limits.md`(10K 세션 OS 한도), `cloud-server-runner-split-load-validation-runbook.md`(OCI), `azure-server-runner-split-load-validation-runbook.md`(Azure), `baselistener-optimization-benchmark.md`(accept 경로 벤치마크 리포트). LoadRunner 옵션 표는 `tests-projects/FastPortTestLoadRunner/README.md`. + +## 작업별 시작점 + +| 하려는 작업 | 고칠 곳 | 같이 확인할 것 | +|---|---|---| +| 새 서버 텔레메트리 지표 추가 | `IServerTelemetry` 메서드 → `ServerTelemetryCollector` → `ServerTelemetrySnapshot` → `ServerObservedMetricsSnapshot`(끝에 기본값 인자) + `FromTelemetry` | 값을 넣는 hook override(`FastPortTestSmokeClientSession` 또는 `FastPortTestSmokeServer`), `ServerTelemetryTests`·`ObservedMetricsTests`, 판정에 쓰면 `LoadValidationEvaluator`, 화면에 쓰면 [dashboard.md](dashboard.md) | +| 엔진 hook 추가 후 계측 연결 | `LibNetworks`에 hook 추가([session.md](session.md)) → SmokeServer 세션 override | 엔진 수정은 scaffold golden 갱신 대상([platform.md](platform.md)) | +| 새 클라이언트 지표 추가 | `MetricsCollector`·`MetricsSnapshot` → `ClientObservedMetricsSnapshot` → `ToClientObservedMetricsSnapshot` | `ObservedMetricsTests.ClientObservedMetricsSnapshot_MapsLoadRunnerMetrics`, `FastPortTestLoadRunnerTests` | +| LoadRunner 옵션 추가 | `LoadRunnerOptions.TryParse`의 `switch`·`PrintUsage`, `LoadScenario` | LoadValidation이 넘겨야 하면 `LoadValidationOptions` + `LoadRunnerCommandBuilder.Build`, README 옵션 표, 두 Tests 파일 | +| 검증 임계값 바꾸기 | `LoadValidationThresholds.Default` 또는 stage별 `Thresholds` 인자 | 판정 로직 `LoadValidationEvaluator.Evaluate`, `staged-load-validation-test-guide.md`의 Pass/Fail 기준 | +| stage·profile 추가 | `LoadValidationProfiles`(`CreateSmokeProfile`/`CreateStagedProfile`, `IsKnownProfile`) | `LoadValidationProfiles_StagedProfile_HasExpectedStages`, cloud 스크립트의 `--stage` 값 | +| 끊김 사유 집계가 `unknown` | `FastPortTestSmokeClientSession.ToTelemetryReason` | `NetworkDisconnectReason` 값 목록 | +| idle 정리 동작 변경 | `SessionIdleTracker`, `SessionIdleTrackerOptions`, `Program.cs`의 `SessionIdleCleanup` 파싱 | `SessionIdleTrackerTests`, LoadRunner heartbeat 간격 | +| JSONL이 비어 있음 | `Telemetry:Output` 설정 여부, `ServerTelemetryExportBackgroundService` | 리더 쪽 `FileShare` 모드 | +| 병합 매칭 0건 | `--merge-tolerance-ms`, `ObservedMetricsMerger` | 서버·러너 시계 동기화 | + +## 실행·테스트 + +```bash +# 스모크 서버 (JSONL 출력은 Telemetry:Output을 줘야 켜진다) +dotnet run -c Release --project tests-projects/FastPortTestSmokeServer -- \ + --Telemetry:Output=artifacts/load-validation/local/server.metrics.jsonl --Telemetry:IntervalSeconds=1 + +# 부하 생성기 단독 +dotnet run -c Release --project tests-projects/FastPortTestLoadRunner -- \ + --host 127.0.0.1 --port 6628 --sessions 1000 --payload random:4096-16384 \ + --ramp-up 60s --duration 3m --output client.metrics.jsonl + +# 단계별 검증 (실행 없이 LoadRunner 명령만 보려면 --dry-run 추가) +dotnet run -c Release --project tests-projects/FastPortTestLoadValidation -- \ + --profile staged --stage s1-fixed-1k --runner-project tests-projects/FastPortTestLoadRunner \ + --server-metrics artifacts/load-validation/local/server.metrics.jsonl --output artifacts/load-validation/s1-local +``` + +- SmokeServer 설정(`appsettings.json`): `FastPortTestSmokeServer:Host/Port/ListenBacklog/OutstandingAccepts`, `SessionIdleCleanup:Enabled/IdleTimeoutSeconds/ScanIntervalSeconds`. `Telemetry:Output/IntervalSeconds`는 appsettings에 없고 명령줄이나 환경 변수(`Telemetry__Output`)로 준다. +- LoadRunner 옵션 전체: `--host --port --sessions --payload(fixed:|random:-) --rate --ramp-up --duration --metrics-interval --output --connect-events-output --heartbeat-interval(none 가능) --max-pending-requests-per-session(구 옵션, fixed-window로 매핑) --pacing-policy(none|fixed-window|adaptive-window) --pacing-fixed-window --pacing-min-window --pacing-initial-window --pacing-max-window --pacing-rtt-target-ms --pacing-rtt-high-ms --pacing-increase-every`. 종료 코드 0 정상, 1 오류, 130 취소. +- LoadValidation 옵션 전체: `--profile(smoke|staged) --host --port --output --stage --runner-project --configuration --server-metrics --merge-tolerance-ms --dry-run --continue-on-failure --runner-no-build` + LoadRunner와 같은 pacing 옵션. 종료 코드 0 통과, 2 판정 실패, 1 인자 오류. +- 출력 디렉터리: `manifest.json`, `summary.json`, `summary.md`, stage별 `{id}.metrics.jsonl`, `{id}.connect-events.jsonl`, `{id}.stdout.log`, `{id}.stderr.log`, `--server-metrics` 사용 시 `{id}.combined.metrics.jsonl`. `artifacts/load-validation/`은 `.gitignore` 대상이다. +- 테스트(`tests-projects/FastPortTests/`, `dotnet test FastPortSharp.sln -c Release`): `FastPortTestSmokeServerTests`(실제 loopback echo + 텔레메트리), `SessionIdleTrackerTests`, `ServerTelemetryTests`, `ObservedMetricsTests`, `FastPortTestLoadRunnerTests`, `FastPortTestLoadValidationTests`. LoadRunner·LoadValidation은 `InternalsVisibleTo("FastPortTests")`로 internal 타입을 연다. + +## 주의 + +- `--runner-project` 기본값 `FastPortTestLoadRunner`는 현재 작업 디렉터리 기준 상대 경로다. 프로젝트는 `tests-projects/` 아래에 있으므로 저장소 루트에서 실행하면 `--runner-project tests-projects/FastPortTestLoadRunner`를 넘긴다. `scripts/cloud/runner-smoke.sh`·`runner-10k.sh`는 이 옵션을 넘기지 않는다. +- `tests-projects/FastPortTestLoadRunner/README.md` 예시의 `--project FastPortTestLoadRunner`도 같은 이유로 루트에서는 `tests-projects/FastPortTestLoadRunner`로 바꿔 실행한다. README 옵션 표에는 pacing 옵션이 빠져 있으니 `LoadRunnerOptions.PrintUsage`를 기준으로 본다. +- 10K 세션은 OS 파일 디스크립터·ephemeral port 한도에 걸린다. 실행 전에 `docs/loadrunner-os-limits.md`를 확인한다. full staged 검증은 기본 `dotnet test`에 넣지 않는다. +- SmokeServer의 `LatencyStats`는 static 단일 인스턴스이고 콘솔 출력이 켜져 있다(`EnableConsoleOutput = true`). +- 클라우드 결과에서 러너 CPU나 로컬 네트워크가 먼저 포화되면 순수 서버 벤치마크로 해석하지 않는다(cloud runbook의 Result Interpretation). +- `cloud-server-runner-split-load-validation-runbook.md`가 언급하는 `docs/load-validation-benchmark-results.md`는 저장소에 없다. +- 용어(세션 Client/Server 명명 등)는 [glossary.md](glossary.md), 전체 지도는 [README.md](README.md). diff --git a/docs/llm/packet-buffers.md b/docs/llm/packet-buffers.md new file mode 100644 index 0000000..1e2668d --- /dev/null +++ b/docs/llm/packet-buffers.md @@ -0,0 +1,83 @@ +# 패킷·버퍼 (와이어 포맷, `BasePacket`, `IBuffers`, 링버퍼, packetId 파싱, 지연 통계, 타이머) + +`LibCommons`는 엔진이 쓰는 바이트 단위 기반 코드다. 수신 바이트를 모아 패킷으로 자르는 링버퍼, 패킷 객체, ID 생성, 지연 통계, 타이머 큐가 있다. `LibNetworks/Extensions/BasePacket+Extensions.cs`는 패킷 payload에서 packetId와 protobuf 메시지를 꺼낸다. + +**이 문서를 읽는 경우**: 패킷 포맷, 길이 헤더, packetId, protobuf 파싱 실패, 패킷 최대 크기, 수신 버퍼(링버퍼) 동작·확장, ArrayPool 대여·반환, `IBuffers` 새 구현, 세션 ID 발급, RTT·서버 처리 시간 통계, 타이머·주기 작업, 단조 시계, 엔진 샘플 proto(`commons.proto`, `tests.proto`). + +**다른 문서로 가는 경우**: 버퍼를 언제 쓰고 언제 끊는지(수신 상한, 잘못된 헤더 disconnect, 송신 큐) → [session.md](session.md). 템플릿 proto(`PacketIds.proto`, `Sample.proto`)와 패킷 추가 절차 → [game-server-template.md](game-server-template.md). `LatencyStats`를 쓰는 샘플 클라이언트·서버 → [sample-apps.md](sample-apps.md). `TimerQueue`를 쓰는 idle 정리 → [load-testing.md](load-testing.md). 용어 → [glossary.md](glossary.md). + +## 핵심 규칙 + +- **와이어 포맷은 `[UInt16 LE 전체 길이][int32 LE packetId][protobuf payload]`다.** 길이 필드는 헤더 2바이트를 포함한 전체 길이다. 송신 쪽은 `BaseSession.TryRequestSendBuffers`가, 수신 쪽은 `ArrayPoolCircularBuffers.GetPacketSizeInBuffersCore`가 같은 규약을 쓴다. 한쪽만 바꾸면 안 된다. +- **헤더 크기는 `BasePacket.HeaderSize` = 2 한 곳에서 정한다.** 하드코딩된 `2`를 새로 만들지 않는다. +- 패킷 최대 크기는 `ushort.MaxValue`(65,535B)다. packetId 4바이트를 빼면 protobuf payload는 최대 65,529B다. 초과하면 `TryRequestSendBuffers`가 `false`를 돌려준다. +- `BasePacket`은 헤더를 뺀 나머지(packetId + protobuf)를 **새 `byte[]`로 복사**해 가진다. 수신 버퍼가 반환·재사용돼도 `BasePacket.Data`는 안전하다. +- `BasePacket.Data`는 packetId 4바이트로 시작한다. protobuf만 원하면 `ParseMessageFromPacket`를 쓴다. `DataSize < 4`면 `false`를 돌려준다. +- **`ParseMessageFromPacket`는 protobuf 예외를 잡지 않는다.** `MergeFrom`이 던진 예외는 `OnReceived`로 올라가고, 세션이 `PacketHandlerError`로 끊긴다([session.md](session.md)). +- **실사용 `IBuffers` 구현은 `ArrayPoolCircularBuffers` 하나다.** 모든 세션 팩토리가 `new ArrayPoolCircularBuffers(8 * 1024)` 또는 `BufferCapacityBytes`로 만든다. `BaseCircularBuffers`, `BaseQueueBuffers`는 테스트·비교용 레거시다. +- `ArrayPoolCircularBuffers`는 모든 public 메서드를 `System.Threading.Lock`으로 보호한다. 수신 콜백(쓰기)과 parser task(읽기)가 동시에 불러도 된다. +- `ArrayPoolCircularBuffers`는 공간이 모자라면 2배씩 커진다(`ExpandBuffer`, `GrowCapacity`). **버퍼 자체에는 상한이 없다.** 상한은 `BaseSession.MaxReceiveBufferedBytes`가 쓰기 전에 검사한다. +- **ArrayPool 소유권**: `GetPacketBuffers`가 돌려준 배열은 호출자가 `ArrayPoolCircularBuffers.ReturnBuffer`로 반환한다. `TryGetBasePackets`는 내부에서 빌린 배열을 `finally`에서 반환한다. 대여 배열은 요청 크기보다 클 수 있으므로 반환값(실제 길이)만 읽는다. `Dispose`는 내부 배열을 풀에 돌려준다. 이후 호출은 `ObjectDisposedException`이다. +- `TryGetBasePackets`는 완성된 패킷만 꺼낸다. 길이 필드가 `HeaderSize`보다 작으면 거기서 멈추고 바이트를 남긴다. 잘못된 헤더 판정과 disconnect는 세션(`HasInvalidPacketHeader`)이 한다. +- `IBuffers.Peek(ref byte[])`는 데이터를 지우지 않는다. 구현이 `ref` 배열을 바꿀 수 있다고 가정하고 호출한다. +- `IDGenerator.GetNextGeneratedId()`는 `Interlocked.Increment` 기반이다. 인스턴스마다 카운터가 따로라서 세션 클래스가 `static readonly`로 하나를 공유한다(`FastPortClientSession`, `GameSession`, `FastPortTestSmokeClientSession`). +- `TimerQueue`는 worker task 하나에서 콜백을 순서대로 실행한다. 콜백은 짧아야 한다. 콜백 예외는 삼키고 `FailedCallbackCount`만 올린다. 주기 타이머는 콜백이 끝난 시각 기준으로 다음 due를 잡는다. +- 타이머 시간은 `IMonotonicTimeSource`(기본 `StopwatchMonotonicTimeSource.Instance`)로 계산한다. 테스트는 가짜 time source를 생성자에 넣는다. + +## 파일·타입 + +| 파일 | 타입·심볼 | 내용 | +|---|---|---| +| `LibCommons/BasePacket.cs` | `BasePacket`, `HeaderSize`, `PacketSize`, `DataSize`, `Data` | 길이 헤더를 뺀 payload 복사본 | +| `LibCommons/IBuffers.cs` | `IBuffers` | `CanReadSize`, `CanWriteSize`, `Write`, `Peek`, `Drain`, `TryGetBasePackets` | +| `LibCommons/ArrayPoolCircularBuffers.cs` | `ArrayPoolCircularBuffers` | 기본 구현. span `Write`/`Peek` 오버로드, `GetPacketBuffers`, `ReturnBuffer`, `GetPacketSizeInBuffers` | +| `LibCommons/BaseCircularBuffers.cs` | `BaseCircularBuffers` | 레거시 링버퍼. 헤더만 있는 패킷(길이 2)을 꺼내지 못한다 | +| `LibCommons/BaseQueueBuffers.cs` | `BaseQueueBuffers` | 레거시 `Queue` 구현. `TryGetBasePackets`가 패킷을 채우지 않는다 | +| `LibCommons/IDGenerator.cs` | `IDGenerator` | `GetNextGeneratedId`, `GetNextGeneratedGuid` | +| `LibCommons/LatencyStats.cs` | `LatencyStats`, `LatencyStatsOptions`, `LatencySample`, `LatencyCalculation`, `LatencyStatsSummary` | T1~T4 기반 RTT·서버 처리·네트워크 지연, p50/p95/p99, 파일 출력 | +| `LibCommons/Timers/ITimerQueue.cs` | `ITimerQueue`, `ITimerQueueHandle` | `Schedule`, `SchedulePeriodic`, `Cancel` | +| `LibCommons/Timers/TimerQueue.cs` | `TimerQueue` | `PriorityQueue` min-heap + `SemaphoreSlim` worker. `ExecutedCallbackCount`, `FailedCallbackCount` | +| `LibCommons/Timers/TimerQueueOptions.cs` | `TimerQueueOptions` | `MaxCallbacksPerWake`(기본 1024) 후 `Task.Yield` | +| `LibCommons/Timers/IMonotonicTimeSource.cs` | `IMonotonicTimeSource`, `StopwatchMonotonicTimeSource` | Stopwatch 기반 단조 시계 | +| `LibNetworks/Extensions/BasePacket+Extensions.cs` | `BasePacketExtensions.ParseMessageFromPacket` | payload 앞 int32 LE packetId + 나머지 protobuf `MergeFrom` | +| `Protocols/Protos/commons.proto` | `ProtocolId`, `ResultCode`, `Empty`, `Header` | C# 네임스페이스 `FastPort.Protocols.Commons`. `Header`에 T1~T3 Stopwatch tick | +| `Protocols/Protos/tests.proto` | `PingRequest/Response`, `EchoRequest/Response`, `ErrorResponse`, `TestService` | C# 네임스페이스 `FastPort.Protocols.Tests` | + +엔진 샘플(`FastPortClient`, SmokeServer)은 `ProtocolId` 값을 packetId로 보낸다(`(int)protocolId`). 템플릿은 `Protocols`를 쓰지 않는다. + +## 작업별 시작점 + +| 하려는 작업 | 고칠 곳 | 같이 확인할 것 | +|---|---|---| +| 길이 헤더를 4바이트로 바꾸기 | `BasePacket.HeaderSize`, `ArrayPoolCircularBuffers.GetPacketSizeInBuffersCore`, `BaseSession.TryRequestSendBuffers`(`WriteUInt16LittleEndian`, `ushort.MaxValue`), `BaseSession.HasInvalidPacketHeader` | `FastPortTestLoadRunner/LoadSession.cs`의 자체 인코딩·디코딩, 템플릿 `PacketDispatcher.TryReadPacketId`, `BaseSessionReceivePolicyTests.BuildPacket`, scaffold golden | +| packetId 파싱 실패 처리 | `BasePacketExtensions.ParseMessageFromPacket` | 호출부 `FastPortServerSession.OnReceived`(FastPortClient), `FastPortTestSmokeClientSession.OnReceived`. 템플릿은 `PacketDispatcher.TryReadPacketId`로 따로 읽는다 | +| 수신 버퍼 초기 용량 바꾸기 | 각 세션 팩토리의 `new ArrayPoolCircularBuffers(...)` / `BufferCapacityBytes` | 상한은 따로다 → `BaseSession.MaxReceiveBufferedBytes` | +| 새 `IBuffers` 구현 추가 | `IBuffers` 구현 클래스 | `IBuffersInterfaceTest`의 `DataRow`에 타입 추가, `TryGetBasePackets`가 잘못된 헤더에서 멈추는지 | +| 링버퍼 버그 수정 | `ArrayPoolCircularBuffers.WriteInternal`/`ReadInternal`/`ExpandBuffer`/`DrainCore` | `ArrayPoolCircularBufferTest`의 wrap-around·확장 케이스 | +| 지연 통계 항목 추가 | `LatencySample.Calculate`, `LatencyStatsSummary`, `LatencyStats.GetSummary` | `GetSummaryString`, `GetSummaryJson`, `FastPortClient/appsettings.json`의 `LatencyStats` 섹션 | +| 주기 작업 추가 | `ITimerQueue.SchedulePeriodic` 호출, 반환 handle 보관 후 `Dispose` | DI 등록 예: `FastPortTestSmokeServer/Program.cs`(`TimerQueue` singleton) | +| 엔진 샘플 메시지 추가 | `Protocols/Protos/tests.proto` (`ProtocolId`가 필요하면 `commons.proto`) | `Protocols.csproj`의 ``로 자동 생성된다. 사용처 SmokeServer·LoadRunner·FastPortClient | + +## 테스트 + +- `tests-projects/FastPortTests/BasePacketTest.cs`: `HeaderSize`, payload 복사, 헤더만 있는 패킷. +- `ArrayPoolCircularBufferTest.cs`: 쓰기·Peek·Drain·확장·wrap-around·`TryGetBasePackets`·Dispose. +- `IBuffersInterfaceTest.cs`: 세 구현 공통 계약. `TryGetBasePackets` 케이스는 `BaseQueueBuffers`를 뺀다. +- `CircularBufferTest.cs`, `QueueBufferTest.cs`: 레거시 구현. +- `IDGeneratorTest.cs`: 증가·동시성·인스턴스 독립. +- `TimerQueueTests.cs`: due 순서, 취소, 주기, 콜백 예외, Dispose. +- `ParseMessageFromPacket`과 `LatencyStats` 전용 테스트는 없다. 수신 경로 통합 테스트(`BaseSessionReceivePolicyTests`)가 와이어 포맷을 간접 확인한다. + +```bash +dotnet test tests-projects/FastPortTests -c Release --filter "FullyQualifiedName~ArrayPoolCircularBufferTest" +dotnet test tests-projects/FastPortTests -c Release --filter "FullyQualifiedName~TimerQueueTests" +``` + +## 주의 + +- **scaffold golden**: `LibCommons/**`와 `LibNetworks/**`는 scaffold가 그대로 복사한다. 주석 한 줄만 바꿔도 `tests/scaffold/run.sh --update-golden case-01-simple` 후 `tests/scaffold/run.sh` 전체 통과가 필요하다. `Protocols/Protos/`는 scaffold 대상이 아니다. +- `LibCommons/LatencyStats.cs`는 UTF-8이 아니다(CP949 계열). 한글 주석이 깨져 보인다. 편집기가 인코딩을 바꿔 저장하면 diff 전체가 바뀌고 golden hash도 바뀐다. 테스트의 `IBuffersInterfaceTest.cs`, `BasePacketTest.cs`, `IDGeneratorTest.cs`도 같은 상태다. +- `LatencyStatsOptions.EnableConsoleOutput` 기본값은 `true`다. 샘플마다 Information 로그를 남기므로 부하 측정에서는 끈다. +- `Header`의 T1~T3은 각 머신의 `Stopwatch` tick이다. `ServerProcessingMs`(T3−T2)와 RTT(T4−T1)만 같은 시계끼리 뺀다. `NetworkLatencyMs`는 RTT − 서버 처리이며 0 미만은 0으로 자른다. +- `BaseQueueBuffers.TryGetBasePackets`는 바이트를 소비하지만 `basePackets`에 넣지 않는다. 세션에 넣으면 패킷이 사라진다. +- `ArrayPoolCircularBuffers.CanWriteSize`는 다음 확장 전까지 남은 논리 용량이다. 쓰기 가능 상한이 아니다. diff --git a/docs/llm/platform.md b/docs/llm/platform.md new file mode 100644 index 0000000..794c112 --- /dev/null +++ b/docs/llm/platform.md @@ -0,0 +1,65 @@ +# 공통 기반 (빌드·테스트·CI·브랜치·개발 환경) + +여러 도메인이 함께 쓰는 빌드·테스트 명령, CI, 브랜치 규칙, 개발 환경을 모았다. 코드 스타일과 커밋 규칙은 루트 `AGENTS.md`에 있다. + +**이 문서를 읽는 경우**: 빌드·테스트 실행, 테스트 하나만 돌리기, CI 실패, 워크플로 수정, 필수 체크, PR 머지 조건, 릴리스 승격, 로컬 개발 환경 준비, 줄바꿈(LF/CRLF) 문제, 솔루션 구성. + +**다른 문서로 가는 경우**: scaffold 스크립트·golden 테스트 → [game-server-template.md](game-server-template.md). 대시보드 MAUI 빌드 → [dashboard.md](dashboard.md). 부하 검증 실행 → [load-testing.md](load-testing.md). + +## 핵심 규칙 + +- **`main`에는 직접 push하지 않는다.** PR + 필수 체크 3개(`build (ubuntu-latest)`, `build (macos-latest)`, `build (windows-latest)`) 통과 후 merge commit으로 머지한다. +- **job `name`을 바꾸면 ruleset 필수 체크 이름도 바꾼다.** 이름이 안 맞으면 체크가 영원히 대기 상태가 되어 PR이 머지되지 않는다. ruleset은 GitHub 저장소 설정(Rules → Rulesets → `main-protection`)에서 저장소 소유자가 고친다. +- 릴리스 브랜치는 `builds/release`다. `main` → `builds/release` PR로 승격한다. `build.yml`, `dashboard.yml`이 두 브랜치를 모두 감시한다. +- **줄바꿈은 LF다.** `.gitattributes`가 `*.sln`만 CRLF로, 나머지 텍스트는 LF로 고정한다. scaffold 결과를 OS 사이에 바이트 단위로 비교하기 때문이다. Windows CI는 checkout 전에 `core.autocrlf false`를 설정한다. +- `FastPortSharp.sln`에는 대시보드 프로젝트가 없다. 대시보드는 `FastPortSharp.Dashboard.sln`으로 빌드한다. + +## 솔루션·프로젝트 공통 설정 + +| 항목 | 값 | 위치 | +|---|---|---| +| Target framework | `net10.0` | 각 `*.csproj` (`Directory.Build.props` 없음) | +| Nullable / ImplicitUsings | `enable` / `enable` | 각 `*.csproj` | +| 테스트 | MSTest, `FastPortTests`는 메서드 단위 병렬 | `tests-projects/FastPortTests/MSTestSettings.cs` | +| proto 코드 생성 | `` 빌드 항목(`Protocols`는 `Grpc.AspNetCore`, 템플릿·SampleClient·Dashboard.Core는 `Grpc.Tools`) | `Protocols/Protocols.csproj`, 템플릿·SampleClient·`FastPortDashboard.Core` csproj | +| 포맷터·분석기 설정 | 없음(`.editorconfig` 없음). 스타일은 `AGENTS.md` 규칙과 리뷰로 지킨다 | — | + +## 빌드·테스트 + +| 목적 | 명령 | +|---|---| +| 엔진·도구 빌드 | `dotnet build FastPortSharp.sln -c Release` | +| 엔진·도구 테스트 전체 | `dotnet test FastPortSharp.sln -c Release` | +| 테스트 클래스 하나 | `dotnet test tests-projects/FastPortTests -c Release --filter "FullyQualifiedName~BaseSessionReceivePolicyTests"` | +| 테스트 메서드 하나 | `dotnet test tests-projects/FastPortTests -c Release --filter "Name=BaseSession_InvalidPacketHeaderOnly_DisconnectsWithoutDelivery"` | +| 대시보드 Core 테스트 | `dotnet test tests-projects/FastPortDashboardTests -c Release` (MAUI workload 불필요) | +| scaffold golden 테스트 | `tests/scaffold/run.sh` (PowerShell 버전 검사: `--script ps1`) | +| golden 갱신 | `tests/scaffold/run.sh --update-golden case-01-simple` | + +- 엔진(`LibCommons`, `LibNetworks`)을 고쳤으면 `FastPortSharp.sln` 전체 테스트와 scaffold 테스트를 모두 돌린다. 엔진은 템플릿·SmokeServer·Dashboard가 함께 쓴다. +- 소켓 테스트는 loopback 포트 0을 쓴다. 고정 포트를 쓰면 병렬 실행에서 충돌한다. + +## CI 워크플로 + +| 워크플로 | 트리거 | job 이름 | 내용 | +|---|---|---|---| +| `.github/workflows/build.yml` | `main`, `builds/release` push·PR, 수동 | `build (ubuntu-latest)`, `build (macos-latest)`, `build (windows-latest)` | `FastPortSharp.sln` restore → Release build → test. **main 필수 체크** | +| `.github/workflows/dashboard.yml` | 위 두 브랜치 + 대시보드·`LibTestTelemetry`·엔진·`template-projects/Protos`·`FastPortSharp.Dashboard.sln` 경로 변경 | `dashboard (macos-latest)`, `dashboard (windows-latest)` | MAUI workload 설치(`--version 10.0.401` 고정) → restore(`-p:Configuration=Release`) → build → test | +| `.github/workflows/scaffold.yml` | `main` push·모든 PR 중 scaffold 스크립트·`tests/scaffold`·템플릿·`template-projects/Protos`·엔진·`.gitattributes` 경로 변경, 수동 | ` / `, `cross-OS byte-identical compare` | 3개 OS × sh/ps1로 scaffold 케이스 실행 후 결과 sha256을 OS 사이에 비교 | + +- CI 실패를 볼 때는 먼저 base 브랜치(`main`)에서도 같은 job이 실패하는지 확인한다. +- `dashboard` job의 MAUI workload 버전을 올리면 runner의 Xcode가 요구하는 MacCatalyst SDK를 지원하는지 확인한다 → [dashboard.md](dashboard.md). +- scaffold windows/ps1 job은 오래 걸린다(관측값 약 16분). + +## 개발 환경 + +- .NET SDK 10이 필요하다(CI는 `actions/setup-dotnet`의 `10.0.x`). +- scaffold ps1 검사를 Linux·macOS에서 돌리려면 PowerShell 7(`pwsh`)이 필요하다. 전역 도구로 설치할 수 있다: `dotnet tool install --global PowerShell`. +- 대시보드 빌드는 macOS 또는 Windows와 MAUI workload가 필요하다. +- macOS는 `/var`가 `/private/var`의 symlink다. 절대 경로로 sln을 빌드하면 같은 프로젝트를 두 경로로 restore해 충돌할 수 있다. scaffold smoke build가 대상 폴더로 이동한 뒤 상대 경로로 빌드하는 이유다. +- Claude Code 프로젝트 설정은 `.claude/settings.json`이다. `superpowers@claude-plugins-official` 플러그인을 켜고 공식 마켓플레이스를 등록한다. + +## 읽지 않아도 되는 곳 + +- `.vscode/`: 개인 편집기 설정. +- `FastPortSharp.sln`의 "솔루션 항목"에 있는 `docs\latency-*.md`, `docs\baseline-benchmark-results.md`는 이미 지워진 파일을 가리킨다. 열 필요 없다. diff --git a/docs/llm/sample-apps.md b/docs/llm/sample-apps.md new file mode 100644 index 0000000..2f3b53a --- /dev/null +++ b/docs/llm/sample-apps.md @@ -0,0 +1,90 @@ +# 엔진 샘플 앱 (FastPortServer, FastPortClient, Protocols, appsettings, Windows 서비스) + +엔진(`LibCommons` + `LibNetworks`)을 최소한으로 쓰는 예시 서버·클라이언트와, 엔진 샘플·테스트 도구가 공유하는 proto 프로젝트다. 실제 게임 서버 출발점은 템플릿이고, 이 앱들은 엔진 사용 예와 지연 측정 실험용이다. + +**이 문서를 읽는 경우**: 샘플 서버·샘플 클라이언트 실행, 엔진 사용 예, 리스너·커넥터를 Generic Host에 붙이는 방법, `FastPortServer` 포트·호스트 설정, Windows 서비스 등록, `appsettings.json`의 `Logging`·`LatencyStats`, 클라이언트 RTT 측정, `Protocols/Protos/` 프로토콜 정의(`ProtocolId`, `ResultCode`, `Header`, Ping·Echo·Error). + +**다른 문서로 가는 경우**: 새 게임 서버를 만들거나 패킷 핸들러 구조가 필요함 → [game-server-template.md](game-server-template.md)(템플릿은 `Protocols`가 아니라 `template-projects/Protos/`를 쓴다). 리스너·커넥터 내부 → [listener-connector.md](listener-connector.md). 세션 송수신 → [session.md](session.md). `LatencyStats`·`BasePacket` → [packet-buffers.md](packet-buffers.md). 계측 echo 서버·부하 → [load-testing.md](load-testing.md). + +## 핵심 규칙 + +- **`FastPortServer`는 echo하지 않는다.** `FastPortClientSession.OnReceived`는 로그만 남긴다. `FastPortServer.csproj`는 `Protocols`를 참조하지 않아 proto를 해석하지도 않는다. +- **`FastPortClient`의 echo 왕복 상대는 `FastPortTestSmokeServer`다.** 둘 다 기본 포트 6628이다. `FastPortClient`를 `FastPortServer`에 붙이면 첫 `EchoRequest`를 보낸 뒤 응답이 오지 않아 멈춘다. +- `FastPortClient` 접속 대상은 `FastPortClientBackgroundService.ExecuteAsync`의 `StartConnect("127.0.0.1", 6628, 1)`로 하드코딩돼 있다. 설정으로 바꿀 수 없다. +- `FastPortServer` 주소는 `Program.cs`가 `FastPortServer` 설정 섹션의 `Host`·`Port`를 읽어 `FastPortServerOptions`로 등록한다. 기본값은 `0.0.0.0`, `6628`이다. 배포된 `appsettings.json`에는 이 섹션이 없어 기본값이 쓰인다. `Host`는 IP 문자열이어야 한다(`AddressConverter`가 DNS를 안 함). +- 서버 수명: `FastPortServerBackgroundService.ExecuteAsync` → `StartAccept(Host, Port)`(backlog 기본값) → 1초 `Task.Delay` 루프. `StopAsync` → `RequestShutdown()`. accept된 세션은 따로 닫지 않는다. +- **Windows 서비스는 패키지 참조만 있다.** `FastPortServer.csproj`가 `Microsoft.Extensions.Hosting.WindowsServices`를 참조하지만 `Program.cs`에 `UseWindowsService()` 호출이 없다. 서비스로 돌리려면 `Host.CreateDefaultBuilder(args)` 다음에 추가해야 한다. +- **서버 `appsettings.json`의 `LatencyStats` 섹션은 읽히지 않는다.** 서버 코드에 `LatencyStats` 사용처가 없다. 클라이언트만 `LatencyStats` 섹션을 `LatencyStatsOptions`로 바인딩한다. +- 클라이언트 지연 측정: `Program.cs`가 `LatencyStats` 싱글톤을 만들고 `FastPortServerSession.ConfigureLatencyStats`로 static 필드에 넣는다. Ctrl+C(`Console.CancelKeyPress`)·`ProcessExit`에서 `PrintLatencyStats`·`SaveLatencyStatsAsync`를 호출한다. +- echo 루프: `OnConnected` → `SendEchoRequest(1)`. 응답마다 `OnReceived`가 `ParseMessageFromPacket` → `RecordSample(T1~T4)` → 다음 `requestId`로 재전송한다. 동시에 1개 요청만 날아간다. +- 패킷 ID는 `(int)ProtocolId.Tests`(=1) 하나다. Echo·Ping 구분은 packetId가 아니라 메시지 타입으로 한다. 서버 쪽은 `FastPortTestSmokeClientSession`이 `ProtocolId.Tests`가 아니면 프로토콜 오류로 기록한다. +- 세션 버퍼: 두 팩토리 모두 `new ArrayPoolCircularBuffers(8 * 1024)`를 수신·송신용으로 하나씩 넘긴다. 송신용 `IBuffers`는 엔진이 쓰지 않는다 → [session.md](session.md). +- 세션 ID: `FastPortClientSession`은 static `IDGenerator`로 `Id`를 만든다. + +## 파일·타입 + +| 파일 | 타입·심볼 | 내용 | +|---|---|---| +| `FastPortServer/Program.cs` | top-level | `Host.CreateDefaultBuilder(args)`, `FastPortServer` 섹션 → `FastPortServerOptions`, DI: `FastPortServerBackgroundService`, `IClientSessionFactory`→`FastPortClientSessionFactory`, `FastPortServer` | +| `FastPortServer/FastPortServer.cs` | `FastPortServer : BaseMessageListener` | 본문 없는 리스너 | +| `FastPortServer/FastPortServerOptions.cs` | `FastPortServerOptions` | `Host`(`0.0.0.0`), `Port`(6628) | +| `FastPortServer/FastPortServerBackgroundService.cs` | `FastPortServerBackgroundService : BackgroundService` | `StartAccept` / `RequestShutdown` 호출 | +| `FastPortServer/Sessions/FastPortClientSession.cs` | `FastPortClientSession : BaseSessionClient` | `OnReceived`·`OnAccepted`·`OnDisconnected` 로그만 | +| `FastPortServer/Sessions/FastPortClientSessionFactory.cs` | `FastPortClientSessionFactory : IClientSessionFactory` | 세션 + 8KB 버퍼 생성 | +| `FastPortServer/Sessions/FastPortClientSessionManager.cs` | `FastPortClientSessionManager` | 빈 stub. 어디에서도 쓰지 않는다 | +| `FastPortServer/appsettings.json` | `Logging`, `LatencyStats` | `LatencyStats`는 미사용 | +| `FastPortClient/Program.cs` | top-level | `LatencyStats` 바인딩·싱글톤, DI: `FastPortClientBackgroundService`, `IServerSessionFactory`→`FastPortServerSessionFactory`, `FastPortConnector`(Transient), 종료 시 통계 출력 | +| `FastPortClient/FastPortConnector.cs` | `FastPortConnector : BaseConnector` | 생성자만 있음 | +| `FastPortClient/FastPortClientBackgroundService.cs` | `FastPortClientBackgroundService` | `StartConnect("127.0.0.1", 6628, 1)`, `StopAsync`에서 `RequestDisconnect()` | +| `FastPortClient/Sessions/FastPortServerSession.cs` | `FastPortServerSession : BaseSessionServer` | `SendMessage`, `SendEchoRequest`, `ConfigureLatencyStats`, `PrintLatencyStats`, `SaveLatencyStatsAsync` | +| `FastPortClient/Sessions/FastPortServerSessionFactory.cs` | `FastPortServerSessionFactory : IServerSessionFactory` | 세션 + 8KB 버퍼 생성 | +| `FastPortClient/appsettings.json` | `Logging`, `LatencyStats` | `OutputDirectory: "Stats"`, `OutputFilePrefix: "latency_stats"` | +| `Protocols/Protocols.csproj` | `` | 빌드 시 C# 생성. `Google.Protobuf`, `Grpc.AspNetCore` 참조 | +| `Protocols/Protos/commons.proto` | `ProtocolId`(`Tests`=1), `ResultCode`(`Ok`, `Error`), `Empty`, `Header` | C# 네임스페이스 `FastPort.Protocols.Commons`. `Header`는 `request_id`와 T1~T3 타임스탬프(Stopwatch ticks) | +| `Protocols/Protos/tests.proto` | `PingRequest/Response`, `EchoRequest/Response`, `ErrorResponse`, `service TestService` | C# 네임스페이스 `FastPort.Protocols.Tests`. Ping·Error·`TestService`는 코드에서 쓰지 않는다 | + +`Protocols` 참조 프로젝트: `FastPortClient`, `FastPortTestSmokeServer`, `FastPortTestLoadRunner`. proto 필드를 바꾸면 세 곳 모두 확인한다. + +## 작업별 시작점 + +| 하려는 작업 | 고칠 곳 | 같이 확인할 것 | +|---|---|---| +| 샘플 서버에 패킷 처리 추가 | `FastPortServer.csproj`에 `Protocols` 참조 추가 → `FastPortClientSession.OnReceived`에서 `ParseMessageFromPacket` → `RequestSendMessage` | 참고 구현 `FastPortTestSmokeClientSession.OnReceived`. 구조화된 dispatcher는 템플릿 쪽 | +| 서버 포트·바인드 주소 변경 | `appsettings.json`에 `"FastPortServer": { "Host", "Port" }` 추가, 또는 환경 변수 `FastPortServer__Port`, 또는 `--FastPortServer:Port=...` 인자 | `FastPortServerOptions` 기본값 | +| 클라이언트 접속 대상 변경 | `FastPortClientBackgroundService.ExecuteAsync`의 `StartConnect` 인자 | 설정화하려면 옵션 클래스 추가 | +| Windows 서비스로 실행 | `FastPortServer/Program.cs`에 `builder.UseWindowsService()` | 서비스 계정의 작업 디렉터리, 로그 대상 | +| 지연 통계 출력 경로·주기 변경 | `FastPortClient/appsettings.json`의 `LatencyStats` | `LatencyStatsOptions`(`LibCommons/LatencyStats.cs`). 경로는 프로세스 현재 디렉터리 기준 | +| 클라이언트 동시 연결 늘리기 | `FastPortConnector`를 여러 개 resolve해 각각 `StartConnect` | `connectionCount` 인자는 무시된다 → [listener-connector.md](listener-connector.md) | +| 새 proto 메시지 추가 | `Protocols/Protos/tests.proto` 또는 새 `.proto`(자동 포함) | packetId 체계가 `ProtocolId` 하나뿐이므로 필요하면 enum 값 추가 | +| 서버에 세션 목록 관리 추가 | `FastPortClientSessionManager` 구현 + 팩토리·`OnDisconnected`에서 등록·해제 | `RequestShutdown`이 세션을 닫지 않는 점 | + +## 실행 + +```bash +dotnet build FastPortSharp.sln -c Release +dotnet run --project FastPortServer -c Release # 0.0.0.0:6628 listen, echo 없음 +dotnet run --project tests-projects/FastPortTestSmokeServer -c Release # echo 상대가 필요할 때 (6628) +dotnet run --project FastPortClient -c Release # 127.0.0.1:6628 접속, Ctrl+C 시 통계 출력·저장 +``` + +- `FastPortServer`와 `FastPortTestSmokeServer`는 둘 다 6628을 쓰므로 동시에 띄우지 않는다. +- `FastPortClient` 통계 파일은 `Stats/latency_stats_yyyy-MM-dd_HH-mm-ss.log`에 생긴다. +- 로그 레벨: 두 `appsettings.json` 모두 `FastPortClient`·`FastPortServer` 카테고리를 `Debug`로 둔다. 수신마다 Debug 로그가 찍힌다. + +## 테스트 + +- 샘플 앱 전용 테스트는 없다. CI(`build.yml`)는 `FastPortSharp.sln` 빌드와 `dotnet test`만 하므로 샘플 앱은 컴파일만 검증된다. +- proto 메시지의 실제 송수신은 `tests-projects/FastPortTests/FastPortTestSmokeServerTests.cs`, `FastPortTestLoadRunnerTests.cs`가 간접 검증한다. + +```bash +dotnet test tests-projects/FastPortTests -c Release --filter "FullyQualifiedName~FastPortTestSmokeServerTests" +``` + +## 주의 + +- 샘플 앱은 scaffold 복사 대상이 아니다. 단, 샘플 수정 중 `LibCommons/**`·`LibNetworks/**`를 건드리면 주석만 바꿔도 `tests/scaffold/run.sh --update-golden case-01-simple`과 `tests/scaffold/run.sh` 전체 통과가 필요하다. +- 이름이 반대다. 서버 앱의 세션은 `FastPortClientSession`(`BaseSessionClient`), 클라이언트 앱의 세션은 `FastPortServerSession`(`BaseSessionServer`)이다. +- 팩토리 인터페이스는 파일명이 뒤바뀌어 있다(`Sessions/IServerSessionFactory.cs`에 `IClientSessionFactory`). 타입명으로 찾는다. +- `Protocols`는 엔진 샘플용이다. 템플릿·scaffold 결과물은 `Protocols`를 참조하면 안 된다(`template-projects/Protos/` 사용). +- `FastPortServerSession`의 `m_LatencyStats`는 static이다. 한 프로세스에서 여러 세션이 같은 통계에 기록한다. +- `FastPortClient/Program.cs`는 `Console.CancelKeyPress`(`Environment.Exit(0)` 호출)와 `ProcessExit` 양쪽에서 통계를 출력·저장한다. 종료 처리를 바꿀 때 두 핸들러를 함께 본다. diff --git a/docs/llm/session.md b/docs/llm/session.md new file mode 100644 index 0000000..6712de4 --- /dev/null +++ b/docs/llm/session.md @@ -0,0 +1,83 @@ +# 세션 (`BaseSession` 수신·송신·종료, 송신 큐, 백프레셔, disconnect reason, 관측 hook) + +`LibNetworks/Sessions/`는 연결 하나를 맡는 세션 엔진이다. 본체는 `BaseSession.cs`(약 1.3K줄)이고, 수신 파싱, 패킷 핸들러 호출, 송신 큐, 연결 종료, 관측 hook이 모두 이 파일에 있다. + +**이 문서를 읽는 경우**: 세션 수명, 수신 흐름, 패킷 핸들러(`OnReceived`) 예외, 수신 버퍼 상한, 잘못된 패킷 헤더, 송신 요청·거부, 송신 큐 상한, 백프레셔, drain 예산, 연결 종료 순서, disconnect reason 추가, 관측 hook(`OnNetwork*`) 추가, 세션 팩토리, `BaseSessionClient`/`BaseSessionServer` 차이. + +**다른 문서로 가는 경우**: 와이어 포맷, `BasePacket`, 링버퍼 내부 → [packet-buffers.md](packet-buffers.md). accept·connect와 세션 생성 시점 → [listener-connector.md](listener-connector.md). hook을 텔레메트리로 잇는 SmokeServer 세션, idle 정리 → [load-testing.md](load-testing.md). 템플릿 `GameSession` → [game-server-template.md](game-server-template.md). 용어 → [glossary.md](glossary.md). + +## 핵심 규칙 + +- **이름이 반대다.** `BaseSessionClient`는 서버가 accept한 세션이다(`Id`, `OnAccepted()`). `BaseSessionServer`는 클라이언트가 서버에 연결한 세션이다(`OnConnected()`). +- **팩토리 파일명도 반대다.** `IClientSessionFactory.cs`에 `IServerSessionFactory`(`BaseSessionServer Create(Socket)`)가, `IServerSessionFactory.cs`에 `IClientSessionFactory`(`BaseSessionClient Create(Socket)`)가 있다. 타입명으로 찾는다. +- 생성자가 소켓 옵션(KeepAlive, Linger 1초, NoDelay)을 걸고 백그라운드 Task 3개(`DoWorkReceivedBuffers`, `DoWorkReceivedPackets`, `DoWorkSendBuffers`)를 바로 시작한다. **수신은 `RequestReceived()`를 불러야 시작한다.** `OnAccepted()`와 `OnConnected()`가 이를 부른다. override하면 `base`를 부르거나 직접 `RequestReceived()`를 부른다. +- **`RequestDisconnect(reason)`는 한 번만 실행된다**(`m_DisconnectRequested`에 `Interlocked.CompareExchange`). 두 번째 호출은 `false`다. 순서: `OnNetworkSessionDisconnected(reason)` → `AbandonPendingSendRequests` → `ClearQueuedSendBytesForDisconnect` → CTS cancel → socket `Shutdown`/`Close` → 송신 큐·수신 패킷 채널 `TryComplete` → `OnEventSessionDisconnected`(여기에 `OnDisconnected`가 구독돼 있다). +- 인자 없는 `RequestDisconnect()`는 `NetworkDisconnectReason.Unknown`이다. 원인을 아는 곳에서는 reason을 넘긴다. +- **`OnReceived`는 세션당 한 task에서 순서대로 불린다.** 수신 패킷 채널은 `Channel.CreateBounded(1000)`, `FullMode = Wait`, single reader다. 핸들러가 느리면 parser가 막히고, 그동안 수신 바이트가 쌓여 결국 `ReceiveBufferOverflow`로 끊긴다. 이것이 수신 쪽 백프레셔다. +- **`OnReceived`에서 처리하지 않은 예외는 세션을 `PacketHandlerError`로 끊는다.** worker가 조용히 죽지 않게 하려는 것이다. 계속 살려야 하는 오류는 핸들러 안에서 잡는다(템플릿 `PacketDispatcher`가 그렇게 한다). +- 수신 상한은 `protected virtual int MaxReceiveBufferedBytes`(기본 `DefaultMaxReceiveBufferedBytes` = 1MB)다. `ProcessReceiveCompleted`가 "버퍼에 남은 바이트 + 이번 수신 바이트"가 상한을 넘으면 쓰기 전에 끊는다. **`ushort.MaxValue`보다 낮추면 정상 대형 패킷도 끊긴다.** +- 길이 필드가 `BasePacket.HeaderSize`(2)보다 작으면 패킷이 영원히 완성되지 않는다. `TryGetBasePackets` 실패 뒤 `HasInvalidPacketHeader()`가 이를 판정해 `InvalidPacketHeader`로 끊는다. 앞의 정상 패킷은 먼저 전달된다. +- 수신 콜백은 동기 완료를 재귀가 아니라 `RequestReceived`의 `while` 루프로 처리한다. SAEA 수신 버퍼는 세션당 8KB 고정 배열(`m_ReceivedSocketBuffers`)이다. +- **송신은 여러 스레드에서 불러도 된다.** 송신 큐는 unbounded `Channel`(single reader, multi writer)이다. 크기 제한은 채널이 아니라 바이트 예약(`TryReserveQueuedSendBytes`, CAS 루프)으로 건다. +- `TryRequestSendBuffers` 거부 조건: 빈 버퍼, 헤더 포함 `ushort.MaxValue` 초과(로그만, hook 없음), 이미 종료 중(`OnNetworkSendRejected`), 큐 바이트 상한 초과(`OnNetworkSendBackpressure` + `OnNetworkSendRejected`), 채널이 닫힘(예약 롤백 후 `OnNetworkSendRejected`). `RequestSendMessage`/`RequestSendBuffers`는 결과를 버린다. 거부를 알아야 하면 `TryRequestSendMessage`/`TryRequestSendBuffers`를 쓴다. +- **송신 버퍼 소유권**: 패킷 배열은 `ArrayPool.Shared`에서 빌린다. 큐에 넣기 전 실패하면 즉시 반환하고 예약을 롤백한다. 전송 완료(`AdvanceSendItems`), worker 종료(`ReturnPendingSendBuffers`, `DrainQueuedSendBuffers`)에서 반환한다. 새 경로를 만들면 반환 지점을 반드시 넣는다. +- `DoWorkSendBuffers`는 최대 `MaxSendBatchSegments`(16)개 segment를 `SendChunkBytes`까지 묶어 보낸다. 한 wake에서 `MaxDrainBytesPerSignal` 바이트 또는 `MaxDrainOperationsPerSignal` 회를 넘기면 `Task.Yield`한다(`OnNetworkSendDrainYield`). `NoBufferSpaceAvailable`/`WouldBlock`은 transient로 보고 `TransientSendBackoffMs` 후 재시도한다. 그 밖의 `SocketException`은 `SendSocketError`, 0바이트 전송은 `SendZeroBytes`로 끊는다. +- `SessionSendOptions` 기본값: `MaxQueuedBytes` 1MB, `SendChunkBytes` 64KB, `MaxDrainBytesPerSignal` 256KB, `MaxDrainOperationsPerSignal` 4, `TransientSendBackoffMs` 1. 엔진은 `Normalized*` 속성만 읽는다. 현재 앱·템플릿은 모두 `SessionSendOptions.Default`를 쓴다(옵션 생성자 오버로드는 테스트만 쓴다). +- 관측 hook은 `protected virtual`이고 기본 구현은 비어 있다. 엔진은 텔레메트리를 모른다. hook 안에서 예외를 던지거나 오래 막지 않는다. hook은 수신 콜백·worker task 위에서 불린다. +- `OnNetworkSessionDisconnected(reason)` 기본 구현은 인자 없는 오버로드를 부른다. `OnNetworkSocketError(phase, ...)`도 2인자 오버로드를 부른다. 옛 오버로드만 override한 하위 클래스를 깨지 않으려는 구조다. + +## 파일·타입 + +| 파일 | 타입·심볼 | 내용 | +|---|---|---| +| `LibNetworks/Sessions/BaseSession.cs` | `BaseSession` | 수신: `RequestReceived`, `ProcessReceiveCompleted`, `DoWorkReceivedBuffers`, `HasInvalidPacketHeader`, `DoWorkReceivedPackets`. 송신: `TryRequestSendMessage`, `TryRequestSendBuffers`, `RequestSendString`, `DoWorkSendBuffers`, `BuildSendSegmentsAsync`, `SendSocketAsync`(virtual). 종료: `RequestDisconnect`, `IsDisconnected`, `WaitSession`, `OnEventSessionDisconnected` | +| 〃 | 상수 | `DefaultMaxReceiveBufferedBytes`(1MB), `MaxSendBatchSegments`(16), `SendBufferBackpressureThresholdBytes`(1MB) | +| 〃 | 내부 타입 | `SendQueueItem`(pooled 배열, `Offset`, `MarkTelemetryRegistered`), `ReceivedPacketItem` | +| 〃 | hook | `OnNetworkSessionDisconnected`, `OnNetworkSocketError`, `OnNetworkPacketReceived`, `OnNetworkReceiveCompleted`, `OnNetworkOperationDuration`, `OnNetworkBytesSent`, `OnNetworkSendRequested`, `OnNetworkSendCompleted`, `OnNetworkSendAbandoned`, `OnNetworkSendBackpressure`, `OnNetworkSendRejected`, `OnNetworkSendDrainYield`, `OnNetworkSendBufferSample`, `GetNetworkTimestamp`, `MarkNetworkActivity`, `LastReceivedTimestamp` | +| `LibNetworks/Sessions/BaseSessionClient.cs` | `BaseSessionClient` | 서버 측 accept 세션. abstract `Id`, `OnAccepted()`. `BaseListener`가 팩토리 생성 직후 `OnAccepted()`를 부른다 | +| `LibNetworks/Sessions/BaseSessionServer.cs` | `BaseSessionServer` | 클라이언트 측 연결 세션. `OnConnected()`. `BaseConnector`가 `Task.Run`으로 부른다 | +| `LibNetworks/Sessions/IClientSessionFactory.cs` | `IServerSessionFactory` | 파일명과 반대. `BaseConnector`가 쓴다 | +| `LibNetworks/Sessions/IServerSessionFactory.cs` | `IClientSessionFactory` | 파일명과 반대. `BaseListener`가 쓴다 | +| `LibNetworks/Sessions/SessionSendOptions.cs` | `SessionSendOptions` (record) | 송신 큐·chunk·drain 예산·backoff 설정, `Default` | +| `LibNetworks/Sessions/NetworkDisconnectReason.cs` | `NetworkDisconnectReason` | `Unknown`(0) ~ `PacketHandlerError`(10) | +| `LibNetworks/Sessions/SendCompletionTracker.cs` | `SendCompletionTracker` (internal) | 바이트 drain을 요청 단위 완료로 바꾸는 추적기. 엔진 미사용, `BaseSessionSendPolicyTests`만 쓴다(`InternalsVisibleTo("FastPortTests")`) | + +disconnect reason이 나오는 곳: `RemoteClosed`(수신 0바이트), `ReceiveSocketError`(수신 완료 오류), `ReceiveRequestError`(`ReceiveAsync` 예외), `SendSocketError`, `SendZeroBytes`, `InvalidPacketHeader`, `ReceiveBufferOverflow`, `PacketHandlerError`는 `BaseSession` 안이다. `IdleTimeout`은 SmokeServer `SessionIdleTracker`가 넘긴다. `LocalShutdown`은 정의만 있고 엔진·앱에서 넘기는 곳이 없다. + +`OnNetworkOperationDuration`의 operation 이름: `receive-buffer-write`, `receive-signal-to-parse`, `receive-packet-extract`, `receive-packet-channel-write`, `receive-packet-queue-delay`, `receive-packet-handler`, `send-enqueue`. `OnNetworkSocketError`의 phase: `receive-completion`, `receive-request`, `send-transient`, `send`, `send-worker`. + +## 작업별 시작점 + +| 하려는 작업 | 고칠 곳 | 같이 확인할 것 | +|---|---|---| +| 수신 버퍼 상한 바꾸기 | 세션 하위 클래스에서 `MaxReceiveBufferedBytes` override (엔진 기본값은 `DefaultMaxReceiveBufferedBytes`) | 65,535 이상 유지, `BaseSessionReceivePolicyTests`의 overflow 케이스 | +| 송신 큐 상한·drain 예산 조정 | `SessionSendOptions` 기본값, 또는 팩토리에서 5인자 생성자로 옵션 전달 | `SendBufferBackpressureThresholdBytes`(1MB 고정)는 `MaxQueuedBytes`가 1MB를 넘어야 의미가 있다. `BaseSessionSendPolicyTests` | +| 새 disconnect reason 추가 | `NetworkDisconnectReason`에 값 추가(번호 재사용 금지) → `BaseSession`에서 `RequestDisconnect(새값)` | SmokeServer `FastPortTestSmokeClientSession.ToTelemetryReason` 문자열 매핑, `BaseSessionSendPolicyTests` 내부 `ToTelemetryReason` 복사본, 대시보드·부하 검증이 reason 문자열을 읽는지 | +| 관측 hook 추가 | `BaseSession`에 빈 `protected virtual OnNetwork*` 추가 후 호출 지점 삽입 | SmokeServer 세션 override → `IServerTelemetry`([load-testing.md](load-testing.md)), 테스트 세션 override | +| 패킷 핸들러 예외 정책 변경 | `DoWorkReceivedPackets`의 `catch` 블록 | `BaseSession_PacketHandlerThrows_DisconnectsAndWorkersComplete`, 템플릿 `PacketDispatcher`의 자체 catch | +| 잘못된 헤더 판정 변경 | `HasInvalidPacketHeader`, `DoWorkReceivedBuffers` | `ArrayPoolCircularBuffers.TryGetBasePackets`의 멈춤 조건([packet-buffers.md](packet-buffers.md)) | +| 송신 실패를 호출자가 알게 하기 | 호출부를 `TryRequestSendMessage`로 교체 | 템플릿 `GameSession.Send`, `FastPortServerSession.SendMessage`는 결과를 버린다 | +| 테스트에서 송신 실패 흉내 | `SendSocketAsync` 두 오버로드 override | `BaseSessionSendPolicyTests`의 `_sendOverride` 패턴 | +| 세션 종료 후 정리 추가 | `OnDisconnected` override(마지막에 `base.OnDisconnected()`) 또는 `OnEventSessionDisconnected` 구독 | 이벤트는 `RequestDisconnect` 끝에서 한 번 호출된다. `EchoClientConnector`가 구독 예시다 | + +## 테스트 + +- `tests-projects/FastPortTests/BaseSessionReceivePolicyTests.cs`: 잘못된 헤더(정상 패킷 뒤·단독), partial 패킷, 1바이트 단위 분할 수신, 수신 상한 초과, 핸들러 예외. 테스트 세션 `ReceiveTestSession`이 `MaxReceiveBufferedBytes`와 `OnNetworkSessionDisconnected`를 override한다. +- `BaseSessionSendPolicyTests.cs`: 큐 바이트 상한 거부, `SendCompletionTracker`, `SessionSendOptions` 정규화, drain yield, transient backpressure, partial send 완료, FIFO 완료, chunk 제한, 닫힌 큐 거부, disconnect reason 기록, `LastReceivedTimestamp` 갱신, pending abandon. +- `SessionIdleTrackerTests.cs`: `IdleTimeout` 종료. `BaseListenerShutdownTests.cs`: 팩토리 예외([listener-connector.md](listener-connector.md)). +- 소켓 테스트는 각 파일 안의 private `SocketPair`(loopback `TcpListener` port 0 + `TcpClient`)로 실제 연결을 만든다. 공용 헬퍼 파일은 없다. 새 테스트 파일도 같은 패턴을 복사한다. + +```bash +dotnet test tests-projects/FastPortTests -c Release --filter "FullyQualifiedName~BaseSessionReceivePolicyTests" +dotnet test tests-projects/FastPortTests -c Release --filter "FullyQualifiedName~BaseSessionSendPolicyTests" +``` + +## 주의 + +- **`BaseSession.cs`는 통째로 읽지 않는다.** `grep -n "private async Task DoWork\|RequestDisconnect\|protected virtual" LibNetworks/Sessions/BaseSession.cs`처럼 메서드명으로 찾고 그 부분만 연다. +- **scaffold golden**: `LibNetworks/**`를 바꾸면 주석만 바꿔도 `tests/scaffold/run.sh --update-golden case-01-simple` 후 `tests/scaffold/run.sh` 전체 통과가 필요하다. +- 생성자의 `sendbuffers`(`IBuffers`) 인자는 받기만 하고 쓰지 않는다. 송신은 `ArrayPool` + 채널이다. `OnSent()`도 선언만 있고 호출되지 않는다. +- `SendQueueItem.MarkTelemetryRegistered` 전에는 send worker가 그 항목을 drain하지 않는다(`WaitForSendTelemetryRegistrationAsync`). `OnNetworkSendRequested`가 `OnNetworkSendCompleted`보다 먼저 찍히게 하려는 순서다. 이 순서를 바꾸면 외부 텔레메트리의 pending 수가 어긋난다. +- disconnect 직후 enqueue가 성공하는 race는 `RecordPendingSendRequested`가 abandon과 예약 롤백으로 보정한다. +- `OnDisconnected`는 `OnEventSessionDisconnected`에서 자기 구독을 해제한다. override에서 `base.OnDisconnected()`를 빼면 구독이 남는다. +- `IsDisconnected`는 "종료 요청됨"이다. 소켓이 실제로 닫혔는지와 worker 종료는 `WaitSession()`으로 기다린다. diff --git a/template-projects/FastPortGameServerTemplate/QUICKSTART.ko.md b/template-projects/FastPortGameServerTemplate/QUICKSTART.ko.md index 2d3646b..36c91c2 100644 --- a/template-projects/FastPortGameServerTemplate/QUICKSTART.ko.md +++ b/template-projects/FastPortGameServerTemplate/QUICKSTART.ko.md @@ -69,12 +69,12 @@ nc -zv 127.0.0.1 7777 ```csharp public sealed class MyHandler : IPacketHandler { - public int PacketId => PacketIds.MyRequest; + public int PacketId => (int)PacketIds.MyRequest; public void Handle(GameSession session, BasePacket packet) { // 1) ParseMessageFromPacket 로 디코딩 // 2) 게임 로직 - // 3) session.Send(PacketIds.MyResponse, response) + // 3) session.Send((int)PacketIds.MyResponse, response) } } ``` @@ -123,8 +123,6 @@ runner의 출발점으로도 사용할 수 있습니다. ## 다음 단계 -- 본 cycle 자체에 대한 배경: `docs/00-pm/...prd.md`, - `docs/01-plan/features/...plan.md`, - `docs/02-design/features/...design.md`. -- 엔진 패키지 NuGet publish 정책: `HANDOFF.md` "Important Architecture - Decisions" 섹션의 game server template 항목 + 차기 cycle. +- 템플릿 구조, 패킷 추가 절차, scaffold 규칙: FastPortSharp 저장소의 + `docs/llm/game-server-template.md`. +- 엔진 세션 동작(수신·송신·종료): FastPortSharp 저장소의 `docs/llm/session.md`. diff --git a/template-projects/FastPortGameServerTemplate/README.md b/template-projects/FastPortGameServerTemplate/README.md index 3f0f0a8..09f8c3c 100644 --- a/template-projects/FastPortGameServerTemplate/README.md +++ b/template-projects/FastPortGameServerTemplate/README.md @@ -133,7 +133,6 @@ publish workflow. ## See also - Repo `README.md` — performance benchmarks, full architecture overview. -- Repo `HANDOFF.md` — important architecture decisions and roadmap context. -- `docs/00-pm/game-server-template-from-network-engine.prd.md` — PM analysis. -- `docs/02-design/features/game-server-template-from-network-engine.design.md` - — design rationale (Option C — Pragmatic Balance). +- Repo `docs/llm/game-server-template.md` — template structure, packet-add + steps, scaffold and golden-test rules. +- Repo `docs/llm/session.md` — engine session receive/send/disconnect behavior. diff --git a/tests/scaffold/case-01-simple/expected/sha256.txt b/tests/scaffold/case-01-simple/expected/sha256.txt index 1609809..69db965 100644 --- a/tests/scaffold/case-01-simple/expected/sha256.txt +++ b/tests/scaffold/case-01-simple/expected/sha256.txt @@ -39,8 +39,8 @@ f9ade1eac6fefb4f6f90437ae42a0c3cb63d21aa016845f8f856765aaaa3392b ./MyLobbyServe 48023b8d56b95a4106a0a86915e92dd3d87a76858303d085276bf5ba738bfa70 ./MyLobbyServer/Handlers/IPacketHandler.cs 25ec0dc212f8093083cb06a8fd8abd6fc137ecb74dab196224d43e8a473fd56f ./MyLobbyServer/MyLobbyServer.csproj d2410c3f175431a664bf74863da79e303777f08647e01c13078a992a17ea0571 ./MyLobbyServer/Program.cs -656a7b40bfc647c2cf1ad6b64c86cdc4482cc9de257865d2c503434dd9ecc974 ./MyLobbyServer/QUICKSTART.ko.md -286d60ec61ab3670d1dd145333ae4bd5156b56a087429533564d6ac7890ac498 ./MyLobbyServer/README.md +d1ea850430c0c0c2a4f8e752ad2a2484c6c12adcb2d7bbcd2ed27c6c89a00836 ./MyLobbyServer/QUICKSTART.ko.md +4058a02daed89cf23cd3f3700b099450b683593e42abd64f4c70ddef1da58d91 ./MyLobbyServer/README.md b435a65f3e2b87eab4f049f54d0fbaaeeb95398c4fdeaa50afc4bb09756a210d ./MyLobbyServer/Sessions/GameSession.cs c5cb28f01573d644cb26bd03ccbb61761d86b294fe62b879a1c32c2aa5be8e86 ./MyLobbyServer/Sessions/GameSessionFactory.cs 82a0e1ecec2b7f1d0e026fdfe4798b0a6d815c05aad88d3f6dd432cf7f0ef12a ./MyLobbyServer/Telemetry/IGameServerTelemetry.cs