Skip to content

fix(net): avoid multiplayer content-transfer port conflicts - #3365

Open
shamough1792 wants to merge 4 commits into
openOMSI-org:mainfrom
shamough1792:multiplayer-port-reservation-pr
Open

shamough1792 wants to merge 4 commits into
openOMSI-org:mainfrom
shamough1792:multiplayer-port-reservation-pr

Conversation

@shamough1792

@shamough1792 shamough1792 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What changes for the player?

Hosting reserves UDP state and TCP content transfers together, skipping an occupied candidate without stopping other applications. LAN search and portless IP/hostname joins now try all ten automatic host ports (27015-27024), so a host moved off 27015 remains reachable.

How to test

  1. Keep a TCP-only listener on 27015 running (for example Apple Mobile Device Service), then host a room with automatic port selection. Confirm the log selects a free UDP/TCP port and starts content serving on that same port.
  2. From another computer on the same LAN, use both local-network search and a bare IP or hostname without a port. Both should find the host on its selected port. Repeat with an older/default-port host to check compatibility.
  3. Verify that an explicit ip:port, hostname:port, bare numeric port, or session code still targets only the specified port. An older client must use a session code or explicit ip:port to reach a host moved off 27015.
  4. Join with content not already cached to verify a cold transfer. Repeat with UDP-only conflicts; with OMSI_NO_LAN_MODS enabled, hosting remains UDP-only.

Automated Windows x64 validation of review commit c63930ec:

  • Red/green regression: the new portless-target test failed against the previous implementation (1 address instead of 10), then passed after the fix.
  • cargo test -p omsi-net --locked -j 4 -- --test-threads=4: 174 unit tests and 12 host-port integration tests passed.
  • cargo test --workspace --locked --no-fail-fast -j 4 -- --test-threads=4: completed; aggregate result lines report 3,555 passed, 1 failed and 156 ignored. The only failed test is the existing omsi-cfg::codepage::tests::detects_the_code_page locale-dependent assertion (details below). All networking tests passed.
  • cargo build --release --locked -j 4: passed (7m 55s).
  • Both root and launcher cargo metadata --locked --format-version 1 checks passed; neither lockfile contains an unused patch.
  • Both root and launcher lockfile audits passed with zero vulnerabilities (cargo-audit 0.22.2).
  • bash scripts/size-budget.sh passed without increasing the baseline; git diff --check passed.

Earlier manual evidence (before this review revision): the reporter completed a two-computer Windows test with TCP 27015 occupied, UDP/TCP 27016 selected, content serving started and the remote bus loaded, without Winsock 10055 / ERR_NO_BUFFER_SPACE. The bus was cached (0 wanted), so this does not demonstrate a cold download or manual verification of this revision's new discovery paths.

Screenshots

No graphical/layout change. Join-field and startup diagnostics now describe the actual port range/transports. Updated diagnostic screenshots have not been captured.

What does this change, and why?

  • Reserve TCP and UDP before announcing the session, then hand the same TcpListener to the mods service without closing/rebinding it. Windows sockets use SO_EXCLUSIVEADDRUSE; a protocol-free loopback probe verifies that the reserved listener is reachable.
  • Send discovery requests across the entire bounded host range on limited broadcast, every subnet/VPN broadcast, and loopback. A small target-builder module makes non-loopback coverage, deduplication and highest-port handling deterministic to test.
  • Expand only portless IP/name joins across the default range; preserve explicit ports and session-code destinations. Join-field and discovery-failure explanations reflect the full range.
  • Retry another candidate after a refused/reset/aborted/timed-out loopback handshake, while retaining non-retryable resource-exhaustion errors. Fixed-port startup failures identify UDP/TCP rather than UDP only.
  • Retain bounded retries, RAII rollback, exclusive reservations, listener handoff and the UDP-only kill switch. Tests cover conflicts, rollback, exhaustion, simultaneous hosts, automatic ranges and explicit destinations.

