Viscord Vonix Mods v5.2.1 Search GitHub
Viscord/Operations/Performance

Performance#

Viscord runs inside the Minecraft server process, so every millisecond it holds the tick thread is a millisecond the server is not ticking. The design rule is simple: nothing on the tick thread does network or disk I/O, and no background task holds a thread while it waits.

This page documents the executor, the coalescing and caching strategies, the off-tick I/O paths, and the shutdown sequence, with the real numbers from Viscord.java, DiscordManager.java, WebhookClient.java, and LinkedAccountsManager.java.

One executor for everything#

All asynchronous work goes through a single static pool:

public static final ScheduledThreadPoolExecutor ASYNC_EXECUTOR = buildExecutor();
Property Value
Core pool size max(2, availableProcessors() / 2)
Maximum pool size max(8, core * 2)
Keep-alive 30 seconds
Core thread timeout Enabled
Threads Daemon, named Viscord-Async-N
Rejection policy CallerRunsPolicy
Cancelled-task cleanup setRemoveOnCancelPolicy(true)

Four of those settings earn their place:

Bounded, not cached. The predecessor was an unbounded cached pool. Under network back-pressure — a Discord API slowdown, a webhook endpoint timing out — every queued task got its own thread and the JVM could run out of them. A fixed ceiling turns that into back-pressure instead of a thread explosion.

Daemon threads. A stuck task cannot keep the JVM alive after the server has finished stopping.

CallerRunsPolicy. When the pool and its queue are saturated, the submitting thread runs the task itself. That is a deliberate choice over dropping: a chat message that is late is better than a chat message that silently vanished. The cost is that saturation pushes work onto the caller, which for an inbound gateway event is a Javacord thread — acceptable, because that is already off-tick.

Context class loader pinning. Every thread is created with setContextClassLoader(Viscord.class.getClassLoader()). On Forge, the EventSubclassTransformer resolves a class's superclass through the thread context loader, and Javacord's shaded classes loaded on a system-loader thread fail to link with ClassNotFoundException: ...shadow.javacord.api.entity.message.MessageBuilderBase. Pinning the loader on pool threads — and again around sendMessage in BotClient — is what makes embed sends work from any thread.

Delayed work without held threads#

There are two helpers:

Viscord.executeAsync(runnable);              // fire and forget
Viscord.scheduleAsync(runnable, delayMs);    // run later

scheduleAsync uses ScheduledThreadPoolExecutor.schedule, which parks the task in a delay queue and hands the thread back. The older pattern — submit(() -> { Thread.sleep(1500); pushStatus(); }) — occupied a pool thread for the entire delay. With a pool sized at half the CPU count, a handful of those starves everything else. Every delay in the current code is a real schedule:

Delay Purpose
500 ms Status refresh after a join or leave
500 ms Fluxer startup embed after the bot connects
1500 ms Fluxer status push after READY
2500 ms Discord disconnect after the server begins stopping
5000 ms Discord startup embed after the bot connects

Status coalescing#

Every player join and every player leave wants to update the bot's activity line. A mass reconnect after a network blip is a hundred joins in a second, and a naive implementation fires a hundred status API calls.

scheduleStatusUpdate collapses them with an AtomicBoolean:

if (!statusUpdatePending.compareAndSet(false, true)) return;
Viscord.scheduleAsync(() -> {
    statusUpdatePending.set(false);
    updateBotStatus();
}, delayMs);

At most one update is ever pending. The first caller flips the flag and schedules; every caller during the window returns immediately. When the scheduled task runs, it clears the flag before pushing, so a join arriving mid-push schedules the next update rather than being lost.

The practical effect: a hundred players reconnecting produce one status push carrying the post-reconnect count, not a hundred pushes carrying intermediate counts.

Bounded caches#

Two caches are bounded LRU maps with explicit size ceilings. Neither is allowed to grow with traffic.

Cache Where Cap Purpose
recentAdvancements DiscordManager 256 entries Deduplicate advancement embeds per player and title
recentDiscordBridges TridirectionalBridge 512 entries Suppress Discord → Fluxer → Discord echo

Both are access-ordered LinkedHashMaps with removeEldestEntry returning true past the cap, wrapped in Collections.synchronizedMap. The advancement cache also applies a time window on read: the same player and the same advancement title within 5000 ms is dropped, because one unlock can fire several server events. The bridge cache applies the same five-second window when it looks up a fingerprint.

