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> chunks = new ArrayList<>(); + List current = new ArrayList<>(); + int bytes = fixed; + for (String name : names) { + int added = MessageSerializer.utf8Bytes(name) + (current.isEmpty() ? 0 : 1); + if (bytes + added > MessageSerializer.MAX_COMMAND_BYTES && !current.isEmpty()) { + chunks.add(current); + current = new ArrayList<>(); + added = MessageSerializer.utf8Bytes(name); + bytes = fixed; + } + if (fixed + added > MessageSerializer.MAX_COMMAND_BYTES) { + throw new MessageSerializer.MessageTooLongException( + "capability name cannot fit a CAP " + subcommand + " line"); + } + current.add(name); + bytes += added; + } + chunks.add(current); + return chunks; + } + + private static Message line( + String serverName, + String target, + String subcommand, + boolean more, + List capabilities) { + List params = + more + ? List.of(target, subcommand, "*", join(capabilities)) + : List.of(target, subcommand, join(capabilities)); + return new Message(java.util.Map.of(), serverName, Command.CAP, "CAP", params); } public static Message ack(String serverName, String target, List capabilities) { diff --git a/jircd-protocol/src/test/java/net/jircd/protocol/CapabilityNegotiationGrammarTest.java b/jircd-protocol/src/test/java/net/jircd/protocol/CapabilityNegotiationGrammarTest.java new file mode 100644 index 0000000..bcc33e0 --- /dev/null +++ b/jircd-protocol/src/test/java/net/jircd/protocol/CapabilityNegotiationGrammarTest.java @@ -0,0 +1,170 @@ +/* + * 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.protocol; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class CapabilityNegotiationGrammarTest { + + private static final String SERVER = "irc.example"; + + private static Message cap(String... params) { + return new Message(java.util.Map.of(), null, Command.CAP, "CAP", List.of(params)); + } + + @Test + void lsVersionIsParsedAsANonnegativeDigitOnlyNumber() { + assertThat(CapabilityNegotiationGrammar.parseLsVersion(cap("LS", "302"))).isEqualTo(302); + assertThat(CapabilityNegotiationGrammar.parseLsVersion(cap("LS", "301"))).isEqualTo(301); + assertThat(CapabilityNegotiationGrammar.parseLsVersion(cap("LS", "0"))).isZero(); + assertThat(CapabilityNegotiationGrammar.parseLsVersion(cap("LS", "000302"))).isEqualTo(302); + } + + @Test + void anAbsentEmptyOrMalformedLsVersionCountsAsNoVersion() { + for (Message message : + List.of( + cap("LS"), + cap("LS", ""), + cap("LS", "-302"), + cap("LS", "+302"), + cap("LS", "30x"), + cap("LS", "3 02"), + cap("LS", "٣٠٢"))) { + assertThat(CapabilityNegotiationGrammar.parseLsVersion(message)) + .as("%s", message.params()) + .isZero(); + } + } + + @Test + void aHugeLsVersionSaturatesInsteadOfOverflowing() { + assertThat(CapabilityNegotiationGrammar.parseLsVersion(cap("LS", "99999999999999999999"))) + .isEqualTo(Integer.MAX_VALUE); + assertThat(CapabilityNegotiationGrammar.parseLsVersion(cap("LS", "2147483648"))) + .isEqualTo(Integer.MAX_VALUE); + assertThat(CapabilityNegotiationGrammar.parseLsVersion(cap("LS", "2147483647"))) + .isEqualTo(Integer.MAX_VALUE); + } + + @Test + void notificationsAreDeduplicatedSortedAndNamesOnly() { + List lines = + CapabilityNegotiationGrammar.notifications( + SERVER, "alice", "DEL", List.of("multi-prefix", "batch", "multi-prefix")); + assertThat(lines).hasSize(1); + assertThat(MessageSerializer.serializeText(lines.getFirst())) + .isEqualTo(":irc.example CAP alice DEL :batch multi-prefix"); + } + + @Test + void anOversizeNotificationSplitsOnTokenBoundariesIntoIndependentlyValidLines() { + List names = new ArrayList<>(); + for (int i = 0; i < 80; i++) names.add("vendor.example/capability-" + i); + List lines = CapabilityNegotiationGrammar.notifications(SERVER, "*", "NEW", names); + assertThat(lines).hasSizeGreaterThan(1); + List seen = new ArrayList<>(); + for (Message line : lines) { + assertThat(MessageSerializer.commandBytes(line)) + .isLessThanOrEqualTo(MessageSerializer.MAX_COMMAND_BYTES); + // No continuation marker: each line is a complete NEW on its own. + assertThat(line.params()).hasSize(3).startsWith("*", "NEW"); + seen.addAll(List.of(line.params().getLast().split(" "))); + } + assertThat(seen).containsExactlyInAnyOrderElementsOf(names); + } + + @Test + void aFittingLsIsOneLineForEveryVersion() { + List offers = List.of("cap-notify", "message-tags"); + for (boolean continuation : List.of(false, true)) { + List lines = + CapabilityNegotiationGrammar.lsReplies(SERVER, "*", offers, continuation); + assertThat(lines).hasSize(1); + assertThat(MessageSerializer.serializeText(lines.getFirst())) + .isEqualTo(":irc.example CAP * LS :cap-notify message-tags"); + } + } + + @Test + void anOversizeLsUsesTheContinuationMarkerOnEveryLineButTheLast() { + List offers = new ArrayList<>(); + for (int i = 0; i < 80; i++) offers.add("vendor.example/capability-" + i); + List lines = CapabilityNegotiationGrammar.lsReplies(SERVER, "*", offers, true); + assertThat(lines).hasSizeGreaterThan(1); + for (int i = 0; i < lines.size(); i++) { + Message line = lines.get(i); + assertThat(MessageSerializer.commandBytes(line)) + .isLessThanOrEqualTo(MessageSerializer.MAX_COMMAND_BYTES); + if (i < lines.size() - 1) { + assertThat(line.params()).hasSize(4).startsWith("*", "LS", "*"); + } else { + assertThat(line.params()).hasSize(3).startsWith("*", "LS"); + } + } + } + + @Test + void aLegacyLsThatCannotFitOneLineIsRejectedRatherThanTruncated() { + List offers = new ArrayList<>(); + for (int i = 0; i < 80; i++) offers.add("vendor.example/capability-" + i); + assertThat(CapabilityNegotiationGrammar.fitsOneLine(SERVER, "*", "LS", offers)).isFalse(); + assertThat(CapabilityNegotiationGrammar.fitsOneLine(SERVER, "*", "LS", List.of("a", "b"))) + .isTrue(); + assertThatThrownBy(() -> CapabilityNegotiationGrammar.lsReplies(SERVER, "*", offers, false)) + .isInstanceOf(MessageSerializer.MessageTooLongException.class); + } + + @Test + void listPacksLikeLsAndAnEmptyListIsOneEmptyLine() { + assertThat(CapabilityNegotiationGrammar.listReplies(SERVER, "bob", List.of(), false)) + .singleElement() + .extracting(MessageSerializer::serializeText) + .isEqualTo(":irc.example CAP bob LIST :"); + List held = new ArrayList<>(); + for (int i = 0; i < 80; i++) held.add("vendor.example/capability-" + i); + assertThat(CapabilityNegotiationGrammar.listReplies(SERVER, "bob", held, true)) + .hasSizeGreaterThan(1) + .allSatisfy( + line -> + assertThat(MessageSerializer.commandBytes(line)) + .isLessThanOrEqualTo(MessageSerializer.MAX_COMMAND_BYTES)); + } + + @Test + void ackAndNakEchoTheRequestExactlyIncludingOrderAndRepeats() { + List requested = List.of("b", "-a", "b"); + assertThat( + MessageSerializer.serializeText( + CapabilityNegotiationGrammar.ack(SERVER, "*", requested))) + .isEqualTo(":irc.example CAP * ACK :b -a b"); + assertThat( + MessageSerializer.serializeText( + CapabilityNegotiationGrammar.nak(SERVER, "*", requested))) + .isEqualTo(":irc.example CAP * NAK :b -a b"); + } + + @Test + void capNotifyIsTheCoreCapabilityName() { + assertThat(CapabilityName.CAP_NOTIFY).isEqualTo("cap-notify"); + assertThat(CapabilityNegotiationGrammar.NOTIFY_VERSION).isEqualTo(302); + } +} diff --git a/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/ExtensionCommandHandler.java b/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/ExtensionCommandHandler.java index 58e44a5..5a8faf1 100644 --- a/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/ExtensionCommandHandler.java +++ b/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/ExtensionCommandHandler.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Locale; import java.util.function.Supplier; +import net.jircd.core.extension.Extension; import net.jircd.core.extension.ExtensionRegistry; import net.jircd.core.session.ClientSession; import net.jircd.core.session.command.CommandHandler; @@ -80,11 +81,11 @@ public void handle(ClientSession session, Message message) { switch (action) { case "ENABLE" -> { extensionRegistry.enable(extensionId); - AdminNotices.send(session, serverName.get(), extensionId + " is now enabled"); + reportOutcome(session, extensionId); } case "DISABLE" -> { extensionRegistry.disable(extensionId); - AdminNotices.send(session, serverName.get(), extensionId + " is now disabled"); + reportOutcome(session, extensionId); } default -> Replies.sendFitted( @@ -99,4 +100,15 @@ public void handle(ClientSession session, Message message) { session, serverName.get(), "Failed to toggle " + extensionId + ": " + e.getMessage()); } } + + /** + * Reports the state the toggle actually left, not the one requested. A repeated toggle is a no-op + * that reports the unchanged state and sends no capability notification. + */ + private void reportOutcome(ClientSession session, String extensionId) { + Extension.State state = extensionRegistry.stateOf(extensionId); + // Known here: the id was found before the toggle, and registrations are never removed. + String outcome = state == null ? "unknown" : state.name().toLowerCase(Locale.ROOT); + AdminNotices.send(session, serverName.get(), extensionId + " is now " + outcome); + } } diff --git a/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/RehashCommandHandler.java b/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/RehashCommandHandler.java index e664341..c610e04 100644 --- a/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/RehashCommandHandler.java +++ b/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/RehashCommandHandler.java @@ -60,13 +60,20 @@ public void handle(ClientSession session, Message message) { return; } try { - configurationReloader.reload(); + ConfigurationReloader.ReloadOutcome outcome = configurationReloader.reloadWithOutcome(); Replies.sendFitted( session, serverName.get(), NumericReply.RPL_REHASHING, List.of(configurationReloader.configPath().toString()), "Rehashing"); + // A valid reload still applies; a provider that failed to change is reported, not hidden. + outcome + .failures() + .forEach( + (id, reason) -> + AdminNotices.send( + session, serverName.get(), "Rehash could not apply " + id + ": " + reason)); } catch (ConfigurationException e) { AdminNotices.send(session, serverName.get(), "Rehash failed: " + e.getMessage()); } catch (IOException e) { diff --git a/jircd-server-extensions/admin/src/test/java/net/jircd/serverextensions/admin/LifecycleOutcomeReportingTest.java b/jircd-server-extensions/admin/src/test/java/net/jircd/serverextensions/admin/LifecycleOutcomeReportingTest.java new file mode 100644 index 0000000..9649c3e --- /dev/null +++ b/jircd-server-extensions/admin/src/test/java/net/jircd/serverextensions/admin/LifecycleOutcomeReportingTest.java @@ -0,0 +1,142 @@ +/* + * 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.serverextensions.admin; + +import static org.assertj.core.api.Assertions.assertThat; + +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.List; +import java.util.Set; +import net.jircd.core.config.ConfigurationLoader; +import net.jircd.core.config.ConfigurationReloader; +import net.jircd.core.extension.Extension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.ServerContext; +import net.jircd.core.extension.ServerExtension; +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.CommandHandler; +import net.jircd.protocol.MessageParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * {@code EXTENSION} and {@code REHASH} report what a lifecycle change actually did: a provider that + * fails to start is reported to the administrator, and a valid reload still applies the rest. + */ +class LifecycleOutcomeReportingTest { + + private static final String HEADER = "serverName: test.jircd.local\n"; + + @TempDir Path directory; + + /** Stands in for the admin extension, whose enabled state authorizes the commands. */ + private static final class Simple implements ServerExtension { + private final String id; + private final boolean failsToStart; + + Simple(String id, boolean failsToStart) { + this.id = id; + this.failsToStart = failsToStart; + } + + @Override + public String id() { + return id; + } + + @Override + public void start(ServerContext context) { + if (failsToStart) throw new IllegalStateException("no backing store"); + } + + @Override + public void stop() { + // Holds no resources. + } + } + + private final ExtensionRegistry registry = new ExtensionRegistry(); + + private ClientSession administrator() { + registry.register(new Simple(AdminExtension.ID, false)); + registry.register(new Simple("broken", true)); + registry.register(new Simple("good", false)); + registry.enable(AdminExtension.ID); + ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults()); + session.setNickname("root"); + session.grantAdministratorPrivilege(); + return session; + } + + private static List run(CommandHandler handler, ClientSession session, String line) + throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = new SessionWriter(session, out, TagRenderer.NONE)) { + session.attachWriter(writer); + handler.handle(session, MessageParser.parse(line)); + } + String text = out.toString(StandardCharsets.UTF_8); + return text.isEmpty() ? List.of() : List.of(text.split("\r\n")); + } + + @Test + void rehashReportsAProviderThatFailedToStartAndAppliesTheRest() throws Exception { + ClientSession root = administrator(); + ConfigurationLoader loader = + new ConfigurationLoader(Set.of(), Set.of(AdminExtension.ID, "broken", "good")); + Path file = directory.resolve("reload.yaml"); + Files.writeString(file, HEADER + "server-extensions: {broken: enabled, good: enabled}\n"); + ConfigurationReloader reloader = + new ConfigurationReloader( + file, + loader, + registry, + loader.load(new ByteArrayInputStream(HEADER.getBytes(StandardCharsets.UTF_8)))); + + List lines = + run(new RehashCommandHandler(reloader, registry, () -> "irc.example"), root, "REHASH"); + + assertThat(lines).hasSize(2); + assertThat(lines.getFirst()).contains(" 382 "); + assertThat(lines.getLast()) + .isEqualTo(":irc.example NOTICE root :Rehash could not apply broken: no backing store"); + assertThat(registry.stateOf("good")).isEqualTo(Extension.State.ENABLED); + assertThat(registry.stateOf("broken")).isEqualTo(Extension.State.FAILED); + } + + @Test + void extensionReportsTheStateTheToggleActuallyLeft() throws Exception { + ClientSession root = administrator(); + ExtensionCommandHandler handler = new ExtensionCommandHandler(registry, () -> "irc.example"); + + assertThat(run(handler, root, "EXTENSION ENABLE broken")) + .containsExactly(":irc.example NOTICE root :Failed to toggle broken: no backing store"); + // Nothing was left to clean up, so the disable is a no-op: it says FAILED, not "disabled". + assertThat(run(handler, root, "EXTENSION DISABLE broken")) + .containsExactly(":irc.example NOTICE root :broken is now failed"); + assertThat(run(handler, root, "EXTENSION ENABLE good")) + .containsExactly(":irc.example NOTICE root :good is now enabled"); + assertThat(run(handler, root, "EXTENSION ENABLE good")) + .containsExactly(":irc.example NOTICE root :good is now enabled"); + } +} diff --git a/jircd-server/src/main/java/net/jircd/server/SighupReloadHandler.java b/jircd-server/src/main/java/net/jircd/server/SighupReloadHandler.java index 4ee1c95..b2d2f65 100644 --- a/jircd-server/src/main/java/net/jircd/server/SighupReloadHandler.java +++ b/jircd-server/src/main/java/net/jircd/server/SighupReloadHandler.java @@ -15,6 +15,7 @@ */ package net.jircd.server; +import net.jircd.core.config.ConfigurationReloader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sun.misc.Signal; @@ -32,15 +33,28 @@ public final class SighupReloadHandler { private SighupReloadHandler() {} public static void install(JircdServerApplication application) { - Signal.handle( - new Signal("HUP"), - signal -> { - try { - application.reloader().reload(); - LOG.info("Configuration reloaded via SIGHUP"); - } catch (Exception e) { - LOG.warn("SIGHUP reload failed, running configuration left untouched", e); - } - }); + Signal.handle(new Signal("HUP"), signal -> reload(application.reloader())); + } + + /** + * One SIGHUP reload, reported as {@code REHASH} reports it: a valid reload still applies, and + * each extension that could not reach its desired state is logged with why instead of the reload + * being reported as clean. + */ + public static void reload(ConfigurationReloader reloader) { + try { + ConfigurationReloader.ReloadOutcome outcome = reloader.reloadWithOutcome(); + if (outcome.failures().isEmpty()) { + LOG.info("Configuration reloaded via SIGHUP"); + } + outcome + .failures() + .forEach( + (id, reason) -> + LOG.warn( + "Configuration reloaded via SIGHUP, but could not apply {}: {}", id, reason)); + } catch (Exception e) { + LOG.warn("SIGHUP reload failed, running configuration left untouched", e); + } } } diff --git a/specs/001-ircv3-server/contracts/irc-protocol-commands.md b/specs/001-ircv3-server/contracts/irc-protocol-commands.md index e6f02ba..b3bf88e 100644 --- a/specs/001-ircv3-server/contracts/irc-protocol-commands.md +++ b/specs/001-ircv3-server/contracts/irc-protocol-commands.md @@ -98,9 +98,10 @@ hostmask prefix — not any client's identity. | Command | Direction | Preconditions | Effect | Replies (see numeric-replies contract) | |---|---|---|---|---| -| `CAP LS [version]` | C→S | Any time before registration completes | Server returns its currently-available capability list | `CAP LS :` — the target is the client's nick once set, else `*` | -| `CAP LIST` | C→S | Any time | Server returns the requesting client's own currently NEGOTIATED capabilities, distinct from `LS`'s full offered list (005-fix-batch-conformance FR-007) | `CAP LIST :` — same target rule | -| `CAP REQ :` | C→S | After `CAP LS` | Atomic: if every name (or `-name` removal) is currently offered, the server applies all of them and sends one `ACK`; if any is unknown or not offered, it sends one `NAK` and changes nothing; an empty request is NAKed with an empty list; a reply that would exceed 512 bytes gets `417` and changes nothing (issue #71) | `CAP ACK`/`CAP NAK` — the reply echoes back the full requested list exactly as given, including repeats and `-` prefixes (005-fix-batch-conformance FR-009); the target is the client's nick once set, else `*` | +| `CAP LS [version]` | C→S | Any time before registration completes | Server returns its currently-available capability list: core `cap-notify` plus every enabled provider's capability, sorted. The highest version a connection has sent is remembered (digit-only, saturating; a malformed, signed or empty version counts as none); once it is 302 or more, a later lower or omitted version does not lower it | `CAP LS :` — the target is the client's nick once set, else `*`. A 302-or-later client gets `CAP LS * :` continuation lines when the list does not fit one line; an older client always gets one line, and enabling a provider whose offer would not fit that line is rejected | +| `CAP LIST` | C→S | Any time | Server returns the requesting client's own currently NEGOTIATED capabilities, distinct from `LS`'s full offered list (005-fix-batch-conformance FR-007). After a runtime disable the pruned names are gone. Explicitly negotiated `cap-notify` is listed; support implied only by `CAP LS 302` is not | `CAP LIST :` — same target rule | +| `CAP REQ :` | C→S | After `CAP LS` | Atomic, applied to a candidate set token by token in order: an addition must be currently offered (including `cap-notify`); a `-name` removal is accepted if the client holds the name, offered or not, or if the name is a known capability it does not hold (for example one a disable already pruned, an acknowledged no-op); an unknown name the client does not hold refuses the request. `-cap-notify` is refused once the client has sent `CAP LS 302` or later; an older client may remove it. Any refusal sends one `NAK` and changes nothing; an empty request is NAKed with an empty list; a reply that would exceed 512 bytes gets `417` and changes nothing (issue #71). Validation, the size check, the change and the reply happen in one step that a concurrent runtime disable cannot interleave with | `CAP ACK`/`CAP NAK` — the reply echoes back the full requested list exactly as given, including repeats and `-` prefixes (005-fix-batch-conformance FR-009); the target is the client's nick once set, else `*` | +| `CAP NEW :` / `CAP DEL :` | S→C | After a runtime enable or disable (`EXTENSION`, `REHASH`, `SIGHUP`) changes the offered set | Sent to every connected client that sent `CAP LS 302` or later or negotiated `cap-notify`, whether or not it held the names; never to others, and never for a toggle that changed nothing or a client's own `REQ` | `CAP DEL :` then `CAP NEW :`: names only, sorted and deduplicated, split on name boundaries into independently valid lines if too long. `NEW` restores no negotiation | | `CAP END` | C→S | After negotiation | Ends negotiation; registration may complete | (none; unblocks registration) | | `CAP ` | C→S | Any time | Rejected | `410 ERR_INVALIDCAPCMD :` (005-fix-batch-conformance FR-008; was previously the generic `421`) — the second param echoes back the client's own invalid subcommand token verbatim (irctest `cap.py::testInvalidCapSubcommand`), not folded into the reason text | | `NICK ` | C→S | Session not yet holding a nickname, or changing an existing one | Atomically claims the nickname (FR-002) | `433 ERR_NICKNAMEINUSE` on conflict; for a post-registration change, a `NICK ` notification (prefixed with the OLD hostmask) is broadcast to the changing client itself and every channel it's currently a member of (005-fix-batch-conformance FR-001/FR-002) — the initial pre-registration claim stays silent, confirmed via the registration burst instead; reclaiming the exact current nickname (same case, a true no-op) broadcasts nothing at all, distinct from a case-only change which is still a real change and does broadcast (irctest `regressions.py::testCaseChanges`) | @@ -185,10 +186,13 @@ defined, the same class of gap the nickname/channel grammars closed for - `CAP` negotiation MUST be usable before `NICK`/`USER` completes registration (FR-006), and registration MUST still succeed for clients that skip `CAP` entirely (FR-008). -- Only `message-tags`, `server-time`, `echo-message`, `away-notify`, `batch` - and `multi-prefix` may appear in the `CAP LS` response (FR-025, extended by - 011-away-notify-batch and issue #96); the SASL capability referenced in the - spec is deferred with Story 3 and MUST NOT appear. +- Only `message-tags`, `server-time`, `echo-message`, `away-notify`, `batch`, + `multi-prefix` and core `cap-notify` may appear in the `CAP LS` response + (FR-025, extended by 011-away-notify-batch, issue #96 and #97); the SASL + capability referenced in the spec is deferred with Story 3 and MUST NOT + appear. `cap-notify` is core infrastructure, not an extension: it is + offered even when no capability provider is installed and cannot be + toggled. - `message-tags` itself now contributes one tag unconditionally: `msgid`, a server-generated unique identifier present on every message relayed to a recipient that has negotiated `message-tags` at all, regardless @@ -204,9 +208,14 @@ defined, the same class of gap the nickname/channel grammars closed for ordinary one, so a self-targeted message is still delivered once; `away-notify`, `batch` and `multi-prefix` are described in their own contract notes. Removing a module from the classpath removes both its `CAP LS` offer and its behavior; core and the other modules are - unaffected. How a capability change is applied to already-connected sessions at runtime is - specified separately (014-capability-plugin-spi, runtime lifecycle) and is not part of this - ownership change. + unaffected. +- **Runtime changes** (014-capability-plugin-spi, #97): disabling a capability removes it from + every connected session's negotiated set in the same step as its offer, so `CAP LIST` and any + output prepared afterwards reflect it; output prepared before may still arrive after the + `CAP DEL`. A batch group queued before the change keeps its closing `BATCH`; a group queued + after `batch` or `message-tags` stopped being effective for its recipient is sent plain, and + `batch` itself stays negotiated. See "Runtime capability changes" in + `server-configuration.md`. - `005`'s token set (FR-055) is deliberately minimal: every token restates a value this specification already committed to elsewhere (casemapping, grammar limits, mode prefixes, UTF-8 enforcement), none diff --git a/specs/001-ircv3-server/contracts/server-configuration.md b/specs/001-ircv3-server/contracts/server-configuration.md index c124aff..b958e57 100644 --- a/specs/001-ircv3-server/contracts/server-configuration.md +++ b/specs/001-ircv3-server/contracts/server-configuration.md @@ -448,3 +448,43 @@ administratorCredentials: `rateLimit`, `listeners`, and `administratorCredentials`, none of which `EXTENSION` touches); `EXTENSION` is the narrower, file-independent equivalent scoped to a single extension's state. +- **Runtime capability changes** (014-capability-plugin-spi, #97; amends + "Path equivalence" above): `EXTENSION`, `REHASH` and `SIGHUP` all change a + capability through the same lifecycle. Under the capability runtime's + administrative mutex, one step publishes the new immutable runtime + snapshot and removes every withdrawn name from the negotiated set of every + connected session, registered or not; disabling a provider also withdraws, + in that same step, every provider that declares it as a hard dependency. + `CAP DEL` (for removals) and then `CAP NEW` (for additions) are queued + afterwards to each notification-eligible client — one that sent + `CAP LS 302` or later, or negotiated `cap-notify` — whether or not it held + the name; other clients get neither. Provider `start`/`stop` run outside + that mutex. Enabling a capability never restores anyone's earlier + negotiation: behavior resumes for a client only after it requests the + capability again. A repeated toggle changes nothing and sends nothing. + Output that was already prepared from the earlier state may still be + delivered after the `DEL`; there is no output cutoff. Anything prepared + after the change uses the new state. `REHASH` validates the whole file + first, including the whole candidate provider graph (every hard + prerequisite of an enabled provider enabled, no dependency cycle, no + duplicate offer or owned slot, no provider command that core or a server + extension already handles); an invalid file changes no configuration, + negotiation or notification, and starts or stops no provider. A valid reload is one lifecycle operation + that no `EXTENSION` toggle can interleave with: providers to enable start + first, prerequisites before their dependents, then one publication swaps + in every capability change together with the new reply limits and + configuration (so one `CAP DEL`/`NEW` per reload), and only then are + withdrawn providers stopped, each before what it requires. A command a + withdrawn provider owned may be taken over by a provider the same reload + enables; it moves to the new owner in that publication. A provider + whose start fails ends `FAILED`, together with any provider in the same + reload that requires it, and is reported without undoing the rest: to the + `REHASH` sender, or in the server log for `SIGHUP`. `EXTENSION` reports the + state the toggle actually left; a disable whose withdrawn dependent fails + to stop reports that failure even though the named provider itself is + disabled. + A nickname change and the client's own `NICK` line are serialized with + publication, so a notification always addresses the nickname the client + knows at that point. + Complete runtime fault withdrawal (a hook that faults at runtime) is + specified with #102. diff --git a/specs/001-ircv3-server/data-model.md b/specs/001-ircv3-server/data-model.md index d3ec491..1804f93 100644 --- a/specs/001-ircv3-server/data-model.md +++ b/specs/001-ircv3-server/data-model.md @@ -24,7 +24,8 @@ guarded by this aggregate. | `outboundQueue` | bounded queue of `OutboundMessage` (below) | The *only* path by which any other session delivers a message to this one (FR-004 fan-out); drained exclusively by this session's own writer thread, per research.md "Message fan-out concurrency model". Never written to directly from another session's thread. Queue elements are **not** pre-formatted wire lines — this session's own writer thread does that at drain time, using this session's own `negotiatedCapabilities` (see `OutboundMessage`). | | `registrationState` | enum: `CONNECTING`, `REGISTERED`, `CLOSING` | A session must reach `REGISTERED` (nickname + user info accepted, FR-001) before most commands are valid. | | `nickname` | string, 0..1 | Present once registration succeeds; unique across all sessions (FR-002), compared case-insensitively per FR-052's casemapping (research.md "IRC casemapping"), not stored/compared in a normalized form — the original casing a client registered with is preserved and shown to others, only the *comparison* folds case; MUST also conform to the nickname grammar (contracts/irc-protocol-commands.md "Connection Registration Grammar") — uniqueness and format are independent checks (`433` vs. `432`). | -| `negotiatedCapabilities` | set of `Capability` names | Populated via CAP negotiation (FR-006, FR-007); empty for clients that never negotiate (FR-008). | +| `negotiatedCapabilities` | set of `Capability` names | Populated via CAP negotiation (FR-006, FR-007); empty for clients that never negotiate (FR-008). An immutable set replaced as a whole, and only under the capability runtime's administrative mutex (a `CAP REQ`, or the prune of a runtime disable), so a request cannot bring back a name a disable removed (014-capability-plugin-spi, #97). Holds explicitly negotiated names only, `cap-notify` included when requested. | +| `highestCapVersion` | integer | The highest `CAP LS` version the connection has sent; never lowered. 302 or more implies notification support, which the client cannot remove. Notification eligibility is derived: this is 302 or more, or `cap-notify` is negotiated. | | `channelMemberships` | set of `Channel` references | Channels this session has joined; drives cleanup on disconnect (FR-017). | | `rateLimitBucket` | token bucket state | Per-connection (FR-016); see research.md "Rate limiting". | | `ident` | string, 0..1 | Absent until this session's `USER` command is processed (FR-001), then derived from its username field (FR-030); not independently verified (see spec.md Assumptions re: RFC 1413). Its presence is also the source-of-truth signal for "this session has already processed a `USER` command" (FR-001's one-shot restriction, `UserCommandHandler` precondition) — no separate boolean field duplicates that fact. | @@ -693,6 +694,18 @@ direction, no restart, quiesced per research.md); `ENABLED` → `FAILED` auto-recover; it requires an administrator action (out of scope to specify the exact recovery UX here). +For a `CapabilityExtension` (014-capability-plugin-spi, #97) the state change +and its effect on sessions are one step: publishing the new immutable runtime +snapshot and pruning withdrawn names from every live session (registered or +not) happen together under the capability runtime's administrative mutex, +which CAP state changes, live-session insertion and removal, and output +captures also use. A provider's hard dependents are withdrawn with it. +`CAP DEL`/`NEW` follow to notification-eligible sessions. Enable restores no +earlier negotiation. Work captured from the earlier state may still finish +after the `DEL`. The provider's `start`/`stop` run outside that mutex and +must be safe to overlap with such captured work. Core `cap-notify` is not an +`Extension` and has no state. + ## ServerConfiguration — *Aggregate Root, Server Extensibility (config) / Administration* The administrator-controlled settings loaded at startup and re-applied on diff --git a/specs/014-capability-plugin-spi/plan.md b/specs/014-capability-plugin-spi/plan.md index 8bf81a4..9cefad1 100644 --- a/specs/014-capability-plugin-spi/plan.md +++ b/specs/014-capability-plugin-spi/plan.md @@ -132,6 +132,8 @@ Hard dependencies are a validated DAG with atomic dependent withdrawal; session ## Complexity Tracking +**Amendment (2026-10-24, #97 implementation)**: "the existing administrative control mutex" throughout this plan is the two-level arrangement recorded in [research.md](research.md) Decision 2. The existing `ExtensionRegistry` lifecycle lock serializes whole transitions, including a whole reload, and is the only lock provider callbacks run under. A short `CapabilityRuntime` mutex, taken after it, covers publication + prune + notification enqueue, CAP state, live-session tracking, nickname changes, captures and group admission. No such single mutex existed before #97. + No constitution violations. Immutable typed registrations/bounds serve current modules. Existing administrative control provides atomic snapshot/prune; only batch's finite admission needs its short current-prerequisite selection. No new lifecycle synchronization subsystem or general event bus is justified. ## Risks and accepted tradeoffs diff --git a/specs/014-capability-plugin-spi/research.md b/specs/014-capability-plugin-spi/research.md index 03a209c..03a5d80 100644 --- a/specs/014-capability-plugin-spi/research.md +++ b/specs/014-capability-plugin-spi/research.md @@ -45,6 +45,11 @@ Core `capability/CapabilityNegotiator` and `session/command/CapCommandHandler` l **Batch exception**: A finite group is a complete validated queue operation with a close. Prepare framed/plain alternatives outside locks; at group admission make a current prerequisite choice under the existing administrative mutex and append under the writer queue lock. If batch or message-tags is ineffective then, choose plain. This narrow selection has no hook call/retry and does not synchronize ordinary output admission. An admitted group keeps its close even if it overlaps DEL. +**Amendment (2026-10-24, #97 implementation)**: no single existing mutex covered all of this. `ExtensionRegistry`'s lifecycle lock serialized enable/disable but is held across provider `start`/`register`/`stop`. `ConfigurationReloader`'s monitor is held across file I/O. `ConnectionHandler.liveSessions` and CAP REQ took no lock. With the user's agreement, "the existing administrative control mutex" in these design documents and their contracts is implemented as two levels: +- **Outer lifecycle lock:** the existing `ExtensionRegistry` lock serializes whole transitions, including an entire valid reload. Provider callbacks run under it and under nothing else. +- **Administrative mutex:** a short inner monitor owned by `CapabilityRuntime`. It is taken only for snapshot publication together with the all-session prune, the reload's reply limits and configuration, and the `DEL`/`NEW` enqueue; CAP LS/LIST/REQ; `liveSessions` insertion, removal and writer attachment; a nickname change with its own `NICK` echo; coherent captures; and group admission. It never runs a provider callback, socket I/O or a channel monitor. +- **Lock order:** lifecycle lock, then the administrative mutex, then at most one session writer's queue lock. + **Alternatives**: A stricter guarantee suppressing captured output after DEL was rejected by the user as unnecessary complexity. Waiting for socket drain lets a slow client block administrative changes. Eventual background pruning does not satisfy atomic all-session state changes. ## Decision 3 — Advertise core cap-notify diff --git a/specs/014-capability-plugin-spi/tasks.md b/specs/014-capability-plugin-spi/tasks.md index 51abec1..2df8ab6 100644 --- a/specs/014-capability-plugin-spi/tasks.md +++ b/specs/014-capability-plugin-spi/tasks.md @@ -87,22 +87,22 @@ ### Tests first -- [ ] T042 [P] [PR97] [US2] Update exact-LS expectations in `jircd-integration-tests/src/test/java/net/jircd/integration/Story2CapabilityListTest.java` and other affected CAP LS tests, and extend `CapabilityProviderAbsenceTest.java` to retain advertised core cap-notify with any/all providers missing; add normal offered positive-REQ coverage without unrelated assertion weakening. -- [ ] T043 [P] [PR97] [US2] Add `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityLifecycleTest.java` for {EXTENSION, REHASH} × {explicit legacy cap-notify, implicit >=302, legacy no-notify} × {multi-prefix, message-tags}; assert exact LS/DEL/NEW/LIST, commands whose runtime/session view is captured after publication, named/unnamed/never-held observers, re-enable without restoring negotiations, no-op/invalid reload and expected #97 baseline failures. -- [ ] T044 [P] [PR97] [US2] Add `jircd-core/src/test/java/net/jircd/core/capability/CapabilityLifecycleNegotiationTest.java` and protocol CapabilityNegotiationGrammarTest for held-but-unoffered/already-pruned/unknown removals, mixed/repeated/empty/oversize request atomicity/exact echo, sticky version/malformed/overflow handling, explicit/implicit LIST and legacy-vs-302 cap-notify removal. -- [ ] T045 [PR97] [US2] Add atomic snapshot/prune/CAP/session-insertion tests in `jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRuntimeTest.java` and `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupLifecycleTest.java`; prove old captured work may complete after DEL, subsequent captures use new state, admitted groups keep close, unadmitted groups choose plain after prerequisites go ineffective, overflow is recipient-local and stop runs outside locks. Do not assert suppression of old captured output. +- [x] T042 [P] [PR97] [US2] Update exact-LS expectations in `jircd-integration-tests/src/test/java/net/jircd/integration/Story2CapabilityListTest.java` and other affected CAP LS tests, and extend `CapabilityProviderAbsenceTest.java` to retain advertised core cap-notify with any/all providers missing; add normal offered positive-REQ coverage without unrelated assertion weakening. +- [x] T043 [P] [PR97] [US2] Add `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityLifecycleTest.java` for {EXTENSION, REHASH} × {explicit legacy cap-notify, implicit >=302, legacy no-notify} × {multi-prefix, message-tags}; assert exact LS/DEL/NEW/LIST, commands whose runtime/session view is captured after publication, named/unnamed/never-held observers, re-enable without restoring negotiations, no-op/invalid reload and expected #97 baseline failures. +- [x] T044 [P] [PR97] [US2] Add `jircd-core/src/test/java/net/jircd/core/capability/CapabilityLifecycleNegotiationTest.java` and protocol CapabilityNegotiationGrammarTest for held-but-unoffered/already-pruned/unknown removals, mixed/repeated/empty/oversize request atomicity/exact echo, sticky version/malformed/overflow handling, explicit/implicit LIST and legacy-vs-302 cap-notify removal. +- [x] T045 [PR97] [US2] Add atomic snapshot/prune/CAP/session-insertion tests in `jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRuntimeTest.java` and `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupLifecycleTest.java`; prove old captured work may complete after DEL, subsequent captures use new state, admitted groups keep close, unadmitted groups choose plain after prerequisites go ineffective, overflow is recipient-local and stop runs outside locks. Do not assert suppression of old captured output. ### State, protocol and administrative path -- [ ] T046 [PR97] [US2] Extend `jircd-protocol/src/main/java/net/jircd/protocol/CapabilityNegotiationGrammar.java` and core `capability/CapabilityNegotiator.java`/`session/command/CapCommandHandler.java` with advertised always-offered core cap-notify, overflow-safe sticky LS-version tracking, explicit-vs-implicit LIST and bounded deterministic NEW/DEL/LS/LIST packing including mandatory core-offer byte budget. -- [ ] T047 [PR97] [US2] Implement new immutable RuntimeSnapshot publication and physical all-session prune in one step under the existing administrative control mutex through `jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRuntime.java`, ExtensionRegistry, ClientSession and ConnectionHandler.liveSessions; capture state/session views and register/remove/attach sessions under the same mutex, queue DEL/NEW after publication, keep old captured work valid and stop/cleanup outside locks; migrate direct-mutating negotiation unit fixtures under `jircd-core/src/test/java/` to the coordinator/test helper in this PR, without unrelated integration edits. Add only the generic group current-prerequisite choice + full append under administrative→one writer lock order, without plugin calls there. -- [ ] T048 [PR97] [US2] Replace separate validation/apply use in `jircd-core/src/main/java/net/jircd/core/capability/CapabilityNegotiator.java`/`session/command/CapCommandHandler.java` with one administrative-mutex candidate-set/reply-size/mutation operation; accept held or known-pruned negative tokens, ordinary offered positives including cap-notify, and preserve zero mutation on NAK/417 with exact request echo. -- [ ] T049 [PR97] [US2] Publish validated candidate capability config/dependency/budget changes coherently through `jircd-core/src/main/java/net/jircd/core/config/ConfigurationReloader.java`; hard dependent withdrawal shares publication, batch uses session-only fallback, invalid config changes nothing, valid provider start failures stay isolated, and enable never restores old negotiations; ensure start/stop callbacks are outside core locks. -- [ ] T050 [PR97] [US2] Route `jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/ExtensionCommandHandler.java`/`RehashCommandHandler.java` through that lifecycle with existing privilege/file-independence/no-op behavior and actual outcome reporting; preserve SIGHUP/REHASH path equivalence and notify ordering by administrative transition, without ordering all captured output against DEL. -- [ ] T051 [PR97] Update runtime/CAP/path-equivalence text in `specs/001-ircv3-server/contracts/server-configuration.md`, `contracts/irc-protocol-commands.md` and `data-model.md` plus `README.md`/public Javadoc for snapshot capture, allowed old output after DEL, immutable negotiation, core cap-notify in LS, negative REQ, enable behavior and provider-safe stop overlap; reserve full hook-fault/diagnostic completion for #102. -- [ ] T052 [PR97] [US2] Run socket matrix/negotiation/group tests and quickstart's admin walkthrough, applicable existing loads and repeatable 1,000-session publication timings from the socket fixture; record exact frames/state/fixture diff, <=100 ms p99 wait+publish/prune and separate notification/callback/drain measurements in `specs/014-capability-plugin-spi/validation/pr-97/results.md`; final dedicated SC-005 load proof belongs to #102. -- [ ] T053 [PR97] Resolve `.github/REVIEW_CHECKLIST.md`/other-vendor/SonarCloud review findings as available and verify only planned exact-LS/provider-absence integration edits in `specs/014-capability-plugin-spi/validation/pr-97/review.md`; no strict-cutoff tests, lifecycle admission barrier or callback lifetime tracking may be introduced. -- [ ] T054 [PR97] Finish #97 with `./gradlew spotlessApply` then `./gradlew build` green, including static/coverage/no-decrease gates; record reports in `specs/014-capability-plugin-spi/validation/pr-97/results.md`. Checkpoint: both admin paths, atomic snapshot/prune, DEL/NEW, negative REQ, core advertised cap-notify and retained batch rules pass. +- [x] T046 [PR97] [US2] Extend `jircd-protocol/src/main/java/net/jircd/protocol/CapabilityNegotiationGrammar.java` and core `capability/CapabilityNegotiator.java`/`session/command/CapCommandHandler.java` with advertised always-offered core cap-notify, overflow-safe sticky LS-version tracking, explicit-vs-implicit LIST and bounded deterministic NEW/DEL/LS/LIST packing including mandatory core-offer byte budget. +- [x] T047 [PR97] [US2] Implement new immutable RuntimeSnapshot publication and physical all-session prune in one step under the existing administrative control mutex through `jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRuntime.java`, ExtensionRegistry, ClientSession and ConnectionHandler.liveSessions; capture state/session views and register/remove/attach sessions under the same mutex, queue DEL/NEW after publication, keep old captured work valid and stop/cleanup outside locks; migrate direct-mutating negotiation unit fixtures under `jircd-core/src/test/java/` to the coordinator/test helper in this PR, without unrelated integration edits. Add only the generic group current-prerequisite choice + full append under administrative→one writer lock order, without plugin calls there. +- [x] T048 [PR97] [US2] Replace separate validation/apply use in `jircd-core/src/main/java/net/jircd/core/capability/CapabilityNegotiator.java`/`session/command/CapCommandHandler.java` with one administrative-mutex candidate-set/reply-size/mutation operation; accept held or known-pruned negative tokens, ordinary offered positives including cap-notify, and preserve zero mutation on NAK/417 with exact request echo. +- [x] T049 [PR97] [US2] Publish validated candidate capability config/dependency/budget changes coherently through `jircd-core/src/main/java/net/jircd/core/config/ConfigurationReloader.java`; hard dependent withdrawal shares publication, batch uses session-only fallback, invalid config changes nothing, valid provider start failures stay isolated, and enable never restores old negotiations; ensure start/stop callbacks are outside core locks. +- [x] T050 [PR97] [US2] Route `jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/ExtensionCommandHandler.java`/`RehashCommandHandler.java` through that lifecycle with existing privilege/file-independence/no-op behavior and actual outcome reporting; preserve SIGHUP/REHASH path equivalence and notify ordering by administrative transition, without ordering all captured output against DEL. +- [x] T051 [PR97] Update runtime/CAP/path-equivalence text in `specs/001-ircv3-server/contracts/server-configuration.md`, `contracts/irc-protocol-commands.md` and `data-model.md` plus `README.md`/public Javadoc for snapshot capture, allowed old output after DEL, immutable negotiation, core cap-notify in LS, negative REQ, enable behavior and provider-safe stop overlap; reserve full hook-fault/diagnostic completion for #102. +- [x] T052 [PR97] [US2] Run socket matrix/negotiation/group tests and quickstart's admin walkthrough, applicable existing loads and repeatable 1,000-session publication timings from the socket fixture; record exact frames/state/fixture diff, <=100 ms p99 wait+publish/prune and separate notification/callback/drain measurements in `specs/014-capability-plugin-spi/validation/pr-97/results.md`; final dedicated SC-005 load proof belongs to #102. +- [x] T053 [PR97] Resolve `.github/REVIEW_CHECKLIST.md`/other-vendor/SonarCloud review findings as available and verify only planned exact-LS/provider-absence integration edits in `specs/014-capability-plugin-spi/validation/pr-97/review.md`; no strict-cutoff tests, lifecycle admission barrier or callback lifetime tracking may be introduced. +- [x] T054 [PR97] Finish #97 with `./gradlew spotlessApply` then `./gradlew build` green, including static/coverage/no-decrease gates; record reports in `specs/014-capability-plugin-spi/validation/pr-97/results.md`. Checkpoint: both admin paths, atomic snapshot/prune, DEL/NEW, negative REQ, core advertised cap-notify and retained batch rules pass. ## PR 4 — #102: FAILED withdrawal + bounded diagnostics + load proof (T055–T066) diff --git a/specs/014-capability-plugin-spi/validation/pr-97/results.md b/specs/014-capability-plugin-spi/validation/pr-97/results.md new file mode 100644 index 0000000..ab86de3 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-97/results.md @@ -0,0 +1,115 @@ +# PR 3 (#97) results (T052, T054) + +Recorded on the uncommitted working tree of branch `014-capability-lifecycle` (base `193752d`, main after #106), JDK Temurin 25.0.4+7, Gradle 9.8.1, Apple M4 Pro. The review self-check is in [review.md](review.md). + +## Design decision taken during implementation + +The contracts call for "the existing administrative control mutex" to cover snapshot publication, the all-session prune, CAP state and `ConnectionHandler.liveSessions`. No such mutex existed: `ExtensionRegistry.lifecycle` serializes enable/disable but is held across provider `start`/`register`/`stop`, `ConfigurationReloader` holds its monitor across file I/O, and `liveSessions` and CAP REQ took no lock at all. I stopped and asked, and the user chose a two-level design: + +- `ExtensionRegistry.lifecycle` stays the outer serializer of whole transitions. Provider callbacks run under it and nothing else. +- `CapabilityRuntime` owns a short inner monitor, the administrative mutex. It is taken for publish + prune + DEL/NEW enqueue, CAP LS/LIST/REQ, `liveSessions` add/remove/writer attach, the coherent capture of snapshot + negotiation, and a group's admission choice. +- Lock order: lifecycle lock, then the administrative mutex, then at most one session writer's queue lock. No provider callback, socket I/O or channel monitor is held under the mutex. + +Code outside core gets `CapabilityControl` (the mutex without publication methods), so `ExtensionRegistry` still exposes no `CapabilityRuntime` (an existing test guards this). + +## Tests first: baseline failures before the change (T042–T045) + +The new and updated integration tests compile against the old API. They ran on the unchanged tree before any production edit: `20 tests completed, 11 failed`. + +| Test | Baseline result | Why (#97) | +| --- | --- | --- | +| `Story2CapabilityListTest` (exact LS) | fail | no `cap-notify` in LS | +| `CapabilityProviderAbsenceTest` full catalog, each-missing, all-missing | 3 fail | no `cap-notify`; all-missing LS was empty | +| `CapabilityLifecycleTest` matrix {EXTENSION, REHASH} × {multi-prefix, message-tags} | 4 fail | LS lacked `cap-notify` for the explicit legacy client | +| `CapabilityLifecycleTest.aRepeatedToggleSendsNoDuplicateNotification` | fail | no `DEL` at all | +| `CapabilityLifecycleTest.anInvalidReloadChangesNoStateAndSendsNothing` | fail | (assertion on single-token LIST format, since corrected; see below) | +| `CapabilityGroupLifecycleTest.afterMessageTagsIsDisabledANewGroupIsPlain...` | fail | still framed after disable: message-tags never pruned | +| the other group / absence tests | pass | retained behavior | + +The core and protocol tests (`CapabilityLifecycleNegotiationTest`, `CapabilityRuntimeTest`, `CapabilityNegotiationGrammarTest`) use new API (`notificationEligible`, `CapabilityControl`, `lsReplies`, …) and could not compile before the change, so their "before" is a compile failure, not a red run. + +The serializer omits the trailing `:` on a one-token list (`CAP * LIST multi-prefix`). That is existing wire behavior and was left alone; my new tests' single-token expectations were corrected to it. + +## Socket matrix, negotiation and group tests (T052) + +All pass in the final build: + +| Suite | Tests | Covers | +| --- | --- | --- | +| `CapabilityLifecycleTest` | 6 | {EXTENSION, REHASH} × {multi-prefix, message-tags}, each run with an explicit legacy cap-notify client, an implicit `CAP LS 302` client, a legacy no-notify client and an unnamed (`*`) never-held observer: exact DEL/NEW lines and targets, none for legacy, LS keeps `cap-notify` and drops the capability, LIST pruned, NAMES/msgid behavior off for commands sent after the change, `-name` on the pruned name ACKs, positive REQ NAKs while disabled, NEW restores nothing, behavior returns only after a new REQ. Repeated toggles through both paths send no duplicate; an invalid REHASH changes nothing and sends nothing | +| `CapabilityGroupLifecycleTest` | 3 | an admitted group keeps its close across a disable; after message-tags is disabled a new group is plain while `batch` stays in LIST; re-enable restores nothing until re-request; an overflowing recipient is closed alone | +| `CapabilityProviderAbsenceTest` | 10 | as before, plus `cap-notify` retained with any provider missing and with all six missing, where `CAP REQ :cap-notify` ACKs and LIST shows it | +| `CapabilityLifecycleNegotiationTest` (core) | 20 | LS with no providers; sorted offers; explicit vs implicit cap-notify in LIST; `-cap-notify` NAK after 302 (also when explicitly held), ACK for legacy; sticky version across lower/omitted/0/malformed/negative; huge version saturates; held-but-unoffered removal; pruned known removal; unknown and bare `-` NAK; unavailable addition atomic NAK; repeated/conflicting tokens in order with exact echo; idempotent re-request; empty NAK; oversize 417 without mutation; DEL targets (`*`/nick) to explicit, implicit and holder, none to legacy; NEW without restoration; no notification for no-op toggles or session-only REQ | +| `CapabilityRuntimeTest` (core) | 11 | every capture under the mutex sees old state or new snapshot with all 200 sessions pruned; 200 REQ-vs-disable races never leave the name negotiated; publication blocks while CAP/session tracking holds the mutex (bounded); an old captured view still frames after DEL while the next capture is current; a group still preparing when `tags` is disabled is admitted plain (latch-held hook); an admitted group on a stalled socket keeps its close; overflow while queuing DEL closes only that recipient; a notification that cannot be built for one recipient does not stop the others; hard dependents withdrawn in the same publication and not restored by re-enabling the prerequisite; missing prerequisite rejected; start/stop see the mutex free | +| `CapabilityNegotiationGrammarTest` (protocol) | 11 | version parsing and saturation; NEW/DEL dedup, sort, token-boundary split; LS/LIST 302 continuation; legacy one-line rejection | +| `ConfigurationReloaderLifecycleTest` (core) | 5 | prerequisites enabled before dependents; dependency-invalid reload changes nothing; omitted-but-enabled prerequisite counts; start failure reported, rest applied; reload disable cascades | +| `LifecycleOutcomeReportingTest` (admin) | 2 | REHASH reports a provider that failed to start; EXTENSION reports the state actually left (`failed` after a no-op disable of a FAILED provider, where the old fixed text said "disabled") | + +**Quickstart admin walkthrough.** Its steps (administrator plus the three client classes; disable and enable through EXTENSION and through test-owned YAML + REHASH; exact DEL/NEW/LS/LIST; pruned removal; concurrent CAP and session insertion against one prune; old captured output after DEL) are what `CapabilityLifecycleTest` and `CapabilityRuntimeTest` drive over the real `JircdServerApplication` and real sockets. I did not run a separate hand-driven session against a started binary: that would need a local config with credentials, and `jircd-server/jircd.yaml` is off limits. + +## Publication timing at 1,000 live sessions (T052) + +`CapabilityPublicationTimingLoadTest` (tag `load`, run by `./gradlew loadTest`) opens 1,000 real socket sessions that all hold `multi-prefix`, then does 5 warm-up and 40 measured disable/enable cycles, re-requesting on every client between cycles so every disable prunes all 1,000. Pass 1 has no notification-eligible client (wait + publish + prune). Pass 2 sends `CAP LS 302` from every client first (plus DEL/NEW enqueue to 1,000 writers). Each figure is the whole `ExtensionRegistry.disable`/`enable` call, which also includes the provider's own stop/start and command removal, so it bounds the publication step from above. With 40 samples, p99 equals the maximum. + +| Run | no-notify disable p50 / p99 (ms) | no-notify enable p50 / p99 | notify-302 disable p50 / p99 | notify-302 enable p50 / p99 | +| --- | --- | --- | --- | --- | +| 1 | 0.306 / 0.526 | 0.150 / 0.305 | 1.104 / 2.682 | 0.986 / 3.452 | +| 2 | 0.328 / 0.567 | 0.152 / 0.340 | 1.170 / 2.766 | 1.058 / 2.829 | +| 3 | 0.336 / 0.617 | 0.159 / 1.001 | 1.108 / 1.972 | 1.035 / 3.542 | +| loadTest 1 | 0.414 / 0.688 | 0.172 / 0.403 | 1.101 / 2.407 | 0.935 / 2.246 | +| loadTest 2 | 0.304 / 1.114 | 0.146 / 0.979 | 1.075 / 2.843 | 0.998 / 2.508 | + +Budget: administrative-mutex wait + snapshot publication/all-session prune p99 ≤ 100 ms. Measured upper bound: ≤ 1.2 ms. Notification enqueue to 1,000 eligible writers adds roughly 0.8 ms at p50. Socket drain is not part of any figure: nothing waits for it. The test asserts the no-notify disable p99 ≤ 100 ms. The dedicated final SC-005 load classes and JFR comparison are #102's. + +The first version of this test timed out at cycle 10. An in-process virtual-thread dump showed no blocked thread. The per-connection rate limit (20-token bucket) silently drops over-rate commands, so the barrier PING was never answered. The fixture now raises `rateLimit` for this test only. Not a server defect. + +## Applicable existing loads + +Tag preparation now captures snapshot + negotiation under the mutex once per recipient, so the delivery loads apply. `./gradlew loadTest` ran twice on this tree; all 9 load tests passed both times. The comparison is with the #101 tree, which is what main `193752d` merged ([pr-101/results.md](../pr-101/results.md)). Per the instruction to stay in this worktree, I did not run a fresh main baseline. + +| Load test | #101 tree (run 1 / run 2) | this tree (run 1 / run 2) | +| --- | --- | --- | +| ConcurrentConnectionScale p50 / p99 (ms) | 0.902 / 1.641; 1.053 / 1.499 | 0.890 / 1.694; 0.839 / 1.614 | +| ExceptionModes join; message p50 (ms) | 2.247, 0.251 / 0.682, 0.260 | 0.451, 0.124 / 0.479, 0.151 | +| JoinStorm (2000) storm ms; quiet p50 / p99 (ms) | 2208; 26.5 / 156.5 · 2244; 31.5 / 171.6 | 2481; 34.1 / 220.6 · 2328; 30.1 / 63.4 | +| SingleWaveJoinStorm ms; overflow disconnects | 1956; 0 · 1942; 0 | 1968; 0 · 1923; 0 | +| Story1DeliveryLatency (50) p50 / p99 (ms) | 0.554 / 2.300; 0.578 / 2.397 | 0.559 / 0.912; 0.603 / 1.205 | + +**Reading:** no regression visible at this resolution. Every figure is within run-to-run noise of #101 and far inside its budget (delivery < 1 s, JOIN-storm quiet p99 < 500 ms, storm < 15 s). The JoinStorm quiet p99 is a maximum over about 20 samples, and its two runs here (220.6 and 63.4 ms) bracket #101's. This is not the controlled ≥5-run comparison with CPU/allocation profiles, which belongs to #102. + +## Final build (T054) + +| Command | Outcome | +| --- | --- | +| `./gradlew spotlessApply` | applied, then clean | +| `./gradlew build --rerun-tasks` | **BUILD SUCCESSFUL** (1 m 32 s, after review round 4): 1340 tests in the module XML reports, 0 failed, 0 skipped; Spotless, SpotBugs (main and test), PMD (main and test) and `testCodeCoverageVerification` all green | + +Earlier builds that failed were fixed, not retried: PMD `CloseResource` on a session writer read for one append in `CapabilityRuntime` and on test writers or clients closed elsewhere (suppressed with the reason at each declaration); SpotBugs `USO_UNSAFE_OBJECT_SYNCHRONIZATION` on a test's `synchronized (ByteArrayOutputStream)` (dropped, since the stream synchronizes itself). + +| Module | Tests | +| --- | --- | +| jircd-protocol | 69 | +| jircd-core | 873 | +| jircd-capabilities: away-notify / batch / echo-message / message-tags / multi-prefix / server-time | 7 / 11 / 13 / 13 / 5 / 6 | +| jircd-server-extensions:admin | 22 | +| jircd-integration-tests | 321 | + +### Coverage (aggregate over every module with `src/main/java`) + +Report: `build/reports/jacoco/testCodeCoverageReport/`. + +| | Covered | Floor | After #101 | Result | +| --- | --- | --- | --- | --- | +| Line | **96.03%** (6165 of 6420) | 85% | 95.72% | pass, higher | +| Branch | **89.11%** (2520 of 2828) | 75% | 88.70% | pass, higher | + +An intermediate build measured 95.69% lines, below #101's figure. The new uncovered lines were the REHASH failure report and the per-recipient notification guard. Tests were added for both (`LifecycleOutcomeReportingTest`, `CapabilityRuntimeTest`), and `RuntimeSnapshot.without`, which no production code used any more, was removed. + +## Checkpoint + +Both admin paths, atomic snapshot/prune, DEL/NEW to exactly the eligible clients, negative REQ, core advertised cap-notify and the retained batch rules (admitted groups close, unadmitted groups plain once a prerequisite is ineffective, batch stays negotiated) pass. + +Not done here: +- SonarCloud branch analysis and the other-vendor review: see [review.md](review.md). +- irctest: not run, since it runs only on request. +- Commit: the work is uncommitted, as instructed. diff --git a/specs/014-capability-plugin-spi/validation/pr-97/review.md b/specs/014-capability-plugin-spi/validation/pr-97/review.md new file mode 100644 index 0000000..0e3fe19 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-97/review.md @@ -0,0 +1,184 @@ +# PR 3 (#97) review (T053) + +Self-check of the uncommitted diff on `014-capability-lifecycle` (base `193752d`) against [`.github/REVIEW_CHECKLIST.md`](../../../../.github/REVIEW_CHECKLIST.md) and the #97 contracts. Results are in [results.md](results.md). + +## Not run here + +- **SonarCloud branch analysis and Codex review:** run by the coordinator after the first pass; their findings and fixes are in "Review round 1" below. +- **irctest:** not run, since it runs only on request. The known tradeoff to record when it is run: output captured before a disable may follow the `DEL`. Both contracts accept this and promise no output cutoff. + +## Integration-test edits are limited to the planned ones + +Existing integration files changed: + +| File | Change | +| --- | --- | +| `Story2CapabilityListTest` | exact LS set gains `cap-notify` (test renamed to say so) | +| `CapabilityProviderAbsenceTest` | the service-file filter hides a set of classes instead of one (to test "all six absent"); expected LS sets gain `cap-notify`; the LS parser accepts a one-token list without `:`; new all-providers-absent test; each missing-provider case also checks `CAP REQ :cap-notify` ACKs | + +No other existing integration assertion changed. New files: `CapabilityLifecycleTest`, `CapabilityGroupLifecycleTest`, `CapabilityPublicationTimingLoadTest` (tag `load`). + +Core unit fixtures that mutated `negotiatedCapabilities()` in place (5 files) now go through `TestNegotiation` (T047). Two existing core tests encoded the old contract and were updated: +- `ExtensionRegistryCapabilityTest.disablingAProviderThatAnotherRequires…` now expects the dependent to be withdrawn with its prerequisite, not the disable refused. +- `CapabilityNegotiatorTest` expects core `cap-notify` among the offers and uses `candidate` instead of the removed `isAcceptable`. + +## Out-of-scope mechanisms + +- No strict output-cutoff test: nothing asserts that output captured before a disable is suppressed. +- No lifecycle admission barrier: ordinary output appends without rechecking state; only group admission makes the current prerequisite choice. +- No callback lifetime tracking: stop does not wait for callbacks. + +## Checklist + +| Item | Finding / resolution | +| --- | --- | +| S3077 volatile non-thread-safe types | First draft had `volatile Set`/`Collection`/`Supplier` fields in `ClientSession` and `CapabilityRuntime`. Now `AtomicReference`; the CAP version is an `AtomicInteger` updated with `accumulateAndGet(max)` | +| S2445 synchronize on private final | The administrative mutex is a `private final Object`. A test's `synchronized` on a stream was removed (SpotBugs USO) | +| S1181 / extension boundary | Provider code under the mutex: `providedCapability()` was first called under it to classify "known" names. It is now read once at `register` (`knownCapabilityNames`), outside every lock. Group admission calls no provider; `prepareGroup` runs the shaper before the mutex | +| S3776 complexity, S3358 nested ternary | Helpers extracted (`chunks`, `line`, `prerequisitesFirst`/`place`, `requirePrerequisitesEnabled`); no nested ternaries | +| S5841 / S5853 tests | `isNotEmpty()` before `noneMatch`; the race test asserts `isEmpty()` rather than `doesNotContain` | +| S2925 no sleep | No `Thread.sleep`; latches and bounded `get`/`join` throughout | +| Failure and cleanup paths | Publication swaps, prunes and then notifies. One recipient whose notification cannot be built (an over-long target) is logged and skipped; the state stays published (tested). Overflow while queuing `DEL` hands that recipient to its existing cleanup thread and does not block (tested). Rolling back a failed enable withdraws nothing it did not publish. Dependents withdrawn by a prerequisite's disable are stopped and their handlers removed; each records its own outcome | +| Concurrency and reload | One mutex covers publish + prune and CAP read-validate-commit-reply, so a REQ racing a disable cannot resurrect a pruned name (200-iteration race test). Live-session add/remove and writer attach take the same mutex. Lock order is lifecycle → mutex → one writer lock; no delivery path takes the mutex under a channel monitor (`Channel.underMonitor` is used only by registry membership transitions). REHASH validates the whole file and the whole candidate provider graph before changing anything, then holds the lifecycle lock for the whole reconciliation: starts (prerequisites first, outside the mutex), one publication with the new limits and configuration, then stops (dependents first). A nickname change and its own `NICK` echo take the mutex too (round 1, A4) | +| Protocol size limits | NEW/DEL split on name boundaries into independently valid lines ≤ 512 bytes. LS/LIST use 302 continuation when needed. Enable rejects an offer set that cannot fit one legacy LS line at the configured nickname maximum. A later longer nickname limit would make LS/LIST fall back to continuation lines rather than throw or truncate (defensive; see open points). ACK/NAK are never split, and an oversize echo is still `417` with no mutation | +| Hostile / oversized input | LS version parsing is linear and saturating, with no allocation that grows with the input; a 23-digit version is tested. Oversize REQ echo gets 417 without mutation | +| Validation edge cases | Empty REQ, bare `-`, unknown `-name`, `-cap-notify` after 302, malformed/negative/empty/zero/huge versions, an all-providers-absent catalog, a reload whose dependent's prerequisite is disabled or omitted: all tested | +| Rejected requests change nothing | NAK and 417 paths commit nothing (unit and socket tests check LIST afterwards); invalid REHASH changes no state and sends no DEL (socket test) | +| Counters and accounting | DEL/NEW use the writer's ordinary byte accounting; no new counters | +| Security | Notifications carry names only. REHASH failure notices carry the provider id and the exception message, as `EXTENSION` already did for its own failures | +| Determinism | LS offers, LIST and NEW/DEL are sorted; no locale- or time-dependent output (state names use `Locale.ROOT`) | +| Public API compatibility | Unreleased `0.1.0-SNAPSHOT`. The README migration table lists: `negotiatedCapabilities()` is an immutable snapshot; `CapabilityNegotiator.isAcceptable`/`apply` → `candidate`/`request`; `CapabilityNegotiationGrammar.ls`/`list` → `lsReplies`/`listReplies` (+ `notifications`); `CapabilityRuntime.withdraw` returns the withdrawn ids; disabling a prerequisite now withdraws its dependents. `RuntimeSnapshot.without` was removed once no production code used it. Also new: `ExtensionRegistry.capabilityControl`, `attachLiveSessions`, `knownCapabilityNames`, `applyDesiredState` with `DesiredState` and `InvalidDesiredStateException`; `RuntimeSnapshot.withdrawalOrder`, `descriptors`; `CapabilityRuntime.publish(change, alongside)`; `ConfigurationReloader.reloadWithOutcome`; `TagRenderer.prepareGroup`/`admitGroup` (defaults keep custom renderers working) | +| Tests that pass while the code is wrong | The new integration tests failed on the unchanged tree (results.md). Assertions are exact wire lines, final negotiated sets and states. The overlap tests prove the overlap: the unadmitted-group test holds the hook open on a latch while the disable runs; the mutex test shows the disable has not completed while the mutex is held | +| Test reliability | Every wait is bounded; writers and clients are closed in `try`/`finally`; the load test is tagged `load` and excluded from `test` | +| Docs and contracts | Updated `specs/001-ircv3-server/contracts/server-configuration.md` (runtime capability changes, amending path equivalence), `irc-protocol-commands.md` (LS/LIST/REQ/NEW/DEL rows, allowed LS names, runtime note), `data-model.md` (negotiation fields, capability lifecycle) and README (runtime section, status, migration rows). Javadoc on `CapabilityRuntime`, `CapabilityControl`, `PreparedGroup`, `ClientSession`, `CapabilityNegotiator`, `CapCommandHandler`, `ConfigurationReloader`, `ConnectionHandler.liveSessions`, `TagRenderer`. Runtime hook-fault withdrawal is left to #102, as the contracts say | + +## Open points for the reviewer + +1. **Mutex amendment.** "The existing administrative control mutex" in spec/plan/contracts is implemented as the two-level design the user chose (results.md). The canonical contracts I updated describe it as "the capability runtime's administrative mutex". The 014 design docs still say "existing"; I did not edit those approved design docs. +2. **Capture per recipient.** Ordinary tag preparation takes the mutex for each recipient to capture snapshot + negotiation together (FR-005). The existing delivery loads show no change at this resolution; #102's controlled measurement should profile it. +3. **Dependents' state after a prerequisite's administrative disable.** They end `DISABLED` (they stopped cleanly). Start failure still ends `FAILED`. The contracts define FAILED for start failure but name no state for an administrative cascade; tell me if `FAILED` is wanted. +4. **Missing hard prerequisite on enable.** Rejected with an actionable error and recorded `FAILED`, matching every other rejected enable in the existing tests, rather than left `DISABLED`. A REHASH with such a configuration is now rejected as invalid before anything changes. +5. **Legacy LS after a later nickname-limit increase.** Enable validates the one-line LS at the current nickname maximum, but a REHASH raising `nicknameMaxLength` is not re-checked against it. In that case the handler sends continuation lines rather than throwing. With today's seven offers (about 80 bytes) this cannot happen: the reply budgets every reload already enforces (WHO's `2S + 2N + … ≤ 512`) keep server name plus nickname far below what would push an LS line past 512 bytes. + +## Review round 1 + +Each item was checked against the code before any change. All were real. The behavior bugs got failing tests first; every one of these tests failed on the round-0 code and passes now. + +### Codex (cross-vendor) + +| # | Finding | Verified | Change | +| --- | --- | --- | --- | +| A1 | Reload prevalidation checked only desired prerequisite booleans, not the graph: an A↔B cycle passed, an unrelated disable applied, the enables failed, `current` became the invalid config | Real: `requirePrerequisitesEnabled` only looked at booleans | `ExtensionRegistry.applyDesiredState` validates the whole candidate graph before changing anything. The candidate is the published providers, minus what the reload withdraws (including cascaded dependents), plus every provider it enables, staged. `RuntimeSnapshot.of` checks it (unknown or disabled prerequisite, cycle, duplicate offer, owned key, slot, command), and the reply budgets are checked at the new lengths. A provider desired enabled but pulled out by a disable cascade is also invalid. Invalid → `ConfigurationException`, nothing changed. A provider whose own `register` throws is a provider fault, not an invalid file: it and its staged dependents end FAILED alone. Tests: `aDependencyCycleIsInvalidAndAnUnrelatedDisableIsNotApplied`, `disablingAPrerequisiteOfADependentDesiredEnabledIsInvalid`, `aProviderWhoseStagingFailsIsIsolatedWithItsDependents` | +| A2 | Reconciliation published each toggle separately and released the locks in between; limits and config were published afterwards; `EXTENSION` could interleave | Real: `reconcileSection` called `enable`/`disable` per id, and `updateConfiguredLengths` ran after | Reload now runs inside one lifecycle-lock hold. Starts run first, prerequisites first, outside the runtime mutex. Then one `CapabilityRuntime.publish(change, alongside)` swaps a snapshot carrying every capability change and the new reply limits, and, in the same critical section, the advertised lengths and `ConfigurationReloader.current`. That gives one prune and one `DEL`/`NEW` per reload. Withdrawn providers stop after the publication, outside the mutex. Server extensions are reconciled one by one inside the same hold. Tests: `aValidReloadIsOnePublicationSentAsOneDel` (one `CAP * DEL :base other`; round 0 sent two lines), `theWholeReloadHoldsTheLifecycleSoNothingIsPublishedUntilEveryStartIsDone` (mid-start the disable is not yet published, and a concurrent `EXTENSION` waits). The publish-failure fallback that would have published a partial batch was removed: the graph is validated under the same lock hold, so refusal is impossible, and if it happened anyway every start in the batch is rolled back with nothing published | +| A3 | `withdrawalClosure` was a `TreeSet`, so cleanup stopped dependents in name order | Real: with `z→a→root`, the round-0 test stopped `a, z, root` | `RuntimeSnapshot.withdrawalOrder` returns a dependents-first order (each provider before everything it requires, deterministic). `withdraw` and the reload's stops use it. The snapshot's provider map is now sorted, so the order never depends on hashing. Test: `transitiveDependentsAreStoppedBeforeWhatTheyRequire` (`z, a, root`) | +| A4 | Notification target races a nickname change | Real: a 200-change race test caught `CAP n0 DEL` after the client had been sent `NICK n1` | Fixed, small: `NickCommandHandler` sets the nickname and queues the client's own `NICK` line in one step under the administrative mutex; peers are told outside it. Lock order is unchanged (mutex → one writer lock; NICK holds no other lock there). A publication therefore addresses the nickname the client knows at that point in its stream. Tests: `aNickChangeWaitsForAPublicationHoldingTheMutex` (deterministic, bounded) and `racingPublicationsAlwaysAddressTheNicknameTheClientCurrentlyKnows` (200 changes against 200 toggles; stable over 5 repeated runs) | + +### SonarCloud branch scan + +| Rule | Location | Change | +| --- | --- | --- | +| javabugs:S2259 | `ExtensionCommandHandler` (`reportOutcome`) | `stateOf` is null for an unknown id. The id was checked before the toggle and registrations are never removed, but the null case now reports "unknown" instead of dereferencing | +| java:S3077 | `CapabilityRuntime.snapshot` | `AtomicReference` | +| java:S9412 | `CapabilityNegotiator`, `CapCommandHandler` | `List.sort(Comparator.naturalOrder())` | +| java:S5411 | `ConfigurationReloader` (boxed `Boolean` condition) | That code moved into `ExtensionRegistry.applyDesiredState`; every boxed condition there uses `Boolean.TRUE/FALSE.equals` | +| java:S7467 | `CapabilityRuntimeTest` catches | `catch (… _)` | +| java:S1130 | `CapabilityRuntimeTest.anAdmittedGroupOnAStalledSocket…` | `throws Exception` removed. Sonar's line numbers predate the round's new test fixture: its line 318 is this method | + +### Design docs + +`plan.md` (Complexity Tracking) and `research.md` (Decision 2) carry a dated amendment. It records that "the existing administrative control mutex" is implemented as the two-level arrangement, with the outer lifecycle lock now held for a whole reload and NICK serialized under the inner mutex. The contract `specs/001-ircv3-server/contracts/server-configuration.md` was updated for the reload order and the NICK serialization. + +### Build after round 1 + +`./gradlew spotlessApply` then `./gradlew build --rerun-tasks`: exit code 0. 1333 tests, 0 failed, 0 skipped; Spotless, SpotBugs, PMD and coverage verification green. Coverage: line 95.93% (6181 of 6443), branch 88.79% (2527 of 2846), both above the #101 baseline (95.72% / 88.70%). One intermediate round-1 build measured 95.52% / 88.41%. The new batch failure paths had no tests; tests for start-failure cascade, staging failure, invalid cascade, retried cleanup (disable and enable) and an unheld id were added, and the unreachable fallback was removed, not covered. + +## Review round 2 + +The Codex reviewer confirmed the four round-1 fixes and found no lock-order or deadlock issue. It found three P2 defects in the reload failure paths. Each was verified against the code and is real. Each got a test in the new `ConfigurationReloaderFailurePathTest` that failed on the round-1 code and passes now. + +| # | Finding | Verified | Change | +| --- | --- | --- | --- | +| 1 | A pending cleanup is forgotten when a later enabling reload fails | Real: `markFailed(entry, false)` assigned `cleanupPending = stopFailed \|\| commands installed`, clearing a flag an earlier failed `stop()` had set. On round-1 code the test's later `disable` was a no-op (no `stop`, state stayed FAILED) | `markFailed` only ever adds to `cleanupPending`; the flag clears only where a cleanup actually succeeds (`finish`) or a start is published. Test `aFailedReEnableKeepsTheCleanupAnEarlierFailedStopLeftForTheNextDisable`: failed stop → reload re-enable fails at staging → `disable` runs `stop` and ends DISABLED | +| 2 | A valid command hand-over fails during a reload | Real: `requireUnownedCommands` saw the withdrawn owner's handler, since cleanup runs after publication; on round-1 code B ended FAILED with "Command TAGMSG is already handled", A was withdrawn and the command was gone | Commands the reload's withdrawn providers own are releasable. A provider the batch enables that claims one gets its guarded handler built before the publication and installed inside it (`installHandover`, in the publication's critical section), so the command moves in the same step as the snapshot swap. Validation still rejects two candidates claiming one command (the candidate graph check). The old owner's later cleanup leaves the new handler alone, because removal is conditional on the handler being the one installed. A registrar failure there is contained and logged: the provider stays published without that command. Test `aReloadMovesACommandFromAWithdrawnOwnerToANewlyEnabledOne`: no failures, B ENABLED, A DISABLED, the command runs B's handler | +| 3 | Batch rollback stops prerequisites before dependents | Real, with a precision. An ordinary exception from C's start is caught and fails C alone; the whole-batch rollback runs only when something escapes the batch (an `Error` other than `LinkageError`, or a refused publication). In that case `started` (prerequisites first) was rolled back forwards: the test saw `stop c, stop a, stop b` | The rollback iterates `started` in reverse, so each provider stops before what it requires (the order round 1 gave withdrawals). Test `aStartThatEscapesRollsTheBatchBackDependentsFirst`: `start a, start b, stop c, stop b, stop a`, nothing published, the error propagates | + +**Sonar S3776 (cognitive complexity 17 in `planCapabilities`):** extracted `classify`, `requireNoneDesiredEnabled` and `validateCandidate`. The round-2 changes would have pushed `startForBatch` over the limit too, so it was split the same way: `mayStart` for the prerequisite and pending-cleanup checks and `splitCommands` for the hand-over split. `applyCapabilities` moved its post-publication stops into `finishWithdrawn`, and its working state into a small `Batch` holder. Behavior is unchanged: the round-1 tests pass without edits. I estimated complexity by hand, since Sonar can only be rerun on the next branch scan. + +**Build after round 2:** `./gradlew spotlessApply` then `./gradlew build --rerun-tasks` and a final `./gradlew build`, both exit code 0. 1336 tests, 0 failed, 0 skipped; Spotless, SpotBugs, PMD and coverage verification green. Coverage: line 95.96% (6230 of 6492), branch 88.79% (2543 of 2864), both above #101 (95.72% / 88.70%). + +## Review round 3 + +The Codex reviewer confirmed the round-2 `cleanupPending` fix and the reverse-order rollback. It found three defects in the command hand-over's failure paths, and SonarCloud's gate failed on java:S3078. As asked, these were treated as one structural problem, not patched one at a time. Provider state was published in one step and command handlers were installed in another (core's mutable command table, after the swap). + +**Spec/plan check before implementing.** The 014 contracts require "scoped command registration" and that a provider's command is executable only while the provider is enabled (capability-spi.md: `command(command, handler)`, "Enabled provider…"; plan.md: "provider-owned staged command registration"). Nothing requires installing provider commands into core's command table. Resolving them from the published snapshot is consistent with both, so I proceeded without stopping. + +**Structure now.** A capability provider's commands are part of its published descriptor: +- **Index:** `RuntimeSnapshot` indexes them (`command(Command)`; validation already guarantees one owner). +- **Lookup:** `ExtensionRegistry.capabilityCommand(Command)` resolves a command from one read of the current snapshot and wraps it for fault containment. +- **Dispatch:** `ConnectionHandler` consults core's table first (core and server-extension commands), then that lookup. +- **One publication:** a provider and its commands therefore appear, disappear and move between providers in the same atomic swap, and dispatch sees the old set and its handlers or the new set and its handlers, never a mix. A handler resolved before a swap may still run afterwards, as captured work. +- **Nothing to fail:** there is no registration step left that can fail once anything is visible. The only command-related failure point is the provider's own staging, which runs before validation and publication and is handled as a staging failure. +- **Removed:** + - capability commands no longer go through `CommandRegistrar`: install, rollback, `installedCommands`, conditional removal on disable, the "refuse once withdrawn" guard, the hand-over sets and `installHandover` + - `HookDispatcher.guardedCommand(registration, live, refused)` + - the command-related `cleanupPending` bookkeeping + + `cleanupPending` now records only an owed `stop`. +- **Unchanged:** `CommandRegistrar` stays for server extensions (admin), whose model is unchanged, and a provider still cannot claim a command core or a server extension handles. + +| # | Finding | Verified | Resolution | +| --- | --- | --- | --- | +| 1 (P1) | An `Error` on the second hand-over registration left B published, stopped, and partly installed | Real. With round-2 sources restored, `anErrorFromCoresTableOnTheSecondMovedCommandCannotHalfEnableTheNewOwner` failed: the `Error` escaped the reload after the swap | No registration step exists after publication; the test passes (B ENABLED, both commands run B's handler) | +| 2 (P2) | A `RuntimeException` on a hand-over registration was only logged; B stayed ENABLED without the command, with no failure reported | Real. On round-2 sources `…RuntimeException…` failed with `421 client WALLOPS :Unknown command` | Same; the test passes | +| 3 (P2) | Between the swap and the handler installation, dispatch could fetch A's handler, see A gone and answer 421 | Real. `dispatchDuringRepeatedHandOversNeverAnswersUnknownCommand` (300 hand-overs against a tight real-dispatch loop through `ConnectionHandler.processLine`) failed on round-2 sources in 3 of 3 runs with repeated `421 client TAGMSG` | Lookup and handler come from one snapshot read; the test passes in 6 of 6 runs here (including 5 repeats) | +| S3078 | `markFailed` read-modify-wrote a volatile (`cleanupPending = cleanupPending \|\| …`) | Real | `markFailed` only sets the flag (`if (stopFailed) cleanupPending = true`); the installed-commands term is gone | + +The round-2 sources for the "fails on current code" runs came from the round-2 build's JaCoCo HTML, which renders each source file in full: `ExtensionRegistry`, `ConnectionHandler`, `RuntimeSnapshot` and `HookDispatcher`. They were swapped in with only the `processLine` test seam added, the five new tests ran, and the round-3 files were restored from a backup. Two of the five new tests, a `RuntimeException` and an `Error` thrown by the provider's own staging at its second command, also pass on round-2 sources. That path was not broken; they pin the new design's single failure point: the new owner ends FAILED with nothing of it visible, or, for an `Error`, nothing changes and the old owner keeps both commands. + +**Tests migrated** (they tested the removed installation mechanism): +- **`ExtensionRegistryCapabilityTest`:** nine tests of registrar install/unregister failures, leftover refusing handlers and hooks-before-commands ordering were replaced. `providerCommandsResolveFromTheSnapshotAndNeverTouchCoresTable` covers resolution while enabled, absence after disable, and an untouched core table. `aRegistrarThatCannotInstallOrRemoveHandlersCannotHalfEnableOrStrandAProvider` covers a registrar that throws on every install and removal, with no effect on enable or disable. The fault-containment test now resolves through `capabilityCommand`. +- **`TagmsgCommandHandlerTest` (message-tags):** dispatches TAGMSG through `capabilityCommand`, and asserts the provider never writes core's table. +- **`ConfigurationReloaderFailurePathTest`:** the round-2 hand-over test dispatches the same way. + +**Coverage.** One run of the first round-3 build measured branch 88.69%, 0.01 pp under #101. The previous run of identical code had given 88.76%, because some branches are reached only under particular interleavings. Deterministic tests were added for the new failure paths: +- a withdrawn provider's failing stop +- a retried cleanup failing again, on disable and on re-enable +- a start failure without a message +- a rollback whose stop fails, keeping the cleanup owed +- a dependent's failing stop on its prerequisite's disable +- `RuntimeSnapshot.effective` + +**Build after round 3:** `./gradlew spotlessApply` then `./gradlew build --rerun-tasks`, twice in a row, both exit code 0. 1339 tests, 0 failed, 0 skipped; Spotless, SpotBugs, PMD and coverage verification green. Coverage: line 96.02% (6160 of 6415), branch 89.11% then 89.08%. README (SPI table, migration rows) and Javadoc were updated for the snapshot-resolved commands. + +## Review round 4 + +The Codex reviewer confirmed that snapshot-based dispatch fixes the three round-3 findings and found nothing wrong in case folding, registration gating, rate limits, HELP/ISUPPORT or lookup after withdrawal. Two findings and one Sonar issue remained; all three were verified against the code and are real. + +| # | Finding | Verified | Change | +| --- | --- | --- | --- | +| 1 (P2) | `CommandRegistrar.isRegistered` saw only core's mutable table, so a server extension registering after a provider enabled X could claim X and, with core-first dispatch, shadow the still-advertised provider | Real: `isRegistered` returned `handlers.containsKey(command)`. New test `ConnectionHandlerCommandTableTest.aCommandAPublishedProviderOwnsIsRegisteredAndCannotBeClaimedByALaterServerExtension` failed on it (`isRegistered(TAGMSG)` was false) | The registrar's ownership now covers both sources dispatch reads. `isRegistered(X)` is true if core's table has X or the current snapshot's provider owns X. `register(X, …)` is refused with an `IllegalArgumentException` while a published provider owns X. Both key on the same `Command` that dispatch folds a line to (the test sends lowercase `tagmsg` and it reaches the provider). An extension's registrations run in its lifecycle callback, which the registry serializes with provider publication, so a provider enable cannot fall between the check and the install. Once the provider is withdrawn, X is free again (tested). The provider-side check (`requireUnownedCommands`) now counts only commands no published provider owns: provider-vs-provider ownership stays with snapshot validation, so the A→B hand-over within one reload keeps working (the round-2 and round-3 hand-over tests pass unchanged) | +| 2 (P2) | The concurrent hand-over test could pass with zero swaps: worker exceptions were dropped, `done` was set in `finally`, and the main loop was unbounded if the worker hung | Real, as described | The worker is a `CompletableFuture`; `get(30 s)` rethrows its failure or fails on a hang. Every completed swap is counted and the test asserts all 300 happened. The dispatch loop ends when the worker is done or after a 30 s deadline. The 421 and handled-count assertions are unchanged. Stable in 3 repeated runs plus the full build | +| S5778 | `CommandHandoverDispatchTest` (`assertThatThrownBy` lambda made two calls) | Real: `handOver(true)` was evaluated inside the lambda | The desired state is built before the assertion; the lambda makes one call | + +**Build after round 4:** `./gradlew spotlessApply` then `./gradlew build --rerun-tasks`: exit code 0. 1340 tests, 0 failed, 0 skipped; Spotless, SpotBugs, PMD and coverage verification green. Coverage: line 96.03% (6165 of 6420), branch 89.11% (2520 of 2828). + +## PR review (Sourcery) + +Sourcery left ten threads on #108 after the rebase onto `037f0c8`. Each was checked against the code at `6a970b7`. Seven are real and fixed test-first, one is already prevented and gets a regression test only, one (H2) is fixed defensively at the user's request, and one is declined with evidence. Every "failed before the fix" below was run against `6a970b7` with only the new test added. + +| # | Finding | Verdict | Evidence and change | +| --- | --- | --- | --- | +| H1 | `publish` swapped the snapshot before `alongside`, so an `alongside` that threw left the new snapshot published with no prune or notification, and the reload then rolled back providers that were in it | Real at the API level. Today's callback only assigns two fields, but `publish(change, alongside)` takes any `Runnable` | `alongside` now runs before the swap, and the registry's callback runs the configuration publication before the `supportedFeatures` write. From the swap on, nothing that can fail runs before the prune and the per-recipient (already guarded) notifications. `CapabilityRuntimeTest.aPublicationWhoseAlongsideStepThrowsSwapsPrunesAndNotifiesNothing` and `ConfigurationReloaderFailurePathTest.aConfigurationPublicationThatThrowsPublishesNothingAndRollsTheStartsBack` both failed before (snapshot swapped; provider `a` left in the snapshot) | +| H2 | Writer attached under control before the overflow callback is installed | Fixed defensively (no reachable failure today) | A publication queues to a session only if `notificationEligible()` (`highestCapVersion >= 302` or `cap-notify` negotiated, `ClientSession.java:230`), and both need a CAP line that is read only after the callbacks are installed, so the window could not be hit. At the user's request the order is swapped anyway: `serveSession` now installs `setOnSendqOverflow` and `setOnWriteFailure` before `session.attachWriter(writer)`, which still runs under `capabilityControl().runUnderControl`. No other behaviour changes. No test was added: the old order cannot be observed deterministically without a seam, because no publication can reach the session in that window | +| M1 | A provider and a server extension enabled in one reload can claim the same command; the extension fails after the capability publication | Declined: no pre-start data exists | Server extensions declare no commands: `ServerExtension` has no command method, and they register imperatively inside `start` (`AdminExtension.java:44`). Validating before publication would need a declarative command SPI for server extensions, which is outside #97. The outcome is already isolated and reported: `register` refuses a provider-owned command (round 4), and `reconcileServerExtensions` records the extension's failure in the outcome (REHASH notice, and with M6 the SIGHUP log). Capabilities reconciling before server extensions is main's order (`ConfigurationReloader.reconcile` on `037f0c8`) | +| M2 | A provider command that core or a server extension owns failed inside `startForBatch` before `start`, whose rollback then ran `stop`, and the conflict was an isolated failure | Real | The ownership check moved into `validateCandidate`, so the conflict makes the candidate invalid (`InvalidDesiredStateException`, REHASH "Rehash failed") before anything starts. `startForBatch` now stops a provider only after its `start` ran. Nothing else can register a command while the lifecycle lock is held for the capability batch. `aProviderCommandThatCoreOwnsRejectsTheReloadBeforeAnythingStartsOrStops` failed before (no exception; `stop a` ran without `start a`) | +| M3 | `register` inserted into `byId` before `providedCapability()`; a throw left a selectable entry and a retry skipped recording the name | Real, and new in this PR: on main `register` never called `providedCapability()` | The name is resolved first. A failure becomes an `IllegalArgumentException` naming the provider, which discovery already skips, and nothing is inserted. `aProviderWhoseCapabilityNameThrowsIsNotRegisteredAndCanRegisterOnceFixed` failed before | +| M4 | A withdrawn provider whose first `stop` failed, desired disabled, was stopped twice in one reload | Real | The pending-cleanup retry skips providers this reload already stopped; the next disable or reload retries them. `aWithdrawnProviderWhoseStopFailsIsReportedAndStaysWithdrawn` now asserts one stop and failed before (`["stop a", "stop a"]`) | +| M5 | Disabling a prerequisite whose withdrawn dependent fails to stop reported success | Real | `cleanUp` returns the provider's own failure, or otherwise the first dependent's (wrapped with both ids), with any others suppressed. `EXTENSION DISABLE` therefore reports "Failed to toggle a: 'b' failed to stop after its prerequisite 'a' was withdrawn: …" while `a` is DISABLED and `b` FAILED. Only the explicit disable path has dependents here: reloads already report each withdrawn provider separately. The renamed `aDependentWhoseStopFailsWhenItsPrerequisiteIsDisabledEndsFailedAndIsReported` failed before (no throwable) | +| M6 | SIGHUP discarded `ReloadOutcome.failures()` and logged success | Real, and a regression in this PR: main logged each reconcile failure in `ConfigurationReloader`; this PR moved reporting into the outcome and only REHASH read it | `SighupReloadHandler.reload(reloader)` uses `reloadWithOutcome()`. It logs the success line only for a clean reload and one warning per extension otherwise. `SighupReloadOutcomeTest` (integration, with `LogCapture`) failed before | +| M7 | `MessageDelivery.deliver` read the snapshot and the sender's negotiated set separately | Real: an old snapshot paired with a newly pruned set is the mix the captured-work rule excludes | `deliver` takes both from one `capture(sender)` under the mutex. The same split read was in `MemberReplies.namesTokens` and `whoStatus` (the requester's NAMES/WHO rendering), now fixed the same way. `MessageDeliveryTest.theSnapshotAndTheSendersNegotiationAreCapturedTogetherUnderTheMutex` and `MemberReplyRenderingTest.namesAndWhoCaptureTheSnapshotAndTheRequestersNegotiationTogether` hold the mutex and require the call to block on it. Both failed before (the thread ran to completion). The delivery test gates every recipient out, so only the capture can wait: its first version passed on old code because the writer's own capture blocked | +| M8 | A provider advertising `cap-notify` is not rejected | Already prevented; no code change | `CapabilityRegistrar`'s constructor rejects `cap-notify` as a capability name or provider id (`CapabilityRegistrar.java:70`, on main since #104). Every published descriptor comes from `stage`, which builds that registrar, so such a provider can never enter a snapshot, be withdrawn, prune `cap-notify` or cause a `DEL`. New regression test `aProviderClaimingCoreCapNotifyIsNeverPublishedSoItsDisableCannotWithdrawIt` covers `EXTENSION ENABLE`, a reload, and a later disable: it passed on `6a970b7` unchanged. The provider ends FAILED, nothing is offered, and a session's negotiated `cap-notify` is untouched | + +**Docs.** `server-configuration.md` ("Runtime capability changes") now lists a provider command already handled by core or a server extension as an invalid candidate that starts or stops nothing. It also says SIGHUP failures go to the server log, and that a disable reports a withdrawn dependent's failed stop. `ExtensionRegistry.applyDesiredState` and `MemberReplies` Javadoc match. + +**Build:** `./gradlew spotlessApply` then `./gradlew build`: exit code 0. 1351 tests, 0 failed, 0 skipped; Spotless, SpotBugs, PMD and coverage verification green. Coverage: line 96.04% (6184 of 6439), branch 89.09% (2532 of 2842).