diff --git a/README.md b/README.md
index 69e9364..234b310 100644
--- a/README.md
+++ b/README.md
@@ -339,7 +339,7 @@ behaviour, so removing a module from the classpath removes its offer and its beh
| `onEvent(kind, hook)` | Reacts to a committed `AWAY_CHANGED`, `JOINED`, `PARTED`, `KICKED`, `QUIT` or `NICK_CHANGED` with delivery intents for peers drawn from the event's audience; recipients are narrowed to those that negotiated the capability | `away-notify` |
| `onMemberStatus`, `onNamesIdentity`, `onReplyField` | Bounded decorations of schema-declared reply slots (the status of a NAMES token or channel WHO entry, the NAMES identity) | `multi-prefix` (status) |
| `onOutputGroup(prerequisites, hook)` | Framing of a finite group; the group is validated and queued whole, or sent plain when the owner or a prerequisite was not negotiated | `batch` |
-| `command` | Provider-owned command; it can never replace a command core or another extension handles, and is removed (answered as unknown) when the provider is withdrawn | `message-tags` (`TAGMSG`) |
+| `command` | Provider-owned command, part of the published snapshot rather than core's command table, so it appears, disappears or moves to another provider exactly when the snapshot does; it can never replace a command core or a server extension handles, and is answered as unknown while no published provider owns it | `message-tags` (`TAGMSG`) |
Hook results are untrusted. An exception, a `null` or an out-of-bounds result is dropped whole,
reported by provider id, hook and category only, and replaced by the documented default; committed
@@ -352,9 +352,18 @@ channel monitor held; a kind nobody listens for costs nothing, and a faulty list
the transition. Client messages go through `MessageDelivery`, which owns target resolution, the
send gates, the envelope (one id and send time for every recipient) and role selection.
-**Status.** Runtime FAILED withdrawal of a faulty provider, atomic runtime enable/disable with
-`CAP NEW`/`DEL`, and the dedicated load measurements are later changes in the same feature
-(`specs/014-capability-plugin-spi/`); this change does not provide them.
+**Runtime enable and disable.** `EXTENSION`, `REHASH` and `SIGHUP` share one lifecycle. Under the
+capability runtime's administrative mutex, a disable publishes the new snapshot and removes the
+withdrawn name, together with any hard dependents', from every connected session in one step; then
+`CAP DEL` goes to every client that sent `CAP LS 302` or negotiated `cap-notify`. An enable sends
+`CAP NEW` but restores no one's negotiation. Core advertises `cap-notify` in `CAP LS` even with no
+provider installed. Work prepared before a change may still be delivered after the `DEL`; anything
+prepared after it sees the new state. A batch group picks framed or plain at admission, against the
+current state, so a queued group always keeps its close. Provider `start`/`stop` run outside the
+mutex and must tolerate overlapping callbacks from captured work.
+
+**Status.** Runtime FAILED withdrawal of a provider whose hook faults, and the dedicated load
+measurements, are a later change in the same feature (`specs/014-capability-plugin-spi/`).
**Migrating code that used the earlier API.** `0.1.0-SNAPSHOT` is unreleased, so these changes
carry no version bump until the first release.
@@ -370,6 +379,11 @@ carry no version bump until the first release.
| `MessageCommandHandler.resolveRecipients`, `deliveryTargets`, `includeSenderInFanOut` | `MessageDelivery.authorize` and `deliver` |
| `TagmsgCommandHandler` in core | Owned by the `message-tags` module |
| `ReplyBudgetCatalog.legacyCompatible()` | `ReplyBudgetCatalog.empty()` plus the providers' own declared bounds |
+| Mutating `ClientSession.negotiatedCapabilities()` | It is now an immutable snapshot; replace the whole set with `replaceNegotiatedCapabilities` |
+| `CapabilityNegotiator.isAcceptable`, `apply` | `CapabilityNegotiator.candidate` (validates without changing anything) and `request` (validates and applies under the administrative mutex) |
+| `CapabilityNegotiationGrammar.ls`, `list` | `lsReplies`, `listReplies` (with 302 continuation lines); `notifications` for `CAP NEW`/`DEL` |
+| A capability provider's command looked up in core's command table (`CommandRegistrar`) | `ExtensionRegistry.capabilityCommand(Command)`; dispatch consults it after core's table. `HookDispatcher.guardedCommand(registration, live, refused)` is gone; the one-argument form wraps faults |
+| `CapabilityRuntime.withdraw` returning a snapshot; `ExtensionRegistry.disable` refusing while a dependent is enabled | `withdraw` returns the withdrawn provider ids; disabling a prerequisite withdraws its hard dependents in the same step |
Reply decorations are budgeted by generic layouts (`ReplyLayout`) rather than a per-capability
width: startup, `REHASH` and runtime enable reserve every installed provider's declared slot bytes
diff --git a/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/TagmsgCommandHandlerTest.java b/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/TagmsgCommandHandlerTest.java
index 144ffae..13f2ece 100644
--- a/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/TagmsgCommandHandlerTest.java
+++ b/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/TagmsgCommandHandlerTest.java
@@ -105,7 +105,10 @@ private ClientSession user(String nick, String... negotiated) {
}
private void tagmsg(ClientSession sender, String line) throws Exception {
- commands.get(Command.TAGMSG).handle(sender, MessageParser.parse(line));
+ extensions
+ .capabilityCommand(Command.TAGMSG)
+ .orElseThrow()
+ .handle(sender, MessageParser.parse(line));
}
private String received(ClientSession session) {
@@ -114,10 +117,12 @@ private String received(ClientSession session) {
}
@Test
- void theProviderInstallsTagmsgAndWithdrawalRemovesIt() {
- assertThat(commands).containsKey(Command.TAGMSG);
+ void theProviderOwnsTagmsgWhileEnabledAndWithdrawalRemovesIt() {
+ // A provider's command is part of the published snapshot, not core's command table.
+ assertThat(extensions.capabilityCommand(Command.TAGMSG)).isPresent();
+ assertThat(commands).isEmpty();
extensions.disable("message-tags");
- assertThat(commands).doesNotContainKey(Command.TAGMSG);
+ assertThat(extensions.capabilityCommand(Command.TAGMSG)).isEmpty();
}
@Test
diff --git a/jircd-core/src/main/java/net/jircd/core/capability/CapabilityNegotiator.java b/jircd-core/src/main/java/net/jircd/core/capability/CapabilityNegotiator.java
index f45ef6a..b9259be 100644
--- a/jircd-core/src/main/java/net/jircd/core/capability/CapabilityNegotiator.java
+++ b/jircd-core/src/main/java/net/jircd/core/capability/CapabilityNegotiator.java
@@ -15,18 +15,28 @@
*/
package net.jircd.core.capability;
+import java.util.ArrayList;
+import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
+import java.util.Optional;
import java.util.Set;
import net.jircd.core.extension.ExtensionRegistry;
import net.jircd.core.session.ClientSession;
+import net.jircd.protocol.CapabilityName;
import net.jircd.protocol.CapabilityNegotiationGrammar;
/**
* The {@code CAP LS}/{@code REQ}/{@code ACK}/{@code NAK}/{@code END} state machine
* (FR-006/FR-007/FR-008). Gates registration completion while a negotiation is in progress. Always
- * available — never one of the toggleable extensions itself (FR-035); the offered/accepted list is
- * sourced live from {@link ExtensionRegistry}, never cached (FR-007).
+ * available — never one of the toggleable extensions itself (FR-035). The offered list is core
+ * {@code cap-notify} plus the published providers', read live from {@link ExtensionRegistry}, never
+ * cached (FR-007).
+ *
+ *
Every read-validate-commit of a session's negotiation runs under the capability runtime's
+ * administrative mutex ({@link #runUnderControl}), the same one a disable holds while it prunes, so
+ * a request racing a disable either lands before the prune and is pruned, or after it and is
+ * refused; it can never bring a withdrawn name back.
*/
public final class CapabilityNegotiator {
@@ -40,44 +50,71 @@ public void beginNegotiation(ClientSession session) {
session.setNegotiating(true);
}
+ /** Core cap-notify and every published provider's capability, sorted. */
public List currentlyOfferedCapabilityNames() {
- return extensionRegistry.offeredCapabilities().stream().map(Capability::name).toList();
+ List offered = new ArrayList<>(extensionRegistry.capabilitySnapshot().offeredNames());
+ offered.add(CapabilityName.CAP_NOTIFY);
+ offered.sort(Comparator.naturalOrder());
+ return offered;
+ }
+
+ /** Runs {@code action} under the administrative mutex CAP state shares with publication. */
+ public void runUnderControl(Runnable action) {
+ extensionRegistry.capabilityControl().runUnderControl(action);
}
/**
- * Whether every requested name (a {@code -name} removal is checked by its bare name) is currently
- * offered. Changes nothing, so the caller can size the reply before committing.
+ * The explicit set {@code requested} would leave {@code session} with, applying its tokens in
+ * order, or empty if the whole request must be refused. Changes nothing. A positive token must be
+ * offered now. A {@code -name} token is accepted if the name is held at that point (offered or
+ * not), or if it is a known capability that is simply not held (for example one a disable already
+ * pruned); an unknown, unheld name refuses the request. Notification support is not removable by
+ * a client that indicated {@code CAP LS 302}. An empty request is refused.
*/
- public boolean isAcceptable(List requested) {
+ public Optional> candidate(ClientSession session, List requested) {
+ if (requested.isEmpty()) return Optional.empty();
Set offered = new HashSet<>(currentlyOfferedCapabilityNames());
- return requested.stream().allMatch(name -> offered.contains(bareName(name)));
+ Set candidate = new HashSet<>(session.negotiatedCapabilities());
+ for (String token : requested) {
+ if (!isRemoval(token)) {
+ if (!offered.contains(token)) return Optional.empty();
+ candidate.add(token);
+ continue;
+ }
+ String name = bareName(token);
+ if (name.equals(CapabilityName.CAP_NOTIFY)
+ && session.highestCapVersion() >= CapabilityNegotiationGrammar.NOTIFY_VERSION) {
+ return Optional.empty();
+ }
+ if (!candidate.remove(name) && !isKnown(name)) return Optional.empty();
+ }
+ return Optional.of(candidate);
}
/**
- * Applies a {@code CAP REQ} atomically: the session changes only if {@link #isAcceptable}, and
- * then through one swap of the whole negotiated set. Returns whether the request was accepted;
- * the {@code ACK}/{@code NAK} echo is the caller's, built from the client's original list so it
- * keeps order and duplicates (005-fix-batch-conformance FR-009).
+ * Whether {@code name} is core cap-notify or offered by any discovered provider, enabled or not.
*/
- public boolean request(ClientSession session, List requested) {
- if (!isAcceptable(requested)) {
- return false;
- }
- apply(session, requested);
- return true;
+ private boolean isKnown(String name) {
+ return name.equals(CapabilityName.CAP_NOTIFY)
+ || extensionRegistry.knownCapabilityNames().contains(name);
}
- /** Applies already-validated additions and removals as a single set replacement. */
- public void apply(ClientSession session, List requested) {
- Set updated = new HashSet<>(session.negotiatedCapabilities());
- for (String name : requested) {
- if (isRemoval(name)) {
- updated.remove(bareName(name));
- } else {
- updated.add(name);
- }
- }
- session.replaceNegotiatedCapabilities(updated);
+ /**
+ * Applies a {@code CAP REQ} atomically under the administrative mutex: the session changes only
+ * if the whole request is acceptable, and then through one swap of the whole negotiated set.
+ * Returns whether the request was accepted; the {@code ACK}/{@code NAK} echo is the caller's,
+ * built from the client's original list so it keeps order and duplicates
+ * (005-fix-batch-conformance FR-009).
+ */
+ public boolean request(ClientSession session, List requested) {
+ return extensionRegistry
+ .capabilityControl()
+ .underControl(
+ () -> {
+ Optional> accepted = candidate(session, requested);
+ accepted.ifPresent(session::replaceNegotiatedCapabilities);
+ return accepted.isPresent();
+ });
}
private static boolean isRemoval(String name) {
diff --git a/jircd-core/src/main/java/net/jircd/core/config/ConfigurationReloader.java b/jircd-core/src/main/java/net/jircd/core/config/ConfigurationReloader.java
index c6913a4..204363e 100644
--- a/jircd-core/src/main/java/net/jircd/core/config/ConfigurationReloader.java
+++ b/jircd-core/src/main/java/net/jircd/core/config/ConfigurationReloader.java
@@ -19,10 +19,8 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
-import net.jircd.core.extension.Extension;
+import java.util.Map;
import net.jircd.core.extension.ExtensionRegistry;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
/**
* The core, manually-triggered reload operation (research.md "Configuration reload mechanism") —
@@ -32,7 +30,15 @@
*/
public final class ConfigurationReloader {
- private static final Logger LOG = LoggerFactory.getLogger(ConfigurationReloader.class);
+ /**
+ * A reload that validated and was applied, with each extension whose reconciliation failed and
+ * why. A provider whose start fails ends FAILED without undoing the rest of the reload.
+ */
+ public record ReloadOutcome(ServerConfiguration configuration, Map failures) {
+ public ReloadOutcome {
+ failures = Map.copyOf(failures);
+ }
+ }
private final Path configPath;
private final ConfigurationLoader loader;
@@ -72,51 +78,52 @@ public Path configPath() {
* thread holds
*/
public ServerConfiguration reload() throws ConfigurationException, IOException {
+ return reloadWithOutcome().configuration();
+ }
+
+ /**
+ * As {@link #reload()}, also reporting each extension whose reconciliation failed.
+ *
+ * @throws IllegalStateException if called from inside a provider lifecycle callback
+ */
+ public ReloadOutcome reloadWithOutcome() throws ConfigurationException, IOException {
// Checked before the reload monitor is taken, so a callback never queues behind a reload that
// is itself waiting for the lifecycle lock the callback holds.
extensionRegistry.requireNoLifecycleCallback();
return reloadSerialized();
}
- private synchronized ServerConfiguration reloadSerialized()
- throws ConfigurationException, IOException {
+ /**
+ * The whole desired configuration is validated before anything changes: the file, the reply
+ * budgets, and the candidate provider graph (hard dependencies present and acyclic, offers and
+ * owned slots unique). A validation failure changes no configuration, negotiation or
+ * notification. A valid reload is then one lifecycle operation that no {@code EXTENSION} toggle
+ * interleaves with: providers to enable start first, prerequisites first, then a single
+ * publication swaps the capability snapshot together with the new reply limits, the advertised
+ * lengths and {@link #current()}, and only then are withdrawn providers stopped. Enable never
+ * restores an earlier negotiation.
+ */
+ private synchronized ReloadOutcome reloadSerialized() throws ConfigurationException, IOException {
ServerConfiguration reloaded;
try (InputStream in = new FileInputStream(configPath.toFile())) {
reloaded = loader.load(in);
}
loader.checkReplyBudgets(
runningServerName, reloaded.nicknameMaxLength(), reloaded.channelNameMaxLength());
- reconcile(reloaded);
- this.current = reloaded;
- extensionRegistry.updateConfiguredLengths(
- reloaded.nicknameMaxLength(),
- reloaded.channelNameMaxLength(),
- reloaded.topicMaxLength(),
- reloaded.maxModesPerCommand());
- return reloaded;
- }
-
- private void reconcile(ServerConfiguration configuration) {
- reconcileSection(configuration.capabilityStates());
- reconcileSection(configuration.serverExtensionStates());
- }
-
- private void reconcileSection(java.util.Map desiredStates) {
- for (var entry : desiredStates.entrySet()) {
- String id = entry.getKey();
- boolean desiredEnabled = entry.getValue();
- Extension.State state = extensionRegistry.stateOf(id);
- boolean currentlyEnabled = state == Extension.State.ENABLED;
- try {
- if (desiredEnabled && !currentlyEnabled) {
- extensionRegistry.enable(id);
- } else if (!desiredEnabled) {
- // Idempotent; for a FAILED provider it retries the cleanup that is still pending.
- extensionRegistry.disable(id);
- }
- } catch (RuntimeException | LinkageError e) {
- LOG.warn("Failed to reconcile extension '{}' to desired state {}", id, desiredEnabled, e);
- }
+ ExtensionRegistry.DesiredState desired =
+ new ExtensionRegistry.DesiredState(
+ reloaded.capabilityStates(),
+ reloaded.serverExtensionStates(),
+ reloaded.nicknameMaxLength(),
+ reloaded.channelNameMaxLength(),
+ reloaded.topicMaxLength(),
+ reloaded.maxModesPerCommand());
+ Map failures;
+ try {
+ failures = extensionRegistry.applyDesiredState(desired, () -> this.current = reloaded);
+ } catch (ExtensionRegistry.InvalidDesiredStateException e) {
+ throw new ConfigurationException(e.getMessage());
}
+ return new ReloadOutcome(reloaded, failures);
}
}
diff --git a/jircd-core/src/main/java/net/jircd/core/extension/ExtensionRegistry.java b/jircd-core/src/main/java/net/jircd/core/extension/ExtensionRegistry.java
index 1d45d91..4419053 100644
--- a/jircd-core/src/main/java/net/jircd/core/extension/ExtensionRegistry.java
+++ b/jircd-core/src/main/java/net/jircd/core/extension/ExtensionRegistry.java
@@ -17,14 +17,21 @@
import java.util.ArrayList;
import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import net.jircd.core.capability.Capability;
import net.jircd.core.config.HostmaskReplyBudget;
+import net.jircd.core.extension.capability.CapabilityControl;
import net.jircd.core.extension.capability.CapabilityDescriptor;
import net.jircd.core.extension.capability.CapabilityRegistrar;
import net.jircd.core.extension.capability.CapabilityRuntime;
@@ -41,11 +48,10 @@
import net.jircd.core.session.CommittedEvents;
import net.jircd.core.session.UserMode;
import net.jircd.core.session.command.CommandHandler;
-import net.jircd.core.session.command.Replies;
+import net.jircd.protocol.CapabilityName;
+import net.jircd.protocol.CapabilityNegotiationGrammar;
import net.jircd.protocol.Command;
-import net.jircd.protocol.Message;
import net.jircd.protocol.MessageSerializer;
-import net.jircd.protocol.NumericReply;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -82,6 +88,7 @@ private static final class Entry {
private final Map byId = new ConcurrentHashMap<>();
private final Map extensionPointOwners = new ConcurrentHashMap<>();
+ private final AtomicReference> knownCapabilityNames = new AtomicReference<>(Set.of());
private volatile ServerContext serverContext;
private volatile SupportedFeatures supportedFeatures = SupportedFeatures.defaults();
@@ -93,27 +100,45 @@ private static final class Entry {
/**
* Serializes enable and disable. It is not the registry monitor and no message path takes it, so
* provider callbacks (start, register, stop) run while it is held without blocking delivery or
- * any registry read, and two lifecycle changes still never interleave.
+ * any registry read, and two lifecycle changes still never interleave. It is taken before the
+ * capability runtime's administrative mutex, never inside it; the publication itself runs under
+ * that short mutex, and provider callbacks never do.
*/
private final ReentrantLock lifecycle = new ReentrantLock();
/**
- * Commands each provider installed in core, so disable can remove exactly those. A record whose
- * removal failed stays here until a retry removes it.
+ * The handler for {@code command} if a published provider owns it, resolved from one read of the
+ * current snapshot: dispatch sees a provider and its commands together, before or after a
+ * publication, never a mix. Provider commands are never installed in core's command table, so a
+ * publication alone moves them; work that resolved a handler before a change may still run it
+ * afterwards, like any other captured work. Faults are contained and reported.
*/
- private final Map> installedCommands = new ConcurrentHashMap<>();
-
- /**
- * The exact handler instance a provider installed, so removal never touches a replacement. The
- * handler itself runs only while the current snapshot contains its provider.
- */
- private record InstalledCommand(Command command, CommandHandler handler) {}
+ public Optional capabilityCommand(Command command) {
+ return capabilityRuntime.snapshot().command(command).map(hookDispatcher::guardedCommand);
+ }
/** The currently published capability hooks; read-only, lifecycle stays with this registry. */
public RuntimeSnapshot capabilitySnapshot() {
return capabilityRuntime.snapshot();
}
+ /**
+ * The capability runtime's administrative mutex, shared by CAP state, live-session tracking,
+ * output capture and group admission. Publication itself goes only through this registry's
+ * lifecycle, so the runtime is not exposed.
+ */
+ public CapabilityControl capabilityControl() {
+ return capabilityRuntime.control();
+ }
+
+ /**
+ * Tracks {@code sessions}, every accepted connection, as the sessions a publication prunes and
+ * notifies. Their owner inserts, removes and attaches writers under {@link #capabilityControl}.
+ */
+ public void attachLiveSessions(Collection sessions) {
+ capabilityRuntime.attachLiveSessions(sessions);
+ }
+
/** The handle {@link CapabilityExtension}s receive through {@link ServerContext}. */
public CapabilityServices capabilityServices() {
return new CapabilityServices(capabilityRuntime::snapshot);
@@ -165,19 +190,23 @@ private static CapabilityDescriptor stage(CapabilityExtension capability) {
*/
public void attachContext(ServerContext serverContext) {
this.serverContext = serverContext;
+ capabilityRuntime.attachServerName(serverContext.serverName());
refreshReplyLimits();
}
/** Slot bounds are evaluated against the validated configuration, as startup and enable do. */
private void refreshReplyLimits() {
+ capabilityRuntime.setLimits(replyLimits(supportedFeatures));
+ }
+
+ private ReplyLimits replyLimits(SupportedFeatures features) {
ServerContext context = serverContext;
- SupportedFeatures features = supportedFeatures;
int serverBytes =
context == null
? ReplyLimits.REFERENCE.serverBytes()
: MessageSerializer.utf8Bytes(context.serverName().get());
- capabilityRuntime.setLimits(
- ReplyLimits.of(serverBytes, features.nicknameMaxLength(), features.channelNameMaxLength()));
+ return ReplyLimits.of(
+ serverBytes, features.nicknameMaxLength(), features.channelNameMaxLength());
}
/**
@@ -198,10 +227,40 @@ public void register(Extension extension) {
}
}
}
+ // Resolved before the entry is inserted, so a provider that cannot name its capability leaves
+ // nothing behind and can register again once fixed.
+ String name = capabilityName(extension);
Entry existing = byId.putIfAbsent(extension.id(), new Entry(extension));
if (existing != null && existing.extension != extension) {
throw new IllegalArgumentException("Duplicate extension id: " + extension.id());
}
+ if (existing == null && name != null) {
+ knownCapabilityNames.updateAndGet(
+ names -> {
+ Set grown = new HashSet<>(names);
+ grown.add(name);
+ return Set.copyOf(grown);
+ });
+ }
+ }
+
+ /** A capability provider's offered name, or {@code null} for a server extension. */
+ private static String capabilityName(Extension extension) {
+ if (!(extension instanceof CapabilityExtension capability)) return null;
+ try {
+ return capability.providedCapability().name();
+ } catch (RuntimeException | LinkageError e) {
+ throw new IllegalArgumentException(
+ "Extension '" + extension.id() + "' cannot name its capability: " + describe(e), e);
+ }
+ }
+
+ /**
+ * The capability names every discovered provider offers, enabled or not, recorded once at
+ * registration so CAP can tell a known name from an unknown one without calling a provider.
+ */
+ public Set knownCapabilityNames() {
+ return knownCapabilityNames.get();
}
/**
@@ -294,8 +353,8 @@ private void enableLocked(String id) {
+ "'");
}
}
- // A cleanup a failed disable left unfinished (leftover handlers, a stop that failed, the
- // extension-point claim) is completed before the provider starts afresh.
+ // A cleanup a failed disable left unfinished (a stop that failed, the extension-point claim)
+ // is completed before the provider starts afresh.
if (entry.cleanupPending) throwIfPresent(cleanUp(entry));
boolean enabled = false;
try {
@@ -313,7 +372,7 @@ private void enableLocked(String id) {
// first, so stop runs (and must tolerate that) and the provider is recorded as failed.
boolean stopFailed = stopQuietly(entry.extension);
entry.state = Extension.State.FAILED;
- entry.cleanupPending = stopFailed || installedCommands.containsKey(id);
+ entry.cleanupPending = stopFailed;
// A claim is only kept while cleanup is unfinished.
if (!entry.cleanupPending && point != null) extensionPointOwners.remove(point, id);
}
@@ -321,33 +380,16 @@ private void enableLocked(String id) {
}
/**
- * Stages and validates a provider's hooks, installs its commands, and only then publishes the
- * hooks in one atomic step, so no reader ever sees hooks of an enable that fails; on any failure
- * nothing is left published or registered, apart from handlers core could not remove, which are
- * kept for retry and refuse to run.
+ * Stages and validates a provider's hooks and commands, then publishes them in one atomic step:
+ * its commands are part of the snapshot, so no reader ever sees hooks or commands of an enable
+ * that fails, and nothing outside the snapshot has to be installed or rolled back.
*/
private void publish(CapabilityExtension capability) {
CapabilityDescriptor descriptor = stage(capability);
RuntimeSnapshot next = capabilityRuntime.snapshot().with(descriptor);
- validateReplyBudget(next);
- List commands = commandsOf(descriptor);
- requireUnownedCommands(commands);
- List done = new ArrayList<>();
- boolean published = false;
- try {
- installCommands(commands, done);
- capabilityRuntime.publish(descriptor);
- published = true;
- } finally {
- String id = descriptor.providerId();
- if (!done.isEmpty()) installedCommands.put(id, List.copyOf(done));
- if (!published) {
- // Any failure, including an Error, leaves neither a published hook nor a runnable handler.
- capabilityRuntime.withdraw(id);
- Throwable removal = removeInstalled(id);
- if (removal != null) LOG.warn("Rolling back commands of '{}' failed", id, removal);
- }
- }
+ validateReplyBudget(next, supportedFeatures);
+ requireUnownedCommands(commandsOf(descriptor));
+ capabilityRuntime.publish(descriptor);
}
private static List commandsOf(CapabilityDescriptor descriptor) {
@@ -356,76 +398,23 @@ private static List commandsOf(CapabilityDescriptor descriptor
.toList();
}
- /** A provider may add commands but never replace one core or another extension owns. */
+ /**
+ * A provider may add commands but never replace one core or a server extension owns. A command
+ * another published provider owns is the snapshot validation's to judge (it may be moving to this
+ * provider in the same reload), so it does not count here even though the registrar reports it as
+ * registered.
+ */
private void requireUnownedCommands(List commands) {
if (serverContext == null) return;
+ RuntimeSnapshot current = capabilityRuntime.snapshot();
for (HookRegistration registration : commands) {
- if (serverContext.commandRegistrar().isRegistered(registration.command())) {
+ Command command = registration.command();
+ if (serverContext.commandRegistrar().isRegistered(command)
+ && current.command(command).isEmpty()) {
throw new IllegalArgumentException(
- "Command " + registration.command() + " is already handled and cannot be replaced");
- }
- }
- }
-
- private void installCommands(List commands, List done) {
- if (serverContext == null) return;
- for (HookRegistration registration : commands) {
- // A command runs only while the published snapshot has its provider, so commands and hooks
- // become available, and stop being available, in the same atomic step.
- String providerId = registration.providerId();
- CommandHandler handler =
- hookDispatcher.guardedCommand(
- registration,
- () -> capabilityRuntime.snapshot().descriptor(providerId).isPresent(),
- this::refuseWithdrawn);
- serverContext.commandRegistrar().register(registration.command(), handler);
- done.add(new InstalledCommand(registration.command(), handler));
- }
- }
-
- /** A handler core could not remove answers as an unknown command once its provider is gone. */
- private void refuseWithdrawn(ClientSession session, Message message) {
- String command = message.rawCommand() != null ? message.rawCommand() : message.command().name();
- Replies.sendFitted(
- session,
- serverContext.serverName().get(),
- NumericReply.ERR_UNKNOWNCOMMAND,
- List.of(command),
- "Unknown command");
- }
-
- /**
- * Attempts every removal of the provider's handlers. A record whose removal failed stays for a
- * retry; the first failure is returned with the later distinct ones suppressed.
- */
- private Throwable removeInstalled(String id) {
- List records = installedCommands.get(id);
- if (records == null) return null;
- Throwable first = null;
- List remaining = new ArrayList<>();
- for (InstalledCommand installed : records) {
- try {
- serverContext.commandRegistrar().unregister(installed.command(), installed.handler());
- } catch (RuntimeException | LinkageError e) {
- first = accumulate(first, e);
- remaining.add(installed);
+ "Command " + command + " is already handled and cannot be replaced");
}
}
- if (remaining.isEmpty()) {
- installedCommands.remove(id);
- } else {
- installedCommands.put(id, List.copyOf(remaining));
- }
- return first;
- }
-
- /**
- * Keeps the first failure and suppresses later distinct ones (a repeat is not self-suppressed).
- */
- private static Throwable accumulate(Throwable first, Throwable next) {
- if (first == null) return next;
- if (first != next) first.addSuppressed(next);
- return first;
}
private static void throwIfPresent(Throwable failure) {
@@ -439,16 +428,28 @@ private static void throwIfPresent(Throwable failure) {
}
}
- /** The same bound checker the configuration loader uses, against the actual active slots. */
- private void validateReplyBudget(RuntimeSnapshot active) {
+ /**
+ * The same bound checker the configuration loader uses, against the actual active slots, and the
+ * one-line {@code CAP LS} an older client must get: core cap-notify plus every offer, addressed
+ * to a nickname of the configured maximum length. An offer set that cannot fit is rejected rather
+ * than truncated.
+ */
+ private void validateReplyBudget(RuntimeSnapshot active, SupportedFeatures features) {
ServerContext context = serverContext;
if (context == null) return;
- SupportedFeatures features = supportedFeatures;
+ String server = context.serverName().get();
HostmaskReplyBudget.requireFits(
- net.jircd.protocol.MessageSerializer.utf8Bytes(context.serverName().get()),
+ net.jircd.protocol.MessageSerializer.utf8Bytes(server),
features.nicknameMaxLength(),
features.channelNameMaxLength(),
active.replyBudgetCatalog());
+ List offers = new ArrayList<>(active.offeredNames());
+ offers.add(CapabilityName.CAP_NOTIFY);
+ if (!CapabilityNegotiationGrammar.fitsOneLine(
+ server, "x".repeat(features.nicknameMaxLength()), "LS", offers)) {
+ throw new IllegalArgumentException(
+ "The offered capabilities would not fit one CAP LS line for a pre-302 client");
+ }
}
/** Whether {@code stop} failed; the failure is logged, not thrown. */
@@ -462,9 +463,393 @@ private static boolean stopQuietly(Extension extension) {
}
}
+ /** A desired configuration that is invalid as a whole; nothing was changed. */
+ public static final class InvalidDesiredStateException extends IllegalArgumentException {
+ public InvalidDesiredStateException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+
+ /** The extension states and configured lengths a validated configuration asks for. */
+ public record DesiredState(
+ Map capabilities,
+ Map serverExtensions,
+ int nicknameMaxLength,
+ int channelNameMaxLength,
+ int topicMaxLength,
+ int maxModesPerCommand) {
+ public DesiredState {
+ capabilities = Map.copyOf(capabilities);
+ serverExtensions = Map.copyOf(serverExtensions);
+ }
+ }
+
+ /**
+ * Applies a whole desired configuration as one lifecycle operation: the lifecycle lock is held
+ * throughout, so no {@code EXTENSION} toggle interleaves. The candidate provider graph is
+ * validated first (hard dependencies present and acyclic, offers, owned keys, slots and commands
+ * unique, no command core or a server extension handles, reply budgets at the new lengths); if it
+ * is invalid nothing changes and no provider is started or stopped. Providers to enable are then
+ * started, prerequisites first, outside the administrative mutex. One publication then swaps in
+ * the new snapshot with the new reply limits, prunes every session, queues {@code DEL}/{@code
+ * NEW}, and runs {@code publishConfiguration} in the same critical section. Withdrawn providers
+ * are stopped afterwards, each before what it requires. A provider whose start fails ends {@code
+ * FAILED} alone (with any dependent in the same reload); the rest still applies. Server
+ * extensions are reconciled one by one inside the same lifecycle hold.
+ *
+ * @return each extension that could not reach its desired state, with why
+ * @throws InvalidDesiredStateException if the desired provider graph is invalid; nothing changed
+ */
+ public Map applyDesiredState(
+ DesiredState desired, Runnable publishConfiguration) {
+ requireNoLifecycleCallback();
+ lifecycle.lock();
+ try {
+ Map failures = new LinkedHashMap<>();
+ applyCapabilities(desired, publishConfiguration, failures);
+ reconcileServerExtensions(desired.serverExtensions(), failures);
+ return failures;
+ } finally {
+ lifecycle.unlock();
+ }
+ }
+
+ /** What a valid reload will enable and withdraw, decided before anything changes. */
+ private record CapabilityPlan(
+ List toStart,
+ Map stagingFailures,
+ List withdrawn,
+ SupportedFeatures features) {}
+
+ /** The working state of one reload's capability batch. */
+ private static final class Batch {
+ final Map failures;
+ final Set failed = new HashSet<>();
+ final List started = new ArrayList<>();
+
+ Batch(Map failures) {
+ this.failures = failures;
+ }
+
+ void fail(String id, String why) {
+ failures.put(id, why);
+ failed.add(id);
+ }
+ }
+
+ private void applyCapabilities(
+ DesiredState desired, Runnable publishConfiguration, Map failures) {
+ CapabilityPlan plan = planCapabilities(desired);
+ Batch batch = new Batch(failures);
+ plan.stagingFailures()
+ .forEach(
+ (id, cause) -> {
+ markFailed(byId.get(id), false);
+ batch.fail(id, describe(cause));
+ });
+ boolean published = false;
+ try {
+ for (CapabilityDescriptor descriptor : plan.toStart()) {
+ if (startForBatch(descriptor, batch)) batch.started.add(descriptor);
+ }
+ publishBatch(plan, batch, publishConfiguration);
+ published = true;
+ } finally {
+ if (!published) {
+ // Something escaped: nothing was published, so nothing started here may stay running.
+ // Dependents are rolled back before the providers they require.
+ for (CapabilityDescriptor descriptor : batch.started.reversed()) {
+ rollBackStart(byId.get(descriptor.providerId()));
+ }
+ }
+ }
+ finishWithdrawn(plan.withdrawn(), desired, failures);
+ }
+
+ /**
+ * Stops the withdrawn providers, each before what it requires, then retries the cleanup of any
+ * FAILED provider desired disabled whose cleanup was unfinished before this reload. A provider
+ * this reload already stopped is not stopped again: its failure is reported, and the next disable
+ * or reload retries it.
+ */
+ private void finishWithdrawn(
+ List withdrawn, DesiredState desired, Map failures) {
+ for (String id : withdrawn) {
+ Throwable failure = finish(byId.get(id));
+ if (failure != null) failures.put(id, describe(failure));
+ }
+ for (var entry : desired.capabilities().entrySet()) {
+ Entry pending = byId.get(entry.getKey());
+ if (Boolean.FALSE.equals(entry.getValue())
+ && pending != null
+ && pending.cleanupPending
+ && !withdrawn.contains(entry.getKey())) {
+ Throwable failure = finish(pending);
+ if (failure != null) failures.put(entry.getKey(), describe(failure));
+ }
+ }
+ }
+
+ /**
+ * Stages every provider to enable and validates the whole candidate graph against the current
+ * snapshot. A provider whose own staging throws is a provider fault, not an invalid
+ * configuration: it, and any staged provider requiring it, end FAILED and are left out.
+ */
+ private CapabilityPlan planCapabilities(DesiredState desired) {
+ RuntimeSnapshot current = capabilityRuntime.snapshot();
+ SupportedFeatures features =
+ supportedFeatures.withConfiguredLengths(
+ desired.nicknameMaxLength(),
+ desired.channelNameMaxLength(),
+ desired.topicMaxLength(),
+ desired.maxModesPerCommand());
+ Map staged = new TreeMap<>();
+ Map stagingFailures = new TreeMap<>();
+ Set withdrawRoots = new TreeSet<>();
+ for (var entry : desired.capabilities().entrySet()) {
+ classify(entry.getKey(), entry.getValue(), staged, stagingFailures, withdrawRoots);
+ }
+ List withdrawn = current.withdrawalOrder(withdrawRoots);
+ requireNoneDesiredEnabled(withdrawn, desired);
+ dropDependentsOf(stagingFailures, staged);
+ validateCandidate(current, withdrawn, staged.values(), features);
+ return new CapabilityPlan(
+ prerequisitesFirst(staged.values()), stagingFailures, withdrawn, features);
+ }
+
+ /** Sorts one desired capability into the withdrawals, the stagings or the staging failures. */
+ private void classify(
+ String id,
+ Boolean desiredEnabled,
+ Map staged,
+ Map stagingFailures,
+ Set withdrawRoots) {
+ Entry known = byId.get(id);
+ if (known == null || !(known.extension instanceof CapabilityExtension capability)) return;
+ if (Boolean.FALSE.equals(desiredEnabled)) {
+ withdrawRoots.add(id);
+ } else if (known.state != Extension.State.ENABLED) {
+ try {
+ staged.put(id, stage(capability));
+ } catch (RuntimeException | LinkageError e) {
+ stagingFailures.put(id, e);
+ }
+ }
+ }
+
+ /** A provider desired enabled must not be pulled out by a disable's dependent cascade. */
+ private static void requireNoneDesiredEnabled(List withdrawn, DesiredState desired) {
+ for (String id : withdrawn) {
+ if (Boolean.TRUE.equals(desired.capabilities().get(id))) {
+ throw new InvalidDesiredStateException(
+ "Capability '"
+ + id
+ + "' is desired enabled but requires a provider this configuration disables",
+ null);
+ }
+ }
+ }
+
+ /**
+ * The published providers minus the withdrawn, plus the staged, must form a valid snapshot, and
+ * no staged provider may claim a command core or a server extension owns. No server extension
+ * starts or registers a command while the lifecycle lock is held for this reload's capability
+ * batch, so the check still holds when the batch is published.
+ */
+ private void validateCandidate(
+ RuntimeSnapshot current,
+ List withdrawn,
+ Collection staged,
+ SupportedFeatures features) {
+ List candidate = new ArrayList<>();
+ for (CapabilityDescriptor descriptor : current.descriptors()) {
+ if (!withdrawn.contains(descriptor.providerId())) candidate.add(descriptor);
+ }
+ candidate.addAll(staged);
+ try {
+ validateReplyBudget(RuntimeSnapshot.of(candidate), features);
+ for (CapabilityDescriptor descriptor : staged) requireUnownedCommands(commandsOf(descriptor));
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ throw new InvalidDesiredStateException(
+ "Invalid capability configuration: " + e.getMessage(), e);
+ }
+ }
+
+ /** Moves every staged provider that transitively requires a failed one into the failures. */
+ private static void dropDependentsOf(
+ Map failures, Map staged) {
+ boolean grew = true;
+ while (grew) {
+ grew = false;
+ for (CapabilityDescriptor descriptor : List.copyOf(staged.values())) {
+ String missing =
+ descriptor.requiresProviders().stream()
+ .filter(failures::containsKey)
+ .findFirst()
+ .orElse(null);
+ if (missing != null) {
+ staged.remove(descriptor.providerId());
+ failures.put(
+ descriptor.providerId(),
+ new IllegalStateException("requires '" + missing + "', which failed"));
+ grew = true;
+ }
+ }
+ }
+ }
+
+ /** {@code descriptors} ordered so each comes after the providers it requires among them. */
+ private static List prerequisitesFirst(
+ Collection descriptors) {
+ Map byProvider = new TreeMap<>();
+ descriptors.forEach(d -> byProvider.put(d.providerId(), d));
+ List ordered = new ArrayList<>();
+ Set placed = new HashSet<>();
+ for (String id : byProvider.keySet()) placePrerequisitesFirst(id, byProvider, placed, ordered);
+ return ordered;
+ }
+
+ private static void placePrerequisitesFirst(
+ String id,
+ Map byProvider,
+ Set placed,
+ List ordered) {
+ if (!placed.add(id)) return;
+ CapabilityDescriptor descriptor = byProvider.get(id);
+ for (String required : new TreeSet<>(descriptor.requiresProviders())) {
+ if (byProvider.containsKey(required)) {
+ placePrerequisitesFirst(required, byProvider, placed, ordered);
+ }
+ }
+ ordered.add(descriptor);
+ }
+
+ /**
+ * Starts one provider of a batch, outside the administrative mutex. Its commands need no
+ * installation: they were validated with the candidate and are part of the descriptor the batch
+ * publishes, so a command a withdrawn provider owned moves to its new owner in that one
+ * publication. Returns whether it is ready to publish; if its start fails it is stopped and
+ * FAILED, and nothing about it is visible. A provider that never starts is never stopped here.
+ */
+ private boolean startForBatch(CapabilityDescriptor descriptor, Batch batch) {
+ String id = descriptor.providerId();
+ Entry entry = byId.get(id);
+ if (!mayStart(descriptor, entry, batch)) return false;
+ boolean ready = false;
+ try {
+ entry.extension.start(serverContext);
+ ready = true;
+ } catch (RuntimeException | LinkageError e) {
+ batch.failures.put(id, describe(e));
+ } finally {
+ if (!ready) {
+ batch.failed.add(id);
+ rollBackStart(entry);
+ }
+ }
+ return ready;
+ }
+
+ /**
+ * Whether a provider of the batch may start: none of its prerequisites failed in this batch, and
+ * any cleanup an earlier failure left is finished first. Otherwise it is recorded as failed.
+ */
+ private boolean mayStart(CapabilityDescriptor descriptor, Entry entry, Batch batch) {
+ String id = descriptor.providerId();
+ String missing =
+ descriptor.requiresProviders().stream()
+ .filter(batch.failed::contains)
+ .findFirst()
+ .orElse(null);
+ if (missing != null) {
+ markFailed(entry, false);
+ batch.fail(id, "requires '" + missing + "', which failed");
+ return false;
+ }
+ if (entry.cleanupPending) {
+ Throwable leftover = cleanUp(entry);
+ if (leftover != null) {
+ batch.fail(id, describe(leftover));
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Undoes a start that will not be published: stopped and recorded FAILED. */
+ private void rollBackStart(Entry entry) {
+ markFailed(entry, stopQuietly(entry.extension));
+ }
+
+ /**
+ * Records {@code entry} FAILED. A cleanup already owed (an earlier stop that failed) stays owed
+ * until a cleanup actually succeeds; this never clears it.
+ */
+ private static void markFailed(Entry entry, boolean stopFailed) {
+ entry.state = Extension.State.FAILED;
+ if (stopFailed) entry.cleanupPending = true;
+ }
+
+ /**
+ * The reload's one publication. The lifecycle lock has been held since the candidate graph was
+ * validated, so the providers it publishes are the ones validated; if it were refused anyway, the
+ * caller rolls back every start and nothing is published. Commands move with the snapshot.
+ */
+ private void publishBatch(CapabilityPlan plan, Batch batch, Runnable publishConfiguration) {
+ ReplyLimits limits = replyLimits(plan.features());
+ // The callback runs first: if it throws, the lengths stay as they were and nothing is
+ // published.
+ Runnable alongside =
+ () -> {
+ publishConfiguration.run();
+ supportedFeatures = plan.features();
+ };
+ Set withdrawn = Set.copyOf(plan.withdrawn());
+ List started = List.copyOf(batch.started);
+ capabilityRuntime.publish(
+ current -> withChanges(current, withdrawn, started).withLimits(limits), alongside);
+ for (CapabilityDescriptor descriptor : started) {
+ Entry entry = byId.get(descriptor.providerId());
+ entry.state = Extension.State.ENABLED;
+ entry.cleanupPending = false;
+ }
+ }
+
+ private static RuntimeSnapshot withChanges(
+ RuntimeSnapshot current, Set withdrawn, List added) {
+ List next = new ArrayList<>();
+ for (CapabilityDescriptor descriptor : current.descriptors()) {
+ if (!withdrawn.contains(descriptor.providerId())) next.add(descriptor);
+ }
+ next.addAll(added);
+ return RuntimeSnapshot.of(next);
+ }
+
+ private void reconcileServerExtensions(
+ Map desiredStates, Map failures) {
+ for (var entry : new TreeMap<>(desiredStates).entrySet()) {
+ String id = entry.getKey();
+ try {
+ if (Boolean.TRUE.equals(entry.getValue())) {
+ enableLocked(id);
+ } else {
+ // Idempotent; for a FAILED provider it retries the cleanup that is still pending.
+ disableLocked(id);
+ }
+ } catch (RuntimeException | LinkageError e) {
+ LOG.warn("Failed to reconcile extension '{}' to desired state {}", id, entry.getValue(), e);
+ failures.put(id, describe(e));
+ }
+ }
+ }
+
+ private static String describe(Throwable failure) {
+ return failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage();
+ }
+
/**
* Disables the named extension. Cleanup completes fully or the provider is recorded FAILED:
- * withdrawn from every new capture, with any handler core could not remove refusing to run. A
+ * withdrawn from every new capture, its commands included (they are part of the snapshot). A
* FAILED provider whose cleanup is unfinished keeps its {@code extensionPoint} claim, so no other
* provider can take the point while its resources may still be live; calling {@code disable}
* again retries the cleanup, and the claim is released when that succeeds. Provider callbacks run
@@ -493,22 +878,52 @@ private void disableLocked(String id) {
}
/**
- * Withdraws the provider (which may refuse while a published dependent needs it), removes its
- * handlers and stops it. A clean run leaves it DISABLED and releases its extension-point claim; a
- * failure leaves it FAILED, withdrawn, with the claim and the unfinished work kept for a retry.
- * Returns the failure instead of throwing so callers decide.
+ * Withdraws the provider together with every published provider that requires it, in one
+ * publication that also prunes their names from every live session and queues {@code DEL}; then
+ * stops them, dependents first, outside the administrative mutex. Their commands left with the
+ * snapshot. A clean run leaves the provider DISABLED and releases its extension-point claim; a
+ * failure leaves it FAILED, withdrawn, with the claim and the unfinished work kept for a retry. A
+ * withdrawn dependent is finished the same way and keeps its own outcome. Returns the failure
+ * instead of throwing so callers decide: the provider's own, or else the first dependent's, so a
+ * dependent left FAILED is never reported as a clean disable. Every other failure is attached as
+ * suppressed.
*/
private Throwable cleanUp(Entry entry) {
String id = entry.extension.id();
- // Withdrawal comes first and may refuse (a published dependent); stop then runs outside any
- // snapshot, so callbacks already holding an older one finish against it.
- capabilityRuntime.withdraw(id);
- Throwable failure = removeInstalled(id);
+ List dependentFailures = new ArrayList<>();
+ // Withdrawal comes first; stop then runs outside any snapshot, so callbacks already holding an
+ // older one finish against it.
+ for (String dependent : capabilityRuntime.withdraw(id)) {
+ Entry other = byId.get(dependent);
+ if (dependent.equals(id) || other == null) continue;
+ Throwable dependentFailure = finish(other);
+ if (dependentFailure != null) {
+ dependentFailures.add(
+ new IllegalStateException(
+ "'"
+ + dependent
+ + "' failed to stop after its prerequisite '"
+ + id
+ + "' was withdrawn: "
+ + describe(dependentFailure),
+ dependentFailure));
+ }
+ }
+ Throwable failure = finish(entry);
+ if (failure == null && !dependentFailures.isEmpty()) failure = dependentFailures.removeFirst();
+ if (failure != null) dependentFailures.forEach(failure::addSuppressed);
+ return failure;
+ }
+
+ /** Stops an already withdrawn provider and records the outcome. */
+ private Throwable finish(Entry entry) {
+ String id = entry.extension.id();
+ Throwable failure = null;
try {
entry.extension.stop();
} catch (RuntimeException | LinkageError e) {
// Already withdrawn: it stays withdrawn, recorded as failed rather than half-enabled.
- failure = accumulate(failure, e);
+ failure = e;
}
entry.state = failure == null ? Extension.State.DISABLED : Extension.State.FAILED;
entry.cleanupPending = failure != null;
@@ -553,12 +968,12 @@ public Collection recognizedUserModes(Collection coreModes)
return result;
}
- /** The capability list currently offered — live, never cached (FR-007/FR-035). */
+ /**
+ * The provider capabilities currently offered: the published snapshot's, live, never cached
+ * (FR-007/FR-035). Core cap-notify is not a provider and is not in it.
+ */
public Collection offeredCapabilities() {
- return enabled().stream()
- .filter(CapabilityExtension.class::isInstance)
- .map(e -> ((CapabilityExtension) e).providedCapability())
- .toList();
+ return capabilityRuntime.snapshot().offeredNames().stream().map(Capability::new).toList();
}
public SupportedFeatures supportedFeatures() {
diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityControl.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityControl.java
new file mode 100644
index 0000000..0bc5b50
--- /dev/null
+++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityControl.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.core.extension.capability;
+
+import java.util.function.Supplier;
+import net.jircd.core.session.ClientSession;
+
+/**
+ * The administrative mutex of the {@link CapabilityRuntime}, without its publication methods: what
+ * CAP handling, live-session tracking, output capture and group admission need. Actions run here
+ * must be short core code that calls no provider and does no socket I/O; appending to one session
+ * writer's queue is allowed.
+ */
+public final class CapabilityControl {
+
+ private final CapabilityRuntime runtime;
+
+ CapabilityControl(CapabilityRuntime runtime) {
+ this.runtime = runtime;
+ }
+
+ /** Runs {@code action} under the administrative mutex. */
+ public void runUnderControl(Runnable action) {
+ runtime.runUnderControl(action);
+ }
+
+ /** Computes {@code action} under the administrative mutex. */
+ public T underControl(Supplier action) {
+ return runtime.underControl(action);
+ }
+
+ /** The current snapshot and {@code session}'s negotiation, captured together. */
+ public CapabilityRuntime.CapturedView capture(ClientSession session) {
+ return runtime.capture(session);
+ }
+}
diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRuntime.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRuntime.java
index 70955a6..0a3afb8 100644
--- a/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRuntime.java
+++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRuntime.java
@@ -15,23 +15,100 @@
*/
package net.jircd.core.extension.capability;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import java.util.function.UnaryOperator;
+import net.jircd.core.session.ClientSession;
+import net.jircd.core.session.SessionWriter;
+import net.jircd.protocol.CapabilityNegotiationGrammar;
+import net.jircd.protocol.Message;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
- * The live holder of the current {@link RuntimeSnapshot}. Publication is serialized and atomic:
- * readers see the old or the new snapshot, and a rejected change leaves the old one in place.
+ * The live holder of the current {@link RuntimeSnapshot}, and owner of the administrative mutex.
+ *
+ * The mutex is short and never held across a provider callback, socket I/O or a channel monitor.
+ * It serializes snapshot publication together with the physical prune of withdrawn names from every
+ * live session, CAP state changes and their replies, live-session insertion, removal and writer
+ * attachment, coherent captures, and the current prerequisite choice a group makes at admission.
+ * Whole lifecycle transitions (provider start, register, stop) are serialized by the extension
+ * registry's own lock, taken before this one, never after. Lock order: lifecycle lock, then this
+ * mutex, then at most one session writer's queue lock.
+ *
+ *
A publication swaps the snapshot, prunes, and then appends {@code CAP DEL}/{@code NEW} to
+ * eligible writers while still holding the mutex; appends never block, and an overflowing recipient
+ * is handed to its own cleanup. Work that captured an older snapshot keeps it and may finish after
+ * the {@code DEL}; the next capture sees the new state.
*/
public final class CapabilityRuntime {
+
+ /** A coherent view captured under the mutex: one snapshot and one session's negotiation. */
+ public record CapturedView(RuntimeSnapshot snapshot, Set negotiated) {}
+
+ private final Object control = new Object();
+ private final CapabilityControl facade = new CapabilityControl(this);
private final AtomicReference snapshot =
new AtomicReference<>(RuntimeSnapshot.EMPTY);
+ private static final Logger LOG = LoggerFactory.getLogger(CapabilityRuntime.class);
+
+ private final AtomicReference> liveSessions =
+ new AtomicReference<>(List.of());
+ private final AtomicReference> serverName = new AtomicReference<>(() -> "*");
public RuntimeSnapshot snapshot() {
return snapshot.get();
}
+ /** The mutex alone, for code that must not publish. */
+ public CapabilityControl control() {
+ return facade;
+ }
+
+ /**
+ * Tracks {@code sessions} — every accepted connection, named or not — as the sessions a
+ * publication prunes and notifies. The owner inserts and removes them through {@link
+ * #runUnderControl}, so no session escapes a prune.
+ */
+ public void attachLiveSessions(Collection sessions) {
+ this.liveSessions.set(Objects.requireNonNull(sessions));
+ }
+
+ /** The server name notifications are sent from. */
+ public void attachServerName(Supplier name) {
+ this.serverName.set(Objects.requireNonNull(name));
+ }
+
+ /** Runs {@code action} under the administrative mutex. It must not call a provider. */
+ public void runUnderControl(Runnable action) {
+ synchronized (control) {
+ action.run();
+ }
+ }
+
+ /** Computes {@code action} under the administrative mutex. It must not call a provider. */
+ public T underControl(Supplier action) {
+ synchronized (control) {
+ return action.get();
+ }
+ }
+
+ /** Captures the current snapshot and {@code session}'s negotiation together. */
+ public CapturedView capture(ClientSession session) {
+ synchronized (control) {
+ return new CapturedView(snapshot.get(), session.negotiatedCapabilities());
+ }
+ }
+
/** Replaces the configured limits slot bounds are evaluated against; providers unchanged. */
- public synchronized void setLimits(ReplyLimits limits) {
- snapshot.set(snapshot.get().withLimits(limits));
+ public void setLimits(ReplyLimits limits) {
+ publish(current -> current.withLimits(limits));
}
/**
@@ -39,22 +116,91 @@ public synchronized void setLimits(ReplyLimits limits) {
*
* @throws IllegalArgumentException if it conflicts with a published provider; nothing changes
*/
- public synchronized RuntimeSnapshot publish(CapabilityDescriptor descriptor) {
- RuntimeSnapshot next = snapshot.get().with(descriptor);
- snapshot.set(next);
- return next;
+ public RuntimeSnapshot publish(CapabilityDescriptor descriptor) {
+ return publish(current -> current.with(descriptor));
}
/**
- * Withdraws {@code providerId} if published.
- *
- * @throws IllegalStateException if a published provider still requires it
+ * Withdraws {@code providerId} together with every published provider that requires it, in one
+ * publication. Returns the provider ids withdrawn, each before every provider it requires (the
+ * order to stop them in); empty if {@code providerId} was not published.
+ */
+ public Set withdraw(String providerId) {
+ synchronized (control) {
+ Set closure = snapshot.get().withdrawalClosure(providerId);
+ if (!closure.isEmpty()) publish(current -> current.withoutAll(closure));
+ return closure;
+ }
+ }
+
+ /**
+ * Builds the next snapshot from the current one and, in the same critical section, swaps it in,
+ * removes every name no longer offered from every live session, and queues {@code DEL} then
+ * {@code NEW} to the eligible ones. {@code change} must be pure core code; if it throws, nothing
+ * changes. A change that leaves the offered set as it was queues nothing.
*/
- public synchronized RuntimeSnapshot withdraw(String providerId) {
- RuntimeSnapshot current = snapshot.get();
- if (current.descriptor(providerId).isEmpty()) return current;
- RuntimeSnapshot next = current.without(providerId);
- snapshot.set(next);
- return next;
+ public RuntimeSnapshot publish(UnaryOperator change) {
+ return publish(change, () -> {});
+ }
+
+ /**
+ * As {@link #publish(UnaryOperator)}, also running {@code alongside} in the same critical
+ * section, right before the swap, so related configuration a reload publishes becomes visible to
+ * captures together with the snapshot. {@code alongside} must be core code that only publishes
+ * values; it runs whether or not the offered set changed. If it throws, the snapshot is not
+ * swapped and nothing is pruned or queued: from the swap on, nothing that can fail runs before
+ * the prune and the per-recipient notifications.
+ */
+ public RuntimeSnapshot publish(UnaryOperator change, Runnable alongside) {
+ synchronized (control) {
+ RuntimeSnapshot before = snapshot.get();
+ RuntimeSnapshot next = Objects.requireNonNull(change.apply(before));
+ Set removed = new HashSet<>(before.offeredNames());
+ next.offeredNames().forEach(removed::remove);
+ Set added = new HashSet<>(next.offeredNames());
+ before.offeredNames().forEach(added::remove);
+ alongside.run();
+ snapshot.set(next);
+ if (removed.isEmpty() && added.isEmpty()) return next;
+ List sessions = new ArrayList<>(liveSessions.get());
+ for (ClientSession session : sessions) prune(session, removed);
+ for (ClientSession session : sessions) {
+ try {
+ notify(session, removed, added);
+ } catch (RuntimeException e) {
+ // The state is already published; one recipient's notification cannot undo it.
+ LOG.warn(
+ "Could not queue capability notification for connection {}",
+ session.connectionId(),
+ e);
+ }
+ }
+ return next;
+ }
+ }
+
+ private static void prune(ClientSession session, Set removed) {
+ Set negotiated = session.negotiatedCapabilities();
+ if (removed.stream().noneMatch(negotiated::contains)) return;
+ Set kept = new HashSet<>(negotiated);
+ kept.removeAll(removed);
+ session.replaceNegotiatedCapabilities(kept);
+ }
+
+ private void notify(ClientSession session, Set removed, Set added) {
+ // Owned and closed by the session's connection, not by this append.
+ @SuppressWarnings("PMD.CloseResource")
+ SessionWriter writer = session.writer();
+ if (writer == null || !session.notificationEligible()) return;
+ String target = session.nickname() != null ? session.nickname() : "*";
+ String server = serverName.get().get();
+ List lines = new ArrayList<>();
+ if (!removed.isEmpty()) {
+ lines.addAll(CapabilityNegotiationGrammar.notifications(server, target, "DEL", removed));
+ }
+ if (!added.isEmpty()) {
+ lines.addAll(CapabilityNegotiationGrammar.notifications(server, target, "NEW", added));
+ }
+ lines.forEach(writer::enqueueRaw);
}
}
diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java
index 5a93cec..1319f03 100644
--- a/jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java
+++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java
@@ -24,7 +24,6 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
-import java.util.function.BooleanSupplier;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
@@ -327,6 +326,18 @@ public FramingPlan outputGroup(
Set recipientNegotiated,
OutputGroup group,
RequestContext context) {
+ return prepareOutputGroup(snapshot, recipientNegotiated, group, context).framed();
+ }
+
+ /**
+ * As {@link #outputGroup}, keeping the plain alternative and the registration whose plan was
+ * chosen, so admission can still choose plain if that owner is no longer effective then.
+ */
+ public PreparedGroup prepareOutputGroup(
+ RuntimeSnapshot snapshot,
+ Set recipientNegotiated,
+ OutputGroup group,
+ RequestContext context) {
for (HookRegistration registration :
snapshot.hooksFor(HookKind.OUTPUT_GROUP, recipientNegotiated)) {
Optional plan =
@@ -334,9 +345,11 @@ public FramingPlan outputGroup(
registration,
() -> registration.hook(Hooks.OutputGroupHook.class).apply(group, context),
p -> validPlan(p, group));
- if (plan.isPresent()) return plan.get();
+ if (plan.isPresent()) {
+ return new PreparedGroup(plan.get(), FramingPlan.plain(group), registration);
+ }
}
- return FramingPlan.plain(group);
+ return PreparedGroup.plain(group);
}
/**
@@ -411,22 +424,8 @@ public void reportAggregateOverflow(HookRegistration registration) {
/** Wraps a provider command handler so its fault is contained and reported. */
public CommandHandler guardedCommand(HookRegistration registration) {
- return guardedCommand(registration, () -> true, (session, message) -> {});
- }
-
- /**
- * Wraps a provider command handler so its fault is contained and reported, and so it refuses to
- * run once {@code live} turns false (the provider was withdrawn but core could not remove the
- * handler): {@code refused} answers instead.
- */
- public CommandHandler guardedCommand(
- HookRegistration registration, BooleanSupplier live, CommandHandler refused) {
CommandHandler handler = registration.commandHandler();
return (session, message) -> {
- if (!live.getAsBoolean()) {
- refused.handle(session, message);
- return;
- }
try {
handler.handle(session, message);
} catch (RuntimeException | LinkageError _) {
diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/PreparedGroup.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/PreparedGroup.java
new file mode 100644
index 0000000..9bcdb22
--- /dev/null
+++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/PreparedGroup.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.core.extension.capability;
+
+/**
+ * A group's two complete, validated alternatives for one recipient, prepared outside every core
+ * lock from a captured view: the framing a provider chose ({@code owner}'s) and the plain members.
+ * At admission core picks one with a current, generic check of {@code owner}'s effectiveness for
+ * the recipient and appends it whole; no provider is called then. With no applicable framing,
+ * {@code owner} is {@code null} and both alternatives are the plain members.
+ */
+public record PreparedGroup(FramingPlan framed, FramingPlan plain, HookRegistration owner) {
+
+ /** The members with no framing, whatever the current state. */
+ public static PreparedGroup plain(OutputGroup group) {
+ FramingPlan plain = FramingPlan.plain(group);
+ return new PreparedGroup(plain, plain, null);
+ }
+}
diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/RuntimeSnapshot.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/RuntimeSnapshot.java
index 5ccaf08..4b97334 100644
--- a/jircd-core/src/main/java/net/jircd/core/extension/capability/RuntimeSnapshot.java
+++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/RuntimeSnapshot.java
@@ -17,16 +17,20 @@
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
+import java.util.TreeSet;
+import net.jircd.protocol.Command;
/**
* An immutable, coherent table of published providers and their hook indexes in deterministic
@@ -40,10 +44,11 @@ public final class RuntimeSnapshot {
private final Map providers;
private final Map> hooks;
private final ReplyLimits limits;
+ private final Map commands;
private RuntimeSnapshot(Map providers, ReplyLimits limits) {
this.limits = limits;
- this.providers = Map.copyOf(providers);
+ this.providers = Collections.unmodifiableMap(new TreeMap<>(providers));
Map> index = new EnumMap<>(HookKind.class);
for (HookKind kind : HookKind.values()) index.put(kind, new ArrayList<>());
providers.values().stream()
@@ -51,6 +56,11 @@ private RuntimeSnapshot(Map providers, ReplyLimits
.forEach(d -> d.registrations().forEach(r -> index.get(r.kind()).add(r)));
index.replaceAll((kind, list) -> List.copyOf(list));
this.hooks = index;
+ Map byCommand = new EnumMap<>(Command.class);
+ for (HookRegistration registration : index.get(HookKind.COMMAND)) {
+ byCommand.put(registration.command(), registration);
+ }
+ this.commands = Collections.unmodifiableMap(byCommand);
}
/**
@@ -132,22 +142,74 @@ public RuntimeSnapshot with(CapabilityDescriptor descriptor) {
}
/**
- * A snapshot without {@code providerId}.
- *
- * @throws IllegalStateException if another published provider still requires it
+ * {@code providerId} and every published provider that requires it, directly or transitively:
+ * what one withdrawal takes out together, in {@link #withdrawalOrder} order. Empty if {@code
+ * providerId} is not published.
+ */
+ public Set withdrawalClosure(String providerId) {
+ return new LinkedHashSet<>(withdrawalOrder(Set.of(providerId)));
+ }
+
+ /**
+ * The published providers among {@code roots} and every published provider that requires one of
+ * them, transitively, ordered so each comes before every provider it requires: the order to stop
+ * them in. Deterministic; unpublished roots are ignored.
*/
- public RuntimeSnapshot without(String providerId) {
- for (CapabilityDescriptor other : providers.values()) {
- if (other.requiresProviders().contains(providerId)) {
- throw new IllegalStateException(
- "provider '" + providerId + "' is required by '" + other.providerId() + "'");
+ public List withdrawalOrder(Collection roots) {
+ List ordered = new ArrayList<>();
+ Set placed = new HashSet<>();
+ for (String root : new TreeSet<>(roots)) {
+ if (providers.containsKey(root)) placeDependentsFirst(root, placed, ordered);
+ }
+ return ordered;
+ }
+
+ private void placeDependentsFirst(String id, Set placed, List ordered) {
+ if (!placed.add(id)) return;
+ for (CapabilityDescriptor dependent : providers.values()) {
+ if (dependent.requiresProviders().contains(id)) {
+ placeDependentsFirst(dependent.providerId(), placed, ordered);
}
}
+ ordered.add(id);
+ }
+
+ /**
+ * The registration of the published provider that owns {@code command}, if any. Validation
+ * guarantees at most one owner.
+ */
+ public Optional command(Command command) {
+ return Optional.ofNullable(commands.get(command));
+ }
+
+ /** The published providers' descriptors, in provider-id order. */
+ public Collection descriptors() {
+ return providers.values();
+ }
+
+ /** A snapshot without the published providers in {@code providerIds}. */
+ public RuntimeSnapshot withoutAll(Set providerIds) {
Map remaining = new HashMap<>(providers);
- remaining.remove(providerId);
+ remaining.keySet().removeAll(providerIds);
return of(remaining.values()).withLimits(limits);
}
+ /** The capability names the published providers offer, sorted. Core cap-notify is not one. */
+ public List offeredNames() {
+ return providers.values().stream().map(CapabilityDescriptor::capabilityName).sorted().toList();
+ }
+
+ /**
+ * Whether {@code registration} is published in this snapshot and its owner and declared
+ * prerequisites are all in {@code negotiated}: the generic check a group admission makes against
+ * the current state, never keyed to a capability name.
+ */
+ public boolean effective(HookRegistration registration, Set negotiated) {
+ return hooks.get(registration.kind()).contains(registration)
+ && registration.ownerNegotiated(negotiated)
+ && registration.prerequisitesMet(negotiated);
+ }
+
/** The validated configured limits slot bounds are evaluated against at dispatch. */
public ReplyLimits limits() {
return limits;
diff --git a/jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java b/jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java
index 8d75f39..b91431d 100644
--- a/jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java
+++ b/jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java
@@ -20,12 +20,15 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.function.Consumer;
import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.capability.CapabilityRuntime;
import net.jircd.core.extension.capability.FramingPlan;
import net.jircd.core.extension.capability.HookDispatcher;
import net.jircd.core.extension.capability.HookKind;
import net.jircd.core.extension.capability.HookRegistration;
import net.jircd.core.extension.capability.OutputGroup;
+import net.jircd.core.extension.capability.PreparedGroup;
import net.jircd.core.extension.capability.RequestContext;
import net.jircd.core.extension.capability.RuntimeSnapshot;
import net.jircd.protocol.MessageSerializer;
@@ -44,11 +47,13 @@ public CapabilityTagRenderer(ExtensionRegistry extensionRegistry) {
@Override
public PreparedTags prepare(ClientSession recipient, OutboundMessage message) {
- Set negotiated = recipient.negotiatedCapabilities();
+ // One view per preparation, captured under the administrative mutex: the snapshot and this
+ // recipient's negotiation come from the same side of any publication and prune. Work prepared
+ // from it may be queued after a later DEL; it is not rechecked.
+ CapabilityRuntime.CapturedView view = extensionRegistry.capabilityControl().capture(recipient);
+ Set negotiated = view.negotiated();
if (negotiated.isEmpty()) return TagRenderer.NONE.prepare(recipient, message);
- // One captured view per preparation: the typed hooks and the unmigrated providers both come
- // from it, so a provider withdrawn after the capture contributes nothing inconsistent.
- RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot();
+ RuntimeSnapshot snapshot = view.snapshot();
// The tags an inbound-tag provider selected go to recipients that negotiated that provider.
Map forwarded = message.forwardedFor(negotiated);
boolean forwardClientTags = !forwarded.isEmpty();
@@ -89,13 +94,41 @@ private static boolean fitsAlongside(Map composed, Map append) {
+ if (group.owner() == null) {
+ append.accept(group.framed());
+ return;
+ }
+ extensionRegistry
+ .capabilityControl()
+ .runUnderControl(
+ () ->
+ append.accept(
+ extensionRegistry
+ .capabilitySnapshot()
+ .effective(group.owner(), recipient.negotiatedCapabilities())
+ ? group.framed()
+ : group.plain()));
}
@Override
diff --git a/jircd-core/src/main/java/net/jircd/core/session/ClientSession.java b/jircd-core/src/main/java/net/jircd/core/session/ClientSession.java
index d406f2d..37fe77b 100644
--- a/jircd-core/src/main/java/net/jircd/core/session/ClientSession.java
+++ b/jircd-core/src/main/java/net/jircd/core/session/ClientSession.java
@@ -23,6 +23,8 @@
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
+import net.jircd.protocol.CapabilityName;
+import net.jircd.protocol.CapabilityNegotiationGrammar;
/**
* A single connected client's live state, from TCP accept to disconnect. {@code connectionId} is
@@ -42,7 +44,8 @@ public final class ClientSession {
private final String connectionId;
private final ConnectionLifecycle lifecycle = new ConnectionLifecycle();
private final AtomicReference> negotiatedCapabilities =
- new AtomicReference<>(ConcurrentHashMap.newKeySet());
+ new AtomicReference<>(Set.of());
+ private final AtomicInteger highestCapVersion = new AtomicInteger();
private final Set channelMemberships = ConcurrentHashMap.newKeySet();
private final Set channelMembershipsView =
Collections.unmodifiableSet(channelMemberships);
@@ -188,18 +191,45 @@ public void setAwayReason(String awayReason) {
this.awayReason = awayReason;
}
+ /**
+ * The capabilities this client explicitly negotiated: an immutable snapshot, never a live view.
+ * Implicit notification support from {@code CAP LS 302} is not in it; see {@link
+ * #notificationEligible()}.
+ */
public Set negotiatedCapabilities() {
return negotiatedCapabilities.get();
}
/**
- * Atomically replaces the whole negotiated set, so a concurrent reader sees either the old set or
- * the new one, never a half-applied {@code CAP REQ}.
+ * Replaces the whole negotiated set in one step, so a concurrent reader sees either the old set
+ * or the new one, never a half-applied {@code CAP REQ}. Server code changes negotiation only
+ * under the capability runtime's administrative mutex (CAP and runtime pruning), so a request
+ * cannot bring back a name a concurrent disable has just pruned.
*/
public void replaceNegotiatedCapabilities(java.util.Collection capabilities) {
- Set replacement = ConcurrentHashMap.newKeySet();
- replacement.addAll(capabilities);
- negotiatedCapabilities.set(replacement);
+ negotiatedCapabilities.set(Set.copyOf(capabilities));
+ }
+
+ /** The highest {@code CAP LS} version this connection has indicated; {@code 0} for none. */
+ public int highestCapVersion() {
+ return highestCapVersion.get();
+ }
+
+ /**
+ * Remembers {@code version} if it is higher than any indicated before: once 302 or later, a later
+ * lower or omitted version cannot take notification support away.
+ */
+ public void recordCapVersion(int version) {
+ highestCapVersion.accumulateAndGet(version, Math::max);
+ }
+
+ /**
+ * Whether this client gets {@code CAP NEW}/{@code DEL}: implied by {@code CAP LS 302} or later,
+ * or negotiated explicitly with {@code cap-notify}. Derived, never set on its own.
+ */
+ public boolean notificationEligible() {
+ return highestCapVersion.get() >= CapabilityNegotiationGrammar.NOTIFY_VERSION
+ || negotiatedCapabilities.get().contains(CapabilityName.CAP_NOTIFY);
}
public boolean isNegotiatingCapabilities() {
diff --git a/jircd-core/src/main/java/net/jircd/core/session/ConnectionHandler.java b/jircd-core/src/main/java/net/jircd/core/session/ConnectionHandler.java
index 383162e..f3a58b9 100644
--- a/jircd-core/src/main/java/net/jircd/core/session/ConnectionHandler.java
+++ b/jircd-core/src/main/java/net/jircd/core/session/ConnectionHandler.java
@@ -44,6 +44,7 @@
import net.jircd.core.extension.Extension;
import net.jircd.core.extension.ExtensionRegistry;
import net.jircd.core.extension.ServerExtension;
+import net.jircd.core.extension.capability.CapabilityControl;
import net.jircd.core.session.command.CommandHandler;
import net.jircd.core.session.command.Replies;
import net.jircd.protocol.Command;
@@ -147,7 +148,14 @@ private static ScheduledThreadPoolExecutor daemonTimer(String name) {
private final Consumer acceptedSocketCustomizer;
private boolean accepting = true;
private final Map connections = new ConcurrentHashMap<>();
+
+ /**
+ * Every accepted connection, named or not, from before its first command until it ends: the
+ * sessions a capability publication prunes and notifies. Insertion, removal and writer attachment
+ * happen under the capability runtime's administrative mutex, so no session escapes a prune.
+ */
private final Set liveSessions = ConcurrentHashMap.newKeySet();
+
private final Semaphore rejectionSlots = new Semaphore(64);
private final Map rejections = new ConcurrentHashMap<>();
private final Map handlers = new ConcurrentHashMap<>();
@@ -283,6 +291,7 @@ public ConnectionHandler(
this.sendqLimitBytes = settings.sendqLimitBytes();
this.registrationTimeout = settings.registrationTimeout();
this.tagRenderer = new CapabilityTagRenderer(extensionRegistry);
+ extensionRegistry.attachLiveSessions(liveSessions);
}
public void registerHandler(Command command, CommandHandler handler) {
@@ -291,17 +300,27 @@ public void registerHandler(Command command, CommandHandler handler) {
/**
* The extension-facing view of this command table: install, ownership query, conditional removal.
+ * Ownership covers both command sources dispatch reads: this table and the commands of the
+ * published capability providers, keyed by the same {@link Command} dispatch folds a line to. A
+ * command a published provider owns is registered, and installing over it is refused, so a server
+ * extension can never shadow it. Extensions install from their lifecycle callbacks, which the
+ * registry serializes with provider publication, so the check and the install cannot be split by
+ * a provider enable.
*/
public CommandRegistrar commandRegistrar() {
return new CommandRegistrar() {
@Override
public void register(Command command, CommandHandler handler) {
+ if (ownedByProvider(command)) {
+ throw new IllegalArgumentException(
+ "Command " + command + " is owned by a capability provider and cannot be replaced");
+ }
registerHandler(command, handler);
}
@Override
public boolean isRegistered(Command command) {
- return handlers.containsKey(command);
+ return handlers.containsKey(command) || ownedByProvider(command);
}
@Override
@@ -312,6 +331,10 @@ public void unregister(Command command, CommandHandler installed) {
};
}
+ private boolean ownedByProvider(Command command) {
+ return extensionRegistry.capabilitySnapshot().command(command).isPresent();
+ }
+
/**
* Connections accepted but not yet {@code REGISTERED} (for {@code RPL_LUSERUNKNOWN}). A session
* that is closing — a registration timeout, a QUIT, a socket error — stops counting the moment it
@@ -521,7 +544,8 @@ private void handleConnection(
java.time.Clock.systemUTC()),
socket instanceof javax.net.ssl.SSLSocket,
socket.getInetAddress());
- liveSessions.add(session);
+ CapabilityControl control = extensionRegistry.capabilityControl();
+ control.runUnderControl(() -> liveSessions.add(session));
try {
serveSession(
session,
@@ -532,7 +556,7 @@ private void handleConnection(
registrationTimeout,
acceptedAt);
} finally {
- liveSessions.remove(session);
+ control.runUnderControl(() -> liveSessions.remove(session));
}
}
@@ -554,11 +578,13 @@ private void serveSession(
closeQuietly(socket);
return;
}
- session.attachWriter(writer);
+ // Callbacks first: once attached, a publication can queue to this writer, and an overflow or
+ // write failure must already reach the connection's cleanup rather than the no-op defaults.
AtomicReference overflowCleanup = new AtomicReference<>();
writer.setOnSendqOverflow(
() -> startOverflowCleanup(session, writer, socket, connectionId, overflowCleanup));
writer.setOnWriteFailure(() -> disconnectCleanup.cleanup(session, "Excess Flood"));
+ extensionRegistry.capabilityControl().runUnderControl(() -> session.attachWriter(writer));
LivenessMonitor livenessMonitor =
new LivenessMonitor(
@@ -765,7 +791,8 @@ private static int indexOfSpace(byte[] lineBytes) {
return -1;
}
- private void processLine(ClientSession session, byte[] lineBytes) {
+ /** Parses and dispatches one inbound line; package-private as a seam for dispatch tests. */
+ void processLine(ClientSession session, byte[] lineBytes) {
if (!Utf8Validator.isValidUtf8(lineBytes)) {
// 005-fix-batch-conformance FR-012 — a definitive, client-visible connection end (the same
// enqueue-then-cleanup shape QuitCommandHandler/KillCommandHandler/LivenessMonitor already
@@ -823,7 +850,12 @@ private void processLine(ClientSession session, byte[] lineBytes) {
return;
}
+ // Core and server-extension commands first; then a capability provider's, resolved from one
+ // read of the published snapshot so a reload moving it never leaves it briefly unknown.
CommandHandler handler = handlers.get(message.command());
+ if (handler == null) {
+ handler = extensionRegistry.capabilityCommand(message.command()).orElse(null);
+ }
if (handler == null) {
Replies.sendFitted(
session,
diff --git a/jircd-core/src/main/java/net/jircd/core/session/MemberReplies.java b/jircd-core/src/main/java/net/jircd/core/session/MemberReplies.java
index 6329f50..522ace6 100644
--- a/jircd-core/src/main/java/net/jircd/core/session/MemberReplies.java
+++ b/jircd-core/src/main/java/net/jircd/core/session/MemberReplies.java
@@ -20,6 +20,7 @@
import java.util.Objects;
import java.util.Set;
import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.capability.CapabilityRuntime;
import net.jircd.core.extension.capability.HookDispatcher;
import net.jircd.core.extension.capability.HookKind;
import net.jircd.core.extension.capability.MemberSnapshot;
@@ -31,7 +32,8 @@
* are separate slots, each replaceable by at most one provider the requester negotiated, so a
* provider that lists every status and one that changes the identity compose without knowing of one
* another. The hooks run on the caller's thread outside every registry and channel monitor, against
- * one published snapshot captured per call, and a faulty hook costs only its own contribution.
+ * one published snapshot and the requester's negotiation, captured together per call, and a faulty
+ * hook costs only its own contribution.
*
* Identity fields are resolved only if a provider could use them: the presented hostname may run
* hostname-display extension code, which a plain NAMES must not pay for on every member.
@@ -45,8 +47,9 @@ public MemberReplies(ExtensionRegistry extensionRegistry) {
/** One NAMES token per member, in the order given. */
public List namesTokens(ClientSession requester, List facts) {
- RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot();
- Set negotiated = requester.negotiatedCapabilities();
+ CapabilityRuntime.CapturedView view = extensionRegistry.capabilityControl().capture(requester);
+ RuntimeSnapshot snapshot = view.snapshot();
+ Set negotiated = view.negotiated();
boolean customStatus = !snapshot.hooksFor(HookKind.MEMBER_STATUS, negotiated).isEmpty();
boolean customIdentity = !snapshot.hooksFor(HookKind.NAMES_IDENTITY, negotiated).isEmpty();
List tokens = new ArrayList<>(facts.size());
@@ -68,8 +71,9 @@ public List namesTokens(ClientSession requester, List facts
/** The status symbols to append to a channel WHO entry's {@code H}/{@code G}/{@code *} flags. */
public String whoStatus(ClientSession requester, MemberFacts fact) {
- RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot();
- Set negotiated = requester.negotiatedCapabilities();
+ CapabilityRuntime.CapturedView view = extensionRegistry.capabilityControl().capture(requester);
+ RuntimeSnapshot snapshot = view.snapshot();
+ Set negotiated = view.negotiated();
if (snapshot.hooksFor(HookKind.MEMBER_STATUS, negotiated).isEmpty()) {
return highest(fact);
}
diff --git a/jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java b/jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java
index 6412979..b4e784c 100644
--- a/jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java
+++ b/jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java
@@ -25,6 +25,7 @@
import java.util.function.Predicate;
import java.util.function.Supplier;
import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.capability.CapabilityRuntime;
import net.jircd.core.extension.capability.DeliveryIntent;
import net.jircd.core.extension.capability.DeliveryRole;
import net.jircd.core.extension.capability.HookDispatcher;
@@ -186,9 +187,12 @@ public Delivery deliver(
OutboundMessage unsent = OutboundMessage.now("", command, target, body);
return new Delivery(unsent, RequestContext.of(0, Map.of()), Map.of());
}
- RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot();
+ // One coherent view: a disable between two separate reads could pair the old snapshot with
+ // the sender's newly pruned set and skip hooks the sender had negotiated.
+ CapabilityRuntime.CapturedView view = extensionRegistry.capabilityControl().capture(sender);
+ RuntimeSnapshot snapshot = view.snapshot();
HookDispatcher dispatcher = extensionRegistry.hookDispatcher();
- Set senderNegotiated = sender.negotiatedCapabilities();
+ Set senderNegotiated = view.negotiated();
long requestId = REQUEST_IDS.incrementAndGet();
RequestContext opening = RequestContext.of(requestId, Map.of());
diff --git a/jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java b/jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java
index f724e7d..d628379 100644
--- a/jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java
+++ b/jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java
@@ -29,7 +29,9 @@
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import net.jircd.core.config.ServerConfiguration;
+import net.jircd.core.extension.capability.FramingPlan;
import net.jircd.core.extension.capability.OutputGroup;
+import net.jircd.core.extension.capability.PreparedGroup;
import net.jircd.core.extension.capability.RequestContext;
import net.jircd.protocol.Command;
import net.jircd.protocol.Message;
@@ -212,13 +214,14 @@ public void enqueueGroup(
// back to plain members. The plan stays bounded by its frame count and the per-frame limits.
OutputGroup group =
new OutputGroup(UUID.randomUUID().toString(), type, typeParams, members, Long.MAX_VALUE);
- List frames = tagRenderer.frame(session, group, context).frames();
- if (frames.isEmpty()) {
- return;
- }
- FrameSequence sequence;
+ // Both alternatives are prepared and serialized here, outside every lock; admission only picks
+ // one and appends it whole. A null sequence is an alternative with no frames.
+ PreparedGroup prepared = tagRenderer.prepareGroup(session, group, context);
+ FrameSequence framed;
+ FrameSequence plain;
try {
- sequence = FrameSequence.of(frames);
+ framed = sequence(prepared.framed());
+ plain = prepared.plain() == prepared.framed() ? framed : sequence(prepared.plain());
} catch (IllegalArgumentException e) {
LOG.warn(
"Dropping an unrepresentable output group for connection {}: {}",
@@ -226,7 +229,17 @@ public void enqueueGroup(
e.getMessage());
return;
}
- enqueueEntry(new Entry(sequence.bytes(), sequence, null));
+ tagRenderer.admitGroup(
+ session,
+ prepared,
+ chosen -> {
+ FrameSequence sequence = chosen == prepared.framed() ? framed : plain;
+ if (sequence != null) enqueueEntry(new Entry(sequence.bytes(), sequence, null));
+ });
+ }
+
+ private static FrameSequence sequence(FramingPlan plan) {
+ return plan.frames().isEmpty() ? null : FrameSequence.of(plan.frames());
}
private void handleOverflow() {
diff --git a/jircd-core/src/main/java/net/jircd/core/session/TagRenderer.java b/jircd-core/src/main/java/net/jircd/core/session/TagRenderer.java
index f739354..d620c55 100644
--- a/jircd-core/src/main/java/net/jircd/core/session/TagRenderer.java
+++ b/jircd-core/src/main/java/net/jircd/core/session/TagRenderer.java
@@ -16,8 +16,10 @@
package net.jircd.core.session;
import java.util.Map;
+import java.util.function.Consumer;
import net.jircd.core.extension.capability.FramingPlan;
import net.jircd.core.extension.capability.OutputGroup;
+import net.jircd.core.extension.capability.PreparedGroup;
import net.jircd.core.extension.capability.RequestContext;
/**
@@ -71,4 +73,25 @@ default PreparedTags prepare(ClientSession recipient, OutboundMessage message) {
default FramingPlan frame(ClientSession recipient, OutputGroup group, RequestContext context) {
return FramingPlan.plain(group);
}
+
+ /**
+ * Both complete alternatives for {@code group}: the framing {@link #frame} would choose now, and
+ * the plain members. Prepared outside every core lock; the default frames nothing.
+ */
+ default PreparedGroup prepareGroup(
+ ClientSession recipient, OutputGroup group, RequestContext context) {
+ FramingPlan framed = frame(recipient, group, context);
+ return new PreparedGroup(framed, FramingPlan.plain(group), null);
+ }
+
+ /**
+ * Chooses one of {@code group}'s alternatives against the current state and hands it to {@code
+ * append}, which queues it whole. Capability renderers choose under the administrative mutex,
+ * framed only while its owner and declared prerequisites are still effective for {@code
+ * recipient}; no provider runs then. The default keeps the framing as prepared.
+ */
+ default void admitGroup(
+ ClientSession recipient, PreparedGroup group, Consumer append) {
+ append.accept(group.framed());
+ }
}
diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/CapCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/CapCommandHandler.java
index 2a66463..456d04d 100644
--- a/jircd-core/src/main/java/net/jircd/core/session/command/CapCommandHandler.java
+++ b/jircd-core/src/main/java/net/jircd/core/session/command/CapCommandHandler.java
@@ -15,7 +15,11 @@
*/
package net.jircd.core.session.command;
+import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
+import java.util.Optional;
+import java.util.Set;
import java.util.function.Supplier;
import net.jircd.core.capability.CapabilityNegotiator;
import net.jircd.core.session.ClientSession;
@@ -31,6 +35,12 @@
* negotiated capabilities, distinct from {@code LS}'s full offered list (005-fix-batch-conformance
* FR-007). A bare {@code CAP ACK}/{@code NAK} from a client (server-originated only, in this
* design) is a no-op.
+ *
+ * {@code LS}, {@code LIST} and {@code REQ} each read state, change it and queue their reply
+ * under the administrative mutex runtime changes publish under, so none of them can observe a
+ * half-applied prune or lose a concurrent update, and each reply is queued in order with any {@code
+ * CAP NEW}/{@code DEL}. Replies to a client that indicated {@code CAP LS 302} use continuation
+ * lines when needed; an older client always gets one line.
*/
public final class CapCommandHandler implements CommandHandler {
@@ -62,7 +72,7 @@ public void handle(ClientSession session, Message message) {
return;
}
switch (subcommand) {
- case LS -> handleLs(session);
+ case LS -> handleLs(session, message);
case LIST -> handleList(session);
case REQ -> handleReq(session, message);
case END -> handleEnd(session);
@@ -72,52 +82,77 @@ public void handle(ClientSession session, Message message) {
}
}
- private void handleLs(ClientSession session) {
+ private static boolean continuation(ClientSession session) {
+ return session.highestCapVersion() >= CapabilityNegotiationGrammar.NOTIFY_VERSION;
+ }
+
+ private void handleLs(ClientSession session, Message message) {
negotiator.beginNegotiation(session);
- enqueue(
- session,
- CapabilityNegotiationGrammar.ls(
- serverName.get(),
- Replies.target(session),
- negotiator.currentlyOfferedCapabilityNames()));
+ int version = CapabilityNegotiationGrammar.parseLsVersion(message);
+ String server = serverName.get();
+ negotiator.runUnderControl(
+ () -> {
+ session.recordCapVersion(version);
+ String target = Replies.target(session);
+ List offered = negotiator.currentlyOfferedCapabilityNames();
+ // Enable rejects an offer set that cannot fit one line for an older client at the
+ // configured nickname length; a later longer limit still never truncates the list.
+ boolean continuation =
+ continuation(session)
+ || !CapabilityNegotiationGrammar.fitsOneLine(server, target, "LS", offered);
+ enqueueAll(
+ session,
+ CapabilityNegotiationGrammar.lsReplies(server, target, offered, continuation));
+ });
}
private void handleList(ClientSession session) {
- enqueue(
- session,
- CapabilityNegotiationGrammar.list(
- serverName.get(),
- Replies.target(session),
- List.copyOf(session.negotiatedCapabilities())));
+ String server = serverName.get();
+ negotiator.runUnderControl(
+ () -> {
+ String target = Replies.target(session);
+ List held = new ArrayList<>(session.negotiatedCapabilities());
+ held.sort(Comparator.naturalOrder());
+ // A held set an older client could not get on one line still reaches it whole.
+ boolean continuation =
+ continuation(session)
+ || !CapabilityNegotiationGrammar.fitsOneLine(server, target, "LIST", held);
+ enqueueAll(
+ session,
+ CapabilityNegotiationGrammar.listReplies(server, target, held, continuation));
+ });
}
/**
* The reply is built and size-checked before any state changes: an echo that cannot fit the
* 512-byte outbound limit would be dropped by the writer after the change, leaving the client
* unaware of it, so that case gets {@code 417} and changes nothing. An empty request is never
- * ACKed.
+ * ACKed. Validation, the size check, the change and the reply happen in one step under the
+ * administrative mutex.
*/
private void handleReq(ClientSession session, Message message) {
List requested = CapabilityNegotiationGrammar.parseCapabilityList(message);
- String target = Replies.target(session);
- boolean accepted = !requested.isEmpty() && negotiator.isAcceptable(requested);
- Message reply =
- accepted
- ? CapabilityNegotiationGrammar.ack(serverName.get(), target, requested)
- : CapabilityNegotiationGrammar.nak(serverName.get(), target, requested);
- if (MessageSerializer.commandBytes(reply) > MessageSerializer.MAX_COMMAND_BYTES) {
- Replies.sendFitted(
- session,
- serverName.get(),
- NumericReply.ERR_INPUTTOOLONG,
- List.of(),
- "Input line was too long");
- return;
- }
- if (accepted) {
- negotiator.apply(session, requested);
- }
- enqueue(session, reply);
+ String server = serverName.get();
+ negotiator.runUnderControl(
+ () -> {
+ String target = Replies.target(session);
+ Optional> candidate = negotiator.candidate(session, requested);
+ Message reply =
+ candidate.isPresent()
+ ? CapabilityNegotiationGrammar.ack(server, target, requested)
+ : CapabilityNegotiationGrammar.nak(server, target, requested);
+ if (MessageSerializer.commandBytes(reply) > MessageSerializer.MAX_COMMAND_BYTES) {
+ Replies.sendFitted(
+ session,
+ server,
+ NumericReply.ERR_INPUTTOOLONG,
+ List.of(),
+ "Input line was too long");
+ return;
+ }
+ candidate.ifPresent(session::replaceNegotiatedCapabilities);
+ enqueue(session, reply);
+ });
}
private void handleEnd(ClientSession session) {
@@ -125,6 +160,10 @@ private void handleEnd(ClientSession session) {
registrationCompletion.tryComplete(session);
}
+ private static void enqueueAll(ClientSession session, List messages) {
+ messages.forEach(message -> enqueue(session, message));
+ }
+
private static void enqueue(ClientSession session, Message message) {
if (session.writer() != null) {
session.writer().enqueueRaw(message);
diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/NickCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/NickCommandHandler.java
index 28484be..5c7d549 100644
--- a/jircd-core/src/main/java/net/jircd/core/session/command/NickCommandHandler.java
+++ b/jircd-core/src/main/java/net/jircd/core/session/command/NickCommandHandler.java
@@ -103,8 +103,24 @@ public void handle(ClientSession session, Message message) {
String oldHostmask =
isChange ? PresentedIdentity.presentedForm(session, extensionRegistry) : null;
- session.setNickname(requested);
- // Disconnect cleanup reads the nickname after claiming CLOSING. If it ran before the line
+ Message notification =
+ isChange
+ ? new Message(Map.of(), oldHostmask, Command.NICK, "NICK", List.of(requested))
+ : null;
+ // The change and the client's own NICK line are one step under the administrative mutex, the
+ // one a capability publication holds while it addresses CAP DEL/NEW. Its target is therefore
+ // the nickname the client knows at that point in its stream: the old one before this line,
+ // the new one after. Peers are told outside the mutex.
+ extensionRegistry
+ .capabilityControl()
+ .runUnderControl(
+ () -> {
+ session.setNickname(requested);
+ if (notification != null && !session.lifecycle().isClosing()) {
+ Recipients.deliver(List.of(session), notification);
+ }
+ });
+ // Disconnect cleanup reads the nickname after claiming CLOSING. If it ran before the change
// above it saw nothing to release, and CLOSING is then visible here: roll the claim back.
if (session.lifecycle().isClosing()) {
nicknameRegistry.release(requested, session);
@@ -112,16 +128,10 @@ public void handle(ClientSession session, Message message) {
}
registrationCompletion.tryComplete(session);
- if (isChange) {
- broadcastNickChange(session, oldHostmask, requested);
+ if (notification != null) {
+ Recipients.deliver(
+ Recipients.sharedChannelNeighbors(session, false, s -> true), notification);
extensionRegistry.committedEvents().nickChanged(session, oldHostmask, previous, requested);
}
}
-
- private static void broadcastNickChange(
- ClientSession session, String oldHostmask, String requested) {
- Message notification =
- new Message(Map.of(), oldHostmask, Command.NICK, "NICK", List.of(requested));
- Recipients.deliver(Recipients.sharedChannelNeighbors(session, true, s -> true), notification);
- }
}
diff --git a/jircd-core/src/test/java/net/jircd/core/capability/CapabilityLifecycleNegotiationTest.java b/jircd-core/src/test/java/net/jircd/core/capability/CapabilityLifecycleNegotiationTest.java
new file mode 100644
index 0000000..c5a2ff7
--- /dev/null
+++ b/jircd-core/src/test/java/net/jircd/core/capability/CapabilityLifecycleNegotiationTest.java
@@ -0,0 +1,350 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.core.capability;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import net.jircd.core.extension.AbstractCapabilityExtension;
+import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.ServerContext;
+import net.jircd.core.session.ClientSession;
+import net.jircd.core.session.RateLimitBucket;
+import net.jircd.core.session.SessionWriter;
+import net.jircd.core.session.TagRenderer;
+import net.jircd.core.session.command.CapCommandHandler;
+import net.jircd.protocol.MessageParser;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * CAP negotiation against runtime changes (#97): advertised core cap-notify, sticky 302 support,
+ * negative REQ for held, pruned and unknown names, all-or-nothing requests with exact echoes, and
+ * DEL/NEW to exactly the eligible sessions.
+ */
+class CapabilityLifecycleNegotiationTest {
+
+ private static final String SERVER = "irc.example";
+
+ private static final class Offer extends AbstractCapabilityExtension {
+ Offer(String name) {
+ super(name);
+ }
+ }
+
+ private ExtensionRegistry registry;
+ private CapCommandHandler cap;
+ private final Set live = ConcurrentHashMap.newKeySet();
+
+ @BeforeEach
+ void setUp() {
+ registry = new ExtensionRegistry();
+ registry.register(new Offer("alpha"));
+ registry.register(new Offer("beta"));
+ registry.attachContext(
+ new ServerContext(
+ null, null, registry, () -> SERVER, null, null, null, registry.capabilityServices()));
+ registry.attachLiveSessions(live);
+ cap = new CapCommandHandler(new CapabilityNegotiator(registry), () -> SERVER, null);
+ }
+
+ private ClientSession connect(String id) {
+ ClientSession session = new ClientSession(id, "host.example", RateLimitBucket.withDefaults());
+ live.add(session);
+ return session;
+ }
+
+ /** The lines written to each session while {@code action} runs. */
+ private static List> during(Runnable action, ClientSession... sessions) {
+ List outputs = new ArrayList<>();
+ List writers = new ArrayList<>();
+ for (ClientSession session : sessions) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ // Closed in the finally below, after the action has run.
+ @SuppressWarnings("PMD.CloseResource")
+ SessionWriter writer = new SessionWriter(session, out, TagRenderer.NONE);
+ session.attachWriter(writer);
+ outputs.add(out);
+ writers.add(writer);
+ }
+ try {
+ action.run();
+ } finally {
+ writers.forEach(SessionWriter::close);
+ }
+ List> result = new ArrayList<>();
+ for (ByteArrayOutputStream out : outputs) {
+ String text = out.toString(StandardCharsets.UTF_8);
+ result.add(text.isEmpty() ? List.of() : List.of(text.split("\r\n")));
+ }
+ return result;
+ }
+
+ private List send(ClientSession session, String line) {
+ return during(
+ () -> {
+ try {
+ cap.handle(session, MessageParser.parse(line));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ },
+ session)
+ .getFirst();
+ }
+
+ @Test
+ void lsAdvertisesCoreCapNotifyEvenWithNoProviderEnabled() {
+ ClientSession client = connect("c1");
+ assertThat(send(client, "CAP LS")).containsExactly(":irc.example CAP * LS cap-notify");
+ assertThat(send(client, "CAP LS 302")).containsExactly(":irc.example CAP * LS cap-notify");
+ }
+
+ @Test
+ void lsListsCapNotifyAndTheEnabledProvidersInADeterministicOrder() {
+ registry.enable("beta");
+ registry.enable("alpha");
+ assertThat(send(connect("c1"), "CAP LS"))
+ .containsExactly(":irc.example CAP * LS :alpha beta cap-notify");
+ }
+
+ @Test
+ void anExplicitCapNotifyRequestIsAnOrdinaryOfferedAdditionAndAppearsInList() {
+ ClientSession client = connect("c1");
+ assertThat(send(client, "CAP REQ :cap-notify"))
+ .containsExactly(":irc.example CAP * ACK cap-notify");
+ assertThat(client.notificationEligible()).isTrue();
+ assertThat(send(client, "CAP LIST")).containsExactly(":irc.example CAP * LIST cap-notify");
+ }
+
+ @Test
+ void implicit302SupportIsOmittedFromListAndCannotBeRemoved() {
+ ClientSession client = connect("c1");
+ send(client, "CAP LS 302");
+ assertThat(client.notificationEligible()).isTrue();
+ assertThat(send(client, "CAP LIST")).containsExactly(":irc.example CAP * LIST :");
+
+ assertThat(send(client, "CAP REQ :-cap-notify"))
+ .containsExactly(":irc.example CAP * NAK -cap-notify");
+ assertThat(client.notificationEligible()).isTrue();
+ }
+
+ @Test
+ void a302ClientThatAlsoRequestedCapNotifyExplicitlyStillCannotRemoveIt() {
+ registry.enable("alpha");
+ ClientSession client = connect("c1");
+ send(client, "CAP LS 302");
+ send(client, "CAP REQ :cap-notify alpha");
+
+ assertThat(send(client, "CAP REQ :-alpha -cap-notify"))
+ .containsExactly(":irc.example CAP * NAK :-alpha -cap-notify");
+ assertThat(client.negotiatedCapabilities()).containsExactlyInAnyOrder("cap-notify", "alpha");
+ }
+
+ @Test
+ void aLegacyClientMayRemoveExplicitCapNotify() {
+ ClientSession client = connect("c1");
+ send(client, "CAP REQ :cap-notify");
+ assertThat(send(client, "CAP REQ :-cap-notify"))
+ .containsExactly(":irc.example CAP * ACK -cap-notify");
+ assertThat(client.notificationEligible()).isFalse();
+ assertThat(send(client, "CAP LIST")).containsExactly(":irc.example CAP * LIST :");
+ }
+
+ @Test
+ void the302VersionIsStickyAgainstLowerOmittedAndMalformedVersions() {
+ ClientSession client = connect("c1");
+ send(client, "CAP LS 302");
+ for (String later : List.of("CAP LS", "CAP LS 301", "CAP LS 0", "CAP LS abc", "CAP LS -1")) {
+ assertThat(send(client, later)).containsExactly(":irc.example CAP * LS cap-notify");
+ assertThat(client.notificationEligible()).as(later).isTrue();
+ }
+ assertThat(client.highestCapVersion()).isEqualTo(302);
+ }
+
+ @Test
+ void aMalformedOrHugeVersionKeepsOrdinaryLsBehavior() {
+ ClientSession malformed = connect("c1");
+ assertThat(send(malformed, "CAP LS 3o2")).containsExactly(":irc.example CAP * LS cap-notify");
+ assertThat(malformed.notificationEligible()).isFalse();
+
+ ClientSession huge = connect("c2");
+ send(huge, "CAP LS 99999999999999999999999");
+ assertThat(huge.notificationEligible()).isTrue();
+ }
+
+ @Test
+ void aHeldButUnofferedNameCanBeRemoved() {
+ ClientSession client = connect("c1");
+ // Controlled setup: a name the session holds although nothing offers it.
+ client.replaceNegotiatedCapabilities(List.of("ghost"));
+
+ assertThat(send(client, "CAP REQ :-ghost")).containsExactly(":irc.example CAP * ACK -ghost");
+ assertThat(client.negotiatedCapabilities()).isEmpty();
+ }
+
+ @Test
+ void removingADiscoveredNameAlreadyPrunedByDisableIsAnAcknowledgedNoOp() {
+ registry.enable("alpha");
+ ClientSession client = connect("c1");
+ send(client, "CAP REQ :alpha");
+ registry.disable("alpha");
+ assertThat(client.negotiatedCapabilities()).isEmpty();
+
+ assertThat(send(client, "CAP REQ :-alpha")).containsExactly(":irc.example CAP * ACK -alpha");
+ assertThat(client.negotiatedCapabilities()).isEmpty();
+ }
+
+ @Test
+ void removingAnUnknownUnheldNameNaksTheWholeRequest() {
+ registry.enable("alpha");
+ ClientSession client = connect("c1");
+ assertThat(send(client, "CAP REQ :alpha -nope"))
+ .containsExactly(":irc.example CAP * NAK :alpha -nope");
+ assertThat(client.negotiatedCapabilities()).isEmpty();
+ assertThat(send(client, "CAP REQ :-")).containsExactly(":irc.example CAP * NAK -");
+ }
+
+ @Test
+ void anUnavailableAdditionNaksAMixedRequestAtomically() {
+ registry.enable("beta");
+ ClientSession client = connect("c1");
+ send(client, "CAP REQ :beta");
+ // alpha is discovered but disabled: not offered, so it cannot be added.
+ assertThat(send(client, "CAP REQ :-beta alpha"))
+ .containsExactly(":irc.example CAP * NAK :-beta alpha");
+ assertThat(client.negotiatedCapabilities()).containsExactly("beta");
+ }
+
+ @Test
+ void repeatedAndConflictingTokensApplyInOrderAndAreEchoedExactly() {
+ registry.enable("alpha");
+ ClientSession client = connect("c1");
+ assertThat(send(client, "CAP REQ :alpha -alpha alpha alpha"))
+ .containsExactly(":irc.example CAP * ACK :alpha -alpha alpha alpha");
+ assertThat(client.negotiatedCapabilities()).containsExactly("alpha");
+ assertThat(send(client, "CAP REQ :alpha -alpha"))
+ .containsExactly(":irc.example CAP * ACK :alpha -alpha");
+ assertThat(client.negotiatedCapabilities()).isEmpty();
+ }
+
+ @Test
+ void anIdempotentRequestOfAHeldOfferedNameSucceeds() {
+ registry.enable("alpha");
+ ClientSession client = connect("c1");
+ send(client, "CAP REQ :alpha");
+ assertThat(send(client, "CAP REQ :alpha")).containsExactly(":irc.example CAP * ACK alpha");
+ assertThat(client.negotiatedCapabilities()).containsExactly("alpha");
+ }
+
+ @Test
+ void anEmptyRequestIsNakedAndChangesNothing() {
+ registry.enable("alpha");
+ ClientSession client = connect("c1");
+ send(client, "CAP REQ :alpha");
+ assertThat(send(client, "CAP REQ :")).containsExactly(":irc.example CAP * NAK :");
+ assertThat(client.negotiatedCapabilities()).containsExactly("alpha");
+ }
+
+ @Test
+ void anOversizeEchoGets417AndChangesNothing() {
+ registry.enable("alpha");
+ ClientSession client = connect("c1");
+ String request = String.join(" ", Collections.nCopies(90, "alpha"));
+ List lines = send(client, "CAP REQ :" + request);
+ assertThat(lines).singleElement().asString().contains(" 417 ");
+ assertThat(client.negotiatedCapabilities()).isEmpty();
+ }
+
+ @Test
+ void listShowsTheCurrentExplicitSetAfterPruning() {
+ registry.enable("alpha");
+ registry.enable("beta");
+ ClientSession client = connect("c1");
+ send(client, "CAP REQ :alpha beta");
+ registry.disable("alpha");
+ assertThat(send(client, "CAP LIST")).containsExactly(":irc.example CAP * LIST beta");
+ }
+
+ @Test
+ void disableSendsDelToExplicitAndImplicitNotifyClientsEvenIfTheyNeverHeldIt() {
+ registry.enable("alpha");
+ ClientSession explicit = connect("explicit");
+ send(explicit, "CAP REQ :cap-notify");
+ ClientSession implicit = connect("implicit");
+ send(implicit, "CAP LS 302");
+ implicit.setNickname("ivy");
+ ClientSession holder = connect("holder");
+ send(holder, "CAP LS 302");
+ send(holder, "CAP REQ :alpha");
+ ClientSession legacy = connect("legacy");
+ send(legacy, "CAP REQ :alpha");
+
+ List> wire =
+ during(() -> registry.disable("alpha"), explicit, implicit, holder, legacy);
+
+ assertThat(wire.get(0)).containsExactly(":irc.example CAP * DEL alpha");
+ assertThat(wire.get(1)).containsExactly(":irc.example CAP ivy DEL alpha");
+ assertThat(wire.get(2)).containsExactly(":irc.example CAP * DEL alpha");
+ assertThat(wire.get(3)).isEmpty();
+ assertThat(holder.negotiatedCapabilities()).isEmpty();
+ assertThat(legacy.negotiatedCapabilities()).isEmpty();
+ }
+
+ @Test
+ void enableSendsNewWithoutRestoringPriorNegotiations() {
+ registry.enable("alpha");
+ ClientSession holder = connect("holder");
+ send(holder, "CAP LS 302");
+ send(holder, "CAP REQ :alpha");
+ registry.disable("alpha");
+
+ List> wire = during(() -> registry.enable("alpha"), holder);
+
+ assertThat(wire.getFirst()).containsExactly(":irc.example CAP * NEW alpha");
+ assertThat(holder.negotiatedCapabilities()).isEmpty();
+ assertThat(send(holder, "CAP REQ :alpha")).containsExactly(":irc.example CAP * ACK alpha");
+ }
+
+ @Test
+ void aRepeatedToggleOrASessionOnlyChangeSendsNoNotification() {
+ registry.enable("alpha");
+ ClientSession observer = connect("observer");
+ send(observer, "CAP LS 302");
+ ClientSession other = connect("other");
+
+ List> wire =
+ during(
+ () -> {
+ registry.enable("alpha");
+ registry.disable("beta");
+ try {
+ cap.handle(other, MessageParser.parse("CAP REQ :alpha"));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ },
+ observer);
+
+ assertThat(wire.getFirst()).isEmpty();
+ }
+}
diff --git a/jircd-core/src/test/java/net/jircd/core/capability/CapabilityNegotiatorTest.java b/jircd-core/src/test/java/net/jircd/core/capability/CapabilityNegotiatorTest.java
index 8cea73f..96c5247 100644
--- a/jircd-core/src/test/java/net/jircd/core/capability/CapabilityNegotiatorTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/capability/CapabilityNegotiatorTest.java
@@ -25,6 +25,7 @@
import net.jircd.core.extension.ServerContext;
import net.jircd.core.session.ClientSession;
import net.jircd.core.session.RateLimitBucket;
+import net.jircd.core.session.TestNegotiation;
import org.junit.jupiter.api.Test;
class CapabilityNegotiatorTest {
@@ -48,7 +49,8 @@ void negotiationSucceedsAndRegistrationCanCompleteWithZeroEnabledCapabilities()
CapabilityNegotiator negotiator = new CapabilityNegotiator(registry);
ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults());
- assertThat(negotiator.currentlyOfferedCapabilityNames()).isEmpty();
+ // Core cap-notify is offered with no provider enabled; it is not a provider.
+ assertThat(negotiator.currentlyOfferedCapabilityNames()).containsExactly("cap-notify");
negotiator.beginNegotiation(session);
assertThat(session.isNegotiatingCapabilities()).isTrue();
@@ -67,7 +69,7 @@ void requestWithAnyUnknownCapabilityChangesNothing() {
CapabilityNegotiator negotiator = new CapabilityNegotiator(registry);
ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults());
- session.negotiatedCapabilities().add("message-tags");
+ TestNegotiation.add(session, "message-tags");
assertThat(negotiator.request(session, List.of("echo-message", "batch"))).isFalse();
assertThat(session.negotiatedCapabilities()).containsExactly("message-tags");
@@ -79,7 +81,7 @@ void requestAppliesAdditionsAndRemovalsTogether() {
offer(registry, "echo-message", "message-tags");
CapabilityNegotiator negotiator = new CapabilityNegotiator(registry);
ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults());
- session.negotiatedCapabilities().add("message-tags");
+ TestNegotiation.add(session, "message-tags");
assertThat(negotiator.request(session, List.of("echo-message", "-message-tags"))).isTrue();
assertThat(session.negotiatedCapabilities()).containsExactly("echo-message");
@@ -92,7 +94,7 @@ void removingAnUnknownCapabilityDeclinesTheWholeRequest() {
CapabilityNegotiator negotiator = new CapabilityNegotiator(registry);
ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults());
- session.negotiatedCapabilities().add("message-tags");
+ TestNegotiation.add(session, "message-tags");
assertThat(negotiator.request(session, List.of("echo-message", "-nope"))).isFalse();
assertThat(session.negotiatedCapabilities()).containsExactly("message-tags");
@@ -104,7 +106,7 @@ void acceptedRequestSwapsInANewSetSoReadersNeverSeeAHalfAppliedOne() {
offer(registry, "echo-message", "message-tags");
CapabilityNegotiator negotiator = new CapabilityNegotiator(registry);
ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults());
- session.negotiatedCapabilities().add("message-tags");
+ TestNegotiation.add(session, "message-tags");
var before = session.negotiatedCapabilities();
assertThat(negotiator.request(session, List.of("echo-message", "-message-tags"))).isTrue();
@@ -114,14 +116,14 @@ void acceptedRequestSwapsInANewSetSoReadersNeverSeeAHalfAppliedOne() {
}
@Test
- void isAcceptableDoesNotChangeTheSession() {
+ void candidateDoesNotChangeTheSession() {
ExtensionRegistry registry = new ExtensionRegistry();
offer(registry, "echo-message");
CapabilityNegotiator negotiator = new CapabilityNegotiator(registry);
ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults());
- assertThat(negotiator.isAcceptable(List.of("echo-message"))).isTrue();
- assertThat(negotiator.isAcceptable(List.of("echo-message", "nope"))).isFalse();
+ assertThat(negotiator.candidate(session, List.of("echo-message"))).isPresent();
+ assertThat(negotiator.candidate(session, List.of("echo-message", "nope"))).isEmpty();
assertThat(session.negotiatedCapabilities()).isEmpty();
}
diff --git a/jircd-core/src/test/java/net/jircd/core/config/ConfigurationReloaderFailurePathTest.java b/jircd-core/src/test/java/net/jircd/core/config/ConfigurationReloaderFailurePathTest.java
new file mode 100644
index 0000000..4441398
--- /dev/null
+++ b/jircd-core/src/test/java/net/jircd/core/config/ConfigurationReloaderFailurePathTest.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.core.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import net.jircd.core.extension.AbstractCapabilityExtension;
+import net.jircd.core.extension.CommandRegistrar;
+import net.jircd.core.extension.Extension;
+import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.ServerContext;
+import net.jircd.core.extension.SupportedFeatures;
+import net.jircd.core.extension.capability.CapabilityRegistrar;
+import net.jircd.core.session.ClientSession;
+import net.jircd.core.session.RateLimitBucket;
+import net.jircd.core.session.command.CommandHandler;
+import net.jircd.protocol.Command;
+import net.jircd.protocol.Message;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Reload failure paths: a pending cleanup survives a failed re-enable, a command moves from a
+ * withdrawn owner to a newly enabled one within one reload, and a batch whose start escapes is
+ * rolled back dependents first.
+ */
+class ConfigurationReloaderFailurePathTest {
+
+ private static final String HEADER = "serverName: test.jircd.local\n";
+
+ @TempDir Path directory;
+
+ private final List events = Collections.synchronizedList(new ArrayList<>());
+ private final Map handlers =
+ Collections.synchronizedMap(new EnumMap<>(Command.class));
+ private final ExtensionRegistry registry = new ExtensionRegistry();
+
+ /** An escape a provider's start can throw that no reload catches. */
+ private static final class StartError extends Error {
+ StartError() {
+ super("start escaped");
+ }
+ }
+
+ /** Records start and stop; its stop, staging and start can each be made to fail. */
+ private final class Probe extends AbstractCapabilityExtension {
+ private final String requires;
+ private final Command command;
+ volatile boolean stopFails;
+ volatile boolean stagingFails;
+ volatile boolean startEscapes;
+ volatile boolean startFails;
+
+ Probe(String id, String requires, Command command) {
+ super(id);
+ this.requires = requires;
+ this.command = command;
+ }
+
+ @Override
+ public void register(CapabilityRegistrar registrar) {
+ if (stagingFails) throw new IllegalStateException("cannot stage " + id());
+ if (requires != null) registrar.requiresProviders(requires);
+ if (command != null) registrar.command(command, (s, m) -> events.add("handled by " + id()));
+ }
+
+ @Override
+ public void start(ServerContext context) {
+ if (startEscapes) throw new StartError();
+ if (startFails) throw new IllegalStateException();
+ events.add("start " + id());
+ }
+
+ @Override
+ public void stop() {
+ events.add("stop " + id());
+ if (stopFails) throw new IllegalStateException("stuck " + id());
+ }
+ }
+
+ private void attachContext() {
+ CommandRegistrar commands =
+ new CommandRegistrar() {
+ @Override
+ public void register(Command command, CommandHandler handler) {
+ handlers.put(command, handler);
+ }
+
+ @Override
+ public boolean isRegistered(Command command) {
+ return handlers.containsKey(command);
+ }
+
+ @Override
+ public void unregister(Command command, CommandHandler installed) {
+ handlers.remove(command, installed);
+ }
+ };
+ registry.attachContext(
+ new ServerContext(
+ null,
+ null,
+ registry,
+ () -> "test.jircd.local",
+ null,
+ null,
+ commands,
+ registry.capabilityServices()));
+ }
+
+ private Probe register(Probe probe) {
+ registry.register(probe);
+ return probe;
+ }
+
+ private ConfigurationReloader reloader(String yaml) throws Exception {
+ ConfigurationLoader loader = new ConfigurationLoader(Set.of("a", "b", "c", "fickle"), Set.of());
+ ServerConfiguration initial =
+ loader.load(new ByteArrayInputStream(HEADER.getBytes(StandardCharsets.UTF_8)));
+ Path file = directory.resolve("reload.yaml");
+ Files.writeString(file, HEADER + yaml);
+ return new ConfigurationReloader(file, loader, registry, initial);
+ }
+
+ @Test
+ void aFailedReEnableKeepsTheCleanupAnEarlierFailedStopLeftForTheNextDisable() throws Exception {
+ Probe fickle = register(new Probe("fickle", null, null));
+ registry.enable("fickle");
+ fickle.stopFails = true;
+ assertThatThrownBy(() -> registry.disable("fickle")).hasMessage("stuck fickle");
+ fickle.stopFails = false;
+ fickle.stagingFails = true;
+
+ assertThat(reloader("capabilities: {fickle: enabled}\n").reloadWithOutcome().failures())
+ .containsOnlyKeys("fickle");
+ assertThat(registry.stateOf("fickle")).isEqualTo(Extension.State.FAILED);
+
+ // The cleanup the failed stop left is still owed: this disable must finish it.
+ events.clear();
+ registry.disable("fickle");
+ assertThat(events).containsExactly("stop fickle");
+ assertThat(registry.stateOf("fickle")).isEqualTo(Extension.State.DISABLED);
+ }
+
+ @Test
+ void aReloadMovesACommandFromAWithdrawnOwnerToANewlyEnabledOne() throws Exception {
+ attachContext();
+ register(new Probe("a", null, Command.TAGMSG));
+ register(new Probe("b", null, Command.TAGMSG));
+ registry.enable("a");
+
+ ConfigurationReloader.ReloadOutcome outcome =
+ reloader("capabilities: {a: disabled, b: enabled}\n").reloadWithOutcome();
+
+ assertThat(outcome.failures()).isEmpty();
+ assertThat(registry.stateOf("a")).isEqualTo(Extension.State.DISABLED);
+ assertThat(registry.stateOf("b")).isEqualTo(Extension.State.ENABLED);
+ assertThat(registry.capabilitySnapshot().providerIds()).containsExactly("b");
+ ClientSession session = new ClientSession("c1", "host", RateLimitBucket.withDefaults());
+ registry
+ .capabilityCommand(Command.TAGMSG)
+ .orElseThrow()
+ .handle(session, Message.of(Command.TAGMSG, "#c"));
+ assertThat(events).contains("handled by b").doesNotContain("handled by a");
+ }
+
+ @Test
+ void aStartThatEscapesRollsTheBatchBackDependentsFirst() throws Exception {
+ register(new Probe("a", null, null));
+ register(new Probe("b", "a", null));
+ register(new Probe("c", null, null)).startEscapes = true;
+
+ ConfigurationReloader reloader =
+ reloader("capabilities: {a: enabled, b: enabled, c: enabled}\n");
+ assertThatThrownBy(reloader::reload).isInstanceOf(StartError.class);
+
+ assertThat(events).containsExactly("start a", "start b", "stop c", "stop b", "stop a");
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+ assertThat(registry.stateOf("a")).isEqualTo(Extension.State.FAILED);
+ assertThat(registry.stateOf("b")).isEqualTo(Extension.State.FAILED);
+ }
+
+ @Test
+ void aWithdrawnProviderWhoseStopFailsIsReportedAndStaysWithdrawn() throws Exception {
+ Probe a = register(new Probe("a", null, null));
+ registry.enable("a");
+ a.stopFails = true;
+ events.clear();
+
+ assertThat(reloader("capabilities: {a: disabled}\n").reloadWithOutcome().failures())
+ .containsEntry("a", "stuck a");
+ // Stopped once: the failure is reported, and the retry waits for the next disable or reload.
+ assertThat(events).containsExactly("stop a");
+ assertThat(registry.stateOf("a")).isEqualTo(Extension.State.FAILED);
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+ }
+
+ @Test
+ void aRetriedCleanupThatFailsAgainIsReported() throws Exception {
+ Probe a = register(new Probe("a", null, null));
+ registry.enable("a");
+ a.stopFails = true;
+ assertThatThrownBy(() -> registry.disable("a")).hasMessage("stuck a");
+
+ assertThat(reloader("capabilities: {a: disabled}\n").reloadWithOutcome().failures())
+ .containsEntry("a", "stuck a");
+ assertThat(registry.stateOf("a")).isEqualTo(Extension.State.FAILED);
+ }
+
+ @Test
+ void aReEnableWhoseLeftoverCleanupFailsAgainIsReportedAndNeverStarts() throws Exception {
+ Probe a = register(new Probe("a", null, null));
+ registry.enable("a");
+ a.stopFails = true;
+ assertThatThrownBy(() -> registry.disable("a")).hasMessage("stuck a");
+ events.clear();
+
+ assertThat(reloader("capabilities: {a: enabled}\n").reloadWithOutcome().failures())
+ .containsEntry("a", "stuck a");
+ assertThat(events).containsExactly("stop a");
+ assertThat(registry.stateOf("a")).isEqualTo(Extension.State.FAILED);
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+ }
+
+ @Test
+ void aStartFailureWithoutAMessageIsNamedByTypeAndAFailingStopKeepsTheCleanupOwed()
+ throws Exception {
+ Probe c = register(new Probe("c", null, null));
+ c.startFails = true;
+ c.stopFails = true;
+
+ assertThat(reloader("capabilities: {c: enabled}\n").reloadWithOutcome().failures())
+ .containsEntry("c", "IllegalStateException");
+ assertThat(registry.stateOf("c")).isEqualTo(Extension.State.FAILED);
+
+ // The rollback's stop failed, so the next disable still owes and runs a stop.
+ c.stopFails = false;
+ events.clear();
+ registry.disable("c");
+ assertThat(events).containsExactly("stop c");
+ assertThat(registry.stateOf("c")).isEqualTo(Extension.State.DISABLED);
+ }
+
+ @Test
+ void aDependentWhoseStopFailsWhenItsPrerequisiteIsDisabledEndsFailedAndIsReported() {
+ register(new Probe("a", null, null));
+ Probe b = register(new Probe("b", "a", null));
+ registry.enable("a");
+ registry.enable("b");
+ b.stopFails = true;
+
+ // The prerequisite itself stops cleanly, but the dependent's failure is the disable's outcome.
+ assertThatThrownBy(() -> registry.disable("a"))
+ .hasMessageContaining("'b'")
+ .hasMessageContaining("stuck b");
+
+ assertThat(registry.stateOf("a")).isEqualTo(Extension.State.DISABLED);
+ assertThat(registry.stateOf("b")).isEqualTo(Extension.State.FAILED);
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+ }
+
+ @Test
+ void aProviderCommandThatCoreOwnsRejectsTheReloadBeforeAnythingStartsOrStops() throws Exception {
+ attachContext();
+ handlers.put(Command.TAGMSG, (s, m) -> events.add("handled by core"));
+ register(new Probe("a", null, Command.TAGMSG));
+
+ ConfigurationReloader reloader = reloader("capabilities: {a: enabled}\n");
+ assertThatThrownBy(reloader::reload)
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("TAGMSG");
+
+ assertThat(events).isEmpty();
+ assertThat(registry.stateOf("a")).isEqualTo(Extension.State.DISABLED);
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+ }
+
+ @Test
+ void aConfigurationPublicationThatThrowsPublishesNothingAndRollsTheStartsBack() {
+ register(new Probe("a", null, null));
+ SupportedFeatures features = registry.supportedFeatures();
+ int nicknameLength = features.nicknameMaxLength();
+ ExtensionRegistry.DesiredState desired =
+ new ExtensionRegistry.DesiredState(
+ Map.of("a", true),
+ Map.of(),
+ nicknameLength + 1,
+ features.channelNameMaxLength(),
+ features.topicMaxLength(),
+ features.maxModesPerCommand());
+ Runnable failing =
+ () -> {
+ throw new IllegalStateException("configuration not published");
+ };
+
+ assertThatThrownBy(() -> registry.applyDesiredState(desired, failing))
+ .hasMessage("configuration not published");
+
+ assertThat(events).containsExactly("start a", "stop a");
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+ assertThat(registry.stateOf("a")).isEqualTo(Extension.State.FAILED);
+ assertThat(registry.supportedFeatures().nicknameMaxLength()).isEqualTo(nicknameLength);
+ }
+}
diff --git a/jircd-core/src/test/java/net/jircd/core/config/ConfigurationReloaderLifecycleTest.java b/jircd-core/src/test/java/net/jircd/core/config/ConfigurationReloaderLifecycleTest.java
new file mode 100644
index 0000000..5b71df3
--- /dev/null
+++ b/jircd-core/src/test/java/net/jircd/core/config/ConfigurationReloaderLifecycleTest.java
@@ -0,0 +1,422 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.core.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import net.jircd.core.extension.AbstractCapabilityExtension;
+import net.jircd.core.extension.Extension;
+import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.ServerContext;
+import net.jircd.core.extension.capability.CapabilityRegistrar;
+import net.jircd.core.session.ClientSession;
+import net.jircd.core.session.RateLimitBucket;
+import net.jircd.core.session.SessionWriter;
+import net.jircd.core.session.TagRenderer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * A REHASH validates the whole candidate provider graph before changing anything, enables
+ * prerequisites before their dependents, applies a valid reload as one publication while holding
+ * the lifecycle lock, and reports a provider that failed to start without undoing the rest.
+ */
+class ConfigurationReloaderLifecycleTest {
+
+ private static final String HEADER = "serverName: test.jircd.local\n";
+
+ @TempDir Path directory;
+
+ private final List started = Collections.synchronizedList(new ArrayList<>());
+
+ private class Offer extends AbstractCapabilityExtension {
+ private final String requires;
+ private final boolean failsToStart;
+
+ Offer(String id, String requires, boolean failsToStart) {
+ super(id);
+ this.requires = requires;
+ this.failsToStart = failsToStart;
+ }
+
+ @Override
+ public void register(CapabilityRegistrar registrar) {
+ if (requires != null) registrar.requiresProviders(requires);
+ }
+
+ @Override
+ public void start(ServerContext context) {
+ if (failsToStart) throw new IllegalStateException("cannot start " + id());
+ started.add(id());
+ }
+ }
+
+ /** Fails while staging its registrations: a provider fault, not an invalid configuration. */
+ private final class Unstageable extends Offer {
+ Unstageable() {
+ super("unstageable", null, false);
+ }
+
+ @Override
+ public void register(CapabilityRegistrar registrar) {
+ throw new IllegalStateException("cannot stage");
+ }
+ }
+
+ /** Its first stop fails, leaving a cleanup for a later disable or reload to finish. */
+ private final class Sticky extends Offer {
+ private boolean failedOnce;
+
+ Sticky() {
+ super("sticky", null, false);
+ }
+
+ @Override
+ public void stop() {
+ if (!failedOnce) {
+ failedOnce = true;
+ throw new IllegalStateException("stuck");
+ }
+ }
+ }
+
+ /** Holds its start open until released, so a test can look at the state mid-reload. */
+ private final class Slow extends Offer {
+ final CountDownLatch entered = new CountDownLatch(1);
+ final CountDownLatch release = new CountDownLatch(1);
+
+ Slow() {
+ super("slow", null, false);
+ }
+
+ @Override
+ public void start(ServerContext context) {
+ entered.countDown();
+ try {
+ release.await(5, TimeUnit.SECONDS);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
+ super.start(context);
+ }
+ }
+
+ private ConfigurationReloader reloader(ExtensionRegistry extensions, String yaml)
+ throws Exception {
+ ConfigurationLoader loader =
+ new ConfigurationLoader(
+ Set.of(
+ "base",
+ "dependent",
+ "broken",
+ "cyc-a",
+ "cyc-b",
+ "other",
+ "slow",
+ "leaning",
+ "unstageable",
+ "propped",
+ "sticky"),
+ Set.of());
+ ServerConfiguration initial =
+ loader.load(new ByteArrayInputStream(HEADER.getBytes(StandardCharsets.UTF_8)));
+ Path file = directory.resolve("reload.yaml");
+ Files.writeString(file, HEADER + yaml);
+ return new ConfigurationReloader(file, loader, extensions, initial);
+ }
+
+ private ExtensionRegistry registry() {
+ ExtensionRegistry extensions = new ExtensionRegistry();
+ // Registered dependent-first, so only dependency ordering can start base before it.
+ extensions.register(new Offer("dependent", "base", false));
+ extensions.register(new Offer("base", null, false));
+ extensions.register(new Offer("broken", null, true));
+ return extensions;
+ }
+
+ @Test
+ void enablesPrerequisitesBeforeTheirDependents() throws Exception {
+ ExtensionRegistry extensions = registry();
+ ConfigurationReloader reloader =
+ reloader(extensions, "capabilities: {dependent: enabled, base: enabled}\n");
+
+ ConfigurationReloader.ReloadOutcome outcome = reloader.reloadWithOutcome();
+
+ assertThat(outcome.failures()).isEmpty();
+ assertThat(started).containsExactly("base", "dependent");
+ assertThat(extensions.stateOf("dependent")).isEqualTo(Extension.State.ENABLED);
+ }
+
+ @Test
+ void aDependentWhosePrerequisiteWouldBeDisabledIsInvalidAndChangesNothing() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.enable("base");
+ ConfigurationReloader reloader =
+ reloader(
+ extensions,
+ "nicknameMaxLength: 20\ncapabilities: {dependent: enabled, base: disabled}\n");
+ ServerConfiguration before = reloader.current();
+
+ assertThatThrownBy(reloader::reload)
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("dependent")
+ .hasMessageContaining("base");
+
+ assertThat(reloader.current()).isSameAs(before);
+ assertThat(extensions.stateOf("base")).isEqualTo(Extension.State.ENABLED);
+ assertThat(extensions.stateOf("dependent")).isEqualTo(Extension.State.DISABLED);
+ }
+
+ @Test
+ void aPrerequisiteLeftOutOfTheFileCountsWhenItIsAlreadyEnabled() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.enable("base");
+ ConfigurationReloader reloader = reloader(extensions, "capabilities: {dependent: enabled}\n");
+
+ assertThat(reloader.reloadWithOutcome().failures()).isEmpty();
+ assertThat(extensions.stateOf("dependent")).isEqualTo(Extension.State.ENABLED);
+ }
+
+ @Test
+ void aProviderThatFailsToStartIsReportedAndTheRestStillApplies() throws Exception {
+ ExtensionRegistry extensions = registry();
+ ConfigurationReloader reloader =
+ reloader(extensions, "capabilities: {broken: enabled, base: enabled}\n");
+
+ ConfigurationReloader.ReloadOutcome outcome = reloader.reloadWithOutcome();
+
+ assertThat(outcome.failures()).containsOnlyKeys("broken");
+ assertThat(outcome.failures().get("broken")).contains("cannot start broken");
+ assertThat(extensions.stateOf("broken")).isEqualTo(Extension.State.FAILED);
+ assertThat(extensions.stateOf("base")).isEqualTo(Extension.State.ENABLED);
+ assertThat(reloader.current()).isSameAs(outcome.configuration());
+ }
+
+ @Test
+ void disablingAPrerequisiteThroughReloadAlsoWithdrawsItsDependent() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.enable("base");
+ extensions.enable("dependent");
+ ConfigurationReloader reloader =
+ reloader(extensions, "capabilities: {base: disabled, dependent: disabled}\n");
+
+ assertThat(reloader.reloadWithOutcome().failures()).isEmpty();
+ assertThat(extensions.stateOf("base")).isEqualTo(Extension.State.DISABLED);
+ assertThat(extensions.stateOf("dependent")).isEqualTo(Extension.State.DISABLED);
+ assertThat(extensions.capabilitySnapshot().providerIds()).isEmpty();
+ }
+
+ @Test
+ void aDependencyCycleIsInvalidAndAnUnrelatedDisableIsNotApplied() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.register(new Offer("cyc-a", "cyc-b", false));
+ extensions.register(new Offer("cyc-b", "cyc-a", false));
+ extensions.register(new Offer("other", null, false));
+ extensions.enable("other");
+ ConfigurationReloader reloader =
+ reloader(
+ extensions,
+ "nicknameMaxLength: 20\ncapabilities: {cyc-a: enabled, cyc-b: enabled, other: disabled}\n");
+ ServerConfiguration before = reloader.current();
+
+ assertThatThrownBy(reloader::reload)
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("cyclic");
+
+ assertThat(reloader.current()).isSameAs(before);
+ assertThat(extensions.stateOf("other")).isEqualTo(Extension.State.ENABLED);
+ assertThat(extensions.stateOf("cyc-a")).isEqualTo(Extension.State.DISABLED);
+ assertThat(extensions.stateOf("cyc-b")).isEqualTo(Extension.State.DISABLED);
+ assertThat(extensions.capabilitySnapshot().providerIds()).containsExactly("other");
+ assertThat(started).containsExactly("other");
+ }
+
+ @Test
+ void aValidReloadIsOnePublicationSentAsOneDel() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.attachContext(
+ new ServerContext(
+ null,
+ null,
+ extensions,
+ () -> "test.jircd.local",
+ null,
+ null,
+ null,
+ extensions.capabilityServices()));
+ extensions.register(new Offer("other", null, false));
+ extensions.enable("base");
+ extensions.enable("other");
+ Set live = ConcurrentHashMap.newKeySet();
+ extensions.attachLiveSessions(live);
+ ClientSession observer =
+ new ClientSession("c1", "host.example", RateLimitBucket.withDefaults());
+ observer.recordCapVersion(302);
+ live.add(observer);
+ ConfigurationReloader reloader =
+ reloader(extensions, "capabilities: {base: disabled, other: disabled}\n");
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (SessionWriter writer = new SessionWriter(observer, out, TagRenderer.NONE)) {
+ observer.attachWriter(writer);
+ assertThat(reloader.reloadWithOutcome().failures()).isEmpty();
+ }
+
+ assertThat(out.toString(StandardCharsets.UTF_8))
+ .isEqualTo(":test.jircd.local CAP * DEL :base other\r\n");
+ }
+
+ @Test
+ void theWholeReloadHoldsTheLifecycleSoNothingIsPublishedUntilEveryStartIsDone() throws Exception {
+ ExtensionRegistry extensions = registry();
+ Slow slow = new Slow();
+ extensions.register(slow);
+ extensions.register(new Offer("other", null, false));
+ extensions.enable("base");
+ extensions.enable("other");
+ ConfigurationReloader reloader =
+ reloader(extensions, "capabilities: {slow: enabled, other: disabled}\n");
+
+ CompletableFuture reload =
+ CompletableFuture.supplyAsync(
+ () -> {
+ try {
+ return reloader.reloadWithOutcome();
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ try {
+ assertThat(slow.entered.await(5, TimeUnit.SECONDS)).isTrue();
+ // Mid-reload: the disable of "other" is not published ahead of the enable.
+ assertThat(extensions.capabilitySnapshot().providerIds()).contains("other", "base");
+ CompletableFuture toggle = CompletableFuture.runAsync(() -> extensions.disable("base"));
+ assertThatThrownBy(() -> toggle.get(200, TimeUnit.MILLISECONDS))
+ .isInstanceOf(TimeoutException.class);
+ slow.release.countDown();
+ assertThat(reload.get(5, TimeUnit.SECONDS).failures()).isEmpty();
+ toggle.get(5, TimeUnit.SECONDS);
+ } finally {
+ slow.release.countDown();
+ }
+ assertThat(extensions.capabilitySnapshot().providerIds()).containsExactly("slow");
+ }
+
+ @Test
+ void aProviderThatFailsToStartTakesItsDependentInTheSameReloadWithIt() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.register(new Offer("leaning", "broken", false));
+ extensions.enable("base");
+ ConfigurationReloader reloader =
+ reloader(extensions, "capabilities: {broken: enabled, leaning: enabled}\n");
+
+ ConfigurationReloader.ReloadOutcome outcome = reloader.reloadWithOutcome();
+
+ assertThat(outcome.failures()).containsOnlyKeys("broken", "leaning");
+ assertThat(outcome.failures().get("leaning")).contains("requires 'broken'");
+ assertThat(extensions.stateOf("leaning")).isEqualTo(Extension.State.FAILED);
+ assertThat(started).containsExactly("base");
+ assertThat(extensions.capabilitySnapshot().providerIds()).containsExactly("base");
+ }
+
+ @Test
+ void aProviderWhoseStagingFailsIsIsolatedWithItsDependents() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.register(new Unstageable());
+ extensions.register(new Offer("propped", "unstageable", false));
+ ConfigurationReloader reloader =
+ reloader(
+ extensions, "capabilities: {unstageable: enabled, propped: enabled, base: enabled}\n");
+
+ ConfigurationReloader.ReloadOutcome outcome = reloader.reloadWithOutcome();
+
+ assertThat(outcome.failures()).containsOnlyKeys("unstageable", "propped");
+ assertThat(extensions.stateOf("unstageable")).isEqualTo(Extension.State.FAILED);
+ assertThat(extensions.stateOf("propped")).isEqualTo(Extension.State.FAILED);
+ assertThat(extensions.stateOf("base")).isEqualTo(Extension.State.ENABLED);
+ assertThat(started).containsExactly("base");
+ }
+
+ @Test
+ void disablingAPrerequisiteOfADependentDesiredEnabledIsInvalid() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.enable("base");
+ extensions.enable("dependent");
+ ConfigurationReloader reloader =
+ reloader(extensions, "capabilities: {base: disabled, dependent: enabled}\n");
+
+ assertThatThrownBy(reloader::reload)
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("dependent");
+ assertThat(extensions.capabilitySnapshot().providerIds())
+ .containsExactlyInAnyOrder("base", "dependent");
+ }
+
+ @Test
+ void aReloadFinishesTheCleanupAnEarlierFailedStopLeft() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.register(new Sticky());
+ extensions.enable("sticky");
+ assertThatThrownBy(() -> extensions.disable("sticky")).hasMessage("stuck");
+ assertThat(extensions.stateOf("sticky")).isEqualTo(Extension.State.FAILED);
+
+ ConfigurationReloader reloader = reloader(extensions, "capabilities: {sticky: disabled}\n");
+ assertThat(reloader.reloadWithOutcome().failures()).isEmpty();
+ assertThat(extensions.stateOf("sticky")).isEqualTo(Extension.State.DISABLED);
+ }
+
+ @Test
+ void aReloadEnablingAProviderWithAnUnfinishedCleanupFinishesItFirst() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.register(new Sticky());
+ extensions.enable("sticky");
+ assertThatThrownBy(() -> extensions.disable("sticky")).hasMessage("stuck");
+
+ ConfigurationReloader reloader = reloader(extensions, "capabilities: {sticky: enabled}\n");
+ assertThat(reloader.reloadWithOutcome().failures()).isEmpty();
+ assertThat(extensions.stateOf("sticky")).isEqualTo(Extension.State.ENABLED);
+ assertThat(extensions.capabilitySnapshot().providerIds()).containsExactly("sticky");
+ }
+
+ @Test
+ void anIdTheRegistryDoesNotHoldIsLeftAloneAndChangesNothingElse() throws Exception {
+ ExtensionRegistry extensions = registry();
+ extensions.enable("base");
+ // Known to the loader, never discovered here: there is no provider to enable or disable.
+ ConfigurationReloader reloader =
+ reloader(extensions, "capabilities: {other: enabled, slow: disabled, base: enabled}\n");
+
+ assertThat(reloader.reloadWithOutcome().failures()).isEmpty();
+ assertThat(extensions.stateOf("other")).isNull();
+ assertThat(extensions.capabilitySnapshot().providerIds()).containsExactly("base");
+ assertThat(started).containsExactly("base");
+ }
+}
diff --git a/jircd-core/src/test/java/net/jircd/core/extension/ExtensionRegistryCapabilityTest.java b/jircd-core/src/test/java/net/jircd/core/extension/ExtensionRegistryCapabilityTest.java
index b59e591..045464d 100644
--- a/jircd-core/src/test/java/net/jircd/core/extension/ExtensionRegistryCapabilityTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/extension/ExtensionRegistryCapabilityTest.java
@@ -95,16 +95,12 @@ private static Provider tagProvider(String id, String key) {
private final Map handlers = new java.util.EnumMap<>(Command.class);
private int failAfterRegistrations = Integer.MAX_VALUE;
private boolean failUnregister;
- private Error unregisterError;
- private RuntimeException sharedUnregisterFailure;
- private Runnable onRegister = () -> {};
/** Core's command table as a provider sees it: ownership query, install and removal. */
private final CommandRegistrar commands =
new CommandRegistrar() {
@Override
public void register(Command command, CommandHandler handler) {
- onRegister.run();
if (handlers.size() >= failAfterRegistrations) throw new NoClassDefFoundError("x");
registeredCommands.add(command.name());
handlers.put(command, handler);
@@ -117,8 +113,6 @@ public boolean isRegistered(Command command) {
@Override
public void unregister(Command command, CommandHandler installed) {
- if (unregisterError != null) throw unregisterError;
- if (sharedUnregisterFailure != null) throw sharedUnregisterFailure;
if (failUnregister) throw new IllegalStateException("cannot remove");
handlers.remove(command, installed);
}
@@ -240,16 +234,16 @@ void aConflictingProviderIsRejectedWithoutDisturbingTheEnabledOne() {
}
@Test
- void disablingAProviderThatAnotherRequiresIsRefusedAndChangesNothing() {
+ void disablingAProviderThatAnotherRequiresWithdrawsBothInOnePublication() {
ExtensionRegistry registry = registry("test.jircd.local");
registry.register(tagProvider("base", "b"));
registry.register(new Provider("dependent", r -> r.requiresProviders("base"), null));
registry.enable("base");
registry.enable("dependent");
- assertThatThrownBy(() -> registry.disable("base")).isInstanceOf(IllegalStateException.class);
- assertThat(registry.stateOf("base")).isEqualTo(Extension.State.ENABLED);
- assertThat(registry.capabilitySnapshot().providerIds())
- .containsExactlyInAnyOrder("base", "dependent");
+ registry.disable("base");
+ assertThat(registry.stateOf("base")).isEqualTo(Extension.State.DISABLED);
+ assertThat(registry.stateOf("dependent")).isEqualTo(Extension.State.DISABLED);
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
}
@Test
@@ -262,21 +256,48 @@ void enablingWithoutItsRequiredProviderFails() {
}
@Test
- void providerCommandsAreInstalledOnEnableAndRemovedOnDisable() {
+ void providerCommandsResolveFromTheSnapshotAndNeverTouchCoresTable() {
ExtensionRegistry registry = registry("test.jircd.local");
AtomicInteger handled = new AtomicInteger();
registry.register(
new Provider(
"cmd", r -> r.command(Command.TAGMSG, (s, m) -> handled.incrementAndGet()), null));
registry.enable("cmd");
- assertThat(registeredCommands).containsExactly("TAGMSG");
+ assertThat(registeredCommands).isEmpty();
ClientSession session = new ClientSession("c", "host", RateLimitBucket.withDefaults());
- handlers.get(Command.TAGMSG).handle(session, Message.of(Command.TAGMSG, "#c"));
+ registry
+ .capabilityCommand(Command.TAGMSG)
+ .orElseThrow()
+ .handle(session, Message.of(Command.TAGMSG, "#c"));
assertThat(handled).hasValue(1);
registry.disable("cmd");
- assertThat(handlers).doesNotContainKey(Command.TAGMSG);
+ assertThat(registry.capabilityCommand(Command.TAGMSG)).isEmpty();
registry.enable("cmd");
- assertThat(handlers).containsKey(Command.TAGMSG);
+ assertThat(registry.capabilityCommand(Command.TAGMSG)).isPresent();
+ assertThat(handlers).isEmpty();
+ }
+
+ @Test
+ void aRegistrarThatCannotInstallOrRemoveHandlersCannotHalfEnableOrStrandAProvider() {
+ ExtensionRegistry registry = registry("test.jircd.local");
+ failAfterRegistrations = 0;
+ failUnregister = true;
+ Provider provider =
+ new Provider(
+ "two",
+ r -> {
+ r.command(Command.TAGMSG, (s, m) -> {});
+ r.command(Command.AWAY, (s, m) -> {});
+ },
+ null);
+ registry.register(provider);
+ registry.enable("two");
+ assertThat(registry.capabilityCommand(Command.TAGMSG)).isPresent();
+ assertThat(registry.capabilityCommand(Command.AWAY)).isPresent();
+ registry.disable("two");
+ assertThat(provider.stops).hasValue(1);
+ assertThat(registry.stateOf("two")).isEqualTo(Extension.State.DISABLED);
+ assertThat(registry.capabilityCommand(Command.TAGMSG)).isEmpty();
}
@Test
@@ -295,24 +316,6 @@ void aProviderCannotReplaceACommandCoreOrAnotherExtensionAlreadyOwns() {
assertThat(handlers).containsEntry(Command.JOIN, core);
}
- @Test
- void aFailureInstallingLaterCommandsRollsBackTheEarlierOnesAndThePublication() {
- ExtensionRegistry registry = registry("test.jircd.local");
- failAfterRegistrations = 1;
- registry.register(
- new Provider(
- "two",
- r -> {
- r.command(Command.TAGMSG, (s, m) -> {});
- r.command(Command.AWAY, (s, m) -> {});
- },
- null));
- assertThatThrownBy(() -> registry.enable("two")).isInstanceOf(NoClassDefFoundError.class);
- assertThat(handlers).isEmpty();
- assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
- assertThat(registry.stateOf("two")).isEqualTo(Extension.State.FAILED);
- }
-
@Test
void aRegistrarMustSupportOwnershipAwareRemovalSoNoProviderHandlerOutlivesWithdrawal()
throws Exception {
@@ -339,81 +342,6 @@ void disablingAProviderLeavesAReplacementInstalledByAnotherExtensionAlone() {
assertThat(handlers).containsEntry(Command.OPER, replacement);
}
- @Test
- void anUnregisterFailureDuringRollbackStillWithdrawsThePublication() {
- ExtensionRegistry registry = registry("test.jircd.local");
- failAfterRegistrations = 1;
- failUnregister = true;
- registry.register(
- new Provider(
- "two",
- r -> {
- r.command(Command.TAGMSG, (s, m) -> {});
- r.command(Command.AWAY, (s, m) -> {});
- },
- null));
- assertThatThrownBy(() -> registry.enable("two")).isInstanceOf(NoClassDefFoundError.class);
- assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
- assertThat(registry.stateOf("two")).isEqualTo(Extension.State.FAILED);
- }
-
- @Test
- void anUnregisterFailureDuringDisableStillStopsAndFinalizesTheProvider() {
- ExtensionRegistry registry = registry("test.jircd.local");
- Provider provider =
- new Provider(
- "two",
- r -> {
- r.command(Command.TAGMSG, (s, m) -> {});
- r.command(Command.AWAY, (s, m) -> {});
- },
- null);
- registry.register(provider);
- registry.enable("two");
- failUnregister = true;
- assertThatThrownBy(() -> registry.disable("two"))
- .isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("cannot remove");
- assertThat(provider.stops).hasValue(1);
- assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
- assertThat(registry.stateOf("two")).isEqualTo(Extension.State.FAILED);
- }
-
- @Test
- void oneSharedExceptionFromUnregisterAndStopStillFinalizesTheProvider() {
- ExtensionRegistry registry = registry("test.jircd.local");
- IllegalStateException shared = new IllegalStateException("shared");
- Provider provider =
- new Provider(
- "two",
- r -> {
- r.command(Command.TAGMSG, (s, m) -> {});
- r.command(Command.AWAY, (s, m) -> {});
- },
- null);
- registry.register(provider);
- registry.enable("two");
- sharedUnregisterFailure = shared;
- provider.stopFailure = shared;
- assertThatThrownBy(() -> registry.disable("two")).isSameAs(shared);
- assertThat(provider.stops).hasValue(1);
- assertThat(registry.stateOf("two")).isEqualTo(Extension.State.FAILED);
- assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
- }
-
- @Test
- void aLinkageErrorFromUnregisterStillStopsAndFinalizesTheProvider() {
- ExtensionRegistry registry = registry("test.jircd.local");
- Provider provider = new Provider("one", r -> r.command(Command.TAGMSG, (s, m) -> {}), null);
- registry.register(provider);
- registry.enable("one");
- unregisterError = new NoClassDefFoundError("gone");
- assertThatThrownBy(() -> registry.disable("one")).isSameAs(unregisterError);
- assertThat(provider.stops).hasValue(1);
- assertThat(registry.stateOf("one")).isEqualTo(Extension.State.FAILED);
- assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
- }
-
@Test
void capabilityServicesExposeOnlyAReadOnlySnapshot() {
ExtensionRegistry registry = registry("test.jircd.local");
@@ -531,45 +459,11 @@ void aFaultingProviderCommandIsContainedAtTheCoreBoundary() {
registry.register(new Provider("cmd", r -> r.command(Command.TAGMSG, failing), null));
registry.enable("cmd");
ClientSession session = new ClientSession("c", "host", RateLimitBucket.withDefaults());
- CommandHandler registered = handlers.get(Command.TAGMSG);
+ CommandHandler registered = registry.capabilityCommand(Command.TAGMSG).orElseThrow();
Message tagmsg = Message.of(Command.TAGMSG, "#c");
assertThatCode(() -> registered.handle(session, tagmsg)).doesNotThrowAnyException();
}
- @Test
- void aRejectedCommandRegistrationLeavesNothingPublished() {
- ExtensionRegistry registry = new ExtensionRegistry();
- registry.attachContext(
- new ServerContext(
- null,
- null,
- registry,
- () -> "test.jircd.local",
- null,
- null,
- new CommandRegistrar() {
- @Override
- public void register(Command command, CommandHandler handler) {
- throw new IllegalStateException("taken");
- }
-
- @Override
- public boolean isRegistered(Command command) {
- return false;
- }
-
- @Override
- public void unregister(Command command, CommandHandler installed) {
- // Nothing was installed.
- }
- },
- registry.capabilityServices()));
- registry.register(new Provider("cmd", r -> r.command(Command.TAGMSG, (s, m) -> {}), null));
- assertThatThrownBy(() -> registry.enable("cmd")).isInstanceOf(IllegalStateException.class);
- assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
- assertThat(registry.stateOf("cmd")).isEqualTo(Extension.State.FAILED);
- }
-
@Test
void enableRejectsDecorationsThatWouldOverflowTheConfiguredReplyLimits() {
// 353 with the legacy 2-byte status already needs S+2N+C+15; this provider adds 300 identity
@@ -610,75 +504,6 @@ void stagingWithoutServerContextStillWorksForUnitSetups() {
// ---- Sourcery review of PR #104
- @Test
- void aLeftoverHandlerRefusesToRunAndIsRemovedByRetryingTheDisable() {
- ExtensionRegistry registry = registry("test.jircd.local");
- AtomicInteger handled = new AtomicInteger();
- registry.register(
- new Provider(
- "cmd", r -> r.command(Command.TAGMSG, (s, m) -> handled.incrementAndGet()), null));
- registry.enable("cmd");
- failUnregister = true;
- assertThatThrownBy(() -> registry.disable("cmd")).isInstanceOf(IllegalStateException.class);
- ClientSession session = new ClientSession("c", "host", RateLimitBucket.withDefaults());
- Message tagmsg = Message.of(Command.TAGMSG, "#c");
- assertThat(handlers).containsKey(Command.TAGMSG);
- handlers.get(Command.TAGMSG).handle(session, tagmsg);
- assertThat(handled).hasValue(0);
- failUnregister = false;
- registry.disable("cmd");
- assertThat(handlers).doesNotContainKey(Command.TAGMSG);
- assertThat(registry.stateOf("cmd")).isEqualTo(Extension.State.DISABLED);
- registry.enable("cmd");
- handlers.get(Command.TAGMSG).handle(session, tagmsg);
- assertThat(handled).hasValue(1);
- }
-
- @Test
- void aFailedRollbackKeepsTheLeftoverForRetryAndItsHandlerRefusesToRun() {
- ExtensionRegistry registry = registry("test.jircd.local");
- AtomicInteger handled = new AtomicInteger();
- failAfterRegistrations = 1;
- failUnregister = true;
- registry.register(
- new Provider(
- "two",
- r -> {
- r.command(Command.TAGMSG, (s, m) -> handled.incrementAndGet());
- r.command(Command.AWAY, (s, m) -> {});
- },
- null));
- assertThatThrownBy(() -> registry.enable("two")).isInstanceOf(NoClassDefFoundError.class);
- ClientSession session = new ClientSession("c", "host", RateLimitBucket.withDefaults());
- assertThat(handlers).containsKey(Command.TAGMSG);
- handlers.get(Command.TAGMSG).handle(session, Message.of(Command.TAGMSG, "#c"));
- assertThat(handled).hasValue(0);
- failUnregister = false;
- registry.disable("two");
- assertThat(handlers).isEmpty();
- assertThat(registry.stateOf("two")).isEqualTo(Extension.State.DISABLED);
- }
-
- @Test
- void hooksAreNeverVisibleBeforeTheCommandsAreInstalledNorAfterAFailedEnable() {
- ExtensionRegistry registry = registry("test.jircd.local");
- List> seen = new ArrayList<>();
- onRegister = () -> seen.add(registry.capabilitySnapshot().providerIds());
- failAfterRegistrations = 1;
- registry.register(
- new Provider(
- "two",
- r -> {
- r.onOutboundTags(Set.of("a"), 16, message -> Map.of("a", "v"));
- r.command(Command.TAGMSG, (s, m) -> {});
- r.command(Command.AWAY, (s, m) -> {});
- },
- null));
- assertThatThrownBy(() -> registry.enable("two")).isInstanceOf(NoClassDefFoundError.class);
- assertThat(seen).isNotEmpty().allSatisfy(ids -> assertThat(ids).isEmpty());
- assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
- }
-
@Test
void providerCallbacksRunOutsideTheRegistryMonitor() {
ExtensionRegistry registry = registry("test.jircd.local");
@@ -853,4 +678,84 @@ public Capability providedCapability() {
assertThat(disableAttempt.get()).isInstanceOf(IllegalStateException.class);
assertThat(registry.stateOf("other")).isEqualTo(Extension.State.DISABLED);
}
+
+ /** Offers {@code capability} under its own id; the name may fail to resolve until fixed. */
+ private static final class Offering implements CapabilityExtension {
+ private final String id;
+ private final String capability;
+ volatile boolean nameFails;
+
+ Offering(String id, String capability) {
+ this.id = id;
+ this.capability = capability;
+ }
+
+ @Override
+ public String id() {
+ return id;
+ }
+
+ @Override
+ public void start(ServerContext context) {
+ // Holds nothing.
+ }
+
+ @Override
+ public void stop() {
+ // Holds nothing.
+ }
+
+ @Override
+ public Capability providedCapability() {
+ if (nameFails) throw new IllegalStateException("no name yet");
+ return new Capability(capability);
+ }
+ }
+
+ @Test
+ void aProviderWhoseCapabilityNameThrowsIsNotRegisteredAndCanRegisterOnceFixed() {
+ ExtensionRegistry registry = registry("irc.example");
+ Offering flaky = new Offering("flaky", "flaky");
+ flaky.nameFails = true;
+
+ // Rejected the way discovery skips a provider, and nothing about it is kept.
+ assertThatThrownBy(() -> registry.register(flaky))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("flaky");
+ assertThat(registry.find("flaky")).isEmpty();
+ assertThat(registry.knownCapabilityNames()).doesNotContain("flaky");
+
+ flaky.nameFails = false;
+ registry.register(flaky);
+ assertThat(registry.find("flaky")).containsSame(flaky);
+ assertThat(registry.knownCapabilityNames()).contains("flaky");
+ }
+
+ @Test
+ void aProviderClaimingCoreCapNotifyIsNeverPublishedSoItsDisableCannotWithdrawIt() {
+ ExtensionRegistry registry = registry("irc.example");
+ registry.register(new Offering("impostor", "cap-notify"));
+ ClientSession session = new ClientSession("c1", "host", RateLimitBucket.withDefaults());
+ session.replaceNegotiatedCapabilities(List.of("cap-notify"));
+ registry.attachLiveSessions(List.of(session));
+
+ assertThatThrownBy(() -> registry.enable("impostor")).hasMessageContaining("core capability");
+ SupportedFeatures features = registry.supportedFeatures();
+ Map failures =
+ registry.applyDesiredState(
+ new ExtensionRegistry.DesiredState(
+ Map.of("impostor", true),
+ Map.of(),
+ features.nicknameMaxLength(),
+ features.channelNameMaxLength(),
+ features.topicMaxLength(),
+ features.maxModesPerCommand()),
+ () -> {});
+ assertThat(failures).containsKey("impostor");
+ registry.disable("impostor");
+
+ assertThat(registry.capabilitySnapshot().offeredNames()).isEmpty();
+ assertThat(registry.stateOf("impostor")).isEqualTo(Extension.State.FAILED);
+ assertThat(session.negotiatedCapabilities()).containsExactly("cap-notify");
+ }
}
diff --git a/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRegistrarTest.java b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRegistrarTest.java
index d4ba51e..730e708 100644
--- a/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRegistrarTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRegistrarTest.java
@@ -274,7 +274,8 @@ void snapshotIndexesHooksDeterministicallyByProviderId() {
assertThat(snapshot.hooks(HookKind.OUTBOUND_TAGS))
.extracting(HookRegistration::providerId)
.containsExactly("alpha", "beta");
- assertThat(snapshot.without("alpha").hooks(HookKind.OUTBOUND_TAGS)).hasSize(1);
+ assertThat(snapshot.withoutAll(java.util.Set.of("alpha")).hooks(HookKind.OUTBOUND_TAGS))
+ .hasSize(1);
assertThat(RuntimeSnapshot.EMPTY.hooks(HookKind.EVENT)).isEmpty();
}
diff --git a/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRuntimeTest.java b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRuntimeTest.java
new file mode 100644
index 0000000..37b77c9
--- /dev/null
+++ b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRuntimeTest.java
@@ -0,0 +1,510 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.core.extension.capability;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import net.jircd.core.capability.CapabilityNegotiator;
+import net.jircd.core.extension.AbstractCapabilityExtension;
+import net.jircd.core.extension.Extension;
+import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.ServerContext;
+import net.jircd.core.session.CapabilityTagRenderer;
+import net.jircd.core.session.ClientSession;
+import net.jircd.core.session.RateLimitBucket;
+import net.jircd.core.session.SessionWriter;
+import net.jircd.core.session.TagRenderer;
+import net.jircd.protocol.Command;
+import net.jircd.protocol.Message;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Snapshot publication and the all-session prune happen as one step under the administrative mutex
+ * that CAP state and live-session tracking share (#97). Work captured earlier may still finish
+ * after DEL; the next capture sees the new state. Group admission alone makes a current
+ * prerequisite choice. Provider callbacks run outside the mutex.
+ */
+class CapabilityRuntimeTest {
+
+ private static final String SERVER = "irc.example";
+ private static final long WAIT_SECONDS = 5;
+
+ private static class Offer extends AbstractCapabilityExtension {
+ Offer(String name) {
+ super(name);
+ }
+ }
+
+ /** Requires {@code alpha} as a hard provider dependency. */
+ private static final class Dependent extends Offer {
+ Dependent() {
+ super("dependent");
+ }
+
+ @Override
+ public void register(CapabilityRegistrar registrar) {
+ registrar.requiresProviders("alpha");
+ }
+ }
+
+ /** Requires {@code requires}, if any, and records when it is stopped. */
+ private static final class Chained extends Offer {
+ private final String requires;
+ private final List stops;
+
+ Chained(String id, String requires, List stops) {
+ super(id);
+ this.requires = requires;
+ this.stops = stops;
+ }
+
+ @Override
+ public void register(CapabilityRegistrar registrar) {
+ if (requires != null) registrar.requiresProviders(requires);
+ }
+
+ @Override
+ public void stop() {
+ stops.add(id());
+ }
+ }
+
+ /** Frames groups for recipients that negotiated "framer" and "tags"; may hold the hook open. */
+ private static final class Framer extends Offer {
+ volatile CountDownLatch entered;
+ volatile CountDownLatch release;
+
+ Framer() {
+ super("framer");
+ }
+
+ @Override
+ public void register(CapabilityRegistrar registrar) {
+ registrar.onOutputGroup(
+ Set.of("tags"),
+ (group, context) -> {
+ CountDownLatch gate = release;
+ if (gate != null) {
+ entered.countDown();
+ try {
+ gate.await(WAIT_SECONDS, TimeUnit.SECONDS);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ List frames = new ArrayList<>();
+ frames.add(new Message(Map.of(), null, null, "BATCH", List.of("+g", group.type())));
+ frames.addAll(group.members());
+ frames.add(new Message(Map.of(), null, null, "BATCH", List.of("-g")));
+ return new FramingPlan(frames);
+ });
+ }
+ }
+
+ /** Records whether the administrative mutex was free while its callbacks ran. */
+ private static final class Probe extends Offer {
+ final AtomicReference runtime = new AtomicReference<>();
+ final AtomicBoolean startSawFreeMutex = new AtomicBoolean();
+ final AtomicBoolean stopSawFreeMutex = new AtomicBoolean();
+
+ Probe() {
+ super("probe");
+ }
+
+ @Override
+ public void start(ServerContext context) {
+ startSawFreeMutex.set(mutexFreeFromAnotherThread(runtime.get()));
+ }
+
+ @Override
+ public void stop() {
+ stopSawFreeMutex.set(mutexFreeFromAnotherThread(runtime.get()));
+ }
+ }
+
+ private static boolean mutexFreeFromAnotherThread(CapabilityControl runtime) {
+ try {
+ return CompletableFuture.supplyAsync(() -> runtime.underControl(() -> true))
+ .get(WAIT_SECONDS, TimeUnit.SECONDS);
+ } catch (Exception _) {
+ return false;
+ }
+ }
+
+ private ExtensionRegistry registry;
+ private CapabilityControl runtime;
+ private final Set live = ConcurrentHashMap.newKeySet();
+
+ @BeforeEach
+ void setUp() {
+ registry = new ExtensionRegistry();
+ registry.attachContext(
+ new ServerContext(
+ null, null, registry, () -> SERVER, null, null, null, registry.capabilityServices()));
+ runtime = registry.capabilityControl();
+ registry.attachLiveSessions(live);
+ }
+
+ private void enable(Extension... extensions) {
+ for (Extension extension : extensions) {
+ registry.register(extension);
+ registry.enable(extension.id());
+ }
+ }
+
+ private ClientSession connect(String id, String... negotiated) {
+ ClientSession session = new ClientSession(id, "host.example", RateLimitBucket.withDefaults());
+ session.replaceNegotiatedCapabilities(List.of(negotiated));
+ live.add(session);
+ return session;
+ }
+
+ private static Message privmsg(String body) {
+ return new Message(
+ Map.of(), "sender!ident@host", Command.PRIVMSG, "PRIVMSG", List.of("#chan", body));
+ }
+
+ private static List lines(ByteArrayOutputStream out) {
+ String text = out.toString(StandardCharsets.UTF_8);
+ return text.isEmpty() ? List.of() : List.of(text.split("\r\n"));
+ }
+
+ @Test
+ void everyCaptureSeesEitherTheOldStateOrTheNewSnapshotWithEverySessionPruned() throws Exception {
+ enable(new Offer("alpha"));
+ List sessions = new ArrayList<>();
+ for (int i = 0; i < 200; i++) sessions.add(connect("c" + i, "alpha"));
+ AtomicBoolean done = new AtomicBoolean();
+ AtomicReference violation = new AtomicReference<>();
+ Thread observer =
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ while (!done.get()) {
+ runtime.runUnderControl(
+ () -> {
+ boolean offered =
+ registry.capabilitySnapshot().descriptor("alpha").isPresent();
+ long holders =
+ sessions.stream()
+ .filter(s -> s.negotiatedCapabilities().contains("alpha"))
+ .count();
+ if (holders != (offered ? sessions.size() : 0)) {
+ violation.set("offered=" + offered + " holders=" + holders);
+ }
+ });
+ }
+ });
+ registry.disable("alpha");
+ done.set(true);
+ observer.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ assertThat(violation.get()).isNull();
+ assertThat(sessions).allMatch(s -> s.negotiatedCapabilities().isEmpty());
+ }
+
+ @Test
+ void aRequestRacingDisableNeverLeavesTheWithdrawnNameNegotiated() throws Exception {
+ CapabilityNegotiator negotiator = new CapabilityNegotiator(registry);
+ registry.register(new Offer("alpha"));
+ for (int i = 0; i < 200; i++) {
+ registry.enable("alpha");
+ ClientSession session = connect("c" + i);
+ CountDownLatch go = new CountDownLatch(1);
+ Thread requester =
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ try {
+ go.await();
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
+ negotiator.request(session, List.of("alpha"));
+ });
+ go.countDown();
+ registry.disable("alpha");
+ requester.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ assertThat(session.negotiatedCapabilities()).as("iteration %d", i).isEmpty();
+ }
+ }
+
+ @Test
+ void publicationWaitsForTheMutexThatCapStateAndSessionTrackingHold() throws Exception {
+ enable(new Offer("alpha"));
+ ClientSession holder = connect("c1", "alpha");
+ CountDownLatch held = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ Thread owner =
+ Thread.ofVirtual()
+ .start(
+ () ->
+ runtime.runUnderControl(
+ () -> {
+ held.countDown();
+ try {
+ release.await(WAIT_SECONDS, TimeUnit.SECONDS);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
+ }));
+ assertThat(held.await(WAIT_SECONDS, TimeUnit.SECONDS)).isTrue();
+ CompletableFuture disable = CompletableFuture.runAsync(() -> registry.disable("alpha"));
+
+ // Bounded: the disable cannot publish or prune while CAP or session tracking holds the mutex.
+ org.assertj.core.api.Assertions.assertThatThrownBy(
+ () -> disable.get(200, TimeUnit.MILLISECONDS))
+ .isInstanceOf(java.util.concurrent.TimeoutException.class);
+ assertThat(registry.capabilitySnapshot().descriptor("alpha")).isPresent();
+ assertThat(holder.negotiatedCapabilities()).contains("alpha");
+
+ release.countDown();
+ disable.get(WAIT_SECONDS, TimeUnit.SECONDS);
+ owner.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ assertThat(holder.negotiatedCapabilities()).isEmpty();
+ assertThat(registry.capabilitySnapshot().descriptor("alpha")).isEmpty();
+ }
+
+ @Test
+ void anOldCapturedViewStaysUsableAfterDelWhileTheNextCaptureUsesTheNewState() {
+ Framer framer = new Framer();
+ enable(framer, new Offer("tags"));
+ ClientSession recipient = connect("c1", "framer", "tags");
+ OutputGroup group =
+ new OutputGroup("g1", "type", List.of(), List.of(privmsg("a")), Long.MAX_VALUE);
+
+ CapabilityRuntime.CapturedView old = runtime.capture(recipient);
+ registry.disable("framer");
+ CapabilityRuntime.CapturedView current = runtime.capture(recipient);
+
+ // The captured work finishes against its own view, framing included.
+ FramingPlan late =
+ registry
+ .hookDispatcher()
+ .outputGroup(old.snapshot(), old.negotiated(), group, RequestContext.of(0, Map.of()));
+ assertThat(late.frames()).hasSize(3);
+ assertThat(current.negotiated()).containsExactly("tags");
+ assertThat(current.snapshot().hooksFor(HookKind.OUTPUT_GROUP, current.negotiated())).isEmpty();
+ }
+
+ @Test
+ void aGroupStillPreparingWhenItsPrerequisiteGoesIneffectiveIsAdmittedPlain() throws Exception {
+ Framer framer = new Framer();
+ enable(framer, new Offer("tags"));
+ ClientSession recipient = connect("c1", "framer", "tags");
+ framer.entered = new CountDownLatch(1);
+ framer.release = new CountDownLatch(1);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (SessionWriter writer =
+ new SessionWriter(recipient, out, new CapabilityTagRenderer(registry))) {
+ recipient.attachWriter(writer);
+ Thread sender =
+ Thread.ofVirtual()
+ .start(() -> writer.enqueueGroup("type", List.of(), List.of(privmsg("a"))));
+ assertThat(framer.entered.await(WAIT_SECONDS, TimeUnit.SECONDS)).isTrue();
+ registry.disable("tags");
+ framer.release.countDown();
+ sender.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
+ }
+ assertThat(lines(out)).containsExactly(":sender!ident@host PRIVMSG #chan a");
+ }
+
+ @Test
+ void anAdmittedGroupOnAStalledSocketKeepsItsCloseAcrossTheDisable() {
+ Framer framer = new Framer();
+ enable(framer, new Offer("tags"));
+ ClientSession recipient = connect("c1", "framer", "tags");
+ CountDownLatch unstall = new CountDownLatch(1);
+ ByteArrayOutputStream written = new ByteArrayOutputStream();
+ OutputStream stalled =
+ new OutputStream() {
+ @Override
+ public void write(int b) throws IOException {
+ try {
+ unstall.await(WAIT_SECONDS, TimeUnit.SECONDS);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
+ written.write(b); // ByteArrayOutputStream synchronizes its own writes and reads
+ }
+ };
+ try (SessionWriter writer =
+ new SessionWriter(recipient, stalled, new CapabilityTagRenderer(registry))) {
+ recipient.attachWriter(writer);
+ writer.enqueueGroup("type", List.of(), List.of(privmsg("a")));
+ registry.disable("tags");
+ registry.disable("framer");
+ unstall.countDown();
+ }
+ assertThat(lines(written))
+ .startsWith("BATCH +g type", ":sender!ident@host PRIVMSG #chan a", "BATCH -g");
+ }
+
+ @Test
+ void anOverflowWhileQueuingDelClosesOnlyThatRecipient() {
+ enable(new Offer("alpha"));
+ ClientSession tiny = connect("tiny");
+ tiny.recordCapVersion(302);
+ ClientSession roomy = connect("roomy");
+ roomy.recordCapVersion(302);
+ ByteArrayOutputStream tinyOut = new ByteArrayOutputStream();
+ ByteArrayOutputStream roomyOut = new ByteArrayOutputStream();
+ try (SessionWriter tinyWriter = new SessionWriter(tiny, tinyOut, TagRenderer.NONE, 8);
+ SessionWriter roomyWriter = new SessionWriter(roomy, roomyOut, TagRenderer.NONE)) {
+ tiny.attachWriter(tinyWriter);
+ roomy.attachWriter(roomyWriter);
+ registry.disable("alpha");
+ }
+ assertThat(lines(tinyOut))
+ .isNotEmpty()
+ .noneMatch(l -> l.contains("DEL"))
+ .anyMatch(l -> l.contains("SendQ"));
+ assertThat(lines(roomyOut)).containsExactly(":irc.example CAP * DEL alpha");
+ assertThat(registry.stateOf("alpha")).isEqualTo(Extension.State.DISABLED);
+ }
+
+ @Test
+ void aNotificationThatCannotBeBuiltForOneRecipientDoesNotStopTheOthersOrThePublication() {
+ enable(new Offer("alpha"));
+ ClientSession unrepresentable = connect("long", "alpha");
+ unrepresentable.recordCapVersion(302);
+ // No CAP line can carry a target this long, so its DEL cannot be built.
+ unrepresentable.setNickname("n".repeat(600));
+ ClientSession ordinary = connect("ordinary", "alpha");
+ ordinary.recordCapVersion(302);
+ ByteArrayOutputStream longOut = new ByteArrayOutputStream();
+ ByteArrayOutputStream ordinaryOut = new ByteArrayOutputStream();
+ try (SessionWriter longWriter = new SessionWriter(unrepresentable, longOut, TagRenderer.NONE);
+ SessionWriter writer = new SessionWriter(ordinary, ordinaryOut, TagRenderer.NONE)) {
+ unrepresentable.attachWriter(longWriter);
+ ordinary.attachWriter(writer);
+ registry.disable("alpha");
+ }
+ assertThat(lines(longOut)).isEmpty();
+ assertThat(lines(ordinaryOut)).containsExactly(":irc.example CAP * DEL alpha");
+ assertThat(unrepresentable.negotiatedCapabilities()).isEmpty();
+ assertThat(registry.stateOf("alpha")).isEqualTo(Extension.State.DISABLED);
+ }
+
+ @Test
+ void disablingAHardPrerequisiteWithdrawsItsDependentsInTheSamePublication() {
+ enable(new Offer("alpha"), new Dependent());
+ ClientSession holder = connect("c1", "alpha", "dependent");
+ holder.recordCapVersion(302);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (SessionWriter writer = new SessionWriter(holder, out, TagRenderer.NONE)) {
+ holder.attachWriter(writer);
+ registry.disable("alpha");
+ }
+ assertThat(lines(out)).containsExactly(":irc.example CAP * DEL :alpha dependent");
+ assertThat(holder.negotiatedCapabilities()).isEmpty();
+ assertThat(registry.stateOf("dependent")).isEqualTo(Extension.State.DISABLED);
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+
+ // Enabling the prerequisite again does not silently bring the dependent back.
+ registry.enable("alpha");
+ assertThat(registry.capabilitySnapshot().providerIds()).containsExactly("alpha");
+ }
+
+ @Test
+ void transitiveDependentsAreStoppedBeforeWhatTheyRequire() {
+ List stops = java.util.Collections.synchronizedList(new ArrayList<>());
+ // Names sort against dependency order, so only a dependency-aware order passes.
+ enable(
+ new Chained("root", null, stops),
+ new Chained("a", "root", stops),
+ new Chained("z", "a", stops));
+ registry.disable("root");
+ assertThat(stops).containsExactly("z", "a", "root");
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+ }
+
+ @Test
+ void anEnableWithAMissingHardPrerequisiteIsRejectedAndPublishesNothing() {
+ registry.register(new Offer("alpha"));
+ registry.register(new Dependent());
+ org.assertj.core.api.Assertions.assertThatThrownBy(() -> registry.enable("dependent"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("alpha");
+ // Like every other rejected enable, it is recorded FAILED until enabled again explicitly.
+ assertThat(registry.stateOf("dependent")).isEqualTo(Extension.State.FAILED);
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+ }
+
+ @Test
+ void startAndStopRunOutsideTheAdministrativeMutex() {
+ Probe probe = new Probe();
+ probe.runtime.set(runtime);
+ enable(probe);
+ registry.disable("probe");
+ assertThat(probe.startSawFreeMutex).isTrue();
+ assertThat(probe.stopSawFreeMutex).isTrue();
+ }
+
+ @Test
+ void aRegistrationIsEffectiveOnlyWhilePublishedAndItsOwnerAndPrerequisitesAreNegotiated() {
+ Framer framer = new Framer();
+ enable(framer, new Offer("tags"));
+ HookRegistration owner = registry.capabilitySnapshot().hooks(HookKind.OUTPUT_GROUP).getFirst();
+ RuntimeSnapshot published = registry.capabilitySnapshot();
+
+ assertThat(published.effective(owner, Set.of("framer", "tags"))).isTrue();
+ assertThat(published.effective(owner, Set.of("tags"))).isFalse();
+ assertThat(published.effective(owner, Set.of("framer"))).isFalse();
+ registry.disable("framer");
+ assertThat(registry.capabilitySnapshot().effective(owner, Set.of("framer", "tags"))).isFalse();
+ }
+
+ @Test
+ void aPublicationWhoseAlongsideStepThrowsSwapsPrunesAndNotifiesNothing() {
+ CapabilityRuntime direct = new CapabilityRuntime();
+ direct.attachLiveSessions(live);
+ CapabilityRegistrar registrar = new CapabilityRegistrar("alpha", "alpha");
+ direct.publish(registrar.build());
+ ClientSession session = connect("c1", "alpha");
+ session.recordCapVersion(302);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ RuntimeSnapshot before = direct.snapshot();
+ try (SessionWriter writer = new SessionWriter(session, out, TagRenderer.NONE)) {
+ session.attachWriter(writer);
+ Runnable failing =
+ () -> {
+ throw new IllegalStateException("not published");
+ };
+ assertThatThrownBy(
+ () -> direct.publish(current -> current.withoutAll(Set.of("alpha")), failing))
+ .hasMessage("not published");
+ }
+ assertThat(direct.snapshot()).isSameAs(before);
+ assertThat(session.negotiatedCapabilities()).containsExactly("alpha");
+ assertThat(lines(out)).isEmpty();
+ }
+}
diff --git a/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererBudgetTest.java b/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererBudgetTest.java
index bcef2f3..50e87f3 100644
--- a/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererBudgetTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererBudgetTest.java
@@ -40,8 +40,8 @@ private static CapabilityTagRenderer renderer(Map serverTags) {
private static ClientSession recipient() {
ClientSession recipient =
new ClientSession("recipient", "host", RateLimitBucket.withDefaults());
- recipient.negotiatedCapabilities().add(CapabilityName.MESSAGE_TAGS);
- recipient.negotiatedCapabilities().add("test-tags");
+ TestNegotiation.add(recipient, CapabilityName.MESSAGE_TAGS);
+ TestNegotiation.add(recipient, "test-tags");
return recipient;
}
diff --git a/jircd-core/src/test/java/net/jircd/core/session/CommandHandoverDispatchTest.java b/jircd-core/src/test/java/net/jircd/core/session/CommandHandoverDispatchTest.java
new file mode 100644
index 0000000..27a8b4c
--- /dev/null
+++ b/jircd-core/src/test/java/net/jircd/core/session/CommandHandoverDispatchTest.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.core.session;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+import net.jircd.core.config.ServerConfiguration;
+import net.jircd.core.extension.AbstractCapabilityExtension;
+import net.jircd.core.extension.CommandRegistrar;
+import net.jircd.core.extension.Extension;
+import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.ServerContext;
+import net.jircd.core.extension.capability.CapabilityRegistrar;
+import net.jircd.core.session.command.CommandHandler;
+import net.jircd.protocol.Command;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Moving provider commands from one provider to another in a reload, through the real dispatch: a
+ * command that exists before and after is never answered as unknown, and nothing that can fail
+ * while the move is prepared leaves the new owner half visible.
+ */
+class CommandHandoverDispatchTest {
+
+ private static final int FLIPS = 300;
+
+ /** An escape no reload catches. */
+ private static final class Escape extends Error {
+ Escape() {
+ super("escaped");
+ }
+ }
+
+ private final List handledBy = Collections.synchronizedList(new ArrayList<>());
+ private final ExtensionRegistry registry = new ExtensionRegistry();
+ private final ConnectionHandler connections =
+ new ConnectionHandler(
+ registry,
+ new DisconnectCleanup(
+ new NicknameRegistry(),
+ new ChannelRegistry(),
+ new WhowasHistory(() -> 100),
+ registry),
+ () -> "test.local",
+ new ConnectionHandler.Settings(
+ () -> new ServerConfiguration.RateLimit(100, 100),
+ () -> 60,
+ () -> 524288,
+ () -> new ServerConfiguration.ConnectionLimits(10, 10)),
+ socket -> {},
+ task -> Thread.ofVirtual().unstarted(task));
+
+ /** Core's command table, failing the n-th registration once armed. */
+ private final AtomicInteger registrations = new AtomicInteger();
+
+ private volatile Supplier failSecondRegistration;
+
+ /** Owns TAGMSG and WALLOPS; its own staging can fail at the second command. */
+ private final class Owner extends AbstractCapabilityExtension {
+ volatile Supplier failSecondCommand;
+
+ Owner(String id) {
+ super(id);
+ }
+
+ @Override
+ public void register(CapabilityRegistrar registrar) {
+ registrar.command(Command.TAGMSG, (session, message) -> handledBy.add(id()));
+ Supplier failure = failSecondCommand;
+ if (failure != null) sneakyThrow(failure.get());
+ registrar.command(Command.WALLOPS, (session, message) -> handledBy.add(id()));
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void sneakyThrow(Throwable failure) throws T {
+ throw (T) failure;
+ }
+
+ private Owner a;
+ private Owner b;
+
+ private void setUp() {
+ CommandRegistrar table = connections.commandRegistrar();
+ CommandRegistrar failing =
+ new CommandRegistrar() {
+ @Override
+ public void register(Command command, CommandHandler handler) {
+ Supplier failure = failSecondRegistration;
+ if (failure != null && registrations.incrementAndGet() == 2) {
+ sneakyThrow(failure.get());
+ }
+ table.register(command, handler);
+ }
+
+ @Override
+ public boolean isRegistered(Command command) {
+ return table.isRegistered(command);
+ }
+
+ @Override
+ public void unregister(Command command, CommandHandler installed) {
+ table.unregister(command, installed);
+ }
+ };
+ registry.attachContext(
+ new ServerContext(
+ null,
+ null,
+ registry,
+ () -> "test.local",
+ null,
+ null,
+ failing,
+ registry.capabilityServices()));
+ a = new Owner("a");
+ b = new Owner("b");
+ registry.register(a);
+ registry.register(b);
+ registry.enable("a");
+ }
+
+ private static ExtensionRegistry.DesiredState handOver(boolean toB) {
+ return new ExtensionRegistry.DesiredState(Map.of("a", !toB, "b", toB), Map.of(), 9, 50, 390, 6);
+ }
+
+ private ClientSession client() {
+ ClientSession client =
+ new ClientSession(
+ "c1", "host.example", new RateLimitBucket(1_000_000, 1_000_000, Clock.systemUTC()));
+ client.setNickname("client");
+ assertThat(client.lifecycle().completeRegistration()).isTrue();
+ return client;
+ }
+
+ /** Dispatches each line through the real path and returns what the client was sent. */
+ private String dispatch(ClientSession client, String... lines) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (SessionWriter writer = new SessionWriter(client, out, TagRenderer.NONE)) {
+ client.attachWriter(writer);
+ for (String line : lines) {
+ connections.processLine(client, line.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ return out.toString(StandardCharsets.UTF_8);
+ }
+
+ @Test
+ void dispatchDuringRepeatedHandOversNeverAnswersUnknownCommand() throws Exception {
+ setUp();
+ ClientSession client = client();
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ AtomicInteger swaps = new AtomicInteger();
+ int dispatched = 0;
+ try (SessionWriter writer = new SessionWriter(client, out, TagRenderer.NONE)) {
+ client.attachWriter(writer);
+ // A worker failure is rethrown by get() below; a hung worker cannot hang the loop.
+ CompletableFuture flips =
+ CompletableFuture.runAsync(
+ () -> {
+ for (int i = 0; i < FLIPS; i++) {
+ registry.applyDesiredState(handOver(i % 2 == 0), () -> {});
+ swaps.incrementAndGet();
+ }
+ });
+ byte[] line = "TAGMSG #c".getBytes(StandardCharsets.UTF_8);
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
+ while (!flips.isDone() && System.nanoTime() < deadline) {
+ connections.processLine(client, line);
+ dispatched++;
+ }
+ flips.get(30, TimeUnit.SECONDS);
+ }
+ assertThat(swaps).hasValue(FLIPS);
+ assertThat(dispatched).isPositive();
+ assertThat(out.toString(StandardCharsets.UTF_8)).doesNotContain(" 421 ");
+ assertThat(handledBy).hasSize(dispatched);
+ }
+
+ @Test
+ void aRuntimeExceptionFromCoresTableOnTheSecondMovedCommandCannotHalfEnableTheNewOwner() {
+ setUp();
+ failSecondRegistration = () -> new IllegalStateException("table refused");
+
+ assertThat(registry.applyDesiredState(handOver(true), () -> {})).isEmpty();
+
+ assertThat(registry.stateOf("b")).isEqualTo(Extension.State.ENABLED);
+ assertThat(dispatch(client(), "TAGMSG #c", "WALLOPS :hi")).doesNotContain(" 421 ");
+ assertThat(handledBy).containsExactly("b", "b");
+ }
+
+ @Test
+ void anErrorFromCoresTableOnTheSecondMovedCommandCannotHalfEnableTheNewOwner() {
+ setUp();
+ failSecondRegistration = Escape::new;
+
+ assertThat(registry.applyDesiredState(handOver(true), () -> {})).isEmpty();
+
+ assertThat(registry.stateOf("b")).isEqualTo(Extension.State.ENABLED);
+ assertThat(dispatch(client(), "TAGMSG #c", "WALLOPS :hi")).doesNotContain(" 421 ");
+ assertThat(handledBy).containsExactly("b", "b");
+ }
+
+ @Test
+ void aRuntimeExceptionStagingTheSecondCommandFailsTheNewOwnerWithNothingOfItVisible() {
+ setUp();
+ b.failSecondCommand = () -> new IllegalStateException("cannot stage WALLOPS");
+
+ Map failures = registry.applyDesiredState(handOver(true), () -> {});
+
+ assertThat(failures).containsOnlyKeys("b");
+ assertThat(registry.stateOf("b")).isEqualTo(Extension.State.FAILED);
+ assertThat(registry.capabilitySnapshot().providerIds()).isEmpty();
+ // a is withdrawn for its own reason (desired disabled); b never became visible.
+ assertThat(dispatch(client(), "TAGMSG #c", "WALLOPS :hi")).contains(" 421 ");
+ assertThat(handledBy).isEmpty();
+ }
+
+ @Test
+ void anErrorStagingTheSecondCommandChangesNothingAndTheOldOwnerKeepsItsCommands() {
+ setUp();
+ b.failSecondCommand = Escape::new;
+
+ ExtensionRegistry.DesiredState desired = handOver(true);
+ assertThatThrownBy(() -> registry.applyDesiredState(desired, () -> {}))
+ .isInstanceOf(Escape.class);
+
+ assertThat(registry.stateOf("a")).isEqualTo(Extension.State.ENABLED);
+ assertThat(registry.capabilitySnapshot().providerIds()).containsExactly("a");
+ assertThat(dispatch(client(), "TAGMSG #c", "WALLOPS :hi")).doesNotContain(" 421 ");
+ assertThat(handledBy).containsExactly("a", "a");
+ }
+}
diff --git a/jircd-core/src/test/java/net/jircd/core/session/ConnectionHandlerCommandTableTest.java b/jircd-core/src/test/java/net/jircd/core/session/ConnectionHandlerCommandTableTest.java
index 2e3bf79..d6a3f50 100644
--- a/jircd-core/src/test/java/net/jircd/core/session/ConnectionHandlerCommandTableTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/session/ConnectionHandlerCommandTableTest.java
@@ -16,15 +16,25 @@
package net.jircd.core.session;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
import net.jircd.core.config.ServerConfiguration;
+import net.jircd.core.extension.AbstractCapabilityExtension;
import net.jircd.core.extension.CommandRegistrar;
import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.ServerContext;
+import net.jircd.core.extension.capability.CapabilityRegistrar;
import net.jircd.core.session.command.CommandHandler;
import net.jircd.protocol.Command;
import org.junit.jupiter.api.Test;
-/** The registrar over the real command table removes only the handler instance it installed. */
+/**
+ * The registrar over the real command table removes only the handler instance it installed, and its
+ * ownership checks cover both command sources: core's table and the published providers'.
+ */
class ConnectionHandlerCommandTableTest {
private static ConnectionHandler handler() {
@@ -91,4 +101,72 @@ void unregisterComparesTheInstalledHandlerByIdentityNotEquality() {
registrar.unregister(Command.OPER, replacement);
assertThat(registrar.isRegistered(Command.OPER)).isFalse();
}
+
+ /** Owns TAGMSG as a capability provider. */
+ private static final class TagmsgOwner extends AbstractCapabilityExtension {
+ private final List handled;
+
+ TagmsgOwner(List handled) {
+ super("owner");
+ this.handled = handled;
+ }
+
+ @Override
+ public void register(CapabilityRegistrar registrar) {
+ registrar.command(Command.TAGMSG, (session, message) -> handled.add("provider"));
+ }
+ }
+
+ @Test
+ void aCommandAPublishedProviderOwnsIsRegisteredAndCannotBeClaimedByALaterServerExtension() {
+ ExtensionRegistry extensions = new ExtensionRegistry();
+ ConnectionHandler table =
+ new ConnectionHandler(
+ extensions,
+ new DisconnectCleanup(
+ new NicknameRegistry(),
+ new ChannelRegistry(),
+ new WhowasHistory(() -> 100),
+ extensions),
+ () -> "test.local",
+ new ConnectionHandler.Settings(
+ () -> new ServerConfiguration.RateLimit(100, 100),
+ () -> 60,
+ () -> 524288,
+ () -> new ServerConfiguration.ConnectionLimits(10, 10)),
+ socket -> {},
+ task -> Thread.ofVirtual().unstarted(task));
+ CommandRegistrar registrar = table.commandRegistrar();
+ extensions.attachContext(
+ new ServerContext(
+ null,
+ null,
+ extensions,
+ () -> "test.local",
+ null,
+ null,
+ registrar,
+ extensions.capabilityServices()));
+ List handled = new ArrayList<>();
+ extensions.register(new TagmsgOwner(handled));
+ extensions.enable("owner");
+
+ assertThat(registrar.isRegistered(Command.TAGMSG)).isTrue();
+ CommandHandler serverExtension = (session, message) -> handled.add("server extension");
+ assertThatThrownBy(() -> registrar.register(Command.TAGMSG, serverExtension))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("TAGMSG");
+
+ ClientSession client = new ClientSession("c1", "host", RateLimitBucket.withDefaults());
+ client.setNickname("client");
+ assertThat(client.lifecycle().completeRegistration()).isTrue();
+ // Lowercase on the wire: dispatch folds it to the same command the checks use.
+ table.processLine(client, "tagmsg #c".getBytes(StandardCharsets.UTF_8));
+ assertThat(handled).containsExactly("provider");
+
+ extensions.disable("owner");
+ assertThat(registrar.isRegistered(Command.TAGMSG)).isFalse();
+ registrar.register(Command.TAGMSG, serverExtension);
+ assertThat(registrar.isRegistered(Command.TAGMSG)).isTrue();
+ }
}
diff --git a/jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java b/jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java
index 818412d..4bbde8a 100644
--- a/jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java
@@ -197,4 +197,48 @@ public void register(CapabilityRegistrar registrar) {
assertThat(seen.get().displayHost()).isEmpty();
assertThat(Set.of(seen.get().nickname())).isNotEmpty();
}
+
+ /** Runs {@code reply} on another thread while the mutex is held; its state once it settles. */
+ private Thread.State stateWhileTheMutexIsHeld(Runnable reply) throws InterruptedException {
+ java.util.concurrent.atomic.AtomicReference worker =
+ new java.util.concurrent.atomic.AtomicReference<>();
+ java.util.concurrent.atomic.AtomicReference observed =
+ new java.util.concurrent.atomic.AtomicReference<>();
+ extensions
+ .capabilityControl()
+ .runUnderControl(
+ () -> {
+ Thread thread = Thread.ofPlatform().start(reply);
+ worker.set(thread);
+ long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
+ while (System.nanoTime() < deadline
+ && thread.getState() != Thread.State.BLOCKED
+ && thread.getState() != Thread.State.TERMINATED) {
+ Thread.onSpinWait();
+ }
+ observed.set(thread.getState());
+ });
+ worker.get().join();
+ return observed.get();
+ }
+
+ @Test
+ void namesAndWhoCaptureTheSnapshotAndTheRequestersNegotiationTogether() throws Exception {
+ populated();
+ extensions.register(new AllStatuses());
+ extensions.enable("all-statuses");
+ ClientSession requester = requester("all-statuses");
+ MemberFacts both =
+ channel.memberFacts().stream()
+ .filter(fact -> "both".equals(fact.member().nickname()))
+ .findFirst()
+ .orElseThrow();
+
+ // A coherent capture waits for the mutex a publication holds; two separate reads would not.
+ assertThat(stateWhileTheMutexIsHeld(() -> replies.namesTokens(requester, List.of(both))))
+ .isEqualTo(Thread.State.BLOCKED);
+ assertThat(stateWhileTheMutexIsHeld(() -> replies.whoStatus(requester, both)))
+ .isEqualTo(Thread.State.BLOCKED);
+ assertThat(replies.whoStatus(requester, both)).isEqualTo("@+");
+ }
}
diff --git a/jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java b/jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java
index b584c52..b24ac53 100644
--- a/jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java
@@ -555,4 +555,43 @@ void forwardedClientTagsGoOnlyToRecipientsOfTheProviderThatSelectedThem() {
assertThat(received(owner)).contains("+private=secret");
assertThat(received(other)).doesNotContain("private").doesNotContain("secret");
}
+
+ @Test
+ void theSnapshotAndTheSendersNegotiationAreCapturedTogetherUnderTheMutex() throws Exception {
+ Provider provider = new Provider();
+ enable(provider);
+ ClientSession alice = user("alice", "copy");
+ user("bob");
+ MessageDelivery.AuthorizedTarget authorized = delivery.authorize(alice, "bob", true);
+ AtomicReference worker = new AtomicReference<>();
+ AtomicReference observed = new AtomicReference<>();
+
+ // While the mutex is held no publication can land, so a coherent capture has to wait for it;
+ // two separate reads would not, and could pair the old snapshot with a newly pruned set.
+ extensions
+ .capabilityControl()
+ .runUnderControl(
+ () -> {
+ Thread thread =
+ Thread.ofPlatform()
+ .start(
+ () ->
+ // Gated out: no writer is touched, only the capture can wait.
+ delivery.deliver(
+ alice, "PRIVMSG", "bob", "hi", Map.of(), authorized, r -> false));
+ worker.set(thread);
+ long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
+ while (System.nanoTime() < deadline
+ && thread.getState() != Thread.State.BLOCKED
+ && thread.getState() != Thread.State.TERMINATED) {
+ Thread.onSpinWait();
+ }
+ observed.set(thread.getState());
+ });
+ worker.get().join();
+
+ assertThat(observed.get()).isEqualTo(Thread.State.BLOCKED);
+ // Alice's negotiation, captured with the snapshot, still selects the provider's hook.
+ assertThat(provider.recipientCalls).hasValue(1);
+ }
}
diff --git a/jircd-core/src/test/java/net/jircd/core/session/RecipientsTest.java b/jircd-core/src/test/java/net/jircd/core/session/RecipientsTest.java
index 2551791..7201e65 100644
--- a/jircd-core/src/test/java/net/jircd/core/session/RecipientsTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/session/RecipientsTest.java
@@ -61,8 +61,8 @@ void capabilityFilterExcludesUnsubscribedNeighborsAndSelf() {
ClientSession self = session("self");
ClientSession subscribed = session("subscribed");
ClientSession other = session("other");
- subscribed.negotiatedCapabilities().add(CapabilityName.AWAY_NOTIFY);
- self.negotiatedCapabilities().add(CapabilityName.AWAY_NOTIFY);
+ TestNegotiation.add(subscribed, CapabilityName.AWAY_NOTIFY);
+ TestNegotiation.add(self, CapabilityName.AWAY_NOTIFY);
ChannelRegistry registry = new ChannelRegistry();
for (ClientSession member : List.of(self, subscribed, other)) {
registry.join("#a", member, ignored -> true);
diff --git a/jircd-core/src/test/java/net/jircd/core/session/SelfDirectMessageTest.java b/jircd-core/src/test/java/net/jircd/core/session/SelfDirectMessageTest.java
index 9f5bdf0..753708d 100644
--- a/jircd-core/src/test/java/net/jircd/core/session/SelfDirectMessageTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/session/SelfDirectMessageTest.java
@@ -57,7 +57,7 @@ void selfTargetedMessageIsDeliveredExactlyOnce(String command, boolean echoNegot
alice.setNickname("alice");
alice.setIdent("ident");
if (echoNegotiated) {
- alice.negotiatedCapabilities().add("echo-message");
+ TestNegotiation.add(alice, "echo-message");
}
NicknameRegistry nicknames = new NicknameRegistry();
assertThat(nicknames.claim("alice", alice)).isTrue();
diff --git a/jircd-core/src/test/java/net/jircd/core/session/SessionWriterByteLimitTest.java b/jircd-core/src/test/java/net/jircd/core/session/SessionWriterByteLimitTest.java
index 5d55f86..9ef744c 100644
--- a/jircd-core/src/test/java/net/jircd/core/session/SessionWriterByteLimitTest.java
+++ b/jircd-core/src/test/java/net/jircd/core/session/SessionWriterByteLimitTest.java
@@ -82,7 +82,7 @@ void contributionMetadataDoesNotChangeMessageValueEquality() {
registry.register(capability("large", Map.of("s", "x".repeat(4092))));
registry.enable("large");
ClientSession recipient = session();
- recipient.negotiatedCapabilities().add("large");
+ TestNegotiation.add(recipient, "large");
new CapabilityTagRenderer(registry).prepare(recipient, first);
assertThat(first)
.isEqualTo(equal)
@@ -166,8 +166,8 @@ void chargesMatchRecipientWireBytesAndFilterClientTags(int capabilities) throws
registry.register(second);
registry.enable(second.id());
ClientSession recipient = session();
- if ((capabilities & 1) != 0) recipient.negotiatedCapabilities().add("message-tags");
- if ((capabilities & 2) != 0) recipient.negotiatedCapabilities().add("server-time");
+ if ((capabilities & 1) != 0) TestNegotiation.add(recipient, "message-tags");
+ if ((capabilities & 2) != 0) TestNegotiation.add(recipient, "server-time");
OutboundMessage message =
ForwardedTags.selectedBy(
"message-tags",
@@ -187,7 +187,7 @@ void legalLargeServerContributionIsChargedInFull() throws Exception {
registry.register(large);
registry.enable(large.id());
ClientSession recipient = session();
- recipient.negotiatedCapabilities().add("large");
+ TestNegotiation.add(recipient, "large");
assertCharge(
recipient,
new CapabilityTagRenderer(registry),
@@ -205,7 +205,7 @@ void manyLargeContributionsStillAdmitTheLegalLineAtTheFloor() throws Exception {
var contributor = capability("overlap" + i, Map.of("s" + i, "x".repeat(4090)));
registry.register(contributor);
registry.enable(contributor.id());
- recipient.negotiatedCapabilities().add(contributor.id());
+ TestNegotiation.add(recipient, contributor.id());
}
assertCharge(
recipient,
@@ -224,7 +224,7 @@ void sameNamedClientAndServerTagsAndTruncatedTextHaveConservativeCharges() throw
registry.register(second);
registry.enable(second.id());
ClientSession recipient = session();
- recipient.negotiatedCapabilities().addAll(List.of("message-tags", "server-time"));
+ TestNegotiation.addAll(recipient, List.of("message-tags", "server-time"));
assertCharge(
recipient,
new CapabilityTagRenderer(registry),
@@ -242,7 +242,7 @@ void maximumLegalLineFitsTheFloor() throws Exception {
registry.register(large);
registry.enable(large.id());
ClientSession recipient = session();
- recipient.negotiatedCapabilities().add("message-tags");
+ TestNegotiation.add(recipient, "message-tags");
OutboundMessage message =
ForwardedTags.selectedBy(
"message-tags",
@@ -273,7 +273,7 @@ public void register(CapabilityRegistrar registrar) {
registry.enable(capability.id());
var renderer = new CapabilityTagRenderer(registry);
ClientSession recipient = session();
- recipient.negotiatedCapabilities().add("message-tags");
+ TestNegotiation.add(recipient, "message-tags");
OutboundMessage message = OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hello");
try (var threads = Executors.newVirtualThreadPerTaskExecutor()) {
var tasks = new java.util.ArrayList>();
@@ -320,12 +320,12 @@ void queuedSelectionCannotAcquireUnchargedTagsAfterNegotiationOrReload() throws
writer.enqueue(OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "before"));
long before = writer.queuedBytes();
registry.enable(capability.id());
- recipient.negotiatedCapabilities().add("message-tags");
+ TestNegotiation.add(recipient, "message-tags");
writer.enqueue(OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "after"));
long after = writer.queuedBytes() - before;
assertThat(after).isGreaterThan(4096);
registry.disable(capability.id());
- recipient.negotiatedCapabilities().clear();
+ TestNegotiation.clear(recipient);
} finally {
out.releaseAll();
}
diff --git a/jircd-core/src/test/java/net/jircd/core/session/TestNegotiation.java b/jircd-core/src/test/java/net/jircd/core/session/TestNegotiation.java
new file mode 100644
index 0000000..a8a8d29
--- /dev/null
+++ b/jircd-core/src/test/java/net/jircd/core/session/TestNegotiation.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.core.session;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Test setup for a session's negotiated capabilities. The negotiated set is an immutable snapshot
+ * replaced as a whole, so fixtures add or clear names through this rather than mutating it.
+ */
+public final class TestNegotiation {
+
+ private TestNegotiation() {}
+
+ /** Adds {@code names} to what {@code session} has negotiated. */
+ public static void add(ClientSession session, String... names) {
+ addAll(session, List.of(names));
+ }
+
+ /** Adds {@code names} to what {@code session} has negotiated. */
+ public static void addAll(ClientSession session, Collection names) {
+ Set grown = new HashSet<>(session.negotiatedCapabilities());
+ grown.addAll(names);
+ session.replaceNegotiatedCapabilities(grown);
+ }
+
+ /** Clears everything {@code session} has negotiated. */
+ public static void clear(ClientSession session) {
+ session.replaceNegotiatedCapabilities(List.of());
+ }
+}
diff --git a/jircd-core/src/test/java/net/jircd/core/session/command/NickCapabilityNotificationOrderTest.java b/jircd-core/src/test/java/net/jircd/core/session/command/NickCapabilityNotificationOrderTest.java
new file mode 100644
index 0000000..ad89004
--- /dev/null
+++ b/jircd-core/src/test/java/net/jircd/core/session/command/NickCapabilityNotificationOrderTest.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.core.session.command;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import net.jircd.core.extension.AbstractCapabilityExtension;
+import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.ServerContext;
+import net.jircd.core.session.ClientSession;
+import net.jircd.core.session.SessionWriter;
+import net.jircd.core.session.TagRenderer;
+import net.jircd.core.session.UserStatistics;
+import net.jircd.protocol.MessageParser;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A nickname change and its own {@code NICK} echo are one step under the administrative mutex, so a
+ * concurrent capability publication addresses its {@code CAP DEL}/{@code NEW} to the nickname the
+ * client knows at that point in its stream, never to one it has already been told it left.
+ */
+class NickCapabilityNotificationOrderTest {
+
+ private static final class Offer extends AbstractCapabilityExtension {
+ Offer() {
+ super("alpha");
+ }
+ }
+
+ private final HandlerHarness harness = new HandlerHarness();
+ private final ExtensionRegistry registry = new ExtensionRegistry();
+ private final Set live = ConcurrentHashMap.newKeySet();
+
+ private NickCommandHandler nick() {
+ registry.attachContext(
+ new ServerContext(
+ null,
+ null,
+ registry,
+ () -> "irc.example",
+ null,
+ null,
+ null,
+ registry.capabilityServices()));
+ registry.attachLiveSessions(live);
+ return new NickCommandHandler(
+ harness.nicknames,
+ () -> "irc.example",
+ () -> 30,
+ new RegistrationCompletion(
+ () -> "irc.example", "test", Instant.now(), registry, new UserStatistics()),
+ registry);
+ }
+
+ @Test
+ void aNickChangeWaitsForAPublicationHoldingTheMutex() throws Exception {
+ NickCommandHandler handler = nick();
+ ClientSession alice = harness.register("alice");
+ live.add(alice);
+ CountDownLatch held = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ Thread owner =
+ Thread.ofVirtual()
+ .start(
+ () ->
+ registry
+ .capabilityControl()
+ .runUnderControl(
+ () -> {
+ held.countDown();
+ try {
+ release.await(5, TimeUnit.SECONDS);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
+ }));
+ assertThat(held.await(5, TimeUnit.SECONDS)).isTrue();
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (SessionWriter writer = new SessionWriter(alice, out, TagRenderer.NONE)) {
+ alice.attachWriter(writer);
+ Thread change =
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ try {
+ handler.handle(alice, MessageParser.parse("NICK bob"));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ // Bounded: while a publication holds the mutex, the change has not happened yet.
+ assertThat(change.join(java.time.Duration.ofMillis(200))).isFalse();
+ assertThat(alice.nickname()).isEqualTo("alice");
+ release.countDown();
+ assertThat(change.join(java.time.Duration.ofSeconds(5))).isTrue();
+ } finally {
+ release.countDown();
+ owner.join(5000);
+ }
+ assertThat(alice.nickname()).isEqualTo("bob");
+ assertThat(out.toString(StandardCharsets.UTF_8)).contains(" NICK bob");
+ }
+
+ @Test
+ void racingPublicationsAlwaysAddressTheNicknameTheClientCurrentlyKnows() throws Exception {
+ NickCommandHandler handler = nick();
+ registry.register(new Offer());
+ registry.enable("alpha");
+ ClientSession client = harness.register("n0");
+ client.recordCapVersion(302);
+ live.add(client);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (SessionWriter writer = new SessionWriter(client, out, TagRenderer.NONE)) {
+ client.attachWriter(writer);
+ Thread toggler =
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ for (int i = 0; i < 200; i++) {
+ registry.disable("alpha");
+ registry.enable("alpha");
+ }
+ });
+ for (int i = 1; i <= 200; i++) {
+ handler.handle(client, MessageParser.parse("NICK n" + i));
+ }
+ toggler.join(10_000);
+ }
+ String known = "n0";
+ List lines = List.of(out.toString(StandardCharsets.UTF_8).split("\r\n"));
+ assertThat(lines).anyMatch(l -> l.contains(" CAP "));
+ for (String line : lines) {
+ if (line.contains(" NICK ")) {
+ known = line.substring(line.lastIndexOf(' ') + 1);
+ } else if (line.contains(" CAP ")) {
+ assertThat(line).as("after NICK %s", known).contains(" CAP " + known + " ");
+ }
+ }
+ }
+}
diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupLifecycleTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupLifecycleTest.java
new file mode 100644
index 0000000..1f65a82
--- /dev/null
+++ b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupLifecycleTest.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.integration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.session.ClientSession;
+import net.jircd.protocol.Command;
+import net.jircd.protocol.Message;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Batch groups across runtime changes, over real sockets with the discovered batch provider. A
+ * group admitted before a disable keeps its close; a group admitted after message-tags stops being
+ * effective for its recipient is plain while batch stays negotiated; a recipient that cannot hold
+ * the group is closed alone. DEL is not an output barrier, so no test orders it against a group.
+ */
+class CapabilityGroupLifecycleTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ private static final String YAML =
+ """
+ capabilities:
+ batch: enabled
+ message-tags: enabled
+ sendqLimitBytes: 16384
+ """;
+
+ private static Message privmsg(String body) {
+ return new Message(
+ Map.of(), "bot!bot@host", Command.PRIVMSG, "PRIVMSG", List.of("#probe", body));
+ }
+
+ private static RawIrcClient client(TestServer server, String nick, String caps) throws Exception {
+ RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort());
+ client.send("CAP LS 302");
+ if (!caps.isEmpty()) client.send("CAP REQ :" + caps);
+ client.send("CAP END");
+ client.registerAndAwaitWelcome(nick, nick);
+ return client;
+ }
+
+ private static ClientSession live(TestServer server, String nick) {
+ return server.application.nicknameRegistry().lookup(nick).orElseThrow();
+ }
+
+ private static List probe(RawIrcClient client) throws Exception {
+ return client.readThroughPong(TIMEOUT).stream()
+ .filter(l -> l.contains("PRIVMSG #probe") || l.contains("BATCH"))
+ .toList();
+ }
+
+ private static void assertFramed(List lines, String... bodies) {
+ assertThat(lines).hasSize(bodies.length + 2);
+ assertThat(lines.getFirst()).startsWith("BATCH +");
+ String ref = lines.getFirst().substring("BATCH +".length(), lines.getFirst().indexOf(' ', 7));
+ for (int i = 0; i < bodies.length; i++) {
+ assertThat(lines.get(i + 1))
+ .startsWith("@batch=" + ref)
+ .endsWith("PRIVMSG #probe " + bodies[i]);
+ }
+ assertThat(lines.getLast()).isEqualTo("BATCH -" + ref);
+ }
+
+ @Test
+ void aGroupAdmittedBeforeTheDisableIsDeliveredWholeWithItsClose() throws Exception {
+ try (TestServer server = TestServer.start(YAML);
+ RawIrcClient alice = client(server, "alice", "batch message-tags")) {
+ ExtensionRegistry registry = server.application.extensionRegistry();
+ live(server, "alice")
+ .writer()
+ .enqueueGroup("probe", List.of(), List.of(privmsg("one"), privmsg("two")));
+ registry.disable("batch");
+
+ assertFramed(probe(alice), "one", "two");
+ }
+ }
+
+ @Test
+ void afterMessageTagsIsDisabledANewGroupIsPlainWhileBatchStaysNegotiated() throws Exception {
+ try (TestServer server = TestServer.start(YAML);
+ RawIrcClient alice = client(server, "alice", "batch message-tags")) {
+ ExtensionRegistry registry = server.application.extensionRegistry();
+ ClientSession session = live(server, "alice");
+
+ registry.disable("message-tags");
+ session.writer().enqueueGroup("probe", List.of(), List.of(privmsg("plain")));
+ assertThat(probe(alice)).containsExactly(":bot!bot@host PRIVMSG #probe plain");
+ alice.send("CAP LIST");
+ assertThat(alice.readUntil("CAP alice LIST", TIMEOUT)).endsWith("LIST batch");
+
+ // Enabling message-tags restores nothing: framing returns only after a new request.
+ registry.enable("message-tags");
+ session.writer().enqueueGroup("probe", List.of(), List.of(privmsg("still")));
+ assertThat(probe(alice)).containsExactly(":bot!bot@host PRIVMSG #probe still");
+ alice.send("CAP REQ :message-tags");
+ alice.readUntil("CAP alice ACK", TIMEOUT);
+ session.writer().enqueueGroup("probe", List.of(), List.of(privmsg("framed")));
+ assertFramed(probe(alice), "framed");
+ }
+ }
+
+ @Test
+ void aRecipientThatCannotHoldTheGroupIsClosedAloneAndOthersStillGetIt() throws Exception {
+ try (TestServer server = TestServer.start(YAML);
+ RawIrcClient alice = client(server, "alice", "batch message-tags");
+ RawIrcClient bob = client(server, "bob", "batch message-tags")) {
+ List large = new ArrayList<>();
+ for (int i = 0; i < 60; i++) large.add(privmsg("x".repeat(400)));
+ live(server, "bob").writer().enqueueGroup("probe", List.of(), large);
+ assertThat(bob.readUntil("ERROR", TIMEOUT)).contains("SendQ exceeded");
+
+ live(server, "alice").writer().enqueueGroup("probe", List.of(), List.of(privmsg("one")));
+ assertFramed(probe(alice), "one");
+ }
+ }
+}
diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityLifecycleTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityLifecycleTest.java
new file mode 100644
index 0000000..5f91415
--- /dev/null
+++ b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityLifecycleTest.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.integration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.file.Files;
+import java.time.Duration;
+import java.util.List;
+import java.util.stream.Stream;
+import net.jircd.core.extension.Extension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * #97 over real sockets: disabling a capability through {@code EXTENSION} or {@code REHASH} prunes
+ * it from every connected session in the same step as the offer, sends {@code DEL} to exactly the
+ * notification-eligible clients, and enabling it again sends {@code NEW} without restoring anyone's
+ * negotiation. Each run covers the three client classes: explicit legacy cap-notify, implicit
+ * {@code CAP LS 302}, and a legacy client without notifications, plus an unnamed observer.
+ */
+class CapabilityLifecycleTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+ private static final String SERVER = ":test.jircd.local";
+
+ private static final List PROVIDERS =
+ List.of(
+ "message-tags", "server-time", "echo-message", "away-notify", "batch", "multi-prefix");
+
+ enum Path {
+ EXTENSION,
+ REHASH
+ }
+
+ static Stream matrix() {
+ return Stream.of(Path.values())
+ .flatMap(
+ path -> Stream.of("multi-prefix", "message-tags").map(cap -> Arguments.of(path, cap)));
+ }
+
+ private static String capabilitiesYaml(String disabled) {
+ StringBuilder yaml = new StringBuilder("capabilities:\n");
+ for (String provider : PROVIDERS) {
+ yaml.append(" ")
+ .append(provider)
+ .append(provider.equals(disabled) ? ": disabled\n" : ": enabled\n");
+ }
+ return yaml.toString();
+ }
+
+ private static TestServer start() throws Exception {
+ return TestServer.start(TestServer.adminEnabledYaml() + capabilitiesYaml(""));
+ }
+
+ private static RawIrcClient admin(TestServer server) throws Exception {
+ RawIrcClient root = RawIrcClient.connectPlaintext(server.plaintextPort());
+ root.registerAndAwaitWelcome("root", "root");
+ root.send("OPER " + TestServer.ADMIN_USERNAME + " :" + TestServer.ADMIN_PASSWORD);
+ root.readUntil("381", TIMEOUT);
+ return root;
+ }
+
+ /** Changes {@code cap} through {@code path} and waits for the administrator's confirmation. */
+ private static void toggle(
+ TestServer server, RawIrcClient root, Path path, String cap, boolean on) throws Exception {
+ if (path == Path.EXTENSION) {
+ root.send("EXTENSION " + (on ? "ENABLE " : "DISABLE ") + cap);
+ assertThat(root.readUntil("NOTICE", TIMEOUT))
+ .contains(cap + " is now " + (on ? "enabled" : "disabled"));
+ } else {
+ Files.writeString(
+ server.configPath,
+ TestServer.baseYaml() + TestServer.adminEnabledYaml() + capabilitiesYaml(on ? "" : cap));
+ root.send("REHASH");
+ root.readUntil("382", TIMEOUT);
+ }
+ }
+
+ private static RawIrcClient client(TestServer server, String nick, String... preamble)
+ throws Exception {
+ RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort());
+ for (String line : preamble) client.send(line);
+ client.send("CAP END");
+ client.registerAndAwaitWelcome(nick, nick);
+ return client;
+ }
+
+ private static List capLines(RawIrcClient client) throws Exception {
+ return client.readThroughPong(TIMEOUT).stream().filter(l -> l.contains(" CAP ")).toList();
+ }
+
+ private static String list(RawIrcClient client, String nick) throws Exception {
+ client.send("CAP LIST");
+ return client.readUntil("CAP " + nick + " LIST", TIMEOUT);
+ }
+
+ /** Whether {@code observer} currently sees {@code cap}'s behavior on newly prepared output. */
+ private static boolean behaves(RawIrcClient root, RawIrcClient observer, String cap)
+ throws Exception {
+ if (cap.equals("multi-prefix")) {
+ observer.send("NAMES #room");
+ return observer.readUntil("353", TIMEOUT).contains("@+implicit");
+ }
+ root.send("PRIVMSG implicit :probe line");
+ return observer.readUntil("probe line", TIMEOUT).contains("msgid=");
+ }
+
+ @ParameterizedTest(name = "{0} {1}")
+ @MethodSource("matrix")
+ void disableAndReEnableReachEveryClientClassConsistently(Path path, String cap) throws Exception {
+ try (TestServer server = start();
+ RawIrcClient root = admin(server);
+ RawIrcClient explicit = client(server, "explicit", "CAP LS", "CAP REQ :cap-notify " + cap);
+ RawIrcClient implicit = client(server, "implicit", "CAP LS 302", "CAP REQ :" + cap);
+ RawIrcClient legacy = client(server, "legacy", "CAP REQ :" + cap);
+ RawIrcClient unnamed = RawIrcClient.connectPlaintext(server.plaintextPort())) {
+ // A legacy client discovers core cap-notify in LS before requesting it.
+ explicit.send("CAP LS");
+ assertThat(explicit.readUntil("CAP explicit LS", TIMEOUT)).contains("cap-notify");
+ unnamed.send("CAP LS 302");
+ unnamed.readUntil("CAP * LS", TIMEOUT);
+ implicit.send("JOIN #room");
+ implicit.readUntil("366", TIMEOUT);
+ implicit.send("MODE #room +v implicit");
+ implicit.readUntil("MODE", TIMEOUT);
+ assertThat(behaves(root, implicit, cap)).isTrue();
+
+ toggle(server, root, path, cap, false);
+
+ assertThat(capLines(explicit)).containsExactly(SERVER + " CAP explicit DEL " + cap);
+ assertThat(capLines(implicit)).containsExactly(SERVER + " CAP implicit DEL " + cap);
+ assertThat(capLines(unnamed)).containsExactly(SERVER + " CAP * DEL " + cap);
+ assertThat(capLines(legacy)).isEmpty();
+
+ implicit.send("CAP LS 302");
+ assertThat(implicit.readUntil("CAP implicit LS", TIMEOUT))
+ .contains("cap-notify")
+ .doesNotContain(cap);
+ assertThat(list(explicit, "explicit")).endsWith("LIST cap-notify");
+ assertThat(list(implicit, "implicit")).endsWith("LIST :");
+ assertThat(list(legacy, "legacy")).endsWith("LIST :");
+ assertThat(behaves(root, implicit, cap)).isFalse();
+
+ // A known name already pruned by the disable: an acknowledged no-op.
+ legacy.send("CAP REQ :-" + cap);
+ assertThat(legacy.readUntil("CAP legacy", TIMEOUT)).endsWith("ACK -" + cap);
+ legacy.send("CAP REQ :" + cap);
+ assertThat(legacy.readUntil("CAP legacy", TIMEOUT)).endsWith("NAK " + cap);
+
+ toggle(server, root, path, cap, true);
+
+ assertThat(capLines(explicit)).containsExactly(SERVER + " CAP explicit NEW " + cap);
+ assertThat(capLines(implicit)).containsExactly(SERVER + " CAP implicit NEW " + cap);
+ assertThat(capLines(unnamed)).containsExactly(SERVER + " CAP * NEW " + cap);
+ assertThat(capLines(legacy)).isEmpty();
+ // Enable restores no negotiation: behavior resumes only after a new request.
+ assertThat(list(implicit, "implicit")).endsWith("LIST :");
+ assertThat(behaves(root, implicit, cap)).isFalse();
+ implicit.send("CAP REQ :" + cap);
+ assertThat(implicit.readUntil("CAP implicit", TIMEOUT)).endsWith("ACK " + cap);
+ assertThat(behaves(root, implicit, cap)).isTrue();
+ }
+ }
+
+ @Test
+ void aRepeatedToggleSendsNoDuplicateNotification() throws Exception {
+ try (TestServer server = start();
+ RawIrcClient root = admin(server);
+ RawIrcClient implicit = client(server, "implicit", "CAP LS 302")) {
+ toggle(server, root, Path.EXTENSION, "multi-prefix", false);
+ assertThat(capLines(implicit)).hasSize(1);
+ toggle(server, root, Path.EXTENSION, "multi-prefix", false);
+ toggle(server, root, Path.REHASH, "multi-prefix", false);
+ assertThat(capLines(implicit)).isEmpty();
+ toggle(server, root, Path.REHASH, "multi-prefix", true);
+ assertThat(capLines(implicit)).hasSize(1);
+ toggle(server, root, Path.EXTENSION, "multi-prefix", true);
+ assertThat(capLines(implicit)).isEmpty();
+ }
+ }
+
+ @Test
+ void anInvalidReloadChangesNoStateAndSendsNothing() throws Exception {
+ try (TestServer server = start();
+ RawIrcClient root = admin(server);
+ RawIrcClient implicit = client(server, "implicit", "CAP LS 302", "CAP REQ :multi-prefix")) {
+ Files.writeString(
+ server.configPath,
+ TestServer.baseYaml()
+ + TestServer.adminEnabledYaml()
+ + capabilitiesYaml("multi-prefix")
+ + "nicknameMaxLength: 0\n");
+ root.send("REHASH");
+ assertThat(root.readUntil("NOTICE", TIMEOUT)).contains("Rehash failed");
+
+ assertThat(capLines(implicit)).isEmpty();
+ assertThat(list(implicit, "implicit")).endsWith("LIST multi-prefix");
+ assertThat(server.application.extensionRegistry().stateOf("multi-prefix"))
+ .isEqualTo(Extension.State.ENABLED);
+ }
+ }
+}
diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java
index 1ad34e1..5b2f702 100644
--- a/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java
+++ b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java
@@ -38,7 +38,7 @@
* behavior with it, and nothing else. The catalog is the ServiceLoader view a server starts with: a
* class loader that hides one provider class from the {@code CapabilityExtension} service file. No
* jar or class file is touched or deleted, and the desired configuration enables only providers
- * that are present, as an administrator's would.
+ * that are present, as an administrator's would. Core cap-notify is offered whatever is missing.
*/
class CapabilityProviderAbsenceTest {
@@ -50,14 +50,14 @@ class CapabilityProviderAbsenceTest {
@TempDir Path directory;
- /** A class loader whose view of the capability service file lacks one provider class. */
+ /** A class loader whose view of the capability service file lacks some provider classes. */
private static final class WithoutProvider extends ClassLoader {
- private final String hiddenClassSuffix;
+ private final Set hiddenClassSuffixes;
private final Path scratch;
- WithoutProvider(String hiddenClassSuffix, Path scratch) {
+ WithoutProvider(Set hiddenClassSuffixes, Path scratch) {
super(CapabilityProviderAbsenceTest.class.getClassLoader());
- this.hiddenClassSuffix = hiddenClassSuffix;
+ this.hiddenClassSuffixes = hiddenClassSuffixes;
this.scratch = scratch;
}
@@ -74,7 +74,7 @@ public Enumeration getResources(String name) throws IOException {
kept =
new String(in.readAllBytes(), StandardCharsets.UTF_8)
.lines()
- .filter(line -> !line.trim().endsWith(hiddenClassSuffix))
+ .filter(line -> hiddenClassSuffixes.stream().noneMatch(line.trim()::endsWith))
.toList();
}
Path file = Files.createTempFile(scratch, "services-", ".txt");
@@ -107,20 +107,33 @@ private static String yamlWithout(String absent) {
}
private TestServer startWithout(String absent) throws Exception {
+ return startHiding(Set.of(providerClass(absent)), yamlWithout(absent));
+ }
+
+ private TestServer startHiding(Set hiddenClasses, String yaml) throws Exception {
Thread thread = Thread.currentThread();
ClassLoader original = thread.getContextClassLoader();
- thread.setContextClassLoader(new WithoutProvider(providerClass(absent), directory));
+ thread.setContextClassLoader(new WithoutProvider(hiddenClasses, directory));
try {
- return TestServer.start(yamlWithout(absent));
+ return TestServer.start(yaml);
} finally {
thread.setContextClassLoader(original);
}
}
+ /** What LS must offer: the given providers plus core cap-notify, which no provider owns. */
+ private static List withCapNotify(List providers) {
+ List offers = new ArrayList<>(providers);
+ offers.add("cap-notify");
+ return offers;
+ }
+
private static Set offered(RawIrcClient client) throws Exception {
client.send("CAP LS 302");
String response = client.readUntil("CAP * LS", TIMEOUT);
- String caps = response.substring(response.indexOf("LS :") + "LS :".length()).trim();
+ String caps = response.substring(response.indexOf(" LS ") + " LS ".length()).trim();
+ // A one-name list is sent without the trailing-parameter colon.
+ if (caps.startsWith(":")) caps = caps.substring(1);
return caps.isEmpty() ? Set.of() : Set.of(caps.split(" "));
}
@@ -140,7 +153,21 @@ private static RawIrcClient registered(TestServer server, String nick, String ca
void theFullCatalogOffersAllSixAndTheBaselineIsWhatTheAbsenceTestsSubtract() throws Exception {
try (TestServer server = TestServer.start(yamlWithout("none"));
RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort())) {
- assertThat(offered(client)).containsExactlyInAnyOrderElementsOf(PROVIDERS);
+ assertThat(offered(client)).containsExactlyInAnyOrderElementsOf(withCapNotify(PROVIDERS));
+ }
+ }
+
+ @Test
+ void withEveryProviderMissingLsStillOffersCapNotifyAndItCanBeRequested() throws Exception {
+ Set all = new java.util.HashSet<>();
+ PROVIDERS.forEach(p -> all.add(providerClass(p)));
+ try (TestServer server = startHiding(all, "");
+ RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort())) {
+ assertThat(offered(client)).containsExactly("cap-notify");
+ client.send("CAP REQ :cap-notify");
+ assertThat(client.readUntil("CAP * ACK", TIMEOUT)).endsWith("ACK cap-notify");
+ client.send("CAP LIST");
+ assertThat(client.readUntil("CAP * LIST", TIMEOUT)).endsWith("LIST cap-notify");
}
}
@@ -153,9 +180,11 @@ void aMissingProviderIsNotOfferedAndCannotBeRequested() throws Exception {
.as("offers without %s", absent)
.doesNotContain(absent)
.containsExactlyInAnyOrderElementsOf(
- PROVIDERS.stream().filter(p -> !p.equals(absent)).toList());
+ withCapNotify(PROVIDERS.stream().filter(p -> !p.equals(absent)).toList()));
client.send("CAP REQ :" + absent);
assertThat(client.readUntil("CAP * NAK", TIMEOUT)).contains(absent);
+ client.send("CAP REQ :cap-notify");
+ assertThat(client.readUntil("CAP * ACK", TIMEOUT)).endsWith("ACK cap-notify");
}
}
}
diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityPublicationTimingLoadTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityPublicationTimingLoadTest.java
new file mode 100644
index 0000000..285ead2
--- /dev/null
+++ b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityPublicationTimingLoadTest.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.integration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import net.jircd.core.extension.ExtensionRegistry;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Repeatable #97 publication timing at 1,000 live socket sessions that all hold the toggled
+ * capability. Each disable publishes the snapshot and prunes every session under the administrative
+ * mutex. The first pass has no notification-eligible client, so it times the wait plus publication
+ * and prune alone; the second makes every client eligible, so the difference is the {@code
+ * DEL}/{@code NEW} enqueue. Both include the provider's own stop and command removal, so they bound
+ * the publication step from above. #102 adds the dedicated final load classes.
+ */
+@Tag("load")
+class CapabilityPublicationTimingLoadTest {
+
+ private static final int SESSIONS = 1000;
+ private static final int WARMUP = 5;
+ private static final int CYCLES = 40;
+ private static final Duration TIMEOUT = Duration.ofSeconds(10);
+
+ /** The per-connection rate limit is raised: over-rate commands are dropped, not delayed. */
+ private static final String YAML =
+ """
+ maxConnections: 10000
+ maxConnectionsPerIp: 10000
+ rateLimit:
+ bucketSize: 10000
+ refillRatePerSecond: 10000
+ capabilities:
+ multi-prefix: enabled
+ """;
+
+ private record Sample(String label, long[] sortedNanos) {
+ double p50Ms() {
+ return sortedNanos[sortedNanos.length / 2] / 1e6;
+ }
+
+ double p99Ms() {
+ return sortedNanos[(int) Math.ceil(sortedNanos.length * 0.99) - 1] / 1e6;
+ }
+
+ double maxMs() {
+ return sortedNanos[sortedNanos.length - 1] / 1e6;
+ }
+
+ void print() {
+ System.out.printf(
+ "CapabilityPublicationTimingLoadTest: %s sessions=%d cycles=%d p50Ms=%.3f p99Ms=%.3f"
+ + " maxMs=%.3f%n",
+ label, SESSIONS, sortedNanos.length, p50Ms(), p99Ms(), maxMs());
+ }
+ }
+
+ /** Disables then re-enables, re-requesting on every client so each disable prunes them all. */
+ // The clients belong to the caller, which closes them; this only writes to and reads from them.
+ @SuppressWarnings("PMD.CloseResource")
+ private static Sample[] measure(
+ String label, ExtensionRegistry registry, List clients) throws Exception {
+ List disables = new ArrayList<>();
+ List enables = new ArrayList<>();
+ for (int cycle = 0; cycle < WARMUP + CYCLES; cycle++) {
+ long start = System.nanoTime();
+ registry.disable("multi-prefix");
+ long disabled = System.nanoTime();
+ registry.enable("multi-prefix");
+ long enabled = System.nanoTime();
+ if (cycle >= WARMUP) {
+ disables.add(disabled - start);
+ enables.add(enabled - disabled);
+ }
+ for (RawIrcClient client : clients) client.send("CAP REQ :multi-prefix");
+ // Every request is answered before the next disable, so each one prunes every session.
+ for (RawIrcClient client : clients) client.readUntil("ACK", TIMEOUT);
+ }
+ return new Sample[] {
+ new Sample(
+ label + " disable", disables.stream().mapToLong(Long::longValue).sorted().toArray()),
+ new Sample(label + " enable", enables.stream().mapToLong(Long::longValue).sorted().toArray())
+ };
+ }
+
+ // Every client is closed in the finally loop, not in the scope that opened it.
+ @SuppressWarnings("PMD.CloseResource")
+ @Test
+ void publicationAndPruneAtOneThousandSessionsStayWithinTheBudget() throws Exception {
+ try (TestServer server = TestServer.start(YAML)) {
+ ExtensionRegistry registry = server.application.extensionRegistry();
+ List clients = new ArrayList<>();
+ try {
+ for (int i = 0; i < SESSIONS; i++) {
+ RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort());
+ clients.add(client);
+ client.send("CAP REQ :multi-prefix");
+ client.send("CAP END");
+ client.registerAndAwaitWelcome("s" + i, "s" + i);
+ }
+
+ Sample[] quiet = measure("no-notify", registry, clients);
+ for (RawIrcClient client : clients) {
+ client.send("CAP LS 302");
+ client.readUntil(" LS ", TIMEOUT);
+ }
+ Sample[] notified = measure("notify-302", registry, clients);
+
+ for (Sample sample : quiet) sample.print();
+ for (Sample sample : notified) sample.print();
+ assertThat(quiet[0].p99Ms()).isLessThanOrEqualTo(100.0);
+ } finally {
+ for (RawIrcClient client : clients) client.close();
+ }
+ }
+ }
+}
diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/SighupReloadOutcomeTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/SighupReloadOutcomeTest.java
new file mode 100644
index 0000000..a66951b
--- /dev/null
+++ b/jircd-integration-tests/src/test/java/net/jircd/integration/SighupReloadOutcomeTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2026 Guillermo Castro
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package net.jircd.integration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Set;
+import net.jircd.core.config.ConfigurationLoader;
+import net.jircd.core.config.ConfigurationReloader;
+import net.jircd.core.config.ServerConfiguration;
+import net.jircd.core.extension.AbstractCapabilityExtension;
+import net.jircd.core.extension.Extension;
+import net.jircd.core.extension.ExtensionRegistry;
+import net.jircd.core.extension.ServerContext;
+import net.jircd.server.SighupReloadHandler;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** A SIGHUP reload reports what it could not apply, as REHASH does, instead of claiming success. */
+class SighupReloadOutcomeTest {
+
+ private static final String HEADER = "serverName: test.jircd.local\n";
+
+ @TempDir Path directory;
+
+ /** A provider whose start always fails. */
+ private static final class Broken extends AbstractCapabilityExtension {
+ Broken() {
+ super("broken");
+ }
+
+ @Override
+ public void start(ServerContext context) {
+ throw new IllegalStateException("cannot start broken");
+ }
+ }
+
+ private ConfigurationReloader reloader(ExtensionRegistry registry, String yaml) throws Exception {
+ ConfigurationLoader loader = new ConfigurationLoader(Set.of("broken"), Set.of());
+ ServerConfiguration initial =
+ loader.load(new ByteArrayInputStream(HEADER.getBytes(StandardCharsets.UTF_8)));
+ Path file = directory.resolve("reload.yaml");
+ Files.writeString(file, HEADER + yaml);
+ return new ConfigurationReloader(file, loader, registry, initial);
+ }
+
+ @Test
+ void aProviderThatEndsFailedIsLoggedAndTheReloadIsNotReportedAsClean() throws Exception {
+ ExtensionRegistry registry = new ExtensionRegistry();
+ registry.register(new Broken());
+ ConfigurationReloader reloader = reloader(registry, "capabilities: {broken: enabled}\n");
+
+ try (LogCapture logs = new LogCapture(SighupReloadHandler.class)) {
+ SighupReloadHandler.reload(reloader);
+
+ assertThat(registry.stateOf("broken")).isEqualTo(Extension.State.FAILED);
+ assertThat(logs.messages())
+ .anySatisfy(
+ message -> assertThat(message).contains("broken").contains("cannot start broken"))
+ .noneMatch("Configuration reloaded via SIGHUP"::equals);
+ }
+ }
+
+ @Test
+ void aCleanReloadIsReportedAsSuch() throws Exception {
+ ExtensionRegistry registry = new ExtensionRegistry();
+ ConfigurationReloader reloader = reloader(registry, "");
+
+ try (LogCapture logs = new LogCapture(SighupReloadHandler.class)) {
+ SighupReloadHandler.reload(reloader);
+
+ assertThat(logs.messages()).containsExactly("Configuration reloaded via SIGHUP");
+ }
+ }
+}
diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/Story2CapabilityListTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/Story2CapabilityListTest.java
index 2f2e457..5852894 100644
--- a/jircd-integration-tests/src/test/java/net/jircd/integration/Story2CapabilityListTest.java
+++ b/jircd-integration-tests/src/test/java/net/jircd/integration/Story2CapabilityListTest.java
@@ -24,7 +24,7 @@
class Story2CapabilityListTest {
@Test
- void capLsReturnsExactlyTheThreeStory2Capabilities() throws Exception {
+ void capLsReturnsExactlyTheThreeStory2CapabilitiesAndCoreCapNotify() throws Exception {
try (TestServer server = TestServer.start(TestServer.ALL_CAPABILITIES_ENABLED_YAML);
RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort())) {
@@ -34,7 +34,8 @@ void capLsReturnsExactlyTheThreeStory2Capabilities() throws Exception {
String capsSection = response.substring(response.indexOf("LS :") + "LS :".length());
List offered = List.of(capsSection.trim().split(" "));
- assertThat(offered).containsExactlyInAnyOrder("message-tags", "server-time", "echo-message");
+ assertThat(offered)
+ .containsExactlyInAnyOrder("message-tags", "server-time", "echo-message", "cap-notify");
}
}
}
diff --git a/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java b/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java
index 27606e7..b221e5c 100644
--- a/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java
+++ b/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java
@@ -32,4 +32,10 @@ private CapabilityName() {}
public static final String AWAY_NOTIFY = "away-notify";
public static final String BATCH = "batch";
public static final String MULTI_PREFIX = "multi-prefix";
+
+ /**
+ * Core infrastructure, not a provider: always offered in {@code CAP LS}, never a toggleable
+ * extension. It opts a pre-302 client in to {@code CAP NEW}/{@code DEL} notifications.
+ */
+ public static final String CAP_NOTIFY = "cap-notify";
}
diff --git a/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityNegotiationGrammar.java b/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityNegotiationGrammar.java
index 6bdb377..1e84056 100644
--- a/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityNegotiationGrammar.java
+++ b/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityNegotiationGrammar.java
@@ -16,8 +16,10 @@
package net.jircd.protocol;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.Locale;
+import java.util.TreeSet;
/**
* The {@code CAP} command's own sub-language (FR-006/FR-007/FR-008): {@code LS}/{@code REQ}/{@code
@@ -37,6 +39,9 @@ public enum Subcommand {
END
}
+ /** The first {@code CAP LS} version with continuation lines and implicit cap-notify. */
+ public static final int NOTIFY_VERSION = 302;
+
private CapabilityNegotiationGrammar() {}
/** Resolves a {@code CAP} message's subcommand token, or {@code null} if unrecognized. */
@@ -66,27 +71,137 @@ public static List parseCapabilityList(Message message) {
return result;
}
- public static Message ls(String serverName, String target, List capabilities) {
- return new Message(
- java.util.Map.of(),
- serverName,
- Command.CAP,
- "CAP",
- List.of(target, "LS", join(capabilities)));
+ /**
+ * Parses the version a {@code CAP LS} carries: a nonnegative, digit-only decimal, saturating at
+ * {@link Integer#MAX_VALUE} rather than overflowing. An absent, empty, signed or otherwise
+ * malformed version reads as {@code 0}, the same as no version.
+ */
+ public static int parseLsVersion(Message message) {
+ if (message.params().size() < 2) {
+ return 0;
+ }
+ String token = message.params().get(1);
+ if (token.isEmpty()) {
+ return 0;
+ }
+ long value = 0;
+ for (int i = 0; i < token.length(); i++) {
+ char c = token.charAt(i);
+ if (c < '0' || c > '9') {
+ return 0;
+ }
+ value = Math.min(Integer.MAX_VALUE, value * 10 + (c - '0'));
+ }
+ return (int) value;
}
/**
- * {@code CAP LIST} — the client's currently negotiated capabilities, distinct from {@link #ls}
- * which reports everything the server offers regardless of negotiation state
- * (005-fix-batch-conformance FR-007).
+ * {@code CAP LS} replies offering {@code capabilities}. A 302-or-later client ({@code
+ * continuation}) gets as many lines as the list needs, every one but the last marked {@code *};
+ * an older client must get exactly one line.
+ *
+ * @throws MessageSerializer.MessageTooLongException if {@code continuation} is false and the list
+ * cannot fit one line; it is never truncated
*/
- public static Message list(String serverName, String target, List capabilities) {
- return new Message(
- java.util.Map.of(),
- serverName,
- Command.CAP,
- "CAP",
- List.of(target, "LIST", join(capabilities)));
+ public static List lsReplies(
+ String serverName, String target, List capabilities, boolean continuation) {
+ return packed(serverName, target, "LS", capabilities, continuation);
+ }
+
+ /**
+ * {@code CAP LIST} replies: the client's currently negotiated capabilities, distinct from {@link
+ * #lsReplies} which reports everything the server offers regardless of negotiation state
+ * (005-fix-batch-conformance FR-007). Packed like {@code LS}.
+ */
+ public static List listReplies(
+ String serverName, String target, List capabilities, boolean continuation) {
+ return packed(serverName, target, "LIST", capabilities, continuation);
+ }
+
+ /**
+ * {@code CAP NEW} or {@code CAP DEL} ({@code subcommand}) lines for {@code capabilities}: names
+ * only, deduplicated and sorted, split on name boundaries into lines that are each a complete,
+ * independently valid notification. There is no continuation marker for these.
+ */
+ public static List notifications(
+ String serverName, String target, String subcommand, Collection capabilities) {
+ List names = List.copyOf(new TreeSet<>(capabilities));
+ List lines = new ArrayList<>();
+ for (List chunk : chunks(serverName, target, subcommand, names)) {
+ lines.add(line(serverName, target, subcommand, false, chunk));
+ }
+ return lines;
+ }
+
+ /** Whether one {@code subcommand} line listing {@code capabilities} fits the 512-byte limit. */
+ public static boolean fitsOneLine(
+ String serverName, String target, String subcommand, List capabilities) {
+ return MessageSerializer.commandBytes(line(serverName, target, subcommand, false, capabilities))
+ <= MessageSerializer.MAX_COMMAND_BYTES;
+ }
+
+ private static List packed(
+ String serverName,
+ String target,
+ String subcommand,
+ List capabilities,
+ boolean continuation) {
+ if (!continuation) {
+ if (!fitsOneLine(serverName, target, subcommand, capabilities)) {
+ throw new MessageSerializer.MessageTooLongException(
+ "CAP " + subcommand + " list does not fit one line");
+ }
+ return List.of(line(serverName, target, subcommand, false, capabilities));
+ }
+ List> chunks = chunks(serverName, target, subcommand, capabilities);
+ List lines = new ArrayList<>();
+ for (int i = 0; i < chunks.size(); i++) {
+ lines.add(line(serverName, target, subcommand, i < chunks.size() - 1, chunks.get(i)));
+ }
+ return lines;
+ }
+
+ /**
+ * Splits {@code names} greedily into runs that each fit one line, sized as if every line carried
+ * the continuation marker. An empty list is one empty run.
+ */
+ private static List> chunks(
+ String serverName, String target, String subcommand, List names) {
+ int fixed =
+ MessageSerializer.commandBytes(line(serverName, target, subcommand, true, List.of()));
+ List