A note on the advancement map's locking. The map is synchronised, but sendAdvancementEmbed also wraps its get-and-put in synchronized (recentAdvancements). Without that, two events for the same advancement arriving concurrently could both read the previous timestamp as absent and both emit.

Off-tick disk I/O#

Two files are written from code that can run on the server thread.

viscord-links.json. LinkedAccountsManager.save() serialises the map to a String inside a synchronized block on the calling thread — that gives a consistent snapshot and is pure in-memory work — then hands the actual write to Viscord.executeAsync. A /viscord discord unlink therefore does not touch the disk from the Brigadier thread.

viscord-preferences.json. PlayerPreferences.savePreferences() builds the JSON on the calling thread and submits the Files.writeString to the executor. This runs on every toggle, so a player flipping /discord messages twice does not produce two synchronous writes.

Both files are small and write-whole-file, not append. There is no journal and no atomic-rename dance: a crash mid-write can leave a truncated file. Keep backups.

Volatile capture to locals#

Several methods read a volatile field more than once. A concurrent reload can null that field between reads, which produces a NullPointerException on a perfectly healthy server. The codebase handles it by capturing once:

DiscordApi local = api;
if (local == null) return;
return local.getYourself().getIdAsString();

The same shape appears in BotClient.updateStatus, BotClient.disconnect, DiscordManager.handleSlashList (MinecraftServer current = server;), and the /home handler. Any new code that reads server, api, or bridge more than once should do the same — a reload resets the manager singleton and rebuilds the platform objects underneath you.

The tick thread never blocks#

The only places Viscord touches the tick thread are unavoidable by nature:

  • Chat capture, which runs on the packet-handling path and only enqueues.
  • server.execute(...) handoffs for anything that must run on the server thread — sending a chat component to players, reading the player list, dispatching /home.
  • The /home command dispatch itself, which must be on the server thread.

Everything else — webhook HTTP, gateway work, embed construction, config reload, file writes — is off-tick. The chat path in particular does the formatting and the webhook submission without blocking: the mixin calls sendChatMessage, which formats and hands off.

Shutdown sequence#

Stopping the server is the one time Viscord deliberately waits, so the "server offline" embed actually arrives before the JVM dies.

  1. shuttingDown is set true. Player-leave handling checks this and skips the leave embed, so a hundred players disconnecting do not produce a hundred embeds.
  2. The Discord disconnect is scheduled 2500 ms out rather than run inline.
  3. DiscordManager.shutdown() sends the offline embed and awaits its future with orTimeout(3, SECONDS), then disconnects the bot and shuts the webhook client down through the same handle chain.
  4. WebhookClient.shutdown() shuts the OkHttp dispatcher and calls awaitTermination(3, SECONDS), then evicts the connection pool.
  5. ASYNC_EXECUTOR.shutdown() runs in the finally block.

The worst case is roughly 2.5 s of deliberate delay plus up to 3 s waiting for the embed, so a stop can take around five to six seconds longer than it otherwise would. If the config has general.enabled = false or the integration never started, the executor is shut down immediately with no delay — which is why a disabled Viscord adds nothing to shutdown time.

Interrupted embeds

If the embed does not finish within its timeout the shutdown continues anyway; the timeout is caught and logged, not propagated. A missing "Server Offline" message means the network was slow, not that the server failed to stop cleanly.

Tuning#

There is very little to tune, by design.

[advanced] declares queue_size and rate_limit, and both are written into a generated config with range checks, but no code path reads either one in 5.2.1. Executor sizing is derived from the CPU count and queueing is the executor's own. If you are carrying these keys from an older build they are inert; leaving them alone is correct.

What you can actually change:

Knob Effect
bot_status.enabled = false Removes the periodic status API calls entirely.
messages.events.* Disabling a category skips the embed build and the network call, not just the display.
filters.chat.enabled Suppresses matching in-game messages before any network work.
discord_rate_limit.* Caps platform-side command volume; 0 disables a bucket.

Measuring#

For a rough read on whether Viscord is contributing to tick time, enable general.debug and watch for repeated rate-limit lines, reconnect attempts, or Failed to send warnings. Persistent webhook failures are the usual cause of queue growth, because each retry occupies a pool slot until its 5-second timeout expires. The webhook HTTP client is built with 5-second connect, write, and read timeouts, so a dead endpoint costs at most 5 seconds per in-flight message before CallerRunsPolicy starts pushing work onto callers.

If you are diagnosing a stall, the useful question is not "is Viscord slow" but "is the executor saturated" — look for Viscord-Async- thread names in a thread dump and count how many are in OkHttp socket reads.