Skip to content

[feature] TCP socket input for remote commands (Mindustry-compatible) - #68

Closed
TOWUK wants to merge 0 commit into
xpdustry:mainfrom
TOWUK:main
Closed

TOWUK wants to merge 0 commit into
xpdustry:mainfrom
TOWUK:main

Conversation

@TOWUK

@TOWUK TOWUK commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Notes for reviewer

  • The listener is intentionally synchronous (one client at a time), same as Mindustry's ServerControl. If concurrent access is ever needed, it's a small change to spawn a handle() thread per connection — but the current model is deliberate: it matches upstream and keeps System.out mirroring deterministic.

  • CommandHandler is not thread-safe upstream. We synchronize on it locally. Console input arrives via Core.app.post(), which serializes on the main thread; in practice the two paths never actually race because command registration happens once at startup.

  • The hooked flag exists so that init() can be called multiple times without chaining stdout wrappers. disposed exists so a late-arriving listen() cannot resurrect a shut-down listener. Both are edge cases, but cheap insurance.

Summary
Adds a TCP socket interface for remote command execution, mirroring Mindustry's socketInput. Enables automation, remote management, and status polling without relying on stdin / journald.

Three new config parameters, managed through the existing config command, with live reload for port/address. The listener duplicates System.out into the socket so clients receive the exact same output the console does.

Motivation
CLaJ has no programmatic interface for external tools. Web dashboards, monitoring scripts, and automation currently have to parse journald — fragile, slow, and platform-dependent. Mindustry solved this years ago with socketInput; this PR ports the same model to CLaJ.

Primary use cases:

  1. Live status polling from a website (status, rooms, players)
  2. Remote administration over a trusted local socket
  3. Integration with systemd units, monitoring agents, and CI checks

Changes
Four files touched:

File Change
server/src/com/xpdustry/claj/server/SocketInput.java New — TCP listener + stdout mirror (~170 LOC)
server/src/com/xpdustry/claj/server/ClajConfig.java +3 Field<> entries with descriptions
server/src/com/xpdustry/claj/server/ClajVars.java +1 static reference

Configuration
Three new fields registered through the existing ClajConfig mechanism, exposed via the standard config command:

Key Type Default Description
socket-input Boolean true Enable/disable the TCP listener
socket-input-port Integer 6860 Bind port (6859 is used by Mindustry)
socket-input-address String localhost Bind address (localhost, 127.0.0.1, 0.0.0.0)

Changes to socket-input-port and socket-input-address take effect immediately — no restart required — via Field.changed(Cons) callbacks.

Example:

config socket-input-port 7000
config socket-input-address 127.0.0.1