This is a generic networking boundary shared by all hosted sessions, not an Apple-specific or add-on-specific workaround. New add-ons require no special handling. No wire-protocol change or newly introduced listening port is involved; the mods service already used TCP on the session port.

Merged updated main at b9935e4c (including the zstd security upgrade from #3367) through merge commit 6ec29bd8; both lockfile audits are now clean. No advisory ignores are added.

What could break?

Host startup requires TCP as well as UDP unless transfers are explicitly disabled. Fixed-port requests still fail instead of silently choosing another port. Firewall/forwarding rules must allow the actual selected port. Discovery sends a bounded ten-port fan-out per distinct target rather than only one broadcast port.

Older clients only search/join the default port automatically: for a host moved off 27015, use its session code or explicit ip:port. Updated clients remain compatible with older hosts on 27015. Saves, settings, add-on formats and the wire protocol are unchanged.

Windows x64 was validated locally; Linux/macOS/other architectures rely on CI. A fresh two-computer test of the new bare-IP/LAN-search behavior, cold full-bus transfer, and exhaustive add-on compatibility remain unverified. This does not claim to solve another application's system-wide socket/handle exhaustion. Missing content/fonts and synchronization-table mismatches are outside this port fix.

Known local baseline failure: omsi-cfg::codepage::tests::detects_the_code_page still fails on this Traditional Chinese Windows machine (left: "咍曖", right: "ЛиАЗ"). The source is identical to upstream and not modified by this PR. All other executed tests passed; the full-workspace checkbox remains unchecked rather than hiding or patching this unrelated failure.

Performance

No feature added. Bounded startup/discovery correctness fix; no frame-time or memory benchmark is claimed.

This branch Current release (0.x.y) 0.2.30
Average FPS N/A N/A N/A
Slowest 1 % of frames (p99, ms) N/A N/A N/A
Memory after 2 min (MB) N/A N/A N/A

Machine, map and settings: Windows x64; the earlier manual two-computer test used Grundorf. No new driving benchmark was collected.

Checklist

  • cargo test --workspace and cargo build --release pass
  • The pull request contains a single change
  • Before/after screenshots are in the description if anything changes on screen (diagnostic text changes only; screenshots not captured)
  • A new feature has its performance table filled in (this branch, the current release, 0.2.30) No feature added.
  • New or changed interface texts are in crates/omsi-app/locales/app.yml, in every language I can (existing networking join explanations/startup diagnostics remain English; no new localized game UI)
  • Online play, saves, settings and mods made for the previous release still work, or the description says what changes
  • No original OMSI 2 content and no file of a megabyte or more in the commits

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Test builds for c63930e

Platform Download
Android (arm64) android.zip
Linux x64 (game and dedicated server) linux-x64.zip
macOS (Apple silicon) macos-arm64.zip
Windows x64 (game and dedicated server) windows-x64.zip
  • Unpack the download into a new folder, not over your openOMSI. The launcher shows the version as ...-pr3365, and a test build is never updated. It shares your settings and OMSI 2 folder; mods you installed into your openOMSI folder are only seen when you unpack it into a copy of that folder.
  • Android: the download holds an APK that installs as a separate app, openOMSI PR fix(net): avoid multiplayer content-transfer port conflicts #3365, beside the release, and shares its openOMSI folder.
  • What to try is under How to test in the description. Please comment with what you found and the version the launcher shows.
  • The links work for 14 days after the build.

@isaacsa2 isaacsa2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 5e8edc7. Reserving UDP and TCP before announcing the host, retaining the listener through handoff and using exclusive Windows binds address the reported port conflict without changing the wire protocol or adding work to the driving loop. The bounded retry/error handling and reservation-lifetime tests cover the important failure paths. Current platform builds and workspace tests pass.

I am holding approval while Security audit is red. Its log reports the four existing zstd-family advisories, and #3367 now supplies the dependency-only fix with passing checks. Once that lands, please merge the updated main into this branch and rerun the locked checks and audit. Repeating the current run without the dependency update will not clear it.

The supplied two-computer test validates selection of port 27016 and loading a cached bus; it does not establish a complete cold transfer. I reviewed the code and CI, not a local two-computer session.

@thibautgnt thibautgnt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @shamough1792, the reservation design is solid: binding TCP and UDP together before the session is announced and handing the very same TcpListener to the mods server removes the race cleanly, and the rollback and exhausted range tests are a nice touch.

  1. Moving off 27015 breaks LAN search and plain IP joins for these hosts. LanSession::discover only broadcasts to DEFAULT_PORT (crates/omsi-net/src/lib.rs:1615, only the loopback targets cover PORT_RANGE), and a bare IP or host name join only tries 27015 (crates/omsi-net/src/join_target.rs:130 and :145). A host that now lands on 27016 (crates/omsi-net/src/host_ports.rs:103-150) stops answering "search the local network" and --lan-join 192.168.x.y from other computers. Before this PR, a Windows host with Apple Mobile Device Service still had UDP 27015, and remote players reached its wildcard TCP listener, so that combination worked on the LAN. That is a lot of Windows players (anyone with iTunes or Apple Devices installed). Suggested fix in this PR: make the join side try the whole range, which stays compatible with older hosts on 27015: send DISCOVER to every port of DEFAULT_PORT..DEFAULT_PORT + PORT_RANGE on the broadcast and subnet targets too (not only loopback), and give a portless IP or name join all ten addresses (join_addr already tries its addresses at once). Please also add to the description that an older client can only find such a host by code or by ip:port.
  2. Isaac's point: the audit job is red on this head because of the zstd advisories. Once #3367 is in main, please merge main into this branch and rerun the locked checks and audit.

Non-blocking:

  • A dedicated server with a fixed port now refuses to start when only its TCP port is taken (it used to run with the mods transfer off). That is a reasonable choice, but the exit message in crates/omsi-app/src/lib.rs:507 still says "cannot open the session on UDP port", which will send server owners looking for the wrong thing. "UDP/TCP port" would do.
  • In verify_loopback (crates/omsi-net/src/host_ports.rs:51-80), any error other than AddrInUse or PermissionDenied (for example a probe connect that is refused or a read that times out) ends hosting at once instead of trying the next port. Mapping probe failures to AddrInUse would keep the bounded retry.

Wire protocol and the ports opened stay the same as before (TCP was already listening on the session port whenever mods sharing was on), so nothing new is exposed to the internet.

Probe every automatic host port on broadcast and subnet targets and for portless IP/name joins, while preserving explicit destinations. Retry connection-level loopback probe failures without hiding resource exhaustion, and identify both transports in fixed-port startup errors.
@shamough1792

Copy link
Copy Markdown
Contributor Author

Updated in c63930ec, after merging main (including #3367) via 6ec29bd8.

  • Limited broadcast, every subnet/VPN broadcast, and loopback now discover the full bounded host range. Deterministic tests cover all target classes, duplicate broadcasts, and the highest port without wrapping.
  • Portless IP/name joins now try 27015-27024 concurrently through the existing join path. Explicit IP/name ports, numeric ports and session codes remain exact; join explanations reflect the range. The PR description documents that older clients need a session code or ip:port for a shifted host.
  • Connection-level loopback probe failures (including refusal and read timeout) become retryable candidate conflicts; resource-exhaustion errors remain fatal. The fixed-port startup message now says UDP/TCP.

Validation: 174 network unit tests and 12 host-port integration tests pass; both lockfiles pass locked metadata checks and cargo-audit with zero vulnerabilities; size-budget and diff checks pass. The full locked workspace run completed with only the previously documented locale-dependent omsi-cfg::codepage::tests::detects_the_code_page failure (source unchanged from upstream). The locked Release build passes. Fresh two-computer validation of the new discovery/bare-IP paths remains pending; the earlier manual test is not presented as verification of these new paths.

Pushed to the fork branch; CI should rerun on this head.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants