Author SHA1 Message Date
Nomads_ReachandGitHub c8ff436942 ops: remove temporary runner deployment hooks
CSharp Server Gate / Repository policy, C# build and test (push) Canceled after 0s
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (pull_request) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (pull_request) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (pull_request) Canceled after 0s
Restore normal CO-SERVER workflows and delete the temporary August 18 deployment hooks after the host execution lanes remained queued/offline.
2026-08-18 14:34:36 -04:00
Nomads_Reach 404ca5285a ops: remove temporary deployment script 2026-08-18 14:34:14 -04:00
Nomads_Reach 387d44eb1e ops: remove temporary deployment trigger 2026-08-18 14:34:07 -04:00
Nomads_Reach 550aac04f9 ops: remove temporary deployment workflow 2026-08-18 14:33:59 -04:00
Nomads_Reach 8258affc22 ops: restore Gitea sync workflow after deployment attempt 2026-08-18 14:33:48 -04:00
Nomads_Reach 4a8ae3cab9 ops: restore CSharp workflow after deployment attempt 2026-08-18 14:33:34 -04:00
Nomads_ReachandGitHub d357dbd62d ops: add visible sync-runner deployment job
CSharp Server Gate / One-shot runner fleet deployment (push) Canceled after 0s
CSharp Server Gate / Visible runner fleet deployment via sync runner (push) Canceled after 0s
CSharp Server Gate / One-shot runner fleet deployment (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (push) Canceled after 0s
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (pull_request) Canceled after 0s
CSharp Server Gate / Visible runner fleet deployment via sync runner (pull_request) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (pull_request) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (pull_request) Canceled after 0s
One-shot runner fleet deployment / deploy (pull_request) Canceled after 0s
Temporary private observability hook for the August 18 runner fleet deployment. It is exact-branch gated and will be removed after verification.
2026-08-18 14:26:46 -04:00
Nomads_Reach febd599ce2 ops: add visible sync-runner deploy check 2026-08-18 14:26:28 -04:00
Nomads_ReachandGitHub 12034c16cd [deploy-runner-fleet-sync] run runner fleet deployment on sync runner
Use the separate co-server-sync runner for the one-shot August 18 scheduler and runner fleet deployment. The normal Gitea sync job is skipped for this marked push. Temporary ops code will be removed after verification.
2026-08-18 14:24:59 -04:00
Nomads_Reach 0cc4c0e4d4 ops: use sync runner for fleet deployment 2026-08-18 14:24:42 -04:00
Nomads_ReachandGitHub 459d929da0 [deploy-runner-fleet] runner fleet deployment with status beacon
CSharp Server Gate / One-shot runner fleet deployment (push) Canceled after 0s
CSharp Server Gate / One-shot runner fleet deployment (pull_request) Canceled after 0s
One-shot runner fleet deployment / deploy (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (push) Canceled after 0s
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (pull_request) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (pull_request) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (pull_request) Canceled after 0s
Run the one-shot scheduler/runner deployment again under the private CO-SERVER runner with a phase/result beacon posted to merged ops PR #27. This is temporary infrastructure code and will be cleaned up after verification.
2026-08-18 14:21:01 -04:00
Nomads_Reach 0a55f3e50a ops: allow private deployment status beacon 2026-08-18 14:20:44 -04:00
Nomads_Reach 746b5ade63 ops: report runner deployment phase 2026-08-18 14:20:24 -04:00
Nomads_ReachandGitHub 80c14f2837 [deploy-runner-fleet] one-shot runner fleet deployment
CSharp Server Gate / One-shot runner fleet deployment (push) Canceled after 0s
CSharp Server Gate / One-shot runner fleet deployment (pull_request) Canceled after 0s
One-shot runner fleet deployment / deploy (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (push) Canceled after 0s
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (pull_request) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (pull_request) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (pull_request) Canceled after 0s
Execute the private August 18 runner deployment bridge on the persistent CO-SERVER host runner. This merge is temporary and will be cleaned up after the deployment/audits finish.
2026-08-18 14:18:15 -04:00
Nomads_Reach 117b0a616b ops: run fleet deployment on marked main push 2026-08-18 14:17:54 -04:00
Nomads_Reach eb23c25ac3 ops: fix deployment temp permissions 2026-08-18 14:17:34 -04:00
Nomads_Reach 1bcc564c03 ops: attach runner fleet deployment job 2026-08-18 14:16:24 -04:00
Nomads_Reach 557f5e867e ops: stage runner fleet deployment script 2026-08-18 14:16:12 -04:00
Nomads_Reach 8d776100bc ops: trigger runner fleet deployment 2026-08-18 14:15:27 -04:00
Nomads_Reach 97f69d966d ops: expose deployment run on draft PR 2026-08-18 14:14:57 -04:00
Nomads_Reach 343bc9dd49 ops: deploy runner scheduler and fleet 2026-08-18 14:13:57 -04:00
Nomads_ReachandGitHub bbd6d93e8d Merge pull request #26 from G-A-R-D-E-N/chore/harden-selfhosted-runner
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (push) Canceled after 0s
Acceptance (end-to-end TCP) / End-to-end TCP acceptance (pull_request) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (push) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (push) Canceled after 0s
CSharp Server Gate / Repository policy, C# build and test (pull_request) Canceled after 0s
GNS Transport Bridge / Linux native GNS bridge (pull_request) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (push) Canceled after 0s
Host GUI (Avalonia) / Build Avalonia host (pull_request) Canceled after 0s
Chore/harden selfhosted runner
2026-08-17 18:10:30 -04:00
Nomads_Reach 9c5c16caf8 Disable Git credential helpers during Gitea sync 2026-08-17 16:02:56 -04:00
Nomads_Reach 30142c9706 Move Gitea sync to isolated trusted runner and stop persisting token 2026-08-17 15:52:19 -04:00
Nomads_Reach 7b4acf61d5 Pin CO-SERVER publish actions and reduce artifact retention 2026-08-17 15:52:05 -04:00
Nomads_Reach 3353726aef Harden CO-SERVER host build runner 2026-08-17 15:51:56 -04:00
Nomads_Reach 6f1e7d8f99 Route GNS build to isolated CO-SERVER runner 2026-08-17 15:51:49 -04:00
Nomads_Reach e1d894e828 Harden CO-SERVER build runner selection 2026-08-17 15:51:40 -04:00
Nomads_Reach f8bd621f18 Route acceptance to isolated CO-SERVER runner 2026-08-17 15:51:31 -04:00
Nomads_ReachandGitHub ece634450b Acceptance: load/fault tier — 16/32/64 clients, burst, churn, handoff-under-load (#15) (#24)
* Acceptance: add the load/fault tier (16/32/64 clients, burst, churn, handoff)

Extend the end-to-end harness (issue #15), harness-only — no server change.
Uses the existing ServerTuning seam to widen the local connect budget and
capacity so many clients can run over a single loopback IP.

- 16/32/64 clients across distinct cells: unique server-owned ids and no
  cross-cell transform spam (generalized from the prior 16-client case)
- burst transform traffic engages the rate limiter without tearing sessions
  down, and the server still relays a fresh transform afterward
- reconnect churn (12 cycles): ids stay monotonic and never reused, and each
  disconnect leaves no stale active session
- authority handoff stays deterministic under churn: retiring the current
  authority repeatedly hands off to the next lowest id with strictly
  increasing epochs

Packet loss/reorder on snapshot traffic is inherently an unreliable-transport
(GNS) property and stays deferred with the GNS sections (blocked on #2).

26/26 pass on real sockets, deterministic across repeated runs.

* Strip verbose comments from the acceptance harness and ServerTuning

Remove the prose/narration comments across the end-to-end harness (keeping only
section dividers) and the ServerTuning header block, matching the repo's terse
comment style.
2026-08-16 20:24:16 -04:00
8 changed files with 196 additions and 130 deletions
+8 -9
View File
@@ -1,11 +1,5 @@
name: Acceptance (end-to-end TCP) name: Acceptance (end-to-end TCP)
# Issue #15 acceptance matrix, executed end-to-end: stands up the real
# AuthoritativeServer behind the real TCP transport on a loopback port and drives
# real client sockets through it. If the runner cannot bind a loopback listener,
# the harness prints SKIP and exits 0 (it still runs for real on dev machines and
# the NAS runner).
on: on:
push: push:
paths: paths:
@@ -17,17 +11,22 @@ on:
- ".github/workflows/acceptance.yml" - ".github/workflows/acceptance.yml"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
acceptance: acceptance:
name: End-to-end TCP acceptance name: End-to-end TCP acceptance
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, co-server]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- name: Enforce repository runtime policy - name: Enforce repository runtime policy
run: bash server/scripts/verify-no-legacy-runtime.sh run: bash server/scripts/verify-no-legacy-runtime.sh
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
env: env:
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
with: with:
+8 -6
View File
@@ -5,20 +5,22 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
build-and-test: build-and-test:
name: Repository policy, C# build and test name: Repository policy, C# build and test
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, co-server]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- name: Enforce repository runtime policy - name: Enforce repository runtime policy
run: bash server/scripts/verify-no-legacy-runtime.sh run: bash server/scripts/verify-no-legacy-runtime.sh
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
# The runner user cannot write to system /usr/share/dotnet; install the
# pinned SDK into a runner-writable, cached path instead. runner.* context
# is only valid at step scope, not job-level env.
env: env:
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
with: with:
+8 -3
View File
@@ -10,14 +10,19 @@ on:
- "server/native_transport/**" - "server/native_transport/**"
- ".github/workflows/gns-transport.yml" - ".github/workflows/gns-transport.yml"
permissions:
contents: read
jobs: jobs:
linux: linux:
name: Linux native GNS bridge name: Linux native GNS bridge
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, co-server]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- name: Verify build dependencies (pre-provisioned on the self-hosted runner) - name: Verify build dependencies
run: | run: |
missing=0 missing=0
for tool in cmake ninja protoc; do for tool in cmake ninja protoc; do
+8 -5
View File
@@ -11,16 +11,19 @@ on:
- ".github/workflows/host.yml" - ".github/workflows/host.yml"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
build: build:
name: Build Avalonia host name: Build Avalonia host
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, co-server]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
# The runner user cannot write to system /usr/share/dotnet; install the
# pinned SDK into a runner-writable, cached path instead.
env: env:
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
with: with:
+43 -28
View File
@@ -1,9 +1,5 @@
name: Open Gitea PR on merge to main name: Open Gitea PR on merge to main
# When main changes on GitHub (i.e. after a PR is merged here), push those
# commits to a branch on Gitea and open a pull request there, so the same
# change can be reviewed and landed on the Gitea side. One-way: GitHub -> Gitea.
on: on:
push: push:
branches: [main] branches: [main]
@@ -14,15 +10,12 @@ permissions:
jobs: jobs:
open-gitea-pr: open-gitea-pr:
# GitHub-hosted runners are billing-blocked for this org, so the hosted job runs-on: [self-hosted, Linux, X64, co-server-sync]
# never started. Run on the self-hosted runner, which also has LAN access to
# the Gitea host. This workflow only fires on push to main (never on PRs from
# forks), so it is safe on the private runner.
runs-on: [self-hosted, Linux, X64]
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false
- name: Push main to Gitea and open a pull request - name: Push main to Gitea and open a pull request
env: env:
@@ -33,38 +26,60 @@ jobs:
SYNC_BRANCH: sync/from-github SYNC_BRANCH: sync/from-github
run: | run: |
set -euo pipefail set -euo pipefail
umask 077
if [ -z "${GITEA_TOKEN:-}" ]; then if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::Missing GITEA_TOKEN secret. Add a Gitea access token as a" echo "::error::Missing GITEA_TOKEN repository secret."
echo "::error::repository secret named GITEA_TOKEN (Settings -> Secrets and"
echo "::error::variables -> Actions -> New repository secret)."
exit 1 exit 1
fi fi
askpass="${RUNNER_TEMP}/gitea-askpass-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.sh"
header_file="${RUNNER_TEMP}/gitea-header-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
response_file="${RUNNER_TEMP}/gitea-pr-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
cleanup() {
git remote remove gitea >/dev/null 2>&1 || true
rm -f -- "$askpass" "$header_file" "$response_file"
}
trap cleanup EXIT
cat > "$askpass" <<'EOF'
#!/usr/bin/env bash
case "$1" in
*Username*) printf '%s\n' "${GITEA_USER:?}" ;;
*Password*) printf '%s\n' "${GITEA_TOKEN:?}" ;;
*) exit 1 ;;
esac
EOF
chmod 700 "$askpass"
printf 'Authorization: token %s\n' "$GITEA_TOKEN" > "$header_file"
chmod 600 "$header_file"
export GIT_ASKPASS="$askpass"
export GIT_TERMINAL_PROMPT=0
git config user.name "github-sync" git config user.name "github-sync"
git config user.email "github-sync@users.noreply.github.com" git config user.email "github-sync@users.noreply.github.com"
git remote remove gitea >/dev/null 2>&1 || true
git remote add gitea "https://${GITEA_HOST}/${GITEA_REPO}.git"
git -c credential.helper= -c credential.useHttpPath=true \
push -f gitea "HEAD:refs/heads/${SYNC_BRANCH}"
# Mirror the current main onto a dedicated Gitea branch. Force is safe: http_code=$(curl -sS -o "$response_file" -w "%{http_code}" -X POST \
# this branch is owned by the automation and only ever tracks GitHub main.
git remote add gitea "https://${GITEA_USER}:${GITEA_TOKEN}@${GITEA_HOST}/${GITEA_REPO}.git"
git push -f gitea "HEAD:refs/heads/${SYNC_BRANCH}"
# Open a PR on Gitea: sync/from-github -> main. If one is already open,
# the push above has already updated it, so a 409 is success too.
http_code=$(curl -sS -o /tmp/gitea_pr.json -w "%{http_code}" -X POST \
"https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}/pulls" \ "https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "@${header_file}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"title\":\"Sync from GitHub main\",\"head\":\"${SYNC_BRANCH}\",\"base\":\"main\",\"body\":\"Automated: GitHub main was updated. Review and merge to land it on Gitea.\"}") -d "{\"title\":\"Sync from GitHub main\",\"head\":\"${SYNC_BRANCH}\",\"base\":\"main\",\"body\":\"GitHub main was updated. Review and merge to land it on Gitea.\"}")
echo "Gitea pulls API returned HTTP ${http_code}" echo "Gitea pulls API returned HTTP ${http_code}"
cat /tmp/gitea_pr.json || true cat "$response_file" || true
echo echo
if [ "${http_code}" = "201" ]; then if [ "$http_code" = "201" ]; then
echo "Opened a new Gitea pull request." echo "Opened a new Gitea pull request."
elif [ "${http_code}" = "409" ] || grep -qiE "already exist|issue_exist" /tmp/gitea_pr.json; then elif [ "$http_code" = "409" ] || grep -qiE "already exist|issue_exist" "$response_file"; then
echo "A Gitea PR from ${SYNC_BRANCH} is already open; it now has the latest commits." echo "A Gitea PR from ${SYNC_BRANCH} is already open; it now has the latest commits."
else else
echo "::warning::Unexpected Gitea response (${http_code}). The branch was pushed;" echo "::error::Unexpected Gitea response (${http_code})."
echo "::warning::open the PR manually on Gitea if it did not appear." exit 1
fi fi
+10 -12
View File
@@ -1,37 +1,34 @@
name: Publish Host (Avalonia) name: Publish Host (Avalonia)
# Produces self-contained, single-file Server Host binaries for Windows and
# Linux. No .NET runtime is required on the target machine. Runs on demand and
# on version tags; each build is uploaded as a workflow artifact.
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
tags: tags:
- "host-v*" - "host-v*"
permissions:
contents: read
jobs: jobs:
publish: publish:
name: Publish ${{ matrix.rid }} name: Publish ${{ matrix.rid }}
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, co-server]
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
rid: [win-x64, linux-x64] rid: [win-x64, linux-x64]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9
# The runner user cannot write to system /usr/share/dotnet; install the
# pinned SDK into a runner-writable, cached path instead.
env: env:
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
with: with:
dotnet-version: "8.0.x" dotnet-version: "8.0.x"
- name: Publish single-file self-contained - name: Publish single-file self-contained
# Untrimmed on purpose: the host uses reflection-based Avalonia bindings,
# which the trimmer would strip.
run: > run: >
dotnet publish host/CommonwealthOnline.Host.csproj dotnet publish host/CommonwealthOnline.Host.csproj
-c Release -c Release
@@ -44,8 +41,9 @@ jobs:
--nologo --nologo
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with: with:
name: CommonwealthOnline.Host-${{ matrix.rid }} name: CommonwealthOnline.Host-${{ matrix.rid }}
path: out/${{ matrix.rid }}/ path: out/${{ matrix.rid }}/
if-no-files-found: error if-no-files-found: error
retention-days: 7
-3
View File
@@ -4,9 +4,6 @@ using System.Text.Json.Nodes;
namespace CommonwealthOnline.Server; namespace CommonwealthOnline.Server;
// Runtime limits with the shipped production defaults. Injected only so tests can
// shorten timeouts and widen the local connect budget; ServerRuntime constructs
// the server without it, so production behavior is unchanged.
internal sealed record ServerTuning internal sealed record ServerTuning
{ {
public int MaxPacketsPerSecond { get; init; } = 120; public int MaxPacketsPerSecond { get; init; } = 120;
+110 -63
View File
@@ -6,16 +6,6 @@ using CommonwealthOnline.Server;
namespace CommonwealthOnline.Server.Acceptance; namespace CommonwealthOnline.Server.Acceptance;
// End-to-end acceptance harness for issue #15. Unlike the in-memory unit tests,
// this stands up the real AuthoritativeServer behind the real TCP transport on a
// loopback port and drives real SyntheticProtocolClient sockets through it, so it
// exercises framing, admission, relay, interest filtering and session teardown
// over an actual connection.
//
// Covered here: the Baseline protocol, TCP compatibility, interest-management,
// movement/combat-validation, and NPC-authority sections of the matrix. GNS
// sections wait on #2 (real-bridge loopback); the 32/64-client load/fault runs
// are deferred follow-ups (see the summary printed at the end).
internal static class Program internal static class Program
{ {
private static int _passed; private static int _passed;
@@ -45,17 +35,22 @@ internal static class Program
await Run("combat: combat hit at a disconnected target is rejected", DisconnectedTargetCombatRejected); await Run("combat: combat hit at a disconnected target is rejected", DisconnectedTargetCombatRejected);
await Run("combat: out-of-interest combat hit is rejected", OutOfInterestCombatRejected); await Run("combat: out-of-interest combat hit is rejected", OutOfInterestCombatRejected);
await Run("interest: same-cell relays, distant is filtered", InterestSameCellVsDistant); await Run("interest: same-cell relays, distant is filtered", InterestSameCellVsDistant);
await Run("interest: 16 clients across cells, no cross-cell transform spam", SixteenClientsNoCrossCellSpam);
await Run("authority: independent populated scopes get independent authorities", IndependentNpcAuthorities); await Run("authority: independent populated scopes get independent authorities", IndependentNpcAuthorities);
await Run("authority: a stale npc authority epoch is rejected over transport", StaleNpcAuthorityEpochRejected); await Run("authority: a stale npc authority epoch is rejected over transport", StaleNpcAuthorityEpochRejected);
await Run("authority: disconnect deterministically hands off to a new owner", AuthorityHandoffOnDisconnect); await Run("authority: disconnect deterministically hands off to a new owner", AuthorityHandoffOnDisconnect);
await Run("authority: cell transition hands off and blocks the previous owner", AuthorityHandoffOnCellTransition); await Run("authority: cell transition hands off and blocks the previous owner", AuthorityHandoffOnCellTransition);
await Run("baseline: handshake timeout closes an unactivated session", HandshakeTimeoutCloses); await Run("baseline: handshake timeout closes an unactivated session", HandshakeTimeoutCloses);
await Run("baseline: idle timeout closes a stale active session", IdleTimeoutCloses); await Run("baseline: idle timeout closes a stale active session", IdleTimeoutCloses);
await Run("load: 16 clients across cells, no cross-cell transform spam", () => NoCrossCellSpam(16));
await Run("load: 32 clients across cells, no cross-cell transform spam", () => NoCrossCellSpam(32));
await Run("load: 64 clients across cells, no cross-cell transform spam", () => NoCrossCellSpam(64));
await Run("load: burst transform traffic keeps the server responsive", BurstTransformTraffic);
await Run("load: reconnect churn leaves no stale sessions", ReconnectChurn);
await Run("load: authority handoff stays deterministic under churn", AuthorityHandoffUnderLoad);
Console.WriteLine(); Console.WriteLine();
Console.WriteLine($"acceptance: {_passed} passed, {_failed} failed"); Console.WriteLine($"acceptance: {_passed} passed, {_failed} failed");
Console.WriteLine("deferred (follow-ups): 32/64-client load + packet-loss/reorder + reconnect-churn, and all GNS sections (blocked on #2)."); Console.WriteLine("deferred (follow-ups): packet loss/reorder on snapshot traffic and all GNS sections (both need the unreliable GNS path, blocked on #2).");
return _failed == 0 ? 0 : 1; return _failed == 0 ? 0 : 1;
} }
@@ -78,8 +73,6 @@ internal static class Program
await using var b = await Connect(s.Port); await using var b = await Connect(s.Port);
await Drain(a, 300); await Drain(b, 300); await Drain(a, 300); await Drain(b, 300);
// a claims a bogus playerId inside its own transform; the server must relay
// b a transform stamped with a's real server-owned id, never the claim.
var spoof = TransformPacket("0000AAAA", "0000BBBB", 0, 0, "spawn"); var spoof = TransformPacket("0000AAAA", "0000BBBB", 0, 0, "spawn");
spoof["playerId"] = 999999; spoof["playerId"] = 999999;
await a.SendAsync(spoof); await a.SendAsync(spoof);
@@ -115,8 +108,6 @@ internal static class Program
await raw.ExpectType("sessionReady", 2000); await raw.ExpectType("sessionReady", 2000);
var transformsBefore = Stat(s.Server, "transformPacketsReceived"); var transformsBefore = Stat(s.Server, "transformPacketsReceived");
// A single line larger than the protocol maximum must terminate the session
// at the transport before it is ever decoded or applied.
await raw.WriteRaw(new byte[ProtocolConstants.MaxMessageBytes + 1024]); await raw.WriteRaw(new byte[ProtocolConstants.MaxMessageBytes + 1024]);
True(await raw.WaitClosed(4000), "server closed the connection on oversize input"); True(await raw.WaitClosed(4000), "server closed the connection on oversize input");
@@ -127,7 +118,6 @@ internal static class Program
{ {
await using var s = new TestServer(); await using var s = new TestServer();
await using var a = await Connect(s.Port); await using var a = await Connect(s.Port);
// Burst well past the 120 packets/second window in a single window.
for (var i = 0; i < 400; i++) for (var i = 0; i < 400; i++)
{ {
try { await a.SendAsync(new JsonObject { ["type"] = "keepAlive" }); } try { await a.SendAsync(new JsonObject { ["type"] = "keepAlive" }); }
@@ -142,19 +132,16 @@ internal static class Program
private static async Task TwoClientSmoke() private static async Task TwoClientSmoke()
{ {
await using var s = new TestServer(); await using var s = new TestServer();
await using var a = await Connect(s.Port); // a connects first -> world-state host await using var a = await Connect(s.Port);
await using var b = await Connect(s.Port); await using var b = await Connect(s.Port);
await a.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn")); await a.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn"));
await b.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn")); await b.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn"));
await Drain(a, 400); await Drain(b, 400); await Drain(a, 400); await Drain(b, 400);
// transform relay
await a.SendAsync(TransformPacket("0000CAFE", "0000F00D", 10, 0)); await a.SendAsync(TransformPacket("0000CAFE", "0000F00D", 10, 0));
NotNull(await ReceiveUntil(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 3000), NotNull(await ReceiveUntil(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 3000),
"b received a's transform"); "b received a's transform");
// durable playerState relay: an in-interest peer receives the durable
// fields (and, being in scope, the live action events too).
await a.SendAsync(new JsonObject await a.SendAsync(new JsonObject
{ {
["type"] = "playerState", ["type"] = "playerState",
@@ -165,7 +152,6 @@ internal static class Program
NotNull(ps, "b received a's playerState"); NotNull(ps, "b received a's playerState");
Equal("Nomad", JsonHelpers.String(ps!["characterName"]), "playerState carried the character name"); Equal("Nomad", JsonHelpers.String(ps!["characterName"]), "playerState carried the character name");
// world-state from the host relays
await a.SendAsync(new JsonObject { ["type"] = "worldState", ["timeHours"] = 12.0 }); await a.SendAsync(new JsonObject { ["type"] = "worldState", ["timeHours"] = 12.0 });
NotNull(await ReceiveUntil(b, p => Type(p) == "worldState", 3000), "b received world state from the host"); NotNull(await ReceiveUntil(b, p => Type(p) == "worldState", 3000), "b received world state from the host");
} }
@@ -183,8 +169,6 @@ internal static class Program
}); });
await Drain(a, 300); await Drain(a, 300);
// a late joiner is replayed a's durable state, which must carry the durable
// fields but never the discrete action events.
await using var late = await Connect(s.Port); await using var late = await Connect(s.Port);
var replay = await ReceiveUntil(late, p => Type(p) == "playerState" && UInt(p["playerId"]) == a.PlayerId, 3000); var replay = await ReceiveUntil(late, p => Type(p) == "playerState" && UInt(p["playerId"]) == a.PlayerId, 3000);
NotNull(replay, "late joiner received a's durable player state"); NotNull(replay, "late joiner received a's durable player state");
@@ -217,7 +201,6 @@ internal static class Program
await using var s = new TestServer(); await using var s = new TestServer();
await using var a = await Connect(s.Port); await using var a = await Connect(s.Port);
await using var b = await Connect(s.Port); await using var b = await Connect(s.Port);
// both anchor in the same cell
await a.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn")); await a.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn"));
await b.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn")); await b.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn"));
await Drain(a, 400); await Drain(b, 400); await Drain(a, 400); await Drain(b, 400);
@@ -227,8 +210,6 @@ internal static class Program
"same-cell transform relayed to b"); "same-cell transform relayed to b");
var filteredBefore = Stat(s.Server, "transformPacketsInterestFiltered"); var filteredBefore = Stat(s.Server, "transformPacketsInterestFiltered");
// a makes a valid transition to a distant, unrelated cell/worldspace; the
// move itself is legal (transition type) but b is no longer in interest.
await a.SendAsync(TransformPacket("00000111", "00000222", 5_000_000, 5_000_000, "cell_change")); await a.SendAsync(TransformPacket("00000111", "00000222", 5_000_000, 5_000_000, "cell_change"));
await Drain(a, 300); await Drain(a, 300);
True(await ExpectNone(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 1200), True(await ExpectNone(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 1200),
@@ -237,35 +218,28 @@ internal static class Program
True(Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, "distant transform counted as interest-filtered"); True(Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, "distant transform counted as interest-filtered");
} }
// 16 clients over a single loopback IP exceed the production per-IP connect private static async Task NoCrossCellSpam(int count)
// throttle (8/10s), so this case runs the test server with a widened local
// connect budget. The 32/64-client load runs remain follow-ups.
private const int SixteenClientCount = 16;
private static async Task SixteenClientsNoCrossCellSpam()
{ {
await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 64 }); await using var s = new TestServer(maxPlayers: Math.Max(64, count),
tuning: new ServerTuning { MaxConnectAttempts = count * 4 });
var clients = new List<SyntheticProtocolClient>(); var clients = new List<SyntheticProtocolClient>();
try try
{ {
for (var i = 0; i < SixteenClientCount; i++) for (var i = 0; i < count; i++)
{ {
var c = await Connect(s.Port); var c = await Connect(s.Port);
clients.Add(c); clients.Add(c);
// every client sits in its own cell AND worldspace (coordinates kept
// well within MaxAbsCoordinate; distinct scopes do the isolating)
await c.SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0, 0, "spawn")); await c.SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0, 0, "spawn"));
} }
var ids = clients.Select(c => c.PlayerId).ToArray(); var ids = clients.Select(c => c.PlayerId).ToArray();
Equal(SixteenClientCount, ids.Distinct().Count(), "unique server-owned ids for every client"); Equal(count, ids.Distinct().Count(), "unique server-owned ids for every client");
foreach (var c in clients) await Drain(c, 150); foreach (var c in clients) await Drain(c, 100);
// each client moves; nobody should see anybody else's transform
for (var i = 0; i < clients.Count; i++) for (var i = 0; i < clients.Count; i++)
await clients[i].SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0 + 10, 0)); await clients[i].SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0 + 10, 0));
foreach (var c in clients) foreach (var c in clients)
True(await ExpectNone(c, p => Type(p) == "transform", 500), "client received no cross-cell transform spam"); True(await ExpectNone(c, p => Type(p) == "transform", 400), "client received no cross-cell transform spam");
} }
finally finally
{ {
@@ -280,13 +254,11 @@ internal static class Program
await using var s = new TestServer(); await using var s = new TestServer();
await using var a = await Connect(s.Port); await using var a = await Connect(s.Port);
await using var b = await Connect(s.Port); await using var b = await Connect(s.Port);
// both anchor in the same cell so b would normally receive a's transforms
await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn")); await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn"));
await b.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn")); await b.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn"));
await Drain(a, 300); await Drain(b, 300); await Drain(a, 300); await Drain(b, 300);
var rejectedBefore = Stat(s.Server, "movementPacketsRejected"); var rejectedBefore = Stat(s.Server, "movementPacketsRejected");
// a "normal" jump of a million units in the same cell is physically impossible
await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 1_000_000, 0, "normal")); await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 1_000_000, 0, "normal"));
NotNull(await ReceiveUntil(a, p => Type(p) == "positionCorrection", 3000), NotNull(await ReceiveUntil(a, p => Type(p) == "positionCorrection", 3000),
@@ -322,13 +294,11 @@ internal static class Program
await b.SendAsync(TransformPacket("0000CB01", "0000CB01", 0, 0, "spawn")); await b.SendAsync(TransformPacket("0000CB01", "0000CB01", 0, 0, "spawn"));
await Drain(a, 300); await Drain(b, 300); await Drain(a, 300); await Drain(b, 300);
// first hit at sequence 5 routes to the in-interest target
await a.SendAsync(CombatHit(b.PlayerId, 5)); await a.SendAsync(CombatHit(b.PlayerId, 5));
NotNull(await ReceiveUntil(b, p => Type(p) == "combatHit", 3000), "in-interest combat hit routed to the target"); NotNull(await ReceiveUntil(b, p => Type(p) == "combatHit", 3000), "in-interest combat hit routed to the target");
var routedBefore = Stat(s.Server, "combatHitsRouted"); var routedBefore = Stat(s.Server, "combatHitsRouted");
var rejectedBefore = Stat(s.Server, "packetsRejected"); var rejectedBefore = Stat(s.Server, "packetsRejected");
// an older sequence must be rejected and not routed
await a.SendAsync(CombatHit(b.PlayerId, 3)); await a.SendAsync(CombatHit(b.PlayerId, 3));
await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000); await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000);
True(Stat(s.Server, "packetsRejected") > rejectedBefore, "replayed combat sequence was rejected"); True(Stat(s.Server, "packetsRejected") > rejectedBefore, "replayed combat sequence was rejected");
@@ -357,7 +327,7 @@ internal static class Program
await using var a = await Connect(s.Port); await using var a = await Connect(s.Port);
await using var b = await Connect(s.Port); await using var b = await Connect(s.Port);
await a.SendAsync(TransformPacket("0000CB03", "0000CB03", 0, 0, "spawn")); await a.SendAsync(TransformPacket("0000CB03", "0000CB03", 0, 0, "spawn"));
await b.SendAsync(TransformPacket("0000CB04", "0000CB04", 1000, 0, "spawn")); // distinct scope await b.SendAsync(TransformPacket("0000CB04", "0000CB04", 1000, 0, "spawn"));
await Drain(a, 300); await Drain(b, 300); await Drain(a, 300); await Drain(b, 300);
var rejectedBefore = Stat(s.Server, "packetsRejected"); var rejectedBefore = Stat(s.Server, "packetsRejected");
var routedBefore = Stat(s.Server, "combatHitsRouted"); var routedBefore = Stat(s.Server, "combatHitsRouted");
@@ -381,7 +351,6 @@ internal static class Program
await using var s = new TestServer(); await using var s = new TestServer();
await using var a = await Connect(s.Port); await using var a = await Connect(s.Port);
await using var b = await Connect(s.Port); await using var b = await Connect(s.Port);
// a and b spawn into distinct, non-overlapping scopes
await a.SendAsync(TransformPacket("0000A111", "0000A111", 0, 0, "spawn")); await a.SendAsync(TransformPacket("0000A111", "0000A111", 0, 0, "spawn"));
await b.SendAsync(TransformPacket("0000B222", "0000B222", 5_000_000, 0, "spawn")); await b.SendAsync(TransformPacket("0000B222", "0000B222", 5_000_000, 0, "spawn"));
@@ -405,7 +374,6 @@ internal static class Program
await Drain(a, 200); await Drain(a, 200);
var rejectsBefore = Stat(s.Server, "npcAuthorityRejects"); var rejectsBefore = Stat(s.Server, "npcAuthorityRejects");
// submit npcState claiming a wrong (future) authority epoch
await a.SendAsync(new JsonObject await a.SendAsync(new JsonObject
{ {
["type"] = "npcState", ["type"] = "npcState",
@@ -422,16 +390,15 @@ internal static class Program
private static async Task AuthorityHandoffOnDisconnect() private static async Task AuthorityHandoffOnDisconnect()
{ {
await using var s = new TestServer(); await using var s = new TestServer();
var a = await Connect(s.Port); // connects first -> lower id -> initial authority var a = await Connect(s.Port);
await using var b = await Connect(s.Port); await using var b = await Connect(s.Port);
await a.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn")); await a.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn"));
var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000); var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
NotNull(grantA, "a is the initial authority for the shared scope"); NotNull(grantA, "a is the initial authority for the shared scope");
var epochA = UInt(grantA!["authorityEpoch"]); var epochA = UInt(grantA!["authorityEpoch"]);
await b.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn")); // b joins the same scope await b.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn"));
await Drain(a, 300); await Drain(b, 300); await Drain(a, 300); await Drain(b, 300);
// a leaves; authority must deterministically hand off to b with a newer epoch
await a.DisposeAsync(); await a.DisposeAsync();
var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000); var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000);
NotNull(grantB, "authority handed off to b after a disconnected"); NotNull(grantB, "authority handed off to b after a disconnected");
@@ -443,20 +410,18 @@ internal static class Program
await using var s = new TestServer(); await using var s = new TestServer();
await using var a = await Connect(s.Port); await using var a = await Connect(s.Port);
await using var b = await Connect(s.Port); await using var b = await Connect(s.Port);
await a.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); // a authority for scope X await a.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn"));
var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000); var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
NotNull(grantA, "a is authority for scope X"); NotNull(grantA, "a is authority for scope X");
var epochX1 = UInt(grantA!["authorityEpoch"]); var epochX1 = UInt(grantA!["authorityEpoch"]);
await b.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); // b joins scope X await b.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn"));
await Drain(a, 300); await Drain(b, 300); await Drain(a, 300); await Drain(b, 300);
// a transitions out to a different cell; scope X must hand off to b
await a.SendAsync(TransformPacket("0000CE20", "0000CE20", 0, 0, "cell_change")); await a.SendAsync(TransformPacket("0000CE20", "0000CE20", 0, 0, "cell_change"));
var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000); var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000);
NotNull(grantB, "scope X handed off to b after a transitioned away"); NotNull(grantB, "scope X handed off to b after a transitioned away");
True(UInt(grantB!["authorityEpoch"]) > epochX1, "handoff carries a newer epoch"); True(UInt(grantB!["authorityEpoch"]) > epochX1, "handoff carries a newer epoch");
// the previous owner can no longer submit npcState for the reassigned scope
await Drain(a, 200); await Drain(a, 200);
var rejectsBefore = Stat(s.Server, "npcAuthorityRejects"); var rejectsBefore = Stat(s.Server, "npcAuthorityRejects");
await a.SendAsync(new JsonObject await a.SendAsync(new JsonObject
@@ -471,6 +436,94 @@ internal static class Program
True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "previous owner cannot submit for the reassigned scope"); True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "previous owner cannot submit for the reassigned scope");
} }
// ---- load / fault -------------------------------------------------------
private static async Task BurstTransformTraffic()
{
await using var s = new TestServer();
await using var a = await Connect(s.Port);
await using var b = await Connect(s.Port);
await a.SendAsync(TransformPacket("0000B057", "0000B057", 5, 5, "spawn"));
await b.SendAsync(TransformPacket("0000B057", "0000B057", 5, 5, "spawn"));
await Drain(a, 300); await Drain(b, 300);
var receivedBefore = Stat(s.Server, "transformPacketsReceived");
for (var i = 0; i < 400; i++)
{
try { await a.SendAsync(TransformPacket("0000B057", "0000B057", 5, 5, "normal")); }
catch { break; }
}
await SpinUntil(() => Stat(s.Server, "rateLimitedPackets") > 0, 3000);
True(Stat(s.Server, "rateLimitedPackets") > 0, "the burst engaged the rate limiter");
True(Stat(s.Server, "transformPacketsReceived") > receivedBefore, "the server processed transforms during the burst");
Equal(2u, Stat(s.Server, "connectedClients"), "both sessions survived the burst");
await Drain(b, 300);
var relayed = false;
for (var attempt = 0; attempt < 5 && !relayed; attempt++)
relayed = await RelayWorks(a, b);
True(relayed, "server relays a fresh transform after the burst");
}
private static async Task<bool> RelayWorks(SyntheticProtocolClient a, SyntheticProtocolClient b)
{
await a.SendAsync(TransformPacket("0000B057", "0000B057", 6, 6, "normal"));
return await ReceiveUntil(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 800) is not null;
}
private static async Task ReconnectChurn()
{
await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 128 });
uint lastId = 0;
for (var k = 0; k < 12; k++)
{
var c = await Connect(s.Port);
True(c.PlayerId > lastId, "server-owned ids are monotonic and never reused across reconnects");
lastId = c.PlayerId;
await c.SendAsync(TransformPacket("0000C401", "0000C401", 0, 0, "spawn"));
await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 3000);
await c.DisposeAsync();
await SpinUntil(() => Stat(s.Server, "connectedClients") == 0, 3000);
Equal(0u, Stat(s.Server, "connectedClients"), "each disconnect leaves no stale active session");
}
await using var final = await Connect(s.Port);
True(final.PlayerId > lastId, "server still assigns fresh ids after churn");
}
private static async Task AuthorityHandoffUnderLoad()
{
const int n = 6;
await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 64 });
var clients = new List<SyntheticProtocolClient>();
try
{
for (var i = 0; i < n; i++)
{
var c = await Connect(s.Port);
clients.Add(c);
await c.SendAsync(TransformPacket("0000A11D", "0000A11D", 0, 0, "spawn"));
}
var watcher = clients[n - 1];
await Drain(watcher, 300);
uint lastEpoch = 0;
for (var i = 0; i < 3; i++)
{
await clients[i].DisposeAsync();
var expected = clients[i + 1].PlayerId;
var grant = await ReceiveUntil(watcher, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == expected, 4000);
NotNull(grant, $"authority handed off to the next owner after retiring holder {i}");
var epoch = UInt(grant!["authorityEpoch"]);
True(epoch > lastEpoch, "each handoff carries a strictly newer epoch");
lastEpoch = epoch;
}
}
finally
{
foreach (var c in clients) await c.DisposeAsync();
}
}
// ---- session teardown --------------------------------------------------- // ---- session teardown ---------------------------------------------------
private static async Task HandshakeTimeoutCloses() private static async Task HandshakeTimeoutCloses()
@@ -478,7 +531,6 @@ internal static class Program
await using var s = new TestServer(); await using var s = new TestServer();
using var raw = await RawClient.Connect(s.Port); using var raw = await RawClient.Connect(s.Port);
await raw.ExpectType("welcome", 2000); await raw.ExpectType("welcome", 2000);
// deliberately never send hello; the reaper must close the pending session
Equal(0u, Stat(s.Server, "connectedClients"), "pending session is not counted as active"); Equal(0u, Stat(s.Server, "connectedClients"), "pending session is not counted as active");
True(await raw.WaitClosed(14000), "server reaped the unactivated session after the handshake timeout"); True(await raw.WaitClosed(14000), "server reaped the unactivated session after the handshake timeout");
Equal(0u, Stat(s.Server, "connectedClients"), "still no active clients after reap"); Equal(0u, Stat(s.Server, "connectedClients"), "still no active clients after reap");
@@ -486,15 +538,12 @@ internal static class Program
private static async Task IdleTimeoutCloses() private static async Task IdleTimeoutCloses()
{ {
// Short idle timeout so the reaper (500ms tick) fires quickly; handshake
// timeout keeps its default so the client can activate first.
await using var s = new TestServer(tuning: new ServerTuning { ClientIdleTimeoutSeconds = 2.0 }); await using var s = new TestServer(tuning: new ServerTuning { ClientIdleTimeoutSeconds = 2.0 });
await using var a = await Connect(s.Port); await using var a = await Connect(s.Port);
await a.SendAsync(TransformPacket("0000171E", "0000171E", 0, 0, "spawn")); await a.SendAsync(TransformPacket("0000171E", "0000171E", 0, 0, "spawn"));
await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 3000); await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 3000);
Equal(1u, Stat(s.Server, "connectedClients"), "client is active before going idle"); Equal(1u, Stat(s.Server, "connectedClients"), "client is active before going idle");
// stay idle past the 2s idle timeout; the reaper must close the session
await SpinUntil(() => Stat(s.Server, "connectedClients") == 0, 6000); await SpinUntil(() => Stat(s.Server, "connectedClients") == 0, 6000);
Equal(0u, Stat(s.Server, "connectedClients"), "idle active session was reaped"); Equal(0u, Stat(s.Server, "connectedClients"), "idle active session was reaped");
} }
@@ -625,8 +674,6 @@ internal static class Program
} }
} }
// Minimal raw client for cases the framed SyntheticProtocolClient can't express
// (oversize input, and connecting without completing the handshake).
private sealed class RawClient : IDisposable private sealed class RawClient : IDisposable
{ {
private readonly TcpClient _client; private readonly TcpClient _client;
@@ -690,11 +737,11 @@ internal static class Program
while (true) while (true)
{ {
var n = await _stream.ReadAsync(one, cts.Token); var n = await _stream.ReadAsync(one, cts.Token);
if (n == 0) return true; // clean EOF -> server closed if (n == 0) return true;
} }
} }
catch (OperationCanceledException) { return false; } catch (OperationCanceledException) { return false; }
catch (IOException) { return true; } // reset also counts as closed catch (IOException) { return true; }
catch (SocketException) { return true; } catch (SocketException) { return true; }
} }