Behavior

  • Local-only by default (localhost), matching Mindustry's security posture.
  • Commands are executed through the same CommandHandler as the console, so all existing commands work unchanged.
  • System.out is mirrored into the active socket — clients receive the exact same output the console does, including formatted status blocks.
  • Errors from a dead or misbehaving client are swallowed at the socket boundary; the listener never crashes and never blocks on a single connection.
  • Only one active client at a time (same model as Mindustry's ServerControl). A second connection replaces the first.
image image

Compatibility

Mindustry-style: same UX, same defaults (localhost binding, one client, port near 6859), same command semantics.

CLaJ-style: uses existing ClajConfig.Field, Field.changed, Threads.daemon, Log, and ApplicationListener — no new patterns introduced.

No breaking changes: socket-input defaults to true but listens only on localhost. Operators who do not want the listener can set config socket-input false.

Testing
Extensive stress testing was performed; the full transcript is attached separately. Highlights:
Correctness — status, help, config produce identical output to the console.
Latency — 50-sample distribution, avg < 1 ms, p99 < 3 ms.
Throughput — 500 / 1000 / 2000 batched commands, all ≥ 95 % delivered.
Concurrency — 25 simultaneous clients, zero errors.
Memory — RSS stable across the entire test suite.
Resilience — oversized payload (2 MB), 20 partial sends, 50 idle connections, 75 rapid connect/disconnect cycles — CLaJ stays alive in all cases.
Live config — port and address changed at runtime, listener restarts cleanly.
Log hygiene — no timeout spam, no unexpected stack traces, no stray [E] lines.
Runtime invariants — exactly one Socket Input thread, 10 s idle timeout, clean heap after GC.

Checklist
Code compiles cleanly (gradlew clean release)
☑ No new warnings introduced by this change
☑ Runs on Linux (Debian 13, Java 25)
☑ Tested on Windows (build) and Linux (runtime)
☑ Follows existing code style and license headers
☑ Uses existing CLaJ mechanisms (config, threads, logging)
☑ No breaking changes to existing APIs
☑ Listener bound to localhost by default
☑ Documentation added in the code (JavaDoc on key methods)

Happy to split into smaller commits if preferred, or adjust naming/placement to match project conventions.

@TOWUK

TOWUK commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author
root@localhost:~# /tmp/claj-stress.sh

  ╔═══════════════════════════════════════════════════╗
  ║        CLaJ SocketInput — Stress Test             ║
  ╚═══════════════════════════════════════════════════╝

  started   20:51:16 MSK
  server    Etc/UTC

▸ Preflight
  why:  no active service → no tests possible
  └ debug: systemctl status claj.service ; journalctl -u claj.service -n 20
  ✓ service active
  ✓ port 6860 listening
  │ PID                14864
  │ baseline RSS       150 MB
  │ started            17:32:45 MSK

▸ Correctness
  why:  socket must use real CommandHandler, not a stub
  code: SocketInput.handle() → CommandHandler.handleMessage()
  └ debug: unzip -p claj-server.jar com/xpdustry/claj/server/SocketInput.class | strings | grep handleMessage
  ✓ status → integrity
  ✓ status → version
  ✓ status → load line
  ✓ help → list
  ✓ config → socket-input:
  ✓ config → socket-input-port:
  ✓ config → socket-input-address:

▸ Latency (50 samples)
  why:  real-world responsiveness; avg < 100ms, p99 < 200ms
  code: BufferedOutputStream + TCP_NODELAY
  └ debug: top -b -n1 | head -15 ; ss -tn | grep 6860
  │ min / avg          0.5ms / 1.0ms
  │ max                2.6ms
  │ p50 / p95 / p99    0.9 / 1.1 / 2.6ms
  │ throughput         1043 req/s
  ✓ avg below 100ms
  ✓ p99 below 200ms

▸ Batch throughput
  why:  stream parsing must handle many lines per connection
  code: readLine() loop in handle()
  └ debug: reduce N below; verify socket-input port is 6860
  ✓ 500 reqs in 227ms (2202 req/s)
  ✓ 1000 reqs in 459ms (2178 req/s)
  ✓ 2000 reqs in 913ms (2190 req/s)

▸ Concurrency (25 × 4)
  why:  listener must not deadlock on slow clients; 0 errors expected
  code: listen() accept → sequential handle()
  └ debug: journalctl -u claj.service | grep -B2 -A2 'Connection reset' ; ulimit -n
  │ success / errors   100 / 0
  │ duration           437ms
  │ rate               228 req/s
  ✓ 0 errors under load

▸ Memory (1000 reqs)
  why:  no leaks of buffers/sockets/FDs
  code: try-with-resources + finally { output = null }
  └ debug: retest: 2nd run should be +1..5 MB; check FDs — ls /proc/14864/fd | wc -l
  │ RSS now            150 MB
  │ delta              +0 MB
  ✓ RSS growth within limits

▸ Resilience
  why:  one bad client must never crash CLaJ or block listener
  code: MAX_LINE=512 · setSoTimeout(10s) · catch (SocketException)
  └ debug: journalctl -u claj.service -n 30 ; check JAR has 'too long' string
  · Oversized 2 MB
  ✓ alive
    ↳ expect: 'Command too long' in journal, only that session closed
  · Partial lines ×20
  ✓ alive
    ↳ expect: silent handling, no stack traces
  · Idle flood ×50
  ✓ alive
    ↳ expect: setSoTimeout catches idle sockets after 10s
  · Rapid cycles ×75
  ✓ alive
    ↳ expect: setReuseAddress(true) prevents TIME_WAIT pileup

▸ Live config change
  why:  Mindustry-compatible — config takes effect without restart
  code: Field.changed() → restart() → synchronized
  └ debug: grep 'SocketInput restarting' in journal ; verify restart() is synchronized
  · port :6860 → :7000
  ✓ → :7000
  ✓ responds
  · load ×20 on :7000
  ✓ 20 clients in 34ms
  · address localhost → 127.0.0.1
  ✓ works
    ↳ tests both port AND address callbacks fire without race
  · return to defaults
  ✓ back to :6860

▸ Log hygiene
  why:  production logs must stay clean; expected events silent
  code: catch (SocketTimeoutException) · catch (SocketException)
  └ debug: journalctl -u claj.service --since '2026-10-12 17:51:16' --no-pager | grep '\[E\]' | tail -20
  · timeout spam
  │ count              0
  ✓ no timeout spam
    ↳ idle clients closed by setSoTimeout must not log
  · unexpected traces from SocketInput
  │ count              0
  ✓ no unexpected traces
    ↳ only 'Command too long' is expected (OOM protection)
  · unexpected [E] errors
  │ count              0
  ✓ log clean
    ↳ empty [E] lines are stack-trace separators, not errors

▸ Runtime
  why:  no per-connection threads, no heap accumulation
  code: Threads.daemon("Socket Input") — exactly one thread
  └ debug: jcmd 14864 Thread.print | grep -A5 'Socket Input'
  · client timeout
  │ measured           10.0s
  ✓ 10s ± 1s
    ↳ READ_TIMEOUT_MS=10000
  · thread count
  │ SocketInput threads 1
  ✓ exactly 1
    ↳ no per-connection spawning
  · garbage collection
  │ heap after GC      2 MB
  ✓ heap clean
  · final RSS
  │ final RSS          150 MB
  │ total delta        +0 MB
  ✓ memory stable

═══════════════════════════════════════════════════
  ALL PASSED   32/32   20:51:16 → 20:51:53 MSK
═══════════════════════════════════════════════════
root@localhost:~#

@TOWUK

TOWUK commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

This pull request will eliminate the need for systemd socket activation and will output the content immediately, without delays. I’m using this for my website to show that everything is fine (I’m too lazy to log in via SSH).
actual demo site: mindcup.ru

@TOWUK

TOWUK commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author
image image image

@ZetaMap

ZetaMap commented Oct 16, 2026

Copy link
Copy Markdown
Member

Mmmm 😒 i don't like ai-made pr but i will reply anyways...

If concurrent access is ever needed, it's a small change to spawn a handle() thread per connection

No. This is too much for the use case. Synchronously is enough.

CommandHandler is not thread-safe upstream. We synchronize on it locally. Console input arrives via Core.app.post(), which serializes on the main thread; in practice the two paths never actually race because command registration happens once at startup.

I don't understand this very well, but seems off-topic. Just need to move command handler to a variable and trigger it via a posted task.

The hooked flag exists so that init() can be called multiple times without chaining stdout wrappers. disposed exists so a late-arriving listen() cannot resurrect a shut-down listener. Both are edge cases, but cheap insurance.

Off-topic

CLaJ has no programmatic interface for external tools. Web dashboards, monitoring scripts, and automation currently have to parse journald — fragile, slow, and platform-dependent. Mindustry solved this years ago with socketInput; this PR ports the same model to CLaJ.

Factually false. CLaJ has indeed no default monitoring thing like that, but stop you from creating one. The server is moddable.

But yea, why not a socket input.

Changes, Configuration, Examples, Checklist: I don't care of this bullshit.

Performance, Testing, Security, Stress-test: Off-topic, this is for sending commands to a server, not doing trading requests... We don't care of that, this is not too difficult to implement...



Well... To summarize, this is too much for a simple thing... No need to make a book for that. I know, I love optimization and low level things, but for what you want to do, is it worst it to do all of this? Just making the implementation, telling me it's working is enough.

More over, this is a badly made code. It's too much for the use case and hooks nonsense things...

I'm sure there is a simpler solution to avoid all this thing, and avoid to hook stdout or in.

@TOWUK

TOWUK commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Okay, well, thank you, then I'll close this request, I hope in the Future someone will write it easier, unfortunately, I don't like sending and receiving data via systemd sockets, although it's a good option.

@TOWUK TOWUK closed this Oct 16, 2026
@ZetaMap

ZetaMap commented Oct 17, 2026

Copy link
Copy Markdown
Member

No need to close it so soon. You can try to make a better implementation. Your idea is great But the execution is not good.

@TOWUK

TOWUK commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

No need to close it so soon. You can try to make a better implementation. Your idea is great But the execution is not good.

I’m not very good at writing in Java, and I can only make minor adjustments to already existing code, study something, or do some testing — nothing more…

@ZetaMap

ZetaMap commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

So you can try to improve yourself. I'm not judging your skills, I'm judging the excessive use of external help without understanding what your doing.

Try to make an implementation by yourself and inspiring you from what anuken or internet done. Your here to learn too.

I'm sure you can do better, take few days to search, code, test an implementation, and suggest your changes. I'll help you with how to implement it properly.

As I said, this kind of thing is usually short to make, as java already does most the job. More over by reading the CLaJ and Arc codebase, you will learn how to implement it by following the "mood". E.g. for getting the console output, there is a simple way than replacing stdout...

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.

2 participants